@aglyn/plugins-outreach 1.0.0-beta.197 → 1.0.0-beta.199

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 (63) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/compliance-section.d.ts +3 -1
  3. package/src/lib/components/compliance-section.js +110 -45
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.d.ts +7 -1
  6. package/src/lib/components/enroll-dialog.js +47 -3
  7. package/src/lib/components/enroll-dialog.js.map +1 -1
  8. package/src/lib/components/outreach-ui.js +1 -0
  9. package/src/lib/components/outreach-ui.js.map +1 -1
  10. package/src/lib/components/sequence-editor.js +25 -24
  11. package/src/lib/components/sequence-editor.js.map +1 -1
  12. package/src/lib/engine/delivery-status.d.ts +8 -0
  13. package/src/lib/engine/delivery-status.js +10 -5
  14. package/src/lib/engine/delivery-status.js.map +1 -1
  15. package/src/lib/engine/enrollment-state.d.ts +2 -0
  16. package/src/lib/engine/enrollment-state.js +2 -2
  17. package/src/lib/engine/enrollment-state.js.map +1 -1
  18. package/src/lib/engine/gates.d.ts +28 -1
  19. package/src/lib/engine/gates.js +28 -1
  20. package/src/lib/engine/gates.js.map +1 -1
  21. package/src/lib/engine/mail-gateway.d.ts +97 -0
  22. package/src/lib/engine/mail-gateway.js +226 -0
  23. package/src/lib/engine/mail-gateway.js.map +1 -0
  24. package/src/lib/engine/mailbox-health.d.ts +3 -3
  25. package/src/lib/engine/mailbox-health.js +17 -11
  26. package/src/lib/engine/mailbox-health.js.map +1 -1
  27. package/src/lib/engine/thread-classification.d.ts +2 -0
  28. package/src/lib/engine/thread-classification.js +2 -1
  29. package/src/lib/engine/thread-classification.js.map +1 -1
  30. package/src/lib/enrollment/enroll-people.d.ts +6 -1
  31. package/src/lib/enrollment/enroll-people.js +18 -4
  32. package/src/lib/enrollment/enroll-people.js.map +1 -1
  33. package/src/lib/enrollment/gate-lookups.d.ts +7 -0
  34. package/src/lib/enrollment/gate-lookups.js +11 -2
  35. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  36. package/src/lib/model/outreach-api.d.ts +14 -0
  37. package/src/lib/model/outreach-api.js.map +1 -1
  38. package/src/lib/model/outreach.types.d.ts +79 -3
  39. package/src/lib/model/outreach.types.js +21 -2
  40. package/src/lib/model/outreach.types.js.map +1 -1
  41. package/src/lib/routes/enroll-routes.js +23 -4
  42. package/src/lib/routes/enroll-routes.js.map +1 -1
  43. package/src/lib/routes/enrollment-routes.js +15 -1
  44. package/src/lib/routes/enrollment-routes.js.map +1 -1
  45. package/src/lib/routes/register-routes.js +3 -0
  46. package/src/lib/routes/register-routes.js.map +1 -1
  47. package/src/lib/routes/route-deps.d.ts +6 -0
  48. package/src/lib/routes/route-deps.js.map +1 -1
  49. package/src/lib/runtime/gateway-ledger.d.ts +46 -0
  50. package/src/lib/runtime/gateway-ledger.js +113 -0
  51. package/src/lib/runtime/gateway-ledger.js.map +1 -0
  52. package/src/lib/runtime/platform-runtime-deps.js +3 -0
  53. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  54. package/src/lib/runtime/runtime-deps.d.ts +6 -0
  55. package/src/lib/runtime/runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +48 -0
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/runtime/sync-job.d.ts +2 -0
  59. package/src/lib/runtime/sync-job.js +40 -3
  60. package/src/lib/runtime/sync-job.js.map +1 -1
  61. package/src/lib/storage/domain-intel-store.d.ts +107 -0
  62. package/src/lib/storage/domain-intel-store.js +320 -0
  63. package/src/lib/storage/domain-intel-store.js.map +1 -0
@@ -45,11 +45,23 @@
45
45
  * refuses — with a sentence that says the check could not be made rather
46
46
  * than one that accuses the address of something. A person who asked not to
47
47
  * be emailed must not be emailed because a read timed out.
48
+ *
49
+ * ## A block and a hold
50
+ *
51
+ * Every gate above REFUSES: the person is not enrolled, or the enrollment
52
+ * is stopped. The mail gateway (AGL-3326) adds one verdict of a different
53
+ * weight. A domain with no MX cannot receive mail and blocks like the rest;
54
+ * a gateway that refused this organization's sender twice in the last
55
+ * thirty days without delivering once HOLDS the send — the enrollment is
56
+ * allowed, and pauses at the send with `gateway_blocked_here` until a
57
+ * member resumes it — because the founder decides, and the decision is
58
+ * shown to them in Check people before they tick the person.
48
59
  *==========================================*/ import { contactEmails, normalizeContactEmail, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
49
60
  import { isPublicMailboxDomain } from "@aglyn/aglyn/app-utils/crm";
50
61
  import { emailStateRefusal, readEmailState } from "@aglyn/aglyn/app-utils/email-state";
51
62
  import { findMemberByEmailAddress } from "@aglyn/aglyn/app-utils/member-email-aliases";
52
63
  import { OUTREACH_ATTESTATION_KINDS, OUTREACH_PERSONAL_LINE_MAX } from "../model/outreach.types.js";
64
+ import { outreachGatewayHoldReason, outreachGatewayHolds } from "./mail-gateway.js";
53
65
  import { outreachCountryName, resolveOutreachRecipientCountry } from "./recipient-country.js";
54
66
  import { readOutreachSequenceSettings } from "./sequence-validation.js";
55
67
  /**
@@ -103,7 +115,7 @@ function listInWords(items) {
103
115
  }
104
116
  const COULD_NOT_CHECK = 'Try again in a moment.';
105
117
  /** Every gate an address and a contact must pass — see the module note. */ export function evaluateOutreachGates(input) {
106
- var _input_contact;
118
+ var _input_contact, _ref;
107
119
  const lookups = input.lookups;
108
120
  const settings = readOutreachSequenceSettings(input.settings);
109
121
  const email = normalizeContactEmail(input.email);
@@ -124,6 +136,7 @@ const COULD_NOT_CHECK = 'Try again in a moment.';
124
136
  source: null
125
137
  },
126
138
  blocks,
139
+ hold: null,
127
140
  missingAttestations: []
128
141
  });
129
142
  if (!contact) {
@@ -199,6 +212,19 @@ const COULD_NOT_CHECK = 'Try again in a moment.';
199
212
  } else if ((lookups == null ? void 0 : lookups.doNotContactDomain) !== false) {
200
213
  block('do_not_contact_domain', `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`);
201
214
  }
215
+ // 4b. The mail gateway in front of the domain (AGL-3326): no MX means no
216
+ // mail; a gateway that refused us twice lately holds, below.
217
+ const gateway = (_ref = lookups == null ? void 0 : lookups.gateway) != null ? _ref : null;
218
+ if (!gateway) {
219
+ block('no_mx', `We couldn't look up the mail server for ${domain}. ${COULD_NOT_CHECK}`);
220
+ } else if (gateway.gateway === 'none') {
221
+ block('no_mx', `${domain} has no MX record, so ${email} cannot receive mail.`);
222
+ }
223
+ const hold = gateway && input.gatewayHoldReleased !== true && outreachGatewayHolds(gateway) ? {
224
+ code: 'gateway_blocked_here',
225
+ gateway: gateway.gateway,
226
+ reason: outreachGatewayHoldReason(gateway.gateway, gateway.blocked30)
227
+ } : null;
202
228
  // 5. Not one of the workspace's own people.
203
229
  const members = lookups == null ? void 0 : lookups.workspaceMembers;
204
230
  if (!Array.isArray(members)) {
@@ -236,6 +262,7 @@ const COULD_NOT_CHECK = 'Try again in a moment.';
236
262
  cold,
237
263
  country,
238
264
  blocks,
265
+ hold,
239
266
  missingAttestations
240
267
  };
