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:
+23
-36
@@ -27,6 +27,26 @@ function readApiErrorMessage(data: unknown): string {
|
||||
return "Request failed";
|
||||
}
|
||||
|
||||
function retryAfterFromResponse(
|
||||
res: Response,
|
||||
data: unknown,
|
||||
): number | undefined {
|
||||
if (res.status !== 429) return undefined;
|
||||
if (data && typeof data === "object" && "details" in data) {
|
||||
const d = (data as { details?: unknown }).details;
|
||||
if (d && typeof d === "object" && "retryAfterMs" in d) {
|
||||
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
|
||||
if (typeof ms === "number" && ms > 0) return ms;
|
||||
}
|
||||
}
|
||||
const h = res.headers.get("retry-after");
|
||||
if (h) {
|
||||
const sec = Number.parseInt(h, 10);
|
||||
if (!Number.isNaN(sec)) return sec * 1000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function fetchAuthSession(): Promise<{
|
||||
user: { id: string; email: string } | null;
|
||||
}> {
|
||||
@@ -54,13 +74,12 @@ export async function requestMagicLink(
|
||||
...(draft && Object.keys(draft).length > 0 ? { draft } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res);
|
||||
const data: unknown = await parseJson(res);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: readApiErrorMessage(data),
|
||||
retryAfterMs:
|
||||
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
|
||||
retryAfterMs: retryAfterFromResponse(res, data),
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -85,22 +104,10 @@ export async function requestEmailChange(
|
||||
});
|
||||
const data: unknown = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
let retryAfterMs: number | undefined;
|
||||
if (
|
||||
res.status === 429 &&
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"details" in data
|
||||
) {
|
||||
const d = (data as { details?: { retryAfterMs?: unknown } }).details;
|
||||
if (d && typeof d.retryAfterMs === "number") {
|
||||
retryAfterMs = d.retryAfterMs;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: readApiErrorMessage(data),
|
||||
retryAfterMs,
|
||||
retryAfterMs: retryAfterFromResponse(res, data),
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -438,26 +445,6 @@ export type RuleStakeholderMutationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string; status: number; retryAfterMs?: number };
|
||||
|
||||
function retryAfterFromResponse(
|
||||
res: Response,
|
||||
data: unknown,
|
||||
): number | undefined {
|
||||
if (res.status !== 429) return undefined;
|
||||
if (data && typeof data === "object" && "details" in data) {
|
||||
const d = (data as { details?: unknown }).details;
|
||||
if (d && typeof d === "object" && "retryAfterMs" in d) {
|
||||
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
|
||||
if (typeof ms === "number" && ms > 0) return ms;
|
||||
}
|
||||
}
|
||||
const h = res.headers.get("retry-after");
|
||||
if (h) {
|
||||
const sec = Number.parseInt(h, 10);
|
||||
if (!Number.isNaN(sec)) return sec * 1000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function addRuleStakeholder(
|
||||
ruleId: string,
|
||||
email: string,
|
||||
|
||||
+99
-68
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user