@aglyn/plugins-outreach 1.0.0-beta.219 → 1.0.0-beta.221

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 (59) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/compliance-section.d.ts +5 -0
  3. package/src/lib/components/compliance-section.js +233 -149
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enrollment-detail.js +2 -2
  6. package/src/lib/components/enrollment-detail.js.map +1 -1
  7. package/src/lib/components/sequence-editor.d.ts +13 -0
  8. package/src/lib/components/sequence-editor.js +508 -302
  9. package/src/lib/components/sequence-editor.js.map +1 -1
  10. package/src/lib/components/sequence-preview.js +25 -10
  11. package/src/lib/components/sequence-preview.js.map +1 -1
  12. package/src/lib/components/sequence-step-card.d.ts +10 -0
  13. package/src/lib/components/sequence-step-card.js +320 -202
  14. package/src/lib/components/sequence-step-card.js.map +1 -1
  15. package/src/lib/engine/compose.js +2 -2
  16. package/src/lib/engine/compose.js.map +1 -1
  17. package/src/lib/engine/delivery-status.js +1 -1
  18. package/src/lib/engine/delivery-status.js.map +1 -1
  19. package/src/lib/engine/enrollment-state.js +2 -2
  20. package/src/lib/engine/enrollment-state.js.map +1 -1
  21. package/src/lib/engine/gates.d.ts +2 -2
  22. package/src/lib/engine/gates.js.map +1 -1
  23. package/src/lib/engine/opt-out-intent.js +1 -1
  24. package/src/lib/engine/opt-out-intent.js.map +1 -1
  25. package/src/lib/engine/sequence-validation.js +2 -2
  26. package/src/lib/engine/sequence-validation.js.map +1 -1
  27. package/src/lib/engine/thread-classification.js +3 -3
  28. package/src/lib/engine/thread-classification.js.map +1 -1
  29. package/src/lib/enrollment/gate-lookups.js +5 -4
  30. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  31. package/src/lib/model/outreach.types.d.ts +1 -1
  32. package/src/lib/model/outreach.types.js.map +1 -1
  33. package/src/lib/model/sales-topic.d.ts +27 -0
  34. package/src/lib/model/sales-topic.js +27 -0
  35. package/src/lib/model/sales-topic.js.map +1 -0
  36. package/src/lib/model/sequence-draft.d.ts +1 -1
  37. package/src/lib/model/sequence-draft.js +2 -2
  38. package/src/lib/model/sequence-draft.js.map +1 -1
  39. package/src/lib/model/stored-records.js +2 -2
  40. package/src/lib/model/stored-records.js.map +1 -1
  41. package/src/lib/routes/curate-routes.js +7 -7
  42. package/src/lib/routes/curate-routes.js.map +1 -1
  43. package/src/lib/routes/enroll-routes.js +10 -13
  44. package/src/lib/routes/enroll-routes.js.map +1 -1
  45. package/src/lib/routes/sequence-routes.js +9 -9
  46. package/src/lib/routes/sequence-routes.js.map +1 -1
  47. package/src/lib/runtime/campaign-credit.js +2 -2
  48. package/src/lib/runtime/campaign-credit.js.map +1 -1
  49. package/src/lib/runtime/campaign-detach.js +3 -2
  50. package/src/lib/runtime/campaign-detach.js.map +1 -1
  51. package/src/lib/runtime/lead-records.d.ts +6 -1
  52. package/src/lib/runtime/lead-records.js +25 -6
  53. package/src/lib/runtime/lead-records.js.map +1 -1
  54. package/src/lib/runtime/platform-runtime-deps.js +3 -3
  55. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +20 -4
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/runtime/sync-job.js +3 -3
  59. package/src/lib/runtime/sync-job.js.map +1 -1
@@ -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 * ## 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 — the platform's `mailDomains/{domain}` cache — and what that\n * gateway did lately with mail from the mailbox's sending domain, from\n * the organization's `mailGatewayLedger` (AGL-3328). `none` blocks; a\n * 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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"}
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/subscription-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 `memberAddressRoster` 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 — the platform's `mailDomains/{domain}` cache — and what that\n * gateway did lately with mail from the mailbox's sending domain, from\n * the organization's `mailGatewayLedger` (AGL-3328). `none` blocks; a\n * 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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"}
@@ -43,7 +43,7 @@
43
43
  *
44
44
  * "No rush", "no problem" and "no worries" are none of these: a "no" only
45
45
  * counts when it is the whole answer.
