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();