Spam banner: inset card, red title, plain-language reasons, translated
The banner sits inside the message body's 16px gutter with room below, "Looks like spam!" is red, and the rspamd symbols are replaced by up to three human-readable reasons (neutral symbols are hidden). All strings go through ctx.t with English and Polish catalog entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -876,6 +876,25 @@ export const en = {
|
|||||||
"mail.translatedFrom": "Translated from {language}",
|
"mail.translatedFrom": "Translated from {language}",
|
||||||
"mail.translatedFromMany": "Translated from {languages}",
|
"mail.translatedFromMany": "Translated from {languages}",
|
||||||
"mail.translateFailed": "Couldn't translate this message. The translation service may be busy or unavailable.",
|
"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;
|
} as const;
|
||||||
|
|
||||||
export type MessageKey = keyof typeof en;
|
export type MessageKey = keyof typeof en;
|
||||||
|
|||||||
@@ -902,4 +902,23 @@ export const pl: Catalog = {
|
|||||||
"mail.translatedFrom": "Przetłumaczono z języka: {language}",
|
"mail.translatedFrom": "Przetłumaczono z języka: {language}",
|
||||||
"mail.translatedFromMany": "Przetłumaczono z języków: {languages}",
|
"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.",
|
"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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,37 @@
|
|||||||
// mounts on every message, and a spam warning on ordinary mail is worse than no
|
// mounts on every message, and a spam warning on ordinary mail is worse than no
|
||||||
// warning at all.
|
// 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) {
|
function esc(s) {
|
||||||
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||||
}
|
}
|
||||||
@@ -14,6 +45,7 @@ export default function mount(el, ctx, props) {
|
|||||||
const messageId = props && props.messageId;
|
const messageId = props && props.messageId;
|
||||||
if (!messageId) return;
|
if (!messageId) return;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
const t = ctx.t;
|
||||||
|
|
||||||
async function act(path, body) {
|
async function act(path, body) {
|
||||||
await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(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;
|
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 = `
|
el.innerHTML = `
|
||||||
<div class="wm-card" role="status">
|
<div class="wm-card" role="status" style="margin: 0 16px 10px">
|
||||||
<strong>Looks like spam</strong>
|
<strong style="color: rgb(239 68 68)">${esc(t("antispam.looksLikeSpam"))}</strong>
|
||||||
<span class="wm-muted">score ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}</span>
|
<span class="wm-muted">${esc(meta)}</span>
|
||||||
<div class="wm-row">
|
<div class="wm-row" style="margin-top: 6px">
|
||||||
<button class="wm-btn" data-a="junk">Move to Spam</button>
|
<button class="wm-btn" data-a="junk">${esc(t("antispam.moveToSpam"))}</button>
|
||||||
<button class="wm-btn-ghost" data-a="ham">Not spam</button>
|
<button class="wm-btn-ghost" data-a="ham">${esc(t("antispam.notSpam"))}</button>
|
||||||
<button class="wm-btn-ghost" data-a="trust">Always trust sender</button>
|
<button class="wm-btn-ghost" data-a="trust">${esc(t("antispam.trustSender"))}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user