@aglyn/plugins-outreach 1.0.0-beta.213 → 1.0.0-beta.214

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 (62) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/enrollment-detail.js +13 -3
  3. package/src/lib/components/enrollment-detail.js.map +1 -1
  4. package/src/lib/components/sequence-editor.js +23 -1
  5. package/src/lib/components/sequence-editor.js.map +1 -1
  6. package/src/lib/components/sequence-report-card.d.ts +3 -2
  7. package/src/lib/components/sequence-report-card.js +29 -1
  8. package/src/lib/components/sequence-report-card.js.map +1 -1
  9. package/src/lib/engine/click-tracking.d.ts +9 -0
  10. package/src/lib/engine/click-tracking.js +2 -2
  11. package/src/lib/engine/click-tracking.js.map +1 -1
  12. package/src/lib/engine/compose.d.ts +13 -0
  13. package/src/lib/engine/compose.js +4 -0
  14. package/src/lib/engine/compose.js.map +1 -1
  15. package/src/lib/engine/open-tracking.d.ts +61 -0
  16. package/src/lib/engine/open-tracking.js +164 -0
  17. package/src/lib/engine/open-tracking.js.map +1 -0
  18. package/src/lib/engine/sequence-validation.js +3 -0
  19. package/src/lib/engine/sequence-validation.js.map +1 -1
  20. package/src/lib/model/enrollment-engagement.d.ts +6 -0
  21. package/src/lib/model/enrollment-engagement.js +17 -1
  22. package/src/lib/model/enrollment-engagement.js.map +1 -1
  23. package/src/lib/model/enrollment-history.d.ts +8 -0
  24. package/src/lib/model/enrollment-history.js +29 -0
  25. package/src/lib/model/enrollment-history.js.map +1 -1
  26. package/src/lib/model/enrollment-timeline.d.ts +7 -1
  27. package/src/lib/model/enrollment-timeline.js +41 -4
  28. package/src/lib/model/enrollment-timeline.js.map +1 -1
  29. package/src/lib/model/outreach.types.d.ts +71 -4
  30. package/src/lib/model/outreach.types.js +3 -2
  31. package/src/lib/model/outreach.types.js.map +1 -1
  32. package/src/lib/model/sequence-draft.js +3 -0
  33. package/src/lib/model/sequence-draft.js.map +1 -1
  34. package/src/lib/model/sequence-report.d.ts +25 -2
  35. package/src/lib/model/sequence-report.js +36 -10
  36. package/src/lib/model/sequence-report.js.map +1 -1
  37. package/src/lib/model/step-preview.d.ts +6 -0
  38. package/src/lib/model/step-preview.js +3 -2
  39. package/src/lib/model/step-preview.js.map +1 -1
  40. package/src/lib/model/stored-records.js +9 -1
  41. package/src/lib/model/stored-records.js.map +1 -1
  42. package/src/lib/routes/step-test-routes.js +40 -12
  43. package/src/lib/routes/step-test-routes.js.map +1 -1
  44. package/src/lib/runtime/click-events.d.ts +2 -0
  45. package/src/lib/runtime/click-events.js +6 -6
  46. package/src/lib/runtime/click-events.js.map +1 -1
  47. package/src/lib/runtime/click-link.d.ts +25 -0
  48. package/src/lib/runtime/click-link.js +49 -0
  49. package/src/lib/runtime/click-link.js.map +1 -1
  50. package/src/lib/runtime/click-route.d.ts +4 -0
  51. package/src/lib/runtime/click-route.js +46 -3
  52. package/src/lib/runtime/click-route.js.map +1 -1
  53. package/src/lib/runtime/open-events.d.ts +55 -0
  54. package/src/lib/runtime/open-events.js +121 -0
  55. package/src/lib/runtime/open-events.js.map +1 -0
  56. package/src/lib/runtime/send-job.js +44 -8
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/transport/rfc5322.d.ts +14 -3
  59. package/src/lib/transport/rfc5322.js +44 -9
  60. package/src/lib/transport/rfc5322.js.map +1 -1
  61. package/src/lib/transport/send-message.js +2 -1
  62. package/src/lib/transport/send-message.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.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 EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *\n * ## A curated step\n *\n * An enrollment may carry its own copy of a step (AGL-3324): a subject and\n * a body drafted for that one person. The composer reads the copy in place\n * of the step's subject and body — and of the template the step names —\n * and then does everything it does to a step: the merge, the threading, the\n * click tracking, the footer. A curated in-thread email still goes out as\n * `Re:` the thread's subject, since it answers the same thread.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n OutreachStepOverride,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n /**\n * The destinations whose links were rewritten for click tracking, in the\n * order a click token indexes them (AGL-3239). Empty when the sequence\n * does not track clicks, when the body carries no links, and when no\n * tracking link could be minted.\n */\n trackedLinks: string[]\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n > &\n Partial<Pick<OutreachEnrollment, 'stepOverrides'>>\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n /**\n * Answers the tracking link for one link in the body, or `null` to leave\n * that one as it was written (AGL-3239).\n *\n * Absent leaves every link alone, which is what a sequence with\n * `trackClicks` off passes and what every caller that is not the sending\n * runtime passes — a PREVIEW must show the rep the body their recipient\n * reads, and minting a live tracking link for a preview would count the\n * rep's own click against the sequence.\n *\n * It runs on the step's rendered body and never on the footer, so the\n * organization's identification and the way out are never rewritten.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n /**\n * The person's own copy of the step the email was written from\n * (AGL-3324), or `null` when the step was sent as the sequence wrote it.\n */\n override: OutreachStepOverride | null\n}\n\n/**\n * The enrollment's own copy of the step at `stepIndex` (AGL-3324), or\n * `null`. A copy with neither a subject nor a body is no copy.\n */\nexport function outreachStepOverrideFor(\n enrollment: Partial<Pick<OutreachEnrollment, 'stepOverrides'>> | null | undefined,\n stepIndex: number,\n): OutreachStepOverride | null {\n const override = enrollment?.stepOverrides?.[String(stepIndex)]\n if (!override || typeof override !== 'object') return null\n const hasSubject = typeof override.subject === 'string'\n const hasBody = typeof override.body === 'string'\n return hasSubject || hasBody ? override : null\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [], override: null })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [], override: null }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n // This person's own copy of the step (AGL-3324), read in place of the\n // step's own words — and of its template — from here on.\n const override = outreachStepOverrideFor(input.enrollment, stepIndex)\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '', trackedLinks: [] }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(override?.subject ?? step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source =\n typeof override?.body === 'string'\n ? override.body\n : step.templateId\n ? String(input.templateBody ?? '')\n : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n /*\n * Click tracking rewrites the STEP'S links and nothing else (AGL-3239):\n * it runs here, on the rendered body, before the footer is appended. The\n * footer's opt-out `mailto:` and the organization's own identification are\n * never rewritten, and the one-click unsubscribe link never appears in a\n * body at all — it rides in `List-Unsubscribe`.\n */\n const tracked = input.rewriteLink\n ? rewriteOutreachBodyLinks(body, input.rewriteLink)\n : { text: body, links: [] }\n email.trackedLinks = tracked.links\n email.text = `${tracked.text}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved, override }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","rewriteOutreachBodyLinks","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","outreachStepOverrideFor","enrollment","stepIndex","override","stepOverrides","String","hasSubject","subject","hasBody","body","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","trackedLinks","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","tracked","rewriteLink","links","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.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 EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *\n * ## A curated step\n *\n * An enrollment may carry its own copy of a step (AGL-3324): a subject and\n * a body drafted for that one person. The composer reads the copy in place\n * of the step's subject and body — and of the template the step names —\n * and then does everything it does to a step: the merge, the threading, the\n * click tracking, the footer. A curated in-thread email still goes out as\n * `Re:` the thread's subject, since it answers the same thread.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n OutreachStepOverride,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { outreachOpenTrackedHtml } from './open-tracking'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /**\n * The same body as HTML with a tracking image (AGL-3395), sent beside\n * `text` as `multipart/alternative`. Absent on every email of a sequence\n * that does not count opens, which goes out as plain text alone.\n */\n html?: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n /**\n * The destinations whose links were rewritten for click tracking, in the\n * order a click token indexes them (AGL-3239). Empty when the sequence\n * does not track clicks, when the body carries no links, and when no\n * tracking link could be minted.\n */\n trackedLinks: string[]\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n > &\n Partial<Pick<OutreachEnrollment, 'stepOverrides'>>\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n /**\n * Answers the tracking link for one link in the body, or `null` to leave\n * that one as it was written (AGL-3239).\n *\n * Absent leaves every link alone, which is what a sequence with\n * `trackClicks` off passes and what every caller that is not the sending\n * runtime passes — a PREVIEW must show the rep the body their recipient\n * reads, and minting a live tracking link for a preview would count the\n * rep's own click against the sequence.\n *\n * It runs on the step's rendered body and never on the footer, so the\n * organization's identification and the way out are never rewritten.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n /**\n * The tracking image's address, minted by the runtime for a sequence that\n * counts opens (AGL-3395). Given, the email gains an HTML part — the same\n * text, footer included, and the image; absent or `null`, it stays plain\n * text, which is what a preview and every other caller pass.\n */\n openPixelUrl?: string | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n /**\n * The person's own copy of the step the email was written from\n * (AGL-3324), or `null` when the step was sent as the sequence wrote it.\n */\n override: OutreachStepOverride | null\n}\n\n/**\n * The enrollment's own copy of the step at `stepIndex` (AGL-3324), or\n * `null`. A copy with neither a subject nor a body is no copy.\n */\nexport function outreachStepOverrideFor(\n enrollment: Partial<Pick<OutreachEnrollment, 'stepOverrides'>> | null | undefined,\n stepIndex: number,\n): OutreachStepOverride | null {\n const override = enrollment?.stepOverrides?.[String(stepIndex)]\n if (!override || typeof override !== 'object') return null\n const hasSubject = typeof override.subject === 'string'\n const hasBody = typeof override.body === 'string'\n return hasSubject || hasBody ? override : null\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [], override: null })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [], override: null }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n // This person's own copy of the step (AGL-3324), read in place of the\n // step's own words — and of its template — from here on.\n const override = outreachStepOverrideFor(input.enrollment, stepIndex)\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '', trackedLinks: [] }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(override?.subject ?? step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source =\n typeof override?.body === 'string'\n ? override.body\n : step.templateId\n ? String(input.templateBody ?? '')\n : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n /*\n * Click tracking rewrites the STEP'S links and nothing else (AGL-3239):\n * it runs here, on the rendered body, before the footer is appended. The\n * footer's opt-out `mailto:` and the organization's own identification are\n * never rewritten, and the one-click unsubscribe link never appears in a\n * body at all — it rides in `List-Unsubscribe`.\n */\n const tracked = input.rewriteLink\n ? rewriteOutreachBodyLinks(body, input.rewriteLink)\n : { text: body, links: [] }\n email.trackedLinks = tracked.links\n email.text = `${tracked.text}\\n\\n${footer}`\n // Built from the finished text, so the HTML part says exactly what the\n // plain-text part says — the footer and any rewritten link included.\n if (input.openPixelUrl) email.html = outreachOpenTrackedHtml(email.text, input.openPixelUrl)\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved, override }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","rewriteOutreachBodyLinks","outreachOpenTrackedHtml","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","outreachStepOverrideFor","enrollment","stepIndex","override","stepOverrides","String","hasSubject","subject","hasBody","body","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","trackedLinks","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","tracked","rewriteLink","links","openPixelUrl","html","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Text safe to place in HTML content or a double-quoted attribute. */
