import { env } from "cloudflare:workers"; import { publicUrl } from "./events/emit"; import type { QueueMessage } from "./events/emit"; import { CATEGORY_INFO } from "~/lib/notification-categories"; import type { NotificationCategory } from "~/lib/notification-categories"; import { captureDevMail, devMailMode } from "./notify/dev-mail"; import { captureServerEvent } from "./analytics"; export type EmailQueueMessage = Extract; export type EmailContent = { subject: string; text: string; html: string; headers?: Record; /** Address replies go to; set on thread notifications so a reply becomes a comment. */ replyTo?: string; }; type Action = { label: string; url: string }; /** Links appended to every notification email; `unsubscribeUrl` also drives the List-Unsubscribe headers. */ export type EmailFooter = { reason: string; manageUrl: string; unsubscribeUrl: string | null }; export type DigestEntry = { title: string; url: string }; const HTML_ESCAPES: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; const MAX_DETAIL_LENGTH = 500; function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char); } function forgeName(): string { return env.FORGE_NAME || "gitfor"; } const MAX_EXCERPT_LENGTH = 2000; function clipExcerpt(value: string): string { const trimmed = value.trim(); return trimmed.length > MAX_EXCERPT_LENGTH ? `${trimmed.slice(0, MAX_EXCERPT_LENGTH)}…` : trimmed; } function clip(value: string): string { return value.length > MAX_DETAIL_LENGTH ? `${value.slice(0, MAX_DETAIL_LENGTH)}…` : value; } function footerHtml(footer: EmailFooter | null): string { if (!footer) return ""; const unsubscribe = footer.unsubscribeUrl ? ` · Unsubscribe` : ""; return `

${escapeHtml(footer.reason)}
Manage notification settings${unsubscribe}

`; } type RenderInput = { subject: string; paragraphs: string[]; action: Action | null; footer?: EmailFooter | null; eyebrow?: string; list?: Action[]; /** Quoted excerpt of the message that triggered the email, shown above the call to action. */ excerpt?: string | null; }; function layout(eyebrow: string, input: RenderInput): string { const body = input.paragraphs .map((text) => `

${escapeHtml(text)}

`) .join(""); const list = input.list ?? []; const items = list.length ? `` : ""; const excerpt = input.excerpt ? `
${escapeHtml(input.excerpt)}
` : ""; const action = input.action; const button = action ? `

${escapeHtml(action.label)}

${escapeHtml(action.url)}

` : ""; return `
${escapeHtml(eyebrow)}

${escapeHtml(input.subject)}

${body}${excerpt}${items}${button}${footerHtml(input.footer ?? null)}
`; } function footerText(footer: EmailFooter | null): string[] { if (!footer) return []; return [ "--", footer.reason, `Manage notification settings: ${footer.manageUrl}`, ...(footer.unsubscribeUrl ? [`Unsubscribe: ${footer.unsubscribeUrl}`] : []), ]; } function unsubscribeHeaders(footer: EmailFooter | null): Record | undefined { if (!footer?.unsubscribeUrl) return undefined; return { "List-Unsubscribe": `<${footer.unsubscribeUrl}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" }; } function render(input: RenderInput): EmailContent { const footer = input.footer ?? null; const text = [ ...input.paragraphs, ...(input.excerpt ? [input.excerpt] : []), ...(input.list ?? []).map((item) => `- ${item.label}\n ${item.url}`), ...(input.action ? [`${input.action.label}: ${input.action.url}`] : []), ...footerText(footer), ].join("\n\n"); const eyebrow = input.eyebrow ? `${forgeName()} · ${input.eyebrow}` : forgeName(); const headers = unsubscribeHeaders(footer); return { subject: `[${forgeName()}] ${input.subject}`, text, html: layout(eyebrow, input), ...(headers ? { headers } : {}), }; } const CATEGORY_COPY: Record = { security_alert: { intro: "A security alert needs your attention.", action: "Review alert" }, vulnerability_report: { intro: "There is an update on a security advisory.", action: "View advisory" }, ci: { intro: "A workflow run you are involved in has finished.", action: "View run" }, ci_watching: { intro: "A workflow run in a repository you watch has finished.", action: "View run" }, deploy: { intro: "A deployment did not complete.", action: "View deployment" }, quota: { intro: "An account you manage is approaching or over a usage limit.", action: "Review usage" }, runner: { intro: "A runner you administer stopped responding.", action: "View runners" }, import: { intro: "A repository you started could not be created.", action: "View repository" }, mergefix: { intro: "A merge-conflict fix you started has finished.", action: "View pull request" }, integration: { intro: "An integration gave up after repeated failures.", action: "View integration" }, }; export const emailTemplates = { verifyEmail(name: string, url: string, kind: "primary" | "added"): EmailContent { const intro = kind === "primary" ? `Hi ${name}, confirm this address to finish setting up your account.` : `Hi ${name}, confirm this address to add it to your account. Once verified it can sign your commits, match CODEOWNERS entries, receive password resets, and be made your primary address.`; return render({ subject: kind === "primary" ? "Verify your email address" : "Confirm your new email address", eyebrow: "Account", paragraphs: [intro, "The link expires in 24 hours. If you didn't request this, you can ignore this email."], action: { label: "Verify email", url }, }); }, passwordReset(name: string, url: string): EmailContent { return render({ subject: "Reset your password", paragraphs: [ `Hi ${name}, someone asked to reset the password for your account.`, "The link expires in one hour. If this wasn't you, you can ignore this email.", ], action: { label: "Choose a new password", url }, }); }, waitlistInvite(url: string, holdFor: string): EmailContent { return render({ subject: "You're off the waitlist", eyebrow: "Account", paragraphs: [ "Good news: a spot opened up and you're next in line. You can create your account now.", `We're holding your spot for ${holdFor}. After that it goes to the next person on the waitlist, and you can join the waitlist again.`, ], action: { label: "Create your account", url }, }); }, securityNotice(name: string, event: string): EmailContent { return render({ subject: "Security alert", eyebrow: "Account security", paragraphs: [ `Hi ${name}, ${event}.`, "If this wasn't you, reset your password and review your security settings.", ], action: { label: "Review security settings", url: publicUrl("/settings/security") }, }); }, accountDisabled(name: string, accountName: string, message: string): EmailContent { return render({ subject: "Your account was disabled", eyebrow: "Account", paragraphs: [`Hi ${name}, the account ${accountName} was disabled.`, message], action: { label: "Terms of Service", url: publicUrl("/terms") }, }); }, accountDeleted(name: string): EmailContent { return render({ subject: "Your account was deleted", eyebrow: "Account", paragraphs: [ `Hi ${name}, your account and its repositories have been deleted, and your username has been retired.`, "Comments and other contributions you made to other people's projects remain, attributed to a deleted user. This cannot be undone. If you did not request this, contact the operator right away using the details in the privacy policy.", ], action: { label: "Privacy policy", url: publicUrl("/privacy") }, }); }, notification( title: string, reason: string, url: string, footer: EmailFooter, thread: { excerpt?: string | null; replyTo?: string | null } = {}, ): EmailContent { const content = render({ subject: title, paragraphs: [ `You are receiving this because of: ${reason.replace(/_/g, " ")}.`, ...(thread.replyTo ? ["Reply to this email to comment on the conversation."] : []), ], excerpt: thread.excerpt ? clipExcerpt(thread.excerpt) : null, action: { label: "View on the forge", url: publicUrl(url) }, footer, }); return thread.replyTo ? { ...content, replyTo: thread.replyTo } : content; }, /** Shared layout per category with its own eyebrow, intro and call to action. Details must already be redacted. */ category( category: NotificationCategory, title: string, details: string[], url: string, footer: EmailFooter, ): EmailContent { const copy = CATEGORY_COPY[category]; return render({ subject: title, eyebrow: CATEGORY_INFO[category].label, paragraphs: [copy.intro, ...details.map(clip)], action: { label: copy.action, url: publicUrl(url) }, footer, }); }, digest(category: NotificationCategory, entries: DigestEntry[], footer: EmailFooter): EmailContent { const label = CATEGORY_INFO[category].label; return render({ subject: `${label}: ${entries.length} new ${entries.length === 1 ? "notification" : "notifications"}`, eyebrow: `${label} digest`, paragraphs: [`Here is what happened since your last ${label.toLowerCase()} digest.`], list: entries.map((entry) => ({ label: entry.title, url: publicUrl(entry.url) })), action: { label: "Open notifications", url: publicUrl("/notifications") }, footer, }); }, }; /** Queues an email; the Queue consumer delivers it through the Email Service binding with retries. */ export async function queueEmail(to: string, content: EmailContent): Promise { const message: EmailQueueMessage = { kind: "email.send", to, ...content }; await env.EVENTS.send(message); } /** Email Service only sends from verified domains, so an unset or localhost sender means email is disabled. */ export function emailConfigured(): boolean { const domain = (env.EMAIL_FROM ?? "").match(/@([^>\s]+)/)?.[1] ?? ""; return domain !== "" && domain !== "localhost" && domain.includes("."); } export async function deliverEmail(message: EmailQueueMessage): Promise { const deliverable = emailConfigured(); const sink = devMailMode(); // Recipient and subject are personal data; only the outcome and whether it was a reply-able thread go out. const note = (outcome: string) => captureServerEvent("email_sent", null, { outcome, thread_reply: Boolean(message.replyTo) }); if (sink) { await captureDevMail(sink, message); note("dev_sink"); return; } if (!deliverable) { console.warn( "Email disabled: set EMAIL_FROM to an address on a verified Email Service domain; dropping", message.subject, ); note("disabled"); return; } await env.EMAIL.send({ to: message.to, from: env.EMAIL_FROM, subject: message.subject, text: message.text, html: message.html, ...(message.headers ? { headers: message.headers } : {}), ...(message.replyTo ? { replyTo: message.replyTo } : {}), }); note("sent"); }