diff --git a/README.md b/README.md index b6f21bc..e3168c3 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://d - `ASSERTION_SIGNING_SECRET_PREVIOUS`: Optional previous HMAC secret accepted during a signing-key rotation. - `EXTENSIONS_V2_EMAIL_PROVIDER`: `mxroute` or `resend` to send moderator notification emails, or unset/`disabled` to skip sending. - `EXTENSIONS_V2_EMAIL_FROM`: Sender address for notification emails (default `noreply@fossbilling.org`). -- `EXTENSIONS_V2_EMAIL_REPLY_TO`: Optional Reply-To address (e.g. `noreply@fossbilling.org`). +- `EXTENSIONS_V2_EMAIL_REPLY_TO`: Optional Reply-To address for a monitored inbox. Omit when unset (no Reply-To header is sent) — notification emails do not invite replies. - `EXTENSIONS_V2_MXROUTE_SERVER`, `EXTENSIONS_V2_MXROUTE_USERNAME`, `EXTENSIONS_V2_MXROUTE_PASSWORD`: Mailbox credentials for the MXroute SMTP API (`https://smtpapi.mxroute.com/`). Required when `EXTENSIONS_V2_EMAIL_PROVIDER=mxroute`. - `EXTENSIONS_V2_RESEND_API_KEY`: API key for Resend. Required when `EXTENSIONS_V2_EMAIL_PROVIDER=resend`. diff --git a/src/services/extensions/v2/email/templates.ts b/src/services/extensions/v2/email/templates.ts index 1026e52..f7e4fdd 100644 --- a/src/services/extensions/v2/email/templates.ts +++ b/src/services/extensions/v2/email/templates.ts @@ -21,43 +21,125 @@ export interface ModerationEmailInput { const DASHBOARD_URL = "https://extensions.fossbilling.org/account"; function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); + // Numeric entities are pure ASCII, so they survive MXroute declaring the + // HTML body iso-8859-1 while receiving UTF-8 (see subjectLabel). Resend + // renders them identically, and the plain-text part below keeps raw + // unicode for clients that prefer it. + let out = ""; + for (const ch of value) { + switch (ch) { + case "&": + out += "&"; + break; + case "<": + out += "<"; + break; + case ">": + out += ">"; + break; + case '"': + out += """; + break; + default: { + const code = ch.codePointAt(0) ?? 0; + out += code > 127 ? `&#${code};` : ch; + } + } + } + return out; +} + +// A name pasted with its own quotes would double up against the wrapping +// quotes the labels below add (after folding, both render as straight +// quotes). Strip surrounding quote-like characters and whitespace first. +function stripSurroundingQuotes(value: string): string { + return value.replace(/^['"“”‘’\s]+|['"“”‘’\s]+$/g, ""); } // Names come from user input with no newline restriction, and labels feed -// the email subject — strip CR/LF so a name can never split an SMTP header. +// the email subject — strip line breaks and tabs so a name can never split +// an SMTP header. function subjectLabel(value: string): string { - return value.replace(/[\r\n]+/g, " "); + return ( + value + .replace(/[\r\n\t]+/g, " ") + // MXroute's SMTP API declares subjects iso-8859-1 while receiving + // UTF-8, so any non-ASCII byte renders as mojibake (curly quotes show + // as "“"). Fold to ASCII: strip diacritics, map common punctuation, + // and replace anything left with "?" rather than corrupt it. + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[–—]/g, "-") + .replace(/…/g, "...") + .replace(/\u00a0/g, " ") + .replace(/[^\u0020-\u007E]/g, "?") + ); +} + +// Bare URLs are auto-linked by most clients but not all — wrap them in +// explicit anchors so the dashboard link is always clickable. Runs on the +// escaped text: quotes are already entities, so a URL cannot contain a raw +// `"` or `<` that would break out of the href. +function linkify(escaped: string): string { + return escaped.replace(/(https:\/\/[^\s<]+)/g, '$1'); } function layout( title: string, paragraphs: string[] ): { html: string; text: string } { - const html = `