18
+ export declare function escapeOutreachHtml(value: string): string;
19
+ /**
20
+ * The HTML part for a plain-text email and its tracking image.
21
+ *
22
+ * @param text The plain-text body as it is sent, footer included — so the
23
+ * HTML part carries the organization's identification and the way out
24
+ * word for word, and any link click tracking already rewrote stays the
25
+ * rewritten link.
26
+ * @param pixelUrl The image's address. Written into a quoted attribute
27
+ * after escaping, so no value can close the attribute.
28
+ */
29
+ export declare function outreachOpenTrackedHtml(text: string, pixelUrl: string): string;
30
+ /**
31
+ * How soon after a send an open is a machine's however it identifies
32
+ * itself — the click window's reasoning, for the same reason: a proxy or a
33
+ * gateway fetches as the message is delivered, and a person reading a cold
34
+ * email does not.
35
+ */
36
+ export declare const OUTREACH_OPEN_HUMAN_DELAY_MS = 30000;
37
+ /** Why an open was read as a machine's. */
38
+ export type OutreachOpenMachineReason = 'agent' | 'too_soon' | 'method' | 'image_proxy' | 'privacy_proxy';
39
+ /** The reasons that are a mail provider's proxy rather than a scanner. */
40
+ export declare const OUTREACH_PROXY_OPEN_REASONS: readonly OutreachOpenMachineReason[];
41
+ /** What one fetch of a tracking image was. */
42
+ export interface OutreachOpenJudgement {
43
+ /** Whether it counts toward the open rate. */
44
+ human: boolean;
45
+ /** Why it does not; `null` when it does. */
46
+ machineReason: OutreachOpenMachineReason | null;
47
+ }
48
+ /**
49
+ * Whether one fetch of a tracking image is a person's open.
50
+ *
51
+ * Read before the open is recorded, never before the image is answered:
52
+ * every fetch gets the same image.
53
+ *
54
+ * @param input.sinceSentMs Milliseconds between the send and this fetch, or
55
+ * `null` when the send it belongs to could not be found.
56
+ */
57
+ export declare function judgeOutreachOpen(input: {
58
+ method: string;
59
+ userAgent: string | null | undefined;
60
+ sinceSentMs: number | null;
61
+ }): OutreachOpenJudgement;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { OUTREACH_MACHINE_AGENTS, outreachBodyLinks } from "./click-tracking.js";
17
+ /*==========================================
18
+ * OPENS, WHEN A SEQUENCE ASKS FOR THEM (AGL-3395).
19
+ *
20
+ * A sequence email is plain text, and a plain-text email cannot report an
21
+ * open: an open is a fetch of an image, and an image needs an HTML part
22
+ * (`./click-tracking.ts` says why that is the default). A sequence that
23
+ * turns on "Count opens" accepts the cost of one, for the sends it makes
24
+ * from then on.
25
+ *
26
+ * ## What the HTML part is
27
+ *
28
+ * The plain-text part is sent exactly as it would have been. The HTML part
29
+ * beside it is the SAME TEXT — escaped, its line breaks as `<br>`, its bare
30
+ * links made into anchors to the same address — with a 1×1 image at the end.
31
+ * No styling, no layout, no second copy of anything: a client that shows
32
+ * the HTML part shows what the plain-text part says, in the reader's own
33
+ * font, and the image is the only thing the text does not carry.
34
+ *
35
+ * The image is a short link (AGL-3297) on the same host the clicks use, so
36
+ * the email names no address a click does not already name.
37
+ *
38
+ * ## What it costs
39
+ *
40
+ * A one-to-one email from a person has no HTML part; one with a remote
41
+ * image reads, to a filter, as marketing. Some gateways score it. That is
42
+ * why the setting is per sequence, off by default, and said in the editor
43
+ * where it is turned on.
44
+ *==========================================*/ /** Every character HTML gives a meaning to, as the entity that does not. */ const HTML_ESCAPES = {
45
+ '&': '&amp;',
46
+ '<': '&lt;',
47
+ '>': '&gt;',
48
+ '"': '&quot;',
49
+ "'": '&#39;'
50
+ };
51
+ /** Text safe to place in HTML content or a double-quoted attribute. */ export function escapeOutreachHtml(value) {
52
+ return String(value != null ? value : '').replace(/[&<>"']/g, (character)=>{
53
+ var _HTML_ESCAPES_character;
54
+ return (_HTML_ESCAPES_character = HTML_ESCAPES[character]) != null ? _HTML_ESCAPES_character : character;
55
+ });
56
+ }
57
+ /** One run of text, escaped, with its line breaks as `<br>`. */ const escapedLines = (text)=>escapeOutreachHtml(text).replace(/\r\n|\r|\n/g, '<br>\n');
58
+ /**
59
+ * The HTML part for a plain-text email and its tracking image.
60
+ *
61
+ * @param text The plain-text body as it is sent, footer included — so the
62
+ * HTML part carries the organization's identification and the way out
63
+ * word for word, and any link click tracking already rewrote stays the
64
+ * rewritten link.
65
+ * @param pixelUrl The image's address. Written into a quoted attribute
66
+ * after escaping, so no value can close the attribute.
67
+ */ export function outreachOpenTrackedHtml(text, pixelUrl) {
68
+ const body = String(text != null ? text : '');
69
+ let html = '';
70
+ let cursor = 0;
71
+ // Linked by the same reader click tracking rewrites by, so a link the
72
+ // plain text offers is exactly a link the HTML offers.
73
+ for (const link of outreachBodyLinks(body)){
74
+ html += escapedLines(body.slice(cursor, link.start));
75
+ const href = escapeOutreachHtml(link.url);
76
+ html += `<a href="${href}">${href}</a>`;
77
+ cursor = link.end;
78
+ }
79
+ html += escapedLines(body.slice(cursor));
80
+ const src = escapeOutreachHtml(pixelUrl);
81
+ return '<!DOCTYPE html>\n<html>\n<head><meta charset="utf-8"></head>\n<body>\n' + `<div dir="auto">${html}</div>\n` + `<img src="${src}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0">\n` + '</body>\n</html>\n';
82
+ }
83
+ /*==========================================
84
+ * WHO OPENED: A PERSON, OR A MACHINE.
85
+ *
86
+ * The same judgement scanner clicks get (`./click-tracking.ts`), with the
87
+ * machines that fetch images rather than links:
88
+ *
89
+ * - **Apple Mail Privacy Protection** fetches every remote image of every
90
+ * message through Apple's proxy when the message arrives, whether or not
91
+ * anyone reads it. Its fetch names itself only as `Mozilla/5.0`.
92
+ * - **Yahoo's image proxy** fetches on the provider's behalf, and is set
93
+ * apart as a proxy.
94
+ *
95
+ * Gmail's `GoogleImageProxy` is NOT a machine here. Gmail fetches through it
96
+ * only when the reader opens the message — it does not prefetch on delivery
97
+ * — so outside the delivery window its fetch is the reader's open, and
98
+ * setting it apart would leave the rate blind to every Gmail and Workspace
99
+ * reader. The proxy caches the image, so a reader's later opens may not
100
+ * arrive at all; the rate is taken over first opens per person either way.
101
+ * - **Gateways and scanners** that open the message to inspect it, which
102
+ * name themselves as the click scanners do, or arrive within seconds of
103
+ * delivery.
104
+ *
105
+ * Counted apart, never dropped: `machineOpens` beside `opens`, as
106
+ * `machineClicks` beside `clicks`.
107
+ *==========================================*/ /**
108
+ * How soon after a send an open is a machine's however it identifies
109
+ * itself — the click window's reasoning, for the same reason: a proxy or a
110
+ * gateway fetches as the message is delivered, and a person reading a cold
111
+ * email does not.
112
+ */ export const OUTREACH_OPEN_HUMAN_DELAY_MS = 30000;
113
+ /** Image proxies that fetch on a mail provider's behalf, matched as substrings of the agent. */ const IMAGE_PROXY_AGENTS = [
114
+ 'yahoomailproxy'
115
+ ];
116
+ /**
117
+ * Gmail's image proxy, which fetches when the reader opens the message and
118
+ * so counts as their open — see the note above. It names itself in an agent
119
+ * the scanner list would otherwise match (`googleimageproxy`).
120
+ */ const GMAIL_PROXY_AGENTS = [
121
+ 'googleimageproxy',
122
+ 'ggpht.com'
123
+ ];
124
+ /**
125
+ * The agent Apple's Mail Privacy Protection proxy sends: the bare product
126
+ * token, with no platform after it — which no browser or mail client that
127
+ * shows an image to a person sends.
128
+ */ const APPLE_PRIVACY_AGENT = 'mozilla/5.0';
129
+ /** The reasons that are a mail provider's proxy rather than a scanner. */ export const OUTREACH_PROXY_OPEN_REASONS = [
130
+ 'image_proxy',
131
+ 'privacy_proxy'
132
+ ];
133
+ /**
134
+ * Whether one fetch of a tracking image is a person's open.
135
+ *
136
+ * Read before the open is recorded, never before the image is answered:
137
+ * every fetch gets the same image.
138
+ *
139
+ * @param input.sinceSentMs Milliseconds between the send and this fetch, or
140
+ * `null` when the send it belongs to could not be found.
141
+ */ export function judgeOutreachOpen(input) {
142
+ var _input_method, _input_userAgent;
143
+ const machine = (machineReason)=>({
144
+ human: false,
145
+ machineReason
146
+ });
147
+ if (String((_input_method = input.method) != null ? _input_method : '').toUpperCase() === 'HEAD') return machine('method');
148
+ const agent = String((_input_userAgent = input.userAgent) != null ? _input_userAgent : '').trim().toLowerCase();
149
+ if (!agent) return machine('agent');
150
+ if (IMAGE_PROXY_AGENTS.some((needle)=>agent.includes(needle))) return machine('image_proxy');
151
+ if (agent === APPLE_PRIVACY_AGENT) return machine('privacy_proxy');
152
+ const gmail = GMAIL_PROXY_AGENTS.some((needle)=>agent.includes(needle));
153
+ if (!gmail && OUTREACH_MACHINE_AGENTS.some((needle)=>agent.includes(needle))) return machine('agent');
154
+ // A fetch before its send is two clocks disagreeing, and read as a person's.
155
+ if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_OPEN_HUMAN_DELAY_MS) {
156
+ return machine('too_soon');
157
+ }
158
+ return {
159
+ human: true,
160
+ machineReason: null
161
+ };
162
+ }
163
+
164
+ //# sourceMappingURL=open-tracking.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/open-tracking.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { OUTREACH_MACHINE_AGENTS, outreachBodyLinks } from './click-tracking'\n\n/*==========================================\n * OPENS, WHEN A SEQUENCE ASKS FOR THEM (AGL-3395).\n *\n * A sequence email is plain text, and a plain-text email cannot report an\n * open: an open is a fetch of an image, and an image needs an HTML part\n * (`./click-tracking.ts` says why that is the default). A sequence that\n * turns on \"Count opens\" accepts the cost of one, for the sends it makes\n * from then on.\n *\n * ## What the HTML part is\n *\n * The plain-text part is sent exactly as it would have been. The HTML part\n * beside it is the SAME TEXT — escaped, its line breaks as `<br>`, its bare\n * links made into anchors to the same address — with a 1×1 image at the end.\n * No styling, no layout, no second copy of anything: a client that shows\n * the HTML part shows what the plain-text part says, in the reader's own\n * font, and the image is the only thing the text does not carry.\n *\n * The image is a short link (AGL-3297) on the same host the clicks use, so\n * the email names no address a click does not already name.\n *\n * ## What it costs\n *\n * A one-to-one email from a person has no HTML part; one with a remote\n * image reads, to a filter, as marketing. Some gateways score it. That is\n * why the setting is per sequence, off by default, and said in the editor\n * where it is turned on.\n *==========================================*/\n\n/** Every character HTML gives a meaning to, as the entity that does not. */\nconst HTML_ESCAPES: Record<string, string> = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n \"'\": '&#39;',\n}\n\n/** Text safe to place in HTML content or a double-quoted attribute. */\nexport function escapeOutreachHtml(value: string): string {\n return String(value ?? '').replace(/[&<>\"']/g, (character) => HTML_ESCAPES[character] ?? character)\n}\n\n/** One run of text, escaped, with its line breaks as `<br>`. */\nconst escapedLines = (text: string): string =>\n escapeOutreachHtml(text).replace(/\\r\\n|\\r|\\n/g, '<br>\\n')\n\n/**\n * The HTML part for a plain-text email and its tracking image.\n *\n * @param text The plain-text body as it is sent, footer included — so the\n * HTML part carries the organization's identification and the way out\n * word for word, and any link click tracking already rewrote stays the\n * rewritten link.\n * @param pixelUrl The image's address. Written into a quoted attribute\n * after escaping, so no value can close the attribute.\n */\nexport function outreachOpenTrackedHtml(text: string, pixelUrl: string): string {\n const body = String(text ?? '')\n let html = ''\n let cursor = 0\n // Linked by the same reader click tracking rewrites by, so a link the\n // plain text offers is exactly a link the HTML offers.\n for (const link of outreachBodyLinks(body)) {\n html += escapedLines(body.slice(cursor, link.start))\n const href = escapeOutreachHtml(link.url)\n html += `<a href=\"${href}\">${href}</a>`\n cursor = link.end\n }\n html += escapedLines(body.slice(cursor))\n const src = escapeOutreachHtml(pixelUrl)\n return (\n '<!DOCTYPE html>\\n<html>\\n<head><meta charset=\"utf-8\"></head>\\n<body>\\n' +\n `<div dir=\"auto\">${html}</div>\\n` +\n `<img src=\"${src}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;width:1px;height:1px;border:0\">\\n` +\n '</body>\\n</html>\\n'\n )\n}\n\n/*==========================================\n * WHO OPENED: A PERSON, OR A MACHINE.\n *\n * The same judgement scanner clicks get (`./click-tracking.ts`), with the\n * machines that fetch images rather than links:\n *\n * - **Apple Mail Privacy Protection** fetches every remote image of every\n * message through Apple's proxy when the message arrives, whether or not\n * anyone reads it. Its fetch names itself only as `Mozilla/5.0`.\n * - **Yahoo's image proxy** fetches on the provider's behalf, and is set\n * apart as a proxy.\n *\n * Gmail's `GoogleImageProxy` is NOT a machine here. Gmail fetches through it\n * only when the reader opens the message — it does not prefetch on delivery\n * — so outside the delivery window its fetch is the reader's open, and\n * setting it apart would leave the rate blind to every Gmail and Workspace\n * reader. The proxy caches the image, so a reader's later opens may not\n * arrive at all; the rate is taken over first opens per person either way.\n * - **Gateways and scanners** that open the message to inspect it, which\n * name themselves as the click scanners do, or arrive within seconds of\n * delivery.\n *\n * Counted apart, never dropped: `machineOpens` beside `opens`, as\n * `machineClicks` beside `clicks`.\n *==========================================*/\n\n/**\n * How soon after a send an open is a machine's however it identifies\n * itself — the click window's reasoning, for the same reason: a proxy or a\n * gateway fetches as the message is delivered, and a person reading a cold\n * email does not.\n */\nexport const OUTREACH_OPEN_HUMAN_DELAY_MS = 30_000\n\n/** Image proxies that fetch on a mail provider's behalf, matched as substrings of the agent. */\nconst IMAGE_PROXY_AGENTS = ['yahoomailproxy']\n\n/**\n * Gmail's image proxy, which fetches when the reader opens the message and\n * so counts as their open — see the note above. It names itself in an agent\n * the scanner list would otherwise match (`googleimageproxy`).\n */\nconst GMAIL_PROXY_AGENTS = ['googleimageproxy', 'ggpht.com']\n\n/**\n * The agent Apple's Mail Privacy Protection proxy sends: the bare product\n * token, with no platform after it — which no browser or mail client that\n * shows an image to a person sends.\n */\nconst APPLE_PRIVACY_AGENT = 'mozilla/5.0'\n\n/** Why an open was read as a machine's. */\nexport type OutreachOpenMachineReason = 'agent' | 'too_soon' | 'method' | 'image_proxy' | 'privacy_proxy'\n\n/** The reasons that are a mail provider's proxy rather than a scanner. */\nexport const OUTREACH_PROXY_OPEN_REASONS: readonly OutreachOpenMachineReason[] = ['image_proxy', 'privacy_proxy']\n\n/** What one fetch of a tracking image was. */\nexport interface OutreachOpenJudgement {\n /** Whether it counts toward the open rate. */\n human: boolean\n /** Why it does not; `null` when it does. */\n machineReason: OutreachOpenMachineReason | null\n}\n\n/**\n * Whether one fetch of a tracking image is a person's open.\n *\n * Read before the open is recorded, never before the image is answered:\n * every fetch gets the same image.\n *\n * @param input.sinceSentMs Milliseconds between the send and this fetch, or\n * `null` when the send it belongs to could not be found.\n */\nexport function judgeOutreachOpen(input: {\n method: string\n userAgent: string | null | undefined\n sinceSentMs: number | null\n}): OutreachOpenJudgement {\n const machine = (machineReason: OutreachOpenMachineReason): OutreachOpenJudgement => ({\n human: false,\n machineReason,\n })\n if (String(input.method ?? '').toUpperCase() === 'HEAD') return machine('method')\n const agent = String(input.userAgent ?? '').trim().toLowerCase()\n if (!agent) return machine('agent')\n if (IMAGE_PROXY_AGENTS.some((needle) => agent.includes(needle))) return machine('image_proxy')\n if (agent === APPLE_PRIVACY_AGENT) return machine('privacy_proxy')\n const gmail = GMAIL_PROXY_AGENTS.some((needle) => agent.includes(needle))\n if (!gmail && OUTREACH_MACHINE_AGENTS.some((needle) => agent.includes(needle))) return machine('agent')\n // A fetch before its send is two clocks disagreeing, and read as a person's.\n if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_OPEN_HUMAN_DELAY_MS) {\n return machine('too_soon')\n }\n return { human: true, machineReason: null }\n}\n"],"names":["OUTREACH_MACHINE_AGENTS","outreachBodyLinks","HTML_ESCAPES","escapeOutreachHtml","value","String","replace","character","escapedLines","text","outreachOpenTrackedHtml","pixelUrl","body","html","cursor","link","slice","start","href","url","end","src","OUTREACH_OPEN_HUMAN_DELAY_MS","IMAGE_PROXY_AGENTS","GMAIL_PROXY_AGENTS","APPLE_PRIVACY_AGENT","OUTREACH_PROXY_OPEN_REASONS","judgeOutreachOpen","input","machine","machineReason","human","method","toUpperCase","agent","userAgent","trim","toLowerCase","some","needle","includes","gmail","sinceSentMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,uBAAuB,EAAEC,iBAAiB,QAAQ,sBAAkB;AAE7E;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA2B4C,GAE5C,0EAA0E,GAC1E,MAAMC,eAAuC;IAC3C,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;AACP;AAEA,qEAAqE,GACrE,OAAO,SAASC,mBAAmBC,KAAa;IAC9C,OAAOC,OAAOD,gBAAAA,QAAS,IAAIE,OAAO,CAAC,YAAY,CAACC;YAAcL;gBAAAA,0BAAAA,YAAY,CAACK,UAAU,YAAvBL,0BAA2BK;;AAC3F;AAEA,8DAA8D,GAC9D,MAAMC,eAAe,CAACC,OACpBN,mBAAmBM,MAAMH,OAAO,CAAC,eAAe;AAElD;;;;;;;;;CASC,GACD,OAAO,SAASI,wBAAwBD,IAAY,EAAEE,QAAgB;IACpE,MAAMC,OAAOP,OAAOI,eAAAA,OAAQ;IAC5B,IAAII,OAAO;IACX,IAAIC,SAAS;IACb,sEAAsE;IACtE,uDAAuD;IACvD,KAAK,MAAMC,QAAQd,kBAAkBW,MAAO;QAC1CC,QAAQL,aAAaI,KAAKI,KAAK,CAACF,QAAQC,KAAKE,KAAK;QAClD,MAAMC,OAAOf,mBAAmBY,KAAKI,GAAG;QACxCN,QAAQ,CAAC,SAAS,EAAEK,KAAK,EAAE,EAAEA,KAAK,IAAI,CAAC;QACvCJ,SAASC,KAAKK,GAAG;IACnB;IACAP,QAAQL,aAAaI,KAAKI,KAAK,CAACF;IAChC,MAAMO,MAAMlB,mBAAmBQ;IAC/B,OACE,2EACA,CAAC,gBAAgB,EAAEE,KAAK,QAAQ,CAAC,GACjC,CAAC,UAAU,EAAEQ,IAAI,oFAAoF,CAAC,GACtG;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;;;;;;4CAwB4C,GAE5C;;;;;CAKC,GACD,OAAO,MAAMC,+BAA+B,MAAM;AAElD,8FAA8F,GAC9F,MAAMC,qBAAqB;IAAC;CAAiB;AAE7C;;;;CAIC,GACD,MAAMC,qBAAqB;IAAC;IAAoB;CAAY;AAE5D;;;;CAIC,GACD,MAAMC,sBAAsB;AAK5B,wEAAwE,GACxE,OAAO,MAAMC,8BAAoE;IAAC;IAAe;CAAgB,CAAA;AAUjH;;;;;;;;CAQC,GACD,OAAO,SAASC,kBAAkBC,KAIjC;QAKYA,eACUA;IALrB,MAAMC,UAAU,CAACC,gBAAqE,CAAA;YACpFC,OAAO;YACPD;QACF,CAAA;IACA,IAAIzB,QAAOuB,gBAAAA,MAAMI,MAAM,YAAZJ,gBAAgB,IAAIK,WAAW,OAAO,QAAQ,OAAOJ,QAAQ;IACxE,MAAMK,QAAQ7B,QAAOuB,mBAAAA,MAAMO,SAAS,YAAfP,mBAAmB,IAAIQ,IAAI,GAAGC,WAAW;IAC9D,IAAI,CAACH,OAAO,OAAOL,QAAQ;IAC3B,IAAIN,mBAAmBe,IAAI,CAAC,CAACC,SAAWL,MAAMM,QAAQ,CAACD,UAAU,OAAOV,QAAQ;IAChF,IAAIK,UAAUT,qBAAqB,OAAOI,QAAQ;IAClD,MAAMY,QAAQjB,mBAAmBc,IAAI,CAAC,CAACC,SAAWL,MAAMM,QAAQ,CAACD;IACjE,IAAI,CAACE,SAASzC,wBAAwBsC,IAAI,CAAC,CAACC,SAAWL,MAAMM,QAAQ,CAACD,UAAU,OAAOV,QAAQ;IAC/F,6EAA6E;IAC7E,IAAID,MAAMc,WAAW,KAAK,QAAQd,MAAMc,WAAW,IAAI,KAAKd,MAAMc,WAAW,GAAGpB,8BAA8B;QAC5G,OAAOO,QAAQ;IACjB;IACA,OAAO;QAAEE,OAAO;QAAMD,eAAe;IAAK;AAC5C"}
@@ -141,6 +141,9 @@ const WEEKDAY_NAMES = [
141
141
  // Absent is off (AGL-3239). Every sequence written before the setting
142
142
  // existed reads as untracked, which is what it was.
143
143
  trackClicks: data['trackClicks'] === true,
144
+ // Absent is off (AGL-3395): a sequence stays plain text unless it asks
145
+ // for the HTML part, and every one written before the setting did not.
146
+ countOpens: data['countOpens'] === true,
144
147
  // Absent is off (AGL-3296): a sequence is one-to-one mail, and every
145
148
  // sequence written before the setting existed reads as headerless.
146
149
  // Read by the shared reader campaigns use too (AGL-3307), with the
@@ -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/** The index of the first email step, or `-1` for a sequence with none. */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n): number {\n return (steps ?? []).findIndex(isEmailStep)\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 */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps)\n return index > first && step.replyInThread !== false\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-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","findIndex","isInThreadEmailStep","index","first","replyInThread","outreachSubjectHasReplyPrefix","subject","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","length","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","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","list","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 { 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/** The index of the first email step, or `-1` for a sequence with none. */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n): number {\n return (steps ?? []).findIndex(isEmailStep)\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 */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps)\n return index > first && step.replyInThread !== false\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","findIndex","isInThreadEmailStep","index","first","replyInThread","outreachSubjectHasReplyPrefix","subject","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","length","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","list","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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"}
@@ -29,6 +29,12 @@ import { type OutreachEnrollmentEngagement } from './outreach.types';
29
29
  * the common case rather than the edge one.
