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, """); } export function resolveMailFrom(): string { return ( process.env.SMTP_FROM?.trim() || process.env.CLOUDRON_MAIL_FROM?.trim() || "noreply@localhost" ); } /** Avoid quoted-printable `token=3D` wrapping that some webmail clients do not decode. */ export const MAIL_TEXT_ENCODING = "base64" as const; /** Plaintext + HTML for one-time verify URLs. */ export function buildVerifyLinkParts( verifyUrl: string, intro: string, outro: string, linkLabel: string, ): { text: string; html: string } { const href = escapeHtml(verifyUrl); const text = `${intro}\n\n<${verifyUrl}>\n\n${outro}`; const html = `

${escapeHtml(intro).replace(/\n/g, "
")}

` + `

${escapeHtml(linkLabel)}

` + `

${href}

` + `

${escapeHtml(outro)}

`; return { text, html }; } async function sendHtmlMail(opts: { to: string; subject: string; text: string; html: string; from?: string; replyTo?: string; devLog: string; }): Promise { 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, textEncoding: MAIL_TEXT_ENCODING, replyTo: opts.replyTo, }); } export async function sendMagicLinkEmail( to: string, verifyUrl: string, ): Promise { const { text, html } = buildVerifyLinkParts( verifyUrl, "Open this link to sign in (it expires in 60 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 { 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 60 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 { const { to, fromEmail, visitorEmail, message, requestId } = params; const text = `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`; const html = `

Request ID: ${escapeHtml(requestId)}
From: ${escapeHtml(visitorEmail)}

` + `
${escapeHtml(message)}
`; 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 { 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 60 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}`, }); }