From 1d5ce92c86a3078c99cfec1a838fc05cb983e8ef Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:50:39 -0700 Subject: [PATCH] fix(digest): a board line needs something to have happened; a switch stops sending in seconds The live dry run of the deployed recap digest (b6e49229, read-only) found one email due at the 2026-10-17 06:00Z run, to an outside stranger, and its whole content was our own seeded onboarding checklist: three "TASK-00x ... is pending" rows. The starter tasks were created inside the 24h window, so the recap's board counted them. No agent had done anything. - A board line now needs the task to have moved: a status other than pending, or an update note. A task created and never touched is skipped. On the same live data that leaves 0 emails; an internal sample keeps its agents' posts and a starter task that was completed. - A stop switch for digest sending, with no deploy and no restart: one system_settings row, digest.sending = { enabled: false }. No row means on; an unreadable switch sends nothing. Generation still runs. The flip is in docs/runbooks/daily-digest.md, with the rule to dry-run content on live data before it ships. Tests: an untouched pending task is not news, a pending task with an update and a done task are; the switch off sends nothing and stamps nothing, on again sends; an unreadable switch sends nothing. Mutations: the switch ignored, the switch failing open, and untouched tasks kept each red an arm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../dailyDigestService.membership.test.js | 33 +++++++++++++++ .../unit/services/digestEmailService.test.js | 32 +++++++++++++++ backend/services/dailyDigestService.ts | 14 ++++++- backend/services/digestEmailService.ts | 31 ++++++++++++++ docs/runbooks/daily-digest.md | 41 +++++++++++++++++++ 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 docs/runbooks/daily-digest.md diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index a7e53699dc..9933bc73cf 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -155,6 +155,39 @@ test("Scout's welcome is not 'your agents' work'; the person's own agent named S spy.mockRestore(); }); +// Live dry run, 2026-10-16: the only email due was a stranger's seeded +// onboarding checklist, created in the window and never touched. +test('a task created and never touched is not news; one that moved is', async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + board: [ + { + taskId: 'TASK-002', + title: 'Give your agent its first task', + status: 'pending', + podId: 'p1', + podName: 'My Workspace', + lastUpdate: null, + }, + { + taskId: 'TASK-8', title: 'Draft the launch note', status: 'pending', podId: 'p1', podName: 'My Workspace', + lastUpdate: { text: 'Outline posted', author: 'rivet' }, + }, + { + taskId: 'TASK-9', title: 'Ship it', status: 'done', podId: 'p1', podName: 'My Workspace', lastUpdate: null, + }, + ], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.content.split('\n')).toEqual([ + '- TASK-8 Draft the launch note is pending (My Workspace)', + '- TASK-9 Ship it is done (My Workspace)', + ]); + spy.mockRestore(); +}); + // sprint-review on #2267: the feed builds an update's podId from a pod lookup // (activity.pod?.id || null). A line with no pod has unknown provenance and // could be a public pod's, so it is withheld rather than mailed. diff --git a/backend/__tests__/unit/services/digestEmailService.test.js b/backend/__tests__/unit/services/digestEmailService.test.js index 3755ac38da..feca70acfc 100644 --- a/backend/__tests__/unit/services/digestEmailService.test.js +++ b/backend/__tests__/unit/services/digestEmailService.test.js @@ -9,6 +9,7 @@ jest.mock('jsonwebtoken', () => ({})); const User = require('../../../models/User'); const Summary = require('../../../models/Summary'); const digestEmailService = require('../../../services/digestEmailService'); +const SystemSetting = require('../../../models/SystemSetting'); const { setupMongoDb, closeMongoDb, @@ -190,6 +191,37 @@ describe('digestEmailService', () => { expect((await User.findById(user._id)).lastDigestSent).toBeInstanceOf(Date); }); + // The stop switch (2026-10-16): one system_settings row stops every send, + // with no deploy. No row means on. + it('sends nothing when the sending switch is off, and sends again when it is on', async () => { + const user = await createUser(); + const digest = await createDigest(user); + await SystemSetting.create({ key: digestEmailService.DIGEST_SENDING_KEY, value: { enabled: false } }); + + await expect(digestEmailService.sendDigestEmails([runEntry(digest)])) + .resolves.toEqual(expect.objectContaining({ disabled: true, sent: 0 })); + expect(mockSendEmail).not.toHaveBeenCalled(); + expect((await Summary.findById(digest._id)).metadata.emailedAt).toBeUndefined(); + + await SystemSetting.updateOne( + { key: digestEmailService.DIGEST_SENDING_KEY }, + { $set: { value: { enabled: true } } }, + ); + await expect(digestEmailService.sendDigestEmails([runEntry(digest)])) + .resolves.toEqual(expect.objectContaining({ disabled: false, sent: 1 })); + }); + + it('sends nothing when the switch cannot be read', async () => { + const user = await createUser(); + const digest = await createDigest(user); + const read = jest.spyOn(SystemSetting, 'findOne').mockImplementation(() => { throw new Error('mongo down'); }); + + await expect(digestEmailService.sendDigestEmails([runEntry(digest)])) + .resolves.toEqual(expect.objectContaining({ disabled: true, sent: 0 })); + expect(mockSendEmail).not.toHaveBeenCalled(); + read.mockRestore(); + }); + it('logs once and skips the entire run when SMTP is unconfigured', async () => { delete process.env.SMTP2GO_API_KEY; const user = await createUser(); diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts index 53ec7ea848..086aea504c 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -44,7 +44,9 @@ interface RecapLike { messageCount?: number; updates?: Array<{ podId?: string | null; podName?: string; content?: string }>; }>; - board?: Array<{ taskId?: string; title?: string; status?: string; podId?: string; podName?: string }>; + board?: Array<{ + taskId?: string; title?: string; status?: string; podId?: string; podName?: string; lastUpdate?: unknown; + }>; } /** Someone active this recently has seen it in the app; the email would repeat it. */ @@ -116,7 +118,15 @@ export class DailyDigestService { const more = updates.length > 1 ? ` (+${updates.length - 1} more)` : ''; lines.push(`${name} in ${oneLine(latest.podName) || 'a pod'}: ${oneLine(latest.content)}${more}`); }); - (recap.board || []).filter((task) => inPrivatePod(task.podId)).slice(0, MAX_BOARD).forEach((task) => { + // A board line says a task moved. A row created in the window and never + // touched (pending, no update note) has not: the dry run on 2026-10-16 + // found the only email due was a stranger's seeded onboarding checklist, + // three "is pending" rows no agent had done anything to. + const moved = (task: { status?: string; lastUpdate?: unknown }) => ( + String(task.status || '') !== 'pending' || Boolean(task.lastUpdate) + ); + const boardRows = (recap.board || []).filter((task) => inPrivatePod(task.podId) && moved(task)); + boardRows.slice(0, MAX_BOARD).forEach((task) => { const title = oneLine(task.title); if (!title) return; const id = task.taskId ? `${task.taskId} ` : ''; diff --git a/backend/services/digestEmailService.ts b/backend/services/digestEmailService.ts index 5446d617de..b9ac9f81c0 100644 --- a/backend/services/digestEmailService.ts +++ b/backend/services/digestEmailService.ts @@ -4,6 +4,29 @@ const Summary = require('../models/Summary'); const User = require('../models/User'); // eslint-disable-next-line global-require const { sendEmail } = require('./emailService'); +// eslint-disable-next-line global-require +const SystemSetting = require('../models/SystemSetting'); + +/** + * The switch that stops digest SENDING in seconds, with no deploy and no + * restart: a system_settings row { key: 'digest.sending', value: { enabled: + * false } }. No row means on. Generation still runs; only the send stops. + * docs/runbooks/daily-digest.md has the one-line flip. Earned 2026-10-16, + * when the only fallback for a bad run was "land a fix before 06:00Z". + */ +export const DIGEST_SENDING_KEY = 'digest.sending'; + +// Unreadable means do not send: for email to people, the switch fails closed. +const digestSendingEnabled = async (): Promise => { + try { + const row = await SystemSetting.findOne({ key: DIGEST_SENDING_KEY }) + .lean() as { value?: { enabled?: unknown } } | null; + return row?.value?.enabled !== false; + } catch (error) { + console.warn('Digest email delivery skipped: the sending switch could not be read:', (error as Error).message); + return false; + } +}; interface DigestRunResult { success?: boolean; @@ -38,6 +61,7 @@ export interface DigestEmailResult { skipped: number; failed: number; unconfigured: boolean; + disabled: boolean; } const primaryUrl = (raw: string | undefined, fallback: string): string => { @@ -103,8 +127,15 @@ export class DigestEmailService { skipped: 0, failed: 0, unconfigured: false, + disabled: false, }; + if (!await digestSendingEnabled()) { + console.warn(`Digest email delivery skipped: switched off (${DIGEST_SENDING_KEY}).`); + result.disabled = true; + return result; + } + if (!process.env.SMTP2GO_API_KEY || !process.env.SMTP2GO_FROM_EMAIL) { console.warn('Digest email delivery skipped: SMTP2GO is not configured.'); result.unconfigured = true; diff --git a/docs/runbooks/daily-digest.md b/docs/runbooks/daily-digest.md new file mode 100644 index 0000000000..0e3c791809 --- /dev/null +++ b/docs/runbooks/daily-digest.md @@ -0,0 +1,41 @@ +# Daily digest: stopping a bad run + +The daily digest ("What your agents did while you were away") is generated and +emailed by the backend scheduler at **06:00 UTC** (`schedulerService`, +`dailyDigestService` → `digestEmailService`). Content comes from the activity +recap and is limited to the recipient's own private pods. Only digests with at +least one line are emailed, and only to verified users who haven't turned the +digest off. + +## Stop all digest email, in seconds + +Sending is controlled by one `system_settings` row. A missing row means +sending is **on**. Flipping it needs no deploy and no restart, and survives +later deploys. Generation still runs; only the send stops. + +```bash +# OFF +kubectl exec -i -n commonly-dev deploy/backend -c backend -- env NODE_PATH=/app/node_modules node -e ' +const m = require("mongoose"); +m.connect(process.env.MONGO_URI).then(async () => { + await m.connection.db.collection("system_settings").updateOne( + { key: "digest.sending" }, + { $set: { key: "digest.sending", value: { enabled: false }, updatedAt: new Date() }, $setOnInsert: { createdAt: new Date() } }, + { upsert: true }); + console.log("digest sending OFF"); process.exit(0); +});' +``` + +To turn it back on, run the same command with `enabled: true`, or delete the +row. If the row can't be read, nothing is sent: the switch fails closed. The +run logs `Digest email delivery skipped: switched off (digest.sending).` + +## Before a change to digest content ships + +Dry-run the content against live data, read-only (no Summary writes, no +sends). Read every recipient's lines, and split them into outside and internal +before trusting the change. Both content bugs found so far passed every unit +test and showed up only on live data: + +- a support answer to another person in a public pod (#2267); +- a stranger's seeded onboarding checklist, "is pending" ×3 (2026-10-16).