@aglyn/plugins-outreach 1.0.0-beta.159 → 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.
- package/README.md +4 -0
- package/package.json +13 -13
- package/src/lib/components/compliance-section.d.ts +3 -2
- package/src/lib/components/compliance-section.js +7 -2
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/do-not-contact-domains.d.ts +29 -0
- package/src/lib/components/do-not-contact-domains.js +208 -0
- package/src/lib/components/do-not-contact-domains.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +5 -1
- package/src/lib/components/use-outreach-api.js +12 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +8 -1
- package/src/lib/components/use-outreach-data.js +32 -0
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +5 -0
- package/src/lib/constants/api-routes.js +4 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/do-not-contact-domain.d.ts +39 -0
- package/src/lib/engine/do-not-contact-domain.js +51 -0
- package/src/lib/engine/do-not-contact-domain.js.map +1 -0
- package/src/lib/engine/gates.d.ts +6 -1
- package/src/lib/engine/gates.js +6 -0
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/gateway-block.d.ts +31 -0
- package/src/lib/engine/gateway-block.js +54 -0
- package/src/lib/engine/gateway-block.js.map +1 -0
- package/src/lib/engine/index.d.ts +3 -0
- package/src/lib/engine/index.js +3 -0
- package/src/lib/engine/index.js.map +1 -1
- package/src/lib/engine/mailbox-health.d.ts +5 -0
- package/src/lib/engine/mailbox-health.js +12 -5
- package/src/lib/engine/mailbox-health.js.map +1 -1
- package/src/lib/engine/mailbox-notice.d.ts +46 -0
- package/src/lib/engine/mailbox-notice.js +64 -0
- package/src/lib/engine/mailbox-notice.js.map +1 -0
- package/src/lib/enrollment/gate-lookups.js +7 -3
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +23 -2
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +33 -1
- package/src/lib/model/outreach.types.js +11 -1
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/routes/do-not-contact-routes.d.ts +35 -0
- package/src/lib/routes/do-not-contact-routes.js +92 -0
- package/src/lib/routes/do-not-contact-routes.js.map +1 -0
- package/src/lib/routes/enroll-routes.js +1 -0
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +4 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/runtime/mailbox-health-store.d.ts +1 -1
- package/src/lib/runtime/mailbox-health-store.js +17 -4
- package/src/lib/runtime/mailbox-health-store.js.map +1 -1
- package/src/lib/runtime/mailbox-notices.d.ts +39 -0
- package/src/lib/runtime/mailbox-notices.js +117 -0
- package/src/lib/runtime/mailbox-notices.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +4 -0
- package/src/lib/runtime/person-erasure.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.js +25 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +21 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +6 -6
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.d.ts +2 -0
- package/src/lib/runtime/sync-job.js +34 -7
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/server.d.ts +1 -1
- package/src/lib/server.js +1 -1
- package/src/lib/server.js.map +1 -1
- package/src/lib/storage/do-not-contact-store.d.ts +28 -1
- package/src/lib/storage/do-not-contact-store.js +117 -9
- 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,
|
|
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
|
-
|
|
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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,cAAa;AACxC,SACEC,2BAA2B,EAC3BC,iCAAiC,QAE5B,8BAA0B;AACjC,SAASC,UAAU,QAAQ,sCAAkC;AAC7D,SAASC,qBAAqB,EAAEC,gBAAgB,QAAQ,mCAA+B;AAQvF;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GAED,qEAAqE,GACrE,MAAMC,iBAAiB;AAEvB,6DAA6D,GAC7D,MAAMC,kBAAkB;AAExB,oDAAoD,GACpD,OAAO,SAASC,mBAAmBC,SAAiB;IAClD,OAAOT,WAAW,UAAUU,MAAM,CAACC,OAAOF,WAAWG,IAAI,IAAIC,MAAM,CAAC,OAAOC,KAAK,CAAC,GAAG;AACtF;AAEA,4EAA4E,GAC5E,OAAO,SAASC,eACdC,MAAwD,EACxDC,IAAwB;IAExB,OAAO;WAAI,CAACD,iBAAAA,SAAU,EAAE,EAAEE,MAAM,CAAC,CAACC,QAAUA,CAAAA,yBAAAA,MAAOC,EAAE,MAAKH,KAAKG,EAAE;QAAGH;KAAK,CAACH,KAAK,CAC7E,CAACZ;AAEL;AAEA,uDAAuD,GACvD,OAAO,SAASmB,2BACdC,OAAqD,EACrDC,KAAa;;QAEGD,iBAGOA,6BAAAA,uBAAAA,kBAGFA;IANrB,MAAMN,SAAS,UAACM,kBAAAA,QAAQE,MAAM,qBAAdF,gBAAgBG,WAAW,mBAAI,EAAE,EAAEX,KAAK,CAAC,CAACZ;IAC1D,MAAMwB,QAAQrB,iBAAiBkB,OAAOD,QAAQK,QAAQ;IACtD,OAAO;QACLC,cAAcC,iBAAOP,mBAAAA,QAAQE,MAAM,sBAAdF,wBAAAA,iBAAgBQ,KAAK,sBAArBR,8BAAAA,qBAAuB,CAACI,MAAM,qBAA9BJ,4BAAgCS,OAAO,oBAAI,MAAM;QACtEN,aAAaT,OAAOgB,MAAM;QAC1BC,mBAAmBjB,OAAOE,MAAM,CAAC,CAACD,OAASA,CAAAA,wBAAAA,KAAMiB,OAAO,MAAK,MAAMF,MAAM;QACzEG,iBAAiB,YAAEb,mBAAAA,QAAQE,MAAM,qBAAdF,iBAAgBa,iBAAiB,oBAAI;QACxDZ;IACF;AACF;AAEA,sEAAsE,GACtE,SAASa,YACPN,KAA6D,EAC7DP,KAAa,EACbI,QAAgB;IAEhB,MAAMU,OAAO,IAAIC;IACjB,IAAK,IAAIC,OAAO,GAAGA,OAAO,IAAIhC,iBAAiBgC,QAAQ,EAAG;QACxD,KAAK,MAAMC,OAAOpC,sBAAsBmB,QAAQgB,OAAO,IAAI,UAAYZ,UAAWU,KAAKI,GAAG,CAACD;IAC7F;IACA,OAAOE,OAAOC,WAAW,CAACD,OAAOE,OAAO,CAACd,gBAAAA,QAAS,CAAC,GAAGZ,MAAM,CAAC,CAAC,CAACsB,IAAI,GAAKH,KAAKQ,GAAG,CAACL;AACnF;AAEA,MAAMM,OAAO,CACXhB,OACAU,KACAO,OACAC,KAAK,CAAC;QAESlB;IAAf,MAAMmB,UAASnB,aAAAA,KAAK,CAACU,IAAI,YAAVV,aAAc;QAAEoB,MAAM;QAAGnB,SAAS;QAAGoB,SAAS;IAAE;IAC/DrB,KAAK,CAACU,IAAI,GAAG,aAAKS;QAAQ,CAACF,MAAM,EAAEK,KAAKC,GAAG,CAAC,GAAGxB,OAAOoB,MAAM,CAACF,MAAM,KAAK,KAAKC;;AAC/E;AAWA,OAAO,MAAMM,2BAA2B,IAAmC,CAAA;QACzEvB,SAAS,EAAE;QACXoB,SAAS;QACTI,eAAe;IACjB,CAAA,EAAE;AAEF;;;;CAIC,GACD,OAAO,eAAeC,2BACpBC,IAAuE,EACvEC,KAA8E;IAE9E,MAAM,EAAEC,KAAK,EAAE,GAAGD;IAClB,IAAI,CAACC,MAAM5B,OAAO,CAACC,MAAM,IAAI,CAAC2B,MAAMR,OAAO,IAAIQ,MAAMJ,aAAa,KAAK,MAAM,OAAO;IACpF,MAAMK,YAAYH,KAAKG,SAAS;IAChC,MAAMrC,QAAQkC,KAAKI,GAAG;IACtB,MAAMC,MAAM3D,WAAWyD,WAAWF,MAAMK,KAAK,EAAEL,MAAMM,SAAS;IAC9D,MAAMC,UAAU,MAAML,UAAUM,cAAc,CAAC,OAAOC;;YAK1B7C,iBACRA,kBAWXA,kBACoCA,kBAGbA,kBACAA;QArB9B,MAAM8C,WAAW,MAAMD,YAAYE,GAAG,CAACP;QACvC,IAAI,CAACM,SAASE,MAAM,EAAE,OAAO;QAC7B,MAAMhD,UAAU,aAAM8C,SAASG,IAAI;YAAwBnD,IAAIgD,SAAShD,EAAE;;QAC1E,MAAMO,WAAWL,QAAQK,QAAQ;QACjC,MAAMG,QAAQM,aAAYd,kBAAAA,QAAQE,MAAM,qBAAdF,gBAAgBQ,KAAK,EAAEP,OAAOI;QACxD,IAAIX,SAAS;wBAAKM,mBAAAA,QAAQE,MAAM,qBAAdF,iBAAgBG,WAAW,mBAAI,EAAE;SAAE;QACrD,KAAK,MAAM+C,UAAUb,MAAM5B,OAAO,CAAE;YAClCe,KAAKhB,OAAOzB,iBAAiBmE,OAAOC,IAAI,EAAE9C,WAAW;YACrD,IAAI6C,OAAO/D,SAAS,EAAE;gBACpB,MAAMW,KAAKZ,mBAAmBgE,OAAO/D,SAAS;gBAC9CO,SAASA,OAAO0D,GAAG,CAAC,CAACzD,OAAUA,CAAAA,wBAAAA,KAAMG,EAAE,MAAKA,KAAK,aAAKH;wBAAMiB,SAAS;yBAASjB;YAChF;QACF;QACA,IAAI0C,MAAMR,OAAO,EAAEL,KAAKhB,OAAOzB,iBAAiBkB,OAAOI,WAAW,WAAWgC,MAAMR,OAAO;QAC1F,MAAMhB,oBACJwB,MAAMJ,aAAa,KAAK,iBACnBjC,mBAAAA,QAAQE,MAAM,qBAAdF,iBAAgBa,iBAAiB,oBAAI,OACtCiB,KAAKC,GAAG,CAACM,MAAMJ,aAAa,EAAE1B,QAAOP,mBAAAA,QAAQE,MAAM,qBAAdF,iBAAgBa,iBAAiB,KAAK;QACjF,MAAMX,SAAS,aACVF,QAAQE,MAAM;YACjBO,SAASqB,KAAKC,GAAG,CAAC,GAAGxB,QAAOP,mBAAAA,QAAQE,MAAM,qBAAdF,iBAAgBS,OAAO,KAAK,KAAK4B,MAAM5B,OAAO,CAACC,MAAM;YACjFmB,SAASC,KAAKC,GAAG,CAAC,GAAGxB,QAAOP,mBAAAA,QAAQE,MAAM,qBAAdF,iBAAgB6B,OAAO,KAAK,KAAKQ,MAAMR,OAAO;YAC1ErB;YACAL,aAAaT;YACbmB;;QAEF,MAAMwC,SAAS,aAAKrD;YAASE;;QAC7B,MAAMoD,cAAcjB,MAAM5B,OAAO,CAACC,MAAM,GAAG,KAAK2B,MAAMJ,aAAa,KAAK;QACxE,MAAMsB,WACJD,eAAetD,QAAQwD,MAAM,KAAK,cAC9B7E,4BAA4BoB,2BAA2BsD,QAAQpD,UAC/D;QACN,MAAMb,SAAkC;YAAEc;YAAQuD,aAAaxD;QAAM;QACrE,IAAIsD,CAAAA,4BAAAA,SAAUG,KAAK,KAAIH,SAASI,MAAM,IAAIJ,SAASK,OAAO,EAAE;YAC1DxE,MAAM,CAAC,SAAS,GAAG;YACnBA,MAAM,CAAC,YAAY,GAAG;gBACpBuE,QAAQJ,SAASI,MAAM;gBACvBC,SAASL,SAASK,OAAO;gBACzBT,MAAMlD;gBACN4D,SAASN,SAASO,aAAa;YACjC;QACF;QACAjB,YAAYzD,MAAM,CAACoD,KAAKpD;QACxB,OAAOmE,CAAAA,4BAAAA,SAAUG,KAAK,IAAG;YAAEH;YAAUQ,OAAO/D,QAAQ+D,KAAK;QAAC,IAAI;IAChE;IACA,IAAI,CAACpB,SAAS,OAAO;IACrB,MAAMR,KAAK6B,cAAc,CACvB5B,MAAMK,KAAK,EACX;QAAEwB,KAAK;IAAK,GACZ,CAAC,6CAA6C,EAAEtB,QAAQY,QAAQ,CAACK,OAAO,EAAE,EAC1E;QAAEM,MAAMlF;QAAgBc,IAAIsC,MAAMM,SAAS;QAAEyB,MAAMxB,QAAQoB,KAAK;IAAC;IAEnE,OAAOpB,QAAQY,QAAQ;AACzB"}
|
|
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;
|
|
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","
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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({
|