@aglyn/plugins-outreach 1.0.0-beta.160 → 1.0.0-beta.161

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +4 -0
  2. package/package.json +13 -13
  3. package/src/lib/components/compliance-section.d.ts +3 -2
  4. package/src/lib/components/compliance-section.js +7 -2
  5. package/src/lib/components/compliance-section.js.map +1 -1
  6. package/src/lib/components/do-not-contact-domains.d.ts +29 -0
  7. package/src/lib/components/do-not-contact-domains.js +208 -0
  8. package/src/lib/components/do-not-contact-domains.js.map +1 -0
  9. package/src/lib/components/use-outreach-api.d.ts +5 -1
  10. package/src/lib/components/use-outreach-api.js +12 -0
  11. package/src/lib/components/use-outreach-api.js.map +1 -1
  12. package/src/lib/components/use-outreach-data.d.ts +8 -1
  13. package/src/lib/components/use-outreach-data.js +32 -0
  14. package/src/lib/components/use-outreach-data.js.map +1 -1
  15. package/src/lib/constants/api-routes.d.ts +5 -0
  16. package/src/lib/constants/api-routes.js +4 -0
  17. package/src/lib/constants/api-routes.js.map +1 -1
  18. package/src/lib/engine/do-not-contact-domain.d.ts +39 -0
  19. package/src/lib/engine/do-not-contact-domain.js +51 -0
  20. package/src/lib/engine/do-not-contact-domain.js.map +1 -0
  21. package/src/lib/engine/gates.d.ts +6 -1
  22. package/src/lib/engine/gates.js +6 -0
  23. package/src/lib/engine/gates.js.map +1 -1
  24. package/src/lib/engine/gateway-block.d.ts +31 -0
  25. package/src/lib/engine/gateway-block.js +54 -0
  26. package/src/lib/engine/gateway-block.js.map +1 -0
  27. package/src/lib/engine/index.d.ts +3 -0
  28. package/src/lib/engine/index.js +3 -0
  29. package/src/lib/engine/index.js.map +1 -1
  30. package/src/lib/engine/mailbox-health.d.ts +5 -0
  31. package/src/lib/engine/mailbox-health.js +12 -5
  32. package/src/lib/engine/mailbox-health.js.map +1 -1
  33. package/src/lib/engine/mailbox-notice.d.ts +46 -0
  34. package/src/lib/engine/mailbox-notice.js +64 -0
  35. package/src/lib/engine/mailbox-notice.js.map +1 -0
  36. package/src/lib/enrollment/gate-lookups.js +7 -3
  37. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  38. package/src/lib/model/outreach-api.d.ts +23 -2
  39. package/src/lib/model/outreach-api.js.map +1 -1
  40. package/src/lib/model/outreach.types.d.ts +33 -1
  41. package/src/lib/model/outreach.types.js +11 -1
  42. package/src/lib/model/outreach.types.js.map +1 -1
  43. package/src/lib/routes/do-not-contact-routes.d.ts +35 -0
  44. package/src/lib/routes/do-not-contact-routes.js +92 -0
  45. package/src/lib/routes/do-not-contact-routes.js.map +1 -0
  46. package/src/lib/routes/enroll-routes.js +1 -0
  47. package/src/lib/routes/enroll-routes.js.map +1 -1
  48. package/src/lib/routes/register-routes.js +4 -0
  49. package/src/lib/routes/register-routes.js.map +1 -1
  50. package/src/lib/runtime/mailbox-health-store.d.ts +1 -1
  51. package/src/lib/runtime/mailbox-health-store.js +17 -4
  52. package/src/lib/runtime/mailbox-health-store.js.map +1 -1
  53. package/src/lib/runtime/mailbox-notices.d.ts +39 -0
  54. package/src/lib/runtime/mailbox-notices.js +117 -0
  55. package/src/lib/runtime/mailbox-notices.js.map +1 -0
  56. package/src/lib/runtime/person-erasure.d.ts +4 -0
  57. package/src/lib/runtime/person-erasure.js.map +1 -1
  58. package/src/lib/runtime/platform-runtime-deps.js +25 -0
  59. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  60. package/src/lib/runtime/runtime-deps.d.ts +21 -0
  61. package/src/lib/runtime/runtime-deps.js.map +1 -1
  62. package/src/lib/runtime/send-job.js +6 -6
  63. package/src/lib/runtime/send-job.js.map +1 -1
  64. package/src/lib/runtime/sync-job.d.ts +2 -0
  65. package/src/lib/runtime/sync-job.js +34 -7
  66. package/src/lib/runtime/sync-job.js.map +1 -1
  67. package/src/lib/server.d.ts +1 -1
  68. package/src/lib/server.js +1 -1
  69. package/src/lib/server.js.map +1 -1
  70. package/src/lib/storage/do-not-contact-store.d.ts +28 -1
  71. package/src/lib/storage/do-not-contact-store.js +117 -9
  72. package/src/lib/storage/do-not-contact-store.js.map +1 -1
@@ -40,7 +40,7 @@ export declare const emptyOutreachHealthDelta: () => OutreachMailboxHealthDelta;
40
40
  * so — one transaction — then writes the activity line a pause earns.
41
41
  * Answers the decision that paused it, or `null`.
42
42
  */
