@aglyn/tenant-data-admin 1.0.0-beta.223 → 1.0.0-beta.225

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.223",
3
+ "version": "1.0.0-beta.225",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.223",
29
- "@aglyn/shared-util-email": "1.0.0-beta.223",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.223",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.223",
32
- "@aglyn/shared-util-http": "1.0.0-beta.223",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.223",
28
+ "@aglyn/aglyn": "1.0.0-beta.225",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.225",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.225",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.225",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.225",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.225",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.5",
@@ -0,0 +1,32 @@
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
+ /** Next's `after()`, as the callers use it. */
18
+ export type AfterResponse = (task: () => Promise<void>) => void;
19
+ /**
20
+ * Next's `after()`, loaded the first time it is asked for; `null` where
21
+ * `next/server` cannot be loaded or has no `after`. One load per process.
22
+ */
23
+ export declare function loadAfterResponse(label?: string): Promise<AfterResponse | null>;
24
+ /**
25
+ * Runs `task` once the response has been sent, through Next's `after()`.
26
+ * Resolves `false` where there is no request to run after — a script, a
27
+ * spec — and the task is then not run at all. Never rejects. `label`
28
+ * prefixes what is logged, the caller's own (`[media-cdn]`).
29
+ */
30
+ export declare function scheduleAfterResponse(task: () => Promise<void>, label: string): Promise<boolean>;
31
+ /** Test seam: forget the loaded `after()` and what was logged. */
32
+ export declare function resetAfterResponseForTests(): void;
@@ -0,0 +1,94 @@
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
+ * WORK RUN AFTER THE RESPONSE: NEXT'S `after()`, LOADED LAZILY.
18
+ *
19
+ * A serverless invocation is frozen the moment its response is sent, and
20
+ * work scheduled any other way does not run (AGL-2327). `after()` is the
21
+ * one way to keep it.
22
+ *
23
+ * ## Loaded, not imported
24
+ *
25
+ * `next/server` evaluates web `Request` classes at load, which a jsdom spec
26
+ * reaching one of this package's writers cannot, and the modules that
27
+ * schedule work ride every writer's import. So it is loaded the first time
28
+ * work is scheduled.
29
+ *
30
+ * ## Through `import()`, never `require()`
31
+ *
32
+ * This package is an ES module. Next 16's Turbopack build compiled the
33
+ * `const loaded = require('next/server')` both callers once used into a
34
+ * hoisted `.after` read and left the body naming `loaded`, a binding that
35
+ * no longer existed: every call threw `ReferenceError: loaded is not
36
+ * defined`, the `catch` around it answered "no request", and neither the
37
+ * media CDN's regeneration (AGL-3486) nor the deliverability check at
38
+ * capture (AGL-3328) ever ran in production. A spec cannot see this:
39
+ * `jest.mock('next/server')` answers `require` and `import()` alike.
40
+ * `after-response.spec.ts` holds the package to `import()`.
41
+ *
42
+ * ## Every drop is said once
43
+ *
44
+ * A task that could not be scheduled is work that silently did not
45
+ * happen. The first time each caller's task is dropped for a given reason
46
+ * it is logged; after that the same reason stays quiet, so a script
47
+ * writing five hundred rows outside a request says it once, not five
48
+ * hundred times. A task that runs and fails is logged every time.
49
+ *==========================================*/ /** Next's `after()`, as the callers use it. */ let afterResponseLoad = null;
50
+ const logged = new Set();
51
+ function logOnce(label, reason, message, error) {
52
+ const key = `${label}|${reason}`;
53
+ if (logged.has(key)) return;
54
+ logged.add(key);
55
+ if (error === undefined) console.error(`${label} ${message}`);
56
+ else console.error(`${label} ${message}`, error);
57
+ }
58
+ /**
59
+ * Next's `after()`, loaded the first time it is asked for; `null` where
60
+ * `next/server` cannot be loaded or has no `after`. One load per process.
61
+ */ export function loadAfterResponse(label = '[after-response]') {
62
+ afterResponseLoad != null ? afterResponseLoad : afterResponseLoad = import("next/server").then((loaded)=>typeof loaded.after === 'function' ? loaded.after : null, (error)=>{
63
+ logOnce(label, 'load', 'next/server could not be loaded', error);
64
+ return null;
65
+ });
66
+ return afterResponseLoad;
67
+ }
68
+ /**
69
+ * Runs `task` once the response has been sent, through Next's `after()`.
70
+ * Resolves `false` where there is no request to run after — a script, a
71
+ * spec — and the task is then not run at all. Never rejects. `label`
72
+ * prefixes what is logged, the caller's own (`[media-cdn]`).
73
+ */ export async function scheduleAfterResponse(task, label) {
74
+ const after = await loadAfterResponse(label);
75
+ if (!after) {
76
+ logOnce(label, 'unavailable', 'after() is unavailable; the task was not scheduled');
77
+ return false;
78
+ }
79
+ try {
80
+ after(()=>task().catch((error)=>{
81
+ console.error(`${label} after-response task failed`, error);
82
+ }));
83
+ return true;
84
+ } catch (error) {
85
+ logOnce(label, 'refused', 'after() refused the task; it was not scheduled', error);
86
+ return false;
87
+ }
88
+ }
89
+ /** Test seam: forget the loaded `after()` and what was logged. */ export function resetAfterResponseForTests() {
90
+ afterResponseLoad = null;
91
+ logged.clear();
92
+ }
93
+
94
+ //# sourceMappingURL=after-response.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/after-response.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\n/*==========================================\n * WORK RUN AFTER THE RESPONSE: NEXT'S `after()`, LOADED LAZILY.\n *\n * A serverless invocation is frozen the moment its response is sent, and\n * work scheduled any other way does not run (AGL-2327). `after()` is the\n * one way to keep it.\n *\n * ## Loaded, not imported\n *\n * `next/server` evaluates web `Request` classes at load, which a jsdom spec\n * reaching one of this package's writers cannot, and the modules that\n * schedule work ride every writer's import. So it is loaded the first time\n * work is scheduled.\n *\n * ## Through `import()`, never `require()`\n *\n * This package is an ES module. Next 16's Turbopack build compiled the\n * `const loaded = require('next/server')` both callers once used into a\n * hoisted `.after` read and left the body naming `loaded`, a binding that\n * no longer existed: every call threw `ReferenceError: loaded is not\n * defined`, the `catch` around it answered \"no request\", and neither the\n * media CDN's regeneration (AGL-3486) nor the deliverability check at\n * capture (AGL-3328) ever ran in production. A spec cannot see this:\n * `jest.mock('next/server')` answers `require` and `import()` alike.\n * `after-response.spec.ts` holds the package to `import()`.\n *\n * ## Every drop is said once\n *\n * A task that could not be scheduled is work that silently did not\n * happen. The first time each caller's task is dropped for a given reason\n * it is logged; after that the same reason stays quiet, so a script\n * writing five hundred rows outside a request says it once, not five\n * hundred times. A task that runs and fails is logged every time.\n *==========================================*/\n\n/** Next's `after()`, as the callers use it. */\nexport type AfterResponse = (task: () => Promise<void>) => void\n\nlet afterResponseLoad: Promise<AfterResponse | null> | null = null\n\nconst logged = new Set<string>()\n\nfunction logOnce(label: string, reason: string, message: string, error?: unknown): void {\n const key = `${label}|${reason}`\n if (logged.has(key)) return\n logged.add(key)\n if (error === undefined) console.error(`${label} ${message}`)\n else console.error(`${label} ${message}`, error)\n}\n\n/**\n * Next's `after()`, loaded the first time it is asked for; `null` where\n * `next/server` cannot be loaded or has no `after`. One load per process.\n */\nexport function loadAfterResponse(label = '[after-response]'): Promise<AfterResponse | null> {\n afterResponseLoad ??= import('next/server').then(\n (loaded) =>\n typeof (loaded as { after?: unknown }).after === 'function'\n ? (loaded as { after: AfterResponse }).after\n : null,\n (error: unknown) => {\n logOnce(label, 'load', 'next/server could not be loaded', error)\n return null\n },\n )\n return afterResponseLoad\n}\n\n/**\n * Runs `task` once the response has been sent, through Next's `after()`.\n * Resolves `false` where there is no request to run after — a script, a\n * spec — and the task is then not run at all. Never rejects. `label`\n * prefixes what is logged, the caller's own (`[media-cdn]`).\n */\nexport async function scheduleAfterResponse(task: () => Promise<void>, label: string): Promise<boolean> {\n const after = await loadAfterResponse(label)\n if (!after) {\n logOnce(label, 'unavailable', 'after() is unavailable; the task was not scheduled')\n return false\n }\n try {\n after(() =>\n task().catch((error: unknown) => {\n console.error(`${label} after-response task failed`, error)\n }),\n )\n return true\n } catch (error) {\n logOnce(label, 'refused', 'after() refused the task; it was not scheduled', error)\n return false\n }\n}\n\n/** Test seam: forget the loaded `after()` and what was logged. */\nexport function resetAfterResponseForTests(): void {\n afterResponseLoad = null\n logged.clear()\n}\n"],"names":["afterResponseLoad","logged","Set","logOnce","label","reason","message","error","key","has","add","undefined","console","loadAfterResponse","then","loaded","after","scheduleAfterResponse","task","catch","resetAfterResponseForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CAiC4C,GAE5C,6CAA6C,GAG7C,IAAIA,oBAA0D;AAE9D,MAAMC,SAAS,IAAIC;AAEnB,SAASC,QAAQC,KAAa,EAAEC,MAAc,EAAEC,OAAe,EAAEC,KAAe;IAC9E,MAAMC,MAAM,GAAGJ,MAAM,CAAC,EAAEC,QAAQ;IAChC,IAAIJ,OAAOQ,GAAG,CAACD,MAAM;IACrBP,OAAOS,GAAG,CAACF;IACX,IAAID,UAAUI,WAAWC,QAAQL,KAAK,CAAC,GAAGH,MAAM,CAAC,EAAEE,SAAS;SACvDM,QAAQL,KAAK,CAAC,GAAGH,MAAM,CAAC,EAAEE,SAAS,EAAEC;AAC5C;AAEA;;;CAGC,GACD,OAAO,SAASM,kBAAkBT,QAAQ,kBAAkB;IAC1DJ,4BAAAA,oBAAAA,oBAAsB,MAAM,CAAC,eAAec,IAAI,CAC9C,CAACC,SACC,OAAO,AAACA,OAA+BC,KAAK,KAAK,aAC7C,AAACD,OAAoCC,KAAK,GAC1C,MACN,CAACT;QACCJ,QAAQC,OAAO,QAAQ,mCAAmCG;QAC1D,OAAO;IACT;IAEF,OAAOP;AACT;AAEA;;;;;CAKC,GACD,OAAO,eAAeiB,sBAAsBC,IAAyB,EAAEd,KAAa;IAClF,MAAMY,QAAQ,MAAMH,kBAAkBT;IACtC,IAAI,CAACY,OAAO;QACVb,QAAQC,OAAO,eAAe;QAC9B,OAAO;IACT;IACA,IAAI;QACFY,MAAM,IACJE,OAAOC,KAAK,CAAC,CAACZ;gBACZK,QAAQL,KAAK,CAAC,GAAGH,MAAM,2BAA2B,CAAC,EAAEG;YACvD;QAEF,OAAO;IACT,EAAE,OAAOA,OAAO;QACdJ,QAAQC,OAAO,WAAW,kDAAkDG;QAC5E,OAAO;IACT;AACF;AAEA,gEAAgE,GAChE,OAAO,SAASa;IACdpB,oBAAoB;IACpBC,OAAOoB,KAAK;AACd"}
@@ -62,6 +62,8 @@ export declare function flushCapturedEmailChecks(deps?: MailDeliverabilityDeps):
62
62
  export declare function scheduleCapturedEmailCheck(item: CapturedEmail): void;
