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.

777 lines · 36.6 KBRaw History
1/** @jsxImportSource hono/jsx */
2import type { Child, FC } from "hono/jsx";
3import { requireAccount, type AppContext } from "../auth/principal.js";
4import type { Account } from "../lib/directory.js";
5import { loadRepo } from "../http/common.js";
6import { HttpError, now } from "../lib/util.js";
7import { ADMIN_TABS, SettingsNav, type SettingsTab } from "../web/account.js";
8import { ComputePausedBanner } from "../web/kill-switches.js";
9import { csrfToken, formData } from "../web/csrf.js";
10import { KCheckbox, KSelect, type KOption } from "../web/kumo.js";
11import { digestSince } from "./digest.js";
12import { Form, Time, WATCH_OPTIONS, act, flash, post, prm, render, repoUrl, type WebApp } from "../web/ui.js";
13import { MailStore, WATCH_LEVELS, type EmailLogRow, type NotificationPrefs, type WatchLevel } from "./prefs.js";
14import { sampleJobs } from "./samples.js";
15import { buildMessage, deliver, enqueueMail, senderAddress } from "./send.js";
16import type { Category, MailJob } from "./types.js";
17import { readUnsubToken, type UnsubScope, type UnsubToken } from "./unsubscribe.js";
18import { normalEmail } from "../auth/signup.js";
19import { MAX_DETAIL, MAX_VERIFY_EMAILS_PER_HOUR, REASON_TEXT, Suppressions, VERIFY_HOURS, normalAddress, ownerCanLift, readVerifyToken, verifyToken, type Suppression } from "./suppression.js";
20
21/**
22 * Email in the web UI: notification settings, one-click unsubscribe, watching
23 * repositories and subscribing to threads, Settings → Email (is my address
24 * suppressed, and verifying it again), and the Email page in site
25 * administration (delivery status, the suppression list, the log and a
26 * gallery of every template).
27 */
28
29const CATEGORY_TEXT: Record<Exclude<Category, "security" | "account">, string> = {
30 participating: "issues and pull requests you take part in",
31 watching: "activity in repositories you watch",
32 actions: "workflow runs",
33 repos: "repository changes",
34 orgs: "organization updates",
35 digest: "the morning digest",
36};
37
38const settingsPage = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child) =>
39 render(
40 c,
41 { title },
42 <div class="grid left">
43 <SettingsNav me={me} tab={tab} db={c.env.DB} />
44 <div>
45 {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null}
46 {body}
47 </div>
48 </div>,
49 );
50
51const requireUser = (c: AppContext): Account => {
52 const me = requireAccount(c);
53 if (me.kind !== "user") throw new HttpError(403, "only people have notification settings");
54 return me;
55};
56
57const requireSiteAdmin = (c: AppContext): Account => {
58 const me = requireUser(c);
59 if (!me.site_admin) throw new HttpError(403, "site administrators only");
60 return me;
61};
62
63/** One-line description of what an unsubscribe link changes. */
64function scopeText(s: UnsubScope): string {
65 if (s.t === "thread") return `Mute ${s.repo}#${s.number}`;
66 if (s.t === "repo") return `Stop watching ${s.repo}`;
67 return `Turn off email about ${CATEGORY_TEXT[s.category] ?? s.category}`;
68}
69
70const StatusBadge: FC<{ s: EmailLogRow["status"] }> = ({ s }) => <span class={s === "sent" ? "ok" : s === "failed" ? "bad" : "faint"}>{s}</span>;
71
72const EmailLog: FC<{ rows: EmailLogRow[]; showTo?: boolean }> = ({ rows, showTo }) =>
73 rows.length ? (
74 <div class="box" style="overflow:auto">
75 <table class="tbl">
76 <thead>
77 <tr>
78 <th>When</th>
79 {showTo ? <th>To</th> : null}
80 <th>Email</th>
81 <th>Status</th>
82 </tr>
83 </thead>
84 <tbody>
85 {rows.map((r) => (
86 <tr>
87 <td class="small">
88 <Time at={r.created_at} />
89 </td>
90 {showTo ? <td class="small">{r.to_addr || "—"}</td> : null}
91 <td>
92 <div>{r.subject || r.kind}</div>
93 <div class="muted small">
94 {r.kind}
95 {r.message_id ? ` · ${r.message_id}` : ""}
96 </div>
97 </td>
98 <td class="small">
99 <StatusBadge s={r.status} />
100 {r.detail ? <div class="muted small">{r.detail}</div> : null}
101 </td>
102 </tr>
103 ))}
104 </tbody>
105 </table>
106 </div>
107 ) : (
108 <p class="muted">No emails yet.</p>
109 );
110
111/** The four-column settings grid: what, on Diffinery, by email. */
112const PrefRow: FC<{ title: string; hint: string; web?: { name: string; on: boolean }; email: { name: string; on: boolean; locked?: boolean }; extra?: Child }> = (p) => (
113 <tr>
114 <td>
115 <b>{p.title}</b>
116 <div class="muted small">{p.hint}</div>
117 {p.extra}
118 </td>
119 <td>{p.web ? <KCheckbox name={p.web.name} checked={p.web.on} aria={`${p.title} on Diffinery`} /> : <span class="faint">—</span>}</td>
120 <td>
121 <KCheckbox name={p.email.name} checked={p.email.on} disabled={p.email.locked} aria={`${p.title} by email`} />
122 </td>
123 </tr>
124);
125
126async function notificationsPage(c: AppContext, me: Account): Promise<Response> {
127 const store = new MailStore(c.env.DB);
128 const dir = c.get("dir");
129 const t = csrfToken(c);
130 const [p, watches, recent, suppressed, digestNext] = await Promise.all([
131 store.prefs(me.id),
132 store.watchedRepos(me.id),
133 store.recent({ accountId: me.id, limit: 20 }),
134 me.email ? new Suppressions(c.env.DB).get(me.email) : null,
135 store.digestNext(me.id),
136 ]);
137 const repos = (await Promise.all(watches.map(async (w) => ({ level: w.level, repo: await dir.repoById(w.repo_id) })))).filter((w) => w.repo);
138 return settingsPage(
139 c,
140 me,
141 "notifications",
142 "Notifications",
143 <>
144 <h1>Notifications</h1>
145 <p class="muted">
146 Emails go to <b>{me.email ?? "no address"}</b> from {senderAddress(c.env)}. Security and account emails always send.
147 </p>
148 {suppressed ? (
149 <div class="flash error" role="alert">
150 <SuppressedNote s={suppressed} /> See <a href="/settings/email">Settings → Email</a>.
151 </div>
152 ) : null}
153 <Form action="/settings/notifications" t={t} class="box">
154 <table class="tbl">
155 <thead>
156 <tr>
157 <th>Notify me about</th>
158 <th>On Diffinery</th>
159 <th>Email</th>
160 </tr>
161 </thead>
162 <tbody>
163 <PrefRow
164 title="Participating"
165 hint="Issues and pull requests you opened, commented on, reviewed, were assigned, asked to review, @mentioned in or subscribed to."
166 web={{ name: "participating.web", on: p.participating.web }}
167 email={{ name: "participating.email", on: p.participating.email }}
168 />
169 <PrefRow title="Watching" hint="Everything else in repositories you watch with All activity." web={{ name: "watching.web", on: p.watching.web }} email={{ name: "watching.email", on: p.watching.email }} />
170 <PrefRow
171 title="Actions"
172 hint="Workflow runs you started, and scheduled runs in repositories you watch."
173 web={{ name: "actions.web", on: p.actions.web }}
174 email={{ name: "actions.email", on: p.actions.email }}
175 extra={
176 <div style="margin-top:6px">
177 <KCheckbox name="actions.failuresOnly" checked={p.actions.failuresOnly} label="Only failed runs" />
178 </div>
179 }
180 />
181 <PrefRow title="Repositories" hint="Repositories created or deleted in your organizations, and access granted to you." email={{ name: "repos.email", on: p.repos.email }} />
182 <PrefRow title="Organizations" hint="Your organization role, accepted invitations and access requests (for administrators)." email={{ name: "orgs.email", on: p.orgs.email }} />
183 <PrefRow title="Security and account" hint="New sign-ins, passkeys, tokens, agents, push protection, invitations and sign-in links." email={{ name: "security", on: true, locked: true }} />
184 </tbody>
185 </table>
186 <DigestSettings p={p} next={digestNext} />
187 <div class="body" style="border-top:1px solid var(--border)">
188 <div class="kstack">
189 <KCheckbox name="ownActivity" checked={p.ownActivity} label="Email me about my own comments and changes" />
190 <KCheckbox name="autoWatch" checked={p.autoWatch} label="Watch repositories I create (All activity)" />
191 </div>
192 <button class="btn primary">Save notification settings</button>
193 </div>
194 </Form>
195
196 <h2 class="mt">Watched repositories</h2>
197 {repos.length ? (
198 <div class="box">
199 <table class="tbl">
200 <tbody>
201 {repos.map(({ repo, level }) => (
202 <tr>
203 <td>
204 <a href={repoUrl(repo!)}>
205 {repo!.owner_login}/{repo!.name}
206 </a>
207 </td>
208 <td style="text-align:right">
209 <Form action={`${repoUrl(repo!)}/subscription`} t={t} class="inline">
210 <input type="hidden" name="back" value="/settings/notifications" />
211 <KSelect name="level" options={WATCH_OPTIONS} value={level} size="sm" label={`Watch ${repo!.owner_login}/${repo!.name}`} autosubmit />
212 </Form>
213 </td>
214 </tr>
215 ))}
216 </tbody>
217 </table>
218 </div>
219 ) : (
220 <p class="muted">You aren't watching or ignoring any repository. Use the Watch menu at the top of a repository to change that.</p>
221 )}
222
223 <h2 class="mt">Recent emails to you</h2>
224 <div class="row wrap mb" style="gap:8px">
225 <Form action="/settings/notifications/test" t={t} class="inline">
226 <button class="btn sm">Send me a test email</button>
227 </Form>
228 <Form action="/settings/notifications/digest" t={t} class="inline">
229 <button class="btn sm">Send me my digest now</button>
230 </Form>
231 </div>
232 <EmailLog rows={recent} />
233 </>,
234 );
235}
236
237/** The time zones the digest offers: every zone the runtime knows, UTC first. */
238function timeZones(current: string): KOption[] {
239 const all = ((Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.("timeZone") ?? []).filter((z) => z !== "UTC");
240 if (current !== "UTC" && !all.includes(current)) all.push(current);
241 return [{ value: BROWSER_TZ, label: "This browser's time zone" }, { value: "UTC", label: "UTC" }, ...all.map((z) => ({ value: z, label: z.replaceAll("_", " ") }))];
242}
243const BROWSER_TZ = "browser";
244const HOURS: KOption[] = Array.from({ length: 24 }, (_, h) => ({ value: String(h), label: `${String(h).padStart(2, "0")}:00` }));
245
246/** The morning digest's part of the settings form (phase 5c, digest.ts). */
247const DigestSettings: FC<{ p: NotificationPrefs; next: string | null }> = ({ p, next }) => {
248 // Someone who never chose a zone gets their browser's; after that, the zone they saved.
249 const fresh = !p.digest.email && p.digest.timeZone === "UTC";
250 return (
251 <div class="body" id="digest" style="border-top:1px solid var(--border)">
252 <h3 class="m0">Morning digest</h3>
253 <p class="muted small">Today by email, once a day at the hour you pick: what needs you, what your agents are doing, and what merged, released or failed since the last one. A day with nothing in it sends nothing.</p>
254 <div class="kstack">
255 <KCheckbox name="digest.email" checked={p.digest.email} label="Email me a morning digest" />
256 <div class="row wrap" style="gap:8px">
257 <KSelect name="digest.hour" options={HOURS} value={String(p.digest.hour)} size="sm" label="Hour" />
258 <KSelect name="digest.timeZone" options={timeZones(p.digest.timeZone)} value={fresh ? BROWSER_TZ : p.digest.timeZone} size="sm" label="Time zone" />
259 </div>
260 <KCheckbox name="digest.weekends" checked={p.digest.weekends} label="On Saturdays and Sundays too" />
261 <input type="hidden" name="digest.browserTimeZone" value="" data-browser-tz />
262 </div>
263 {p.digest.email && next ? (
264 <p class="muted small m0" style="margin-top:8px">
265 The next one goes out <Time at={next} /> ({String(p.digest.hour).padStart(2, "0")}:00 {p.digest.timeZone.replaceAll("_", " ")}).
266 </p>
267 ) : null}
268 </div>
269 );
270};
271
272function prefsFromForm(f: { b(name: string): boolean; s(name: string): string }): NotificationPrefs {
273 const tz = f.s("digest.timeZone");
274 return {
275 participating: { web: f.b("participating.web"), email: f.b("participating.email") },
276 watching: { web: f.b("watching.web"), email: f.b("watching.email") },
277 actions: { web: f.b("actions.web"), email: f.b("actions.email"), failuresOnly: f.b("actions.failuresOnly") },
278 repos: { email: f.b("repos.email") },
279 orgs: { email: f.b("orgs.email") },
280 ownActivity: f.b("ownActivity"),
281 autoWatch: f.b("autoWatch"),
282 // mergePrefs (prefs.ts) drops an hour or zone it doesn't know.
283 digest: { email: f.b("digest.email"), hour: Number(f.s("digest.hour")), timeZone: tz === BROWSER_TZ ? f.s("digest.browserTimeZone") || "UTC" : tz, weekends: f.b("digest.weekends") },
284 };
285}
286
287/** Test emails one account may send in an hour. */
288export const MAX_TEST_EMAILS_PER_HOUR = 5;
289
290/** Send a test email now (not through the queue) so the result shows right away. */
291export async function sendTest(c: AppContext, me: Account) {
292 const sent = (await new MailStore(c.env.DB).recent({ accountId: me.id, kind: "test", since: new Date(Date.now() - 3600_000).toISOString(), limit: MAX_TEST_EMAILS_PER_HOUR })).length;
293 if (sent >= MAX_TEST_EMAILS_PER_HOUR) throw new HttpError(429, `you can send at most ${MAX_TEST_EMAILS_PER_HOUR} test emails an hour; try again later`);
294 return deliver(c.env, { kind: "test", data: { by: me.login }, to: { accountId: me.id }, reason: "account", at: now() });
295}
296
297/** Digests one account may ask for in an hour, on top of the scheduled one. */
298export const MAX_DIGESTS_NOW_PER_HOUR = 3;
299
300/** Send the morning digest now (not through the queue), even when it has nothing in it and whether or not it is turned on. */
301export async function sendDigestNow(c: AppContext, me: Account) {
302 const store = new MailStore(c.env.DB);
303 const sent = (await store.recent({ accountId: me.id, kind: "digest", since: new Date(Date.now() - 3600_000).toISOString(), limit: MAX_DIGESTS_NOW_PER_HOUR + 1 })).length;
304 if (sent >= MAX_DIGESTS_NOW_PER_HOUR) throw new HttpError(429, `you can ask for your digest at most ${MAX_DIGESTS_NOW_PER_HOUR} times an hour; try again later`);
305 const at = new Date();
306 return deliver(c.env, { kind: "digest", data: { since: digestSince(await store.digestLastSent(me.id), at), now: true }, to: { accountId: me.id }, reason: "digest", at: at.toISOString(), force: true });
307}
308
309// ---------------------------------------------------------------- the suppression list (suppression.ts)
310
311export const suppressionJson = (s: Suppression) => ({ email: s.email, reason: s.reason, detail: s.detail, source: s.source, created_by: s.created_by, created_at: s.created_at, owner_can_lift: ownerCanLift(s) });
312
313const audit = (c: AppContext, me: Account, action: string, target: string, detail: unknown) => c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action, target, detail });
314
315/**
316 * Email yourself a link that lifts a bounce or complaint on your own address. It is sent now and
317 * past the suppression list: the only email that is. A manual suppression needs an administrator.
318 */
319export async function sendVerification(c: AppContext, me: Account) {
320 if (!me.email) throw new HttpError(422, "your account has no email address");
321 const s = await new Suppressions(c.env.DB).get(me.email);
322 if (!s) throw new HttpError(409, "email to your address isn't suppressed, so there is nothing to verify");
323 if (!ownerCanLift(s)) throw new HttpError(403, "a site administrator suppressed your address; ask one to remove it");
324 const sent = (await new MailStore(c.env.DB).recent({ accountId: me.id, kind: "email_verify", since: new Date(Date.now() - 3600_000).toISOString(), limit: MAX_VERIFY_EMAILS_PER_HOUR })).length;
325 if (sent >= MAX_VERIFY_EMAILS_PER_HOUR) throw new HttpError(429, `you can ask for at most ${MAX_VERIFY_EMAILS_PER_HOUR} verification emails an hour; try again later`);
326 const expiresAt = Date.now() + VERIFY_HOURS * 3600_000;
327 const url = `${c.env.PUBLIC_URL}/settings/email/verify/${await verifyToken(c.env, { accountId: me.id, email: me.email, expiresAt })}`;
328 return deliver(c.env, { kind: "email_verify", data: { email: me.email, url, expiresAt: new Date(expiresAt).toISOString() }, to: { accountId: me.id }, reason: "account", at: now() }, { ignoreSuppression: true });
329}
330
331/** Follow a verification link: lift the suppression on your own address. Null when it was already lifted. */
332export async function liftOwnSuppression(c: AppContext, me: Account, token: string): Promise<Suppression | null> {
333 const t = await readVerifyToken(c.env, token);
334 if (!t || t.accountId !== me.id) throw new HttpError(404, "this link doesn't work: it expired, or it was sent to another account");
335 if (!me.email || normalAddress(me.email) !== t.email) throw new HttpError(409, "your email address changed after this link was sent");
336 const list = new Suppressions(c.env.DB);
337 const s = await list.get(t.email);
338 if (!s) return null;
339 if (!ownerCanLift(s)) throw new HttpError(403, "a site administrator suppressed your address; ask one to remove it");
340 await list.remove(t.email);
341 await audit(c, me, "email.suppression.lift", t.email, { reason: s.reason });
342 return s;
343}
344
345/** A site administrator stops all email to an address. */
346export async function adminSuppress(c: AppContext, me: Account, email: string, note: string): Promise<Suppression> {
347 const addr = normalEmail(email);
348 if (!addr) throw new HttpError(422, "enter a valid email address");
349 const s = await new Suppressions(c.env.DB).add({ email: addr, reason: "manual", detail: note.slice(0, MAX_DETAIL) || null, source: "admin", by: me.login });
350 await audit(c, me, "email.suppression.add", addr, { reason: "manual", source: "admin", detail: s.detail });
351 return s;
352}
353
354/** A site administrator removes an address from the list, whatever put it there. */
355export async function adminUnsuppress(c: AppContext, me: Account, email: string): Promise<Suppression> {
356 const s = await new Suppressions(c.env.DB).remove(email);
357 if (!s) throw new HttpError(404, "that address isn't suppressed");
358 await audit(c, me, "email.suppression.remove", s.email, { reason: s.reason, source: s.source });
359 return s;
360}
361
362const SuppressedNote: FC<{ s: Suppression }> = ({ s }) => (
363 <>
364 Since <Time at={s.created_at} />, Diffinery sends no email to <b>{s.email}</b> because {REASON_TEXT[s.reason]}
365 {s.detail ? <> ({s.detail})</> : null}.
366 </>
367);
368
369async function emailPage(c: AppContext, me: Account): Promise<Response> {
370 const t = csrfToken(c);
371 const s = me.email ? await new Suppressions(c.env.DB).get(me.email) : null;
372 return settingsPage(
373 c,
374 me,
375 "email-address",
376 "Email",
377 <>
378 <h1>Email</h1>
379 <div class="box">
380 <div class="head">
381 <h2>{me.email ?? "No email address"}</h2>
382 {me.email ? s ? <span class="badge red">suppressed</span> : <span class="badge green">receiving email</span> : null}
383 </div>
384 <div class="body">
385 {!me.email ? (
386 <p class="m0">Your account has no email address, so Diffinery can't email you.</p>
387 ) : !s ? (
388 <p class="m0">
389 Diffinery emails this address from {senderAddress(c.env)}. Choose what you get in <a href="/settings/notifications">notification settings</a>.
390 </p>
391 ) : (
392 <>
393 <p>
394 <SuppressedNote s={s} /> That includes sign-in links, account recovery and security alerts.
395 </p>
396 {ownerCanLift(s) ? (
397 <>
398 <p class="muted">If the inbox works again, or the spam report was a mistake, confirm the address: we send one email to it, and following its link while you're signed in turns email back on.</p>
399 <Form action="/settings/email/verify" t={t}>
400 <button class="btn primary">Send a verification email</button>
401 </Form>
402 </>
403 ) : (
404 <p class="muted m0">Only a site administrator can lift a suppression they added. Ask one.</p>
405 )}
406 </>
407 )}
408 </div>
409 </div>
410 </>,
411 );
412}
413
414async function readToken(c: AppContext): Promise<{ t: UnsubToken; account: Account } | null> {
415 const t = await readUnsubToken(c.env, prm(c, "token")).catch(() => null);
416 if (!t) return null;
417 const account = await c.get("dir").account(t.accountId);
418 if (!account || account.status === "disabled") return null;
419 return { t, account };
420}
421
422async function applyUnsub(c: AppContext, t: UnsubToken, undo = false): Promise<void> {
423 const store = new MailStore(c.env.DB);
424 const s = t.scope;
425 if (s.t === "category") {
426 if (!(s.category in CATEGORY_TEXT)) throw new HttpError(400, "unknown kind of email");
427 const p = await store.prefs(t.accountId);
428 (p[s.category] as { email: boolean }).email = undo;
429 await store.setPrefs(t.accountId, p);
430 } else if (s.t === "thread") {
431 if (undo) await store.clearThreadSub(t.accountId, s.repoId, s.number);
432 else await store.setThreadSub(t.accountId, s.repoId, s.number, false);
433 } else if (s.t === "repo") {
434 await store.setWatch(t.accountId, s.repoId, undo ? "all" : "participating");
435 }
436}
437
438const UnsubPage: FC<{ title: string; children?: Child }> = ({ title, children }) => (
439 <div style="max-width:560px;margin:40px auto">
440 <div class="box">
441 <div class="body">
442 <h1 style="margin-top:0">{title}</h1>
443 {children}
444 </div>
445 </div>
446 </div>
447);
448
449export function registerMailWeb(web: WebApp): void {
450 // ------------------------------------------------------------ settings
451
452 web.get("/settings/notifications", async (c) => notificationsPage(c, requireUser(c)));
453
454 post(web, "/settings/notifications", async (c, f) => {
455 const me = requireUser(c);
456 return act(c, "/settings/notifications", () => new MailStore(c.env.DB).setPrefs(me.id, prefsFromForm(f)), "Notification settings saved.");
457 });
458
459 post(web, "/settings/notifications/test", async (c) => {
460 const me = requireUser(c);
461 const r = await sendTest(c, me).catch((e) => {
462 if (e instanceof HttpError && e.status === 429) return { status: "refused", detail: e.message };
463 throw e;
464 });
465 if (r.status === "sent") flash(c, "notice", `Test email sent to ${me.email}.`);
466 else flash(c, "error", `The test email was ${r.status}: ${r.detail ?? "unknown reason"}`);
467 return c.redirect("/settings/notifications", 303);
468 });
469
470 post(web, "/settings/notifications/digest", async (c) => {
471 const me = requireUser(c);
472 const r = await sendDigestNow(c, me).catch((e) => {
473 if (e instanceof HttpError && e.status === 429) return { status: "refused", detail: e.detail };
474 throw e;
475 });
476 if (r.status === "sent") flash(c, "notice", `Your digest was sent to ${me.email}.`);
477 else flash(c, "error", `The digest was ${r.status}: ${r.detail ?? "unknown reason"}`);
478 return c.redirect("/settings/notifications", 303);
479 });
480
481 // ------------------------------------------------------------ Settings → Email: suppressed or not, and verifying the address again
482
483 web.get("/settings/email", async (c) => emailPage(c, requireUser(c)));
484
485 post(web, "/settings/email/verify", async (c) => {
486 const me = requireUser(c);
487 return act(c, "/settings/email", async () => {
488 const r = await sendVerification(c, me);
489 if (r.status === "sent") flash(c, "notice", `We sent a verification email to ${me.email}. Follow its link within ${VERIFY_HOURS} hours, signed in as ${me.login}.`);
490 else flash(c, "error", `The verification email was ${r.status}: ${r.detail ?? "unknown reason"}`);
491 });
492 });
493
494 // The emailed link opens a page with a button: mail scanners that fetch links don't lift anything.
495 web.get("/settings/email/verify/:token", async (c) => {
496 const me = requireUser(c);
497 const t = await readVerifyToken(c.env, prm(c, "token"));
498 const ok = !!t && t.accountId === me.id;
499 return render(
500 c,
501 { title: "Confirm your email address", status: ok ? 200 : 404 },
502 ok ? (
503 <UnsubPage title="Confirm your email address">
504 <p>
505 Turn email back on for <b>{t!.email}</b>, the address of <b>{me.login}</b>.
506 </p>
507 <Form action={c.req.path} t={csrfToken(c)}>
508 <button class="btn primary">Confirm and get email again</button>
509 </Form>
510 </UnsubPage>
511 ) : (
512 <UnsubPage title="This link doesn't work">
513 <p>
514 It expired, or it was sent to another account. Ask for a new one in <a href="/settings/email">Settings → Email</a>.
515 </p>
516 </UnsubPage>
517 ),
518 );
519 });
520
521 post(web, "/settings/email/verify/:token", async (c) => {
522 const me = requireUser(c);
523 return act(c, "/settings/email", async () => {
524 const s = await liftOwnSuppression(c, me, prm(c, "token"));
525 flash(c, "notice", s ? `Confirmed. Diffinery emails ${me.email} again.` : `${me.email} wasn't suppressed any more.`);
526 });
527 });
528
529 // ------------------------------------------------------------ unsubscribe (no sign-in: the signed link is the permission)
530
531 web.get("/-/unsubscribe/:token", async (c) => {
532 const x = await readToken(c);
533 if (!x) return render(c, { title: "Unsubscribe", status: 404 }, <UnsubPage title="This link doesn't work">
534 <p>It may be incomplete or the account no longer exists. You can change what you get by email in <a href="/settings/notifications">notification settings</a>.</p>
535 </UnsubPage>);
536 return render(
537 c,
538 { title: "Unsubscribe" },
539 <UnsubPage title={scopeText(x.t.scope)}>
540 <p class="muted">
541 For <b>{x.account.login}</b>.
542 </p>
543 <form method="post" action={c.req.path}>
544 <button class="btn primary">{scopeText(x.t.scope)}</button>
545 </form>
546 <p class="muted small mt">
547 Or choose exactly what you get in <a href="/settings/notifications">notification settings</a>.
548 </p>
549 </UnsubPage>,
550 );
551 });
552
553 // Mail apps send `List-Unsubscribe=One-Click` here (RFC 8058); the button above posts an empty form.
554 web.post("/-/unsubscribe/:token", async (c) => {
555 const x = await readToken(c);
556 const form = await formData(c).catch(() => null);
557 const oneClick = form?.s("List-Unsubscribe").toLowerCase() === "one-click";
558 if (!x) return oneClick ? c.text("invalid link", 404) : c.redirect(c.req.path, 303);
559 await applyUnsub(c, x.t);
560 if (oneClick) return c.text("Unsubscribed.");
561 return render(
562 c,
563 { title: "Unsubscribed" },
564 <UnsubPage title="Done">
565 <p>{scopeText(x.t.scope)}: done. You won't get these emails any more.</p>
566 <form method="post" action={`${c.req.path}/undo`} class="inline">
567 <button class="btn">Undo</button>
568 </form>{" "}
569 <a class="btn" href="/settings/notifications">
570 Notification settings
571 </a>
572 </UnsubPage>,
573 );
574 });
575
576 web.post("/-/unsubscribe/:token/undo", async (c) => {
577 const x = await readToken(c);
578 if (!x) return c.redirect(`/-/unsubscribe/${prm(c, "token")}`, 303);
579 await applyUnsub(c, x.t, true);
580 return render(
581 c,
582 { title: "Undone" },
583 <UnsubPage title="Undone">
584 <p>You'll get these emails again.</p>
585 <a class="btn" href="/settings/notifications">
586 Notification settings
587 </a>
588 </UnsubPage>,
589 );
590 });
591
592 // ------------------------------------------------------------ watching and thread subscriptions
593
594 post(web, "/:owner/:repo/subscription", async (c, f) => {
595 const me = requireUser(c);
596 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read", { whileArchived: true, whileImporting: true });
597 const back = f.s("back") === "/settings/notifications" ? "/settings/notifications" : repoUrl(l.repo);
598 const level = WATCH_LEVELS.find((x) => x === f.s("level")) as WatchLevel | undefined;
599 if (!level) throw new HttpError(422, "pick participating, all or ignore");
600 const label = WATCH_OPTIONS.find((o) => o.value === level)!.label;
601 return act(c, back, () => new MailStore(c.env.DB).setWatch(me.id, l.repo.id, level), `Watching ${l.repo.owner_login}/${l.repo.name}: ${label}.`);
602 });
603
604 post(web, "/:owner/:repo/issues/:number/subscription", async (c, f) => {
605 const me = requireUser(c);
606 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read", { whileArchived: true, whileImporting: true });
607 const n = Number(prm(c, "number"));
608 if (!Number.isInteger(n) || n < 1) throw new HttpError(404, "not found");
609 const on = f.s("subscribed") === "1";
610 return act(c, `${repoUrl(l.repo)}/issues/${n}`, () => new MailStore(c.env.DB).setThreadSub(me.id, l.repo.id, n, on), on ? "Subscribed. You'll be notified about all activity." : "Unsubscribed. You'll still be notified if you're @mentioned, assigned or asked to review.");
611 });
612
613 // ------------------------------------------------------------ site administration: Email
614
615 web.get("/settings/admin/email", async (c) => {
616 const me = requireSiteAdmin(c);
617 const store = new MailStore(c.env.DB);
618 const t = csrfToken(c);
619 const since = new Date(Date.now() - 86400_000).toISOString();
620 const [counts, rows, suppressed] = await Promise.all([store.counts(since), store.recent({ limit: 100 }), new Suppressions(c.env.DB).list()]);
621 const samples = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login);
622 const pick = samples.find((s) => s.id === c.req.query("sample")) ?? samples[0]!;
623 return settingsPage(
624 c,
625 me,
626 "email",
627 "Email",
628 <>
629 <h1>Email</h1>
630 <div class="box">
631 <div class="body">
632 <dl class="kv m0">
633 <dt>Sending</dt>
634 <dd>{c.env.EMAIL ? <span class="ok">Cloudflare Email Sending is bound</span> : <span class="bad">not configured (no EMAIL binding): emails are logged, not sent</span>}</dd>
635 <dt>Queue</dt>
636 <dd>{c.env.MAIL ? "diffinery-mail" : <span class="bad">not bound: emails are dropped</span>}</dd>
637 <dt>From</dt>
638 <dd>{senderAddress(c.env)}</dd>
639 <dt>Bounces and complaints</dt>
640 <dd>
641 From the diffinery-mail-events queue (Email Sending event subscriptions) · signed endpoint {c.env.MAIL_EVENTS_SECRET ? <span class="ok">on</span> : <span class="faint">off (no MAIL_EVENTS_SECRET)</span>}
642 </dd>
643 <dt>Last 24 hours</dt>
644 <dd>
645 <span class="ok">{counts.sent} sent</span> · <span class={counts.failed ? "bad" : "faint"}>{counts.failed} failed</span> · <span class="faint">{counts.skipped} skipped</span>
646 </dd>
647 </dl>
648 </div>
649 </div>
650 <div class="row mt mb">
651 <Form action="/settings/notifications/test" t={t} class="inline">
652 <button class="btn">Send me a test email</button>
653 </Form>
654 <Form action="/settings/admin/email/samples" t={t} class="inline" confirm={`Send all ${samples.length} templates to ${me.email}?`}>
655 <button class="btn">Send every template to me</button>
656 </Form>
657 </div>
658
659 <h2 id="suppressions">Suppressed addresses</h2>
660 <p class="muted">
661 Diffinery sends nothing to these addresses, sign-in links included. Bounces and spam complaints add them; people lift a bounce or complaint on their own address by verifying it in Settings → Email. Adding and removing is in the <a href="/settings/audit">audit log</a>.
662 </p>
663 <div class="box">
664 {suppressed.length ? (
665 <table class="tbl">
666 <thead>
667 <tr>
668 <th>Address</th>
669 <th>Why</th>
670 <th>Since</th>
671 <th></th>
672 </tr>
673 </thead>
674 <tbody>
675 {suppressed.map((x) => (
676 <tr>
677 <td>{x.email}</td>
678 <td class="small">
679 {x.reason}
680 <div class="muted small">
681 {x.source === "admin" ? `by ${x.created_by ?? "an administrator"}` : x.source === "send" ? "Email Sending refused a send" : `from ${x.source}`}
682 {x.detail ? ` · ${x.detail}` : ""}
683 </div>
684 </td>
685 <td class="small">
686 <Time at={x.created_at} />
687 </td>
688 <td style="text-align:right">
689 <Form action="/settings/admin/email/suppressions/remove" t={t} class="inline" confirm={`Send email to ${x.email} again?`}>
690 <input type="hidden" name="email" value={x.email} />
691 <button class="btn sm">Remove</button>
692 </Form>
693 </td>
694 </tr>
695 ))}
696 </tbody>
697 </table>
698 ) : (
699 <div class="body">
700 <p class="muted m0">No address is suppressed.</p>
701 </div>
702 )}
703 </div>
704 <Form action="/settings/admin/email/suppressions" t={t} class="box mb">
705 <div class="body">
706 <div class="formrow">
707 <div class="field">
708 <label for="sup-email">Suppress an address</label>
709 <input id="sup-email" name="email" type="email" required placeholder="someone@example.com" autocomplete="off" />
710 </div>
711 <div class="field grow">
712 <label for="sup-note">Note</label>
713 <input id="sup-note" name="note" maxlength={MAX_DETAIL} placeholder="e.g. asked us to stop all email" />
714 </div>
715 <button class="btn">Suppress</button>
716 </div>
717 </div>
718 </Form>
719
720 <h2>Templates</h2>
721 <form method="get" action="/settings/admin/email" class="row mb">
722 <KSelect name="sample" options={samples.map((s) => ({ value: s.id, label: s.label }))} value={pick.id} label="Template" autosubmit />
723 <a class="btn" href={`/settings/admin/email/preview/${pick.id}`} target="_blank" rel="noopener">
724 Open HTML
725 </a>
726 <a class="btn" href={`/settings/admin/email/preview/${pick.id}?format=text`} target="_blank" rel="noopener">
727 Plain text
728 </a>
729 </form>
730 <iframe src={`/settings/admin/email/preview/${pick.id}`} title={`Preview: ${pick.label}`} style="width:100%;height:720px;border:1px solid var(--border);border-radius:8px;background:#fff" sandbox="" />
731
732 <h2 class="mt">Recent emails</h2>
733 <EmailLog rows={rows} showTo />
734 </>,
735 );
736 });
737
738 post(web, "/settings/admin/email/suppressions", async (c, f) => {
739 const me = requireSiteAdmin(c);
740 return act(c, "/settings/admin/email#suppressions", async () => {
741 const s = await adminSuppress(c, me, f.s("email"), f.s("note").trim());
742 flash(c, "notice", `Diffinery sends no email to ${s.email} now.`);
743 });
744 });
745
746 post(web, "/settings/admin/email/suppressions/remove", async (c, f) => {
747 const me = requireSiteAdmin(c);
748 return act(c, "/settings/admin/email#suppressions", async () => {
749 const s = await adminUnsuppress(c, me, f.s("email"));
750 flash(c, "notice", `Removed ${s.email} from the suppression list: it gets email again.`);
751 });
752 });
753
754 web.get("/settings/admin/email/preview/:id", async (c) => {
755 const me = requireSiteAdmin(c);
756 const s = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login).find((x) => x.id === c.req.param("id"));
757 if (!s) throw new HttpError(404, "no such template");
758 const msg = await buildMessage(c.env, s.job, { email: me.email ?? "you@example.com", name: me.name || undefined, login: me.login, accountId: me.id });
759 c.header("content-security-policy", "default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; frame-ancestors 'self'; base-uri 'none'; form-action 'none'");
760 c.header("x-frame-options", "SAMEORIGIN");
761 c.header("x-content-type-options", "nosniff");
762 c.header("cache-control", "private, no-store");
763 if (c.req.query("format") === "text") {
764 const head = [`From: ${msg.from.name} <${msg.from.email}>`, `Subject: ${msg.subject}`, ...Object.entries(msg.headers).map(([k, v]) => `${k}: ${v}`)].join("\n");
765 return c.text(`${head}\n\n${msg.text}`);
766 }
767 return c.html(msg.html);
768 });
769
770 post(web, "/settings/admin/email/samples", async (c) => {
771 const me = requireSiteAdmin(c);
772 const jobs = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login).map((s) => ({ ...s.job, force: true }) as MailJob);
773 await enqueueMail(c.env, jobs);
774 flash(c, "notice", `Queued ${jobs.length} emails to ${me.email}. They show up under Recent emails as they're sent.`);
775 return c.redirect("/settings/admin/email", 303);
776 });
777}