Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions backend/__tests__/unit/services/digestEmailService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 12 additions & 2 deletions backend/services/dailyDigestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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} ` : '';
Expand Down
31 changes: 31 additions & 0 deletions backend/services/digestEmailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
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;
Expand Down Expand Up @@ -38,6 +61,7 @@ export interface DigestEmailResult {
skipped: number;
failed: number;
unconfigured: boolean;
disabled: boolean;
}

const primaryUrl = (raw: string | undefined, fallback: string): string => {
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions docs/runbooks/daily-digest.md
Original file line number Diff line number Diff line change
@@ -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).
Loading