diff --git a/apps/api/.test-autopilot-hold-once-3499025.db b/apps/api/.test-autopilot-hold-once-3499025.db
new file mode 100644
index 0000000..8b170bb
Binary files /dev/null and b/apps/api/.test-autopilot-hold-once-3499025.db differ
diff --git a/apps/api/.test-autopilot-imported-address-3499025.db b/apps/api/.test-autopilot-imported-address-3499025.db
new file mode 100644
index 0000000..6549eeb
Binary files /dev/null and b/apps/api/.test-autopilot-imported-address-3499025.db differ
diff --git a/apps/api/.test-autopilot-skip-event-3499025.db b/apps/api/.test-autopilot-skip-event-3499025.db
new file mode 100644
index 0000000..5344d95
Binary files /dev/null and b/apps/api/.test-autopilot-skip-event-3499025.db differ
diff --git a/apps/api/.test-digest-notes-3499025.db b/apps/api/.test-digest-notes-3499025.db
new file mode 100644
index 0000000..e69de29
diff --git a/apps/api/.test-digest-off-3499025.db b/apps/api/.test-digest-off-3499025.db
new file mode 100644
index 0000000..d471c50
Binary files /dev/null and b/apps/api/.test-digest-off-3499025.db differ
diff --git a/apps/api/.test-digest-plain-3499025.db b/apps/api/.test-digest-plain-3499025.db
new file mode 100644
index 0000000..e69de29
diff --git a/apps/api/.test-digest-queue-3499025.db b/apps/api/.test-digest-queue-3499025.db
new file mode 100644
index 0000000..e69de29
diff --git a/apps/api/.test-digest-quiet-3499025.db b/apps/api/.test-digest-quiet-3499025.db
new file mode 100644
index 0000000..cc770eb
Binary files /dev/null and b/apps/api/.test-digest-quiet-3499025.db differ
diff --git a/apps/api/.test-digest-window-3499025.db b/apps/api/.test-digest-window-3499025.db
new file mode 100644
index 0000000..92888b1
Binary files /dev/null and b/apps/api/.test-digest-window-3499025.db differ
diff --git a/packages/email/src/notifications.ts b/packages/email/src/notifications.ts
index 25c510c..e394728 100644
--- a/packages/email/src/notifications.ts
+++ b/packages/email/src/notifications.ts
@@ -154,6 +154,13 @@ export interface DigestLead {
export interface DailyDigest {
/** The UTC date this digest covers, `YYYY-MM-DD`. */
readonly date: string;
+ /**
+ * Whose digest this is. One person can own several workspaces (a test one
+ * among them), and two digests that differ only in their numbers read as
+ * one product contradicting itself.
+ */
+ readonly workspaceName?: string;
+ /** The counts below cover the 24 hours before the digest was sent. */
readonly sitesCrawled: number;
readonly peopleFound: number;
readonly messagesSent: number;
@@ -190,10 +197,11 @@ export function dailyDigestEmail(to: string, digest: DailyDigest, appUrl: string
const base = appUrl.replace(/\/$/, '');
const quiet = digest.peopleFound === 0 && digest.messagesSent === 0;
+ const whose = digest.workspaceName ? ` · ${digest.workspaceName}` : '';
const subject = quiet
- ? `Nothing new today · OutreachGraph`
+ ? `Nothing new today${whose} · OutreachGraph`
: `${digest.peopleFound} new ${digest.peopleFound === 1 ? 'lead' : 'leads'}, ` +
- `${digest.messagesSent} sent · OutreachGraph`;
+ `${digest.messagesSent} sent${whose} · OutreachGraph`;
// "Awaiting approval: 1117" was the line that got this digest replied to
// with "why is it not sending?". Most of those cards were not waiting for
@@ -231,13 +239,15 @@ export function dailyDigestEmail(to: string, digest: DailyDigest, appUrl: string
const text = [
`OutreachGraph · ${digest.date}`,
+ digest.workspaceName ? `Workspace: ${digest.workspaceName}` : '',
+ 'Last 24 hours:',
'',
...counts,
'',
quiet
? 'Nothing new came back today. Campaigns are still running.'
: leadLines.length
- ? `Today's leads:\n${leadLines.join('\n')}`
+ ? `New leads:\n${leadLines.join('\n')}`
: '',
digest.notes?.length ? `\nNotes:\n${digest.notes.map((n) => ` · ${n}`).join('\n')}` : '',
'',
@@ -248,7 +258,10 @@ export function dailyDigestEmail(to: string, digest: DailyDigest, appUrl: string
const html = [
markHtml(base),
- `
${escapeHtml(digest.date)}
`,
+ `${escapeHtml(digest.date)}
`,
+ `${
+ digest.workspaceName ? `${escapeHtml(digest.workspaceName)} · ` : ''
+ }last 24 hours
`,
'',
row('Sites read', digest.sitesCrawled),
row('New people', digest.peopleFound),
diff --git a/packages/pipeline/src/autopilot.ts b/packages/pipeline/src/autopilot.ts
index c0c27b7..55bdd40 100644
--- a/packages/pipeline/src/autopilot.ts
+++ b/packages/pipeline/src/autopilot.ts
@@ -256,12 +256,15 @@ export class HoldLedger {
/** The holds in force, grouped by reason, largest group first. */
summary(workspaceId: string): readonly HeldGroup[] {
+ // Grouped by what the reader sees, not by the raw reason: two desks at
+ // different addresses are one line, not "4 held: a desk" and "1 held: a desk".
const groups = new Map();
for (const entry of this.held.get(workspaceId)?.values() ?? []) {
- const group = groups.get(entry.key);
+ const label = describeHold(entry.reason);
+ const group = groups.get(label);
if (group) group.count += 1;
- else groups.set(entry.key, { label: describeHold(entry.reason), count: 1 });
+ else groups.set(label, { label, count: 1 });
}
return [...groups.values()].sort((a, b) => b.count - a.count);
@@ -322,6 +325,7 @@ export function describeHold(reason: string): string {
if (/no address published/i.test(reason)) return 'no address to write to';
if (/own products/i.test(reason)) return "the address is one of this workspace's own";
if (/desk, not a buyer/i.test(reason)) return 'the address is a support or abuse desk';
+ if (/outside business hours/i.test(reason)) return "waiting for the recipient's business hours";
if (/no drafted message/i.test(reason)) return 'no message written yet';
if (/quality checks/i.test(reason)) return 'the draft failed its quality checks';
if (/giving up after/i.test(reason)) return 'sending failed repeatedly';
diff --git a/packages/pipeline/src/notify.test.ts b/packages/pipeline/src/notify.test.ts
index 4328970..58b0e17 100644
--- a/packages/pipeline/src/notify.test.ts
+++ b/packages/pipeline/src/notify.test.ts
@@ -232,6 +232,73 @@ describe('sendDailyDigest', () => {
});
});
+describe('what the digest counts', () => {
+ test('counts the last 24 hours, not since midnight, and names the workspace', async () => {
+ seeded = await seedDatabase('digest-window');
+ const { db } = seeded;
+ await setDigestHour(db, 9);
+
+ const sendAt = at(12);
+ const hoursAgo = (h: number): string =>
+ new Date(sendAt.getTime() - h * 60 * 60 * 1000).toISOString();
+
+ // Two went out yesterday afternoon, after yesterday's digest: a midnight
+ // window never counts them, in any digest. One is older than a day.
+ const sends = [
+ ['act_morning', hoursAgo(2)],
+ ['act_afternoon', hoursAgo(20)],
+ ['act_evening', hoursAgo(16)],
+ ['act_stale', hoursAgo(30)],
+ ];
+ for (const [id, executedAt] of sends) {
+ await db.execute({
+ sql: `INSERT INTO actions (id, workspace_id, recommendation_id, person_id, kind, network,
+ mode, status, created_at, executed_at)
+ VALUES (?, ?, ?, ?, 'send_email', 'email', 'customer_managed', 'completed', ?, ?)`,
+ args: [
+ id!,
+ SEED.workspaceId,
+ SEED.recommendationId,
+ SEED.personId,
+ executedAt!,
+ executedAt!,
+ ],
+ });
+ }
+
+ const { sent, mailer } = recordingMailer();
+ await sendDailyDigest({ db, mailer, appUrl: APP_URL, now: sendAt }, SEED.workspaceId);
+
+ const mail = sent[0];
+ expect(mail?.text).toContain('Messages sent: 3');
+ expect(mail?.text).toContain('Last 24 hours');
+ expect(mail?.subject).toBe('0 new leads, 3 sent · Test · OutreachGraph');
+ });
+
+ test('holds that read the same are one note, not two', async () => {
+ seeded = await seedDatabase('digest-notes');
+ const { db } = seeded;
+ await setDigestHour(db, 9);
+
+ const ledger = new HoldLedger();
+ ledger.observe(SEED.workspaceId, 'rec_a', 'support@a.example is a support desk, not a buyer');
+ ledger.observe(SEED.workspaceId, 'rec_b', 'support@a.example is a support desk, not a buyer');
+ ledger.observe(SEED.workspaceId, 'rec_c', 'abuse@b.example is an abuse desk, not a buyer');
+
+ const { sent, mailer } = recordingMailer();
+ await sendDailyDigest(
+ { db, mailer, appUrl: APP_URL, now: at(12), holdLedger: ledger },
+ SEED.workspaceId,
+ );
+
+ const text = sent[0]?.text ?? '';
+ expect(text).toContain(
+ '3 held in the autopilot queue: the address is a support or abuse desk.',
+ );
+ expect(text.match(/support or abuse desk/g)).toHaveLength(1);
+ });
+});
+
describe('what the digest says about the queue', () => {
test('splits the queue into what autopilot will send and what needs a human', async () => {
seeded = await seedDatabase('digest-queue');
diff --git a/packages/pipeline/src/notify.ts b/packages/pipeline/src/notify.ts
index b893f70..f06357e 100644
--- a/packages/pipeline/src/notify.ts
+++ b/packages/pipeline/src/notify.ts
@@ -292,7 +292,16 @@ export async function sendDailyDigest(deps: NotifyDeps, workspaceId: string): Pr
if (!(await claim(deps.db, workspaceId, 'daily_digest', today, to))) return false;
- const since = `${today}T00:00:00.000Z`;
+ // The last 24 hours, not "since midnight UTC". The digest goes out at its
+ // hour (13:00 by default), so a midnight window only ever saw the morning:
+ // a day that sent 64 reported 3, and everything sent after the digest hour
+ // was never counted by any digest at all.
+ const since = new Date(at.getTime() - DAY_MS).toISOString();
+ const workspace = await queryOne<{ name: string }>(
+ deps.db,
+ `SELECT name FROM workspaces WHERE id = ?`,
+ [workspaceId],
+ );
const internal = INTERNAL_ACTION_KINDS.map(() => '?').join(', ');
@@ -399,6 +408,7 @@ export async function sendDailyDigest(deps: NotifyDeps, workspaceId: string): Pr
const digest: DailyDigest = {
date: today,
+ ...(workspace?.name ? { workspaceName: workspace.name } : {}),
sitesCrawled: crawled,
peopleFound: found,
messagesSent: sent,
@@ -436,6 +446,8 @@ export async function sendDailyDigest(deps: NotifyDeps, workspaceId: string): Pr
return true;
}
+const DAY_MS = 24 * 60 * 60 * 1000;
+
async function countOne(db: Client, sql: string, args: unknown[]): Promise {
const row = await queryOne<{ n: number }>(db, sql, args as never);
return row?.n ?? 0;