43
- export declare function applyOutreachMailboxHealth(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'logOrgActivity'>, input: {
43
+ export declare function applyOutreachMailboxHealth(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'logOrgActivity' | 'notifyMailboxOwner'>, input: {
44
44
  orgId: string;
45
45
  mailboxId: string;
46
46
  delta: OutreachMailboxHealthDelta;
@@ -18,6 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { decideOutreachMailboxHealth, OUTREACH_BOUNCE_RATE_WINDOW_SENDS } from "../engine/mailbox-health.js";
19
19
  import { mailboxRef } from "../mailboxes/mailbox-credentials.js";
20
20
  import { outreachLastSevenDays, outreachLocalDay } from "../mailboxes/mailbox-settings.js";
21
+ import { notifyOutreachMailboxOwner } from "./mailbox-notices.js";
21
22
  /**
22
23
  * WHAT A MAILBOX'S HEALTH IS JUDGED ON, KEPT AS IT HAPPENS (AGL-2981).
23
24
  *
@@ -40,8 +41,10 @@ import { outreachLastSevenDays, outreachLocalDay } from "../mailboxes/mailbox-se
40
41
  * it lands in.
41
42
  *
42
43
  * A pause is the mailbox's own: status `paused` with `autoPause` beside it,
43
- * which the Mailboxes card shows as the reason, and a line on the
44
- * organization's activity feed under `outreach:mailbox`.
44
+ * which the Mailboxes card shows as the reason, a line on the
45
+ * organization's activity feed under `outreach:mailbox`, and an email to
46
+ * the member whose mailbox it is (AGL-3244) — sent by the write that paused
47
+ * it, so once per pause.
45
48
  */ /** The activity target a mailbox's rows are filed under (AGL-2978). */ const MAILBOX_TARGET = 'outreach:mailbox';
46
49
  /** How many local days of `health.daily` the runtime keeps. */ const DAILY_DAYS_KEPT = 14;
47
50
  /** The digest a send is named by in `recentSends`. */ export function outreachSendDigest(messageId) {
@@ -94,6 +97,7 @@ export const emptyOutreachHealthDelta = ()=>({
94
97
  * so — one transaction — then writes the activity line a pause earns.
95
98
  * Answers the decision that paused it, or `null`.
96
99
  */ export async function applyOutreachMailboxHealth(deps, input) {
100
+ var _outcome_decision_message;
97
101
  const { delta } = input;
98
102
  if (!delta.bounces.length && !delta.replies && delta.complaintAtMs === null) return null;
99
103
  const firestore = deps.firestore();
@@ -151,7 +155,7 @@ export const emptyOutreachHealthDelta = ()=>({
151
155
  transaction.update(ref, update);
152
156
  return (decision == null ? void 0 : decision.pause) ? {
153
157
  decision,
154
- email: mailbox.email
158
+ mailbox
155
159
  } : null;
156
160
  });
157
161
  if (!outcome) return null;
@@ -160,7 +164,16 @@ export const emptyOutreachHealthDelta = ()=>({
160
164
  }, `Paused a mailbox in Sequences automatically: ${outcome.decision.message}`, {
161
165
  type: MAILBOX_TARGET,
162
166
  id: input.mailboxId,
163
- name: outcome.email
167
+ name: outcome.mailbox.email
168
+ });
169
+ // The owner is told by the write that paused it (AGL-3244), and so once:
170
+ // a mailbox already paused is not judged again above, and so never
171
+ // reaches here until a member resumes it and new evidence arrives.
172
+ await notifyOutreachMailboxOwner(deps, {
173
+ orgId: input.orgId,
174
+ mailbox: outcome.mailbox,
175
+ kind: 'auto_pause',
176
+ message: (_outcome_decision_message = outcome.decision.message) != null ? _outcome_decision_message : ''
164
177
  });
165
178
  return outcome.decision;
166
179
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/mailbox-health-store.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createHash } from 'node:crypto'\nimport {\n decideOutreachMailboxHealth,\n OUTREACH_BOUNCE_RATE_WINDOW_SENDS,\n type OutreachMailboxHealthDecision,\n} from '../engine/mailbox-health'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLastSevenDays, outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport type {\n OutreachMailbox,\n OutreachMailboxDailyHealth,\n OutreachRecentSend,\n} from '../model/outreach.types'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT A MAILBOX'S HEALTH IS JUDGED ON, KEPT AS IT HAPPENS (AGL-2981).\n *\n * The engine's `decideOutreachMailboxHealth` is pure: it needs the hard\n * bounces in the mailbox's current day, its last fifty sends and which of\n * them bounced, and when a reply last called its mail spam. This is where\n * the runtime keeps those on the mailbox document:\n *\n * - a send appends `{ id, atMs, bounced: false }` to `health.recentSends`,\n * the id a digest of its `Message-ID`, never its recipient, and the list\n * keeps the last {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS};\n * - a hard bounce marks its send there, when the send is still in the\n * window, and counts on `health.daily[<its local day>].bounces`;\n * - a complaint moves `health.lastComplaintAtMs`.\n *\n * The health is judged when NEW evidence arrives — a bounce or a complaint\n * this run recorded — and never otherwise. A member who re-checked the list\n * and resumed the mailbox is not paused again by the evidence they already\n * answered; the next bounce is new evidence, and is judged with the window\n * it lands in.\n *\n * A pause is the mailbox's own: status `paused` with `autoPause` beside it,\n * which the Mailboxes card shows as the reason, and a line on the\n * organization's activity feed under `outreach:mailbox`.\n */\n\n/** The activity target a mailbox's rows are filed under (AGL-2978). */\nconst MAILBOX_TARGET = 'outreach:mailbox' as const\n\n/** How many local days of `health.daily` the runtime keeps. */\nconst DAILY_DAYS_KEPT = 14\n\n/** The digest a send is named by in `recentSends`. */\nexport function outreachSendDigest(messageId: string): string {\n return createHash('sha256').update(String(messageId).trim()).digest('hex').slice(0, 16)\n}\n\n/** The window with one more send in it, the oldest dropped past the limit. */\nexport function withRecentSend(\n recent: readonly OutreachRecentSend[] | null | undefined,\n send: OutreachRecentSend,\n): OutreachRecentSend[] {\n return [...(recent ?? []).filter((entry) => entry?.id !== send.id), send].slice(\n -OUTREACH_BOUNCE_RATE_WINDOW_SENDS,\n )\n}\n\n/** The engine's inputs, read off a mailbox as stored. */\nexport function outreachMailboxHealthInput(\n mailbox: Pick<OutreachMailbox, 'health' | 'timezone'>,\n nowMs: number,\n): Parameters<typeof decideOutreachMailboxHealth>[0] {\n const recent = (mailbox.health?.recentSends ?? []).slice(-OUTREACH_BOUNCE_RATE_WINDOW_SENDS)\n const today = outreachLocalDay(nowMs, mailbox.timezone)\n return {\n bouncesToday: Number(mailbox.health?.daily?.[today]?.bounces ?? 0) || 0,\n recentSends: recent.length,\n recentHardBounces: recent.filter((send) => send?.bounced === true).length,\n lastComplaintAtMs: mailbox.health?.lastComplaintAtMs ?? null,\n nowMs,\n }\n}\n\n/** The daily map with the days older than the runtime keeps dropped. */\nfunction prunedDaily(\n daily: Record<string, OutreachMailboxDailyHealth> | undefined,\n nowMs: number,\n timezone: string,\n): Record<string, OutreachMailboxDailyHealth> {\n const kept = new Set<string>()\n for (let week = 0; week * 7 < DAILY_DAYS_KEPT; week += 1) {\n for (const day of outreachLastSevenDays(nowMs - week * 7 * 86_400_000, timezone)) kept.add(day)\n }\n return Object.fromEntries(Object.entries(daily ?? {}).filter(([day]) => kept.has(day)))\n}\n\nconst bump = (\n daily: Record<string, OutreachMailboxDailyHealth>,\n day: string,\n field: keyof OutreachMailboxDailyHealth,\n by = 1,\n) => {\n const counts = daily[day] ?? { sent: 0, bounces: 0, replies: 0 }\n daily[day] = { ...counts, [field]: Math.max(0, Number(counts[field]) || 0) + by }\n}\n\n/** What one run learned about a mailbox, applied in one write. */\nexport interface OutreachMailboxHealthDelta {\n /** Hard bounces, each with when it arrived and the send it reports, when known. */\n bounces: Array<{ atMs: number; messageId: string | null }>\n replies: number\n /** The latest complaint this run read, epoch ms. */\n complaintAtMs: number | null\n}\n\nexport const emptyOutreachHealthDelta = (): OutreachMailboxHealthDelta => ({\n bounces: [],\n replies: 0,\n complaintAtMs: null,\n})\n\n/**\n * Applies a run's evidence to a mailbox and pauses it when the engine says\n * so — one transaction — then writes the activity line a pause earns.\n * Answers the decision that paused it, or `null`.\n */\nexport async function applyOutreachMailboxHealth(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'logOrgActivity'>,\n input: { orgId: string; mailboxId: string; delta: OutreachMailboxHealthDelta },\n): Promise<OutreachMailboxHealthDecision | null> {\n const { delta } = input\n if (!delta.bounces.length && !delta.replies && delta.complaintAtMs === null) return null\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const ref = mailboxRef(firestore, input.orgId, input.mailboxId)\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) return null\n const mailbox = { ...(snapshot.data() as OutreachMailbox), id: snapshot.id }\n const timezone = mailbox.timezone\n const daily = prunedDaily(mailbox.health?.daily, nowMs, timezone)\n let recent = [...(mailbox.health?.recentSends ?? [])]\n for (const bounce of delta.bounces) {\n bump(daily, outreachLocalDay(bounce.atMs, timezone), 'bounces')\n if (bounce.messageId) {\n const id = outreachSendDigest(bounce.messageId)\n recent = recent.map((send) => (send?.id === id ? { ...send, bounced: true } : send))\n }\n }\n if (delta.replies) bump(daily, outreachLocalDay(nowMs, timezone), 'replies', delta.replies)\n const lastComplaintAtMs =\n delta.complaintAtMs === null\n ? (mailbox.health?.lastComplaintAtMs ?? null)\n : Math.max(delta.complaintAtMs, Number(mailbox.health?.lastComplaintAtMs) || 0)\n const health = {\n ...mailbox.health,\n bounces: Math.max(0, Number(mailbox.health?.bounces) || 0) + delta.bounces.length,\n replies: Math.max(0, Number(mailbox.health?.replies) || 0) + delta.replies,\n daily,\n recentSends: recent,\n lastComplaintAtMs,\n }\n const judged = { ...mailbox, health }\n const newEvidence = delta.bounces.length > 0 || delta.complaintAtMs !== null\n const decision =\n newEvidence && mailbox.status === 'connected'\n ? decideOutreachMailboxHealth(outreachMailboxHealthInput(judged, nowMs))\n : null\n const update: Record<string, unknown> = { health, updatedAtMs: nowMs }\n if (decision?.pause && decision.reason && decision.message) {\n update['status'] = 'paused'\n update['autoPause'] = {\n reason: decision.reason,\n message: decision.message,\n atMs: nowMs,\n untilMs: decision.pausedUntilMs,\n }\n }\n transaction.update(ref, update)\n return decision?.pause ? { decision, email: mailbox.email } : null\n })\n if (!outcome) return null\n await deps.logOrgActivity(\n input.orgId,\n { uid: null },\n `Paused a mailbox in Sequences automatically: ${outcome.decision.message}`,\n { type: MAILBOX_TARGET, id: input.mailboxId, name: outcome.email },\n )\n return outcome.decision\n}\n"],"names":["createHash","decideOutreachMailboxHealth","OUTREACH_BOUNCE_RATE_WINDOW_SENDS","mailboxRef","outreachLastSevenDays","outreachLocalDay","MAILBOX_TARGET","DAILY_DAYS_KEPT","outreachSendDigest","messageId","update","String","trim","digest","slice","withRecentSend","recent","send","filter","entry","id","outreachMailboxHealthInput","mailbox","nowMs","health","recentSends","today","timezone","bouncesToday","Number","daily","bounces","length","recentHardBounces","bounced","lastComplaintAtMs","prunedDaily","kept","Set","week","day","add","Object","fromEntries","entries","has","bump","field","by","counts","sent","replies","Math","max","emptyOutreachHealthDelta","complaintAtMs","applyOutreachMailboxHealth","deps","input","delta","firestore","now","ref","orgId","mailboxId","outcome","runTransaction","transaction","snapshot","get","exists","data","bounce","atMs","map","judged","newEvidence","decision","status","updatedAtMs","pause","reason","message","untilMs","pausedUntilMs","email","logOrgActivity","uid","type","name"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/mailbox-health-store.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createHash } from 'node:crypto'\nimport {\n decideOutreachMailboxHealth,\n OUTREACH_BOUNCE_RATE_WINDOW_SENDS,\n type OutreachMailboxHealthDecision,\n} from '../engine/mailbox-health'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLastSevenDays, outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport type {\n OutreachMailbox,\n OutreachMailboxDailyHealth,\n OutreachRecentSend,\n} from '../model/outreach.types'\nimport { notifyOutreachMailboxOwner } from './mailbox-notices'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT A MAILBOX'S HEALTH IS JUDGED ON, KEPT AS IT HAPPENS (AGL-2981).\n *\n * The engine's `decideOutreachMailboxHealth` is pure: it needs the hard\n * bounces in the mailbox's current day, its last fifty sends and which of\n * them bounced, and when a reply last called its mail spam. This is where\n * the runtime keeps those on the mailbox document:\n *\n * - a send appends `{ id, atMs, bounced: false }` to `health.recentSends`,\n * the id a digest of its `Message-ID`, never its recipient, and the list\n * keeps the last {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS};\n * - a hard bounce marks its send there, when the send is still in the\n * window, and counts on `health.daily[<its local day>].bounces`;\n * - a complaint moves `health.lastComplaintAtMs`.\n *\n * The health is judged when NEW evidence arrives — a bounce or a complaint\n * this run recorded — and never otherwise. A member who re-checked the list\n * and resumed the mailbox is not paused again by the evidence they already\n * answered; the next bounce is new evidence, and is judged with the window\n * it lands in.\n *\n * A pause is the mailbox's own: status `paused` with `autoPause` beside it,\n * which the Mailboxes card shows as the reason, a line on the\n * organization's activity feed under `outreach:mailbox`, and an email to\n * the member whose mailbox it is (AGL-3244) — sent by the write that paused\n * it, so once per pause.\n */\n\n/** The activity target a mailbox's rows are filed under (AGL-2978). */\nconst MAILBOX_TARGET = 'outreach:mailbox' as const\n\n/** How many local days of `health.daily` the runtime keeps. */\nconst DAILY_DAYS_KEPT = 14\n\n/** The digest a send is named by in `recentSends`. */\nexport function outreachSendDigest(messageId: string): string {\n return createHash('sha256').update(String(messageId).trim()).digest('hex').slice(0, 16)\n}\n\n/** The window with one more send in it, the oldest dropped past the limit. */\nexport function withRecentSend(\n recent: readonly OutreachRecentSend[] | null | undefined,\n send: OutreachRecentSend,\n): OutreachRecentSend[] {\n return [...(recent ?? []).filter((entry) => entry?.id !== send.id), send].slice(\n -OUTREACH_BOUNCE_RATE_WINDOW_SENDS,\n )\n}\n\n/** The engine's inputs, read off a mailbox as stored. */\nexport function outreachMailboxHealthInput(\n mailbox: Pick<OutreachMailbox, 'health' | 'timezone'>,\n nowMs: number,\n): Parameters<typeof decideOutreachMailboxHealth>[0] {\n const recent = (mailbox.health?.recentSends ?? []).slice(-OUTREACH_BOUNCE_RATE_WINDOW_SENDS)\n const today = outreachLocalDay(nowMs, mailbox.timezone)\n return {\n bouncesToday: Number(mailbox.health?.daily?.[today]?.bounces ?? 0) || 0,\n recentSends: recent.length,\n recentHardBounces: recent.filter((send) => send?.bounced === true).length,\n lastComplaintAtMs: mailbox.health?.lastComplaintAtMs ?? null,\n nowMs,\n }\n}\n\n/** The daily map with the days older than the runtime keeps dropped. */\nfunction prunedDaily(\n daily: Record<string, OutreachMailboxDailyHealth> | undefined,\n nowMs: number,\n timezone: string,\n): Record<string, OutreachMailboxDailyHealth> {\n const kept = new Set<string>()\n for (let week = 0; week * 7 < DAILY_DAYS_KEPT; week += 1) {\n for (const day of outreachLastSevenDays(nowMs - week * 7 * 86_400_000, timezone)) kept.add(day)\n }\n return Object.fromEntries(Object.entries(daily ?? {}).filter(([day]) => kept.has(day)))\n}\n\nconst bump = (\n daily: Record<string, OutreachMailboxDailyHealth>,\n day: string,\n field: keyof OutreachMailboxDailyHealth,\n by = 1,\n) => {\n const counts = daily[day] ?? { sent: 0, bounces: 0, replies: 0 }\n daily[day] = { ...counts, [field]: Math.max(0, Number(counts[field]) || 0) + by }\n}\n\n/** What one run learned about a mailbox, applied in one write. */\nexport interface OutreachMailboxHealthDelta {\n /** Hard bounces, each with when it arrived and the send it reports, when known. */\n bounces: Array<{ atMs: number; messageId: string | null }>\n replies: number\n /** The latest complaint this run read, epoch ms. */\n complaintAtMs: number | null\n}\n\nexport const emptyOutreachHealthDelta = (): OutreachMailboxHealthDelta => ({\n bounces: [],\n replies: 0,\n complaintAtMs: null,\n})\n\n/**\n * Applies a run's evidence to a mailbox and pauses it when the engine says\n * so — one transaction — then writes the activity line a pause earns.\n * Answers the decision that paused it, or `null`.\n */\nexport async function applyOutreachMailboxHealth(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'logOrgActivity' | 'notifyMailboxOwner'>,\n input: { orgId: string; mailboxId: string; delta: OutreachMailboxHealthDelta },\n): Promise<OutreachMailboxHealthDecision | null> {\n const { delta } = input\n if (!delta.bounces.length && !delta.replies && delta.complaintAtMs === null) return null\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const ref = mailboxRef(firestore, input.orgId, input.mailboxId)\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) return null\n const mailbox = { ...(snapshot.data() as OutreachMailbox), id: snapshot.id }\n const timezone = mailbox.timezone\n const daily = prunedDaily(mailbox.health?.daily, nowMs, timezone)\n let recent = [...(mailbox.health?.recentSends ?? [])]\n for (const bounce of delta.bounces) {\n bump(daily, outreachLocalDay(bounce.atMs, timezone), 'bounces')\n if (bounce.messageId) {\n const id = outreachSendDigest(bounce.messageId)\n recent = recent.map((send) => (send?.id === id ? { ...send, bounced: true } : send))\n }\n }\n if (delta.replies) bump(daily, outreachLocalDay(nowMs, timezone), 'replies', delta.replies)\n const lastComplaintAtMs =\n delta.complaintAtMs === null\n ? (mailbox.health?.lastComplaintAtMs ?? null)\n : Math.max(delta.complaintAtMs, Number(mailbox.health?.lastComplaintAtMs) || 0)\n const health = {\n ...mailbox.health,\n bounces: Math.max(0, Number(mailbox.health?.bounces) || 0) + delta.bounces.length,\n replies: Math.max(0, Number(mailbox.health?.replies) || 0) + delta.replies,\n daily,\n recentSends: recent,\n lastComplaintAtMs,\n }\n const judged = { ...mailbox, health }\n const newEvidence = delta.bounces.length > 0 || delta.complaintAtMs !== null\n const decision =\n newEvidence && mailbox.status === 'connected'\n ? decideOutreachMailboxHealth(outreachMailboxHealthInput(judged, nowMs))\n : null\n const update: Record<string, unknown> = { health, updatedAtMs: nowMs }\n if (decision?.pause && decision.reason && decision.message) {\n update['status'] = 'paused'\n update['autoPause'] = {\n reason: decision.reason,\n message: decision.message,\n atMs: nowMs,\n untilMs: decision.pausedUntilMs,\n }\n }\n transaction.update(ref, update)\n return decision?.pause ? { decision, mailbox } : null\n })\n if (!outcome) return null\n await deps.logOrgActivity(\n input.orgId,\n { uid: null },\n `Paused a mailbox in Sequences automatically: ${outcome.decision.message}`,\n { type: MAILBOX_TARGET, id: input.mailboxId, name: outcome.mailbox.email },\n )\n // The owner is told by the write that paused it (AGL-3244), and so once:\n // a mailbox already paused is not judged again above, and so never\n // reaches here until a member resumes it and new evidence arrives.\n await notifyOutreachMailboxOwner(deps, {\n orgId: input.orgId,\n mailbox: outcome.mailbox,\n kind: 'auto_pause',\n message: outcome.decision.message ?? '',\n })\n return outcome.decision\n}\n"],"names":["createHash","decideOutreachMailboxHealth","OUTREACH_BOUNCE_RATE_WINDOW_SENDS","mailboxRef","outreachLastSevenDays","outreachLocalDay","notifyOutreachMailboxOwner","MAILBOX_TARGET","DAILY_DAYS_KEPT","outreachSendDigest","messageId","update","String","trim","digest","slice","withRecentSend","recent","send","filter","entry","id","outreachMailboxHealthInput","mailbox","nowMs","health","recentSends","today","timezone","bouncesToday","Number","daily","bounces","length","recentHardBounces","bounced","lastComplaintAtMs","prunedDaily","kept","Set","week","day","add","Object","fromEntries","entries","has","bump","field","by","counts","sent","replies","Math","max","emptyOutreachHealthDelta","complaintAtMs","applyOutreachMailboxHealth","deps","input","outcome","delta","firestore","now","ref","orgId","mailboxId","runTransaction","transaction","snapshot","get","exists","data","bounce","atMs","map","judged","newEvidence","decision","status","updatedAtMs","pause","reason","message","untilMs","pausedUntilMs","logOrgActivity","uid","type","name","email","kind"],"mappings":"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"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type OutreachMailboxNoticeKind } from '../engine/mailbox-notice';
18
+ import type { OutreachMailbox } from '../model/outreach.types';
19
+ import type { OutreachRuntimeDeps } from './runtime-deps';
20
+ /** How many enrollments are active on a mailbox — the ones its pause is holding. */
21
+ export declare function countOutreachMailboxWaiting(firestore: FirebaseFirestore.Firestore, orgId: string, mailboxId: string): Promise<number>;
22
+ /** Emails the owner through the platform's seam; never throws. */
23
+ export declare function notifyOutreachMailboxOwner(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'notifyMailboxOwner'>, input: {
24
+ orgId: string;
25
+ mailbox: Pick<OutreachMailbox, 'id' | 'email' | 'sendAs' | 'displayName' | 'connectedByUid'>;
26
+ kind: OutreachMailboxNoticeKind;
27
+ message: string;
28
+ }): Promise<void>;
29
+ /**
30
+ * Marks a mailbox `reconnect_required` and tells its owner — once: a
31
+ * mailbox already waiting to be reconnected is left as it is, and nobody is
32
+ * told twice. Answers whether this call was the one that marked it.
33
+ */
34
+ export declare function noteOutreachMailboxReconnectRequired(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'notifyMailboxOwner' | 'logOrgActivity'>, input: {
35
+ orgId: string;
36
+ mailboxId: string;
37
+ errorCode: string;
38
+ nowMs: number;
39
+ }): Promise<boolean>;
@@ -0,0 +1,117 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { outreachReconnectRequiredSentence } from "../engine/mailbox-notice.js";
18
+ import { mailboxRef } from "../mailboxes/mailbox-credentials.js";
19
+ import { outreachOrgCollection } from "../storage/outreach-records.js";
20
+ /**
21
+ * TELLING THE OWNER, ONCE (AGL-3244).
22
+ *
23
+ * A mailbox that stops sending — paused by its own health, or refused by
24
+ * Google — is told to its owner the moment the runtime writes the state,
25
+ * and told ONCE: the write that changes the status is the trigger, so a run
26
+ * that finds the mailbox already paused or already waiting to be
27
+ * reconnected sends nothing. The pause's write lives in
28
+ * `mailbox-health-store.ts` and calls {@link notifyOutreachMailboxOwner}
29
+ * after its transaction; the reconnect's write is
30
+ * {@link noteOutreachMailboxReconnectRequired}, here, which does the same
31
+ * inside one transaction that reads the status first.
32
+ *
33
+ * The notice is a courtesy beside the state: nothing here throws, and a
34
+ * notice that could not be sent leaves the mailbox exactly as paused as it
35
+ * was.
36
+ */ /** The activity target a mailbox's rows are filed under (AGL-2978). */ const MAILBOX_TARGET = 'outreach:mailbox';
37
+ /** How many enrollments are active on a mailbox — the ones its pause is holding. */ export async function countOutreachMailboxWaiting(firestore, orgId, mailboxId) {
38
+ try {
39
+ const active = await outreachOrgCollection(firestore, orgId, 'enrollments').where('mailboxId', '==', mailboxId).where('status', '==', 'active').get();
40
+ return active.size;
41
+ } catch (error) {
42
+ console.error('[outreach] counting a mailbox’s waiting enrollments failed', error);
43
+ return 0;
44
+ }
45
+ }
46
+ /** Emails the owner through the platform's seam; never throws. */ export async function notifyOutreachMailboxOwner(deps, input) {
47
+ const { mailbox } = input;
48
+ try {
49
+ const waiting = await countOutreachMailboxWaiting(deps.firestore(), input.orgId, mailbox.id);
50
+ await deps.notifyMailboxOwner({
51
+ orgId: input.orgId,
52
+ mailboxId: mailbox.id,
53
+ connectedByUid: mailbox.connectedByUid,
54
+ kind: input.kind,
55
+ mailbox: {
56
+ email: mailbox.email,
57
+ sendAs: mailbox.sendAs,
58
+ displayName: mailbox.displayName
59
+ },
60
+ message: input.message,
61
+ waiting
62
+ });
63
+ } catch (error) {
64
+ console.error('[outreach] the mailbox owner could not be told', error);
65
+ }
66
+ }
67
+ /**
68
+ * Marks a mailbox `reconnect_required` and tells its owner — once: a
69
+ * mailbox already waiting to be reconnected is left as it is, and nobody is
70
+ * told twice. Answers whether this call was the one that marked it.
71
+ */ export async function noteOutreachMailboxReconnectRequired(deps, input) {
72
+ const firestore = deps.firestore();
73
+ const ref = mailboxRef(firestore, input.orgId, input.mailboxId);
74
+ let marked;
75
+ try {
76
+ marked = await firestore.runTransaction(async (transaction)=>{
77
+ const snapshot = await transaction.get(ref);
78
+ if (!snapshot.exists) return null;
79
+ const mailbox = _extends({}, snapshot.data(), {
80
+ id: snapshot.id
81
+ });
82
+ if (mailbox.status === 'reconnect_required') return null;
83
+ transaction.update(ref, {
84
+ status: 'reconnect_required',
85
+ 'health.lastErrorAtMs': input.nowMs,
86
+ 'health.lastErrorCode': input.errorCode,
87
+ updatedAtMs: input.nowMs
88
+ });
89
+ return mailbox;
90
+ });
91
+ } catch (error) {
92
+ console.error('[outreach] marking a mailbox reconnect_required failed', error);
93
+ return false;
94
+ }
95
+ if (!marked) return false;
96
+ const message = outreachReconnectRequiredSentence(input.errorCode);
97
+ try {
98
+ await deps.logOrgActivity(input.orgId, {
99
+ uid: null
100
+ }, `A mailbox in Sequences needs reconnecting: Google stopped accepting its connection (${input.errorCode})`, {
101
+ type: MAILBOX_TARGET,
102
+ id: marked.id,
103
+ name: marked.email
104
+ });
105
+ } catch (error) {
106
+ console.error('[outreach] the reconnect activity line could not be written', error);
107
+ }
108
+ await notifyOutreachMailboxOwner(deps, {
109
+ orgId: input.orgId,
110
+ mailbox: marked,
111
+ kind: 'reconnect_required',
112
+ message
113
+ });
114
+ return true;
115
+ }
116
+
117
+ //# sourceMappingURL=mailbox-notices.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/mailbox-notices.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { outreachReconnectRequiredSentence, type OutreachMailboxNoticeKind } from '../engine/mailbox-notice'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport type { OutreachMailbox } from '../model/outreach.types'\nimport { outreachOrgCollection } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * TELLING THE OWNER, ONCE (AGL-3244).\n *\n * A mailbox that stops sending — paused by its own health, or refused by\n * Google — is told to its owner the moment the runtime writes the state,\n * and told ONCE: the write that changes the status is the trigger, so a run\n * that finds the mailbox already paused or already waiting to be\n * reconnected sends nothing. The pause's write lives in\n * `mailbox-health-store.ts` and calls {@link notifyOutreachMailboxOwner}\n * after its transaction; the reconnect's write is\n * {@link noteOutreachMailboxReconnectRequired}, here, which does the same\n * inside one transaction that reads the status first.\n *\n * The notice is a courtesy beside the state: nothing here throws, and a\n * notice that could not be sent leaves the mailbox exactly as paused as it\n * was.\n */\n\n/** The activity target a mailbox's rows are filed under (AGL-2978). */\nconst MAILBOX_TARGET = 'outreach:mailbox' as const\n\n/** How many enrollments are active on a mailbox — the ones its pause is holding. */\nexport async function countOutreachMailboxWaiting(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n mailboxId: string,\n): Promise<number> {\n try {\n const active = await outreachOrgCollection(firestore, orgId, 'enrollments')\n .where('mailboxId', '==', mailboxId)\n .where('status', '==', 'active')\n .get()\n return active.size\n } catch (error) {\n console.error('[outreach] counting a mailbox’s waiting enrollments failed', error)\n return 0\n }\n}\n\n/** Emails the owner through the platform's seam; never throws. */\nexport async function notifyOutreachMailboxOwner(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'notifyMailboxOwner'>,\n input: {\n orgId: string\n mailbox: Pick<OutreachMailbox, 'id' | 'email' | 'sendAs' | 'displayName' | 'connectedByUid'>\n kind: OutreachMailboxNoticeKind\n message: string\n },\n): Promise<void> {\n const { mailbox } = input\n try {\n const waiting = await countOutreachMailboxWaiting(deps.firestore(), input.orgId, mailbox.id)\n await deps.notifyMailboxOwner({\n orgId: input.orgId,\n mailboxId: mailbox.id,\n connectedByUid: mailbox.connectedByUid,\n kind: input.kind,\n mailbox: { email: mailbox.email, sendAs: mailbox.sendAs, displayName: mailbox.displayName },\n message: input.message,\n waiting,\n })\n } catch (error) {\n console.error('[outreach] the mailbox owner could not be told', error)\n }\n}\n\n/**\n * Marks a mailbox `reconnect_required` and tells its owner — once: a\n * mailbox already waiting to be reconnected is left as it is, and nobody is\n * told twice. Answers whether this call was the one that marked it.\n */\nexport async function noteOutreachMailboxReconnectRequired(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'notifyMailboxOwner' | 'logOrgActivity'>,\n input: { orgId: string; mailboxId: string; errorCode: string; nowMs: number },\n): Promise<boolean> {\n const firestore = deps.firestore()\n const ref = mailboxRef(firestore, input.orgId, input.mailboxId)\n let marked: OutreachMailbox | null\n try {\n marked = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) return null\n const mailbox = { ...(snapshot.data() as OutreachMailbox), id: snapshot.id }\n if (mailbox.status === 'reconnect_required') return null\n transaction.update(ref, {\n status: 'reconnect_required',\n 'health.lastErrorAtMs': input.nowMs,\n 'health.lastErrorCode': input.errorCode,\n updatedAtMs: input.nowMs,\n })\n return mailbox\n })\n } catch (error) {\n console.error('[outreach] marking a mailbox reconnect_required failed', error)\n return false\n }\n if (!marked) return false\n const message = outreachReconnectRequiredSentence(input.errorCode)\n try {\n await deps.logOrgActivity(\n input.orgId,\n { uid: null },\n `A mailbox in Sequences needs reconnecting: Google stopped accepting its connection (${input.errorCode})`,\n { type: MAILBOX_TARGET, id: marked.id, name: marked.email },\n )\n } catch (error) {\n console.error('[outreach] the reconnect activity line could not be written', error)\n }\n await notifyOutreachMailboxOwner(deps, { orgId: input.orgId, mailbox: marked, kind: 'reconnect_required', message })\n return true\n}\n"],"names":["outreachReconnectRequiredSentence","mailboxRef","outreachOrgCollection","MAILBOX_TARGET","countOutreachMailboxWaiting","firestore","orgId","mailboxId","active","where","get","size","error","console","notifyOutreachMailboxOwner","deps","input","mailbox","waiting","id","notifyMailboxOwner","connectedByUid","kind","email","sendAs","displayName","message","noteOutreachMailboxReconnectRequired","ref","marked","runTransaction","transaction","snapshot","exists","data","status","update","nowMs","errorCode","updatedAtMs","logOrgActivity","uid","type","name"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iCAAiC,QAAwC,8BAA0B;AAC5G,SAASC,UAAU,QAAQ,sCAAkC;AAE7D,SAASC,qBAAqB,QAAQ,iCAA6B;AAGnE;;;;;;;;;;;;;;;;CAgBC,GAED,qEAAqE,GACrE,MAAMC,iBAAiB;AAEvB,kFAAkF,GAClF,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,KAAa,EACbC,SAAiB;IAEjB,IAAI;QACF,MAAMC,SAAS,MAAMN,sBAAsBG,WAAWC,OAAO,eAC1DG,KAAK,CAAC,aAAa,MAAMF,WACzBE,KAAK,CAAC,UAAU,MAAM,UACtBC,GAAG;QACN,OAAOF,OAAOG,IAAI;IACpB,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,8DAA8DA;QAC5E,OAAO;IACT;AACF;AAEA,gEAAgE,GAChE,OAAO,eAAeE,2BACpBC,IAAmE,EACnEC,KAKC;IAED,MAAM,EAAEC,OAAO,EAAE,GAAGD;IACpB,IAAI;QACF,MAAME,UAAU,MAAMd,4BAA4BW,KAAKV,SAAS,IAAIW,MAAMV,KAAK,EAAEW,QAAQE,EAAE;QAC3F,MAAMJ,KAAKK,kBAAkB,CAAC;YAC5Bd,OAAOU,MAAMV,KAAK;YAClBC,WAAWU,QAAQE,EAAE;YACrBE,gBAAgBJ,QAAQI,cAAc;YACtCC,MAAMN,MAAMM,IAAI;YAChBL,SAAS;gBAAEM,OAAON,QAAQM,KAAK;gBAAEC,QAAQP,QAAQO,MAAM;gBAAEC,aAAaR,QAAQQ,WAAW;YAAC;YAC1FC,SAASV,MAAMU,OAAO;YACtBR;QACF;IACF,EAAE,OAAON,OAAO;QACdC,QAAQD,KAAK,CAAC,kDAAkDA;IAClE;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAee,qCACpBZ,IAAsF,EACtFC,KAA6E;IAE7E,MAAMX,YAAYU,KAAKV,SAAS;IAChC,MAAMuB,MAAM3B,WAAWI,WAAWW,MAAMV,KAAK,EAAEU,MAAMT,SAAS;IAC9D,IAAIsB;IACJ,IAAI;QACFA,SAAS,MAAMxB,UAAUyB,cAAc,CAAC,OAAOC;YAC7C,MAAMC,WAAW,MAAMD,YAAYrB,GAAG,CAACkB;YACvC,IAAI,CAACI,SAASC,MAAM,EAAE,OAAO;YAC7B,MAAMhB,UAAU,aAAMe,SAASE,IAAI;gBAAwBf,IAAIa,SAASb,EAAE;;YAC1E,IAAIF,QAAQkB,MAAM,KAAK,sBAAsB,OAAO;YACpDJ,YAAYK,MAAM,CAACR,KAAK;gBACtBO,QAAQ;gBACR,wBAAwBnB,MAAMqB,KAAK;gBACnC,wBAAwBrB,MAAMsB,SAAS;gBACvCC,aAAavB,MAAMqB,KAAK;YAC1B;YACA,OAAOpB;QACT;IACF,EAAE,OAAOL,OAAO;QACdC,QAAQD,KAAK,CAAC,0DAA0DA;QACxE,OAAO;IACT;IACA,IAAI,CAACiB,QAAQ,OAAO;IACpB,MAAMH,UAAU1B,kCAAkCgB,MAAMsB,SAAS;IACjE,IAAI;QACF,MAAMvB,KAAKyB,cAAc,CACvBxB,MAAMV,KAAK,EACX;YAAEmC,KAAK;QAAK,GACZ,CAAC,oFAAoF,EAAEzB,MAAMsB,SAAS,CAAC,CAAC,CAAC,EACzG;YAAEI,MAAMvC;YAAgBgB,IAAIU,OAAOV,EAAE;YAAEwB,MAAMd,OAAON,KAAK;QAAC;IAE9D,EAAE,OAAOX,OAAO;QACdC,QAAQD,KAAK,CAAC,+DAA+DA;IAC/E;IACA,MAAME,2BAA2BC,MAAM;QAAET,OAAOU,MAAMV,KAAK;QAAEW,SAASY;QAAQP,MAAM;QAAsBI;IAAQ;IAClH,OAAO;AACT"}
@@ -30,6 +30,10 @@ import type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-pers
30
30
  * detail — a member's note, a bounce's diagnostic — and the enrollment it
31
31
  * came from, which no longer exists.
32
32
  *
33
+ * The DOMAIN list (`outreachDoNotContactDomains`, AGL-3244) is not touched:
34
+ * a domain names a company, not a person, and an entry there carries the
35
+ * bounce's diagnostic already scrubbed of the address it named.
36
+ *
33
37
  * A dry run counts both and writes nothing.
34
38
  */
35
39
  type Firestore = FirebaseFirestore.Firestore;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/person-erasure.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginPersonEraser,\n PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachDoNotContactCollection } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A PERSON ERASURE (AGL-2981).\n *\n * The person's ENROLLMENTS go: each names them — their address, their\n * contact, the personal line a rep wrote about them, the attestations — and\n * is about them. They are found by the address and by the contacts the\n * erasure is deleting, and deleted whatever their status.\n *\n * Their DO-NOT-CONTACT entry STAYS, on the rule every suppression list\n * follows through an erasure: it is keyed by `personKey`, carries no\n * address, and is the one promise the workspace made the person that has to\n * outlive their data. What in it could point back at them is cleared: the\n * detail — a member's note, a bounce's diagnostic — and the enrollment it\n * came from, which no longer exists.\n *\n * A dry run counts both and writes nothing.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** `in` takes this many values at most. */\nconst IN_LIMIT = 30\n\nexport function createOutreachPersonEraser(deps: { firestore(): Firestore }): PluginPersonEraser {\n return async ({ orgId, email, key, contactIds, dryRun }): Promise<PluginPersonErasureReport> => {\n const firestore = deps.firestore()\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const found = new Map<string, FirebaseFirestore.DocumentReference>()\n for (const doc of (await enrollments.where('email', '==', email).get()).docs) found.set(doc.id, doc.ref)\n for (let start = 0; start < contactIds.length; start += IN_LIMIT) {\n const chunk = contactIds.slice(start, start + IN_LIMIT)\n for (const doc of (await enrollments.where('contactId', 'in', chunk).get()).docs) found.set(doc.id, doc.ref)\n }\n const entry = outreachDoNotContactCollection(firestore, orgId).doc(key)\n const listed = (await entry.get()).exists\n if (dryRun) return { enrollments: found.size, doNotContactKept: listed, doNotContactScrubbed: null }\n\n const refs = [...found.values()]\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) batch.delete(ref)\n await batch.commit()\n }\n if (listed) await entry.update({ detail: null, enrollmentId: null })\n return { enrollments: refs.length, doNotContactKept: listed, doNotContactScrubbed: listed }\n }\n}\n"],"names":["outreachDoNotContactCollection","outreachOrgCollection","BATCH_LIMIT","IN_LIMIT","createOutreachPersonEraser","deps","orgId","email","key","contactIds","dryRun","firestore","enrollments","found","Map","doc","where","get","docs","set","id","ref","start","length","chunk","slice","entry","listed","exists","size","doNotContactKept","doNotContactScrubbed","refs","values","batch","delete","commit","update","detail","enrollmentId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD,SAASA,8BAA8B,QAAQ,qCAAiC;AAChF,SAASC,qBAAqB,QAAQ,iCAA6B;AAsBnE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,yCAAyC,GACzC,MAAMC,WAAW;AAEjB,OAAO,SAASC,2BAA2BC,IAAgC;IACzE,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,GAAG,EAAEC,UAAU,EAAEC,MAAM,EAAE;QACrD,MAAMC,YAAYN,KAAKM,SAAS;QAChC,MAAMC,cAAcX,sBAAsBU,WAAWL,OAAO;QAC5D,MAAMO,QAAQ,IAAIC;QAClB,KAAK,MAAMC,OAAO,AAAC,CAAA,MAAMH,YAAYI,KAAK,CAAC,SAAS,MAAMT,OAAOU,GAAG,EAAC,EAAGC,IAAI,CAAEL,MAAMM,GAAG,CAACJ,IAAIK,EAAE,EAAEL,IAAIM,GAAG;QACvG,IAAK,IAAIC,QAAQ,GAAGA,QAAQb,WAAWc,MAAM,EAAED,SAASnB,SAAU;YAChE,MAAMqB,QAAQf,WAAWgB,KAAK,CAACH,OAAOA,QAAQnB;YAC9C,KAAK,MAAMY,OAAO,AAAC,CAAA,MAAMH,YAAYI,KAAK,CAAC,aAAa,MAAMQ,OAAOP,GAAG,EAAC,EAAGC,IAAI,CAAEL,MAAMM,GAAG,CAACJ,IAAIK,EAAE,EAAEL,IAAIM,GAAG;QAC7G;QACA,MAAMK,QAAQ1B,+BAA+BW,WAAWL,OAAOS,GAAG,CAACP;QACnE,MAAMmB,SAAS,AAAC,CAAA,MAAMD,MAAMT,GAAG,EAAC,EAAGW,MAAM;QACzC,IAAIlB,QAAQ,OAAO;YAAEE,aAAaC,MAAMgB,IAAI;YAAEC,kBAAkBH;YAAQI,sBAAsB;QAAK;QAEnG,MAAMC,OAAO;eAAInB,MAAMoB,MAAM;SAAG;QAChC,IAAK,IAAIX,QAAQ,GAAGA,QAAQU,KAAKT,MAAM,EAAED,SAASpB,YAAa;YAC7D,MAAMgC,QAAQvB,UAAUuB,KAAK;YAC7B,KAAK,MAAMb,OAAOW,KAAKP,KAAK,CAACH,OAAOA,QAAQpB,aAAcgC,MAAMC,MAAM,CAACd;YACvE,MAAMa,MAAME,MAAM;QACpB;QACA,IAAIT,QAAQ,MAAMD,MAAMW,MAAM,CAAC;YAAEC,QAAQ;YAAMC,cAAc;QAAK;QAClE,OAAO;YAAE3B,aAAaoB,KAAKT,MAAM;YAAEO,kBAAkBH;YAAQI,sBAAsBJ;QAAO;IAC5F;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/person-erasure.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginPersonEraser,\n PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachDoNotContactCollection } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A PERSON ERASURE (AGL-2981).\n *\n * The person's ENROLLMENTS go: each names them — their address, their\n * contact, the personal line a rep wrote about them, the attestations — and\n * is about them. They are found by the address and by the contacts the\n * erasure is deleting, and deleted whatever their status.\n *\n * Their DO-NOT-CONTACT entry STAYS, on the rule every suppression list\n * follows through an erasure: it is keyed by `personKey`, carries no\n * address, and is the one promise the workspace made the person that has to\n * outlive their data. What in it could point back at them is cleared: the\n * detail — a member's note, a bounce's diagnostic — and the enrollment it\n * came from, which no longer exists.\n *\n * The DOMAIN list (`outreachDoNotContactDomains`, AGL-3244) is not touched:\n * a domain names a company, not a person, and an entry there carries the\n * bounce's diagnostic already scrubbed of the address it named.\n *\n * A dry run counts both and writes nothing.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** `in` takes this many values at most. */\nconst IN_LIMIT = 30\n\nexport function createOutreachPersonEraser(deps: { firestore(): Firestore }): PluginPersonEraser {\n return async ({ orgId, email, key, contactIds, dryRun }): Promise<PluginPersonErasureReport> => {\n const firestore = deps.firestore()\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const found = new Map<string, FirebaseFirestore.DocumentReference>()\n for (const doc of (await enrollments.where('email', '==', email).get()).docs) found.set(doc.id, doc.ref)\n for (let start = 0; start < contactIds.length; start += IN_LIMIT) {\n const chunk = contactIds.slice(start, start + IN_LIMIT)\n for (const doc of (await enrollments.where('contactId', 'in', chunk).get()).docs) found.set(doc.id, doc.ref)\n }\n const entry = outreachDoNotContactCollection(firestore, orgId).doc(key)\n const listed = (await entry.get()).exists\n if (dryRun) return { enrollments: found.size, doNotContactKept: listed, doNotContactScrubbed: null }\n\n const refs = [...found.values()]\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) batch.delete(ref)\n await batch.commit()\n }\n if (listed) await entry.update({ detail: null, enrollmentId: null })\n return { enrollments: refs.length, doNotContactKept: listed, doNotContactScrubbed: listed }\n }\n}\n"],"names":["outreachDoNotContactCollection","outreachOrgCollection","BATCH_LIMIT","IN_LIMIT","createOutreachPersonEraser","deps","orgId","email","key","contactIds","dryRun","firestore","enrollments","found","Map","doc","where","get","docs","set","id","ref","start","length","chunk","slice","entry","listed","exists","size","doNotContactKept","doNotContactScrubbed","refs","values","batch","delete","commit","update","detail","enrollmentId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD,SAASA,8BAA8B,QAAQ,qCAAiC;AAChF,SAASC,qBAAqB,QAAQ,iCAA6B;AA0BnE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,yCAAyC,GACzC,MAAMC,WAAW;AAEjB,OAAO,SAASC,2BAA2BC,IAAgC;IACzE,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,GAAG,EAAEC,UAAU,EAAEC,MAAM,EAAE;QACrD,MAAMC,YAAYN,KAAKM,SAAS;QAChC,MAAMC,cAAcX,sBAAsBU,WAAWL,OAAO;QAC5D,MAAMO,QAAQ,IAAIC;QAClB,KAAK,MAAMC,OAAO,AAAC,CAAA,MAAMH,YAAYI,KAAK,CAAC,SAAS,MAAMT,OAAOU,GAAG,EAAC,EAAGC,IAAI,CAAEL,MAAMM,GAAG,CAACJ,IAAIK,EAAE,EAAEL,IAAIM,GAAG;QACvG,IAAK,IAAIC,QAAQ,GAAGA,QAAQb,WAAWc,MAAM,EAAED,SAASnB,SAAU;YAChE,MAAMqB,QAAQf,WAAWgB,KAAK,CAACH,OAAOA,QAAQnB;YAC9C,KAAK,MAAMY,OAAO,AAAC,CAAA,MAAMH,YAAYI,KAAK,CAAC,aAAa,MAAMQ,OAAOP,GAAG,EAAC,EAAGC,IAAI,CAAEL,MAAMM,GAAG,CAACJ,IAAIK,EAAE,EAAEL,IAAIM,GAAG;QAC7G;QACA,MAAMK,QAAQ1B,+BAA+BW,WAAWL,OAAOS,GAAG,CAACP;QACnE,MAAMmB,SAAS,AAAC,CAAA,MAAMD,MAAMT,GAAG,EAAC,EAAGW,MAAM;QACzC,IAAIlB,QAAQ,OAAO;YAAEE,aAAaC,MAAMgB,IAAI;YAAEC,kBAAkBH;YAAQI,sBAAsB;QAAK;QAEnG,MAAMC,OAAO;eAAInB,MAAMoB,MAAM;SAAG;QAChC,IAAK,IAAIX,QAAQ,GAAGA,QAAQU,KAAKT,MAAM,EAAED,SAASpB,YAAa;YAC7D,MAAMgC,QAAQvB,UAAUuB,KAAK;YAC7B,KAAK,MAAMb,OAAOW,KAAKP,KAAK,CAACH,OAAOA,QAAQpB,aAAcgC,MAAMC,MAAM,CAACd;YACvE,MAAMa,MAAME,MAAM;QACpB;QACA,IAAIT,QAAQ,MAAMD,MAAMW,MAAM,CAAC;YAAEC,QAAQ;YAAMC,cAAc;QAAK;QAClE,OAAO;YAAE3B,aAAaoB,KAAKT,MAAM;YAAEO,kBAAkBH;YAAQI,sBAAsBJ;QAAO;IAC5F;AACF"}
@@ -21,9 +21,11 @@ import { suppressEmail } from "@aglyn/tenant-data-admin/server/email-suppression
21
21
  import { recordTopicOptOut } from "@aglyn/tenant-data-admin/server/email-topic-confirmation";
22
22
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
23
23
  import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
24
+ import { sendOrgMemberNotice } from "@aglyn/tenant-data-admin/server/org-member-notice";
24
25
  import { logOrgActivity } from "@aglyn/tenant-data-admin/server/organizations";
25
26
  import { filterEnabledPluginsByReleaseFlags } from "@aglyn/tenant-data-admin/server/release-flags";
26
27
  import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
28
+ import { composeOutreachMailboxNotice } from "../engine/mailbox-notice.js";
27
29
  import { openOutreachMailboxClient } from "../mailboxes/mailbox-transport.js";
28
30
  import { canonicalConsoleOrigin } from "../mailboxes/oauth-redirect.js";
29
31
  import { outreachClickUrl } from "./click-link.js";
@@ -82,6 +84,29 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
82
84
  firestore: firestore()
83
85
  });
