Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
121 changes: 101 additions & 20 deletions src/services/extensions/v2/email/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// 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 += "&amp;";
break;
case "<":
out += "&lt;";
break;
case ">":
out += "&gt;";
break;
case '"':
out += "&quot;";
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")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.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, '<a href="$1">$1</a>');
}

function layout(
title: string,
paragraphs: string[]
): { html: string; text: string } {
const html = `<p>${paragraphs.map(escapeHtml).join("</p><p>")}</p>`;
// 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 = `<p>${paragraphs.map((p) => linkify(escapeHtml(p))).join("</p><p>")}</p>`;
return {
html: `<h2>${escapeHtml(title)}</h2>${html}`,
html: `<html><body><h2>${escapeHtml(title)}</h2>${html}</body></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;
Expand All @@ -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}`
];
Expand All @@ -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}`
];
Expand All @@ -96,25 +177,25 @@ 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;
case "claim-approved":
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;
case "claim-rejected":
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;
}
Expand Down
100 changes: 92 additions & 8 deletions test/services/extensions/v2/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"extensions@fossbilling.org"
);
});

it("reports provider failures without throwing", async () => {
Expand Down Expand Up @@ -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&#246;k&#233; &#8212; 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(
"&#8220;&#26481;&#20140; Dev&#8221; (tokyo-dev)"
);
expect(message.html).not.toContain("\u6771\u4eac");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
});

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("&#8220;unverified&#8221; &#8212;");
// 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(
'<a href="https://extensions.fossbilling.org/account">https://extensions.fossbilling.org/account</a>'
);
expect(message.text).toContain(
"https://extensions.fossbilling.org/account"
);
expect(message.text).not.toContain("<a href=");
});

it("renders every kind with a subject, a dashboard link, and a complete HTML document", () => {
const kinds = [
"extension-delisted",
"revision-approved",
Expand All @@ -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("<html><body>");
expect(message.html).toContain("</body></html>");
}
});
});
28 changes: 25 additions & 3 deletions test/services/extensions/v2/moderation-notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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("&#8220;gone&#8221; &#8212;");
});

it("skips the email on ?notify=false without calling the provider", async () => {
await insertUser(db, { id: "mod-1", is_moderator: 1 });
await seedLiveExtension();
Expand Down
Loading