63
63
  /** Test seam: drop anything queued. */
64
64
  export declare function resetCapturedEmailChecksForTests(): void;
65
+ /** Settles once the last capture's flush was handed to `after()`, or dropped. For the spec. */
66
+ export declare function capturedEmailCheckScheduledForTests(): Promise<void>;
65
67
  /** What is queued, for the spec. */
66
68
  export declare function queuedCapturedEmailsForTests(): readonly CapturedEmail[];
67
69
  /**
@@ -36,9 +36,11 @@
36
36
  *
37
37
  * `after()` rather than a bare promise (AGL-2327): a serverless invocation
38
38
  * is frozen the moment its response is sent, and work scheduled any other
39
- * way does not run. Outside a request — a script, a spec — there is nothing
40
- * to defer to, and nothing is checked; the backfill covers what those
41
- * write.
39
+ * way does not run. `after-response.ts` loads it, and says why through
40
+ * `import()`: the `require()` this module once used compiled to a
41
+ * ReferenceError under Turbopack, and the check never ran in production.
42
+ * Outside a request — a script, a spec — there is nothing to defer to, and
43
+ * nothing is checked; the backfill covers what those write.
42
44
  *
43
45
  * ## The check's own verdict, and only that, is taken back
44
46
  *
@@ -53,6 +55,7 @@
53
55
  import { isPublicMailboxDomain } from "@aglyn/aglyn/app-utils/crm";
54
56
  import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
55
57
  import { bareSenderAddress, decidingDeliverabilityFinding, mailDomainRefusesMail, mailGatewayChip, mailGatewayStanding, normalizeDeliverabilityEmail } from "@aglyn/shared-util-email";
58
+ import { scheduleAfterResponse } from "./after-response.js";
56
59
  import { isGatewayHeldVerdict, isNoMailServerVerdict, readMailDeliverability } from "./email-deliverability.js";
57
60
  /**
58
61
  * How long an import waits, past its last write, for its addresses' mail
@@ -194,19 +197,7 @@ import { isGatewayHeldVerdict, isNoMailServerVerdict, readMailDeliverability } f
194
197
  * THE QUEUE, FLUSHED AFTER THE RESPONSE
195
198
  *==========================================*/ const queued = [];
196
199
  let flushScheduled = false;
