| 1 | import type { Env } from "../env.js"; |
| 2 | import { Directory } from "../lib/directory.js"; |
| 3 | import { MailStore, wantsEmail } from "./prefs.js"; |
| 4 | import { compose, toHtml, toText, type RenderCtx } from "./templates.js"; |
| 5 | import { categoryOf, isMandatory, type MailJob } from "./types.js"; |
| 6 | import { unsubUrl } from "./unsubscribe.js"; |
| 7 | |
| 8 | /** |
| 9 | * Sending: jobs go on the `diffinery-mail` queue (so a slow or failing send |
| 10 | * never holds up a request, and failures retry with backoff), and the queue |
| 11 | * consumer checks the recipient's settings, renders and sends each one through |
| 12 | * Cloudflare Email Sending. |
| 13 | */ |
| 14 | |
| 15 | /** Queue emails. Never throws: email must not break the action that caused it. */ |
| 16 | export async function enqueueMail(env: Env, jobs: MailJob[]): Promise<void> { |
| 17 | if (!jobs.length) return; |
| 18 | if (!env.MAIL) { |
| 19 | console.warn("mail queue not bound; dropping", jobs.length, "email(s)"); |
| 20 | return; |
| 21 | } |
| 22 | try { |
| 23 | for (let i = 0; i < jobs.length; i += 50) await env.MAIL.sendBatch(jobs.slice(i, i + 50).map((body) => ({ body }))); |
| 24 | } catch (e) { |
| 25 | console.error("could not queue email", e); |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | const RETRYABLE = new Set(["E_RATE_LIMIT_EXCEEDED", "E_INTERNAL_SERVER_ERROR", "E_DELIVERY_FAILED"]); |
| 30 | |
| 31 | export class RetryableMailError extends Error {} |
| 32 | |
| 33 | /** The address every email is sent from: MAIL_FROM, or notifications@ the public host. */ |
| 34 | export const senderAddress = (env: Pick<Env, "MAIL_FROM" | "PUBLIC_URL">): string => env.MAIL_FROM || `notifications@${new URL(env.PUBLIC_URL).hostname}`; |
| 35 | |
| 36 | const clean = (s: string) => s.replace(/[\r\n]+/g, " ").trim(); |
| 37 | const listIdLabel = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "x"; |
| 38 | |
| 39 | export interface Outgoing { |
| 40 | from: { name: string; email: string }; |
| 41 | to: { name: string; email: string } | string; |
| 42 | subject: string; |
| 43 | html: string; |
| 44 | text: string; |
| 45 | headers: Record<string, string>; |
| 46 | } |
| 47 | |
| 48 | /** Render a job for one recipient: the message plus the headers mail clients use for threading and unsubscribing. */ |
| 49 | export async function buildMessage(env: Env, job: MailJob, to: { email: string; name?: string; login?: string; accountId?: string }): Promise<Outgoing> { |
| 50 | const pub = env.PUBLIC_URL; |
| 51 | const email = compose(job, pub, to.login); |
| 52 | const category = categoryOf(job); |
| 53 | const unsubscribe: Array<{ label: string; url: string }> = []; |
| 54 | if (to.accountId && !isMandatory(category)) { |
| 55 | const a = to.accountId; |
| 56 | if (job.kind === "thread") { |
| 57 | const d = job.data; |
| 58 | unsubscribe.push({ label: "Mute this thread", url: await unsubUrl(env, { accountId: a, scope: { t: "thread", repoId: d.repoId, number: d.number, repo: d.repo } }) }); |
| 59 | if (job.reason === "subscribed") unsubscribe.push({ label: `Stop watching ${d.repo}`, url: await unsubUrl(env, { accountId: a, scope: { t: "repo", repoId: d.repoId, repo: d.repo } }) }); |
| 60 | } |
| 61 | if (category !== "security" && category !== "account") { |
| 62 | unsubscribe.push({ label: "Unsubscribe from emails like this", url: await unsubUrl(env, { accountId: a, scope: { t: "category", category } }) }); |
| 63 | } |
| 64 | } |
| 65 | const ctx: RenderCtx = { publicUrl: pub, login: to.login, reason: job.reason, unsubscribe, settingsUrl: to.accountId ? `${pub}/settings/notifications` : undefined }; |
| 66 | const host = new URL(pub).hostname; |
| 67 | const headers: Record<string, string> = { |
| 68 | "Auto-Submitted": "auto-generated", |
| 69 | "X-Diffinery-Reason": job.reason, |
| 70 | "X-Diffinery-Kind": job.kind, |
| 71 | }; |
| 72 | if (to.login) headers["X-Diffinery-Recipient"] = to.login; |
| 73 | if (!isMandatory(category)) headers.Precedence = "list"; |
| 74 | if (email.thread) { |
| 75 | const ref = `<${email.thread}@${host}>`; |
| 76 | headers.References = ref; |
| 77 | if (job.kind === "thread" && job.data.event !== "opened") headers["In-Reply-To"] = ref; |
| 78 | } |
| 79 | if (email.context) { |
| 80 | const [owner, name] = email.context.label.includes("/") ? email.context.label.split("/") : [email.context.label, ""]; |
| 81 | headers["List-Id"] = `${clean(email.context.label)} <${name ? `${listIdLabel(name)}.` : ""}${listIdLabel(owner!)}.${host}>`; |
| 82 | headers["X-Diffinery-Repository"] = email.context.label; |
| 83 | } |
| 84 | if (unsubscribe.length) { |
| 85 | headers["List-Unsubscribe"] = `<${unsubscribe[0]!.url}>`; |
| 86 | headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"; |
| 87 | } |
| 88 | return { |
| 89 | from: { name: email.fromName ? `${clean(email.fromName)} (Diffinery)` : "Diffinery", email: senderAddress(env) }, |
| 90 | to: to.name ? { name: clean(to.name), email: to.email } : to.email, |
| 91 | subject: clean(email.subject).slice(0, 900), |
| 92 | html: toHtml(email, ctx), |
| 93 | text: toText(email, ctx), |
| 94 | headers, |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | /** Send one job now: resolve the recipient, apply their settings, render and send. */ |
| 99 | export async function deliver(env: Env, job: MailJob): Promise<{ status: "sent" | "skipped" | "failed"; detail?: string; messageId?: string }> { |
| 100 | const dir = new Directory(env); |
| 101 | const store = new MailStore(env.DB); |
| 102 | const category = categoryOf(job); |
| 103 | let to: { email: string; name?: string; login?: string; accountId?: string }; |
| 104 | if ("accountId" in job.to) { |
| 105 | const accountId = job.to.accountId; |
| 106 | const a = await dir.account(accountId); |
| 107 | const skip = async (detail: string) => { |
| 108 | await store.log({ account_id: accountId, to_addr: a?.email ?? "", kind: job.kind, subject: "", status: "skipped", detail, message_id: null, dev_copy: null }); |
| 109 | return { status: "skipped" as const, detail }; |
| 110 | }; |
| 111 | if (!a) return skip("no such account"); |
| 112 | if (a.kind !== "user") return skip(`${a.kind} accounts have no email`); |
| 113 | if (!a.email) return skip("no email address"); |
| 114 | if (a.status === "disabled" && job.kind !== "account_status") return skip("account disabled"); |
| 115 | if (!job.force && !isMandatory(category) && !wantsEmail(await store.prefs(a.id), category)) return skip(`turned off ${category} emails`); |
| 116 | to = { email: a.email, name: a.name || undefined, login: a.login, accountId: a.id }; |
| 117 | } else { |
| 118 | to = { email: job.to.email, name: job.to.name }; |
| 119 | } |
| 120 | |
| 121 | const msg = await buildMessage(env, job, to); |
| 122 | const devCopy = env.DEV_AUTH ? JSON.stringify(msg) : null; |
| 123 | const base = { account_id: to.accountId ?? null, to_addr: to.email, kind: job.kind, subject: msg.subject, dev_copy: devCopy }; |
| 124 | if (!env.EMAIL) { |
| 125 | await store.log({ ...base, status: "skipped", detail: "email sending is not configured (no EMAIL binding)", message_id: null }); |
| 126 | return { status: "skipped", detail: "no EMAIL binding" }; |
| 127 | } |
| 128 | try { |
| 129 | const res = await env.EMAIL.send(msg); |
| 130 | await store.log({ ...base, status: "sent", detail: null, message_id: res.messageId ?? null }); |
| 131 | return { status: "sent", messageId: res.messageId }; |
| 132 | } catch (e) { |
| 133 | const code = (e as { code?: string }).code ?? ""; |
| 134 | const detail = `${code ? `${code}: ` : ""}${(e as Error).message ?? String(e)}`; |
| 135 | if (RETRYABLE.has(code)) throw new RetryableMailError(detail); |
| 136 | await store.log({ ...base, status: "failed", detail, message_id: null }); |
| 137 | console.error("email failed", job.kind, to.email, detail); |
| 138 | return { status: "failed", detail }; |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /** Queue consumer for `diffinery-mail`. */ |
| 143 | export async function mailQueue(batch: MessageBatch<MailJob>, env: Env): Promise<void> { |
| 144 | for (const msg of batch.messages) { |
| 145 | try { |
| 146 | await deliver(env, msg.body); |
| 147 | msg.ack(); |
| 148 | } catch (e) { |
| 149 | console.error("email delivery error", msg.body.kind, e); |
| 150 | if (msg.attempts >= 6) { |
| 151 | await new MailStore(env.DB) |
| 152 | .log({ account_id: "accountId" in msg.body.to ? msg.body.to.accountId : null, to_addr: "email" in msg.body.to ? msg.body.to.email : "", kind: msg.body.kind, subject: "", status: "failed", detail: `gave up after ${msg.attempts} attempts: ${(e as Error).message}`, message_id: null, dev_copy: null }) |
| 153 | .catch(() => {}); |
| 154 | msg.ack(); |
| 155 | } else msg.retry({ delaySeconds: Math.min(600, 15 * 2 ** msg.attempts) }); |
| 156 | } |
| 157 | } |
| 158 | } |