Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

158 lines · 7.7 KBRaw History
1import type { Env } from "../env.js";
2import { Directory } from "../lib/directory.js";
3import { MailStore, wantsEmail } from "./prefs.js";
4import { compose, toHtml, toText, type RenderCtx } from "./templates.js";
5import { categoryOf, isMandatory, type MailJob } from "./types.js";
6import { 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. */
16export 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
29const RETRYABLE = new Set(["E_RATE_LIMIT_EXCEEDED", "E_INTERNAL_SERVER_ERROR", "E_DELIVERY_FAILED"]);
30
31export class RetryableMailError extends Error {}
32
33/** The address every email is sent from: MAIL_FROM, or notifications@ the public host. */
34export const senderAddress = (env: Pick<Env, "MAIL_FROM" | "PUBLIC_URL">): string => env.MAIL_FROM || `notifications@${new URL(env.PUBLIC_URL).hostname}`;
35
36const clean = (s: string) => s.replace(/[\r\n]+/g, " ").trim();
37const listIdLabel = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "x";
38
39export 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. */
49export 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. */
99export 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`. */
143export 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}