197
- /**
198
- * Next's `after()`, required when first asked for rather than imported:
199
- * `next/server` evaluates web `Request` classes at load, which a jsdom spec
200
- * reaching one of the writers cannot, and this module rides every writer's
201
- * import. `null` where it cannot be loaded.
202
- */ function afterResponse() {
203
- try {
204
- const loaded = require('next/server');
205
- return typeof (loaded == null ? void 0 : loaded.after) === 'function' ? loaded.after : null;
206
- } catch (unused) {
207
- return null;
208
- }
209
- }
200
+ /** The flush being handed to `after()`, until it has been; the spec awaits it. */ let scheduling = null;
210
201
  /** Runs whatever is queued. Exported for the spec; production reaches it through `after()`. */ export async function flushCapturedEmailChecks(deps = {}) {
211
202
  flushScheduled = false;
212
203
  const batch = queued.splice(0);
@@ -228,15 +219,16 @@ let flushScheduled = false;
228
219
  if (queued.length >= QUEUE_MAX) return;
229
220
  queued.push(_extends({}, item));
230
221
  if (flushScheduled) return;
231
- try {
232
- const after = afterResponse();
233
- if (!after) throw new Error('no request to run after');
234
- after(()=>flushCapturedEmailChecks().then(()=>undefined));
235
- flushScheduled = true;
236
- } catch (unused) {
237
- // No request to run after: a script or a spec. Nothing is checked.
222
+ // Set before `after()` is loaded, so every capture until then joins
223
+ // this flush rather than asking for its own.
224
+ flushScheduled = true;
225
+ scheduling = scheduleAfterResponse(()=>flushCapturedEmailChecks().then(()=>undefined), '[deliverability]').then((scheduled)=>{
226
+ if (scheduled) return;
227
+ // No request to run after: a script or a spec. Nothing is checked,
228
+ // and what was queued meanwhile goes with it.
238
229
  queued.length = 0;
239
- }
230
+ flushScheduled = false;
231
+ });
240
232
  } catch (error) {
241
233
  console.error('[deliverability] a captured address could not be queued', error);
242
234
  }
@@ -244,6 +236,10 @@ let flushScheduled = false;
244
236
  /** Test seam: drop anything queued. */ export function resetCapturedEmailChecksForTests() {
245
237
  queued.length = 0;
246
238
  flushScheduled = false;
239
+ scheduling = null;
240
+ }
241
+ /** Settles once the last capture's flush was handed to `after()`, or dropped. For the spec. */ export async function capturedEmailCheckScheduledForTests() {
242
+ await scheduling;
247
243
  }