46
- *==========================================*/ import { stripQuotedHistory } from "@aglyn/aglyn/app-utils/crm-inbound";
46
+ *==========================================*/ import { stripQuotedHistory } from "@aglyn/aglyn/app-utils/email-text";
47
47
  const NONE = {
48
48
  optOut: false,
49
49
  complaint: false,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/opt-out-intent.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 * A REPLY THAT SAYS STOP (AGL-2979).\n *\n * Every Outreach email tells its reader to reply \"no\" to be left alone, so\n * a reply has to be read for that. Any reply already stops its sequence;\n * reading one as an OPT-OUT also puts the address on the organization's\n * do-not-contact list, so no later sequence reaches it either. Both mistakes\n * cost something: a missed opt-out lets another sequence write to somebody\n * who said no, and a false one silently loses a prospect who said \"no rush\".\n *\n * ## Only the reply's own words\n *\n * A reply quotes the email it answers, and that email's footer says \"Reply\n * 'no'\". So the quoted history is cut first — the CRM capture reader's\n * `stripQuotedHistory` — along with a signature and any footer line a mail\n * client quoted without marking it.\n *\n * ## What counts\n *\n * - A short reply that says nothing but no: \"No.\", \"No thanks\", \"Nope\",\n * \"Pass\", \"Stop\" — with a greeting and a sign-off allowed around it.\n * - A request anywhere in the reply: unsubscribe, remove me, take me off\n * the list, stop emailing, don't contact me, opt out, no more emails —\n * unless it is negated (\"please don't remove me\").\n * - \"Not interested\", unless it defers (\"not interested yet\", \"not\n * interested right now, try me next quarter\") or asks (\"not interested?\").\n * - A reply that calls the email spam, which is also a COMPLAINT.\n *\n * \"No rush\", \"no problem\" and \"no worries\" are none of these: a \"no\" only\n * counts when it is the whole answer.\n *==========================================*/\n\nimport { stripQuotedHistory } from '@aglyn/aglyn/app-utils/crm-inbound'\n\nexport interface OutreachOptOutIntent {\n /** The reply asks not to be emailed again. */\n optOut: boolean\n /** The reply calls the email spam — an opt-out that also counts against the mailbox. */\n complaint: boolean\n /** The words that decided it, for the timeline and the log; `null` when nothing matched. */\n matched: string | null\n}\n\nconst NONE: OutreachOptOutIntent = { optOut: false, complaint: false, matched: null }\n\n/** A line of the footer every Outreach email carries, however a client re-quoted it. */\nconst FOOTER_LINE =\n /reply\\s+[\"“”'‘’]?no[\"“”'‘’]?\\s+and\\s+i\\s+won['’]?t\\s+email|(?:business\\s+solicitation|sales\\s+email)\\s+from/i\n\n/**\n * The words a reply adds to its thread: the text above the quoted history,\n * with a signature and any quoted footer line removed.\n */\nexport function outreachFreshReplyText(text: unknown): string {\n const body = stripQuotedHistory(\n String(text ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[\\u00a0\\u202f]/g, ' '),\n )\n const lines = body.split('\\n')\n const signature = lines.findIndex((line) => /^--\\s*$/.test(line))\n return (signature >= 0 ? lines.slice(0, signature) : lines)\n .filter((line) => !FOOTER_LINE.test(line))\n .join('\\n')\n .trim()\n}\n\n/** Curly quotes straightened, case folded, whitespace settled. */\nfunction fold(text: string): string {\n return text\n .replace(/[‘’]/g, \"'\")\n .replace(/[“”]/g, '\"')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\nconst GREETING_LINE = /^(hi|hello|hey|dear|greetings|good (morning|afternoon|evening))\\b[^\\n]{0,40}$/i\nconst GREETING_LEAD = /^(hi|hello|hey|dear|greetings)(\\s+[a-z'’-]+)?\\s*[,!.:–—-]\\s*/i\nconst SIGN_OFF =\n /^(thanks|thank you|thx|many thanks|cheers|regards|best|best regards|kind regards|warm regards|sincerely|all the best|sent from my\\b.*|get outlook\\b.*)[\\s,.!]*$/i\nconst NAME = \"[A-Z][a-z'’-]+( [A-Z][a-z'’-]+)?\"\n/** Thanks, then a signed name: \"No thanks, Jordan\" or \"No thanks — Jordan\". */\nconst THANKS_THEN_NAME = new RegExp(`\\\\b(thanks|thank you|thx|cheers)[\\\\s,–—-]+${NAME}[.!]?\\\\s*$`, 'i')\nconst NAME_LINE = new RegExp(`^${NAME}[.!]?$`)\n\n/**\n * What is left of a reply once its greeting, its sign-off and a name signed\n * after \"thanks\" are gone. A name is only taken off after a thank-you,\n * because \"No, WordPress.\" is an answer about a platform, not a signature.\n */\nfunction answerOnly(fresh: string): string {\n const lines = fresh\n .split('\\n')\n .map((line) => line.trim())\n .filter(Boolean)\n if (lines.length > 1 && GREETING_LINE.test(lines[0])) lines.shift()\n const signOff = lines.findIndex((line, index) => index > 0 && SIGN_OFF.test(line))\n const kept = signOff >= 0 ? lines.slice(0, signOff) : lines\n if (\n kept.length > 1 &&\n NAME_LINE.test(kept[kept.length - 1]) &&\n /\\b(thanks|thank you|thx|cheers)[\\s,.!]*$/i.test(kept[kept.length - 2])\n ) {\n kept.pop()\n }\n return kept\n .join(' ')\n .replace(GREETING_LEAD, '')\n .replace(THANKS_THEN_NAME, '$1')\n}\n\n/** The only words a reply may hold to be read as a bare \"no\". */\nconst BARE_NO_WORDS = new Set([\n 'no',\n 'nope',\n 'nah',\n 'pass',\n \"i'll\",\n 'ill',\n 'i',\n 'will',\n 'stop',\n 'unsubscribe',\n 'remove',\n 'me',\n 'us',\n 'please',\n 'thanks',\n 'thank',\n 'you',\n 'thx',\n 'sorry',\n 'but',\n 'not',\n 'for',\n 'interested',\n])\nconst BARE_NO_TRIGGERS = new Set(['no', 'nope', 'nah', 'pass', 'stop', 'unsubscribe', 'remove'])\n\nfunction bareNo(answer: string): string | null {\n const words = fold(answer)\n .replace(/[^a-z' ]+/g, ' ')\n .split(' ')\n .filter(Boolean)\n if (!words.length || words.length > 6) return null\n if (!words.every((word) => BARE_NO_WORDS.has(word))) return null\n const said = words.join(' ')\n if (\n words.some((word) => BARE_NO_TRIGGERS.has(word)) ||\n /\\bnot interested\\b|\\bnot for (me|us)\\b/.test(said)\n ) {\n return said\n }\n return null\n}\n\n/** Negations that turn a request around when they come just before it. */\nconst NEGATED = /\\b(don't|dont|do not|didn't|didnt|did not|never|not|no need to|please don't|wouldn't|won't)\\s+(\\w+\\s+){0,2}$/\n\nconst REQUESTS: readonly RegExp[] = [\n /\\bunsubscrib(e|ed|ing)\\b/,\n /\\bopt(ed|ing)?[ -]?(me )?out\\b/,\n /\\bremove (me|us|my (email|e-mail|address|name|details|info))\\b/,\n /\\bremove\\b[^.!?\\n]{0,30}\\bfrom (your|the|this|all) (list|lists|mailing list|emails?|database|system|sequence)\\b/,\n /\\btake (me|us|my (email|e-mail|address|name)) off\\b/,\n /\\btake\\b[^.!?\\n]{0,30}\\boff (your|the|this|all) (list|lists|mailing list|emails?)\\b/,\n // Aimed at us: \"stop emailing me\", \"stop sending these\", \"stop contacting.\" —\n // not \"we had to stop sending newsletters ourselves\".\n /\\b(stop|quit) (emailing|e-mailing|mailing|contacting|messaging|writing|sending|reaching out)(?=\\s*([.!,;]|$)|\\s+(me|us|my|our|this|these|those|them|to (me|us)|emails?|e-mails?|messages?|mail)\\b)/,\n /\\bplease stop\\b/,\n /^stop\\b(?! (by|in|over|at|the|thinking|worrying))/,\n /\\b(do not|don't|dont|never) (email|e-mail|contact|message|write to|reach out to) (me|us)\\b/,\n /\\bno (more|further) (emails?|e-mails?|messages|mail|contact)\\b/,\n /\\b(don't|do not) want (to (hear|receive)|any more|these)\\b[^.!?\\n]{0,20}\\b(emails?|messages|from you)\\b/,\n]\n\nconst COMPLAINTS: readonly RegExp[] = [\n /\\b(this|that|it|you|your emails?|these emails?|this email)\\s*(is|are|'s|'re)\\s+(just\\s+)?spam\\b/,\n /\\b(stop|quit|don't|do not) spam(ming)?\\b/,\n /\\bspamming (me|us|my|our)\\b/,\n /\\b(i|we)('ve| have| just| will| am| are)?\\s*(report(ed|ing)?|mark(ed|ing)?|flag(ged|ging)?)\\b[^.!?\\n]{0,30}\\b(as|for) spam\\b/,\n]\n\n/** Words after \"not interested\" that put it off rather than rule it out. */\nconst DEFERRAL =\n /^[\\s,]*(yet|right now|for now|now|at the moment|at this (time|moment|point|stage)|today|this (week|month|quarter|year)|until|in the (near )?future|but\\b|however\\b|though\\b)/\n\nfunction notInterested(text: string): string | null {\n const pattern = /\\bnot (really |at all )?interested\\b/g\n for (let match = pattern.exec(text); match; match = pattern.exec(text)) {\n const before = text.slice(0, match.index)\n const after = text.slice(match.index + match[0].length)\n const sentenceEnd = after.search(/[.!?\\n]/)\n const rest = sentenceEnd >= 0 ? after.slice(0, sentenceEnd + 1) : after\n if (rest.trimEnd().endsWith('?')) continue\n if (DEFERRAL.test(after)) continue\n if (/\\b(if|unless|in case|are you|were you|you're|you are)\\s+(\\w+\\s+){0,2}$/.test(before)) continue\n return match[0]\n }\n return null\n}\n\n/**\n * Whether a SUBJECT says nothing but stop — \"Unsubscribe\", \"Remove me\",\n * \"STOP\" — as a message sent from an unsubscribe link, or typed fresh, does.\n * Only a bare answer counts: a subject is a sender's headline, and \"Stop\n * losing leads\" is one.\n */\nexport function detectOutreachOptOutSubject(subject: unknown): OutreachOptOutIntent {\n const said = bareNo(String(subject ?? ''))\n return said ? { optOut: true, complaint: false, matched: said } : NONE\n}\n\n/**\n * Whether a reply's text asks to be left alone — see the module note. Pass\n * the reply's text as received; the quoted history is removed here.\n */\nexport function detectOutreachOptOutIntent(text: unknown): OutreachOptOutIntent {\n const fresh = outreachFreshReplyText(text)\n if (!fresh) return NONE\n const folded = fold(fresh)\n for (const pattern of COMPLAINTS) {\n const match = pattern.exec(folded)\n if (match) return { optOut: true, complaint: true, matched: match[0] }\n }\n const bare = bareNo(answerOnly(fresh))\n if (bare) return { optOut: true, complaint: false, matched: bare }\n for (const pattern of REQUESTS) {\n const match = pattern.exec(folded)\n if (match && !NEGATED.test(folded.slice(0, match.index))) {\n return { optOut: true, complaint: false, matched: match[0] }\n }\n }\n const declined = notInterested(folded)\n if (declined) return { optOut: true, complaint: false, matched: declined }\n return NONE\n}\n"],"names":["stripQuotedHistory","NONE","optOut","complaint","matched","FOOTER_LINE","outreachFreshReplyText","text","body","String","replace","lines","split","signature","findIndex","line","test","slice","filter","join","trim","fold","toLowerCase","GREETING_LINE","GREETING_LEAD","SIGN_OFF","NAME","THANKS_THEN_NAME","RegExp","NAME_LINE","answerOnly","fresh","map","Boolean","length","shift","signOff","index","kept","pop","BARE_NO_WORDS","Set","BARE_NO_TRIGGERS","bareNo","answer","words","every","word","has","said","some","NEGATED","REQUESTS","COMPLAINTS","DEFERRAL","notInterested","pattern","match","exec","before","after","sentenceEnd","search","rest","trimEnd","endsWith","detectOutreachOptOutSubject","subject","detectOutreachOptOutIntent","folded","bare","declined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/opt-out-intent.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 * A REPLY THAT SAYS STOP (AGL-2979).\n *\n * Every Outreach email tells its reader to reply \"no\" to be left alone, so\n * a reply has to be read for that. Any reply already stops its sequence;\n * reading one as an OPT-OUT also puts the address on the organization's\n * do-not-contact list, so no later sequence reaches it either. Both mistakes\n * cost something: a missed opt-out lets another sequence write to somebody\n * who said no, and a false one silently loses a prospect who said \"no rush\".\n *\n * ## Only the reply's own words\n *\n * A reply quotes the email it answers, and that email's footer says \"Reply\n * 'no'\". So the quoted history is cut first — the CRM capture reader's\n * `stripQuotedHistory` — along with a signature and any footer line a mail\n * client quoted without marking it.\n *\n * ## What counts\n *\n * - A short reply that says nothing but no: \"No.\", \"No thanks\", \"Nope\",\n * \"Pass\", \"Stop\" — with a greeting and a sign-off allowed around it.\n * - A request anywhere in the reply: unsubscribe, remove me, take me off\n * the list, stop emailing, don't contact me, opt out, no more emails —\n * unless it is negated (\"please don't remove me\").\n * - \"Not interested\", unless it defers (\"not interested yet\", \"not\n * interested right now, try me next quarter\") or asks (\"not interested?\").\n * - A reply that calls the email spam, which is also a COMPLAINT.\n *\n * \"No rush\", \"no problem\" and \"no worries\" are none of these: a \"no\" only\n * counts when it is the whole answer.\n *==========================================*/\n\nimport { stripQuotedHistory } from '@aglyn/aglyn/app-utils/email-text'\n\nexport interface OutreachOptOutIntent {\n /** The reply asks not to be emailed again. */\n optOut: boolean\n /** The reply calls the email spam — an opt-out that also counts against the mailbox. */\n complaint: boolean\n /** The words that decided it, for the timeline and the log; `null` when nothing matched. */\n matched: string | null\n}\n\nconst NONE: OutreachOptOutIntent = { optOut: false, complaint: false, matched: null }\n\n/** A line of the footer every Outreach email carries, however a client re-quoted it. */\nconst FOOTER_LINE =\n /reply\\s+[\"“”'‘’]?no[\"“”'‘’]?\\s+and\\s+i\\s+won['’]?t\\s+email|(?:business\\s+solicitation|sales\\s+email)\\s+from/i\n\n/**\n * The words a reply adds to its thread: the text above the quoted history,\n * with a signature and any quoted footer line removed.\n */\nexport function outreachFreshReplyText(text: unknown): string {\n const body = stripQuotedHistory(\n String(text ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[\\u00a0\\u202f]/g, ' '),\n )\n const lines = body.split('\\n')\n const signature = lines.findIndex((line) => /^--\\s*$/.test(line))\n return (signature >= 0 ? lines.slice(0, signature) : lines)\n .filter((line) => !FOOTER_LINE.test(line))\n .join('\\n')\n .trim()\n}\n\n/** Curly quotes straightened, case folded, whitespace settled. */\nfunction fold(text: string): string {\n return text\n .replace(/[‘’]/g, \"'\")\n .replace(/[“”]/g, '\"')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\nconst GREETING_LINE = /^(hi|hello|hey|dear|greetings|good (morning|afternoon|evening))\\b[^\\n]{0,40}$/i\nconst GREETING_LEAD = /^(hi|hello|hey|dear|greetings)(\\s+[a-z'’-]+)?\\s*[,!.:–—-]\\s*/i\nconst SIGN_OFF =\n /^(thanks|thank you|thx|many thanks|cheers|regards|best|best regards|kind regards|warm regards|sincerely|all the best|sent from my\\b.*|get outlook\\b.*)[\\s,.!]*$/i\nconst NAME = \"[A-Z][a-z'’-]+( [A-Z][a-z'’-]+)?\"\n/** Thanks, then a signed name: \"No thanks, Jordan\" or \"No thanks — Jordan\". */\nconst THANKS_THEN_NAME = new RegExp(`\\\\b(thanks|thank you|thx|cheers)[\\\\s,–—-]+${NAME}[.!]?\\\\s*$`, 'i')\nconst NAME_LINE = new RegExp(`^${NAME}[.!]?$`)\n\n/**\n * What is left of a reply once its greeting, its sign-off and a name signed\n * after \"thanks\" are gone. A name is only taken off after a thank-you,\n * because \"No, WordPress.\" is an answer about a platform, not a signature.\n */\nfunction answerOnly(fresh: string): string {\n const lines = fresh\n .split('\\n')\n .map((line) => line.trim())\n .filter(Boolean)\n if (lines.length > 1 && GREETING_LINE.test(lines[0])) lines.shift()\n const signOff = lines.findIndex((line, index) => index > 0 && SIGN_OFF.test(line))\n const kept = signOff >= 0 ? lines.slice(0, signOff) : lines\n if (\n kept.length > 1 &&\n NAME_LINE.test(kept[kept.length - 1]) &&\n /\\b(thanks|thank you|thx|cheers)[\\s,.!]*$/i.test(kept[kept.length - 2])\n ) {\n kept.pop()\n }\n return kept\n .join(' ')\n .replace(GREETING_LEAD, '')\n .replace(THANKS_THEN_NAME, '$1')\n}\n\n/** The only words a reply may hold to be read as a bare \"no\". */\nconst BARE_NO_WORDS = new Set([\n 'no',\n 'nope',\n 'nah',\n 'pass',\n \"i'll\",\n 'ill',\n 'i',\n 'will',\n 'stop',\n 'unsubscribe',\n 'remove',\n 'me',\n 'us',\n 'please',\n 'thanks',\n 'thank',\n 'you',\n 'thx',\n 'sorry',\n 'but',\n 'not',\n 'for',\n 'interested',\n])\nconst BARE_NO_TRIGGERS = new Set(['no', 'nope', 'nah', 'pass', 'stop', 'unsubscribe', 'remove'])\n\nfunction bareNo(answer: string): string | null {\n const words = fold(answer)\n .replace(/[^a-z' ]+/g, ' ')\n .split(' ')\n .filter(Boolean)\n if (!words.length || words.length > 6) return null\n if (!words.every((word) => BARE_NO_WORDS.has(word))) return null\n const said = words.join(' ')\n if (\n words.some((word) => BARE_NO_TRIGGERS.has(word)) ||\n /\\bnot interested\\b|\\bnot for (me|us)\\b/.test(said)\n ) {\n return said\n }\n return null\n}\n\n/** Negations that turn a request around when they come just before it. */\nconst NEGATED = /\\b(don't|dont|do not|didn't|didnt|did not|never|not|no need to|please don't|wouldn't|won't)\\s+(\\w+\\s+){0,2}$/\n\nconst REQUESTS: readonly RegExp[] = [\n /\\bunsubscrib(e|ed|ing)\\b/,\n /\\bopt(ed|ing)?[ -]?(me )?out\\b/,\n /\\bremove (me|us|my (email|e-mail|address|name|details|info))\\b/,\n /\\bremove\\b[^.!?\\n]{0,30}\\bfrom (your|the|this|all) (list|lists|mailing list|emails?|database|system|sequence)\\b/,\n /\\btake (me|us|my (email|e-mail|address|name)) off\\b/,\n /\\btake\\b[^.!?\\n]{0,30}\\boff (your|the|this|all) (list|lists|mailing list|emails?)\\b/,\n // Aimed at us: \"stop emailing me\", \"stop sending these\", \"stop contacting.\" —\n // not \"we had to stop sending newsletters ourselves\".\n /\\b(stop|quit) (emailing|e-mailing|mailing|contacting|messaging|writing|sending|reaching out)(?=\\s*([.!,;]|$)|\\s+(me|us|my|our|this|these|those|them|to (me|us)|emails?|e-mails?|messages?|mail)\\b)/,\n /\\bplease stop\\b/,\n /^stop\\b(?! (by|in|over|at|the|thinking|worrying))/,\n /\\b(do not|don't|dont|never) (email|e-mail|contact|message|write to|reach out to) (me|us)\\b/,\n /\\bno (more|further) (emails?|e-mails?|messages|mail|contact)\\b/,\n /\\b(don't|do not) want (to (hear|receive)|any more|these)\\b[^.!?\\n]{0,20}\\b(emails?|messages|from you)\\b/,\n]\n\nconst COMPLAINTS: readonly RegExp[] = [\n /\\b(this|that|it|you|your emails?|these emails?|this email)\\s*(is|are|'s|'re)\\s+(just\\s+)?spam\\b/,\n /\\b(stop|quit|don't|do not) spam(ming)?\\b/,\n /\\bspamming (me|us|my|our)\\b/,\n /\\b(i|we)('ve| have| just| will| am| are)?\\s*(report(ed|ing)?|mark(ed|ing)?|flag(ged|ging)?)\\b[^.!?\\n]{0,30}\\b(as|for) spam\\b/,\n]\n\n/** Words after \"not interested\" that put it off rather than rule it out. */\nconst DEFERRAL =\n /^[\\s,]*(yet|right now|for now|now|at the moment|at this (time|moment|point|stage)|today|this (week|month|quarter|year)|until|in the (near )?future|but\\b|however\\b|though\\b)/\n\nfunction notInterested(text: string): string | null {\n const pattern = /\\bnot (really |at all )?interested\\b/g\n for (let match = pattern.exec(text); match; match = pattern.exec(text)) {\n const before = text.slice(0, match.index)\n const after = text.slice(match.index + match[0].length)\n const sentenceEnd = after.search(/[.!?\\n]/)\n const rest = sentenceEnd >= 0 ? after.slice(0, sentenceEnd + 1) : after\n if (rest.trimEnd().endsWith('?')) continue\n if (DEFERRAL.test(after)) continue\n if (/\\b(if|unless|in case|are you|were you|you're|you are)\\s+(\\w+\\s+){0,2}$/.test(before)) continue\n return match[0]\n }\n return null\n}\n\n/**\n * Whether a SUBJECT says nothing but stop — \"Unsubscribe\", \"Remove me\",\n * \"STOP\" — as a message sent from an unsubscribe link, or typed fresh, does.\n * Only a bare answer counts: a subject is a sender's headline, and \"Stop\n * losing leads\" is one.\n */\nexport function detectOutreachOptOutSubject(subject: unknown): OutreachOptOutIntent {\n const said = bareNo(String(subject ?? ''))\n return said ? { optOut: true, complaint: false, matched: said } : NONE\n}\n\n/**\n * Whether a reply's text asks to be left alone — see the module note. Pass\n * the reply's text as received; the quoted history is removed here.\n */\nexport function detectOutreachOptOutIntent(text: unknown): OutreachOptOutIntent {\n const fresh = outreachFreshReplyText(text)\n if (!fresh) return NONE\n const folded = fold(fresh)\n for (const pattern of COMPLAINTS) {\n const match = pattern.exec(folded)\n if (match) return { optOut: true, complaint: true, matched: match[0] }\n }\n const bare = bareNo(answerOnly(fresh))\n if (bare) return { optOut: true, complaint: false, matched: bare }\n for (const pattern of REQUESTS) {\n const match = pattern.exec(folded)\n if (match && !NEGATED.test(folded.slice(0, match.index))) {\n return { optOut: true, complaint: false, matched: match[0] }\n }\n }\n const declined = notInterested(folded)\n if (declined) return { optOut: true, complaint: false, matched: declined }\n return NONE\n}\n"],"names":["stripQuotedHistory","NONE","optOut","complaint","matched","FOOTER_LINE","outreachFreshReplyText","text","body","String","replace","lines","split","signature","findIndex","line","test","slice","filter","join","trim","fold","toLowerCase","GREETING_LINE","GREETING_LEAD","SIGN_OFF","NAME","THANKS_THEN_NAME","RegExp","NAME_LINE","answerOnly","fresh","map","Boolean","length","shift","signOff","index","kept","pop","BARE_NO_WORDS","Set","BARE_NO_TRIGGERS","bareNo","answer","words","every","word","has","said","some","NEGATED","REQUESTS","COMPLAINTS","DEFERRAL","notInterested","pattern","match","exec","before","after","sentenceEnd","search","rest","trimEnd","endsWith","detectOutreachOptOutSubject","subject","detectOutreachOptOutIntent","folded","bare","declined"],"mappings":"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"}