241
268
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gates.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 * WHO MAY BE EMAILED (AGL-2979).\n *\n * One pure decision, run twice: when a rep enrolls a person, so the dialog\n * can say everything that stands in the way at once, and again before every\n * send, because a person enrolled on Monday can unsubscribe, become a\n * customer or join the team by Thursday. It reads a contact as stored and\n * the RESULTS of the lookups the runtime makes; it makes none itself.\n *\n * ## Every block, not the first\n *\n * The result lists each gate that refuses, in the order below, with a\n * sentence a rep can act on. A dialog that revealed one problem per attempt\n * would be a dialog people learn to fight.\n *\n * ## Cold\n *\n * A contact is COLD when no inbound door ever brought them to the sending\n * site's business — no form, sign-up, order or booking recorded on that\n * site's facet of the contact, and no email they wrote to the workspace.\n * Cold contacts carry the heavier rules: a business address rather than a\n * personal mailbox, a United States address, a personal line from the rep,\n * and the three attestations only the rep can make. The facet is read, never\n * the whole document, because a form filled in on another client's site is\n * that client's relationship and not this one's.\n *\n * ## Lookups fail closed\n *\n * A lookup the runtime could not complete is passed as `null`, and `null`\n * refuses — with a sentence that says the check could not be made rather\n * than one that accuses the address of something. A person who asked not to\n * be emailed must not be emailed because a read timed out.\n *==========================================*/\n\nimport {\n type ContactSource,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { emailStateRefusal, readEmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n findMemberByEmailAddress,\n type MemberAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_PERSONAL_LINE_MAX,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollmentStatus,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport {\n type OutreachRecipientCountry,\n outreachCountryName,\n resolveOutreachRecipientCountry,\n} from './recipient-country'\nimport { readOutreachSequenceSettings } from './sequence-validation'\n\n/**\n * The capture doors that mean a person came to the business: a form, a\n * sign-up to the site or its newsletter, an order, a booking. `api`,\n * `import` and `manual` are how a business adds people it found, which is\n * exactly what cold means.\n */\nexport const OUTREACH_INBOUND_CONTACT_SOURCES: readonly ContactSource[] = [\n 'form',\n 'member',\n 'newsletter',\n 'order',\n 'booking',\n]\n\n/** The enrollment statuses that hold a person: one sequence at a time. */\nexport const OUTREACH_OPEN_ENROLLMENT_STATUSES: readonly OutreachEnrollmentStatus[] = [\n 'active',\n 'paused',\n]\n\n/**\n * What the runtime looked up about the address before asking. Every field is\n * `null` when its read failed, and a `null` refuses.\n */\nexport interface OutreachGateLookups {\n /**\n * `emailSuppressions/{key}` holds a record in force — a hard bounce or a\n * spam complaint anywhere on the platform (`isEmailSuppressed`).\n */\n platformSuppressed: boolean | null\n /** `hosts/{hostId}/suppressions/{key}` exists for the sequence's site. */\n hostSuppressed: boolean | null\n /**\n * The address's standing on the site's `sales` topic —\n * `readTopicSubscriptionState` of the `sales` entry in\n * `hosts/{hostId}/topicOptOuts/{key}`, folded across the site's consent\n * group. Only `opted-out` refuses: a pending confirmation — the site's own,\n * or a sibling's where the org said its group waits — is about a\n * subscription, and a sales email does not rest on one.\n */\n salesTopicState: TopicSubscriptionState | null\n /** The address is on the organization's Outreach do-not-contact list. */\n doNotContact: boolean | null\n /**\n * The address's DOMAIN is on that list (AGL-3244): a member put it there,\n * or the domain's mail gateway blocked the sender outright.\n */\n doNotContactDomain: boolean | null\n /**\n * The organization's roster: each member's sign-in address and confirmed\n * aliases, as `crmInboundRoster` builds it.\n */\n workspaceMembers: readonly MemberAddresses[] | null\n /**\n * The person's enrollments that are `active` or `paused`, found by contact\n * id or by address. Other statuses are ignored, so passing every\n * enrollment is safe too.\n */\n openEnrollments: ReadonlyArray<{\n id: string\n sequenceId: string\n status: OutreachEnrollmentStatus\n }> | null\n /**\n * An email the person wrote (`direction: 'inbound'`) is filed on the\n * contact. `null` reads as `false`, which only makes the contact cold and\n * the rules stricter.\n */\n hasInboundEmail: boolean | null\n}\n\nexport interface OutreachGateInput {\n /** The address the steps go to. */\n email: string\n /** The contact document as stored (`orgs/{orgId}/contacts/{id}`), or `null` when it is gone. */\n contact: Record<string, unknown> | null | undefined\n /** The consent group of the sequence's site: whose facet holds stage, sources and address. */\n contactGroupId: string\n /** The company document the contact is filed under, when there is one. */\n company?: Record<string, unknown> | null\n /** The sequence's settings as stored; a missing field reads as its default. */\n settings: Partial<OutreachSequenceSettings> | null | undefined\n /** The rep's signal sentence. */\n personalLine: string | null | undefined\n attestations: OutreachAttestations | null | undefined\n /** The enrollment being re-checked before a send, so it does not count as another. */\n enrollmentId?: string | null\n lookups: OutreachGateLookups\n}\n\nexport type OutreachGateCode =\n | 'contact_missing'\n | 'invalid_email'\n | 'email_not_on_contact'\n | 'free_mail'\n | 'country_not_allowed'\n | 'country_unknown'\n | 'platform_suppressed'\n | 'host_suppressed'\n | 'email_state'\n | 'sales_opted_out'\n | 'do_not_contact'\n | 'do_not_contact_domain'\n | 'workspace_member'\n | 'customer'\n | 'already_enrolled'\n | 'attestations_missing'\n | 'personal_line_missing'\n | 'personal_line_too_long'\n\nexport interface OutreachGateBlock {\n code: OutreachGateCode\n /** A sentence a rep can act on. */\n reason: string\n}\n\nexport interface OutreachGateResult {\n allowed: boolean\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n cold: boolean\n country: OutreachRecipientCountry\n /** Every gate that refuses, in gate order. */\n blocks: OutreachGateBlock[]\n /** The attestations a cold contact still needs, in the order they are asked. */\n missingAttestations: OutreachAttestationKind[]\n}\n\n/** Local parts that answer no person: nobody reads a reply sent there. */\nconst MACHINE_LOCAL_PART = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)(\\+.*)?$/\n\n/**\n * Whether a contact came to the sending site's business through no inbound\n * door — see the module note.\n */\nexport function isColdOutreachContact(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n hasInboundEmail: boolean,\n): boolean {\n if (hasInboundEmail) return false\n const { sources } = readContactFacet(contact, contactGroupId)\n return !OUTREACH_INBOUND_CONTACT_SOURCES.some((source) => sources?.[source] === true)\n}\n\n/**\n * Whether the sending site's business counts the contact as a customer: a\n * `customer` stage, the `evangelist` stage past it, or an order on record.\n */\nexport function isOutreachCustomer(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n): boolean {\n const facet = readContactFacet(contact, contactGroupId)\n return (\n facet.lifecycleStage === 'customer' ||\n facet.lifecycleStage === 'evangelist' ||\n facet.sources?.order === true ||\n Number(facet.ordersCount) > 0\n )\n}\n\n/** A confirmation counts only when it says who confirmed it and when. */\nexport function outreachAttestationHolds(\n attestations: OutreachAttestations | null | undefined,\n kind: OutreachAttestationKind,\n): boolean {\n const entry = attestations?.[kind]\n return (\n typeof entry?.uid === 'string' &&\n entry.uid.trim() !== '' &&\n typeof entry.atMs === 'number' &&\n Number.isFinite(entry.atMs) &&\n entry.atMs > 0\n )\n}\n\n/** The personal line as stored: one line, whitespace settled. */\nexport function normalizeOutreachPersonalLine(value: unknown): string {\n return typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n}\n\n/** Each attestation as the sentence that asks for it reads it. */\nconst ATTESTATION_PHRASES: Record<OutreachAttestationKind, string> = {\n us_business_address: \"it's a US business address\",\n published_or_given: 'they or their company published it, or they gave it to you',\n verified_deliverable: 'it was verified as deliverable',\n}\n\nfunction listInWords(items: readonly string[]): string {\n if (items.length <= 1) return items.join('')\n return `${items.slice(0, -1).join('; ')}; and ${items[items.length - 1]}`\n}\n\nconst COULD_NOT_CHECK = 'Try again in a moment.'\n\n/** Every gate an address and a contact must pass — see the module note. */\nexport function evaluateOutreachGates(input: OutreachGateInput): OutreachGateResult {\n const lookups = input.lookups\n const settings = readOutreachSequenceSettings(input.settings)\n const email = normalizeContactEmail(input.email)\n const contact = input.contact ?? null\n const cold = isColdOutreachContact(\n contact,\n input.contactGroupId,\n lookups?.hasInboundEmail === true,\n )\n const blocks: OutreachGateBlock[] = []\n const block = (code: OutreachGateCode, reason: string) => blocks.push({ code, reason })\n // The gates below need both a record and an address to read.\n const refusedOutright = (): OutreachGateResult => ({\n allowed: false,\n email,\n cold,\n country: { country: null, source: null },\n blocks,\n missingAttestations: [],\n })\n\n if (!contact) {\n block('contact_missing', 'This contact no longer exists in the CRM.')\n return refusedOutright()\n }\n\n // 1. A valid address, one that belongs to this contact and to a person.\n if (!email) {\n block('invalid_email', `\"${String(input.email ?? '').trim()}\" isn't a valid email address.`)\n return refusedOutright()\n }\n if (MACHINE_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')))) {\n block('invalid_email', `${email} is an automated address that no person reads.`)\n }\n if (!contactEmails(contact).includes(email)) {\n block('email_not_on_contact', `${email} isn't an address on this contact's record.`)\n }\n\n // 2. A business address for a cold contact.\n if (cold && isPublicMailboxDomain(email)) {\n block(\n 'free_mail',\n `${email} is a personal mailbox. Cold outreach goes only to business addresses.`,\n )\n }\n\n // 3. The country.\n const facet = readContactFacet(contact, input.contactGroupId)\n const companyAddress = input.company ? input.company['address'] : null\n const country = resolveOutreachRecipientCountry({\n email,\n contactAddress: facet.address,\n companyAddress,\n })\n const attestedUs = outreachAttestationHolds(input.attestations, 'us_business_address')\n if (country.country) {\n const name = outreachCountryName(country.country)\n const evidence =\n country.source === 'domain' ? `its domain places it in ${name}` : `it's in ${name}`\n if (!settings.allowedCountries.includes(country.country)) {\n block(\n 'country_not_allowed',\n `${email} can't be emailed from this sequence: ${evidence}, which isn't one of its countries.`,\n )\n } else if (cold && country.country !== 'US') {\n block(\n 'country_not_allowed',\n `Cold outreach goes only to US business addresses, and ${evidence}.`,\n )\n }\n } else if (!settings.allowedCountries.includes('US')) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Add the country to the contact's address.`,\n )\n } else if (!attestedUs) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Confirm it's a US business address, or add the country to the contact.`,\n )\n }\n\n // 4. Every list that says not to — the record's own verdict first\n // (AGL-3245), in the words the record page shows: what the lists below\n // hold, said by the reason it is held.\n const recordRefusal = emailStateRefusal(readEmailState(contact))\n if (recordRefusal) block('email_state', recordRefusal)\n if (lookups?.platformSuppressed === true) {\n block(\n 'platform_suppressed',\n `${email} is on the platform's suppression list after a hard bounce or a spam complaint.`,\n )\n } else if (lookups?.platformSuppressed !== false) {\n block(\n 'platform_suppressed',\n `We couldn't check the platform's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.hostSuppressed === true) {\n block('host_suppressed', `${email} unsubscribed from this site's email, or mail to it bounced.`)\n } else if (lookups?.hostSuppressed !== false) {\n block(\n 'host_suppressed',\n `We couldn't check this site's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.salesTopicState === 'opted-out') {\n block('sales_opted_out', `${email} opted out of sales outreach from this site.`)\n } else if (lookups?.salesTopicState !== 'subscribed' && lookups?.salesTopicState !== 'pending') {\n block(\n 'sales_opted_out',\n `We couldn't check whether ${email} opted out of sales outreach. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.doNotContact === true) {\n block('do_not_contact', `${email} is on your organization's do-not-contact list.`)\n } else if (lookups?.doNotContact !== false) {\n block(\n 'do_not_contact',\n `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n if (lookups?.doNotContactDomain === true) {\n block(\n 'do_not_contact_domain',\n `${email} is at ${domain}, which is on your organization's do-not-contact list: no address there is emailed.`,\n )\n } else if (lookups?.doNotContactDomain !== false) {\n block(\n 'do_not_contact_domain',\n `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`,\n )\n }\n\n // 5. Not one of the workspace's own people.\n const members = lookups?.workspaceMembers\n if (!Array.isArray(members)) {\n block(\n 'workspace_member',\n `We couldn't check whether ${email} belongs to a member of this workspace. ${COULD_NOT_CHECK}`,\n )\n } else if (findMemberByEmailAddress(members, email)) {\n block('workspace_member', `${email} belongs to a member of this workspace.`)\n }\n\n // 6. Not a customer, unless the sequence is written for them.\n if (!settings.allowCustomers && isOutreachCustomer(contact, input.contactGroupId)) {\n block('customer', \"This contact is a customer, and this sequence doesn't include customers.\")\n }\n\n // 7. One sequence at a time.\n const enrollments = lookups?.openEnrollments\n if (!Array.isArray(enrollments)) {\n block(\n 'already_enrolled',\n `We couldn't check this contact's other sequences. ${COULD_NOT_CHECK}`,\n )\n } else if (\n enrollments.some(\n (enrollment) =>\n enrollment?.id !== input.enrollmentId &&\n OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment?.status),\n )\n ) {\n block(\n 'already_enrolled',\n 'This contact is already in another sequence. Finish or stop that one first.',\n )\n }\n\n // 8. What only the rep can vouch for, on a cold contact.\n const personalLine = normalizeOutreachPersonalLine(input.personalLine)\n const missingAttestations = cold\n ? OUTREACH_ATTESTATION_KINDS.filter(\n (kind) => !outreachAttestationHolds(input.attestations, kind),\n )\n : []\n if (missingAttestations.length) {\n const phrases = missingAttestations.map((kind) => `that ${ATTESTATION_PHRASES[kind]}`)\n block(\n 'attestations_missing',\n `Before emailing a cold contact, confirm ${listInWords(phrases)}.`,\n )\n }\n if (cold && !personalLine) {\n block(\n 'personal_line_missing',\n 'Write a personal line: one sentence on why you are writing to this person now.',\n )\n }\n if (personalLine.length > OUTREACH_PERSONAL_LINE_MAX) {\n block(\n 'personal_line_too_long',\n `Keep the personal line to one sentence, under ${OUTREACH_PERSONAL_LINE_MAX} characters.`,\n )\n }\n\n return {\n allowed: blocks.length === 0,\n email,\n cold,\n country,\n blocks,\n missingAttestations,\n }\n}\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","emailStateRefusal","readEmailState","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","outreachCountryName","resolveOutreachRecipientCountry","readOutreachSequenceSettings","OUTREACH_INBOUND_CONTACT_SOURCES","OUTREACH_OPEN_ENROLLMENT_STATUSES","MACHINE_LOCAL_PART","isColdOutreachContact","contact","contactGroupId","hasInboundEmail","sources","some","source","isOutreachCustomer","facet","lifecycleStage","order","Number","ordersCount","outreachAttestationHolds","attestations","kind","entry","uid","trim","atMs","isFinite","normalizeOutreachPersonalLine","value","replace","ATTESTATION_PHRASES","us_business_address","published_or_given","verified_deliverable","listInWords","items","length","join","slice","COULD_NOT_CHECK","evaluateOutreachGates","input","lookups","settings","email","cold","blocks","block","code","reason","push","refusedOutright","allowed","country","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","recordRefusal","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","domain","doNotContactDomain","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gates.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 * WHO MAY BE EMAILED (AGL-2979).\n *\n * One pure decision, run twice: when a rep enrolls a person, so the dialog\n * can say everything that stands in the way at once, and again before every\n * send, because a person enrolled on Monday can unsubscribe, become a\n * customer or join the team by Thursday. It reads a contact as stored and\n * the RESULTS of the lookups the runtime makes; it makes none itself.\n *\n * ## Every block, not the first\n *\n * The result lists each gate that refuses, in the order below, with a\n * sentence a rep can act on. A dialog that revealed one problem per attempt\n * would be a dialog people learn to fight.\n *\n * ## Cold\n *\n * A contact is COLD when no inbound door ever brought them to the sending\n * site's business — no form, sign-up, order or booking recorded on that\n * site's facet of the contact, and no email they wrote to the workspace.\n * Cold contacts carry the heavier rules: a business address rather than a\n * personal mailbox, a United States address, a personal line from the rep,\n * and the three attestations only the rep can make. The facet is read, never\n * the whole document, because a form filled in on another client's site is\n * that client's relationship and not this one's.\n *\n * ## Lookups fail closed\n *\n * A lookup the runtime could not complete is passed as `null`, and `null`\n * refuses — with a sentence that says the check could not be made rather\n * than one that accuses the address of something. A person who asked not to\n * be emailed must not be emailed because a read timed out.\n *\n * ## A block and a hold\n *\n * Every gate above REFUSES: the person is not enrolled, or the enrollment\n * is stopped. The mail gateway (AGL-3326) adds one verdict of a different\n * weight. A domain with no MX cannot receive mail and blocks like the rest;\n * a gateway that refused this organization's sender twice in the last\n * thirty days without delivering once HOLDS the send — the enrollment is\n * allowed, and pauses at the send with `gateway_blocked_here` until a\n * member resumes it — because the founder decides, and the decision is\n * shown to them in Check people before they tick the person.\n *==========================================*/\n\nimport {\n type ContactSource,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { emailStateRefusal, readEmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n findMemberByEmailAddress,\n type MemberAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_PERSONAL_LINE_MAX,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollmentStatus,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport {\n type OutreachGatewayStanding,\n outreachGatewayHoldReason,\n outreachGatewayHolds,\n} from './mail-gateway'\nimport {\n type OutreachRecipientCountry,\n outreachCountryName,\n resolveOutreachRecipientCountry,\n} from './recipient-country'\nimport { readOutreachSequenceSettings } from './sequence-validation'\n\n/**\n * The capture doors that mean a person came to the business: a form, a\n * sign-up to the site or its newsletter, an order, a booking. `api`,\n * `import` and `manual` are how a business adds people it found, which is\n * exactly what cold means.\n */\nexport const OUTREACH_INBOUND_CONTACT_SOURCES: readonly ContactSource[] = [\n 'form',\n 'member',\n 'newsletter',\n 'order',\n 'booking',\n]\n\n/** The enrollment statuses that hold a person: one sequence at a time. */\nexport const OUTREACH_OPEN_ENROLLMENT_STATUSES: readonly OutreachEnrollmentStatus[] = [\n 'active',\n 'paused',\n]\n\n/**\n * What the runtime looked up about the address before asking. Every field is\n * `null` when its read failed, and a `null` refuses.\n */\nexport interface OutreachGateLookups {\n /**\n * `emailSuppressions/{key}` holds a record in force — a hard bounce or a\n * spam complaint anywhere on the platform (`isEmailSuppressed`).\n */\n platformSuppressed: boolean | null\n /** `hosts/{hostId}/suppressions/{key}` exists for the sequence's site. */\n hostSuppressed: boolean | null\n /**\n * The address's standing on the site's `sales` topic —\n * `readTopicSubscriptionState` of the `sales` entry in\n * `hosts/{hostId}/topicOptOuts/{key}`, folded across the site's consent\n * group. Only `opted-out` refuses: a pending confirmation — the site's own,\n * or a sibling's where the org said its group waits — is about a\n * subscription, and a sales email does not rest on one.\n */\n salesTopicState: TopicSubscriptionState | null\n /** The address is on the organization's Outreach do-not-contact list. */\n doNotContact: boolean | null\n /**\n * The address's DOMAIN is on that list (AGL-3244): a member put it there,\n * or the domain's mail gateway blocked the sender outright.\n */\n doNotContactDomain: boolean | null\n /**\n * The organization's roster: each member's sign-in address and confirmed\n * aliases, as `crmInboundRoster` builds it.\n */\n workspaceMembers: readonly MemberAddresses[] | null\n /**\n * The person's enrollments that are `active` or `paused`, found by contact\n * id or by address. Other statuses are ignored, so passing every\n * enrollment is safe too.\n */\n openEnrollments: ReadonlyArray<{\n id: string\n sequenceId: string\n status: OutreachEnrollmentStatus\n }> | null\n /**\n * An email the person wrote (`direction: 'inbound'`) is filed on the\n * contact. `null` reads as `false`, which only makes the contact cold and\n * the rules stricter.\n */\n hasInboundEmail: boolean | null\n /**\n * The mail gateway in front of the address's domain (AGL-3326), as its MX\n * names it — `outreachDomainIntel/{domain}` — and what that gateway did\n * with this organization's mail lately, from `outreachGatewayStats`.\n * `none` blocks; a gateway that holds is answered in `hold`.\n */\n gateway: OutreachGatewayStanding | null\n}\n\nexport interface OutreachGateInput {\n /** The address the steps go to. */\n email: string\n /** The contact document as stored (`orgs/{orgId}/contacts/{id}`), or `null` when it is gone. */\n contact: Record<string, unknown> | null | undefined\n /** The consent group of the sequence's site: whose facet holds stage, sources and address. */\n contactGroupId: string\n /** The company document the contact is filed under, when there is one. */\n company?: Record<string, unknown> | null\n /** The sequence's settings as stored; a missing field reads as its default. */\n settings: Partial<OutreachSequenceSettings> | null | undefined\n /** The rep's signal sentence. */\n personalLine: string | null | undefined\n attestations: OutreachAttestations | null | undefined\n /** The enrollment being re-checked before a send, so it does not count as another. */\n enrollmentId?: string | null\n /**\n * A member already released the gateway hold on this enrollment\n * (AGL-3326) — resumed it, or ticked the person past the red chip — so\n * the gateway's history does not hold it again.\n */\n gatewayHoldReleased?: boolean\n lookups: OutreachGateLookups\n}\n\nexport type OutreachGateCode =\n | 'contact_missing'\n | 'invalid_email'\n | 'email_not_on_contact'\n | 'free_mail'\n | 'country_not_allowed'\n | 'country_unknown'\n | 'platform_suppressed'\n | 'host_suppressed'\n | 'email_state'\n | 'sales_opted_out'\n | 'do_not_contact'\n | 'do_not_contact_domain'\n | 'no_mx'\n | 'workspace_member'\n | 'customer'\n | 'already_enrolled'\n | 'attestations_missing'\n | 'personal_line_missing'\n | 'personal_line_too_long'\n\nexport interface OutreachGateBlock {\n code: OutreachGateCode\n /** A sentence a rep can act on. */\n reason: string\n}\n\n/** The one verdict that holds a send rather than refusing it — see the module note. */\nexport interface OutreachGateHold {\n code: 'gateway_blocked_here'\n gateway: OutreachGatewayStanding['gateway']\n /** A sentence the enrollment row shows. */\n reason: string\n}\n\nexport interface OutreachGateResult {\n allowed: boolean\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n cold: boolean\n country: OutreachRecipientCountry\n /** Every gate that refuses, in gate order. */\n blocks: OutreachGateBlock[]\n /**\n * The gateway hold (AGL-3326), when the address's gateway refused this\n * organization twice lately and nobody released it; `null` otherwise. An\n * allowed result can carry one: the send waits for a member.\n */\n hold: OutreachGateHold | null\n /** The attestations a cold contact still needs, in the order they are asked. */\n missingAttestations: OutreachAttestationKind[]\n}\n\n/** Local parts that answer no person: nobody reads a reply sent there. */\nconst MACHINE_LOCAL_PART = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)(\\+.*)?$/\n\n/**\n * Whether a contact came to the sending site's business through no inbound\n * door — see the module note.\n */\nexport function isColdOutreachContact(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n hasInboundEmail: boolean,\n): boolean {\n if (hasInboundEmail) return false\n const { sources } = readContactFacet(contact, contactGroupId)\n return !OUTREACH_INBOUND_CONTACT_SOURCES.some((source) => sources?.[source] === true)\n}\n\n/**\n * Whether the sending site's business counts the contact as a customer: a\n * `customer` stage, the `evangelist` stage past it, or an order on record.\n */\nexport function isOutreachCustomer(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n): boolean {\n const facet = readContactFacet(contact, contactGroupId)\n return (\n facet.lifecycleStage === 'customer' ||\n facet.lifecycleStage === 'evangelist' ||\n facet.sources?.order === true ||\n Number(facet.ordersCount) > 0\n )\n}\n\n/** A confirmation counts only when it says who confirmed it and when. */\nexport function outreachAttestationHolds(\n attestations: OutreachAttestations | null | undefined,\n kind: OutreachAttestationKind,\n): boolean {\n const entry = attestations?.[kind]\n return (\n typeof entry?.uid === 'string' &&\n entry.uid.trim() !== '' &&\n typeof entry.atMs === 'number' &&\n Number.isFinite(entry.atMs) &&\n entry.atMs > 0\n )\n}\n\n/** The personal line as stored: one line, whitespace settled. */\nexport function normalizeOutreachPersonalLine(value: unknown): string {\n return typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n}\n\n/** Each attestation as the sentence that asks for it reads it. */\nconst ATTESTATION_PHRASES: Record<OutreachAttestationKind, string> = {\n us_business_address: \"it's a US business address\",\n published_or_given: 'they or their company published it, or they gave it to you',\n verified_deliverable: 'it was verified as deliverable',\n}\n\nfunction listInWords(items: readonly string[]): string {\n if (items.length <= 1) return items.join('')\n return `${items.slice(0, -1).join('; ')}; and ${items[items.length - 1]}`\n}\n\nconst COULD_NOT_CHECK = 'Try again in a moment.'\n\n/** Every gate an address and a contact must pass — see the module note. */\nexport function evaluateOutreachGates(input: OutreachGateInput): OutreachGateResult {\n const lookups = input.lookups\n const settings = readOutreachSequenceSettings(input.settings)\n const email = normalizeContactEmail(input.email)\n const contact = input.contact ?? null\n const cold = isColdOutreachContact(\n contact,\n input.contactGroupId,\n lookups?.hasInboundEmail === true,\n )\n const blocks: OutreachGateBlock[] = []\n const block = (code: OutreachGateCode, reason: string) => blocks.push({ code, reason })\n // The gates below need both a record and an address to read.\n const refusedOutright = (): OutreachGateResult => ({\n allowed: false,\n email,\n cold,\n country: { country: null, source: null },\n blocks,\n hold: null,\n missingAttestations: [],\n })\n\n if (!contact) {\n block('contact_missing', 'This contact no longer exists in the CRM.')\n return refusedOutright()\n }\n\n // 1. A valid address, one that belongs to this contact and to a person.\n if (!email) {\n block('invalid_email', `\"${String(input.email ?? '').trim()}\" isn't a valid email address.`)\n return refusedOutright()\n }\n if (MACHINE_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')))) {\n block('invalid_email', `${email} is an automated address that no person reads.`)\n }\n if (!contactEmails(contact).includes(email)) {\n block('email_not_on_contact', `${email} isn't an address on this contact's record.`)\n }\n\n // 2. A business address for a cold contact.\n if (cold && isPublicMailboxDomain(email)) {\n block(\n 'free_mail',\n `${email} is a personal mailbox. Cold outreach goes only to business addresses.`,\n )\n }\n\n // 3. The country.\n const facet = readContactFacet(contact, input.contactGroupId)\n const companyAddress = input.company ? input.company['address'] : null\n const country = resolveOutreachRecipientCountry({\n email,\n contactAddress: facet.address,\n companyAddress,\n })\n const attestedUs = outreachAttestationHolds(input.attestations, 'us_business_address')\n if (country.country) {\n const name = outreachCountryName(country.country)\n const evidence =\n country.source === 'domain' ? `its domain places it in ${name}` : `it's in ${name}`\n if (!settings.allowedCountries.includes(country.country)) {\n block(\n 'country_not_allowed',\n `${email} can't be emailed from this sequence: ${evidence}, which isn't one of its countries.`,\n )\n } else if (cold && country.country !== 'US') {\n block(\n 'country_not_allowed',\n `Cold outreach goes only to US business addresses, and ${evidence}.`,\n )\n }\n } else if (!settings.allowedCountries.includes('US')) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Add the country to the contact's address.`,\n )\n } else if (!attestedUs) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Confirm it's a US business address, or add the country to the contact.`,\n )\n }\n\n // 4. Every list that says not to — the record's own verdict first\n // (AGL-3245), in the words the record page shows: what the lists below\n // hold, said by the reason it is held.\n const recordRefusal = emailStateRefusal(readEmailState(contact))\n if (recordRefusal) block('email_state', recordRefusal)\n if (lookups?.platformSuppressed === true) {\n block(\n 'platform_suppressed',\n `${email} is on the platform's suppression list after a hard bounce or a spam complaint.`,\n )\n } else if (lookups?.platformSuppressed !== false) {\n block(\n 'platform_suppressed',\n `We couldn't check the platform's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.hostSuppressed === true) {\n block('host_suppressed', `${email} unsubscribed from this site's email, or mail to it bounced.`)\n } else if (lookups?.hostSuppressed !== false) {\n block(\n 'host_suppressed',\n `We couldn't check this site's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.salesTopicState === 'opted-out') {\n block('sales_opted_out', `${email} opted out of sales outreach from this site.`)\n } else if (lookups?.salesTopicState !== 'subscribed' && lookups?.salesTopicState !== 'pending') {\n block(\n 'sales_opted_out',\n `We couldn't check whether ${email} opted out of sales outreach. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.doNotContact === true) {\n block('do_not_contact', `${email} is on your organization's do-not-contact list.`)\n } else if (lookups?.doNotContact !== false) {\n block(\n 'do_not_contact',\n `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n if (lookups?.doNotContactDomain === true) {\n block(\n 'do_not_contact_domain',\n `${email} is at ${domain}, which is on your organization's do-not-contact list: no address there is emailed.`,\n )\n } else if (lookups?.doNotContactDomain !== false) {\n block(\n 'do_not_contact_domain',\n `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`,\n )\n }\n\n // 4b. The mail gateway in front of the domain (AGL-3326): no MX means no\n // mail; a gateway that refused us twice lately holds, below.\n const gateway = lookups?.gateway ?? null\n if (!gateway) {\n block('no_mx', `We couldn't look up the mail server for ${domain}. ${COULD_NOT_CHECK}`)\n } else if (gateway.gateway === 'none') {\n block('no_mx', `${domain} has no MX record, so ${email} cannot receive mail.`)\n }\n const hold: OutreachGateHold | null =\n gateway && input.gatewayHoldReleased !== true && outreachGatewayHolds(gateway)\n ? {\n code: 'gateway_blocked_here',\n gateway: gateway.gateway,\n reason: outreachGatewayHoldReason(gateway.gateway, gateway.blocked30),\n }\n : null\n\n // 5. Not one of the workspace's own people.\n const members = lookups?.workspaceMembers\n if (!Array.isArray(members)) {\n block(\n 'workspace_member',\n `We couldn't check whether ${email} belongs to a member of this workspace. ${COULD_NOT_CHECK}`,\n )\n } else if (findMemberByEmailAddress(members, email)) {\n block('workspace_member', `${email} belongs to a member of this workspace.`)\n }\n\n // 6. Not a customer, unless the sequence is written for them.\n if (!settings.allowCustomers && isOutreachCustomer(contact, input.contactGroupId)) {\n block('customer', \"This contact is a customer, and this sequence doesn't include customers.\")\n }\n\n // 7. One sequence at a time.\n const enrollments = lookups?.openEnrollments\n if (!Array.isArray(enrollments)) {\n block(\n 'already_enrolled',\n `We couldn't check this contact's other sequences. ${COULD_NOT_CHECK}`,\n )\n } else if (\n enrollments.some(\n (enrollment) =>\n enrollment?.id !== input.enrollmentId &&\n OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment?.status),\n )\n ) {\n block(\n 'already_enrolled',\n 'This contact is already in another sequence. Finish or stop that one first.',\n )\n }\n\n // 8. What only the rep can vouch for, on a cold contact.\n const personalLine = normalizeOutreachPersonalLine(input.personalLine)\n const missingAttestations = cold\n ? OUTREACH_ATTESTATION_KINDS.filter(\n (kind) => !outreachAttestationHolds(input.attestations, kind),\n )\n : []\n if (missingAttestations.length) {\n const phrases = missingAttestations.map((kind) => `that ${ATTESTATION_PHRASES[kind]}`)\n block(\n 'attestations_missing',\n `Before emailing a cold contact, confirm ${listInWords(phrases)}.`,\n )\n }\n if (cold && !personalLine) {\n block(\n 'personal_line_missing',\n 'Write a personal line: one sentence on why you are writing to this person now.',\n )\n }\n if (personalLine.length > OUTREACH_PERSONAL_LINE_MAX) {\n block(\n 'personal_line_too_long',\n `Keep the personal line to one sentence, under ${OUTREACH_PERSONAL_LINE_MAX} characters.`,\n )\n }\n\n return {\n allowed: blocks.length === 0,\n email,\n cold,\n country,\n blocks,\n hold,\n missingAttestations,\n }\n}\n\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","emailStateRefusal","readEmailState","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","outreachGatewayHoldReason","outreachGatewayHolds","outreachCountryName","resolveOutreachRecipientCountry","readOutreachSequenceSettings","OUTREACH_INBOUND_CONTACT_SOURCES","OUTREACH_OPEN_ENROLLMENT_STATUSES","MACHINE_LOCAL_PART","isColdOutreachContact","contact","contactGroupId","hasInboundEmail","sources","some","source","isOutreachCustomer","facet","lifecycleStage","order","Number","ordersCount","outreachAttestationHolds","attestations","kind","entry","uid","trim","atMs","isFinite","normalizeOutreachPersonalLine","value","replace","ATTESTATION_PHRASES","us_business_address","published_or_given","verified_deliverable","listInWords","items","length","join","slice","COULD_NOT_CHECK","evaluateOutreachGates","input","lookups","settings","email","cold","blocks","block","code","reason","push","refusedOutright","allowed","country","hold","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","recordRefusal","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","domain","doNotContactDomain","gateway","gatewayHoldReleased","blocked30","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}
@@ -0,0 +1,97 @@
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
+ export type OutreachMailGateway = 'barracuda' | 'proofpoint' | 'mimecast' | 'google' | 'microsoft' | 'other' | 'none';
18
+ export declare const OUTREACH_MAIL_GATEWAYS: readonly OutreachMailGateway[];
19
+ /** The gateway as the Check-people chip and the enrollment row name it. */
20
+ export declare const OUTREACH_MAIL_GATEWAY_LABELS: Record<OutreachMailGateway, string>;
21
+ /**
22
+ * The security gateways that stand in front of a whole company's mail and
23
+ * refuse a sender for everyone behind them: the ones the enroll dialog
24
+ * counts as "gateway-fronted".
25
+ */
26
+ export declare const OUTREACH_GATEWAY_FRONTED: readonly OutreachMailGateway[];
27
+ /** Blocks in the window, with no delivery beside them, that hold the next send. */
28
+ export declare const OUTREACH_GATEWAY_HOLD_BLOCKS = 2;
29
+ /** The days the hold is judged over. */
30
+ export declare const OUTREACH_GATEWAY_WINDOW_DAYS = 30;
31
+ /** The days the Check-people chip counts. */
32
+ export declare const OUTREACH_GATEWAY_CHIP_DAYS = 7;
33
+ /** How long a domain's MX answer is trusted before it is looked up again. */
34
+ export declare const OUTREACH_DOMAIN_INTEL_TTL_MS: number;
35
+ /** A send with no bounce this long after it counts as delivered. */
36
+ export declare const OUTREACH_GATEWAY_DELIVERED_AFTER_MS: number;
37
+ /**
38
+ * The gateway ONE host names, or `null` when it names none of the known
39
+ * ones — a Remote-MTA in a bounce, or one MX exchange.
40
+ */
41
+ export declare function outreachMailGatewayOfHost(host: unknown): OutreachMailGateway | null;
42
+ /**
43
+ * The gateway a domain's MX records name — see the module note. A domain
44
+ * whose exchanges name more than one is read as the first named one in MX
45
+ * order (the list is handed over lowest-priority-first); one that names
46
+ * none is `other`; an empty list is `none`.
47
+ */
48
+ export declare function classifyOutreachMailGateway(mxHosts: readonly string[]): OutreachMailGateway;
49
+ /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */
50
+ export declare function isOutreachGatewayFronted(gateway: OutreachMailGateway | null | undefined): boolean;
51
+ /** The counts one UTC day holds on a gateway's ledger. */
52
+ export interface OutreachGatewayDayCounts {
53
+ sent: number;
54
+ delivered: number;
55
+ blocked: number;
56
+ }
57
+ /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */
58
+ export declare function outreachGatewayDay(atMs: number): string;
59
+ /** The ledger's counts summed over the last `days` UTC days, today included. */
60
+ export declare function outreachGatewayWindow(days: Record<string, Partial<OutreachGatewayDayCounts>> | null | undefined, nowMs: number, windowDays: number): OutreachGatewayDayCounts;
61
+ /** The ledger's days with everything older than `keepDays` dropped. */
62
+ export declare function pruneOutreachGatewayDays<T>(days: Record<string, T> | null | undefined, nowMs: number, keepDays: number): Record<string, T>;
63
+ /**
64
+ * What the gates and the enroll dialog know about the gateway in front of
65
+ * one address: which it is, and what it did with this organization's mail
66
+ * over the chip's week and the hold's month.
67
+ */
68
+ export interface OutreachGatewayStanding {
69
+ gateway: OutreachMailGateway;
70
+ /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_CHIP_DAYS} days. */
71
+ blocked7: number;
72
+ delivered7: number;
73
+ /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days. */
74
+ blocked30: number;
75
+ delivered30: number;
76
+ }
77
+ /**
78
+ * Whether the gateway holds the next send — see the module note. A named
79
+ * gateway only: `other` is many exchanges, and `none` blocks instead.
80
+ */
81
+ export declare function outreachGatewayHolds(standing: Pick<OutreachGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean;
82
+ /** The sentence the hold is written with, on the enrollment and in the engine's reason. */
83
+ export declare function outreachGatewayHoldReason(gateway: OutreachMailGateway, blocked30: number): string;
84
+ /** The chip's tone: what color the Check-people step gives it. */
85
+ export type OutreachGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked';
86
+ export interface OutreachGatewayChip {
87
+ label: string;
88
+ tone: OutreachGatewayChipTone;
89
+ }
90
+ /**
91
+ * The Check-people chip for one person, or `null` when there is nothing
92
+ * worth a chip: an unrecognized exchange with no verdict this week. Red for
93
+ * a gateway that refused this week, green for one that delivered, neutral
94
+ * for the hosted providers and the security gateways with no verdict yet,
95
+ * and blocked for a domain with no MX.
96
+ */
97
+ export declare function outreachGatewayChip(standing: OutreachGatewayStanding | null | undefined): OutreachGatewayChip | null;
@@ -0,0 +1,226 @@
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
+ * THE MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY PREDICTS
18
+ * THE BLOCK (AGL-3326).
19
+ *
20
+ * Two Barracuda gateways refused the same sender three days apart, and each
21
+ * refusal was read only after the email had left: a hard bounce, a paused
22
+ * mailbox, ten first emails still queued. Both domains had Barracuda ESS as
23
+ * their MX, and a third enrolled domain with the same MX was stopped by hand
24
+ * before it sent. So the signal is cheap and it is available BEFORE a send:
25
+ * the domain's MX names the gateway in front of it, and what that gateway
26
+ * has done with this organization's mail lately says what it will do next.
27
+ *
28
+ * ## The kinds
29
+ *
30
+ * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`
31
+ * and `mimecast` are the security gateways that refuse a SENDER on policy
32
+ * and reputation; `google` and `microsoft` are the two hosted providers,
33
+ * which judge mail one message at a time; `other` is every self-hosted or
34
+ * unrecognized exchange; `none` is a domain with no MX at all, which cannot
35
+ * receive mail and blocks the enrollment outright.
36
+ *
37
+ * ## The ledger and the hold
38
+ *
39
+ * Every send, every delivery and every block is counted per gateway per
40
+ * organization, by UTC day, and a gateway that has REFUSED this
41
+ * organization twice in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days
42
+ * without delivering once holds the next send into it: nothing goes into a
43
+ * gateway that has refused us twice unless a member says so. `other` never
44
+ * holds, because it is a bucket of unrelated exchanges rather than one
45
+ * gateway; `none` blocks instead.
46
+ *==========================================*/ export const OUTREACH_MAIL_GATEWAYS = [
47
+ 'barracuda',
48
+ 'proofpoint',
49
+ 'mimecast',
50
+ 'google',
51
+ 'microsoft',
52
+ 'other',
53
+ 'none'
54
+ ];
55
+ /** The gateway as the Check-people chip and the enrollment row name it. */ export const OUTREACH_MAIL_GATEWAY_LABELS = {
56
+ barracuda: 'Barracuda',
57
+ proofpoint: 'Proofpoint',
58
+ mimecast: 'Mimecast',
59
+ google: 'Google Workspace',
60
+ microsoft: 'Microsoft 365',
61
+ other: 'Other mail gateway',
62
+ none: 'No MX record'
63
+ };
64
+ /**
65
+ * The security gateways that stand in front of a whole company's mail and
66
+ * refuse a sender for everyone behind them: the ones the enroll dialog
67
+ * counts as "gateway-fronted".
68
+ */ export const OUTREACH_GATEWAY_FRONTED = [
69
+ 'barracuda',
70
+ 'proofpoint',
71
+ 'mimecast'
72
+ ];
73
+ /** Blocks in the window, with no delivery beside them, that hold the next send. */ export const OUTREACH_GATEWAY_HOLD_BLOCKS = 2;
74
+ /** The days the hold is judged over. */ export const OUTREACH_GATEWAY_WINDOW_DAYS = 30;
75
+ /** The days the Check-people chip counts. */ export const OUTREACH_GATEWAY_CHIP_DAYS = 7;
76
+ /** How long a domain's MX answer is trusted before it is looked up again. */ export const OUTREACH_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
77
+ /** A send with no bounce this long after it counts as delivered. */ export const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000;
78
+ /** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */ const GATEWAY_HOST_SUFFIXES = [
79
+ [
80
+ 'barracuda',
81
+ [
82
+ '.ess.barracudanetworks.com',
83
+ '.barracudanetworks.com'
84
+ ]
85
+ ],
86
+ [
87
+ 'proofpoint',
88
+ [
89
+ '.pphosted.com',
90
+ '.ppe-hosted.com',
91
+ '.proofpoint.com'
92
+ ]
93
+ ],
94
+ [
95
+ 'mimecast',
96
+ [
97
+ '.mimecast.com',
98
+ '.mimecast.co.za',
99
+ '.mimecast-offshore.com'
100
+ ]
101
+ ],
102
+ [
103
+ 'google',
104
+ [
105
+ '.google.com',
106
+ '.googlemail.com',
107
+ '.gmail.com'
108
+ ]
109
+ ],
110
+ [
111
+ 'microsoft',
112
+ [
113
+ '.mail.protection.outlook.com',
114
+ '.outlook.com',
115
+ '.protection.outlook.com'
116
+ ]
117
+ ]
118
+ ];
119
+ /** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */ function normalizeHost(host) {
120
+ return String(host != null ? host : '').trim().toLowerCase().replace(/\.$/, '');
121
+ }
122
+ /**
123
+ * The gateway ONE host names, or `null` when it names none of the known
124
+ * ones — a Remote-MTA in a bounce, or one MX exchange.
125
+ */ export function outreachMailGatewayOfHost(host) {
126
+ const name = normalizeHost(host);
127
+ if (!name) return null;
128
+ for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES){
129
+ if (suffixes.some((suffix)=>name === suffix.slice(1) || name.endsWith(suffix))) return gateway;
130
+ }
131
+ return null;
132
+ }
133
+ /**
134
+ * The gateway a domain's MX records name — see the module note. A domain
135
+ * whose exchanges name more than one is read as the first named one in MX
136
+ * order (the list is handed over lowest-priority-first); one that names
137
+ * none is `other`; an empty list is `none`.
138
+ */ export function classifyOutreachMailGateway(mxHosts) {
139
+ const hosts = mxHosts.map(normalizeHost).filter(Boolean);
140
+ // A lone "." (or an empty exchange) is RFC 7505's null MX: the domain
141
+ // says outright that it takes no mail.
142
+ if (!hosts.length) return 'none';
143
+ for (const host of hosts){
144
+ const gateway = outreachMailGatewayOfHost(host);
145
+ if (gateway) return gateway;
146
+ }
147
+ return 'other';
148
+ }
149
+ /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */ export function isOutreachGatewayFronted(gateway) {
150
+ return gateway !== null && gateway !== undefined && OUTREACH_GATEWAY_FRONTED.includes(gateway);
151
+ }
152
+ /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */ export function outreachGatewayDay(atMs) {
153
+ return new Date(atMs).toISOString().slice(0, 10);
154
+ }
155
+ const count = (value)=>{
156
+ const number = Number(value);
157
+ return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0;
158
+ };
159
+ /** The ledger's counts summed over the last `days` UTC days, today included. */ export function outreachGatewayWindow(days, nowMs, windowDays) {
160
+ const summed = {
161
+ sent: 0,
162
+ delivered: 0,
163
+ blocked: 0
164
+ };
165
+ if (!days) return summed;
166
+ const oldest = outreachGatewayDay(nowMs - (windowDays - 1) * 86400000);
167
+ const newest = outreachGatewayDay(nowMs);
168
+ for (const [day, counts] of Object.entries(days)){
169
+ if (day < oldest || day > newest) continue;
170
+ summed.sent += count(counts == null ? void 0 : counts.sent);
171
+ summed.delivered += count(counts == null ? void 0 : counts.delivered);
172
+ summed.blocked += count(counts == null ? void 0 : counts.blocked);
173
+ }
174
+ return summed;
175
+ }
176
+ /** The ledger's days with everything older than `keepDays` dropped. */ export function pruneOutreachGatewayDays(days, nowMs, keepDays) {
177
+ const oldest = outreachGatewayDay(nowMs - (keepDays - 1) * 86400000);
178
+ return Object.fromEntries(Object.entries(days != null ? days : {}).filter(([day])=>day >= oldest));
179
+ }
180
+ /**
181
+ * Whether the gateway holds the next send — see the module note. A named
182
+ * gateway only: `other` is many exchanges, and `none` blocks instead.
183
+ */ export function outreachGatewayHolds(standing) {
184
+ if (standing.gateway === 'other' || standing.gateway === 'none') return false;
185
+ return count(standing.blocked30) >= OUTREACH_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0;
186
+ }
187
+ /** The sentence the hold is written with, on the enrollment and in the engine's reason. */ export function outreachGatewayHoldReason(gateway, blocked30) {
188
+ const name = OUTREACH_MAIL_GATEWAY_LABELS[gateway];
189
+ return `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` + `${OUTREACH_GATEWAY_WINDOW_DAYS} days and delivered nothing, so this email is held. ` + 'Resume the enrollment to send it anyway.';
190
+ }
191
+ /**
192
+ * The Check-people chip for one person, or `null` when there is nothing
193
+ * worth a chip: an unrecognized exchange with no verdict this week. Red for
194
+ * a gateway that refused this week, green for one that delivered, neutral
195
+ * for the hosted providers and the security gateways with no verdict yet,
196
+ * and blocked for a domain with no MX.
197
+ */ export function outreachGatewayChip(standing) {
198
+ if (!standing) return null;
199
+ const name = OUTREACH_MAIL_GATEWAY_LABELS[standing.gateway];
200
+ if (standing.gateway === 'none') return {
201
+ label: 'No MX record — cannot receive mail',
202
+ tone: 'blocked'
203
+ };
204
+ const blocked = count(standing.blocked7);
205
+ const delivered = count(standing.delivered7);
206
+ const settled = blocked + delivered;
207
+ if (blocked > 0) {
208
+ return {
209
+ label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,
210
+ tone: 'refused'
211
+ };
212
+ }
213
+ if (delivered > 0) {
214
+ return {
215
+ label: `${name} · ${delivered} of ${settled} delivered this week`,
216
+ tone: 'delivered'
217
+ };
218
+ }
219
+ if (standing.gateway === 'other') return null;
220
+ return {
221
+ label: name,
222
+ tone: 'neutral'
223
+ };
224
+ }
225
+
226
+ //# sourceMappingURL=mail-gateway.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mail-gateway.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 MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY PREDICTS\n * THE BLOCK (AGL-3326).\n *\n * Two Barracuda gateways refused the same sender three days apart, and each\n * refusal was read only after the email had left: a hard bounce, a paused\n * mailbox, ten first emails still queued. Both domains had Barracuda ESS as\n * their MX, and a third enrolled domain with the same MX was stopped by hand\n * before it sent. So the signal is cheap and it is available BEFORE a send:\n * the domain's MX names the gateway in front of it, and what that gateway\n * has done with this organization's mail lately says what it will do next.\n *\n * ## The kinds\n *\n * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`\n * and `mimecast` are the security gateways that refuse a SENDER on policy\n * and reputation; `google` and `microsoft` are the two hosted providers,\n * which judge mail one message at a time; `other` is every self-hosted or\n * unrecognized exchange; `none` is a domain with no MX at all, which cannot\n * receive mail and blocks the enrollment outright.\n *\n * ## The ledger and the hold\n *\n * Every send, every delivery and every block is counted per gateway per\n * organization, by UTC day, and a gateway that has REFUSED this\n * organization twice in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days\n * without delivering once holds the next send into it: nothing goes into a\n * gateway that has refused us twice unless a member says so. `other` never\n * holds, because it is a bucket of unrelated exchanges rather than one\n * gateway; `none` blocks instead.\n *==========================================*/\n\nexport type OutreachMailGateway =\n | 'barracuda'\n | 'proofpoint'\n | 'mimecast'\n | 'google'\n | 'microsoft'\n | 'other'\n | 'none'\n\nexport const OUTREACH_MAIL_GATEWAYS: readonly OutreachMailGateway[] = [\n 'barracuda',\n 'proofpoint',\n 'mimecast',\n 'google',\n 'microsoft',\n 'other',\n 'none',\n]\n\n/** The gateway as the Check-people chip and the enrollment row name it. */\nexport const OUTREACH_MAIL_GATEWAY_LABELS: Record<OutreachMailGateway, string> = {\n barracuda: 'Barracuda',\n proofpoint: 'Proofpoint',\n mimecast: 'Mimecast',\n google: 'Google Workspace',\n microsoft: 'Microsoft 365',\n other: 'Other mail gateway',\n none: 'No MX record',\n}\n\n/**\n * The security gateways that stand in front of a whole company's mail and\n * refuse a sender for everyone behind them: the ones the enroll dialog\n * counts as \"gateway-fronted\".\n */\nexport const OUTREACH_GATEWAY_FRONTED: readonly OutreachMailGateway[] = ['barracuda', 'proofpoint', 'mimecast']\n\n/** Blocks in the window, with no delivery beside them, that hold the next send. */\nexport const OUTREACH_GATEWAY_HOLD_BLOCKS = 2\n\n/** The days the hold is judged over. */\nexport const OUTREACH_GATEWAY_WINDOW_DAYS = 30\n\n/** The days the Check-people chip counts. */\nexport const OUTREACH_GATEWAY_CHIP_DAYS = 7\n\n/** How long a domain's MX answer is trusted before it is looked up again. */\nexport const OUTREACH_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000\n\n/** A send with no bounce this long after it counts as delivered. */\nexport const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000\n\n/** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */\nconst GATEWAY_HOST_SUFFIXES: ReadonlyArray<readonly [OutreachMailGateway, readonly string[]]> = [\n ['barracuda', ['.ess.barracudanetworks.com', '.barracudanetworks.com']],\n ['proofpoint', ['.pphosted.com', '.ppe-hosted.com', '.proofpoint.com']],\n ['mimecast', ['.mimecast.com', '.mimecast.co.za', '.mimecast-offshore.com']],\n ['google', ['.google.com', '.googlemail.com', '.gmail.com']],\n ['microsoft', ['.mail.protection.outlook.com', '.outlook.com', '.protection.outlook.com']],\n]\n\n/** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */\nfunction normalizeHost(host: unknown): string {\n return String(host ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, '')\n}\n\n/**\n * The gateway ONE host names, or `null` when it names none of the known\n * ones — a Remote-MTA in a bounce, or one MX exchange.\n */\nexport function outreachMailGatewayOfHost(host: unknown): OutreachMailGateway | null {\n const name = normalizeHost(host)\n if (!name) return null\n for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES) {\n if (suffixes.some((suffix) => name === suffix.slice(1) || name.endsWith(suffix))) return gateway\n }\n return null\n}\n\n/**\n * The gateway a domain's MX records name — see the module note. A domain\n * whose exchanges name more than one is read as the first named one in MX\n * order (the list is handed over lowest-priority-first); one that names\n * none is `other`; an empty list is `none`.\n */\nexport function classifyOutreachMailGateway(mxHosts: readonly string[]): OutreachMailGateway {\n const hosts = mxHosts.map(normalizeHost).filter(Boolean)\n // A lone \".\" (or an empty exchange) is RFC 7505's null MX: the domain\n // says outright that it takes no mail.\n if (!hosts.length) return 'none'\n for (const host of hosts) {\n const gateway = outreachMailGatewayOfHost(host)\n if (gateway) return gateway\n }\n return 'other'\n}\n\n/** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */\nexport function isOutreachGatewayFronted(gateway: OutreachMailGateway | null | undefined): boolean {\n return gateway !== null && gateway !== undefined && OUTREACH_GATEWAY_FRONTED.includes(gateway)\n}\n\n/** The counts one UTC day holds on a gateway's ledger. */\nexport interface OutreachGatewayDayCounts {\n sent: number\n delivered: number\n blocked: number\n}\n\n/** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */\nexport function outreachGatewayDay(atMs: number): string {\n return new Date(atMs).toISOString().slice(0, 10)\n}\n\nconst count = (value: unknown): number => {\n const number = Number(value)\n return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0\n}\n\n/** The ledger's counts summed over the last `days` UTC days, today included. */\nexport function outreachGatewayWindow(\n days: Record<string, Partial<OutreachGatewayDayCounts>> | null | undefined,\n nowMs: number,\n windowDays: number,\n): OutreachGatewayDayCounts {\n const summed: OutreachGatewayDayCounts = { sent: 0, delivered: 0, blocked: 0 }\n if (!days) return summed\n const oldest = outreachGatewayDay(nowMs - (windowDays - 1) * 86_400_000)\n const newest = outreachGatewayDay(nowMs)\n for (const [day, counts] of Object.entries(days)) {\n if (day < oldest || day > newest) continue\n summed.sent += count(counts?.sent)\n summed.delivered += count(counts?.delivered)\n summed.blocked += count(counts?.blocked)\n }\n return summed\n}\n\n/** The ledger's days with everything older than `keepDays` dropped. */\nexport function pruneOutreachGatewayDays<T>(\n days: Record<string, T> | null | undefined,\n nowMs: number,\n keepDays: number,\n): Record<string, T> {\n const oldest = outreachGatewayDay(nowMs - (keepDays - 1) * 86_400_000)\n return Object.fromEntries(Object.entries(days ?? {}).filter(([day]) => day >= oldest))\n}\n\n/**\n * What the gates and the enroll dialog know about the gateway in front of\n * one address: which it is, and what it did with this organization's mail\n * over the chip's week and the hold's month.\n */\nexport interface OutreachGatewayStanding {\n gateway: OutreachMailGateway\n /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_CHIP_DAYS} days. */\n blocked7: number\n delivered7: number\n /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days. */\n blocked30: number\n delivered30: number\n}\n\n/**\n * Whether the gateway holds the next send — see the module note. A named\n * gateway only: `other` is many exchanges, and `none` blocks instead.\n */\nexport function outreachGatewayHolds(standing: Pick<OutreachGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean {\n if (standing.gateway === 'other' || standing.gateway === 'none') return false\n return count(standing.blocked30) >= OUTREACH_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0\n}\n\n/** The sentence the hold is written with, on the enrollment and in the engine's reason. */\nexport function outreachGatewayHoldReason(gateway: OutreachMailGateway, blocked30: number): string {\n const name = OUTREACH_MAIL_GATEWAY_LABELS[gateway]\n return (\n `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` +\n `${OUTREACH_GATEWAY_WINDOW_DAYS} days and delivered nothing, so this email is held. ` +\n 'Resume the enrollment to send it anyway.'\n )\n}\n\n/** The chip's tone: what color the Check-people step gives it. */\nexport type OutreachGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked'\n\nexport interface OutreachGatewayChip {\n label: string\n tone: OutreachGatewayChipTone\n}\n\n/**\n * The Check-people chip for one person, or `null` when there is nothing\n * worth a chip: an unrecognized exchange with no verdict this week. Red for\n * a gateway that refused this week, green for one that delivered, neutral\n * for the hosted providers and the security gateways with no verdict yet,\n * and blocked for a domain with no MX.\n */\nexport function outreachGatewayChip(standing: OutreachGatewayStanding | null | undefined): OutreachGatewayChip | null {\n if (!standing) return null\n const name = OUTREACH_MAIL_GATEWAY_LABELS[standing.gateway]\n if (standing.gateway === 'none') return { label: 'No MX record — cannot receive mail', tone: 'blocked' }\n const blocked = count(standing.blocked7)\n const delivered = count(standing.delivered7)\n const settled = blocked + delivered\n if (blocked > 0) {\n return {\n label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,\n tone: 'refused',\n }\n }\n if (delivered > 0) {\n return { label: `${name} · ${delivered} of ${settled} delivered this week`, tone: 'delivered' }\n }\n if (standing.gateway === 'other') return null\n return { label: name, tone: 'neutral' }\n}\n"],"names":["OUTREACH_MAIL_GATEWAYS","OUTREACH_MAIL_GATEWAY_LABELS","barracuda","proofpoint","mimecast","google","microsoft","other","none","OUTREACH_GATEWAY_FRONTED","OUTREACH_GATEWAY_HOLD_BLOCKS","OUTREACH_GATEWAY_WINDOW_DAYS","OUTREACH_GATEWAY_CHIP_DAYS","OUTREACH_DOMAIN_INTEL_TTL_MS","OUTREACH_GATEWAY_DELIVERED_AFTER_MS","GATEWAY_HOST_SUFFIXES","normalizeHost","host","String","trim","toLowerCase","replace","outreachMailGatewayOfHost","name","gateway","suffixes","some","suffix","slice","endsWith","classifyOutreachMailGateway","mxHosts","hosts","map","filter","Boolean","length","isOutreachGatewayFronted","undefined","includes","outreachGatewayDay","atMs","Date","toISOString","count","value","number","Number","isFinite","Math","max","floor","outreachGatewayWindow","days","nowMs","windowDays","summed","sent","delivered","blocked","oldest","newest","day","counts","Object","entries","pruneOutreachGatewayDays","keepDays","fromEntries","outreachGatewayHolds","standing","blocked30","delivered30","outreachGatewayHoldReason","outreachGatewayChip","label","tone","blocked7","delivered7","settled"],"mappings":"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"}
@@ -18,8 +18,8 @@
18
18
  export declare const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2;
19
19
  /** How many of the most recent sends the bounce rate is taken over. */
20
20
  export declare const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50;
21
- /** The bounce rate a mailbox may not exceed over that window. */
22
- export declare const OUTREACH_PAUSE_BOUNCE_RATE = 0.03;
21
+ /** Hard bounces in that window that pause the mailbox. */
22
+ export declare const OUTREACH_PAUSE_BOUNCES_PER_WINDOW = 3;
23
23
  /**
24
24
  * The fewest sends the window must hold before its bounce rate is judged
25
25
  * (AGL-3244). Below it the daily rule alone pauses the mailbox.
@@ -27,7 +27,7 @@ export declare const OUTREACH_PAUSE_BOUNCE_RATE = 0.03;
27
27
  export declare const OUTREACH_BOUNCE_RATE_MIN_SENDS = 25;
28
28
  /** How long a spam complaint keeps a mailbox paused. */
29
29
  export declare const OUTREACH_COMPLAINT_PAUSE_MS: number;
30
- export type OutreachHealthPauseReason = 'bounces_today' | 'bounce_rate' | 'complaint';
30
+ export type OutreachHealthPauseReason = 'bounces_today' | 'bounces_in_window' | 'complaint';
31
31
  export interface OutreachMailboxHealthInput {
32
32
  /** Hard bounces on this mailbox's mail received during its current local day. */
33
33
  bouncesToday: number;