| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import type { Child, FC } from "hono/jsx"; |
| 3 | import { requireAccount, type AppContext } from "../auth/principal.js"; |
| 4 | import type { Account } from "../lib/directory.js"; |
| 5 | import { loadRepo } from "../http/common.js"; |
| 6 | import { HttpError, now } from "../lib/util.js"; |
| 7 | import { ADMIN_TABS, SettingsNav, type SettingsTab } from "../web/account.js"; |
| 8 | import { ComputePausedBanner } from "../web/kill-switches.js"; |
| 9 | import { csrfToken, formData } from "../web/csrf.js"; |
| 10 | import { KCheckbox, KSelect, type KOption } from "../web/kumo.js"; |
| 11 | import { digestSince } from "./digest.js"; |
| 12 | import { Form, Time, WATCH_OPTIONS, act, flash, post, prm, render, repoUrl, type WebApp } from "../web/ui.js"; |
| 13 | import { MailStore, WATCH_LEVELS, type EmailLogRow, type NotificationPrefs, type WatchLevel } from "./prefs.js"; |
| 14 | import { sampleJobs } from "./samples.js"; |
| 15 | import { buildMessage, deliver, enqueueMail, senderAddress } from "./send.js"; |
| 16 | import type { Category, MailJob } from "./types.js"; |
| 17 | import { readUnsubToken, type UnsubScope, type UnsubToken } from "./unsubscribe.js"; |
| 18 | import { normalEmail } from "../auth/signup.js"; |
| 19 | import { 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 | |
| 29 | const 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 | |
| 38 | const 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 | |
| 51 | const 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 | |
| 57 | const 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. */ |
| 64 | function 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 | |
| 70 | const StatusBadge: FC<{ s: EmailLogRow["status"] }> = ({ s }) => <span class={s === "sent" ? "ok" : s === "failed" ? "bad" : "faint"}>{s}</span>; |
| 71 | |
| 72 | const 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. */ |
| 112 | const 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 | |
| 126 | async 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. */ |
| 238 | function 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 | } |
| 243 | const BROWSER_TZ = "browser"; |
| 244 | const 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). */ |
| 247 | const 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 | |
| 272 | function 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. */ |
| 288 | export const MAX_TEST_EMAILS_PER_HOUR = 5; |
| 289 | |
| 290 | /** Send a test email now (not through the queue) so the result shows right away. */ |
| 291 | export 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. */ |
| 298 | export 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. */ |
| 301 | export 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 | |
| 311 | export 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 | |
| 313 | const 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 | */ |
| 319 | export 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. */ |
| 332 | export 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. */ |
| 346 | export 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. */ |
| 355 | export 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 | |
| 362 | const 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 | |
| 369 | async 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 | |
| 414 | async 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 | |
| 422 | async 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 | |
| 438 | const 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 | |
| 449 | export 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 | } |