248
244
  /** What is queued, for the spec. */ export function queuedCapturedEmailsForTests() {
249
245
  return queued;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/capture-email-check.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\n/*==========================================\n * THE DELIVERABILITY CHECK AT CAPTURE (AGL-3328).\n *\n * A person arrives — a form, an import, the New lead drawer, `POST\n * /v1/contacts`, a list add — and the address they gave is looked at\n * before anybody writes to it: the domain's MX, from the platform cache\n * `email-deliverability.ts` keeps. A domain that takes no mail (no MX, or\n * RFC 7505's null MX) puts `undeliverable` — \"Would bounce\" — on every\n * record the address is, through the core's record email-state seam, so the\n * record a person opens says so and the lists' `emailStatus` filter finds\n * it.\n *\n * ## Never on the write's time\n *\n * The writers that call this — `addHostLead`, `upsertHostContact`,\n * `enrollListMember`, the API's contact create — have finished their own\n * write when they do, and they do not wait: {@link scheduleCapturedEmailCheck}\n * queues the address and runs the queue in `after()`, once the response\n * has gone. One flush per invocation, however many rows an import wrote,\n * so a file of five hundred people from forty domains asks forty domains.\n *\n * `after()` rather than a bare promise (AGL-2327): a serverless invocation\n * is frozen the moment its response is sent, and work scheduled any other\n * way does not run. Outside a request — a script, a spec — there is nothing\n * to defer to, and nothing is checked; the backfill covers what those\n * write.\n *\n * ## The check's own verdict, and only that, is taken back\n *\n * `undeliverable` is the weakest refusal (`email-state.ts`): any real bounce\n * or block replaces it. The check withdraws it when the domain takes mail\n * again — a typo'd domain somebody since registered, a company that set up\n * its mail — and only when the record's standing verdict is that one; a\n * bounce is never cleared by DNS. A writer that knows the record held it\n * says so (`predicted`), which is what lets every other capture skip the\n * read a withdrawal costs.\n *==========================================*/\n\nimport type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n bareSenderAddress,\n decidingDeliverabilityFinding,\n type EmailDeliverabilityCode,\n type EmailDeliverabilityVerdict,\n type MailGateway,\n type MailGatewayChip,\n mailDomainRefusesMail,\n mailGatewayChip,\n mailGatewayStanding,\n normalizeDeliverabilityEmail,\n} from '@aglyn/shared-util-email'\nimport {\n isGatewayHeldVerdict,\n isNoMailServerVerdict,\n type MailDeliverabilityDeps,\n readMailDeliverability,\n} from './email-deliverability'\n\n/** One captured address, and whose records it lands on. */\nexport interface CapturedEmail {\n /** The organization whose records carry it; or `hostId`, which the writer reads the organization off. */\n orgId?: string | null\n hostId?: string | null\n email: string\n /**\n * The record already holds the check's own `undeliverable` verdict: a\n * domain that answers now withdraws it, and one that still takes no mail\n * writes nothing again.\n */\n predicted?: boolean\n}\n\n/** What one run of the check did. */\nexport interface CapturedEmailCheckReport {\n /** Addresses whose domain was answered for. */\n checked: number\n /** Addresses whose domain takes no mail. */\n undeliverable: number\n /** Records the seam moved to \"Would bounce\". */\n stamped: number\n /** Records whose \"Would bounce\" was withdrawn. */\n withdrawn: number\n}\n\n/**\n * How long an import waits, past its last write, for its addresses' mail\n * servers before it answers without the count (see `settleWithin`).\n */\nexport const IMPORT_MAIL_CHECK_GRACE_MS = 5_000\n\n/** The seam's writes in flight at once. */\nconst STAMP_CONCURRENCY = 8\n\n/** Addresses one invocation may hold for its flush; past it, the backfill catches up. */\nconst QUEUE_MAX = 20_000\n\n/** Runs `work` over `items`, at most `limit` at a time. */\nasync function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {\n let next = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n while (next < items.length) {\n const item = items[next]\n next += 1\n await work(item)\n }\n })\n await Promise.all(runners)\n}\n\n/**\n * The detail a \"Would bounce\" carries. A missing mail server is what the\n * chip already says; a null MX is the domain saying it outright, which is\n * worth the words.\n */\nfunction undeliverableDetail(verdict: EmailDeliverabilityVerdict): string | null {\n return verdict.intel?.status === 'null_mx'\n ? `${verdict.domain} publishes a null MX record: it says it accepts no email.`\n : null\n}\n\n/** The address a verdict answers for, as the seam keys it. */\nfunction stampTarget(item: CapturedEmail): { orgId?: string; hostId?: string } | null {\n const orgId = String(item.orgId ?? '').trim()\n if (orgId) return { orgId }\n const hostId = String(item.hostId ?? '').trim()\n return hostId ? { hostId } : null\n}\n\n/**\n * Checks the addresses and writes what was found onto their records: \"Would\n * bounce\" for a domain that takes no mail, the withdrawal of a standing\n * \"Would bounce\" for one that does again. Never throws — the capture has\n * already happened, and a record left unstamped is what the backfill is for.\n */\nexport async function checkCapturedEmails(\n items: readonly CapturedEmail[],\n deps: MailDeliverabilityDeps = {},\n): Promise<CapturedEmailCheckReport> {\n const report: CapturedEmailCheckReport = { checked: 0, undeliverable: 0, stamped: 0, withdrawn: 0 }\n // One entry per record set: the same address captured twice for one\n // organization in one flush is asked about once, and a `predicted` on\n // either capture stands.\n const unique = new Map<string, CapturedEmail & { email: string }>()\n for (const item of items) {\n const email = normalizeDeliverabilityEmail(item?.email)\n const target = item ? stampTarget(item) : null\n if (!email || !target) continue\n const key = `${target.orgId ?? `host:${target.hostId}`}|${email}`\n const held = unique.get(key)\n unique.set(key, { ...target, email, predicted: Boolean(held?.predicted || item.predicted) })\n }\n if (!unique.size) return report\n\n const nowMs = deps.nowMs ?? Date.now()\n let verdicts: Map<string, EmailDeliverabilityVerdict>\n try {\n verdicts = await readMailDeliverability(\n {\n emails: [...new Set([...unique.values()].map((item) => item.email))],\n purpose: 'bulk',\n sendingDomain: null,\n scope: null,\n },\n { ...deps, nowMs },\n )\n } catch (error) {\n console.error('[deliverability] captured addresses could not be checked', error)\n return report\n }\n\n const writes: Array<() => Promise<void>> = []\n for (const item of unique.values()) {\n const verdict = verdicts.get(item.email)\n if (!verdict || verdict.outcome === 'unchecked') continue\n report.checked += 1\n const target = { ...(item.orgId ? { orgId: item.orgId } : { hostId: item.hostId ?? '' }) }\n if (isNoMailServerVerdict(verdict)) {\n report.undeliverable += 1\n if (item.predicted) continue\n const state: EmailState = {\n status: 'undeliverable',\n atMs: nowMs,\n source: 'check',\n detail: undeliverableDetail(verdict),\n }\n writes.push(async () => {\n const stamped = await stampRecordEmailState({ ...target, email: item.email, state })\n report.stamped += stamped?.records ?? 0\n })\n } else if (item.predicted && verdict.intel && !mailDomainRefusesMail(verdict.intel.status)) {\n const state: EmailState = { status: 'undeliverable', atMs: nowMs, source: 'check', detail: null }\n writes.push(async () => {\n const withdrawn = await stampRecordEmailState({ ...target, email: item.email, state, withdraw: true })\n report.withdrawn += withdrawn?.records ?? 0\n })\n }\n }\n await eachLimited(writes, STAMP_CONCURRENCY, (write) => write())\n return report\n}\n\n/*==========================================\n * THE QUEUE, FLUSHED AFTER THE RESPONSE\n *==========================================*/\n\nconst queued: CapturedEmail[] = []\nlet flushScheduled = false\n\ntype AfterResponse = (task: () => Promise<void>) => void\n\n/**\n * Next's `after()`, required when first asked for rather than imported:\n * `next/server` evaluates web `Request` classes at load, which a jsdom spec\n * reaching one of the writers cannot, and this module rides every writer's\n * import. `null` where it cannot be loaded.\n */\nfunction afterResponse(): AfterResponse | null {\n try {\n const loaded = require('next/server') as { after?: AfterResponse }\n return typeof loaded?.after === 'function' ? loaded.after : null\n } catch {\n return null\n }\n}\n\n/** Runs whatever is queued. Exported for the spec; production reaches it through `after()`. */\nexport async function flushCapturedEmailChecks(deps: MailDeliverabilityDeps = {}): Promise<CapturedEmailCheckReport> {\n flushScheduled = false\n const batch = queued.splice(0)\n if (!batch.length) return { checked: 0, undeliverable: 0, stamped: 0, withdrawn: 0 }\n return checkCapturedEmails(batch, deps)\n}\n\n/**\n * Queues one captured address for the check, which runs once the response\n * has been sent — see the module note. Returns at once, never throws, and\n * costs the caller nothing but the push.\n */\nexport function scheduleCapturedEmailCheck(item: CapturedEmail): void {\n try {\n if (!item || !normalizeDeliverabilityEmail(item.email) || !stampTarget(item)) return\n if (queued.length >= QUEUE_MAX) return\n queued.push({ ...item })\n if (flushScheduled) return\n try {\n const after = afterResponse()\n if (!after) throw new Error('no request to run after')\n after(() => flushCapturedEmailChecks().then(() => undefined))\n flushScheduled = true\n } catch {\n // No request to run after: a script or a spec. Nothing is checked.\n queued.length = 0\n }\n } catch (error) {\n console.error('[deliverability] a captured address could not be queued', error)\n }\n}\n\n/** Test seam: drop anything queued. */\nexport function resetCapturedEmailChecksForTests(): void {\n queued.length = 0\n flushScheduled = false\n}\n\n/** What is queued, for the spec. */\nexport function queuedCapturedEmailsForTests(): readonly CapturedEmail[] {\n return queued\n}\n\n/*==========================================\n * WHAT AN IMPORT REPORTS\n *==========================================*/\n\n/**\n * Which of these addresses (normalized) have a domain that takes no mail,\n * from the same cache the check writes — what an import's result counts.\n * Never rejects: `null` when the answer could not be had, and the import\n * says nothing rather than something untrue.\n */\nexport async function findUndeliverableEmails(\n emails: readonly string[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Set<string> | null> {\n const wanted = [\n ...new Set(emails.map((email) => normalizeDeliverabilityEmail(email)).filter((email): email is string => Boolean(email))),\n ]\n if (!wanted.length) return new Set()\n try {\n const verdicts = await readMailDeliverability(\n { emails: wanted, purpose: 'bulk', sendingDomain: null, scope: null },\n deps,\n )\n const found = new Set<string>()\n for (const [email, verdict] of verdicts) if (isNoMailServerVerdict(verdict)) found.add(email)\n return found\n } catch (error) {\n console.error('[deliverability] an import’s addresses could not be checked', error)\n return null\n }\n}\n\n/**\n * `promise`'s answer, or `null` when it has not arrived within `ms`. An\n * import starts its lookups with its first row and waits this long past its\n * last write for them; a slower answer still stamps the records, through the\n * check the writes queued, and only the count is left unsaid.\n */\nexport async function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T | null> {\n let timer: ReturnType<typeof setTimeout> | undefined\n const late = new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), Math.max(0, ms))\n })\n try {\n return await Promise.race([promise, late])\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/** How many of `emails` a {@link findUndeliverableEmails} answer names; `undefined` without an answer. */\nexport function countUndeliverableEmails(\n emails: readonly string[],\n undeliverable: ReadonlySet<string> | null,\n): number | undefined {\n if (!undeliverable) return undefined\n let count = 0\n for (const email of new Set(emails.map((entry) => normalizeDeliverabilityEmail(entry)))) {\n if (email && undeliverable.has(email)) count += 1\n }\n return count\n}\n\n/*==========================================\n * ONE ADDRESS, AS A RECORD PAGE AND A COMPOSER READ IT\n *==========================================*/\n\n/** What a surface shows for one address before somebody writes to it. */\nexport interface AddressDeliverability {\n /** The address, normalized; `null` when it is not one. */\n email: string | null\n /** Whether the domain's MX was read; `false` means nothing is known, and nothing is warned. */\n checked: boolean\n /** The finding a composer warns with — a domain with no mail server, or a gateway holding this sender. */\n code: Extract<EmailDeliverabilityCode, 'no_mx' | 'null_mx' | 'gateway_held'> | null\n /** That finding in a sentence, as the engine words it. */\n message: string | null\n /** The gateway in front of the address, when its MX names one. */\n gateway: MailGateway | null\n /** The chip beside the address: the gateway, and what it did with this sender's mail this week. */\n chip: MailGatewayChip | null\n}\n\n/**\n * One address's standing for the site mail would leave from: the platform\n * MX cache (looked up, server side, when cold) and the sending domain's\n * gateway ledger. Read, never written onto a record — a composer asks it\n * before a send and a record page before it draws the address. `from` is\n * the sending identity's address; without one, no ledger is read and\n * nothing is held.\n */\nexport async function readAddressDeliverability(\n input: { email: string; from?: string | null },\n deps: MailDeliverabilityDeps = {},\n): Promise<AddressDeliverability> {\n const email = normalizeDeliverabilityEmail(input.email)\n const none: AddressDeliverability = { email, checked: false, code: null, message: null, gateway: null, chip: null }\n if (!email) return none\n const sender = bareSenderAddress(input.from ?? null)\n const sendingDomain = sender ? sender.slice(sender.lastIndexOf('@') + 1) : null\n const nowMs = deps.nowMs ?? Date.now()\n let verdict: EmailDeliverabilityVerdict | undefined\n try {\n verdict = (\n await readMailDeliverability(\n { emails: [email], purpose: 'bulk', sendingDomain, scope: null },\n { ...deps, nowMs },\n )\n ).get(email)\n } catch (error) {\n console.error('[deliverability] an address could not be checked', error)\n return none\n }\n if (!verdict?.intel) return none\n const deciding = decidingDeliverabilityFinding(verdict)\n const warns = isNoMailServerVerdict(verdict) || isGatewayHeldVerdict(verdict)\n const standing = verdict.gateway ?? mailGatewayStanding(verdict.intel.gateway, null, nowMs)\n let chip = mailGatewayChip(standing)\n // A public mailbox provider's MX is the provider's, not a gateway the\n // person's company chose: \"Google Workspace\" beside a gmail.com address\n // says something untrue. It keeps a chip only for a verdict this week.\n if (chip?.tone === 'neutral' && isPublicMailboxDomain(verdict.domain)) chip = null\n return {\n email,\n checked: true,\n code: warns && deciding ? (deciding.code as AddressDeliverability['code']) : null,\n message: warns && deciding ? deciding.message : null,\n gateway: verdict.intel.gateway === 'none' ? null : verdict.intel.gateway,\n chip,\n }\n}\n"],"names":["isPublicMailboxDomain","stampRecordEmailState","bareSenderAddress","decidingDeliverabilityFinding","mailDomainRefusesMail","mailGatewayChip","mailGatewayStanding","normalizeDeliverabilityEmail","isGatewayHeldVerdict","isNoMailServerVerdict","readMailDeliverability","IMPORT_MAIL_CHECK_GRACE_MS","STAMP_CONCURRENCY","QUEUE_MAX","eachLimited","items","limit","work","next","runners","Array","from","length","Math","min","item","Promise","all","undeliverableDetail","verdict","intel","status","domain","stampTarget","orgId","String","trim","hostId","checkCapturedEmails","deps","report","checked","undeliverable","stamped","withdrawn","unique","Map","target","email","key","held","get","set","predicted","Boolean","size","nowMs","Date","now","verdicts","emails","Set","values","map","purpose","sendingDomain","scope","error","console","writes","outcome","state","atMs","source","detail","push","records","withdraw","write","queued","flushScheduled","afterResponse","loaded","require","after","flushCapturedEmailChecks","batch","splice","scheduleCapturedEmailCheck","Error","then","undefined","resetCapturedEmailChecksForTests","queuedCapturedEmailsForTests","findUndeliverableEmails","wanted","filter","found","add","settleWithin","promise","ms","timer","late","resolve","setTimeout","max","race","clearTimeout","countUndeliverableEmails","count","entry","has","readAddressDeliverability","input","none","code","message","gateway","chip","sender","slice","lastIndexOf","deciding","warns","standing","tone"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/capture-email-check.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\n/*==========================================\n * THE DELIVERABILITY CHECK AT CAPTURE (AGL-3328).\n *\n * A person arrives — a form, an import, the New lead drawer, `POST\n * /v1/contacts`, a list add — and the address they gave is looked at\n * before anybody writes to it: the domain's MX, from the platform cache\n * `email-deliverability.ts` keeps. A domain that takes no mail (no MX, or\n * RFC 7505's null MX) puts `undeliverable` — \"Would bounce\" — on every\n * record the address is, through the core's record email-state seam, so the\n * record a person opens says so and the lists' `emailStatus` filter finds\n * it.\n *\n * ## Never on the write's time\n *\n * The writers that call this — `addHostLead`, `upsertHostContact`,\n * `enrollListMember`, the API's contact create — have finished their own\n * write when they do, and they do not wait: {@link scheduleCapturedEmailCheck}\n * queues the address and runs the queue in `after()`, once the response\n * has gone. One flush per invocation, however many rows an import wrote,\n * so a file of five hundred people from forty domains asks forty domains.\n *\n * `after()` rather than a bare promise (AGL-2327): a serverless invocation\n * is frozen the moment its response is sent, and work scheduled any other\n * way does not run. `after-response.ts` loads it, and says why through\n * `import()`: the `require()` this module once used compiled to a\n * ReferenceError under Turbopack, and the check never ran in production.\n * Outside a request — a script, a spec — there is nothing to defer to, and\n * nothing is checked; the backfill covers what those write.\n *\n * ## The check's own verdict, and only that, is taken back\n *\n * `undeliverable` is the weakest refusal (`email-state.ts`): any real bounce\n * or block replaces it. The check withdraws it when the domain takes mail\n * again — a typo'd domain somebody since registered, a company that set up\n * its mail — and only when the record's standing verdict is that one; a\n * bounce is never cleared by DNS. A writer that knows the record held it\n * says so (`predicted`), which is what lets every other capture skip the\n * read a withdrawal costs.\n *==========================================*/\n\nimport type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n bareSenderAddress,\n decidingDeliverabilityFinding,\n type EmailDeliverabilityCode,\n type EmailDeliverabilityVerdict,\n type MailGateway,\n type MailGatewayChip,\n mailDomainRefusesMail,\n mailGatewayChip,\n mailGatewayStanding,\n normalizeDeliverabilityEmail,\n} from '@aglyn/shared-util-email'\nimport { scheduleAfterResponse } from './after-response'\nimport {\n isGatewayHeldVerdict,\n isNoMailServerVerdict,\n type MailDeliverabilityDeps,\n readMailDeliverability,\n} from './email-deliverability'\n\n/** One captured address, and whose records it lands on. */\nexport interface CapturedEmail {\n /** The organization whose records carry it; or `hostId`, which the writer reads the organization off. */\n orgId?: string | null\n hostId?: string | null\n email: string\n /**\n * The record already holds the check's own `undeliverable` verdict: a\n * domain that answers now withdraws it, and one that still takes no mail\n * writes nothing again.\n */\n predicted?: boolean\n}\n\n/** What one run of the check did. */\nexport interface CapturedEmailCheckReport {\n /** Addresses whose domain was answered for. */\n checked: number\n /** Addresses whose domain takes no mail. */\n undeliverable: number\n /** Records the seam moved to \"Would bounce\". */\n stamped: number\n /** Records whose \"Would bounce\" was withdrawn. */\n withdrawn: number\n}\n\n/**\n * How long an import waits, past its last write, for its addresses' mail\n * servers before it answers without the count (see `settleWithin`).\n */\nexport const IMPORT_MAIL_CHECK_GRACE_MS = 5_000\n\n/** The seam's writes in flight at once. */\nconst STAMP_CONCURRENCY = 8\n\n/** Addresses one invocation may hold for its flush; past it, the backfill catches up. */\nconst QUEUE_MAX = 20_000\n\n/** Runs `work` over `items`, at most `limit` at a time. */\nasync function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {\n let next = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n while (next < items.length) {\n const item = items[next]\n next += 1\n await work(item)\n }\n })\n await Promise.all(runners)\n}\n\n/**\n * The detail a \"Would bounce\" carries. A missing mail server is what the\n * chip already says; a null MX is the domain saying it outright, which is\n * worth the words.\n */\nfunction undeliverableDetail(verdict: EmailDeliverabilityVerdict): string | null {\n return verdict.intel?.status === 'null_mx'\n ? `${verdict.domain} publishes a null MX record: it says it accepts no email.`\n : null\n}\n\n/** The address a verdict answers for, as the seam keys it. */\nfunction stampTarget(item: CapturedEmail): { orgId?: string; hostId?: string } | null {\n const orgId = String(item.orgId ?? '').trim()\n if (orgId) return { orgId }\n const hostId = String(item.hostId ?? '').trim()\n return hostId ? { hostId } : null\n}\n\n/**\n * Checks the addresses and writes what was found onto their records: \"Would\n * bounce\" for a domain that takes no mail, the withdrawal of a standing\n * \"Would bounce\" for one that does again. Never throws — the capture has\n * already happened, and a record left unstamped is what the backfill is for.\n */\nexport async function checkCapturedEmails(\n items: readonly CapturedEmail[],\n deps: MailDeliverabilityDeps = {},\n): Promise<CapturedEmailCheckReport> {\n const report: CapturedEmailCheckReport = { checked: 0, undeliverable: 0, stamped: 0, withdrawn: 0 }\n // One entry per record set: the same address captured twice for one\n // organization in one flush is asked about once, and a `predicted` on\n // either capture stands.\n const unique = new Map<string, CapturedEmail & { email: string }>()\n for (const item of items) {\n const email = normalizeDeliverabilityEmail(item?.email)\n const target = item ? stampTarget(item) : null\n if (!email || !target) continue\n const key = `${target.orgId ?? `host:${target.hostId}`}|${email}`\n const held = unique.get(key)\n unique.set(key, { ...target, email, predicted: Boolean(held?.predicted || item.predicted) })\n }\n if (!unique.size) return report\n\n const nowMs = deps.nowMs ?? Date.now()\n let verdicts: Map<string, EmailDeliverabilityVerdict>\n try {\n verdicts = await readMailDeliverability(\n {\n emails: [...new Set([...unique.values()].map((item) => item.email))],\n purpose: 'bulk',\n sendingDomain: null,\n scope: null,\n },\n { ...deps, nowMs },\n )\n } catch (error) {\n console.error('[deliverability] captured addresses could not be checked', error)\n return report\n }\n\n const writes: Array<() => Promise<void>> = []\n for (const item of unique.values()) {\n const verdict = verdicts.get(item.email)\n if (!verdict || verdict.outcome === 'unchecked') continue\n report.checked += 1\n const target = { ...(item.orgId ? { orgId: item.orgId } : { hostId: item.hostId ?? '' }) }\n if (isNoMailServerVerdict(verdict)) {\n report.undeliverable += 1\n if (item.predicted) continue\n const state: EmailState = {\n status: 'undeliverable',\n atMs: nowMs,\n source: 'check',\n detail: undeliverableDetail(verdict),\n }\n writes.push(async () => {\n const stamped = await stampRecordEmailState({ ...target, email: item.email, state })\n report.stamped += stamped?.records ?? 0\n })\n } else if (item.predicted && verdict.intel && !mailDomainRefusesMail(verdict.intel.status)) {\n const state: EmailState = { status: 'undeliverable', atMs: nowMs, source: 'check', detail: null }\n writes.push(async () => {\n const withdrawn = await stampRecordEmailState({ ...target, email: item.email, state, withdraw: true })\n report.withdrawn += withdrawn?.records ?? 0\n })\n }\n }\n await eachLimited(writes, STAMP_CONCURRENCY, (write) => write())\n return report\n}\n\n/*==========================================\n * THE QUEUE, FLUSHED AFTER THE RESPONSE\n *==========================================*/\n\nconst queued: CapturedEmail[] = []\nlet flushScheduled = false\n/** The flush being handed to `after()`, until it has been; the spec awaits it. */\nlet scheduling: Promise<void> | null = null\n\n/** Runs whatever is queued. Exported for the spec; production reaches it through `after()`. */\nexport async function flushCapturedEmailChecks(deps: MailDeliverabilityDeps = {}): Promise<CapturedEmailCheckReport> {\n flushScheduled = false\n const batch = queued.splice(0)\n if (!batch.length) return { checked: 0, undeliverable: 0, stamped: 0, withdrawn: 0 }\n return checkCapturedEmails(batch, deps)\n}\n\n/**\n * Queues one captured address for the check, which runs once the response\n * has been sent — see the module note. Returns at once, never throws, and\n * costs the caller nothing but the push.\n */\nexport function scheduleCapturedEmailCheck(item: CapturedEmail): void {\n try {\n if (!item || !normalizeDeliverabilityEmail(item.email) || !stampTarget(item)) return\n if (queued.length >= QUEUE_MAX) return\n queued.push({ ...item })\n if (flushScheduled) return\n // Set before `after()` is loaded, so every capture until then joins\n // this flush rather than asking for its own.\n flushScheduled = true\n scheduling = scheduleAfterResponse(\n () => flushCapturedEmailChecks().then(() => undefined),\n '[deliverability]',\n ).then((scheduled) => {\n if (scheduled) return\n // No request to run after: a script or a spec. Nothing is checked,\n // and what was queued meanwhile goes with it.\n queued.length = 0\n flushScheduled = false\n })\n } catch (error) {\n console.error('[deliverability] a captured address could not be queued', error)\n }\n}\n\n/** Test seam: drop anything queued. */\nexport function resetCapturedEmailChecksForTests(): void {\n queued.length = 0\n flushScheduled = false\n scheduling = null\n}\n\n/** Settles once the last capture's flush was handed to `after()`, or dropped. For the spec. */\nexport async function capturedEmailCheckScheduledForTests(): Promise<void> {\n await scheduling\n}\n\n/** What is queued, for the spec. */\nexport function queuedCapturedEmailsForTests(): readonly CapturedEmail[] {\n return queued\n}\n\n/*==========================================\n * WHAT AN IMPORT REPORTS\n *==========================================*/\n\n/**\n * Which of these addresses (normalized) have a domain that takes no mail,\n * from the same cache the check writes — what an import's result counts.\n * Never rejects: `null` when the answer could not be had, and the import\n * says nothing rather than something untrue.\n */\nexport async function findUndeliverableEmails(\n emails: readonly string[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Set<string> | null> {\n const wanted = [\n ...new Set(emails.map((email) => normalizeDeliverabilityEmail(email)).filter((email): email is string => Boolean(email))),\n ]\n if (!wanted.length) return new Set()\n try {\n const verdicts = await readMailDeliverability(\n { emails: wanted, purpose: 'bulk', sendingDomain: null, scope: null },\n deps,\n )\n const found = new Set<string>()\n for (const [email, verdict] of verdicts) if (isNoMailServerVerdict(verdict)) found.add(email)\n return found\n } catch (error) {\n console.error('[deliverability] an import’s addresses could not be checked', error)\n return null\n }\n}\n\n/**\n * `promise`'s answer, or `null` when it has not arrived within `ms`. An\n * import starts its lookups with its first row and waits this long past its\n * last write for them; a slower answer still stamps the records, through the\n * check the writes queued, and only the count is left unsaid.\n */\nexport async function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T | null> {\n let timer: ReturnType<typeof setTimeout> | undefined\n const late = new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), Math.max(0, ms))\n })\n try {\n return await Promise.race([promise, late])\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/** How many of `emails` a {@link findUndeliverableEmails} answer names; `undefined` without an answer. */\nexport function countUndeliverableEmails(\n emails: readonly string[],\n undeliverable: ReadonlySet<string> | null,\n): number | undefined {\n if (!undeliverable) return undefined\n let count = 0\n for (const email of new Set(emails.map((entry) => normalizeDeliverabilityEmail(entry)))) {\n if (email && undeliverable.has(email)) count += 1\n }\n return count\n}\n\n/*==========================================\n * ONE ADDRESS, AS A RECORD PAGE AND A COMPOSER READ IT\n *==========================================*/\n\n/** What a surface shows for one address before somebody writes to it. */\nexport interface AddressDeliverability {\n /** The address, normalized; `null` when it is not one. */\n email: string | null\n /** Whether the domain's MX was read; `false` means nothing is known, and nothing is warned. */\n checked: boolean\n /** The finding a composer warns with — a domain with no mail server, or a gateway holding this sender. */\n code: Extract<EmailDeliverabilityCode, 'no_mx' | 'null_mx' | 'gateway_held'> | null\n /** That finding in a sentence, as the engine words it. */\n message: string | null\n /** The gateway in front of the address, when its MX names one. */\n gateway: MailGateway | null\n /** The chip beside the address: the gateway, and what it did with this sender's mail this week. */\n chip: MailGatewayChip | null\n}\n\n/**\n * One address's standing for the site mail would leave from: the platform\n * MX cache (looked up, server side, when cold) and the sending domain's\n * gateway ledger. Read, never written onto a record — a composer asks it\n * before a send and a record page before it draws the address. `from` is\n * the sending identity's address; without one, no ledger is read and\n * nothing is held.\n */\nexport async function readAddressDeliverability(\n input: { email: string; from?: string | null },\n deps: MailDeliverabilityDeps = {},\n): Promise<AddressDeliverability> {\n const email = normalizeDeliverabilityEmail(input.email)\n const none: AddressDeliverability = { email, checked: false, code: null, message: null, gateway: null, chip: null }\n if (!email) return none\n const sender = bareSenderAddress(input.from ?? null)\n const sendingDomain = sender ? sender.slice(sender.lastIndexOf('@') + 1) : null\n const nowMs = deps.nowMs ?? Date.now()\n let verdict: EmailDeliverabilityVerdict | undefined\n try {\n verdict = (\n await readMailDeliverability(\n { emails: [email], purpose: 'bulk', sendingDomain, scope: null },\n { ...deps, nowMs },\n )\n ).get(email)\n } catch (error) {\n console.error('[deliverability] an address could not be checked', error)\n return none\n }\n if (!verdict?.intel) return none\n const deciding = decidingDeliverabilityFinding(verdict)\n const warns = isNoMailServerVerdict(verdict) || isGatewayHeldVerdict(verdict)\n const standing = verdict.gateway ?? mailGatewayStanding(verdict.intel.gateway, null, nowMs)\n let chip = mailGatewayChip(standing)\n // A public mailbox provider's MX is the provider's, not a gateway the\n // person's company chose: \"Google Workspace\" beside a gmail.com address\n // says something untrue. It keeps a chip only for a verdict this week.\n if (chip?.tone === 'neutral' && isPublicMailboxDomain(verdict.domain)) chip = null\n return {\n email,\n checked: true,\n code: warns && deciding ? (deciding.code as AddressDeliverability['code']) : null,\n message: warns && deciding ? deciding.message : null,\n gateway: verdict.intel.gateway === 'none' ? null : verdict.intel.gateway,\n chip,\n }\n}\n"],"names":["isPublicMailboxDomain","stampRecordEmailState","bareSenderAddress","decidingDeliverabilityFinding","mailDomainRefusesMail","mailGatewayChip","mailGatewayStanding","normalizeDeliverabilityEmail","scheduleAfterResponse","isGatewayHeldVerdict","isNoMailServerVerdict","readMailDeliverability","IMPORT_MAIL_CHECK_GRACE_MS","STAMP_CONCURRENCY","QUEUE_MAX","eachLimited","items","limit","work","next","runners","Array","from","length","Math","min","item","Promise","all","undeliverableDetail","verdict","intel","status","domain","stampTarget","orgId","String","trim","hostId","checkCapturedEmails","deps","report","checked","undeliverable","stamped","withdrawn","unique","Map","target","email","key","held","get","set","predicted","Boolean","size","nowMs","Date","now","verdicts","emails","Set","values","map","purpose","sendingDomain","scope","error","console","writes","outcome","state","atMs","source","detail","push","records","withdraw","write","queued","flushScheduled","scheduling","flushCapturedEmailChecks","batch","splice","scheduleCapturedEmailCheck","then","undefined","scheduled","resetCapturedEmailChecksForTests","capturedEmailCheckScheduledForTests","queuedCapturedEmailsForTests","findUndeliverableEmails","wanted","filter","found","add","settleWithin","promise","ms","timer","late","resolve","setTimeout","max","race","clearTimeout","countUndeliverableEmails","count","entry","has","readAddressDeliverability","input","none","code","message","gateway","chip","sender","slice","lastIndexOf","deciding","warns","standing","tone"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { AglynOrgBilling } from '@aglyn/aglyn/foundation';
18
18
  import { type CrmActivityLink, type CrmEmailQuotaResult, type CrmRecordsQuotaResult } from '@aglyn/aglyn/server';
19
- import { type CrmListCollection } from '@aglyn/aglyn/app-utils/crm';
19
+ import { type CrmListCollection, type CrmListFieldsContext } from '@aglyn/aglyn/app-utils/crm';
20
20
  import { type EmailRampVerdict } from '@aglyn/shared-util-email';
21
21
  /**
22
22
  * The CRM RECORDS band's measurement (AGL-2611): the three collections it
@@ -152,7 +152,16 @@ export interface CrmListFieldsRestamp {
152
152
  missing: number;
153
153
  }
154
154
  /**
155
- * Restamp one record's list fields from the record as stored.
155
+ * What a record's restamp reads beside it (AGL-3577): for a lead, the org's
156
+ * lead source list, which its `leadSourceDirection` is read off. `{}` for
157
+ * any other record, and for a lead whose org list cannot be read — the
158
+ * restamp then leaves a held value's direction as stored rather than guess.
159
+ */
160
+ export declare function crmListFieldsContextFor(ref: FirebaseFirestore.DocumentReference, collection: CrmListCollection): Promise<CrmListFieldsContext>;
161
+ /**
162
+ * Restamp one record's list fields from the record as stored — and a lead's
163
+ * direction from its org's lead source list, read here unless `context`
164
+ * already holds it.
156
165
  *
157
166
  * Then tell the plugins a record was written (AGL-3336): every server writer
158
167
  * of an org record passes here after its write, so this is the one place a
@@ -160,7 +169,7 @@ export interface CrmListFieldsRestamp {
160
169
  * is told, without an edit to each writer. Not for a record that no longer
161
170
  * exists, and after the restamp's own transaction, never inside it.
162
171
  */
163
- export declare function restampCrmListFieldsAt(ref: FirebaseFirestore.DocumentReference, collection: CrmListCollection): Promise<'restamped' | 'current' | 'missing'>;
172
+ export declare function restampCrmListFieldsAt(ref: FirebaseFirestore.DocumentReference, collection: CrmListCollection, context?: CrmListFieldsContext): Promise<'restamped' | 'current' | 'missing'>;
164
173
  /** Restamp several records of one collection, a few at a time. */
165
174
  export declare function restampCrmListFieldsOf(refs: readonly FirebaseFirestore.DocumentReference[], collection: CrmListCollection): Promise<CrmListFieldsRestamp>;
166
175
  /** Restamp several records of one collection under `orgs/{orgId}/`, by id. */
@@ -19,7 +19,7 @@ import { checkCrmEmailQuota, checkCrmRecordsQuota, CRM_COLLECTIONS, CRM_EMAIL_US
19
19
  * By module path, not the barrel: the capture door's specs substitute a
20
20
  * fixture barrel (see `upsert-contact.ts`), and the restamp is reached from
21
21
  * the door.
22
- */ import { crmListFieldsPatch } from "@aglyn/aglyn/app-utils/crm";
22
+ */ import { CRM_LEAD_SOURCE_PICKLIST, crmListFieldsPatch, effectiveCrmLeadSourcePicklist } from "@aglyn/aglyn/app-utils/crm";
23
23
  import { rampedDailyAllowance } from "@aglyn/shared-util-email";
24
24
  import { notifyPluginRecordWritten } from "@aglyn/aglyn/plugin-manager/plugin-record-written";
25
25
  import { FieldValue } from "firebase-admin/firestore";
@@ -221,21 +221,43 @@ import { FieldValue } from "firebase-admin/firestore";
221
221
  }
222
222
  }
