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>
149 lines
4.3 KiB
TypeScript
149 lines
4.3 KiB
TypeScript
import nodemailer from "nodemailer";
|
|
import { logger } from "../logger";
|
|
import { getSmtpUrl } from "./env";
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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(opts.devLog);
|
|
return;
|
|
}
|
|
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
|
|
}
|
|
|
|
const transporter = nodemailer.createTransport(smtpUrl);
|
|
await transporter.sendMail({
|
|
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}`,
|
|
});
|
|
}
|
|
|
|
/** 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 { 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,
|
|
html,
|
|
devLog: `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`,
|
|
});
|
|
}
|
|
|
|
/** 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;
|
|
fromEmail: string;
|
|
visitorEmail: string;
|
|
message: string;
|
|
requestId: string;
|
|
}): Promise<void> {
|
|
const { to, fromEmail, visitorEmail, message, requestId } = params;
|
|
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,
|
|
html,
|
|
devLog: `[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
|
|
});
|
|
}
|
|
|
|
export async function sendEmailChangeEmail(
|
|
to: string,
|
|
verifyUrl: string,
|
|
): Promise<void> {
|
|
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,
|
|
html,
|
|
devLog: `[dev] Email change verify for ${to}: ${verifyUrl}`,
|
|
});
|
|
}
|