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.

Morning digest: Today by email at the hour you choose, and Today on phones (phase 5c) (#168)

* WIP: morning digest (phase 5c)

* WIP 5c: docs and e2e

* 5c e2e: count digests after the template gallery's sample

* Docs: what a hang-up mid-answer costs, and when its slot frees

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 7dd2e1ccommit c67c4e395a49a9c41450fa027d5b77d600f2e77fBrowse files
22 files changed+869−50
Files (22)
modifiedapps/diffinery/docs/accounts/notifications.md+23−2View file
@@ -53,6 +53,7 @@
5353 | Actions | Workflow runs you started (failed runs only, unless you turn off **Only failed runs**), and scheduled runs in repositories you watch | Yes | On, failures only |
5454 | Repositories | Repositories created or deleted in your organizations (for owners and admins), your own new repositories, and repository access granted, changed or removed | Yes | On |
5555 | Organizations | Your organization role added, changed or removed (with who did it), invitations you sent being accepted, and access requests (for site administrators) | Yes | On |
56+| Morning digest | [Today](/docs/agents/today) by email, once a day at the hour you choose | Yes | Off |
5657
5758 ## Notification settings
5859
@@ -74,9 +75,17 @@
7475 - **Email me about my own comments and changes** (off by default). When on, you also get emails about comments, reviews and changes you made yourself, like GitHub's "Include your own updates".
7576 - **Watch repositories I create (All activity)** (on by default). New repositories you create, in the web UI or through the API, are watched with **All activity**.
7677
78+Under **Morning digest**:
79+
80+- **Email me a morning digest** (off by default).
81+- **Hour** and **Time zone**: when it goes out, in your time zone. Until you save one, the time zone is your browser's.
82+- **On Saturdays and Sundays too** (off by default).
83+
84+When the digest is on, the page says when the next one goes out. See [The morning digest](/docs/agents/today#the-morning-digest).
85+
7786 Click **Save notification settings**. You see "Notification settings saved."
7887
79-The same page lists **Watched repositories** (every repository you watch or ignore, with a menu to change the level) and **Recent emails to you** (the last 20, with their status), and has a **Send me a test email** button.
88+The same page lists **Watched repositories** (every repository you watch or ignore, with a menu to change the level) and **Recent emails to you** (the last 20, with their status), and has **Send me a test email** and **Send me my digest now** buttons.
8089
8190 ## Who gets notified about issues and pull requests
8291
@@ -220,6 +229,7 @@
220229 | `admin` | You are an administrator (of the organization, repository or instance). |
221230 | `account` | You have a Diffinery account (account and membership changes). |
222231 | `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). |
232+| `digest` | You turned on the morning digest in your notification settings. |
223233
224234 ## When Diffinery stops emailing you
225235
@@ -263,6 +273,7 @@
263273 | `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you |
264274 | `review_limit_reached` | `[acme/website] Agent review reached its daily limit` | Account | `your_activity` | [Agent review](/docs/agents/review) on a repository whose reviews use your model key reached its daily limit (at most once a day per repository) |
265275 | `test` | `[Diffinery] Test email` | Account | `account` | You clicked **Send me a test email** (or used the API) |
276+| `digest` | `[Diffinery] Today: 2 need you, 3 merged` | Morning digest | `digest` | At the hour you chose, when something needs you or happened since the last one; or when you click **Send me my digest now** |
266277 | `invite_accepted` | `[Diffinery] @diffy accepted your invitation` | Organizations | `account` | Someone accepted an invite you created |
267278 | `access_request` | `[Diffinery] newcomer@example.com asked for access` | Organizations | `admin` | A person waiting for an invite requested access (sent to site administrators) |
268279 | `org_membership` | `[Diffinery] You're now a member of acme` | Organizations | `account` | Your organization role was added, changed or removed by an owner or admin. The email names who added you ("Added by") or who changed your role ("Changed by"). |
@@ -316,6 +327,7 @@
316327 | `DELETE /api/v3/repos/{owner}/{repo}/subscription` | Back to participating | `write` or `admin` |
317328 | `GET /api/v3/diffinery/emails` | Your recent emails: `kind`, `subject`, `status` (`sent`, `failed` or `skipped`), `detail`, `message_id`, `created_at`. Query: `kind`, `since` (ISO time), `per_page` (default 50, up to 500) | any |
318329 | `POST /api/v3/diffinery/emails/test` | Send yourself a test email now | `write` or `admin`, all repositories |
330+| `POST /api/v3/diffinery/emails/digest` | Send yourself the morning digest now, even when it is off or has nothing in it (3 an hour) | `write` or `admin`, all repositories |
319331 | `GET /api/v3/diffinery/email` | Your email address and whether it is suppressed | any |
320332 | `POST /api/v3/diffinery/email/verification` | Send the email that lifts a bounce or complaint on your address | `write` or `admin`, all repositories |
321333
@@ -329,10 +341,19 @@
329341 "repos": { "email": true },
330342 "orgs": { "email": true },
331343 "ownActivity": false,
332- "autoWatch": true
344+ "autoWatch": true,
345+ "digest": { "email": false, "hour": 8, "timeZone": "UTC", "weekends": false }
333346 }
334347 ```
335348
349+`digest.hour` is 0 to 23 and `digest.timeZone` an IANA time zone, such as `Europe/Istanbul`; anything else returns `422`. To get the digest at 7:00 in Istanbul:
350+
351+```sh
352+curl -X PUT https://diffinery.com/api/v3/diffinery/notification-settings \
353+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
354+ -d '{"digest": {"email": true, "hour": 7, "timeZone": "Europe/Istanbul"}}'
355+```
356+
336357 For example, to stop watching emails and get every workflow run result:
337358
338359 ```sh
modifiedapps/diffinery/docs/agents/organizations.md+1−0View file
@@ -69,6 +69,7 @@
6969 - While an allowance is set, a person has at most 4 model requests on the organization's keys on their way at once, across all their runs. A fifth gets `429` with `retry-after`, and the agent's CLI tries again.
7070 - So requests already on their way can take a person past the allowance by at most 4 requests. Each one's output is capped at what it booked; its input can cost more than its estimate when it tokenizes densely or carries images or PDFs, which bill by pixels and pages.
7171 - A request still streaming after 15 minutes stops counting toward the 4.
72+- If the agent hangs up while an answer is still streaming, Diffinery keeps reading it for about 30 seconds. An answer that takes longer stays charged at its booking, and its place among the 4 frees after 15 minutes.
7273 - An answer that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap.
7374 - A request books at most 1,000,000 output tokens, whatever cap it asks for, and is sent with that cap.
7475 - Runs on a person's own key don't count.
modifiedapps/diffinery/docs/agents/review.md+1−1View file
@@ -116,7 +116,7 @@
116116 Diffinery counts tokens itself, as requests pass through the model proxy:
117117
118118 - Before a request goes to the provider, the proxy books the most it may cost: a token per 3 bytes of request, plus the output it allows (its `max_tokens` or the endpoint's equivalent, or 32,000 when it sets none; a Codex compaction, which can't be capped, books 128,000). If that could take the review past its budget, the request is refused and the review stops. A review has at most 4 requests on their way at once (a fifth gets `429` and is tried again), so requests sent at once can overrun its budget by at most that many: each one's output is capped at what it booked, and only its input can cost more than its estimate (dense text, or images and PDFs, which bill by pixels and pages).
119-- Once the response has streamed through, the booking is replaced by the usage the provider reports: input, output and cache writes in full, cache reads as a tenth, since providers bill them at about a tenth. A response that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap. An error response costs nothing.
119+- Once the response has streamed through, the booking is replaced by the usage the provider reports: input, output and cache writes in full, cache reads as a tenth, since providers bill them at about a tenth. A response that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap. An error response costs nothing. If the agent hangs up while an answer is still streaming, Diffinery keeps reading it for about 30 seconds; an answer that takes longer stays charged at its booking, and its place among the 4 frees after 15 minutes.
120120 - A review can't start background responses, pull in input its request doesn't carry (`previous_response_id`, `conversation`, `prompt` or `item_reference` inputs, an Anthropic `container`, or images, documents and files given by URL or file id), send a body that isn't a JSON object, ask for more than one answer (`n`), use an OpenRouter web search model (`:online`) or preset, read back stored responses, or set routing and paid extras (`models`, `route`, `provider`, `plugins`, `web_search_options`, `speed`, `best_of`, `preset`). A review through OpenRouter asks only for its configured model. A review's requests keep only tools the agent runs itself (function and custom tools, and client-run ones such as bash, text editor, memory, a local shell and apply patch); every tool that runs on the provider is stripped, along with MCP servers, so web search, web fetch, code execution, file search, image generation and any server tool a provider adds later never reach it. Claude Code reviews also run with WebSearch and WebFetch turned off, and Codex reviews with web search disabled. A review's request is sent with every output cap set to the one booked (the largest it set, or 32,000 when it set none), so its booking holds.
121121
122122 Every run's model requests and tokens show in its transcript and in the API's `runs` (`model_requests`, `tokens`).
modifiedapps/diffinery/docs/agents/today.md+23−2View file
@@ -5,13 +5,13 @@
55
66 **Today** is the first page you see after signing in to Diffinery. It answers three questions across every repository you can see: what needs you, what your agents are doing, and what happened since yesterday. Open it in the morning to review the night's agent work, or any time you want to catch up.
77
8-The same view is available to scripts and agents from the REST API and the `today` MCP tool, so your own coding agent can read your morning too.
8+The same view is available to scripts and agents from the REST API and the `today` MCP tool, so your own coding agent can read your morning too. It can also come to you by email as a [morning digest](#the-morning-digest), and it fits a phone's screen.
99
1010 ## What it shows
1111
1212 At the top, four numbers link to the sections below them: how many items need you, how many agent pull requests are open, how many pull requests merged since yesterday (and how many of them were by agents), and how many workflow runs failed.
1313
14-A side column lists your repositories: those you or your organizations own, up to 40. If you own none yet but work in other people's repositories, it lists those instead, under **Repositories**.
14+A side column lists your repositories: those you or your organizations own, up to 40. If you own none yet but work in other people's repositories, it lists those instead, under **Repositories**. On a phone, Today comes first and the repository list after it.
1515
1616 ### Needs you
1717
@@ -58,6 +58,27 @@
5858
5959 [Agent tasks](/docs/agents/tasks) you started that are running or changed in the last 24 hours, with their state and pull request. The section appears only when you have some.
6060
61+## The morning digest
62+
63+Turn on **Email me a morning digest** in **Settings → Notifications** to get Today by email once a day. Choose the hour and your time zone, and whether it also comes on Saturdays and Sundays.
64+
65+The digest has the same sections as the page, each with up to 8 lines and a link to the rest:
66+
67+- **Needs you** and **Deployments waiting for you**, as they are when it is sent.
68+- **Agents at work**: your agents' open pull requests, without drafts.
69+- **Your agent tasks**: running, or changed since the last digest.
70+- **Failed runs**, **Merged** and **Released**, since the last digest.
71+
72+"Since the last digest" covers a weekend when weekends are off, and at most 7 days. A first digest covers the last 24 hours.
73+
74+- **When nothing needs you and nothing happened, no digest is sent.** The email log on the settings page lists it as skipped, with the reason.
75+- The digest is gathered when it is sent, so it shows what is true then.
76+- It goes out within 5 minutes of the hour.
77+- **Turning it off:** use **Unsubscribe from emails like this** in its footer or your mail app's unsubscribe button. That turns off only the digest.
78+- **Send me my digest now**, next to the test email button, sends it at once, even when it is off or empty. You can do that 3 times an hour.
79+
80+Like every email, the digest isn't sent to an address on the suppression list (see [When Diffinery stops emailing you](/docs/accounts/notifications#when-diffinery-stops-emailing-you)). Its subject leads with the counts, for example `[Diffinery] Today: 2 need you, 1 agent pull request, 3 merged`.
81+
6182 ## Which repositories it covers
6283
6384 Today looks at the repositories you can see that had activity in the last 30 days, such as a push, a pull request, a review, a workflow run or a release, most recent first, up to 100 of them. Archived repositories are left out. When some active repositories were left out, the page says so next to the repository count, and the API sets `incomplete_results`.
modifiedapps/diffinery/docs/reference/limits.md+1−0View file
@@ -363,6 +363,7 @@
363363 | Recipients per event | 200 | At most 200 people are notified about one event, in the app or by email. |
364364 | Invitations | 50 per person per 24 hours | Invitations to an organization, a repository or the site, counted per inviter. The 51st answers `429 you can send at most 50 invitations in 24 hours; try again later`. Site administrators aren't limited. |
365365 | Test emails | 5 per account per hour | **Send me a test email** and `POST /api/v3/diffinery/emails/test`; the 6th answers `429 you can send at most 5 test emails an hour; try again later`. |
366+| Morning digest | Once a day at the hour you choose, within 5 minutes; up to 8 lines per section | **Send me my digest now** and `POST /api/v3/diffinery/emails/digest`: 3 an hour, then `429 you can ask for your digest at most 3 times an hour; try again later`. "Since the last digest" reaches back at most 7 days. |
366367 | Images in emails | Linked, not loaded | An image in issue, pull request or comment text that isn't on Diffinery itself shows in the email as a link (`[alt text]`), so opening the email doesn't tell the image's host that you read it. |
367368
368369 ## Platform limits for operators
addedapps/diffinery/migrations/0032_mail_digests.sql+9−0View file
@@ -1,0 +1,9 @@
1+-- The morning digest (phase 5c, mail/digest.ts): when each person who turned it on gets their next
2+-- one. Their choices (hour, time zone, weekends) live in notification_prefs; this row is only the
3+-- schedule, written whenever those settings are saved and moved on by the cron after each send.
4+CREATE TABLE IF NOT EXISTS mail_digests (
5+ account_id TEXT PRIMARY KEY REFERENCES accounts(id),
6+ next_at TEXT NOT NULL, -- the next send, UTC ISO
7+ last_sent_at TEXT -- the last one queued; the next digest covers what happened since
8+);
9+CREATE INDEX IF NOT EXISTS mail_digests_next ON mail_digests(next_at);
modifiedapps/diffinery/src/accounts/lifecycle.ts+1−0View file
@@ -99,6 +99,7 @@
9999 db.prepare("DELETE FROM invites WHERE accepted_at IS NULL AND (account_id = ? OR invited_by = ?)").bind(account.id, account.id),
100100 forIds("DELETE FROM notifications WHERE account_id IN (?)"),
101101 forMe("DELETE FROM notification_prefs WHERE account_id = ?"),
102+ forMe("DELETE FROM mail_digests WHERE account_id = ?"),
102103 forMe("DELETE FROM repo_watches WHERE account_id = ?"),
103104 forMe("DELETE FROM thread_subscriptions WHERE account_id = ?"),
104105 forMe("DELETE FROM known_devices WHERE account_id = ?"),
addedapps/diffinery/src/agentic/digest.ts+72−0View file
@@ -1,0 +1,72 @@
1+import type { Env } from "../env.js";
2+import type { Account, Directory } from "../lib/directory.js";
3+import { localDate } from "../mail/digest.js";
4+import type { DigestItem, DigestSection, DigestSummary } from "../mail/types.js";
5+import { AGENT_TEXT, NEEDS_TEXT, TASK_TEXT, gatherToday, type Today } from "./today.js";
6+
7+/**
8+ * The morning digest's content (phase 5c): Today (./today.ts) for one person, cut down to what fits
9+ * in an email. The schedule and sending are in mail/digest.ts and mail/send.ts.
10+ */
11+
12+/** Lines per section; the email links to Today for the rest. */
13+export const DIGEST_MAX_ITEMS = 8;
14+
15+const short = (s: string, n = 120) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
16+
17+/** Today as the digest's sections, in the order the page shows them. Pure, so it is unit tested. */
18+export function digestSummary(base: string, t: Today, date: string): DigestSummary {
19+ const section = (id: DigestSection["id"], title: string, all: DigestItem[]): DigestSection => ({ id, title, total: all.length, items: all.slice(0, DIGEST_MAX_ITEMS) });
20+ const pull = (repo: { fullName: string; url: string }, n: number) => ({ url: `${base}${repo.url}/pull/${n}`, where: `${repo.fullName}#${n}` });
21+ const working = t.agentsAtWork.filter((x) => x.state !== "draft");
22+ const sections = [
23+ section(
24+ "needs_you",
25+ "Needs you",
26+ t.needsYou.map((x) => ({ title: short(x.pull.title), ...pull(x.repo, x.pull.number), note: `${NEEDS_TEXT[x.kind].label} · by ${x.pull.author.login}`, mark: x.kind === "checks_failing" ? "fail" : "dot" })),
27+ ),
28+ section(
29+ "deployments",
30+ "Deployments waiting for you",
31+ t.deployments.map((x) => ({ title: `${x.deployment.environment}: ${short(x.deployment.title || x.deployment.workflow)}`, url: `${base}${x.repo.url}/actions/runs/${x.deployment.runId}`, where: x.repo.fullName, note: `${x.deployment.workflow} #${x.deployment.runNumber}`, mark: "dot" })),
32+ ),
33+ section(
34+ "agents",
35+ "Agents at work",
36+ working.map((x) => ({ title: short(x.pull.title), ...pull(x.repo, x.pull.number), note: `${AGENT_TEXT[x.state].label} · ${x.pull.author.login}`, mark: x.state === "checks_failing" ? "fail" : x.state === "approved" || x.state === "landing" ? "ok" : "dot" })),
37+ ),
38+ section(
39+ "tasks",
40+ "Your agent tasks",
41+ t.tasks.map((x) => ({ title: short(x.title), url: `${base}/${x.repository}/tasks/${x.number}`, where: `${x.repository} task #${x.number}`, note: `${TASK_TEXT[x.state].label} · ${x.agent}`, mark: x.state === "failed" ? "fail" : x.state === "ready" ? "ok" : "dot" })),
42+ ),
43+ section(
44+ "failures",
45+ "Failed runs",
46+ t.failures.map((x) => ({ title: short(x.run.title || x.run.workflow), url: `${base}${x.repo.url}/actions/runs/${x.run.id}`, where: x.repo.fullName, note: `${x.run.workflow} on ${x.run.ref.replace(/^refs\/heads\//, "")} · ${x.run.actor.login}`, mark: "fail" })),
47+ ),
48+ section(
49+ "landed",
50+ "Merged",
51+ t.landed.map((x) => ({ title: short(x.pull.title), ...pull(x.repo, x.pull.number), note: `by ${x.pull.author.login}${x.pull.author.kind === "agent" ? " (agent)" : ""}${x.pull.mergedBy ? `, merged by ${x.pull.mergedBy}` : ""}`, mark: "ok" })),
52+ ),
53+ section(
54+ "releases",
55+ "Released",
56+ t.releases.map((x) => ({ title: short(x.release.name || x.release.tag), url: `${base}${x.repo.url}/releases/tag/${encodeURIComponent(x.release.tag)}`, where: x.repo.fullName, note: x.release.prerelease ? "Pre-release" : x.release.tag, mark: "ok" })),
57+ ),
58+ ].filter((s) => s.total > 0);
59+ return {
60+ date,
61+ counts: { needsYou: t.needsYou.length + t.deployments.length, agents: working.length, merged: t.landed.length, failed: t.failures.length },
62+ sections,
63+ repositories: t.repositories,
64+ incomplete: t.incomplete,
65+ };
66+}
67+
68+/** Gather one person's digest now: Today since `since`, dated in their time zone. */
69+export async function gatherDigest(env: Env, dir: Directory, me: Account, since: string, timeZone: string, at = new Date()): Promise<DigestSummary> {
70+ const today = await gatherToday(env, dir, me, { since, now: at });
71+ return digestSummary(env.PUBLIC_URL, today, localDate(timeZone, at));
72+}
modifiedapps/diffinery/src/agentic/today.ts+29−2View file
@@ -64,6 +64,32 @@
6464 tasks: MyTask[];
6565 }
6666
67+/** Labels and badge tones, shared by the Today page (web.tsx) and the morning digest email (digest.ts). */
68+export const NEEDS_TEXT: Record<NeedsKind, { label: string; tone: string }> = {
69+ review_requested: { label: "Review requested", tone: "blue" },
70+ agent_approval: { label: "Agent PR waiting for a person", tone: "purple" },
71+ changes_requested: { label: "Changes requested", tone: "amber" },
72+ checks_failing: { label: "Checks failing", tone: "red" },
73+};
74+
75+export const AGENT_TEXT: Record<AgentPullState, { label: string; tone: string }> = {
76+ draft: { label: "Draft", tone: "" },
77+ landing: { label: "Landing", tone: "green" },
78+ checks_failing: { label: "Checks failing", tone: "red" },
79+ changes_requested: { label: "Changes requested", tone: "amber" },
80+ approved: { label: "Approved", tone: "green" },
81+ checks_running: { label: "Checks running", tone: "blue" },
82+ waiting_for_approval: { label: "Waiting for approval", tone: "purple" },
83+};
84+
85+export const TASK_TEXT: Record<MyTask["state"], { label: string; tone: string }> = {
86+ queued: { label: "Queued", tone: "" },
87+ running: { label: "Running", tone: "blue" },
88+ ready: { label: "Ready", tone: "green" },
89+ failed: { label: "Failed", tone: "red" },
90+ stopped: { label: "Stopped", tone: "" },
91+};
92+
6793 export interface ClassifyInput {
6894 me: { id: string; login: string };
6995 /** Agents the viewer or their organizations own (and the viewer itself, when an agent asks). */
@@ -155,9 +181,10 @@
155181 * Gather Today for one account (a person, or an agent asking over the API or MCP).
156182 * `repoIds` limits it to the repositories a token is restricted to.
157183 */
158-export async function gatherToday(env: Env, dir: Directory, me: Account, opts: { repoIds?: string[] | null; now?: Date } = {}): Promise<Today> {
184+export async function gatherToday(env: Env, dir: Directory, me: Account, opts: { repoIds?: string[] | null; now?: Date; since?: string } = {}): Promise<Today> {
159185 const now = opts.now ?? new Date();
160- const since = new Date(now.getTime() - SINCE_HOURS * 3600_000).toISOString();
186+ // The morning digest passes its own start: the last digest, which can be days ago after a weekend.
187+ const since = opts.since ?? new Date(now.getTime() - SINCE_HOURS * 3600_000).toISOString();
161188 const cutoff = new Date(now.getTime() - ACTIVE_DAYS * 86400_000).toISOString();
162189 const [visible, activityRows, orgs] = await Promise.all([
163190 dir.reposVisibleTo(me),
modifiedapps/diffinery/src/agentic/web.tsx+1−26View file
@@ -3,29 +3,12 @@
33 import type { FC } from "hono/jsx";
44 import { ICONS } from "../web/assets.js";
55 import { Actor, Status, Time } from "../web/ui.js";
6-import type { AgentItem, AgentPullState, NeedsItem, NeedsKind, Today } from "./today.js";
6+import { AGENT_TEXT, NEEDS_TEXT, TASK_TEXT, type AgentItem, type NeedsItem, type Today } from "./today.js";
77 import type { TodayPull } from "./today-repo.js";
88 import type { MyTask } from "./tasks.js";
99
1010 /** The Today view's sections on the dashboard (web/account.tsx): what needs you, your agents, and the last day. */
1111
12-const NEEDS_TEXT: Record<NeedsKind, { label: string; tone: string }> = {
13- review_requested: { label: "Review requested", tone: "blue" },
14- agent_approval: { label: "Agent PR waiting for a person", tone: "purple" },
15- changes_requested: { label: "Changes requested", tone: "amber" },
16- checks_failing: { label: "Checks failing", tone: "red" },
17-};
18-
19-const AGENT_TEXT: Record<AgentPullState, { label: string; tone: string }> = {
20- draft: { label: "Draft", tone: "" },
21- landing: { label: "Landing", tone: "green" },
22- checks_failing: { label: "Checks failing", tone: "red" },
23- changes_requested: { label: "Changes requested", tone: "amber" },
24- approved: { label: "Approved", tone: "green" },
25- checks_running: { label: "Checks running", tone: "blue" },
26- waiting_for_approval: { label: "Waiting for approval", tone: "purple" },
27-};
28-
2912 const ChecksIcon: FC<{ p: TodayPull }> = ({ p }) =>
3013 p.checks === "none" ? (
3114 <span class="st faint" title="No checks">{raw(ICONS.pr)}</span>
@@ -90,14 +73,6 @@
9073 );
9174 };
9275
93-const TASK_TEXT: Record<MyTask["state"], { label: string; tone: string }> = {
94- queued: { label: "Queued", tone: "" },
95- running: { label: "Running", tone: "blue" },
96- ready: { label: "Ready", tone: "green" },
97- failed: { label: "Failed", tone: "red" },
98- stopped: { label: "Stopped", tone: "" },
99-};
100-
10176 const Tasks: FC<{ items: MyTask[] }> = ({ items }) => (
10277 <div class="box" id="tasks">
10378 <div class="head">
modifiedapps/diffinery/src/mail/api.ts+15−2View file
@@ -5,11 +5,11 @@
55 import type { Account } from "../lib/directory.js";
66 import { loadRepo } from "../http/common.js";
77 import { HttpError, now } from "../lib/util.js";
8-import { MailStore, mergePrefs, type WatchLevel } from "./prefs.js";
8+import { MailStore, isDigestHour, isTimeZone, mergePrefs, type WatchLevel } from "./prefs.js";
99 import { sampleJobs } from "./samples.js";
1010 import { deliver, senderAddress } from "./send.js";
1111 import { Suppressions } from "./suppression.js";
12-import { adminSuppress, adminUnsuppress, sendTest, sendVerification, suppressionJson } from "./web.js";
12+import { adminSuppress, adminUnsuppress, sendDigestNow, sendTest, sendVerification, suppressionJson } from "./web.js";
1313
1414 /**
1515 * Email over the REST API: repository watching in GitHub's shape
@@ -99,6 +99,12 @@
9999 if (base === undefined) throw new HttpError(422, `unknown setting ${k}`);
100100 merged[k] = base && typeof base === "object" && v && typeof v === "object" ? { ...base, ...v } : v;
101101 }
102+ // mergePrefs drops what it doesn't know; say so instead of saving something else.
103+ const d = b.digest as Record<string, unknown> | undefined;
104+ if (d && typeof d === "object") {
105+ if ("hour" in d && !isDigestHour(d.hour)) throw new HttpError(422, "digest.hour must be a whole number from 0 to 23");
106+ if ("timeZone" in d && !isTimeZone(d.timeZone)) throw new HttpError(422, "digest.timeZone must be an IANA time zone, such as Europe/Istanbul or UTC");
107+ }
102108 const next = mergePrefs(merged);
103109 await store.setPrefs(me.id, next);
104110 return c.json(next);
@@ -126,6 +132,13 @@
126132 return c.json(await sendTest(c, me));
127133 });
128134
135+/** Your morning digest, now (digest.ts): sent even when it has nothing in it or is turned off. */
136+mailApi.post("/diffinery/emails/digest", async (c) => {
137+ requireAccountScope(c, "write");
138+ const me = writer(c);
139+ return c.json(await sendDigestNow(c, me));
140+});
141+
129142 /** Your address, and whether it is on the suppression list (suppression.ts). */
130143 mailApi.get("/diffinery/email", async (c) => {
131144 const me = user(c);
addedapps/diffinery/src/mail/digest.ts+118−0View file
@@ -1,0 +1,118 @@
1+import type { DigestPrefs, NotificationPrefs } from "./prefs.js";
2+import type { MailJob } from "./types.js";
3+
4+/**
5+ * The morning digest's schedule (phase 5c). A person turns the digest on in their notification
6+ * settings and picks a local hour, a time zone and whether weekends count. Saving those settings
7+ * writes the next send time to `mail_digests`; the five-minute cron queues a `digest` job for every
8+ * row that is due and moves it on to the next day. The job is an ordinary email: it goes through
9+ * deliver() in send.ts, so the recipient's settings, the suppression list and the one-click
10+ * unsubscribe header all apply. Today itself is gathered when the job is sent (agentic/digest.ts).
11+ */
12+
13+/** Digests queued per cron run; the rest wait for the next run, five minutes later. */
14+export const DIGESTS_PER_RUN = 200;
15+/** "Since the last digest" never reaches back further than this. */
16+export const MAX_SINCE_DAYS = 7;
17+/** Without a last digest (or after a long gap) the digest covers this many hours. */
18+export const DEFAULT_SINCE_HOURS = 24;
19+
20+interface Local {
21+ y: number;
22+ m: number;
23+ d: number;
24+ h: number;
25+ mi: number;
26+ s: number;
27+}
28+
29+const formatters = new Map<string, Intl.DateTimeFormat>();
30+function local(tz: string, t: number): Local {
31+ let f = formatters.get(tz);
32+ if (!f) {
33+ f = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
34+ formatters.set(tz, f);
35+ }
36+ const p: Record<string, number> = {};
37+ for (const x of f.formatToParts(new Date(t))) if (x.type !== "literal") p[x.type] = Number(x.value);
38+ return { y: p.year!, m: p.month!, d: p.day!, h: p.hour! % 24, mi: p.minute!, s: p.second! };
39+}
40+
41+/** How far the zone is ahead of UTC at instant t, in milliseconds. */
42+function offsetAt(tz: string, t: number): number {
43+ const l = local(tz, t);
44+ return Date.UTC(l.y, l.m - 1, l.d, l.h, l.mi, l.s) - Math.floor(t / 1000) * 1000;
45+}
46+
47+/** The instant a local wall-clock hour happens on a local date. A time skipped by a clock change comes out an hour later. */
48+function atLocal(tz: string, y: number, m: number, d: number, h: number): number {
49+ const wall = Date.UTC(y, m - 1, d, h);
50+ let t = wall - offsetAt(tz, wall);
51+ const again = offsetAt(tz, t);
52+ if (wall - again !== t) t = wall - again;
53+ return t;
54+}
55+
56+/** When the next digest goes out after `after`: the chosen local hour on the next day that counts. */
57+export function nextDigestAt(p: Pick<DigestPrefs, "hour" | "timeZone" | "weekends">, after: Date): Date {
58+ const today = local(p.timeZone, after.getTime());
59+ for (let i = 0; i < 10; i++) {
60+ const day = new Date(Date.UTC(today.y, today.m - 1, today.d + i));
61+ const weekday = day.getUTCDay();
62+ if (!p.weekends && (weekday === 0 || weekday === 6)) continue;
63+ const t = atLocal(p.timeZone, day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), p.hour);
64+ if (t > after.getTime()) return new Date(t);
65+ }
66+ // Unreachable: some day in the next ten is a weekday with the hour still ahead.
67+ return new Date(after.getTime() + 86400_000);
68+}
69+
70+/** The local date for the digest's title, e.g. "Wednesday, October 14". */
71+export function localDate(tz: string, at: Date): string {
72+ return at.toLocaleDateString("en-US", { timeZone: tz, weekday: "long", month: "long", day: "numeric" });
73+}
74+
75+/** Keep the schedule in step with someone's settings: a row while the digest is on, none while it is off. */
76+export async function syncDigest(db: D1Database, accountId: string, prefs: NotificationPrefs, at = new Date()): Promise<void> {
77+ if (!prefs.digest.email) {
78+ await db.prepare("DELETE FROM mail_digests WHERE account_id = ?").bind(accountId).run();
79+ return;
80+ }
81+ await db
82+ .prepare("INSERT INTO mail_digests (account_id, next_at) VALUES (?, ?) ON CONFLICT(account_id) DO UPDATE SET next_at = excluded.next_at")
83+ .bind(accountId, nextDigestAt(prefs.digest, at).toISOString())
84+ .run();
85+}
86+
87+/** Where a digest sent at `at` starts: the last one, if it was within MAX_SINCE_DAYS, else DEFAULT_SINCE_HOURS ago. */
88+export function digestSince(lastSentAt: string | null, at: Date): string {
89+ const floor = new Date(at.getTime() - MAX_SINCE_DAYS * 86400_000).toISOString();
90+ if (lastSentAt && lastSentAt >= floor && lastSentAt < at.toISOString()) return lastSentAt;
91+ return new Date(at.getTime() - DEFAULT_SINCE_HOURS * 3600_000).toISOString();
92+}
93+
94+/**
95+ * From the cron: queue the digests that are due and move each row to its next send. A row moves
96+ * only if no other cron run moved it first, so overlapping runs never send one twice.
97+ */
98+export async function digestCron(db: D1Database, at: Date, prefsFor: (ids: string[]) => Promise<Map<string, NotificationPrefs>>, send: (jobs: MailJob[]) => Promise<void>): Promise<number> {
99+ const now = at.toISOString();
100+ const due = (
101+ await db.prepare("SELECT account_id, next_at, last_sent_at FROM mail_digests WHERE next_at <= ? ORDER BY next_at LIMIT ?").bind(now, DIGESTS_PER_RUN).all<{ account_id: string; next_at: string; last_sent_at: string | null }>()
102+ ).results;
103+ if (!due.length) return 0;
104+ const prefs = await prefsFor(due.map((r) => r.account_id));
105+ const jobs: MailJob[] = [];
106+ for (const r of due) {
107+ const p = prefs.get(r.account_id);
108+ if (!p?.digest.email) {
109+ await db.prepare("DELETE FROM mail_digests WHERE account_id = ? AND next_at = ?").bind(r.account_id, r.next_at).run();
110+ continue;
111+ }
112+ const moved = await db.prepare("UPDATE mail_digests SET next_at = ?, last_sent_at = ? WHERE account_id = ? AND next_at = ?").bind(nextDigestAt(p.digest, at).toISOString(), now, r.account_id, r.next_at).run();
113+ if (!moved.meta.changes) continue;
114+ jobs.push({ kind: "digest", data: { since: digestSince(r.last_sent_at, at) }, to: { accountId: r.account_id }, reason: "digest", at: now });
115+ }
116+ await send(jobs);
117+ return jobs.length;
118+}
modifiedapps/diffinery/src/mail/notify.ts+3−1View file
@@ -23,6 +23,7 @@
2323 import { sha256 } from "../lib/util.js";
2424 import { blockersOf } from "../lib/moderation.js";
2525 import { MailStore, type NotificationPrefs } from "./prefs.js";
26+import { digestCron } from "./digest.js";
2627 import { enqueueMail } from "./send.js";
2728 import type { JobInfo, RunInfo } from "../repo/actions.js";
2829 import type { MailJob, Reason, ThreadData } from "./types.js";
@@ -395,7 +396,7 @@
395396 await enqueueMail(env, jobs);
396397 }
397398
398-/** Daily-ish jobs from the cron trigger: token expiry reminders and log pruning. */
399+/** Jobs from the five-minute cron trigger: token expiry reminders, morning digests that are due, and log pruning. */
399400 export async function mailCron(env: Env, dir: Directory, at: Date): Promise<void> {
400401 const store = new MailStore(env.DB);
401402 const now = at.toISOString();
@@ -415,5 +416,6 @@
415416 await env.DB.prepare("UPDATE tokens SET expiry_notified_at = ? WHERE id = ?").bind(now, t.id).run();
416417 }
417418 await enqueueMail(env, jobs);
419+ await digestCron(env.DB, at, (ids) => store.prefsFor(ids), (digests) => enqueueMail(env, digests));
418420 if (at.getUTCHours() === 3 && at.getUTCMinutes() < 5) await store.prune();
419421 }
modifiedapps/diffinery/src/mail/prefs.ts+55−1View file
@@ -1,4 +1,5 @@
11 import { newId, now } from "../lib/util.js";
2+import { syncDigest } from "./digest.js";
23 import type { Category } from "./types.js";
34
45 /** Per-person notification settings. Missing keys fall back to DEFAULT_PREFS. */
@@ -17,8 +18,33 @@
1718 ownActivity: boolean;
1819 /** Watch repositories I create. */
1920 autoWatch: boolean;
21+ /** The morning digest (phase 5c): Today by email, at a local hour. Off unless the person turns it on. */
22+ digest: DigestPrefs;
2023 }
2124
25+export interface DigestPrefs {
26+ email: boolean;
27+ /** Local hour, 0 to 23. */
28+ hour: number;
29+ /** IANA time zone, e.g. Europe/Istanbul. */
30+ timeZone: string;
31+ /** Also on Saturdays and Sundays. */
32+ weekends: boolean;
33+}
34+
35+/** Whether a string is a time zone this runtime knows. */
36+export function isTimeZone(tz: unknown): tz is string {
37+ if (typeof tz !== "string" || !tz || tz.length > 64) return false;
38+ try {
39+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
40+ return true;
41+ } catch {
42+ return false;
43+ }
44+}
45+
46+export const isDigestHour = (h: unknown): h is number => typeof h === "number" && Number.isInteger(h) && h >= 0 && h <= 23;
47+
2248 export const DEFAULT_PREFS: NotificationPrefs = {
2349 participating: { web: true, email: true },
2450 watching: { web: true, email: true },
@@ -27,6 +53,7 @@
2753 orgs: { email: true },
2854 ownActivity: false,
2955 autoWatch: true,
56+ digest: { email: false, hour: 8, timeZone: "UTC", weekends: false },
3057 };
3158
3259 export function mergePrefs(stored: unknown): NotificationPrefs {
@@ -45,9 +72,21 @@
4572 orgs: pick(DEFAULT_PREFS.orgs, s.orgs),
4673 ownActivity: typeof s.ownActivity === "boolean" ? s.ownActivity : DEFAULT_PREFS.ownActivity,
4774 autoWatch: typeof s.autoWatch === "boolean" ? s.autoWatch : DEFAULT_PREFS.autoWatch,
75+ digest: mergeDigest(s.digest),
4876 };
4977 }
5078
79+function mergeDigest(v: unknown): DigestPrefs {
80+ const d = (v && typeof v === "object" ? v : {}) as Record<string, unknown>;
81+ const def = DEFAULT_PREFS.digest;
82+ return {
83+ email: typeof d.email === "boolean" ? d.email : def.email,
84+ hour: isDigestHour(d.hour) ? d.hour : def.hour,
85+ timeZone: isTimeZone(d.timeZone) ? d.timeZone : def.timeZone,
86+ weekends: typeof d.weekends === "boolean" ? d.weekends : def.weekends,
87+ };
88+}
89+
5190 /** Whether a settings group wants email (security and account emails always send). */
5291 export function wantsEmail(p: NotificationPrefs, c: Category): boolean {
5392 switch (c) {
@@ -64,6 +103,8 @@
64103 return p.repos.email;
65104 case "orgs":
66105 return p.orgs.email;
106+ case "digest":
107+ return p.digest.email;
67108 }
68109 }
69110
@@ -114,13 +155,26 @@
114155 return out;
115156 }
116157
158+ /** Save someone's settings, and move their morning digest's schedule to match (digest.ts). */
117159 async setPrefs(accountId: string, prefs: NotificationPrefs): Promise<void> {
160+ const p = mergePrefs(prefs);
118161 await this.db
119162 .prepare("INSERT INTO notification_prefs (account_id, prefs, updated_at) VALUES (?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET prefs = excluded.prefs, updated_at = excluded.updated_at")
120- .bind(accountId, JSON.stringify(mergePrefs(prefs)), now())
163+ .bind(accountId, JSON.stringify(p), now())
121164 .run();
165+ await syncDigest(this.db, accountId, p);
122166 }
123167
168+ /** When the morning digest goes out next, if it is on (digest.ts). */
169+ async digestNext(accountId: string): Promise<string | null> {
170+ return (await this.db.prepare("SELECT next_at FROM mail_digests WHERE account_id = ?").bind(accountId).first<{ next_at: string }>())?.next_at ?? null;
171+ }
172+
173+ /** When the last scheduled digest was queued. */
174+ async digestLastSent(accountId: string): Promise<string | null> {
175+ return (await this.db.prepare("SELECT last_sent_at FROM mail_digests WHERE account_id = ?").bind(accountId).first<{ last_sent_at: string | null }>())?.last_sent_at ?? null;
176+ }
177+
124178 async watchLevel(accountId: string, repoId: string): Promise<WatchLevel> {
125179 const r = await this.db.prepare("SELECT level FROM repo_watches WHERE account_id = ? AND repo_id = ?").bind(accountId, repoId).first<{ level: WatchLevel }>();
126180 return r?.level ?? "participating";
modifiedapps/diffinery/src/mail/samples.ts+36−0View file
@@ -95,6 +95,42 @@
9595 item("repo-created", "Repository created", { kind: "repo_created", reason: "your_activity", data: { repo, creator: me, visibility: "private", defaultBranch: "main", description: "Marketing site" } }),
9696 item("repo-deleted", "Repository deleted", { kind: "repo_deleted", reason: "admin", data: { repo, actor: "kim" } }),
9797 item("repo-transferred", "Repository transferred", { kind: "repo_transferred", reason: "admin", data: { from: `${me}/${repo.split("/")[1]}`, to: repo, actor: "kim" } }),
98+ item("digest", "Morning digest", {
99+ kind: "digest",
100+ reason: "digest",
101+ data: {
102+ since: new Date(Date.now() - 86400_000).toISOString(),
103+ summary: {
104+ date: new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" }),
105+ counts: { needsYou: 2, agents: 1, merged: 3, failed: 1 },
106+ repositories: 4,
107+ incomplete: false,
108+ sections: [
109+ {
110+ id: "needs_you",
111+ title: "Needs you",
112+ total: 2,
113+ items: [
114+ { title: "Cache build artifacts between workflow runs", url: `${pub}/${repo}/pull/42`, where: `${repo}#42`, note: "Review requested · by diffy-dev", mark: "dot" },
115+ { title: "Retry flaky upload step", url: `${pub}/${repo}/pull/44`, where: `${repo}#44`, note: "Agent PR waiting for a person · by fixer-bot", mark: "dot" },
116+ ],
117+ },
118+ { id: "agents", title: "Agents at work", total: 1, items: [{ title: "Retry flaky upload step", url: `${pub}/${repo}/pull/44`, where: `${repo}#44`, note: "Waiting for approval · fixer-bot", mark: "dot" }] },
119+ { id: "failures", title: "Failed runs", total: 1, items: [{ title: "Nightly build", url: `${pub}/${repo}/actions/runs/1`, where: repo, note: "nightly on main · " + me, mark: "fail" }] },
120+ {
121+ id: "landed",
122+ title: "Merged",
123+ total: 3,
124+ items: [
125+ { title: "Bump the runner image", url: `${pub}/${repo}/pull/40`, where: `${repo}#40`, note: "by fixer-bot (agent), merged by " + me, mark: "ok" },
126+ { title: "Docs: caching", url: `${pub}/${repo}/pull/41`, where: `${repo}#41`, note: "by rivera", mark: "ok" },
127+ { title: "Fix the release notes link", url: `${pub}/${repo}/pull/39`, where: `${repo}#39`, note: "by " + me, mark: "ok" },
128+ ],
129+ },
130+ ],
131+ },
132+ },
133+ }),
98134 item("test", "Test email", { kind: "test", reason: "account", data: { by: me } }),
99135 ];
100136 }
modifiedapps/diffinery/src/mail/send.ts+16−1View file
@@ -1,6 +1,6 @@
11 import { devAuth } from "../auth/dev.js";
22 import type { Env } from "../env.js";
3-import { Directory } from "../lib/directory.js";
3+import { Directory, type Account } from "../lib/directory.js";
44 import { MailStore, wantsEmail } from "./prefs.js";
55 import { compose, toHtml, toText, type RenderCtx } from "./templates.js";
66 import { categoryOf, isMandatory, type MailJob } from "./types.js";
@@ -115,6 +115,7 @@
115115 const store = new MailStore(env.DB);
116116 const category = categoryOf(job);
117117 let to: { email: string; name?: string; login?: string; accountId?: string };
118+ let account: Account | null = null;
118119 if ("accountId" in job.to) {
119120 const accountId = job.to.accountId;
120121 const a = await dir.account(accountId);
@@ -128,6 +129,7 @@
128129 if (a.status === "disabled" && job.kind !== "account_status") return skip("account disabled");
129130 if (!job.force && !isMandatory(category) && !wantsEmail(await store.prefs(a.id), category)) return skip(`turned off ${category} emails`);
130131 to = { email: a.email, name: a.name || undefined, login: a.login, accountId: a.id };
132+ account = a;
131133 } else {
132134 to = { email: job.to.email, name: job.to.name };
133135 }
@@ -140,6 +142,19 @@
140142 }
141143 }
142144
145+ // The morning digest is gathered now, as it is sent (agentic/digest.ts), and a scheduled one with nothing in it isn't sent.
146+ if (job.kind === "digest" && !job.data.summary) {
147+ if (!account) return { status: "skipped", detail: "a digest needs an account" };
148+ const { gatherDigest } = await import("../agentic/digest.js");
149+ const summary = await gatherDigest(env, dir, account, job.data.since, (await store.prefs(account.id)).digest.timeZone);
150+ if (!summary.sections.length && !job.data.now) {
151+ const detail = "nothing to report since the last digest";
152+ await store.log({ account_id: account.id, to_addr: to.email, kind: job.kind, subject: "", status: "skipped", detail, message_id: null, dev_copy: null });
153+ return { status: "skipped", detail };
154+ }
155+ job = { ...job, data: { ...job.data, summary } };
156+ }
157+
143158 const msg = await buildMessage(env, job, to);
144159 const devCopy = devAuth(env) ? JSON.stringify(msg) : null;
145160 const base = { account_id: to.accountId ?? null, to_addr: to.email, kind: job.kind, subject: msg.subject, dev_copy: devCopy };
modifiedapps/diffinery/src/mail/templates.ts+35−1View file
@@ -5,7 +5,7 @@
55 * neutral palette, blue for actions and the orange mark.
66 */
77 import { escapeHtml, renderMarkdown } from "../web/markdown.js";
8-import type { MailJob, Reason, RunData, ThreadData } from "./types.js";
8+import type { DigestData, MailJob, Reason, RunData, ThreadData } from "./types.js";
99
1010 export type Inline = string | { b: string } | { a: string; href: string } | { code: string };
1111
@@ -166,6 +166,7 @@
166166 admin: "You are receiving this because you are an administrator.",
167167 account: "You are receiving this because you have a Diffinery account.",
168168 signup: "You are receiving this because someone asked to create a Diffinery account with this email address. If it wasn't you, you can ignore this email.",
169+ digest: "You are receiving this because you turned on the morning digest in your notification settings.",
169170 };
170171
171172 const LOGO = (url: string) => `${url}/icon-512.png`;
@@ -860,6 +861,8 @@
860861 url,
861862 };
862863 }
864+ case "digest":
865+ return digestEmail(job.data, pub);
863866 case "test":
864867 return {
865868 subject: "[Diffinery] Test email",
@@ -871,3 +874,34 @@
871874 };
872875 }
873876 }
877+
878+// ---------------------------------------------------------------- the morning digest (phase 5c)
879+
880+/** Today by email: what needs you, your agents, and what happened since the last digest (agentic/digest.ts). */
881+function digestEmail(d: DigestData, pub: string): Email {
882+ const today = `${pub}/`;
883+ const s = d.summary;
884+ if (!s) {
885+ // deliver() always fills the summary in; this only shows if something sends the job without it.
886+ return { subject: "[Diffinery] Your morning digest", preheader: "Open Today on Diffinery.", eyebrow: { text: "Today", tone: "blue" }, title: ["Your morning digest"], blocks: [{ button: { label: "Open Today", url: today } }], url: today };
887+ }
888+ const c = s.counts;
889+ const parts = [c.needsYou ? `${c.needsYou} need${c.needsYou === 1 ? "s" : ""} you` : "", c.agents ? plural(c.agents, "agent pull request") : "", c.merged ? `${c.merged} merged` : "", c.failed ? plural(c.failed, "failed run") : ""].filter(Boolean);
890+ const headline = parts.length ? parts.join(", ") : "nothing needs you";
891+ const blocks: Block[] = [];
892+ if (!s.sections.length) blocks.push({ p: ["Nothing needs you, your agents have no open pull requests, and nothing merged, released or failed since the last digest."] });
893+ for (const sec of s.sections) {
894+ blocks.push({ p: [{ b: sec.title }, sec.total > sec.items.length ? ` · ${sec.items.length} of ${sec.total}` : ` · ${sec.total}`] });
895+ blocks.push({ items: sec.items.map((it) => ({ mark: it.mark, text: [{ b: it.title }, ` ${it.where} · ${it.note}`], url: it.url })) });
896+ }
897+ blocks.push({ button: { label: "Open Today", url: today } });
898+ blocks.push({ p: [`Since ${at(d.since)}, across ${plural(s.repositories, "active repository", "active repositories")}${s.incomplete ? " (some were left out; Today shows the most recently active)" : ""}.`], muted: true });
899+ return {
900+ subject: `[Diffinery] Today: ${headline}`,
901+ preheader: s.sections.length ? s.sections.map((x) => `${x.title}: ${x.total}`).join(" · ") : "A quiet morning.",
902+ eyebrow: { text: s.date, tone: c.needsYou ? "purple" : "blue" },
903+ title: [c.needsYou ? `${c.needsYou} ${c.needsYou === 1 ? "thing needs" : "things need"} you today` : "Your morning digest"],
904+ blocks,
905+ url: today,
906+ };
907+}
modifiedapps/diffinery/src/mail/types.ts+43−3View file
@@ -20,13 +20,14 @@
2020 | "invitation"
2121 | "admin"
2222 | "account"
23- | "signup";
23+ | "signup"
24+ | "digest";
2425
2526 /**
2627 * Settings groups. `security` and `account` always send: they protect the
2728 * account or are the only way to reach someone (invites, sign-in links).
2829 */
29-export type Category = "security" | "account" | "participating" | "watching" | "actions" | "repos" | "orgs";
30+export type Category = "security" | "account" | "participating" | "watching" | "actions" | "repos" | "orgs" | "digest";
3031
3132 export type Recipient = { accountId: string } | { email: string; name?: string };
3233
@@ -70,6 +71,42 @@
7071 jobs: Array<{ name: string; conclusion: string | null; url: string; failure?: string | null }>;
7172 }
7273
74+/** One line of the morning digest: what it is, where, and a short note (state, who, when). */
75+export interface DigestItem {
76+ title: string;
77+ url: string;
78+ /** e.g. "acme/website#42". */
79+ where: string;
80+ note: string;
81+ mark: "ok" | "fail" | "dot";
82+}
83+
84+export interface DigestSection {
85+ id: "needs_you" | "deployments" | "agents" | "tasks" | "failures" | "landed" | "releases";
86+ title: string;
87+ /** How many there are; `items` holds at most DIGEST_MAX_ITEMS of them. */
88+ total: number;
89+ items: DigestItem[];
90+}
91+
92+export interface DigestSummary {
93+ /** The person's local date, e.g. "Wednesday, October 14". */
94+ date: string;
95+ counts: { needsYou: number; agents: number; merged: number; failed: number };
96+ sections: DigestSection[];
97+ repositories: number;
98+ incomplete: boolean;
99+}
100+
101+export interface DigestData {
102+ /** "Since the last digest" starts here. */
103+ since: string;
104+ /** Today, gathered when the email is sent (deliver in send.ts); a scheduled job carries only `since`. */
105+ summary?: DigestSummary;
106+ /** Asked for on Settings → Notifications: sent even when there is nothing to report. */
107+ now?: boolean;
108+}
109+
73110 export type MailBody =
74111 | { kind: "signin_new"; data: { method: "passkey" | "access" | "link"; device: string; ip?: string; location?: string; passkey?: string } }
75112 | { kind: "passkey_added"; data: { name: string; via?: "settings" | "signin-link" | "invite" } }
@@ -102,7 +139,8 @@
102139 | { kind: "repo_transferred"; data: { from: string; to: string; actor: string } }
103140 | { kind: "thread"; data: ThreadData }
104141 | { kind: "run"; data: RunData }
105- | { kind: "test"; data: { by: string } };
142+ | { kind: "test"; data: { by: string } }
143+ | { kind: "digest"; data: DigestData };
106144
107145 export type MailKind = MailBody["kind"];
108146
@@ -158,6 +196,8 @@
158196 return "actions";
159197 case "thread":
160198 return job.reason === "subscribed" ? "watching" : "participating";
199+ case "digest":
200+ return "digest";
161201 }
162202 }
163203
modifiedapps/diffinery/src/mail/web.tsx+82−7View file
@@ -7,7 +7,8 @@
77 import { ADMIN_TABS, SettingsNav, type SettingsTab } from "../web/account.js";
88 import { ComputePausedBanner } from "../web/kill-switches.js";
99 import { csrfToken, formData } from "../web/csrf.js";
10-import { KCheckbox, KSelect } from "../web/kumo.js";
10+import { KCheckbox, KSelect, type KOption } from "../web/kumo.js";
11+import { digestSince } from "./digest.js";
1112 import { Form, Time, WATCH_OPTIONS, act, flash, post, prm, render, repoUrl, type WebApp } from "../web/ui.js";
1213 import { MailStore, WATCH_LEVELS, type EmailLogRow, type NotificationPrefs, type WatchLevel } from "./prefs.js";
1314 import { sampleJobs } from "./samples.js";
@@ -31,6 +32,7 @@
3132 actions: "workflow runs",
3233 repos: "repository changes",
3334 orgs: "organization updates",
35+ digest: "the morning digest",
3436 };
3537
3638 const settingsPage = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child) =>
@@ -125,7 +127,13 @@
125127 const store = new MailStore(c.env.DB);
126128 const dir = c.get("dir");
127129 const t = csrfToken(c);
128- const [p, watches, recent, suppressed] = await Promise.all([store.prefs(me.id), store.watchedRepos(me.id), store.recent({ accountId: me.id, limit: 20 }), me.email ? new Suppressions(c.env.DB).get(me.email) : null]);
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+ ]);
129137 const repos = (await Promise.all(watches.map(async (w) => ({ level: w.level, repo: await dir.repoById(w.repo_id) })))).filter((w) => w.repo);
130138 return settingsPage(
131139 c,
@@ -175,7 +183,8 @@
175183 <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 }} />
176184 </tbody>
177185 </table>
178- <div class="body">
186+ <DigestSettings p={p} next={digestNext} />
187+ <div class="body" style="border-top:1px solid var(--border)">
179188 <div class="kstack">
180189 <KCheckbox name="ownActivity" checked={p.ownActivity} label="Email me about my own comments and changes" />
181190 <KCheckbox name="autoWatch" checked={p.autoWatch} label="Watch repositories I create (All activity)" />
@@ -212,15 +221,56 @@
212221 )}
213222
214223 <h2 class="mt">Recent emails to you</h2>
215- <Form action="/settings/notifications/test" t={t} class="mb">
216- <button class="btn sm">Send me a test email</button>
217- </Form>
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>
218232 <EmailLog rows={recent} />
219233 </>,
220234 );
221235 }
222236
223-function prefsFromForm(f: { b(name: string): boolean }): NotificationPrefs {
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");
224274 return {
225275 participating: { web: f.b("participating.web"), email: f.b("participating.email") },
226276 watching: { web: f.b("watching.web"), email: f.b("watching.email") },
@@ -229,6 +279,8 @@
229279 orgs: { email: f.b("orgs.email") },
230280 ownActivity: f.b("ownActivity"),
231281 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") },
232284 };
233285 }
234286
@@ -242,6 +294,18 @@
242294 return deliver(c.env, { kind: "test", data: { by: me.login }, to: { accountId: me.id }, reason: "account", at: now() });
243295 }
244296
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+
245309 // ---------------------------------------------------------------- the suppression list (suppression.ts)
246310
247311 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) });
@@ -403,6 +467,17 @@
403467 return c.redirect("/settings/notifications", 303);
404468 });
405469
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+
406481 // ------------------------------------------------------------ Settings → Email: suppressed or not, and verifying the address again
407482
408483 web.get("/settings/email", async (c) => emailPage(c, requireUser(c)));
modifiedapps/diffinery/src/web/assets.ts+2−0View file
@@ -356,6 +356,8 @@
356356 const d=document,b=d.body,$=(s,r=d)=>r.querySelector(s),$$=(s,r=d)=>[...r.querySelectorAll(s)];
357357 const repo=b.dataset.repo||'',ref=b.dataset.ref||'';
358358 const enc=p=>p.split('/').map(encodeURIComponent).join('/');
359+// Fields that want the browser's time zone (the morning digest's "this browser" choice).
360+$$('input[data-browser-tz]').forEach(i=>{try{i.value=Intl.DateTimeFormat().resolvedOptions().timeZone||''}catch(_){}});
359361 d.addEventListener('click',e=>{
360362 const c=e.target.closest('[data-copy]');
361363 if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.innerHTML;if(c.dataset.copyIcon)c.innerHTML=${JSON.stringify(UI_ICONS.check)};else c.textContent='Copied';setTimeout(()=>{c.innerHTML=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return}
modifiedapps/diffinery/test/e2e/email.test.mjs+82−1View file
@@ -9,7 +9,8 @@
99 // own activity staying quiet, one-click unsubscribe (RFC 8058) turning that
1010 // kind of email off, the footer link muting a thread while mentions still get
1111 // through, failed workflow run emails, and the suppression list: signed bounce
12-// and complaint intake, no email to a suppressed address, lifting it again.
12+// and complaint intake, no email to a suppressed address, lifting it again; the
13+// morning digest on its schedule (phase 5c) and Today at phone width.
1314 import { spawn, execFileSync } from "node:child_process";
1415 import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
1516 import { tmpdir } from "node:os";
@@ -19,6 +20,7 @@
1920 import { createServer } from "node:net";
2021 import { test, before, after } from "node:test";
2122 import assert from "node:assert/strict";
23+import { chromium } from "playwright";
2224
2325 const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
2426 const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
@@ -406,3 +408,82 @@
406408 const audit = await (await fetch(`${base}/settings/audit`)).text();
407409 for (const action of ["email.suppression.add", "email.suppression.lift", "email.suppression.remove"]) assert.match(audit, new RegExp(action.replaceAll(".", "\\.")));
408410 });
411+
412+test("email: the morning digest on its schedule, one-click unsubscribe, and Today on a phone (phase 5c)", async () => {
413+ const S = "/diffinery/notification-settings";
414+ // The template gallery in the first test already sent a sample digest; count from here.
415+ const sample = (await emails()).filter((e) => e.kind === "digest").map((e) => e.id ?? `${e.created_at} ${e.subject}`);
416+ const digests = async () => (await emails()).filter((e) => e.kind === "digest" && !sample.includes(e.id ?? `${e.created_at} ${e.subject}`));
417+ assert.deepEqual((await ok("GET", S)).digest, { email: false, hour: 8, timeZone: "UTC", weekends: false });
418+ assert.equal((await api("PUT", S, { digest: { hour: 24 } })).status, 422);
419+ assert.equal((await api("PUT", S, { digest: { timeZone: "Mars/Olympus" } })).status, 422);
420+
421+ // On, for the next hour in Tokyo, every day.
422+ const hour = (Number(new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Tokyo", hour: "numeric", hourCycle: "h23" }).format(new Date())) + 1) % 24;
423+ const saved = await ok("PUT", S, { digest: { email: true, hour, timeZone: "Asia/Tokyo", weekends: true } });
424+ assert.deepEqual(saved.digest, { email: true, hour, timeZone: "Asia/Tokyo", weekends: true });
425+ const next = /The next one goes out <time datetime="([^"]+)"/.exec(await (await fetch(`${base}/settings/notifications`)).text())?.[1];
426+ assert.ok(next && Date.parse(next) > Date.now() && Date.parse(next) <= Date.now() + 3600_000, `next digest at ${next}`);
427+ assert.equal(new Date(next).getUTCMinutes(), 0);
428+
429+ // The five-minute cron sends nothing before the hour, then queues it once; it goes out like any email.
430+ const cron = (t) => fetch(`${base}/cdn-cgi/local/scheduled?cron=${encodeURIComponent("*/5 * * * *")}&time=${t}`);
431+ assert.equal((await cron(Date.parse(next) - 10 * 60_000)).status, 200);
432+ await sleep(2000);
433+ assert.equal((await digests()).length, 0);
434+ assert.equal((await cron(Date.parse(next) + 2 * 60_000)).status, 200);
435+ const d = await until("the morning digest", async () => (await digests())[0]);
436+ assert.equal(d.status, "sent", d.detail);
437+ assert.match(d.subject, /^\[Diffinery\] Today: /);
438+ assert.equal(d.dev_copy.headers["X-Diffinery-Kind"], "digest");
439+ assert.equal(d.dev_copy.headers["X-Diffinery-Reason"], "digest");
440+ assert.match(d.dev_copy.text, /Failed runs · \d+/);
441+ assert.ok(d.dev_copy.text.includes(`Open Today: ${base}/`));
442+ assert.match(d.dev_copy.text, /you turned on the morning digest/);
443+ assert.equal((await cron(Date.parse(next) + 7 * 60_000)).status, 200);
444+ await sleep(3000);
445+ assert.equal((await digests()).length, 1, "the next run doesn't send it again");
446+ assert.match(await (await fetch(`${base}/settings/notifications`)).text(), /The next one goes out/);
447+
448+ // The mail app's one-click unsubscribe turns off only the digest.
449+ const link = /^<(.+)>$/.exec(d.dev_copy.headers["List-Unsubscribe"])[1];
450+ const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" });
451+ assert.equal(unsub.status, 200);
452+ const off = await ok("GET", S);
453+ assert.equal(off.digest.email, false);
454+ assert.equal(off.participating.email, true);
455+ assert.doesNotMatch(await (await fetch(`${base}/settings/notifications`)).text(), /The next one goes out/);
456+
457+ // Asked for, it is sent now even while off.
458+ const now = await ok("POST", "/diffinery/emails/digest");
459+ assert.equal(now.status, "sent", JSON.stringify(now));
460+ assert.equal((await digests()).length, 2);
461+
462+ // The settings form: the browser's time zone, filled in by the page.
463+ const formPage = await fetch(`${base}/settings/notifications`);
464+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await formPage.text())?.[1];
465+ const cookie = /dfy_csrf=([^;]+)/.exec(formPage.headers.get("set-cookie") ?? "")?.[1];
466+ const fields = [["_csrf", csrf], ...["participating.web", "participating.email", "watching.web", "watching.email", "actions.web", "actions.email", "actions.failuresOnly", "repos.email", "orgs.email", "autoWatch"].map((k) => [k, "1"]), ["digest.email", "1"], ["digest.hour", "7"], ["digest.timeZone", "browser"], ["digest.browserTimeZone", "Europe/Istanbul"]];
467+ const posted = await fetch(`${base}/settings/notifications`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams(fields) });
468+ assert.equal(posted.status, 303);
469+ assert.deepEqual((await ok("GET", S)).digest, { email: true, hour: 7, timeZone: "Europe/Istanbul", weekends: false });
470+
471+ // On a phone: the page fills in the browser's zone, nothing scrolls sideways, and Today comes before the repository list.
472+ const browser = await chromium.launch();
473+ try {
474+ const page = await (await browser.newContext({ viewport: { width: 390, height: 844 }, timezoneId: "America/Sao_Paulo", isMobile: true, hasTouch: true })).newPage();
475+ const overflow = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
476+ await page.goto(`${base}/settings/notifications`);
477+ assert.equal(await page.locator("input[data-browser-tz]").inputValue(), "America/Sao_Paulo");
478+ assert.equal(await overflow(), 0, "the settings page scrolls sideways");
479+ if (process.env.E2E_SHOTS) await page.screenshot({ path: join(process.env.E2E_SHOTS, "notifications-390.png"), fullPage: true });
480+ await page.goto(`${base}/`);
481+ assert.equal(await overflow(), 0, "Today scrolls sideways");
482+ const today = await page.locator("h1", { hasText: "Today" }).boundingBox();
483+ const side = await page.locator("h3", { hasText: /repositories/i }).first().boundingBox();
484+ assert.ok(today && side && today.y < side.y, "Today comes first");
485+ if (process.env.E2E_SHOTS) await page.screenshot({ path: join(process.env.E2E_SHOTS, "today-390.png"), fullPage: true });
486+ } finally {
487+ await browser.close();
488+ }
489+});
addedapps/diffinery/test/unit/digest.test.ts+221−0View file
@@ -1,0 +1,221 @@
1+import { describe, expect, it } from "vitest";
2+import { DIGEST_MAX_ITEMS, digestSummary } from "../../src/agentic/digest.js";
3+import type { Today, TodayRepo } from "../../src/agentic/today.js";
4+import type { TodayPull } from "../../src/agentic/today-repo.js";
5+import { DEFAULT_SINCE_HOURS, digestCron, digestSince, localDate, nextDigestAt } from "../../src/mail/digest.js";
6+import { DEFAULT_PREFS, MailStore, mergePrefs, wantsEmail, type NotificationPrefs } from "../../src/mail/prefs.js";
7+import { compose, toText } from "../../src/mail/templates.js";
8+import { categoryOf, type MailJob } from "../../src/mail/types.js";
9+import { testD1 } from "./stubs/d1.js";
10+
11+const digest = (d: Partial<NotificationPrefs["digest"]>) => ({ ...DEFAULT_PREFS.digest, email: true, ...d });
12+const iso = (d: Date) => d.toISOString();
13+
14+describe("nextDigestAt", () => {
15+ it("is the chosen hour in UTC, today if it is still ahead, else tomorrow", () => {
16+ // 2026-10-14 is a Wednesday.
17+ expect(iso(nextDigestAt(digest({ hour: 8 }), new Date("2026-10-14T07:59:00Z")))).toBe("2026-10-14T08:00:00.000Z");
18+ expect(iso(nextDigestAt(digest({ hour: 8 }), new Date("2026-10-14T08:00:00Z")))).toBe("2026-10-15T08:00:00.000Z");
19+ expect(iso(nextDigestAt(digest({ hour: 0 }), new Date("2026-10-14T23:30:00Z")))).toBe("2026-10-15T00:00:00.000Z");
20+ });
21+
22+ it("is the local hour in the person's time zone", () => {
23+ // Istanbul is UTC+3 all year.
24+ expect(iso(nextDigestAt(digest({ hour: 8, timeZone: "Europe/Istanbul" }), new Date("2026-10-14T04:00:00Z")))).toBe("2026-10-14T05:00:00.000Z");
25+ expect(iso(nextDigestAt(digest({ hour: 8, timeZone: "Europe/Istanbul" }), new Date("2026-10-14T06:00:00Z")))).toBe("2026-10-15T05:00:00.000Z");
26+ // Late in the UTC day it is already tomorrow in Tokyo (UTC+9).
27+ expect(iso(nextDigestAt(digest({ hour: 7, timeZone: "Asia/Tokyo" }), new Date("2026-10-14T20:00:00Z")))).toBe("2026-10-14T22:00:00.000Z");
28+ // And still yesterday in Los Angeles (UTC-7 in October).
29+ expect(iso(nextDigestAt(digest({ hour: 9, timeZone: "America/Los_Angeles" }), new Date("2026-10-15T03:00:00Z")))).toBe("2026-10-15T16:00:00.000Z");
30+ });
31+
32+ it("skips Saturdays and Sundays unless weekends are on", () => {
33+ // Friday 2026-10-16, after the hour: the next is Monday.
34+ const fri = new Date("2026-10-16T10:00:00Z");
35+ expect(iso(nextDigestAt(digest({ hour: 8 }), fri))).toBe("2026-10-19T08:00:00.000Z");
36+ expect(iso(nextDigestAt(digest({ hour: 8, weekends: true }), fri))).toBe("2026-10-17T08:00:00.000Z");
37+ // A weekday in the person's zone, not in UTC: Monday 07:00 in Tokyo is Sunday 22:00 UTC.
38+ expect(iso(nextDigestAt(digest({ hour: 7, timeZone: "Asia/Tokyo" }), new Date("2026-10-17T00:00:00Z")))).toBe("2026-10-18T22:00:00.000Z");
39+ });
40+
41+ it("follows clock changes", () => {
42+ // New York leaves daylight saving time on 2026-11-01: 08:00 is 12:00 UTC before, 13:00 UTC after.
43+ const ny = digest({ hour: 8, timeZone: "America/New_York", weekends: true });
44+ expect(iso(nextDigestAt(ny, new Date("2026-10-31T09:00:00Z")))).toBe("2026-10-31T12:00:00.000Z");
45+ expect(iso(nextDigestAt(ny, new Date("2026-10-31T12:00:00Z")))).toBe("2026-11-01T13:00:00.000Z");
46+ // Berlin skips 02:00 to 03:00 on 2026-03-29: a 02:00 digest goes out at 03:00 local that day.
47+ const berlin = digest({ hour: 2, timeZone: "Europe/Berlin", weekends: true });
48+ expect(iso(nextDigestAt(berlin, new Date("2026-03-28T12:00:00Z")))).toBe("2026-03-29T01:00:00.000Z");
49+ });
50+});
51+
52+describe("digestSince", () => {
53+ const at = new Date("2026-10-19T08:00:00Z");
54+ it("starts at the last digest, so Monday's covers the weekend", () => {
55+ expect(digestSince("2026-10-16T08:00:00.000Z", at)).toBe("2026-10-16T08:00:00.000Z");
56+ });
57+ it("covers a day without one, or after a long gap", () => {
58+ const day = new Date(at.getTime() - DEFAULT_SINCE_HOURS * 3600_000).toISOString();
59+ expect(digestSince(null, at)).toBe(day);
60+ expect(digestSince("2026-09-01T08:00:00.000Z", at)).toBe(day);
61+ expect(digestSince("2026-10-20T08:00:00.000Z", at)).toBe(day);
62+ });
63+});
64+
65+describe("digest settings", () => {
66+ it("are off by default, and keep only an hour and a time zone that exist", () => {
67+ expect(mergePrefs(null).digest).toEqual({ email: false, hour: 8, timeZone: "UTC", weekends: false });
68+ expect(mergePrefs({ digest: { email: true, hour: 6, timeZone: "Europe/Istanbul", weekends: true } }).digest).toEqual({ email: true, hour: 6, timeZone: "Europe/Istanbul", weekends: true });
69+ expect(mergePrefs({ digest: { email: true, hour: 24, timeZone: "Mars/Olympus" } }).digest).toEqual({ email: true, hour: 8, timeZone: "UTC", weekends: false });
70+ expect(mergePrefs({ digest: { hour: 7.5 } }).digest.hour).toBe(8);
71+ });
72+
73+ it("are their own settings group, so the unsubscribe link turns only the digest off", () => {
74+ const job = { kind: "digest", reason: "digest" } as const;
75+ expect(categoryOf(job)).toBe("digest");
76+ expect(wantsEmail(DEFAULT_PREFS, "digest")).toBe(false);
77+ expect(wantsEmail(mergePrefs({ digest: { email: true } }), "digest")).toBe(true);
78+ });
79+});
80+
81+describe("the digest's schedule", () => {
82+ const ID = "u-mia";
83+ function setup() {
84+ const db = testD1();
85+ db.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, created_at) VALUES (?, 'mia', 'user', 'active', ?)").run(ID, new Date().toISOString());
86+ const store = new MailStore(db);
87+ const sent: MailJob[] = [];
88+ const cron = (at: string) => digestCron(db, new Date(at), (ids) => store.prefsFor(ids), async (jobs) => void sent.push(...jobs));
89+ const row = () => db.sqlite.prepare("SELECT next_at, last_sent_at FROM mail_digests WHERE account_id = ?").get(ID) as { next_at: string; last_sent_at: string | null } | undefined;
90+ return { db, store, sent, cron, row };
91+ }
92+
93+ it("is written when the digest is turned on and removed when it is turned off", async () => {
94+ const { store, row } = setup();
95+ await store.setPrefs(ID, mergePrefs({ digest: { email: true, hour: 9, timeZone: "Europe/Istanbul" } }));
96+ expect(row()?.next_at).toBe(iso(nextDigestAt(digest({ hour: 9, timeZone: "Europe/Istanbul" }), new Date())));
97+ expect(await store.digestNext(ID)).toBe(row()!.next_at);
98+ await store.setPrefs(ID, mergePrefs({ digest: { email: false, hour: 9 } }));
99+ expect(row()).toBeUndefined();
100+ expect(await store.digestNext(ID)).toBeNull();
101+ });
102+
103+ it("queues each due digest once, then moves it to the next day", async () => {
104+ const { db, store, sent, cron, row } = setup();
105+ await store.setPrefs(ID, mergePrefs({ digest: { email: true, hour: 8 } }));
106+ db.sqlite.prepare("UPDATE mail_digests SET next_at = ? WHERE account_id = ?").run("2026-10-14T08:00:00.000Z", ID);
107+ expect(await cron("2026-10-14T07:58:00Z")).toBe(0);
108+ expect(await cron("2026-10-14T08:02:00Z")).toBe(1);
109+ expect(sent).toEqual([{ kind: "digest", data: { since: "2026-10-13T08:02:00.000Z" }, to: { accountId: ID }, reason: "digest", at: "2026-10-14T08:02:00.000Z" }]);
110+ expect(row()).toEqual({ next_at: "2026-10-15T08:00:00.000Z", last_sent_at: "2026-10-14T08:02:00.000Z" });
111+ // The next run, five minutes later, finds nothing due.
112+ expect(await cron("2026-10-14T08:07:00Z")).toBe(0);
113+ // Tomorrow's covers what happened since today's.
114+ expect(await cron("2026-10-15T08:01:00Z")).toBe(1);
115+ expect(sent[1]!.data).toEqual({ since: "2026-10-14T08:02:00.000Z" });
116+ });
117+
118+ it("drops a row whose digest was turned off some other way", async () => {
119+ const { db, sent, cron, row } = setup();
120+ db.sqlite.prepare("INSERT INTO mail_digests (account_id, next_at) VALUES (?, ?)").run(ID, "2026-10-14T08:00:00.000Z");
121+ expect(await cron("2026-10-14T08:01:00Z")).toBe(0);
122+ expect(sent).toEqual([]);
123+ expect(row()).toBeUndefined();
124+ });
125+});
126+
127+// ---------------------------------------------------------------- content and the email
128+
129+const repo: TodayRepo = { id: "r1", fullName: "acme/web", url: "/acme/web", defaultBranch: "main", visibility: "private" };
130+const pull = (n: number, over: Partial<TodayPull> = {}): TodayPull => ({
131+ number: n,
132+ title: `PR ${n}`,
133+ author: { id: "u-bob", login: "bob", kind: "user" },
134+ head: "h",
135+ base: "main",
136+ draft: false,
137+ requestedReviewers: [],
138+ intent: null,
139+ createdAt: "2026-10-13T08:00:00Z",
140+ updatedAt: "2026-10-13T08:00:00Z",
141+ approvedBy: [],
142+ approvedHead: false,
143+ changesRequestedBy: [],
144+ reviewerIds: [],
145+ checks: "success",
146+ failingChecks: [],
147+ autoMerge: false,
148+ queued: false,
149+ ...over,
150+});
151+const today = (over: Partial<Today> = {}): Today => ({
152+ generatedAt: "2026-10-14T08:00:00Z",
153+ since: "2026-10-13T08:00:00Z",
154+ repositories: 3,
155+ incomplete: false,
156+ needsYou: [],
157+ agentsAtWork: [],
158+ landed: [],
159+ releases: [],
160+ failures: [],
161+ deployments: [],
162+ tasks: [],
163+ ...over,
164+});
165+const BASE = "https://diffinery.example";
166+
167+describe("digestSummary", () => {
168+ it("keeps the sections that have something, in Today's order, with links", () => {
169+ const bot = { id: "a-bot", login: "fix-bot", kind: "agent" };
170+ const s = digestSummary(
171+ BASE,
172+ today({
173+ needsYou: [{ kind: "review_requested", repo, pull: pull(4) }],
174+ agentsAtWork: [
175+ { state: "checks_failing", repo, pull: pull(5, { author: bot }) },
176+ { state: "draft", repo, pull: pull(6, { author: bot, draft: true }) },
177+ ],
178+ landed: [{ repo, pull: { number: 3, title: "Merged one", author: bot, mergedBy: "mia", mergedAt: "2026-10-13T20:00:00Z" } }],
179+ }),
180+ "Wednesday, October 14",
181+ );
182+ expect(s.counts).toEqual({ needsYou: 1, agents: 1, merged: 1, failed: 0 });
183+ expect(s.sections.map((x) => x.id)).toEqual(["needs_you", "agents", "landed"]);
184+ expect(s.sections[0]!.items[0]).toEqual({ title: "PR 4", url: `${BASE}/acme/web/pull/4`, where: "acme/web#4", note: "Review requested · by bob", mark: "dot" });
185+ expect(s.sections[1]!.items[0]!.mark).toBe("fail");
186+ expect(s.sections[2]!.items[0]!.note).toBe("by fix-bot (agent), merged by mia");
187+ });
188+
189+ it("caps each section and keeps the total", () => {
190+ const s = digestSummary(BASE, today({ needsYou: Array.from({ length: 12 }, (_, i) => ({ kind: "review_requested" as const, repo, pull: pull(i + 1) })) }), "x");
191+ expect(s.sections[0]!.total).toBe(12);
192+ expect(s.sections[0]!.items.length).toBe(DIGEST_MAX_ITEMS);
193+ });
194+
195+ it("has no sections on a quiet day", () => {
196+ expect(digestSummary(BASE, today(), "x").sections).toEqual([]);
197+ });
198+});
199+
200+describe("the digest email", () => {
201+ const job = (summary: ReturnType<typeof digestSummary>): MailJob => ({ kind: "digest", reason: "digest", to: { accountId: "u" }, at: "2026-10-14T08:00:00Z", data: { since: "2026-10-13T08:00:00Z", summary } });
202+
203+ it("leads with what needs you, and lists each section", () => {
204+ const s = digestSummary(BASE, today({ needsYou: [{ kind: "review_requested", repo, pull: pull(4) }], failures: [{ repo, run: { id: "run1", runNumber: 7, workflow: "CI", title: "Fix it", ref: "refs/heads/main", event: "push", conclusion: "failure", actor: { id: "u", login: "mia", kind: "user" }, prNumber: null, at: "2026-10-13T22:00:00Z" } }] }), localDate("Europe/Istanbul", new Date("2026-10-14T05:00:00Z")));
205+ const e = compose(job(s), BASE);
206+ expect(e.subject).toBe("[Diffinery] Today: 1 needs you, 1 failed run");
207+ expect(e.eyebrow?.text).toBe("Wednesday, October 14");
208+ const text = toText(e, { publicUrl: BASE, reason: "digest" });
209+ expect(text).toContain("Needs you · 1");
210+ expect(text).toContain(`PR 4 acme/web#4 · Review requested · by bob <${BASE}/acme/web/pull/4>`);
211+ expect(text).toContain("[failed] Fix it acme/web · CI on main · mia");
212+ expect(text).toContain(`Open Today: ${BASE}/`);
213+ expect(text).toContain("you turned on the morning digest");
214+ });
215+
216+ it("says so on a quiet day (sent only when asked for)", () => {
217+ const e = compose(job(digestSummary(BASE, today(), "x")), BASE);
218+ expect(e.subject).toBe("[Diffinery] Today: nothing needs you");
219+ expect(toText(e, { publicUrl: BASE, reason: "digest" })).toContain("Nothing needs you");
220+ });
221+});