diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index c5c15fe..ac156e8 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -876,6 +876,25 @@ export const en = { "mail.translatedFrom": "Translated from {language}", "mail.translatedFromMany": "Translated from {languages}", "mail.translateFailed": "Couldn't translate this message. The translation service may be busy or unavailable.", + "antispam.looksLikeSpam": "Looks like spam!", + "antispam.score": "score {score}", + "antispam.moveToSpam": "Move to Spam", + "antispam.notSpam": "Not spam", + "antispam.trustSender": "Always trust sender", + "antispam.why.bayes": "resembles mail you marked as spam", + "antispam.why.fuzzy": "matches known spam", + "antispam.why.reputation": "sender has a poor reputation", + "antispam.why.blocklist": "sending server is on a spam blocklist", + "antispam.why.badLinks": "links to known spam or phishing sites", + "antispam.why.phishing": "link text hides its real destination", + "antispam.why.authFail": "sender failed authenticity checks", + "antispam.why.forged": "sender address looks forged", + "antispam.why.replyTo": "replies would go to a different address", + "antispam.why.headers": "message is missing standard headers", + "antispam.why.hiddenText": "contains hidden text", + "antispam.why.suspiciousServer": "sending server looks suspicious", + "antispam.why.shouting": "subject written in capitals", + "antispam.why.date": "message date is implausible", } as const; export type MessageKey = keyof typeof en; diff --git a/frontend/src/i18n/pl.ts b/frontend/src/i18n/pl.ts index 92a85c1..1559580 100644 --- a/frontend/src/i18n/pl.ts +++ b/frontend/src/i18n/pl.ts @@ -902,4 +902,23 @@ export const pl: Catalog = { "mail.translatedFrom": "Przetłumaczono z języka: {language}", "mail.translatedFromMany": "Przetłumaczono z języków: {languages}", "mail.translateFailed": "Nie udało się przetłumaczyć tej wiadomości. Usługa tłumaczenia może być zajęta lub niedostępna.", + "antispam.looksLikeSpam": "Wygląda na spam!", + "antispam.score": "wynik {score}", + "antispam.moveToSpam": "Przenieś do spamu", + "antispam.notSpam": "To nie spam", + "antispam.trustSender": "Zawsze ufaj nadawcy", + "antispam.why.bayes": "przypomina wiadomości oznaczone przez Ciebie jako spam", + "antispam.why.fuzzy": "pasuje do znanego spamu", + "antispam.why.reputation": "nadawca ma złą reputację", + "antispam.why.blocklist": "serwer nadawcy jest na liście spamerów", + "antispam.why.badLinks": "zawiera linki do stron ze spamem lub phishingiem", + "antispam.why.phishing": "tekst linku ukrywa jego prawdziwy adres", + "antispam.why.authFail": "nadawca nie przeszedł weryfikacji autentyczności", + "antispam.why.forged": "adres nadawcy wygląda na podrobiony", + "antispam.why.replyTo": "odpowiedzi trafiłyby na inny adres", + "antispam.why.headers": "brakuje standardowych nagłówków", + "antispam.why.hiddenText": "zawiera ukryty tekst", + "antispam.why.suspiciousServer": "serwer nadawcy wygląda podejrzanie", + "antispam.why.shouting": "temat napisany wielkimi literami", + "antispam.why.date": "data wiadomości jest nieprawdopodobna", }; diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js index 4dc321e..b72afd6 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js @@ -6,6 +6,37 @@ // mounts on every message, and a spam warning on ordinary mail is worse than no // warning at all. +// Engine symbols mean nothing to a reader, so the banner explains only the ones +// that point AT spam, in plain words (app catalog keys antispam.why.*). Neutral +// or reassuring symbols (ARC_NA, DKIM_TRACE, R_SPF_ALLOW…) are not reasons and +// stay hidden. Ordered by how telling the reason is; first match wins per symbol. +const REASONS = [ + [/^BAYES_SPAM$/, "bayes"], + [/^FUZZY_(DENIED|PROB|BLACK)/, "fuzzy"], + [/^PHISH(ING|ED)/, "phishing"], + [/^(PH_SURBL|DBL_PHISH|URIBL_|SURBL_|DBL_|SEM_URIBL|RSPAMD_URIBL|MSBL_)/, "badLinks"], + [/^(RBL_|RECEIVED_SPAMHAUS|RCVD_IN_(?!DNSWL)|RSPAMD_EMAILBL|MAILSPIKE_BAD)/, "blocklist"], + [/^(BAD_REP_|RWL_.*_BAD|SENDER_REP_SPAM|IP_REPUTATION_SPAM)/, "reputation"], + [/^(R_SPF_(FAIL|SOFTFAIL)|R_DKIM_REJECT|DMARC_POLICY_(REJECT|QUARANTINE|SOFTFAIL)|ARC_REJECT)$/, "authFail"], + [/^(FORGED_|SPOOF_)/, "forged"], + [/^(FREEMAIL_REPLYTO_NEQ_FROM|REPLYTO_DOM_NEQ_FROM_DOM|REPLYTO_ADDR_EQ_TO_ADDR)/, "replyTo"], + [/^(ZERO_FONT|MANY_INVISIBLE_PARTS|R_WHITE_ON_WHITE|HTML_SHORT_LINK_IMG)/, "hiddenText"], + [/^HFILTER_/, "suspiciousServer"], + [/^MISSING_(DATE|MID|FROM|TO)$/, "headers"], + [/^(DATE_IN_FUTURE|DATE_IN_PAST)$/, "date"], + [/^SUBJ_ALL_CAPS$/, "shouting"], +]; + +/** Distinct plain-language reasons for a verdict's symbols, most telling first. */ +function reasons(symbols) { + const found = new Set(); + for (const s of symbols || []) { + const hit = REASONS.find(([re]) => re.test(s)); + if (hit) found.add(hit[1]); + } + return REASONS.map(([, key]) => key).filter((key) => found.has(key)); +} + function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); } @@ -14,6 +45,7 @@ export default function mount(el, ctx, props) { const messageId = props && props.messageId; if (!messageId) return; let disposed = false; + const t = ctx.t; async function act(path, body) { await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(body) }); @@ -31,15 +63,18 @@ export default function mount(el, ctx, props) { } if (disposed || !v || v.action !== "banner") return; - const why = (v.symbols || []).slice(0, 4).join(", "); + const why = reasons(v.symbols).slice(0, 3).map((key) => t(`antispam.why.${key}`)); + const meta = [t("antispam.score", { score: Number(v.score).toFixed(1) }), ...why].join(" · "); + // Inset like the rest of the message body (px-4) with air below, so the card + // does not run edge to edge into the remote-images notice under it. el.innerHTML = ` -