84
86
  },
87
+ async notifyMailboxOwner (notice) {
88
+ // The Mailboxes page, by the organization's slug — the page Resume and
89
+ // Reconnect live on (AGL-3244). Without a slug or a console origin the
90
+ // notice still goes, and says where the page is instead.
91
+ const org = await firestore().collection('orgs').doc(notice.orgId).get();
92
+ const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : '';
93
+ const origin = canonicalConsoleOrigin();
94
+ const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null;
95
+ const email = composeOutreachMailboxNotice(notice, {
96
+ mailboxesUrl
97
+ });
98
+ const result = await sendOrgMemberNotice({
99
+ orgId: notice.orgId,
100
+ uids: [
101
+ notice.connectedByUid
102
+ ],
103
+ includeAdmins: true,
104
+ subject: email.subject,
105
+ text: email.text,
106
+ context: 'outreach-mailbox-notice'
107
+ });
108
+ if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`);
109
+ },
85
110
  unsubscribeUrl: (target)=>outreachUnsubscribeUrl({
86
111
  origin: canonicalConsoleOrigin(),
87
112
  target
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, firestore: firestore() })\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","logOrgActivity","filterEnabledPluginsByReleaseFlags","OUTREACH_PLUGIN_ID","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","suppressBouncedEmail","reason","context","unsubscribeUrl","origin","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,gBAAgB,QAAQ,2CAA0C;AAC3E,SAASC,eAAe,QAAQ,8CAA6C;AAC7E,SAASC,0BAA0B,QAAQ,qDAAoD;AAC/F,SAASC,aAAa,QAAQ,oDAAmD;AACjF,SAASC,iBAAiB,QAAQ,2DAA0D;AAC5F,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,cAAc,QAAQ,gDAA+C;AAC9E,SAASC,kCAAkC,QAAQ,gDAA+C;AAClG,SAASC,kBAAkB,QAAQ,gCAA4B;AAC/D,SAASC,yBAAyB,QAAQ,oCAAgC;AAC1E,SAASC,sBAAsB,QAAQ,iCAA6B;AAGpE,SAASC,gBAAgB,QAAQ,kBAAc;AAC/C,SAASC,wBAAwB,QAAQ,mBAAe;AACxD,SAASC,0BAA0B,QAAQ,sBAAkB;AAE7D,SAASC,sBAAsB,QAAQ,wBAAoB;AAC3D,SAASC,8BAA8B,QAAQ,yBAAqB;AAEpE;;;;CAIC,GACD,OAAO,SAASC;IACd,MAAMC,YAAY,IAAMb,cAAcc,GAAG,GAAGD,SAAS;IACrD,OAAO;QACLA;QACAE,KAAKC,KAAKD,GAAG;QACbE,QAAQC,KAAKD,MAAM;QACnBE,aAAa,CAACC,YAAcf,0BAA0BQ,aAAa;gBAAEO;YAAU;QAC/E,MAAMC,YAAWC,KAAK,EAAEC,GAAG;YACzB,IAAI,CAAC3B,gBAAgB2B,KAAsCnB,qBAAqB,OAAO;YACvF,IAAI,CAACT,iBAAiB4B,KAAK,aAAa,OAAO;YAC/C,MAAMC,WAAW,MAAMrB,mCAAmC;gBAACC;aAAmB,EAAE;gBAAEkB;YAAM;YACxF,OAAOE,SAASC,MAAM,GAAG,OAAO;QAClC;QACA,MAAMC,aAAY,EAAEH,GAAG,EAAEI,GAAG,EAAE;YAC5B,MAAMC,SAAS,MAAM3B,mBAAmB;gBAAEsB;gBAAKI;gBAAKE,QAAQ;YAAQ;YACpE,OAAOD,SAAS,CAAC,OAAO,EAAEA,OAAOE,KAAK,EAAE,GAAG;QAC7C;QACAC,UAAU;;gBAAMlC;4BAAAA,8BAAAA,iDAAAA,4BAA8BmC,MAAM,mBAAI;;QACxD9B,gBAAgB,CAACoB,OAAOW,OAAOC,QAAQC,SAAWjC,eAAeoB,OAAOW,OAAOC,QAAQC;QACvF,MAAMC,oBAAmB,EAAEC,MAAM,EAAEC,KAAK,EAAE;YACxC,MAAMvC,kBAAkBsC,QAAQC,OAAO5C,mBAAmB;gBAAEmB,WAAWA;YAAY;QACrF;QACA,MAAM0B,sBAAqB,EAAED,KAAK,EAAED,MAAM,EAAE;YAC1C,MAAMvC,cAAc;gBAAEwC;gBAAOE,QAAQ;gBAAUC,SAAS;gBAAYJ;gBAAQxB,WAAWA;YAAY;QACrG;QACA6B,gBAAgB,CAACP,SAAWzB,uBAAuB;gBAAEiC,QAAQrC;gBAA0B6B;YAAO;QAC9FS,UAAU,CAACT,SAAW5B,iBAAiB;gBAAEoC,QAAQrC;gBAA0B6B;YAAO;IACpF;AACF;AAEA,uDAAuD,GACvD,OAAO,SAASU;IACd,OAAOlC,+BAA+BC;AACxC;AAEA,wEAAwE,GACxE,OAAO,SAASkC;IACd,OAAOtC,yBAAyBI;AAClC;AAEA,uDAAuD,GACvD,OAAO,SAASmC;IACd,OAAOtC,2BAA2B;QAAEI,WAAW,IAAMb,cAAcc,GAAG,GAAGD,SAAS;IAAG;AACvF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, firestore: firestore() })\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","suppressBouncedEmail","reason","context","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"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"}
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
18
+ import type { OutreachMailboxNotice } from '../engine/mailbox-notice';
18
19
  import type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport';
19
20
  import type { OutreachClickTarget } from './click-link';
20
21
  import type { OutreachUnsubscribeTarget } from './unsubscribe-link';
@@ -35,6 +36,18 @@ export interface OutreachRuntimeActivityTarget {
35
36
  id: string;
36
37
  name: string;
37
38
  }
39
+ /**
40
+ * What the runtime tells a mailbox's owner (AGL-3244): the notice the
41
+ * engine composes the email from, and who it is for — the member who
42
+ * connected the mailbox, and the organization's owners and admins beside
43
+ * them, since a paused mailbox is the organization's problem too.
44
+ */
45
+ export interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {
46
+ orgId: string;
47
+ mailboxId: string;
48
+ /** The member whose mailbox it is. */
49
+ connectedByUid: string;
50
+ }
38
51
  export interface OutreachRuntimeDeps {
39
52
  firestore(): FirebaseFirestore.Firestore;
40
53
  now(): number;
@@ -73,6 +86,14 @@ export interface OutreachRuntimeDeps {
73
86
  email: string;
74
87
  hostId: string | null;
75
88
  }): Promise<void>;
89
+ /**
90
+ * Emails the mailbox's owner, and the organization's admins, that the
91
+ * mailbox paused itself or needs reconnecting (AGL-3244), through the
92
+ * platform's transactional sender. Called once per pause and once per
93
+ * reconnect, by the code path that wrote it; a failure to send is the
94
+ * platform's to log and never the runtime's to retry.
95
+ */
96
+ notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): Promise<void>;
76
97
  /** The signed one-click link for an enrollment, or `null` when none can be minted. */
77
98
  unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null;
78
99
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The signed tracking link for one link in one email, or `null` when none\n * can be minted (AGL-3239) — no console origin, no signing secret, or a\n * destination we will not sign.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickUrl(target: OutreachClickTarget): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA0BD,WA0CC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OutreachMailboxNotice } from '../engine/mailbox-notice'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: string\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The signed tracking link for one link in one email, or `null` when none\n * can be minted (AGL-3239) — no console origin, no signing secret, or a\n * destination we will not sign.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickUrl(target: OutreachClickTarget): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAwCD,WAkDC"}
@@ -27,7 +27,6 @@ import { readOutreachEnrollCandidates } from "../enrollment/enroll-people.js";
27
27
  import { readOutreachGateLookups } from "../enrollment/gate-lookups.js";
28
28
  import { mailboxRef } from "../mailboxes/mailbox-credentials.js";
29
29
  import { outreachEffectiveDailyCap, outreachLocalDay } from "../mailboxes/mailbox-settings.js";
30
- import { markOutreachMailboxReconnectRequired } from "../mailboxes/mailbox-transport.js";
31
30
  import { effectiveOutreachAllowedCountries } from "../model/compliance-settings.js";
32
31
  import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
33
32
  import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
@@ -37,6 +36,7 @@ import { Rfc5322MessageError } from "../transport/rfc5322.js";
37
36
  import { sendComposedOutreachEmail } from "../transport/send-message.js";
38
37
  import { applyOutreachEvent } from "./enrollment-events.js";
39
38
  import { outreachSendDigest, withRecentSend } from "./mailbox-health-store.js";
39
+ import { noteOutreachMailboxReconnectRequired } from "./mailbox-notices.js";
40
40
  import { fileOutreachEmail, fileOutreachTask, OUTREACH_TASK_KIND_TO_RECORD } from "./timeline.js";
41
41
  import { outreachUnsubscribeMailbox } from "./unsubscribe-link.js";
42
42
  /*==========================================
@@ -315,13 +315,13 @@ async function runMailbox(deps, firestore, input) {
315
315
  address: mailbox.sendAs || mailbox.email,
316
316
  name: mailbox.displayName || null
317
317
  });
318
- async function openClient(deps, firestore, run) {
318
+ async function openClient(deps, run) {
319
319
  var _run, _opened;
320
320
  const opened = (_opened = (_run = run).opened) != null ? _opened : _run.opened = await deps.openMailbox(run.mailbox.id);
321
321
  if (opened.ok === true) return opened.client;
322
322
  run.stopped = true;
323
323
  if (opened.reason !== 'not-configured') {
324
- await markOutreachMailboxReconnectRequired(firestore, {
324
+ await noteOutreachMailboxReconnectRequired(deps, {
325
325
  orgId: run.orgId,
326
326
  mailboxId: run.mailbox.id,
327
327
  errorCode: opened.reason,
@@ -716,7 +716,7 @@ async function runEmailStep(deps, firestore, run, input) {
716
716
  report.held += 1;
717
717
  return;
718
718
  }
719
- const client = await openClient(deps, firestore, run);
719
+ const client = await openClient(deps, run);
720
720
  if (!client) {
721
721
  await releaseClaim(deps, firestore, run, enrollment, true);
722
722
  report.held += 1;
@@ -743,7 +743,7 @@ async function runEmailStep(deps, firestore, run, input) {
743
743
  run.stopped = true;
744
744
  const code = error instanceof GmailTransportError ? error.code : 'unexpected';
745
745
  if (isReconnectRequired(error)) {
746
- await markOutreachMailboxReconnectRequired(firestore, {
746
+ await noteOutreachMailboxReconnectRequired(deps, {
747
747
  orgId: run.orgId,
748
748
  mailboxId: mailbox.id,
749
749
  errorCode: code,
@@ -813,7 +813,7 @@ async function runEmailStep(deps, firestore, run, input) {
813
813
  await releaseClaim(deps, firestore, run, enrollment, false);
814
814
  return true;
815
815
  }
816
- const client = await openClient(deps, firestore, run);
816
+ const client = await openClient(deps, run);
817
817
  if (!client) return false;
818
818
  const bare = claim.messageId.replace(/^<|>$/g, '');
819
819
  const found = await client.listMessages({