30
30
  */
31
31
  export declare function readOutreachEngagement(raw: unknown): Required<OutreachEnrollmentEngagement>;
32
+ /**
33
+ * How many rows one enrollment's history holds for its clicks and opens:
34
+ * what the writers compare with `OUTREACH_ENROLLMENT_HISTORY_MAX` before
35
+ * adding another.
36
+ */
37
+ export declare function outreachLoggedEngagementRows(engagement: Required<OutreachEnrollmentEngagement>): number;
32
38
  /**
33
39
  * What the enrollments table and the detail view say about one person's
34
40
  * clicks (AGL-3332), from the engagement alone.
@@ -35,6 +35,8 @@
35
35
  const ms = (value)=>typeof value === 'number' && Number.isFinite(value) ? value : null;
36
36
  const clicks = count(data['clicks']);
37
37
  const machineClicks = count(data['machineClicks']);
38
+ const opens = count(data['opens']);
39
+ const machineOpens = count(data['machineOpens']);
38
40
  const links = Array.isArray(data['links']) ? [
39
41
  ...new Set(data['links'].filter((entry)=>typeof entry === 'string' && entry !== ''))
40
42
  ] : [];
@@ -48,9 +50,23 @@
48
50
  // Never more than the totals they are part of: a history row is written
49
51
  // in the same transaction as the count it itemizes.
50
52
  loggedClicks: Math.min(count(data['loggedClicks']), clicks),
51
- loggedMachineClicks: Math.min(count(data['loggedMachineClicks']), machineClicks)
53
+ loggedMachineClicks: Math.min(count(data['loggedMachineClicks']), machineClicks),
54
+ // Opens (AGL-3395): absent on every enrollment of a sequence that does
55
+ // not count them, and read as none.
56
+ opens,
57
+ firstOpenAtMs: ms(data['firstOpenAtMs']),
58
+ lastOpenAtMs: ms(data['lastOpenAtMs']),
59
+ machineOpens,
60
+ loggedOpens: Math.min(count(data['loggedOpens']), opens + machineOpens)
52
61
  };
53
62
  }
63
+ /**
64
+ * How many rows one enrollment's history holds for its clicks and opens:
65
+ * what the writers compare with `OUTREACH_ENROLLMENT_HISTORY_MAX` before
66
+ * adding another.
67
+ */ export function outreachLoggedEngagementRows(engagement) {
68
+ return engagement.loggedClicks + engagement.loggedMachineClicks + engagement.loggedOpens;
69
+ }
54
70
  export function outreachClickSummary(raw) {
55
71
  const engagement = readOutreachEngagement(raw);
56
72
  const followed = [