@@ -31,7 +31,7 @@
31
31
  *==========================================*/ import { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
32
32
  import { readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubscribe";
33
33
  import { CRM_MERGE_FIELDS, crmMergeFieldsIn } from "@aglyn/aglyn/app-utils/crm-email-templates";
34
- import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
34
+ import { threadSubject } from "@aglyn/aglyn/app-utils/email-text";
35
35
  import { OUTREACH_DEFAULT_ALLOWED_COUNTRIES, OUTREACH_MAX_EMAIL_STEPS, OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS, OUTREACH_MAX_STEPS, OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS, OUTREACH_TASK_KINDS } from "../model/outreach.types.js";
36
36
  import { outreachBodyLinks } from "./click-tracking.js";
37
37
  /** The merge field the rep's signal sentence is written into a step with. */ export const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine';
@@ -111,7 +111,7 @@ const isEmailStep = (step)=>(step == null ? void 0 : step.kind) === 'email';
111
111
  * reader's, so the two agree on what one looks like.
112
112
  */ export function outreachSubjectHasReplyPrefix(subject) {
113
113
  const collapsed = asText(subject).replace(/\s+/g, ' ').trim();
114
- return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX);
114
+ return collapsed !== '' && threadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX);
115
115
  }
116
116
  const WEEKDAY_NAMES = [
117
117
  'Sunday',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.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 * WHAT A SEQUENCE MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { readListUnsubscribeSetting } from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\nimport { outreachBodyLinks } from './click-tracking'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n | 'too_many_links'\n | 'discount_language'\n | 'not_plain_text'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/**\n * The index of the first email step at or after `fromIndex` — the first\n * email an enrollment that began there sends (AGL-3228) — or `-1` when\n * there is none.\n */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n fromIndex = 0,\n): number {\n const list = steps ?? []\n for (let index = Math.max(0, fromIndex); index < list.length; index += 1) {\n if (isEmailStep(list[index])) return index\n }\n return -1\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n *\n * `startStepIndex` is where the enrollment began (AGL-3228): the first email\n * it sends from there starts the thread, because the steps before it were\n * sent by hand, if at all, and there is no email of ours to answer.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n startStepIndex = 0,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps, startStepIndex)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * The subject an email step starts a thread with, before merge fields\n * (AGL-3228). A step written to start a thread carries its own. One written\n * as a reply may start the thread only for an enrollment that began at it,\n * and its subject field is not what its author wrote for it — so it takes\n * the sequence's first email's subject, the one the person already had by\n * hand.\n */\nexport function outreachThreadStartSubject(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): string {\n const step = steps?.[index]\n if (!isEmailStep(step)) return ''\n if (!isInThreadEmailStep(steps, index)) return asText(step.subject)\n const first = steps?.[firstEmailStepIndex(steps)]\n return isEmailStep(first) ? asText(first.subject) : ''\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n // Absent is off (AGL-3239). Every sequence written before the setting\n // existed reads as untracked, which is what it was.\n trackClicks: data['trackClicks'] === true,\n // Absent is off (AGL-3395): a sequence stays plain text unless it asks\n // for the HTML part, and every one written before the setting did not.\n countOpens: data['countOpens'] === true,\n // Absent is off (AGL-3296): a sequence is one-to-one mail, and every\n // sequence written before the setting existed reads as headerless.\n // Read by the shared reader campaigns use too (AGL-3307), with the\n // sequence's own default.\n listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false),\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/*==========================================\n * A CURATED STEP (AGL-3324).\n *\n * One person's own copy of a step is held to everything a step is held to —\n * a subject where the email starts a thread, a body, the lengths, the merge\n * fields — and to the outbound playbook's rules the sequence editor trusts\n * its author with but an AI draft cannot be trusted with: one link at most,\n * list price only, plain text. Each is an ERROR: a draft that breaks one is\n * refused rather than stored, whoever wrote it.\n *==========================================*/\n\n/**\n * Words the playbook keeps out of every message: a discount, a coupon, a\n * cohort, a slot count, a deadline. Matched on word boundaries, so \"count\"\n * and \"expired domain\" pass and \"coupon\" and \"20% off\" do not.\n */\nconst DISCOUNT_LANGUAGE =\n /\\b(discounts?|discounted|coupons?|promo(?:tional)?\\s+codes?|promo\\b|vouchers?|\\d+\\s*%\\s*off|percent\\s+off|money\\s+off|special\\s+offer|limited[-\\s]time|expires?\\s+(?:on|at|in|soon|today|tomorrow)|deadline|(?:only|last|first)\\s+\\d+\\s+(?:spots?|slots?|seats?|places?)|cohorts?)\\b/i\n\n/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */\nconst MARKDOWN = /\\*\\*[^*\\n]+\\*\\*|\\[[^\\]\\n]+\\]\\([^)\\s]+\\)|^#{1,6}\\s|<\\/?[a-z][^>]*>/im\n\nexport interface OutreachCuratedStepInput {\n /** The subject, when the copy carries one. Read only where the email starts a thread. */\n subject?: string | null\n body?: string | null\n /** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */\n startsThread: boolean\n}\n\n/** Everything one person's copy of a step has to be before it may be stored — see the section note. */\nexport function validateOutreachCuratedStep(\n input: OutreachCuratedStepInput,\n path = 'override',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const subject = asText(input.subject)\n if (input.startsThread && input.subject !== undefined && input.subject !== null) {\n if (!subject.trim()) {\n issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'))\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`),\n )\n }\n if (DISCOUNT_LANGUAGE.test(subject)) {\n issues.push(\n error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(input.body)\n if (!body.trim()) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email.'))\n return issues\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n const links = outreachBodyLinks(body)\n if (links.length > 1) {\n issues.push(\n error(\n `${path}.body`,\n 'too_many_links',\n `This email carries ${links.length} links. A sequence email carries one at most.`,\n ),\n )\n }\n if (DISCOUNT_LANGUAGE.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'discount_language',\n 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.',\n ),\n )\n }\n if (MARKDOWN.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'not_plain_text',\n 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.',\n ),\n )\n }\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","readListUnsubscribeSetting","CRM_MERGE_FIELDS","crmMergeFieldsIn","crmThreadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","outreachBodyLinks","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","fromIndex","list","index","Math","max","length","isInThreadEmailStep","startStepIndex","first","replyInThread","outreachThreadStartSubject","subject","outreachSubjectHasReplyPrefix","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","trackClicks","countOpens","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","DISCOUNT_LANGUAGE","MARKDOWN","validateOutreachCuratedStep","input","undefined","links","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.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 * WHAT A SEQUENCE MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { readListUnsubscribeSetting } from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\nimport { outreachBodyLinks } from './click-tracking'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n | 'too_many_links'\n | 'discount_language'\n | 'not_plain_text'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/**\n * The index of the first email step at or after `fromIndex` — the first\n * email an enrollment that began there sends (AGL-3228) — or `-1` when\n * there is none.\n */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n fromIndex = 0,\n): number {\n const list = steps ?? []\n for (let index = Math.max(0, fromIndex); index < list.length; index += 1) {\n if (isEmailStep(list[index])) return index\n }\n return -1\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n *\n * `startStepIndex` is where the enrollment began (AGL-3228): the first email\n * it sends from there starts the thread, because the steps before it were\n * sent by hand, if at all, and there is no email of ours to answer.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n startStepIndex = 0,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps, startStepIndex)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * The subject an email step starts a thread with, before merge fields\n * (AGL-3228). A step written to start a thread carries its own. One written\n * as a reply may start the thread only for an enrollment that began at it,\n * and its subject field is not what its author wrote for it — so it takes\n * the sequence's first email's subject, the one the person already had by\n * hand.\n */\nexport function outreachThreadStartSubject(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): string {\n const step = steps?.[index]\n if (!isEmailStep(step)) return ''\n if (!isInThreadEmailStep(steps, index)) return asText(step.subject)\n const first = steps?.[firstEmailStepIndex(steps)]\n return isEmailStep(first) ? asText(first.subject) : ''\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && threadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n // Absent is off (AGL-3239). Every sequence written before the setting\n // existed reads as untracked, which is what it was.\n trackClicks: data['trackClicks'] === true,\n // Absent is off (AGL-3395): a sequence stays plain text unless it asks\n // for the HTML part, and every one written before the setting did not.\n countOpens: data['countOpens'] === true,\n // Absent is off (AGL-3296): a sequence is one-to-one mail, and every\n // sequence written before the setting existed reads as headerless.\n // Read by the shared reader campaigns use too (AGL-3307), with the\n // sequence's own default.\n listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false),\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/*==========================================\n * A CURATED STEP (AGL-3324).\n *\n * One person's own copy of a step is held to everything a step is held to —\n * a subject where the email starts a thread, a body, the lengths, the merge\n * fields — and to the outbound playbook's rules the sequence editor trusts\n * its author with but an AI draft cannot be trusted with: one link at most,\n * list price only, plain text. Each is an ERROR: a draft that breaks one is\n * refused rather than stored, whoever wrote it.\n *==========================================*/\n\n/**\n * Words the playbook keeps out of every message: a discount, a coupon, a\n * cohort, a slot count, a deadline. Matched on word boundaries, so \"count\"\n * and \"expired domain\" pass and \"coupon\" and \"20% off\" do not.\n */\nconst DISCOUNT_LANGUAGE =\n /\\b(discounts?|discounted|coupons?|promo(?:tional)?\\s+codes?|promo\\b|vouchers?|\\d+\\s*%\\s*off|percent\\s+off|money\\s+off|special\\s+offer|limited[-\\s]time|expires?\\s+(?:on|at|in|soon|today|tomorrow)|deadline|(?:only|last|first)\\s+\\d+\\s+(?:spots?|slots?|seats?|places?)|cohorts?)\\b/i\n\n/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */\nconst MARKDOWN = /\\*\\*[^*\\n]+\\*\\*|\\[[^\\]\\n]+\\]\\([^)\\s]+\\)|^#{1,6}\\s|<\\/?[a-z][^>]*>/im\n\nexport interface OutreachCuratedStepInput {\n /** The subject, when the copy carries one. Read only where the email starts a thread. */\n subject?: string | null\n body?: string | null\n /** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */\n startsThread: boolean\n}\n\n/** Everything one person's copy of a step has to be before it may be stored — see the section note. */\nexport function validateOutreachCuratedStep(\n input: OutreachCuratedStepInput,\n path = 'override',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const subject = asText(input.subject)\n if (input.startsThread && input.subject !== undefined && input.subject !== null) {\n if (!subject.trim()) {\n issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'))\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`),\n )\n }\n if (DISCOUNT_LANGUAGE.test(subject)) {\n issues.push(\n error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(input.body)\n if (!body.trim()) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email.'))\n return issues\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n const links = outreachBodyLinks(body)\n if (links.length > 1) {\n issues.push(\n error(\n `${path}.body`,\n 'too_many_links',\n `This email carries ${links.length} links. A sequence email carries one at most.`,\n ),\n )\n }\n if (DISCOUNT_LANGUAGE.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'discount_language',\n 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.',\n ),\n )\n }\n if (MARKDOWN.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'not_plain_text',\n 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.',\n ),\n )\n }\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","readListUnsubscribeSetting","CRM_MERGE_FIELDS","crmMergeFieldsIn","threadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","outreachBodyLinks","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","fromIndex","list","index","Math","max","length","isInThreadEmailStep","startStepIndex","first","replyInThread","outreachThreadStartSubject","subject","outreachSubjectHasReplyPrefix","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","trackClicks","countOpens","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","DISCOUNT_LANGUAGE","MARKDOWN","validateOutreachCuratedStep","input","undefined","links","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"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"}
@@ -46,7 +46,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
46
46
  * bounces that report this recipient are counted either way, because mailbox
47
47
  * health counts every one.
48
48
  *==========================================*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
49
- import { crmThreadSubject, emailAddressOf, htmlToPlainText } from "@aglyn/aglyn/app-utils/crm-inbound";
49
+ import { emailAddressOf, htmlToPlainText, threadSubject } from "@aglyn/aglyn/app-utils/email-text";
50
50
  import { readOutreachDeliveryReport, isMailerDaemonMessage } from "./delivery-status.js";
51
51
  import { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from "./opt-out-intent.js";
52
52
  import { outreachHeader } from "./thread-message.js";
@@ -161,8 +161,8 @@ function readableText(message) {
161
161
  // A subject is read only on a message that starts fresh — no `Re:`, and
162
162
  // not the thread's own — and only when it says nothing but stop.
163
163
  const subject = String((_message_subject = message.subject) != null ? _message_subject : '').replace(/\s+/g, ' ').trim();
164
- const fresh = crmThreadSubject(subject) === subject;
165
- if (subject && fresh && subject !== crmThreadSubject(context.threadSubject)) {
164
+ const fresh = threadSubject(subject) === subject;
165
+ if (subject && fresh && subject !== threadSubject(context.threadSubject)) {
166
166
  const subjectIntent = detectOutreachOptOutSubject(subject);
167
167
  if (subjectIntent.optOut) {
168
168
  return _extends({}, base, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/thread-classification.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 * WHAT CAME BACK (AGL-2979).\n *\n * The runtime reads each enrollment's threads and asks what the new\n * messages mean. Each message is one of:\n *\n * - `self` — the mailbox's own: a step, or the rep writing in the thread.\n * - `hard_bounce` / `soft_bounce` — a delivery report (`./delivery-status.ts`).\n * - `delivery_report` — a mail system's message that reports no failure.\n * - `auto_reply` — an out-of-office or an autoresponder. It POSTPONES the\n * next step and does not stop the sequence: nobody answered.\n * - `opt_out` — a reply that asks to be left alone (`./opt-out-intent.ts`),\n * or a message sent to the unsubscribe address.\n * - `reply` — any other message from anybody else.\n *\n * ## Automatic replies\n *\n * Read from the headers the standards give an autoresponder —\n * `Auto-Submitted` with any value but `no` (RFC 3834), `X-Autoreply`,\n * `X-Autorespond`, `Precedence: auto_reply`, `bulk` or `junk` — and, for\n * the servers that set none, from an out-of-office subject in the usual\n * languages.\n *\n * ## The thread's outcome\n *\n * `opted_out` outranks `replied`, which outranks `bounced`, which outranks\n * `postpone`: an opt-out is a reply that also has to reach the do-not-contact\n * list, a person who wrote back matters more than a report about an earlier\n * send, and an out-of-office only matters when nothing else happened. The\n * bounces that report this recipient are counted either way, because mailbox\n * health counts every one.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmThreadSubject, emailAddressOf, htmlToPlainText } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { readOutreachDeliveryReport, isMailerDaemonMessage } from './delivery-status'\nimport { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from './opt-out-intent'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachMessageKind =\n | 'self'\n | 'hard_bounce'\n | 'soft_bounce'\n | 'delivery_report'\n | 'auto_reply'\n | 'opt_out'\n | 'reply'\n\nexport interface OutreachBounceDetail {\n /** The addresses the report says failed; empty when it names none. */\n recipients: string[]\n status: string | null\n diagnostic: string | null\n /** The receiving server the report names, when it does (AGL-3326). */\n remoteMta: string | null\n}\n\nexport interface OutreachMessageClassification {\n messageId: string\n atMs: number\n kind: OutreachMessageKind\n /** The sender's address, normalized, when the `From` carried one. */\n from: string | null\n /** Set on a bounce. */\n bounce: OutreachBounceDetail | null\n /** An opt-out that calls the email spam. */\n complaint: boolean\n /** What decided it, for the timeline and the log. */\n evidence: string | null\n}\n\nexport interface OutreachMessageContext {\n /** The mailbox's own addresses: the account's and every alias it sends as. */\n selfAddresses: readonly string[]\n /** The address a `List-Unsubscribe` mailto points at, when the runtime set one. */\n unsubscribeAddress?: string | null\n /** The subject the thread was started with, so a new subject can be read on its own. */\n threadSubject?: string | null\n}\n\nconst AUTO_REPLY_SUBJECT = new RegExp(\n [\n '^\\\\s*\\\\[?\\\\s*(',\n 'auto(matic)?[\\\\s-]*(reply|response|responder|answer)',\n '|out[\\\\s-]+of[\\\\s-]+(the[\\\\s-]+)?office',\n '|ooo\\\\b',\n \"|i('m| am) (currently )?(out of the office|away|on (vacation|leave|holiday))\",\n '|(on )?(vacation|holiday|annual leave)[\\\\s-]*(reply|response|notice|message|autoreply)',\n '|away from (the |my )?(office|desk)',\n '|abwesenheitsnotiz|abwesenheit',\n '|r[ée]ponse automatique|absence du bureau',\n '|respuesta autom[áa]tica|fuera de la oficina',\n '|risposta automatica|fuori ufficio',\n '|automatisch antwoord|afwezigheidsbericht',\n '|resposta autom[áa]tica|fora do escrit[óo]rio',\n '|autosvar|automaattinen vastaus',\n ')',\n ].join(''),\n 'i',\n)\n\n/**\n * Why a message is an automatic reply, or `null` when it is not one — see\n * the module note for the headers and subjects read.\n */\nexport function outreachAutoReplyEvidence(\n message: Pick<OutreachThreadMessage, 'headers' | 'subject'>,\n): string | null {\n const autoSubmitted = outreachHeader(message, 'Auto-Submitted').split(';')[0].trim().toLowerCase()\n if (autoSubmitted && autoSubmitted !== 'no') return `Auto-Submitted: ${autoSubmitted}`\n for (const name of ['X-Autoreply', 'X-Autorespond']) {\n const value = outreachHeader(message, name).trim()\n if (value && value.toLowerCase() !== 'no') return `${name}: ${value}`\n }\n const precedence = outreachHeader(message, 'Precedence').trim().toLowerCase()\n if (['auto_reply', 'bulk', 'junk'].includes(precedence)) return `Precedence: ${precedence}`\n const subject = String(message?.subject ?? '').replace(/^\\s*(re|fwd?)\\s*:\\s*/i, '')\n const match = AUTO_REPLY_SUBJECT.exec(subject)\n return match ? `Subject: ${match[0].trim()}` : null\n}\n\nfunction addressesIn(header: string): string[] {\n return header\n .split(',')\n .map((part) => emailAddressOf(part))\n .filter((address): address is string => address !== null)\n}\n\nfunction readableText(message: OutreachThreadMessage): string {\n if (message.textBody && message.textBody.trim()) return message.textBody\n if (message.htmlBody && message.htmlBody.trim()) return htmlToPlainText(message.htmlBody)\n return message.snippet ?? ''\n}\n\n/** What one message in an enrollment's thread is — see the module note. */\nexport function classifyOutreachMessage(\n message: OutreachThreadMessage,\n context: OutreachMessageContext,\n): OutreachMessageClassification {\n const from = emailAddressOf(message.from)\n const base = {\n messageId: String(message.id ?? ''),\n atMs: Number.isFinite(message.internalDateMs) ? message.internalDateMs : 0,\n from,\n bounce: null,\n complaint: false,\n evidence: null,\n }\n\n const report = readOutreachDeliveryReport(message)\n if (report) {\n if (!report.kind) return { ...base, kind: 'delivery_report', evidence: 'delivery report' }\n return {\n ...base,\n kind: report.kind === 'hard' ? 'hard_bounce' : 'soft_bounce',\n bounce: {\n recipients: report.failedAddresses,\n status: report.status,\n diagnostic: report.diagnostic,\n remoteMta: report.remoteMta,\n },\n evidence: report.status ? `Status: ${report.status}` : 'delivery report',\n }\n }\n\n const self = new Set(\n (context.selfAddresses ?? [])\n .map((address) => normalizeContactEmail(address))\n .filter((address): address is string => address !== null),\n )\n if (from && self.has(from)) return { ...base, kind: 'self' }\n if (isMailerDaemonMessage(message)) {\n return { ...base, kind: 'delivery_report', evidence: 'mail system message' }\n }\n\n const unsubscribe = normalizeContactEmail(context.unsubscribeAddress)\n if (unsubscribe && addressesIn(message.to ?? '').includes(unsubscribe)) {\n return { ...base, kind: 'opt_out', evidence: `To: ${unsubscribe}` }\n }\n\n const autoReply = outreachAutoReplyEvidence(message)\n if (autoReply) return { ...base, kind: 'auto_reply', evidence: autoReply }\n\n const intent = detectOutreachOptOutIntent(readableText(message))\n if (intent.optOut) {\n return { ...base, kind: 'opt_out', complaint: intent.complaint, evidence: intent.matched }\n }\n // A subject is read only on a message that starts fresh — no `Re:`, and\n // not the thread's own — and only when it says nothing but stop.\n const subject = String(message.subject ?? '').replace(/\\s+/g, ' ').trim()\n const fresh = crmThreadSubject(subject) === subject\n if (subject && fresh && subject !== crmThreadSubject(context.threadSubject)) {\n const subjectIntent = detectOutreachOptOutSubject(subject)\n if (subjectIntent.optOut) {\n return { ...base, kind: 'opt_out', evidence: `Subject: ${subjectIntent.matched}` }\n }\n }\n return { ...base, kind: 'reply' }\n}\n\nexport type OutreachThreadOutcome = 'none' | 'postpone' | 'bounced' | 'replied' | 'opted_out'\n\nexport interface OutreachThreadDecisionInput extends OutreachMessageContext {\n /** The thread's messages, in any order. */\n messages: readonly OutreachThreadMessage[]\n /** The enrollment's recipient. */\n recipient: string\n /** Messages received at or before this instant were handled by an earlier run. */\n afterMs?: number | null\n /** Ids of messages an earlier run handled. */\n handledMessageIds?: readonly string[]\n}\n\nexport interface OutreachThreadDecision {\n outcome: OutreachThreadOutcome\n /** The message that decided the outcome; `null` for `none`. */\n decidedBy: OutreachMessageClassification | null\n /** Every new message, classified, oldest first. */\n classifications: OutreachMessageClassification[]\n /** A new reply called the email spam: the mailbox is paused as well. */\n complaint: boolean\n /** New hard bounces that report this recipient. */\n hardBounces: number\n /** New soft bounces that report this recipient. */\n softBounces: number\n}\n\nconst RANK: Record<OutreachThreadOutcome, number> = {\n none: 0,\n postpone: 1,\n bounced: 2,\n replied: 3,\n opted_out: 4,\n}\n\nconst OUTCOME_OF: Partial<Record<OutreachMessageKind, OutreachThreadOutcome>> = {\n auto_reply: 'postpone',\n hard_bounce: 'bounced',\n reply: 'replied',\n opt_out: 'opted_out',\n}\n\n/** What the new messages in an enrollment's thread mean for it — see the module note. */\nexport function decideOutreachThread(input: OutreachThreadDecisionInput): OutreachThreadDecision {\n const recipient = normalizeContactEmail(input.recipient)\n const handled = new Set(input.handledMessageIds ?? [])\n const afterMs = typeof input.afterMs === 'number' ? input.afterMs : null\n const fresh = [...(input.messages ?? [])]\n .filter((message) => message && !handled.has(message.id))\n .filter((message) => afterMs === null || message.internalDateMs > afterMs)\n .sort((a, b) => a.internalDateMs - b.internalDateMs)\n\n const decision: OutreachThreadDecision = {\n outcome: 'none',\n decidedBy: null,\n classifications: [],\n complaint: false,\n hardBounces: 0,\n softBounces: 0,\n }\n for (const message of fresh) {\n const classification = classifyOutreachMessage(message, input)\n decision.classifications.push(classification)\n const bounce = classification.bounce\n const aboutRecipient =\n !bounce || !bounce.recipients.length || (recipient !== null && bounce.recipients.includes(recipient))\n if (classification.kind === 'hard_bounce' && aboutRecipient) decision.hardBounces += 1\n if (classification.kind === 'soft_bounce' && aboutRecipient) decision.softBounces += 1\n if (classification.complaint) decision.complaint = true\n const outcome = OUTCOME_OF[classification.kind]\n if (!outcome || (classification.kind === 'hard_bounce' && !aboutRecipient)) continue\n if (RANK[outcome] > RANK[decision.outcome]) {\n decision.outcome = outcome\n decision.decidedBy = classification\n }\n }\n return decision\n}\n"],"names":["normalizeContactEmail","crmThreadSubject","emailAddressOf","htmlToPlainText","readOutreachDeliveryReport","isMailerDaemonMessage","detectOutreachOptOutIntent","detectOutreachOptOutSubject","outreachHeader","AUTO_REPLY_SUBJECT","RegExp","join","outreachAutoReplyEvidence","message","autoSubmitted","split","trim","toLowerCase","name","value","precedence","includes","subject","String","replace","match","exec","addressesIn","header","map","part","filter","address","readableText","textBody","htmlBody","snippet","classifyOutreachMessage","context","from","base","messageId","id","atMs","Number","isFinite","internalDateMs","bounce","complaint","evidence","report","kind","recipients","failedAddresses","status","diagnostic","remoteMta","self","Set","selfAddresses","has","unsubscribe","unsubscribeAddress","to","autoReply","intent","optOut","matched","fresh","threadSubject","subjectIntent","RANK","none","postpone","bounced","replied","opted_out","OUTCOME_OF","auto_reply","hard_bounce","reply","opt_out","decideOutreachThread","input","recipient","handled","handledMessageIds","afterMs","messages","sort","a","b","decision","outcome","decidedBy","classifications","hardBounces","softBounces","classification","push","aboutRecipient","length"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/thread-classification.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 * WHAT CAME BACK (AGL-2979).\n *\n * The runtime reads each enrollment's threads and asks what the new\n * messages mean. Each message is one of:\n *\n * - `self` — the mailbox's own: a step, or the rep writing in the thread.\n * - `hard_bounce` / `soft_bounce` — a delivery report (`./delivery-status.ts`).\n * - `delivery_report` — a mail system's message that reports no failure.\n * - `auto_reply` — an out-of-office or an autoresponder. It POSTPONES the\n * next step and does not stop the sequence: nobody answered.\n * - `opt_out` — a reply that asks to be left alone (`./opt-out-intent.ts`),\n * or a message sent to the unsubscribe address.\n * - `reply` — any other message from anybody else.\n *\n * ## Automatic replies\n *\n * Read from the headers the standards give an autoresponder —\n * `Auto-Submitted` with any value but `no` (RFC 3834), `X-Autoreply`,\n * `X-Autorespond`, `Precedence: auto_reply`, `bulk` or `junk` — and, for\n * the servers that set none, from an out-of-office subject in the usual\n * languages.\n *\n * ## The thread's outcome\n *\n * `opted_out` outranks `replied`, which outranks `bounced`, which outranks\n * `postpone`: an opt-out is a reply that also has to reach the do-not-contact\n * list, a person who wrote back matters more than a report about an earlier\n * send, and an out-of-office only matters when nothing else happened. The\n * bounces that report this recipient are counted either way, because mailbox\n * health counts every one.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { emailAddressOf, htmlToPlainText, threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport { readOutreachDeliveryReport, isMailerDaemonMessage } from './delivery-status'\nimport { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from './opt-out-intent'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachMessageKind =\n | 'self'\n | 'hard_bounce'\n | 'soft_bounce'\n | 'delivery_report'\n | 'auto_reply'\n | 'opt_out'\n | 'reply'\n\nexport interface OutreachBounceDetail {\n /** The addresses the report says failed; empty when it names none. */\n recipients: string[]\n status: string | null\n diagnostic: string | null\n /** The receiving server the report names, when it does (AGL-3326). */\n remoteMta: string | null\n}\n\nexport interface OutreachMessageClassification {\n messageId: string\n atMs: number\n kind: OutreachMessageKind\n /** The sender's address, normalized, when the `From` carried one. */\n from: string | null\n /** Set on a bounce. */\n bounce: OutreachBounceDetail | null\n /** An opt-out that calls the email spam. */\n complaint: boolean\n /** What decided it, for the timeline and the log. */\n evidence: string | null\n}\n\nexport interface OutreachMessageContext {\n /** The mailbox's own addresses: the account's and every alias it sends as. */\n selfAddresses: readonly string[]\n /** The address a `List-Unsubscribe` mailto points at, when the runtime set one. */\n unsubscribeAddress?: string | null\n /** The subject the thread was started with, so a new subject can be read on its own. */\n threadSubject?: string | null\n}\n\nconst AUTO_REPLY_SUBJECT = new RegExp(\n [\n '^\\\\s*\\\\[?\\\\s*(',\n 'auto(matic)?[\\\\s-]*(reply|response|responder|answer)',\n '|out[\\\\s-]+of[\\\\s-]+(the[\\\\s-]+)?office',\n '|ooo\\\\b',\n \"|i('m| am) (currently )?(out of the office|away|on (vacation|leave|holiday))\",\n '|(on )?(vacation|holiday|annual leave)[\\\\s-]*(reply|response|notice|message|autoreply)',\n '|away from (the |my )?(office|desk)',\n '|abwesenheitsnotiz|abwesenheit',\n '|r[ée]ponse automatique|absence du bureau',\n '|respuesta autom[áa]tica|fuera de la oficina',\n '|risposta automatica|fuori ufficio',\n '|automatisch antwoord|afwezigheidsbericht',\n '|resposta autom[áa]tica|fora do escrit[óo]rio',\n '|autosvar|automaattinen vastaus',\n ')',\n ].join(''),\n 'i',\n)\n\n/**\n * Why a message is an automatic reply, or `null` when it is not one — see\n * the module note for the headers and subjects read.\n */\nexport function outreachAutoReplyEvidence(\n message: Pick<OutreachThreadMessage, 'headers' | 'subject'>,\n): string | null {\n const autoSubmitted = outreachHeader(message, 'Auto-Submitted').split(';')[0].trim().toLowerCase()\n if (autoSubmitted && autoSubmitted !== 'no') return `Auto-Submitted: ${autoSubmitted}`\n for (const name of ['X-Autoreply', 'X-Autorespond']) {\n const value = outreachHeader(message, name).trim()\n if (value && value.toLowerCase() !== 'no') return `${name}: ${value}`\n }\n const precedence = outreachHeader(message, 'Precedence').trim().toLowerCase()\n if (['auto_reply', 'bulk', 'junk'].includes(precedence)) return `Precedence: ${precedence}`\n const subject = String(message?.subject ?? '').replace(/^\\s*(re|fwd?)\\s*:\\s*/i, '')\n const match = AUTO_REPLY_SUBJECT.exec(subject)\n return match ? `Subject: ${match[0].trim()}` : null\n}\n\nfunction addressesIn(header: string): string[] {\n return header\n .split(',')\n .map((part) => emailAddressOf(part))\n .filter((address): address is string => address !== null)\n}\n\nfunction readableText(message: OutreachThreadMessage): string {\n if (message.textBody && message.textBody.trim()) return message.textBody\n if (message.htmlBody && message.htmlBody.trim()) return htmlToPlainText(message.htmlBody)\n return message.snippet ?? ''\n}\n\n/** What one message in an enrollment's thread is — see the module note. */\nexport function classifyOutreachMessage(\n message: OutreachThreadMessage,\n context: OutreachMessageContext,\n): OutreachMessageClassification {\n const from = emailAddressOf(message.from)\n const base = {\n messageId: String(message.id ?? ''),\n atMs: Number.isFinite(message.internalDateMs) ? message.internalDateMs : 0,\n from,\n bounce: null,\n complaint: false,\n evidence: null,\n }\n\n const report = readOutreachDeliveryReport(message)\n if (report) {\n if (!report.kind) return { ...base, kind: 'delivery_report', evidence: 'delivery report' }\n return {\n ...base,\n kind: report.kind === 'hard' ? 'hard_bounce' : 'soft_bounce',\n bounce: {\n recipients: report.failedAddresses,\n status: report.status,\n diagnostic: report.diagnostic,\n remoteMta: report.remoteMta,\n },\n evidence: report.status ? `Status: ${report.status}` : 'delivery report',\n }\n }\n\n const self = new Set(\n (context.selfAddresses ?? [])\n .map((address) => normalizeContactEmail(address))\n .filter((address): address is string => address !== null),\n )\n if (from && self.has(from)) return { ...base, kind: 'self' }\n if (isMailerDaemonMessage(message)) {\n return { ...base, kind: 'delivery_report', evidence: 'mail system message' }\n }\n\n const unsubscribe = normalizeContactEmail(context.unsubscribeAddress)\n if (unsubscribe && addressesIn(message.to ?? '').includes(unsubscribe)) {\n return { ...base, kind: 'opt_out', evidence: `To: ${unsubscribe}` }\n }\n\n const autoReply = outreachAutoReplyEvidence(message)\n if (autoReply) return { ...base, kind: 'auto_reply', evidence: autoReply }\n\n const intent = detectOutreachOptOutIntent(readableText(message))\n if (intent.optOut) {\n return { ...base, kind: 'opt_out', complaint: intent.complaint, evidence: intent.matched }\n }\n // A subject is read only on a message that starts fresh — no `Re:`, and\n // not the thread's own — and only when it says nothing but stop.\n const subject = String(message.subject ?? '').replace(/\\s+/g, ' ').trim()\n const fresh = threadSubject(subject) === subject\n if (subject && fresh && subject !== threadSubject(context.threadSubject)) {\n const subjectIntent = detectOutreachOptOutSubject(subject)\n if (subjectIntent.optOut) {\n return { ...base, kind: 'opt_out', evidence: `Subject: ${subjectIntent.matched}` }\n }\n }\n return { ...base, kind: 'reply' }\n}\n\nexport type OutreachThreadOutcome = 'none' | 'postpone' | 'bounced' | 'replied' | 'opted_out'\n\nexport interface OutreachThreadDecisionInput extends OutreachMessageContext {\n /** The thread's messages, in any order. */\n messages: readonly OutreachThreadMessage[]\n /** The enrollment's recipient. */\n recipient: string\n /** Messages received at or before this instant were handled by an earlier run. */\n afterMs?: number | null\n /** Ids of messages an earlier run handled. */\n handledMessageIds?: readonly string[]\n}\n\nexport interface OutreachThreadDecision {\n outcome: OutreachThreadOutcome\n /** The message that decided the outcome; `null` for `none`. */\n decidedBy: OutreachMessageClassification | null\n /** Every new message, classified, oldest first. */\n classifications: OutreachMessageClassification[]\n /** A new reply called the email spam: the mailbox is paused as well. */\n complaint: boolean\n /** New hard bounces that report this recipient. */\n hardBounces: number\n /** New soft bounces that report this recipient. */\n softBounces: number\n}\n\nconst RANK: Record<OutreachThreadOutcome, number> = {\n none: 0,\n postpone: 1,\n bounced: 2,\n replied: 3,\n opted_out: 4,\n}\n\nconst OUTCOME_OF: Partial<Record<OutreachMessageKind, OutreachThreadOutcome>> = {\n auto_reply: 'postpone',\n hard_bounce: 'bounced',\n reply: 'replied',\n opt_out: 'opted_out',\n}\n\n/** What the new messages in an enrollment's thread mean for it — see the module note. */\nexport function decideOutreachThread(input: OutreachThreadDecisionInput): OutreachThreadDecision {\n const recipient = normalizeContactEmail(input.recipient)\n const handled = new Set(input.handledMessageIds ?? [])\n const afterMs = typeof input.afterMs === 'number' ? input.afterMs : null\n const fresh = [...(input.messages ?? [])]\n .filter((message) => message && !handled.has(message.id))\n .filter((message) => afterMs === null || message.internalDateMs > afterMs)\n .sort((a, b) => a.internalDateMs - b.internalDateMs)\n\n const decision: OutreachThreadDecision = {\n outcome: 'none',\n decidedBy: null,\n classifications: [],\n complaint: false,\n hardBounces: 0,\n softBounces: 0,\n }\n for (const message of fresh) {\n const classification = classifyOutreachMessage(message, input)\n decision.classifications.push(classification)\n const bounce = classification.bounce\n const aboutRecipient =\n !bounce || !bounce.recipients.length || (recipient !== null && bounce.recipients.includes(recipient))\n if (classification.kind === 'hard_bounce' && aboutRecipient) decision.hardBounces += 1\n if (classification.kind === 'soft_bounce' && aboutRecipient) decision.softBounces += 1\n if (classification.complaint) decision.complaint = true\n const outcome = OUTCOME_OF[classification.kind]\n if (!outcome || (classification.kind === 'hard_bounce' && !aboutRecipient)) continue\n if (RANK[outcome] > RANK[decision.outcome]) {\n decision.outcome = outcome\n decision.decidedBy = classification\n }\n }\n return decision\n}\n"],"names":["normalizeContactEmail","emailAddressOf","htmlToPlainText","threadSubject","readOutreachDeliveryReport","isMailerDaemonMessage","detectOutreachOptOutIntent","detectOutreachOptOutSubject","outreachHeader","AUTO_REPLY_SUBJECT","RegExp","join","outreachAutoReplyEvidence","message","autoSubmitted","split","trim","toLowerCase","name","value","precedence","includes","subject","String","replace","match","exec","addressesIn","header","map","part","filter","address","readableText","textBody","htmlBody","snippet","classifyOutreachMessage","context","from","base","messageId","id","atMs","Number","isFinite","internalDateMs","bounce","complaint","evidence","report","kind","recipients","failedAddresses","status","diagnostic","remoteMta","self","Set","selfAddresses","has","unsubscribe","unsubscribeAddress","to","autoReply","intent","optOut","matched","fresh","subjectIntent","RANK","none","postpone","bounced","replied","opted_out","OUTCOME_OF","auto_reply","hard_bounce","reply","opt_out","decideOutreachThread","input","recipient","handled","handledMessageIds","afterMs","messages","sort","a","b","decision","outcome","decidedBy","classifications","hardBounces","softBounces","classification","push","aboutRecipient","length"],"mappings":"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"}