Send sign-in mail as HTML so quoted-printable wrapping cannot break the verify URL.

Staging was delivering magic links that looked expired because the token query string was encoded and wrapped in plaintext MIME. Also read rate-limit retry from the API error body, mention spam in the success copy, and document SES relay DNS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-01 10:01:25 -06:00
co-authored by Cursor
parent 511c3efb4c
commit d920e39f09
10 changed files with 258 additions and 112 deletions
+99 -68
View File
@@ -2,62 +2,108 @@ import nodemailer from "nodemailer";
import { logger } from "../logger";
import { getSmtpUrl } from "./env";
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const url = getSmtpUrl();
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
if (!url) {
export function resolveMailFrom(): string {
return (
process.env.SMTP_FROM?.trim() ||
process.env.CLOUDRON_MAIL_FROM?.trim() ||
"noreply@localhost"
);
}
/** Plaintext + HTML for one-time verify URLs. HTML `href` survives quoted-printable wrapping. */
export function buildVerifyLinkParts(
verifyUrl: string,
intro: string,
outro: string,
linkLabel: string,
): { text: string; html: string } {
const text = `${intro}\n\n${verifyUrl}\n\n${outro}`;
const html =
`<p>${escapeHtml(intro).replace(/\n/g, "<br />")}</p>` +
`<p><a href="${escapeHtml(verifyUrl)}">${escapeHtml(linkLabel)}</a></p>` +
`<p>${escapeHtml(outro)}</p>`;
return { text, html };
}
async function sendHtmlMail(opts: {
to: string;
subject: string;
text: string;
html: string;
from?: string;
replyTo?: string;
devLog: string;
}): Promise<void> {
const smtpUrl = getSmtpUrl();
if (!smtpUrl) {
if (process.env.NODE_ENV === "development") {
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`);
logger.info(opts.devLog);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
const transporter = nodemailer.createTransport(smtpUrl);
await transporter.sendMail({
from,
to,
subject: "Sign in to Community Rule",
text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`,
from: opts.from ?? resolveMailFrom(),
to: opts.to,
subject: opts.subject,
text: opts.text,
html: opts.html,
replyTo: opts.replyTo,
});
}
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const { text, html } = buildVerifyLinkParts(
verifyUrl,
"Open this link to sign in (it expires in 15 minutes):",
"If you did not request this, you can ignore this email.",
"Sign in",
);
await sendHtmlMail({
to,
subject: "Sign in to Community Rule",
text,
html,
devLog: `[dev] Magic link for ${to}: ${verifyUrl}`,
});
}
/** CR-103: confirm control of the new inbox before `User.email` is updated. */
/** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */
export async function sendRuleStakeholderInviteEmail(
to: string,
verifyUrl: string,
ruleTitle: string,
): Promise<void> {
const url = getSmtpUrl();
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(
`[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
await transporter.sendMail({
from,
const { text, html } = buildVerifyLinkParts(
verifyUrl,
`You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:`,
"If you did not expect this, you can ignore this email.",
"Open the rule",
);
await sendHtmlMail({
to,
subject: `You're invited to view a Community Rule: ${ruleTitle}`,
text: `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:\n\n${verifyUrl}\n\nIf you did not expect this, you can ignore this email.`,
text,
html,
devLog: `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
});
}
/** CR-107: notify support/organizers when a visitor submits the Ask an organizer form. */
/** Notify support/organizers when a visitor submits the Ask an organizer form. */
export async function sendOrganizerInquiryNotification(params: {
/** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */
to: string;
@@ -67,26 +113,18 @@ export async function sendOrganizerInquiryNotification(params: {
requestId: string;
}): Promise<void> {
const { to, fromEmail, visitorEmail, message, requestId } = params;
const url = getSmtpUrl();
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(
`[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
await transporter.sendMail({
from: fromEmail,
const text = `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`;
const html =
`<p>Request ID: ${escapeHtml(requestId)}<br />From: ${escapeHtml(visitorEmail)}</p>` +
`<pre>${escapeHtml(message)}</pre>`;
await sendHtmlMail({
to,
from: fromEmail,
replyTo: visitorEmail,
subject: `Ask an organizer inquiry from ${visitorEmail}`,
text: `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`,
text,
html,
devLog: `[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
});
}
@@ -94,24 +132,17 @@ export async function sendEmailChangeEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const url = getSmtpUrl();
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
await transporter.sendMail({
from,
const { text, html } = buildVerifyLinkParts(
verifyUrl,
"You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):",
"If you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.",
"Confirm email",
);
await sendHtmlMail({
to,
subject: "Confirm your new Community Rule email",
text: `You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.`,
text,
html,
devLog: `[dev] Email change verify for ${to}: ${verifyUrl}`,
});
}