@oxygen-agent/cli 1.1014.620 → 1.1014.773

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +15 -0
  3. package/dist/command-support.d.ts +5 -5
  4. package/dist/command-support.js +7 -7
  5. package/dist/commands/billing.js +12 -6
  6. package/dist/commands/columns.js +2 -2
  7. package/dist/commands/companies.js +1 -1
  8. package/dist/commands/copilot.js +1 -1
  9. package/dist/commands/dashboards.js +1 -1
  10. package/dist/commands/enrich-column.js +2 -2
  11. package/dist/commands/find.js +2 -2
  12. package/dist/commands/mailboxes.js +36 -0
  13. package/dist/commands/recipes.js +1 -1
  14. package/dist/commands/sequences.js +6 -6
  15. package/dist/commands/tables.js +1 -1
  16. package/dist/commands/verify.js +112 -11
  17. package/dist/http-client.js +1 -70
  18. package/dist/index.js +1 -1
  19. package/dist/refactor-internals/binary-download.d.ts +5 -0
  20. package/dist/refactor-internals/binary-download.js +71 -0
  21. package/node_modules/@oxygen/formula/dist/evaluate.js +2 -82
  22. package/node_modules/@oxygen/formula/dist/refactor-internals/novelty-score.d.ts +2 -0
  23. package/node_modules/@oxygen/formula/dist/refactor-internals/novelty-score.js +87 -0
  24. package/node_modules/@oxygen/shared/dist/billing.d.ts +15 -3
  25. package/node_modules/@oxygen/shared/dist/billing.js +19 -7
  26. package/node_modules/@oxygen/shared/dist/cancellation-reasons.d.ts +35 -0
  27. package/node_modules/@oxygen/shared/dist/cancellation-reasons.js +21 -0
  28. package/node_modules/@oxygen/shared/dist/capability-discovery.js +27 -1
  29. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +6 -6
  30. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +1 -1
  31. package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +1 -0
  32. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +41 -17
  33. package/node_modules/@oxygen/shared/dist/email-verification-batch.d.ts +26 -0
  34. package/node_modules/@oxygen/shared/dist/email-verification-batch.js +55 -0
  35. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +3 -3
  36. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +3 -3
  37. package/node_modules/@oxygen/shared/dist/knowledge-brain-draft.d.ts +70 -0
  38. package/node_modules/@oxygen/shared/dist/knowledge-brain-draft.js +42 -0
  39. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +8 -0
  40. package/node_modules/@oxygen/shared/dist/linkedin-url.js +10 -0
  41. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +1 -1
  42. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
  43. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +3 -1
  44. package/node_modules/@oxygen/shared/dist/product-doctrine.js +1 -1
  45. package/node_modules/@oxygen/shared/dist/sequence-internals/esp-policy.d.ts +225 -0
  46. package/node_modules/@oxygen/shared/dist/sequence-internals/esp-policy.js +578 -0
  47. package/node_modules/@oxygen/shared/dist/sequences.d.ts +1 -226
  48. package/node_modules/@oxygen/shared/dist/sequences.js +2 -576
  49. package/node_modules/@oxygen/shared/dist/teammate-groups.d.ts +103 -24
  50. package/node_modules/@oxygen/shared/dist/teammate-groups.js +125 -32
  51. package/node_modules/@oxygen/shared/dist/teammate-messages.d.ts +1 -1
  52. package/node_modules/@oxygen/shared/dist/teammates.d.ts +28 -2
  53. package/node_modules/@oxygen/shared/dist/teammates.js +32 -2
  54. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +40 -8
  55. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  56. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  57. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +1 -16
  58. package/node_modules/@oxygen/workflows/dist/graph/expression.js +4 -224
  59. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +2 -2
  60. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -63
  61. package/node_modules/@oxygen/workflows/dist/index.js +1 -193
  62. package/node_modules/@oxygen/workflows/dist/refactor-internals/condition-comparison.d.ts +18 -0
  63. package/node_modules/@oxygen/workflows/dist/refactor-internals/condition-comparison.js +222 -0
  64. package/node_modules/@oxygen/workflows/dist/refactor-internals/execution-policy.d.ts +64 -0
  65. package/node_modules/@oxygen/workflows/dist/refactor-internals/execution-policy.js +204 -0
  66. package/node_modules/@oxygen/workflows/package.json +4 -0
  67. package/package.json +1 -1