${paragraphs.map(escapeHtml).join("

")}

`; + // A complete document rather than a fragment: MXroute flags fragment-only + // bodies (HTML_MIME_NO_HTML_TAG) and some clients render fragments + // inconsistently. + const html = `

${paragraphs.map((p) => linkify(escapeHtml(p))).join("

")}

`; return { - html: `

${escapeHtml(title)}

${html}`, + html: `

${escapeHtml(title)}

${html}`, text: `${title}\n\n${paragraphs.join("\n\n")}` }; } +// Display form of the same label: original characters preserved for the +// body (entity-encoded for HTML, raw for text), with only line breaks +// flattened and pasted quotes stripped. The folded subjectLabel above must +// never reach the body — "José" would read as "Jose" and "東京" as "??". +function displayLabel( + name: string | undefined, + id: string | undefined, + fallback: string +): string { + const clean = (v: string): string => + stripSurroundingQuotes(v).replace(/[\r\n\t]+/g, " "); + if (name && id) return `“${clean(name)}” (${id})`; + if (id) return id; + if (name) return clean(name); + return fallback; +} + export function buildModerationEmail( input: ModerationEmailInput ): EmailMessage { const extLabel = subjectLabel( input.extensionName && input.extensionId - ? `“${input.extensionName}” (${input.extensionId})` + ? `“${stripSurroundingQuotes(input.extensionName)}” (${input.extensionId})` : (input.extensionId ?? input.extensionName ?? "your extension") ); const devLabel = subjectLabel( input.developerName && input.developerId - ? `“${input.developerName}” (${input.developerId})` + ? `“${stripSurroundingQuotes(input.developerName)}” (${input.developerId})` : (input.developerId ?? input.developerName ?? "your developer profile") ); + const extDisplay = displayLabel( + input.extensionName, + input.extensionId, + "your extension" + ); + const devDisplay = displayLabel( + input.developerName, + input.developerId, + "your developer profile" + ); let subject: string; let title: string; @@ -68,17 +150,16 @@ export function buildModerationEmail( subject = `${extLabel} removed from the FOSSBilling directory`; title = "Your extension was removed from the directory"; paragraphs = [ - `${extLabel} has been removed from the public FOSSBilling extension directory by a moderator. Its content and history are kept, and you can still see it in your dashboard.`, + `${extDisplay} has been removed from the public FOSSBilling extension directory by a moderator. Its content and history are kept, and you can still see it in your dashboard.`, input.reason ? `Reason given: ${input.reason}` : "No reason was given.", - `View it here: ${DASHBOARD_URL}`, - "If you believe this was a mistake, reply to this email." + `View it here: ${DASHBOARD_URL}` ]; break; case "revision-approved": subject = `${extLabel} update approved`; title = "Your extension update was approved"; paragraphs = [ - `Your update to ${extLabel} has been approved by a moderator and is now live in the directory.`, + `Your update to ${extDisplay} has been approved by a moderator and is now live in the directory.`, ...(input.reason ? [`Moderator note: ${input.reason}`] : []), `View it here: ${DASHBOARD_URL}` ]; @@ -87,7 +168,7 @@ export function buildModerationEmail( subject = `${extLabel} update needs changes`; title = "Your extension update was not approved"; paragraphs = [ - `Your update to ${extLabel} was not approved. The published version is unchanged.`, + `Your update to ${extDisplay} was not approved. The published version is unchanged.`, input.reason ? `Reason given: ${input.reason}` : "No reason was given.", `Revise and resubmit here: ${DASHBOARD_URL}` ]; @@ -96,7 +177,7 @@ export function buildModerationEmail( subject = `${devLabel} approved`; title = "Your developer profile was approved"; paragraphs = [ - `${devLabel} has been reviewed and approved. It now shows an approval badge in the directory.`, + `${devDisplay} has been reviewed and approved. It now shows an approval badge in the directory.`, `View it here: ${DASHBOARD_URL}/developer` ]; break; @@ -104,7 +185,7 @@ export function buildModerationEmail( subject = `${devLabel} claim approved`; title = "Your profile claim was approved"; paragraphs = [ - `Your claim on ${devLabel} has been approved. You now own this developer profile.`, + `Your claim on ${devDisplay} has been approved. You now own this developer profile.`, `Manage it here: ${DASHBOARD_URL}/developer` ]; break; @@ -112,9 +193,9 @@ export function buildModerationEmail( subject = `${devLabel} claim not approved`; title = "Your profile claim was not approved"; paragraphs = [ - `Your claim on ${devLabel} was not approved.`, + `Your claim on ${devDisplay} was not approved.`, input.reason ? `Reason given: ${input.reason}` : "No reason was given.", - "If you believe this was a mistake, reply to this email." + `View your claims here: ${DASHBOARD_URL}` ]; break; } diff --git a/test/services/extensions/v2/email.test.ts b/test/services/extensions/v2/email.test.ts index fd2b27c..b7b6442 100644 --- a/test/services/extensions/v2/email.test.ts +++ b/test/services/extensions/v2/email.test.ts @@ -62,12 +62,12 @@ describe("email config", () => { loadEmailIdentity( reader({ EXTENSIONS_V2_EMAIL_FROM: "extensions@fossbilling.org", - EXTENSIONS_V2_EMAIL_REPLY_TO: "noreply@fossbilling.org" + EXTENSIONS_V2_EMAIL_REPLY_TO: "extensions@fossbilling.org" }) ) ).toEqual({ from: "extensions@fossbilling.org", - replyTo: "noreply@fossbilling.org" + replyTo: "extensions@fossbilling.org" }); expect( loadEmailIdentity(reader({ EXTENSIONS_V2_EMAIL_FROM: " " })).from @@ -135,6 +135,25 @@ describe("MxrouteSender", () => { to: "author@example.com", subject: "s" }); + // No monitored inbox is configured, so no Reply-To may be sent. + expect(body.reply_to).toBeUndefined(); + }); + + it("forwards replyTo as reply_to when a monitored inbox is configured", async () => { + const configWithReply = loadMxrouteConfig(reader(MXROUTE_VARS), { + from: "extensions@fossbilling.org", + replyTo: "extensions@fossbilling.org" + })!; + const calls: Array<{ url: unknown; init: RequestInit }> = []; + const sender = new MxrouteSender(configWithReply, (async (url, init) => { + calls.push({ url, init: init as RequestInit }); + return jsonResponse({ success: true, message: "sent" }); + }) as typeof fetch); + + await expect(sender.send(message)).resolves.toEqual({ ok: true }); + expect(JSON.parse(String(calls[0].init.body)).reply_to).toBe( + "extensions@fossbilling.org" + ); }); it("reports provider failures without throwing", async () => { @@ -266,7 +285,74 @@ describe("moderation templates", () => { expect(message.subject).toContain("Bad Header: injected"); }); - it("renders every kind with a subject and, where applicable, a dashboard link", () => { + it("folds non-ASCII names to ASCII-safe subjects", () => { + const message = buildModerationEmail({ + kind: "extension-delisted", + to: "author@example.com", + extensionId: "paygate", + extensionName: "“Smöké — Test”", + reason: "gone" + }); + // MXroute declares subjects iso-8859-1 while receiving UTF-8, so any + // non-ASCII byte would render as mojibake. + expect(message.subject).toMatch(/^[\u0020-\u007E]*$/); + expect(message.subject).toContain('"Smoke - Test"'); + // The body keeps the original characters: entities in HTML, raw in text. + expect(message.text).toContain( + "\u201cSm\u00f6k\u00e9 \u2014 Test\u201d (paygate)" + ); + expect(message.html).toContain("Smöké — Test"); + expect(message.html).not.toContain("Sm\u00f6k\u00e9"); + }); + + it("preserves original developer names in the body", () => { + const message = buildModerationEmail({ + kind: "claim-rejected", + to: "author@example.com", + developerId: "tokyo-dev", + developerName: "\u6771\u4eac Dev", + reason: "Could not verify" + }); + expect(message.subject).toContain("tokyo-dev"); + expect(message.text).toContain("\u201c\u6771\u4eac Dev\u201d (tokyo-dev)"); + expect(message.html).toContain( + "“東京 Dev” (tokyo-dev)" + ); + expect(message.html).not.toContain("\u6771\u4eac"); + }); + + it("encodes non-ASCII as HTML entities while keeping text raw", () => { + const message = buildModerationEmail({ + kind: "claim-rejected", + to: "author@example.com", + developerId: "paygate-dev", + developerName: "Paygate Dev", + reason: "Ownership “unverified” — see notes" + }); + expect(message.html).toMatch(/^[\u0020-\u007E]*$/); + expect(message.html).toContain("“unverified” —"); + // The plain-text part (used by Resend, which is UTF-8 clean) keeps + // readable unicode. + expect(message.text).toContain("“unverified” — see notes"); + }); + + it("links dashboard URLs as anchors in HTML but not in text", () => { + const message = buildModerationEmail({ + kind: "revision-approved", + to: "author@example.com", + extensionId: "paygate", + extensionName: "Paygate" + }); + expect(message.html).toContain( + 'https://extensions.fossbilling.org/account' + ); + expect(message.text).toContain( + "https://extensions.fossbilling.org/account" + ); + expect(message.text).not.toContain(" { const kinds = [ "extension-delisted", "revision-approved", @@ -286,11 +372,9 @@ describe("moderation templates", () => { reason: "Needs work" }); expect(message.subject).toContain("[FOSSBilling]"); - // A rejected claim leaves the claimant with nothing to open, so its - // template replies-by-email instead of linking the dashboard. - if (kind !== "claim-rejected") { - expect(message.text).toContain("extensions.fossbilling.org/account"); - } + expect(message.text).toContain("extensions.fossbilling.org/account"); + expect(message.html).toContain(""); + expect(message.html).toContain(""); } }); }); diff --git a/test/services/extensions/v2/moderation-notify.test.ts b/test/services/extensions/v2/moderation-notify.test.ts index bb6129e..92d68ed 100644 --- a/test/services/extensions/v2/moderation-notify.test.ts +++ b/test/services/extensions/v2/moderation-notify.test.ts @@ -27,7 +27,6 @@ setupExtensionsV2Tests(); const MXROUTE_ENV = { EXTENSIONS_V2_EMAIL_PROVIDER: "mxroute", EXTENSIONS_V2_EMAIL_FROM: "extensions@fossbilling.org", - EXTENSIONS_V2_EMAIL_REPLY_TO: "noreply@fossbilling.org", EXTENSIONS_V2_MXROUTE_SERVER: "tuesday.mxrouting.net", EXTENSIONS_V2_MXROUTE_USERNAME: "extensions@fossbilling.org", EXTENSIONS_V2_MXROUTE_PASSWORD: "secret" @@ -108,13 +107,36 @@ describe("moderation notification emails", () => { const body = JSON.parse(String(calls[0].init.body)); expect(body).toMatchObject({ to: "author@example.com", - from: "extensions@fossbilling.org", - reply_to: "noreply@fossbilling.org" + from: "extensions@fossbilling.org" }); + expect(body.reply_to).toBeUndefined(); expect(body.subject).toContain("live-ext"); expect(body.body).toContain("Upstream source removed"); }); + it("sends ASCII-safe payloads for unicode moderator notes", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedLiveExtension(); + setEmailEnv(); + const calls = stubSmtpApi(); + + const res = await post( + "/extensions/v2/extensions/live-ext/delist", + await authHeaders("mod-1"), + { reason: "Upstream “gone” — domain lapsed" } + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + result: { notified: true } + }); + const body = JSON.parse(String(calls[0].init.body)); + // MXroute declares payloads iso-8859-1: raw UTF-8 would render as + // mojibake, so subjects stay ASCII and bodies use HTML entities. + expect(body.subject).toMatch(/^[\u0020-\u007E]*$/); + expect(body.body).toMatch(/^[\u0020-\u007E]*$/); + expect(body.body).toContain("“gone” —"); + }); + it("skips the email on ?notify=false without calling the provider", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedLiveExtension();