/** * 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, '"'); }