@@ -156,582 +156,8 @@ export function recipientEmailFromRow(rowValues, emailColumnKey) {
156
156
  }
157
157
  return null;
158
158
  }
159
- /**
160
- * ESP (email service provider) MATCHING mode for a sequence (settings.esp_matching).
161
- * Deliverability lore: a send lands better when the SENDING mailbox and the
162
- * RECIPIENT sit on the same provider (Google→Google, Microsoft→Microsoft), so at
163
- * mailbox-pick the dispatcher can bias rotation toward a same-ESP mailbox.
164
- * - "off" — no ESP bias; rotation is pure LRU / domain-spread.
165
- * - "prefer" — (DEFAULT) pick a same-ESP mailbox when one is available, else
166
- * fall back to any sendable mailbox (never blocks a send). On by default
167
- * because it only ever improves deliverability and cannot starve sends.
168
- * - "strict" — require a same-ESP mailbox; when none is available the action is
169
- * DEFERRED with a visible skipped_reason rather than sent cross-provider.
170
- * Power-user setting, reachable via CLI/MCP; the web "Provider Matching"
171
- * checkbox toggles off↔prefer only.
172
- */
173
- export const ESP_MATCHING_MODES = ["off", "prefer", "strict"];
174
- // Provider Matching is ON by default (prefer): a same-provider send lands better,
175
- // and prefer never blocks — an absent setting biases mailbox pick without risk.
176
- export const DEFAULT_ESP_MATCHING_MODE = "prefer";
177
- export function isEspMatchingMode(value) {
178
- return typeof value === "string" && ESP_MATCHING_MODES.includes(value);
179
- }
180
- /**
181
- * Validate settings.esp_matching before persistence. Absent/null is valid and
182
- * means DEFAULT_ESP_MATCHING_MODE, which is "prefer" — an unset sequence still
183
- * biases rotation toward a same-provider sender, so a workspace whose senders on
184
- * one provider are in bad standing concentrates that provider's recipients on
185
- * them until an operator sets "off". Any other value must be one of the three
186
- * modes. Pure; throws OxygenError("invalid_sequence_settings") on a bad value so
187
- * the CLI / MCP / API report an identical error. Called from the tenant-db
188
- * settings validator alongside the budget/prioritization checks.
189
- */
190
- export function validateEspMatchingSetting(value) {
191
- if (value === undefined || value === null)
192
- return;
193
- if (typeof value === "string" || typeof value !== "object" || Array.isArray(value)) {
194
- if (!isEspMatchingMode(value)) {
195
- throw new OxygenError("invalid_sequence_settings", `settings.esp_matching must be one of: ${ESP_MATCHING_MODES.join(", ")}.`, { details: { field: "esp_matching", value }, exitCode: 1 });
196
- }
197
- return;
198
- }
199
- validateEspRoutingPolicy(value);
200
- }
201
- /** Throw with the exact jsonb path that failed, so a typo is nameable rather than mysterious. */
202
- function espSettingError(path, message, value) {
203
- return new OxygenError("invalid_sequence_settings", `settings.${path} ${message}`, {
204
- details: { field: "esp_matching", path: `esp_matching.${path.replace(/^esp_matching\.?/, "")}`, ...(value === undefined ? {} : { value }) },
205
- exitCode: 1,
206
- });
207
- }
208
- /**
209
- * Strict validation of the object form, the write-path counterpart to the
210
- * lenient espPolicyFromSetting. Deliberately rejects UNKNOWN TOP-LEVEL KEYS by
211
- * name: this setting's failure mode is invisible — a policy that silently did
212
- * nothing because someone wrote "routs" would be discovered only as a month of
213
- * mail sent from the wrong fleet.
214
- */
215
- function validateEspRoutingPolicy(record) {
216
- const allowed = new Set(["mode", "routes", "exclude"]);
217
- for (const key of Object.keys(record)) {
218
- if (!allowed.has(key)) {
219
- throw espSettingError("esp_matching", `has an unknown key "${key}"; expected mode, routes or exclude.`);
220
- }
221
- }
222
- if (record.mode !== undefined && record.mode !== null && !isEspMatchingMode(record.mode)) {
223
- throw espSettingError("esp_matching.mode", `must be one of: ${ESP_MATCHING_MODES.join(", ")}.`, record.mode);
224
- }
225
- if (record.routes !== undefined && record.routes !== null) {
226
- if (typeof record.routes !== "object" || Array.isArray(record.routes)) {
227
- throw espSettingError("esp_matching.routes", "must be an object keyed by recipient provider.");
228
- }
229
- for (const [bucket, weights] of Object.entries(record.routes)) {
230
- if (!isEspRouteBucket(bucket)) {
231
- throw espSettingError("esp_matching.routes", `has an unknown recipient "${bucket}"; expected one of: ${ESP_ROUTE_BUCKETS.join(", ")}.`);
232
- }
233
- if (typeof weights !== "object" || weights === null || Array.isArray(weights)) {
234
- throw espSettingError(`esp_matching.routes.${bucket}`, "must be an object of sender weights.");
235
- }
236
- for (const [provider, weight] of Object.entries(weights)) {
237
- if (!RECIPIENT_ESPS.includes(provider)) {
238
- throw espSettingError(`esp_matching.routes.${bucket}`, `has an unknown sender "${provider}"; expected one of: ${RECIPIENT_ESPS.join(", ")}.`);
239
- }
240
- if (typeof weight !== "number" || !Number.isFinite(weight) || weight < 0 || weight > MAX_ESP_ROUTE_WEIGHT) {
241
- throw espSettingError(`esp_matching.routes.${bucket}.${provider}`, `must be a number between 0 and ${MAX_ESP_ROUTE_WEIGHT}.`, weight);
242
- }
243
- }
244
- }
245
- }
246
- if (record.exclude !== undefined && record.exclude !== null) {
247
- if (!Array.isArray(record.exclude)) {
248
- throw espSettingError("esp_matching.exclude", "must be an array of sender-domain exclusions.");
249
- }
250
- if (record.exclude.length > MAX_ESP_SENDER_EXCLUSIONS) {
251
- throw espSettingError("esp_matching.exclude", `must hold at most ${MAX_ESP_SENDER_EXCLUSIONS} entries; it is a routing policy, not a suppression list.`);
252
- }
253
- record.exclude.forEach((entry, index) => {
254
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
255
- throw espSettingError(`esp_matching.exclude[${index}]`, "must be an object with a domain.");
256
- }
257
- const item = entry;
258
- for (const key of Object.keys(item)) {
259
- if (key !== "domain" && key !== "from_recipients") {
260
- throw espSettingError(`esp_matching.exclude[${index}]`, `has an unknown key "${key}".`);
261
- }
262
- }
263
- if (typeof item.domain !== "string" || !normalizeEspSenderDomain(item.domain)) {
264
- throw espSettingError(`esp_matching.exclude[${index}].domain`, "must be a non-empty sending domain.", item.domain);
265
- }
266
- if (normalizeEspSenderDomain(item.domain).length > 253) {
267
- throw espSettingError(`esp_matching.exclude[${index}].domain`, "must be at most 253 characters.");
268
- }
269
- if (item.from_recipients !== undefined && item.from_recipients !== null) {
270
- if (!Array.isArray(item.from_recipients) || !item.from_recipients.every(isEspRouteBucket)) {
271
- throw espSettingError(`esp_matching.exclude[${index}].from_recipients`, `must be an array of: ${ESP_ROUTE_BUCKETS.join(", ")}.`);
272
- }
273
- }
274
- });
275
- }
276
- }
277
- /**
278
- * The MATCHABLE email-provider a recipient routes through — the narrow set ESP
279
- * matching can act on. Kept intentionally aligned with EMAIL_MAILBOX_PROVIDERS in
280
- * tenant-db so a mailbox's `provider` and a resolved recipient ESP compare
281
- * directly: we can only bias rotation toward a provider we can actually SEND
282
- * from, and the pool is Google/Microsoft only.
283
- *
284
- * This is deliberately NARROWER than RECIPIENT_ESP_FAMILIES (what we DISPLAY).
285
- * A domain behind Proofpoint has family "proofpoint" but may still be matchable
286
- * as "microsoft" — see resolveEspFamily.
287
- */
288
- export const RECIPIENT_ESPS = ["google", "microsoft"];
289
- /**
290
- * WEIGHTED SENDER ROUTING — the object form of settings.esp_matching.
291
- *
292
- * The three scalar modes above answer one question ("bias toward the recipient's
293
- * own provider, yes or no"), and they bake in the assumption that same-provider
294
- * is always better. That assumption fails whenever one provider's senders are in
295
- * bad standing: a workspace whose Google-hosted domains are refused by Gmail
296
- * wants its Google-hosted RECIPIENTS served from Microsoft senders, which no
297
- * scalar mode can express. "off" does not express it either, because free
298
- * rotation still hands Google recipients a Google sender in proportion to the
299
- * pool.
300
- *
301
- * So the object form states the routing directly, as SEND SHARES per recipient
302
- * provider:
303
- *
304
- * { mode: "prefer",
305
- * routes: { google: { microsoft: 100 }, microsoft: { microsoft: 100 } },
306
- * exclude: [{ domain: "burned.example", from_recipients: ["google"] }] }
307
- *
308
- * `routes` is keyed by the RECIPIENT's resolved provider; the inner map is the
309
- * SENDER provider. Weights are relative shares, so { google: 3, microsoft: 1 }
310
- * and { google: 75, microsoft: 25 } are the same policy and nothing has to sum
311
- * to 100. An omitted weight is zero. A bucket whose entry is explicitly present
312
- * but carries no positive weight means "no opinion, rotate freely for this
313
- * recipient class"; a bucket the operator never mentioned keeps the historical
314
- * same-provider default, so editing Google routing cannot silently change how
315
- * Microsoft recipients are served.
316
- *
317
- * `exclude` is a hard never, not a preference: a sender domain listed here is
318
- * removed from the candidate pool for the named recipient buckets (all buckets
319
- * when `from_recipients` is absent) in EVERY mode, including "off", and it
320
- * survives the prefer-fallback. A deliverability guard that a matching toggle
321
- * could disarm would not be a guard.
322
- *
323
- * The scalar modes remain valid values and are read as policies, so there is one
324
- * routing code path rather than two: "prefer" becomes { mode: "prefer" } with no
325
- * routes, which resolves to the same-provider default and behaves exactly as it
326
- * did before.
327
- */
328
- export const ESP_ROUTE_BUCKETS = ["google", "microsoft", "unknown"];
329
- /** Cap on settings.esp_matching.exclude — a routing policy, not a suppression list. */
330
- export const MAX_ESP_SENDER_EXCLUSIONS = 50;
331
- /** Upper bound on a single route weight; relative shares never need more. */
332
- export const MAX_ESP_ROUTE_WEIGHT = 1_000_000;
333
- export function isEspRouteBucket(value) {
334
- return typeof value === "string" && ESP_ROUTE_BUCKETS.includes(value);
335
- }
336
- function isRecipientEspValue(value) {
337
- return typeof value === "string" && RECIPIENT_ESPS.includes(value);
338
- }
339
- /**
340
- * A sending domain as the mailbox table stores it: lowercase, no leading "@".
341
- * Mirrors lower(split_part(email_address, '@', 2)) in pickEmailMailbox so an
342
- * exclusion the operator typed as "@Burned.Example " still matches.
343
- */
344
- export function normalizeEspSenderDomain(value) {
345
- return value.trim().toLowerCase().replace(/^@+/, "");
346
- }
347
- /**
348
- * LENIENT read of a persisted settings.esp_matching (never throws — a read must
349
- * not break a send). Absent, null, or malformed reads as the default policy, and
350
- * a malformed FIELD bails the WHOLE object rather than half-applying a routing
351
- * policy, which is the readMailboxRampConfig convention in tenant-db. The strict
352
- * counterpart that guards the write path is validateEspMatchingSetting.
353
- */
354
- export function espPolicyFromSetting(value) {
355
- const fallback = { mode: DEFAULT_ESP_MATCHING_MODE, routes: {}, exclude: [] };
356
- if (value === undefined || value === null)
357
- return fallback;
358
- if (typeof value === "string") {
359
- return isEspMatchingMode(value) ? { mode: value, routes: {}, exclude: [] } : fallback;
360
- }
361
- if (typeof value !== "object" || Array.isArray(value))
362
- return fallback;
363
- const record = value;
364
- const mode = record.mode === undefined || record.mode === null ? DEFAULT_ESP_MATCHING_MODE : record.mode;
365
- if (!isEspMatchingMode(mode))
366
- return fallback;
367
- const routes = {};
368
- if (record.routes !== undefined && record.routes !== null) {
369
- if (typeof record.routes !== "object" || Array.isArray(record.routes))
370
- return fallback;
371
- for (const [bucket, raw] of Object.entries(record.routes)) {
372
- if (!isEspRouteBucket(bucket))
373
- return fallback;
374
- if (typeof raw !== "object" || raw === null || Array.isArray(raw))
375
- return fallback;
376
- const weights = {};
377
- for (const [provider, weight] of Object.entries(raw)) {
378
- if (!isRecipientEspValue(provider))
379
- return fallback;
380
- if (typeof weight !== "number" || !Number.isFinite(weight) || weight < 0)
381
- return fallback;
382
- weights[provider] = weight;
383
- }
384
- routes[bucket] = weights;
385
- }
386
- }
387
- const exclude = [];
388
- if (record.exclude !== undefined && record.exclude !== null) {
389
- if (!Array.isArray(record.exclude))
390
- return fallback;
391
- for (const entry of record.exclude) {
392
- if (typeof entry !== "object" || entry === null || Array.isArray(entry))
393
- return fallback;
394
- const item = entry;
395
- if (typeof item.domain !== "string")
396
- return fallback;
397
- const domain = normalizeEspSenderDomain(item.domain);
398
- if (!domain)
399
- return fallback;
400
- let buckets = ESP_ROUTE_BUCKETS;
401
- if (item.from_recipients !== undefined && item.from_recipients !== null) {
402
- if (!Array.isArray(item.from_recipients))
403
- return fallback;
404
- if (item.from_recipients.length > 0) {
405
- if (!item.from_recipients.every(isEspRouteBucket))
406
- return fallback;
407
- buckets = item.from_recipients;
408
- }
409
- }
410
- exclude.push({ domain, buckets });
411
- }
412
- }
413
- return { mode, routes, exclude };
414
- }
415
- /**
416
- * The effective send shares for one recipient bucket. An explicitly-present
417
- * entry wins even when it is empty or all-zero (that is the operator saying
418
- * "rotate freely here"); a bucket that was never mentioned keeps the historical
419
- * same-provider default, and "unknown" has no same-provider to default to.
420
- */
421
- export function espRouteWeightsFor(policy, bucket) {
422
- const configured = policy.routes[bucket];
423
- if (configured)
424
- return configured;
425
- return bucket === "unknown" ? {} : { [bucket]: 1 };
426
- }
427
- /**
428
- * The sender providers to try, best first, for one recipient bucket — a
429
- * deterministic weighted shuffle WITHOUT replacement.
430
- *
431
- * Returning an ORDER rather than a single draw is what makes "prefer" correct:
432
- * a 90/10 Google policy over a pool holding no Google mailbox must send 100%
433
- * from Microsoft, not defer and not fall through to provider-blind rotation.
434
- * An empty result means "no opinion" and the caller rotates freely.
435
- *
436
- * Deterministic by construction: the seed carries the enrollment and step, never
437
- * a clock or Math.random, so a dry-run preview and the later live send choose
438
- * the same provider, and a replan after a defer re-derives the same route.
439
- * Candidates are enumerated in RECIPIENT_ESPS order rather than object-key
440
- * order, because jsonb does not preserve key order and a policy that round-trips
441
- * through the database must not reroute the fleet.
442
- */
443
- export function espRouteCandidates(input) {
444
- const weights = espRouteWeightsFor(input.policy, input.recipient);
445
- let pool = RECIPIENT_ESPS.map((provider) => ({ provider, weight: weights[provider] ?? 0 })).filter((entry) => Number.isFinite(entry.weight) && entry.weight > 0);
446
- const order = [];
447
- let draw = 0;
448
- while (pool.length > 0) {
449
- const total = pool.reduce((sum, entry) => sum + entry.weight, 0);
450
- // Re-hash per draw rather than reusing the residue: a second draw derived
451
- // from the first would correlate the tail of the order with its head.
452
- const seed = draw === 0 ? input.seed : `${input.seed}:${draw + 1}`;
453
- const target = ((hashVariantKey(seed) % 1_000_000) / 1_000_000) * total;
454
- let cumulative = 0;
455
- let chosen = pool[pool.length - 1].provider;
456
- for (const entry of pool) {
457
- cumulative += entry.weight;
458
- if (target < cumulative) {
459
- chosen = entry.provider;
460
- break;
461
- }
462
- }
463
- order.push(chosen);
464
- pool = pool.filter((entry) => entry.provider !== chosen);
465
- draw += 1;
466
- }
467
- return order;
468
- }
469
- /**
470
- * Sending domains that must not serve this recipient bucket, normalized and
471
- * de-duplicated for pickEmailMailbox. Applies in every mode and survives the
472
- * prefer-fallback — see the exclude contract above.
473
- */
474
- export function espExcludedSenderDomains(policy, bucket) {
475
- const domains = new Set();
476
- for (const entry of policy.exclude) {
477
- if (entry.buckets.includes(bucket))
478
- domains.add(entry.domain);
479
- }
480
- return [...domains];
481
- }
482
- /**
483
- * The DISPLAY taxonomy: the mail-infrastructure family a recipient domain sits
484
- * on. Wider than RecipientEsp because "everything that isn't Google or Microsoft"
485
- * is not one thing — a list that is 23% Proofpoint is a very different
486
- * deliverability problem from one that is 23% self-hosted, and collapsing both to
487
- * null (today's behavior) hides that. Split into three groups:
488
- *
489
- * - MAILBOX providers (google, microsoft) — imply a matchable ESP.
490
- * - GATEWAY vendors (proofpoint, mimecast, ...) — security filters that sit IN
491
- * FRONT of a mailbox provider. Their MX tells you the filter, NOT where the
492
- * mailbox lives; the SPF fallback is what sees behind them.
493
- * - OTHER mailbox hosts (zoho, yahoo, fastmail, ...) and self_hosted — real
494
- * destinations we simply cannot send as, so never matchable.
495
- */
496
- export const RECIPIENT_ESP_FAMILIES = [
497
- "google",
498
- "microsoft",
499
- "proofpoint",
500
- "mimecast",
501
- "barracuda",
502
- "cisco",
503
- "zscaler",
504
- "trendmicro",
505
- "sophos",
506
- "zoho",
507
- "yahoo",
508
- "apple",
509
- "proton",
510
- "fastmail",
511
- "gmx",
512
- "amazon",
513
- "self_hosted",
514
- ];
515
- export function isRecipientEspFamily(value) {
516
- return typeof value === "string" && RECIPIENT_ESP_FAMILIES.includes(value);
517
- }
518
- /**
519
- * Families that are inbound SECURITY GATEWAYS rather than mailbox hosts. Seeing
520
- * one in MX means the real mailbox provider is hidden behind it, so the resolver
521
- * must fall through to the SPF signal instead of concluding "not Google/Microsoft".
522
- * This is the single most important distinction in this file: treating a
523
- * Proofpoint MX as "no known ESP" is how a Microsoft-hosted list silently loses
524
- * ESP matching.
525
- */
526
- const GATEWAY_FAMILIES = new Set([
527
- "proofpoint",
528
- "mimecast",
529
- "barracuda",
530
- "cisco",
531
- "zscaler",
532
- "trendmicro",
533
- "sophos",
534
- ]);
535
- export function isEspGatewayFamily(family) {
536
- return family !== null && family !== undefined && GATEWAY_FAMILIES.has(family);
537
- }
538
- /**
539
- * The matchable ESP a display family implies, or null when the family is a
540
- * gateway (unknown until SPF) or a provider we cannot send as. The ONLY place
541
- * family → esp is decided, so display and matching can't drift.
542
- */
543
- export function matchableEspFromFamily(family) {
544
- if (family === "google" || family === "microsoft")
545
- return family;
546
- return null;
547
- }
548
- /**
549
- * Consumer/freemail domains that resolve deterministically from the domain string
550
- * with NO network call. Only google/microsoft are matchable; the rest are display
551
- * families (a list heavy on these is a list-quality signal worth surfacing).
552
- */
553
- const CONSUMER_DOMAIN_FAMILIES = new Map([
554
- ["gmail.com", "google"],
555
- ["googlemail.com", "google"],
556
- ["outlook.com", "microsoft"],
557
- ["hotmail.com", "microsoft"],
558
- ["live.com", "microsoft"],
559
- ["msn.com", "microsoft"],
560
- ["yahoo.com", "yahoo"],
561
- ["yahoo.co.uk", "yahoo"],
562
- ["ymail.com", "yahoo"],
563
- ["aol.com", "yahoo"],
564
- ["icloud.com", "apple"],
565
- ["me.com", "apple"],
566
- ["mac.com", "apple"],
567
- ["proton.me", "proton"],
568
- ["protonmail.com", "proton"],
569
- ["pm.me", "proton"],
570
- ["fastmail.com", "fastmail"],
571
- ["zoho.com", "zoho"],
572
- ["gmx.com", "gmx"],
573
- ["gmx.de", "gmx"],
574
- ["web.de", "gmx"],
575
- ]);
576
- /**
577
- * Normalize a recipient domain: lowercase, trim, drop a trailing FQDN dot, and if
578
- * a full address is passed take the part after the last '@'. Returns "" for junk.
579
- */
580
- function normalizeRecipientDomain(domain) {
581
- if (typeof domain !== "string")
582
- return "";
583
- let d = domain.trim().toLowerCase();
584
- const at = d.lastIndexOf("@");
585
- if (at >= 0)
586
- d = d.slice(at + 1);
587
- return d.replace(/\.+$/, "");
588
- }
589
- /**
590
- * Does `host` sit at or under `suffix`? Requires a DOT boundary, so "google.com"
591
- * matches "aspmx.l.google.com" and itself but NOT "notgoogle.com" — a bare
592
- * endsWith() would map an unrelated lookalike domain onto a real ESP family and,
593
- * under strict matching, route a send at the wrong pool.
594
- */
595
- function hostMatchesSuffix(host, suffix) {
596
- return host === suffix || host.endsWith(`.${suffix}`);
597
- }
598
- /**
599
- * MX exchange-host suffix → display family, most specific first. Every entry is
600
- * the vendor's own mail-routing domain (what shows up in a real MX answer):
601
- * Google Workspace publishes aspmx.l.google.com, Microsoft 365 publishes
602
- * <tenant>.mail.protection.outlook.com, Proofpoint pphosted.com, and so on.
603
- */
604
- const MX_FAMILY_SUFFIXES = [
605
- ["protection.outlook.com", "microsoft"],
606
- ["outlook.com", "microsoft"],
607
- ["google.com", "google"],
608
- ["googlemail.com", "google"],
609
- ["pphosted.com", "proofpoint"],
610
- ["ppe-hosted.com", "proofpoint"],
611
- ["mimecast.com", "mimecast"],
612
- ["mimecast.co.za", "mimecast"],
613
- ["mimecast-offshore.com", "mimecast"],
614
- ["barracudanetworks.com", "barracuda"],
615
- ["barracuda.com", "barracuda"],
616
- ["iphmx.com", "cisco"],
617
- ["cisco.com", "cisco"],
618
- ["zscaler.net", "zscaler"],
619
- ["zscalerthree.net", "zscaler"],
620
- ["trendmicro.com", "trendmicro"],
621
- ["trendmicro.eu", "trendmicro"],
622
- ["sophos.com", "sophos"],
623
- ["zoho.com", "zoho"],
624
- ["zoho.eu", "zoho"],
625
- ["yahoodns.net", "yahoo"],
626
- ["icloud.com", "apple"],
627
- ["protonmail.ch", "proton"],
628
- ["proton.me", "proton"],
629
- ["messagingengine.com", "fastmail"],
630
- ["gmx.net", "gmx"],
631
- ["amazonaws.com", "amazon"],
632
- ["awsapps.com", "amazon"],
633
- ];
634
- /**
635
- * Map a domain's resolved MX exchange hostnames to a DISPLAY family. Returns null
636
- * when no host matches any known vendor — the caller records that as
637
- * "self_hosted" only once it is sure the lookup itself succeeded (an empty/failed
638
- * answer is "unknown", which is a different fact).
639
- */
640
- export function familyFromMxHosts(hosts) {
641
- if (!Array.isArray(hosts))
642
- return null;
643
- for (const raw of hosts) {
644
- if (typeof raw !== "string")
645
- continue;
646
- const host = raw.trim().toLowerCase().replace(/\.+$/, "");
647
- if (!host)
648
- continue;
649
- for (const [suffix, family] of MX_FAMILY_SUFFIXES) {
650
- if (hostMatchesSuffix(host, suffix))
651
- return family;
652
- }
653
- }
654
- return null;
655
- }
656
- /**
657
- * SPF `include:` targets that identify the ESP a domain SENDS through — the
658
- * signal that sees behind an inbound security gateway. An org filtering inbound
659
- * mail through Proofpoint still relays its own outbound via Microsoft 365, and
660
- * says so in SPF, so this recovers a matchable ESP that MX alone cannot.
661
- */
662
- const SPF_INCLUDE_ESPS = [
663
- ["spf.protection.outlook.com", "microsoft"],
664
- ["spf.protection.office365.us", "microsoft"],
665
- ["_spf.google.com", "google"],
666
- ["_spf.googlemail.com", "google"],
667
- ["aspmx.googlemail.com", "google"],
668
- ];
669
- /**
670
- * Resolve the matchable ESP from a domain's TXT records by reading its SPF
671
- * mechanisms. Scans every `v=spf1` record's include:/redirect= targets.
672
- *
673
- * AMBIGUITY IS NOT GUESSED: a domain mid-migration (or running Workspace
674
- * alongside an Office tenant) can include BOTH vendors. Returning either one
675
- * would be a coin flip that strict matching then acts on, so a both-match
676
- * degrades to null ("unknown") and the caller falls back / defers honestly.
677
- */
678
- export function espFromSpfRecords(records) {
679
- if (!Array.isArray(records))
680
- return null;
681
- const found = new Set();
682
- for (const raw of records) {
683
- if (typeof raw !== "string")
684
- continue;
685
- const record = raw.trim().toLowerCase().replace(/^"|"$/g, "");
686
- if (!record.startsWith("v=spf1"))
687
- continue;
688
- for (const term of record.split(/\s+/)) {
689
- const target = term.startsWith("include:")
690
- ? term.slice("include:".length)
691
- : term.startsWith("redirect=")
692
- ? term.slice("redirect=".length)
693
- : null;
694
- if (!target)
695
- continue;
696
- const host = target.replace(/\.+$/, "");
697
- for (const [suffix, esp] of SPF_INCLUDE_ESPS) {
698
- if (hostMatchesSuffix(host, suffix))
699
- found.add(esp);
700
- }
701
- }
702
- }
703
- return found.size === 1 ? [...found][0] : null;
704
- }
705
- /**
706
- * Infer a recipient's DISPLAY family from its domain string alone,
707
- * DETERMINISTICALLY, for the well-known consumer/freemail domains. Returns null
708
- * for any other domain — the caller then resolves via the cached MX (+ SPF)
709
- * lookup, because a custom domain fronted by Workspace / M365 / a gateway is not
710
- * decidable from the string. Pure: the single source of truth for the map.
711
- */
712
- export function inferRecipientEspFamily(domain) {
713
- const d = normalizeRecipientDomain(domain);
714
- if (!d)
715
- return null;
716
- return CONSUMER_DOMAIN_FAMILIES.get(d) ?? null;
717
- }
718
- /**
719
- * Infer a recipient's MATCHABLE ESP from its domain string. Narrow by design:
720
- * only the Google/Microsoft consumer domains resolve here, everything else
721
- * (including yahoo/proton/icloud, which are real but unsendable-as) is null.
722
- * Unchanged contract — the dispatcher's fast path still calls this.
723
- */
724
- export function inferRecipientEsp(domain) {
725
- return matchableEspFromFamily(inferRecipientEspFamily(domain));
726
- }
727
- /**
728
- * Back-compat narrow view of familyFromMxHosts: MX hosts → matchable ESP. Now
729
- * derived from the family table so the two can't drift, and gateway MX hosts
730
- * correctly yield null here (they need the SPF signal to become matchable).
731
- */
732
- export function espFromMxHosts(hosts) {
733
- return matchableEspFromFamily(familyFromMxHosts(hosts));
734
- }
159
+ // Existing Sequence imports retain the same ESP contract and function identities.
160
+ export { ESP_MATCHING_MODES, DEFAULT_ESP_MATCHING_MODE, isEspMatchingMode, validateEspMatchingSetting, RECIPIENT_ESPS, ESP_ROUTE_BUCKETS, MAX_ESP_SENDER_EXCLUSIONS, MAX_ESP_ROUTE_WEIGHT, isEspRouteBucket, normalizeEspSenderDomain, espPolicyFromSetting, espRouteWeightsFor, espRouteCandidates, espExcludedSenderDomains, RECIPIENT_ESP_FAMILIES, isRecipientEspFamily, isEspGatewayFamily, matchableEspFromFamily, familyFromMxHosts, espFromSpfRecords, inferRecipientEspFamily, inferRecipientEsp, espFromMxHosts, } from "./sequence-internals/esp-policy.js";
735
161
  /**
736
162
  * row_values keys an enrollment's phone number may live under, in
737
163
  * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST