/**
* QReaper โ complemento de Gmail (Google Apps Script).
*
* Al abrir un correo, saca los enlaces del cuerpo y busca codigos QR en los
* adjuntos (imagenes y PDF), y pasa cada URL por la API de QReaper. Pinta un
* veredicto por URL: verde = puedes abrirlo, rojo/naranja = no lo abras.
*
* El mismo complemento funciona en Gmail web y en la app de Gmail de Android
* y iOS: los complementos de Gmail se renderizan de forma nativa en las tres.
*
* Nada de esto analiza en el propio Apps Script: todo el veredicto lo da el
* servidor de QReaper, que es quien sabe leer QR y detonar URLs en sandbox.
*/
/** Servidor de QReaper. Cambialo si tienes el tuyo montado. */
var SERVIDOR = 'https://qreaper.b-dev.es';
/** Tope de adjuntos que mandamos a analizar, para no eternizar la tarjeta. */
var MAX_ADJUNTOS = 5;
/** Tope de bytes por adjunto (el servidor rechaza por encima de 10 MB). */
var MAX_BYTES = 10 * 1024 * 1024;
/** Extensiones que el servidor sabe decodificar. */
var EXT_VALIDAS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.tif', '.pdf'];
var COLOR = {
PELIGRO: '#ff5c5c',
SOSPECHOSO: '#ffb020',
SEGURO: '#3ecf8e',
DESCONOCIDO: '#8b98a5'
};
var EMOJI = {
PELIGRO: '๐ด',
SOSPECHOSO: '๐ ',
SEGURO: '๐ข',
DESCONOCIDO: 'โช'
};
/**
* Punto de entrada: se dispara al abrir cualquier correo.
* Devuelve una tarjeta con un boton, no el analisis: asi no se manda nada a
* ningun sitio hasta que la persona lo pide explicitamente.
*/
function alAbrirCorreo(e) {
var seccion = CardService.newCardSection()
.addWidget(CardService.newTextParagraph().setText(
'Comprueba los enlaces y los codigos QR de este correo antes de ' +
'tocarlos. QReaper los abre en un entorno aislado y te dice si son ' +
'peligrosos.'))
.addWidget(CardService.newTextButton()
.setText('Analizar este correo')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setOnClickAction(CardService.newAction().setFunctionName('analizarCorreo')));
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('QReaper โ anti-quishing'))
.addSection(seccion)
.build();
}
/** Analiza el correo abierto y devuelve la tarjeta con los veredictos. */
function analizarCorreo(e) {
var mensaje;
try {
GmailApp.setCurrentMessageAccessToken(e.gmail.accessToken);
mensaje = GmailApp.getMessageById(e.gmail.messageId);
} catch (err) {
return navegarA(tarjetaError('No he podido leer el correo: ' + err));
}
var hallazgos = [];
hallazgos = hallazgos.concat(analizarEnlacesDelCuerpo(mensaje));
hallazgos = hallazgos.concat(analizarAdjuntos(mensaje));
return navegarA(tarjetaResultados(hallazgos));
}
/** URLs que aparecen en el texto del correo (deduplicadas). */
function analizarEnlacesDelCuerpo(mensaje) {
var urls = extraerUrls(mensaje.getBody() || '')
.concat(extraerUrls(mensaje.getPlainBody() || ''));
urls = unicos(urls);
if (!urls.length) return [];
var resp = peticion('/analizar/lote', { urls: urls });
if (resp.error) return [{ origen: 'enlace', error: resp.error }];
return (resp.datos.resultados || []).map(function (r) {
return { origen: 'enlace', resultado: r };
});
}
/** Busca QR dentro de los adjuntos: eso lo hace el servidor, no Apps Script. */
function analizarAdjuntos(mensaje) {
var adjuntos = mensaje.getAttachments({ includeInlineImages: true, includeAttachments: true });
var hallazgos = [];
var mirados = 0;
for (var i = 0; i < adjuntos.length && mirados < MAX_ADJUNTOS; i++) {
var a = adjuntos[i];
var nombre = nombreValido(a.getName() || '', a.getContentType() || '');
if (!nombre) continue;
if (a.getSize() > MAX_BYTES) continue;
mirados++;
var blob = a.copyBlob().setName(nombre);
var resp = subir('/analizar/archivo', blob);
if (resp.error) {
hallazgos.push({ origen: 'adjunto', nombre: a.getName(), error: resp.error });
continue;
}
var resultados = resp.datos.resultados || [];
for (var j = 0; j < resultados.length; j++) {
hallazgos.push({ origen: 'adjunto', nombre: a.getName(), resultado: resultados[j] });
}
}
return hallazgos;
}
/**
* Nombre con una extension que el servidor sepa abrir. Si el adjunto no la
* trae, la deducimos del tipo MIME; si tampoco, lo descartamos.
*/
function nombreValido(nombre, mime) {
var bajo = nombre.toLowerCase();
for (var i = 0; i < EXT_VALIDAS.length; i++) {
if (bajo.slice(-EXT_VALIDAS[i].length) === EXT_VALIDAS[i]) return nombre;
}
var porMime = {
'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
'image/bmp': '.bmp', 'image/tiff': '.tiff', 'application/pdf': '.pdf'
};
var ext = porMime[String(mime).split(';')[0].trim()];
return ext ? (nombre || 'adjunto') + ext : null;
}
/** Saca URLs http(s) de un texto o de un HTML (atributos href incluidos). */
function extraerUrls(texto) {
var encontradas = [];
var re = /https?:\/\/[^\s<>"')\]]+/gi;
var m;
while ((m = re.exec(texto)) !== null) {
encontradas.push(m[0].replace(/[.,;:]+$/, ''));
}
return encontradas;
}
function unicos(lista) {
var vistos = {};
var fuera = [];
for (var i = 0; i < lista.length; i++) {
if (!vistos[lista[i]]) { vistos[lista[i]] = true; fuera.push(lista[i]); }
}
return fuera;
}
/* --- Llamadas al servidor ------------------------------------------------ */
function peticion(ruta, cuerpo) {
try {
var r = UrlFetchApp.fetch(SERVIDOR + ruta, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(cuerpo),
muteHttpExceptions: true
});
return leerRespuesta(r);
} catch (err) {
return { error: 'No he podido contactar con ' + SERVIDOR };
}
}
function subir(ruta, blob) {
try {
var r = UrlFetchApp.fetch(SERVIDOR + ruta, {
method: 'post',
payload: { archivo: blob },
muteHttpExceptions: true
});
return leerRespuesta(r);
} catch (err) {
return { error: 'No he podido subir el adjunto a ' + SERVIDOR };
}
}
function leerRespuesta(r) {
var codigo = r.getResponseCode();
var texto = r.getContentText();
var datos;
try { datos = JSON.parse(texto); } catch (err) { datos = null; }
if (codigo < 200 || codigo >= 300) {
return { error: (datos && datos.detail) ? String(datos.detail) : ('HTTP ' + codigo) };
}
return { datos: datos || {} };
}
/* --- Tarjetas ------------------------------------------------------------ */
function tarjetaResultados(hallazgos) {
var constructor = CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('QReaper โ resultado'));
if (!hallazgos.length) {
constructor.addSection(CardService.newCardSection().addWidget(
CardService.newTextParagraph().setText(
'No he encontrado ningun enlace ni ningun QR en este correo.')));
return constructor.build();
}
var peores = resumen(hallazgos);
constructor.addSection(CardService.newCardSection().addWidget(
CardService.newDecoratedText()
.setText('' +
textoResumen(peores) + '')
.setWrapText(true)));
var seccion = CardService.newCardSection().setHeader(
hallazgos.length + (hallazgos.length === 1 ? ' enlace' : ' enlaces') + ' encontrados');
for (var i = 0; i < hallazgos.length; i++) {
seccion.addWidget(widgetHallazgo(hallazgos[i]));
}
constructor.addSection(seccion);
constructor.addSection(CardService.newCardSection().addWidget(
CardService.newTextButton()
.setText('Abrir QReaper')
.setOpenLink(CardService.newOpenLink().setUrl(SERVIDOR + '/app'))));
return constructor.build();
}
function widgetHallazgo(h) {
if (h.error) {
return CardService.newDecoratedText()
.setText('โ ๏ธ ' + escapar(h.nombre || 'enlaces del correo') + ': ' + escapar(h.error))
.setWrapText(true);
}
var r = h.resultado || {};
var s = r.scoring || {};
var veredicto = s.veredicto || r.veredicto || 'DESCONOCIDO';
var nota = (s.nota !== undefined && s.nota !== null) ? s.nota : r.nota;
var motivos = s.motivos || r.motivos || [];
var etiqueta = (EMOJI[veredicto] || '') + ' ' + veredicto +
(nota !== undefined && nota !== null ? ' ยท riesgo ' + nota + '/100' : '') +
(h.origen === 'adjunto' ? ' ยท QR de ' + escapar(h.nombre || 'un adjunto') : '');
var cuerpo = '' + etiqueta + '
' +
escapar(r.url || '');
if (motivos.length) {
cuerpo += '
' +
escapar(motivos.slice(0, 3).join(' ยท ')) + '';
}
var widget = CardService.newDecoratedText().setText(cuerpo).setWrapText(true);
// Solo ofrecemos seguir lo que ha salido limpio, y nunca saltando directos
// al destino: pasamos por QReaper, que vuelve a ensenar el veredicto y deja
// abrir con un toque. Asi el complemento no se convierte en un redirector
// ciego a cualquier dominio (y encaja con openLinkUrlPrefixes).
if (veredicto === 'SEGURO' && r.url) {
widget.setButton(CardService.newTextButton()
.setText('Ver y abrir')
.setOpenLink(CardService.newOpenLink()
.setUrl(SERVIDOR + '/app?url=' + encodeURIComponent(r.url))));
}
return widget;
}
/** El correo vale lo que valga su peor enlace. */
function resumen(hallazgos) {
var orden = ['SEGURO', 'DESCONOCIDO', 'SOSPECHOSO', 'PELIGRO'];
var peor = 'SEGURO';
var malos = 0;
for (var i = 0; i < hallazgos.length; i++) {
var r = hallazgos[i].resultado;
if (!r) continue;
var v = (r.scoring && r.scoring.veredicto) || r.veredicto || 'DESCONOCIDO';
if (orden.indexOf(v) > orden.indexOf(peor)) peor = v;
if (v === 'PELIGRO' || v === 'SOSPECHOSO') malos++;
}
return { veredicto: peor, malos: malos, total: hallazgos.length };
}
function textoResumen(p) {
if (p.veredicto === 'PELIGRO') {
return '๐ด NO toques este correo โ ' + p.malos + ' enlace(s) peligrosos.';
}
if (p.veredicto === 'SOSPECHOSO') {
return '๐ Cuidado โ ' + p.malos + ' enlace(s) sospechosos.';
}
if (p.veredicto === 'DESCONOCIDO') {
return 'โช No he podido dictaminar todos los enlaces.';
}
return '๐ข Los ' + p.total + ' enlace(s) de este correo salen limpios.';
}
function tarjetaError(mensaje) {
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle('QReaper'))
.addSection(CardService.newCardSection().addWidget(
CardService.newTextParagraph().setText(escapar(mensaje))))
.build();
}
function navegarA(tarjeta) {
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().updateCard(tarjeta))
.build();
}
/** El texto de las tarjetas admite HTML: hay que escapar lo que venga de fuera. */
function escapar(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}