Quoted-printable still rewrote token= as token=3D in the raw MIME, which some clients never decode. Distinguish a missing token from a real expiry, and keep links valid for 60 minutes. Co-authored-by: Cursor <cursoragent@cursor.com>
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import nodemailer from "nodemailer";
|
|
import {
|
|
MAIL_TEXT_ENCODING,
|
|
buildVerifyLinkParts,
|
|
resolveMailFrom,
|
|
} from "../../lib/server/mail";
|
|
|
|
const VERIFY_URL =
|
|
"https://staging.communityrule.info/api/auth/magic-link/verify?token=5IdE_BHowaw-QJj7Rwue7CbB8wDXvYITvnxRb1FGqxA";
|
|
|
|
function decodeBase64Parts(raw: string): string {
|
|
const blocks = [
|
|
...raw.matchAll(
|
|
/Content-Transfer-Encoding:\s*base64\s*\r?\n\r?\n([A-Za-z0-9+/=\s]+)/gi,
|
|
),
|
|
];
|
|
return blocks
|
|
.map((match) =>
|
|
Buffer.from(match[1].replace(/\s/g, ""), "base64").toString("utf8"),
|
|
)
|
|
.join("\n");
|
|
}
|
|
|
|
const MAIL_FROM_KEYS = ["SMTP_FROM", "CLOUDRON_MAIL_FROM"] as const;
|
|
const ORIGINAL_FROM = Object.fromEntries(
|
|
MAIL_FROM_KEYS.map((key) => [key, process.env[key]]),
|
|
) as Record<(typeof MAIL_FROM_KEYS)[number], string | undefined>;
|
|
|
|
afterEach(() => {
|
|
for (const key of MAIL_FROM_KEYS) {
|
|
const original = ORIGINAL_FROM[key];
|
|
if (original === undefined) delete process.env[key];
|
|
else process.env[key] = original;
|
|
}
|
|
});
|
|
|
|
describe("buildVerifyLinkParts", () => {
|
|
it("puts the exact verify URL in text, href, and visible HTML link", () => {
|
|
const { text, html } = buildVerifyLinkParts(
|
|
VERIFY_URL,
|
|
"Open this link to sign in (it expires in 60 minutes):",
|
|
"If you did not request this, you can ignore this email.",
|
|
"Sign in",
|
|
);
|
|
expect(text).toContain(`<${VERIFY_URL}>`);
|
|
expect(html).toContain(`href="${VERIFY_URL}"`);
|
|
expect(html).toContain(`>${VERIFY_URL}</a>`);
|
|
expect(html).toContain(">Sign in</a>");
|
|
});
|
|
|
|
it("escapes HTML in the intro and href", () => {
|
|
const { html } = buildVerifyLinkParts(
|
|
'https://example.test/verify?token=a&b="c"',
|
|
'View "Rule <beta>"',
|
|
"Ignore if unexpected.",
|
|
"Open",
|
|
);
|
|
expect(html).toContain("View "Rule <beta>"");
|
|
expect(html).toContain(
|
|
'href="https://example.test/verify?token=a&b="c""',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("MIME encoding of verify-link mail", () => {
|
|
it("uses base64 so the raw MIME never contains token=3D", async () => {
|
|
const { text, html } = buildVerifyLinkParts(
|
|
VERIFY_URL,
|
|
"Open this link to sign in (it expires in 60 minutes):",
|
|
"If you did not request this, you can ignore this email.",
|
|
"Sign in",
|
|
);
|
|
const transporter = nodemailer.createTransport({
|
|
streamTransport: true,
|
|
buffer: true,
|
|
newline: "unix",
|
|
});
|
|
const info = await transporter.sendMail({
|
|
from: "Community Rule <staging.app@communityrule.info>",
|
|
to: "member@example.com",
|
|
subject: "Sign in to Community Rule",
|
|
text,
|
|
html,
|
|
textEncoding: MAIL_TEXT_ENCODING,
|
|
});
|
|
const raw = Buffer.isBuffer(info.message)
|
|
? info.message.toString("utf8")
|
|
: String(info.message);
|
|
expect(raw).toMatch(/Content-Transfer-Encoding:\s*base64/i);
|
|
expect(raw).not.toContain("token=3D");
|
|
expect(raw).not.toMatch(/quoted-printable/i);
|
|
const decoded = decodeBase64Parts(raw);
|
|
expect(decoded).toContain(`href="${VERIFY_URL}"`);
|
|
expect(decoded).toContain(VERIFY_URL);
|
|
});
|
|
});
|
|
|
|
describe("resolveMailFrom", () => {
|
|
it("prefers SMTP_FROM, then CLOUDRON_MAIL_FROM", () => {
|
|
delete process.env.SMTP_FROM;
|
|
delete process.env.CLOUDRON_MAIL_FROM;
|
|
expect(resolveMailFrom()).toBe("noreply@localhost");
|
|
|
|
process.env.CLOUDRON_MAIL_FROM = "staging.app@communityrule.info";
|
|
expect(resolveMailFrom()).toBe("staging.app@communityrule.info");
|
|
|
|
process.env.SMTP_FROM = "Community Rule <hello@communityrule.info>";
|
|
expect(resolveMailFrom()).toBe(
|
|
"Community Rule <hello@communityrule.info>",
|
|
);
|
|
});
|
|
});
|