223
223
  /**
224
- * Restamp one record's list fields from the record as stored.
224
+ * What a record's restamp reads beside it (AGL-3577): for a lead, the org's
225
+ * lead source list, which its `leadSourceDirection` is read off. `{}` for
226
+ * any other record, and for a lead whose org list cannot be read — the
227
+ * restamp then leaves a held value's direction as stored rather than guess.
228
+ */ export async function crmListFieldsContextFor(ref, collection) {
229
+ const org = collection === 'leads' ? ref.parent.parent : null;
230
+ if (!org) return {};
231
+ try {
232
+ const list = await org.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get();
233
+ return {
234
+ leadSources: effectiveCrmLeadSourcePicklist(list.data())
235
+ };
236
+ } catch (error) {
237
+ console.error(`[crm] lead source list unreadable for ${org.path}`, error);
238
+ return {};
239
+ }
240
+ }
241
+ /**
242
+ * Restamp one record's list fields from the record as stored — and a lead's
243
+ * direction from its org's lead source list, read here unless `context`
244
+ * already holds it.
225
245
  *
226
246
  * Then tell the plugins a record was written (AGL-3336): every server writer
227
247
  * of an org record passes here after its write, so this is the one place a
228
248
  * plugin that acts on the record AFTER any write — the CRM's sharing rules —
229
249
  * is told, without an edit to each writer. Not for a record that no longer
230
250
  * exists, and after the restamp's own transaction, never inside it.
231
- */ export async function restampCrmListFieldsAt(ref, collection) {
251
+ */ export async function restampCrmListFieldsAt(ref, collection, context) {
232
252
  let outcome;
233
253
  try {
254
+ // Read outside the transaction: the list is the org's, not the record's.
255
+ const known = context != null ? context : await crmListFieldsContextFor(ref, collection);
234
256
  outcome = await ref.firestore.runTransaction(async (tx)=>{
235
257
  var _snapshot_data;
236
258
  const snapshot = await tx.get(ref);
237
259
  if (!snapshot.exists) return 'missing';
238
- const patch = crmListFieldsPatch(collection, (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
260
+ const patch = crmListFieldsPatch(collection, (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {}, known);
239
261
  if (!Object.keys(patch).length) return 'current';
240
262
  tx.update(ref, patch);
241
263
  return 'restamped';
@@ -264,10 +286,23 @@ import { FieldValue } from "firebase-admin/firestore";
264
286
  ref
265
287
  ])).values()
266
288
  ];
289
+ // The org's lead source list, read once per org rather than per lead.
290
+ const contexts = new Map();
291
+ const contextOf = (ref)=>{
292
+ var _ref;
293
+ var _ref_parent_parent;
294
+ const key = (_ref = (_ref_parent_parent = ref.parent.parent) == null ? void 0 : _ref_parent_parent.path) != null ? _ref : '';
295
+ let context = contexts.get(key);
296
+ if (!context) {
297
+ context = crmListFieldsContextFor(ref, collection);
298
+ contexts.set(key, context);
299
+ }
300
+ return context;
301
+ };
267
302
  // Each is its own small transaction on its own record.
268
303
  const worker = async ()=>{
269
304
  for(let ref = queue.shift(); ref !== undefined; ref = queue.shift()){
270
- const outcome = await restampCrmListFieldsAt(ref, collection);
305
+ const outcome = await restampCrmListFieldsAt(ref, collection, await contextOf(ref));
271
306
  result[outcome] += 1;
272
307
  }
273
308
  };