@aglyn/plugins-outreach 1.0.0-beta.195 → 1.0.0-beta.196
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/curate-step-dialog.d.ts +50 -0
- package/src/lib/components/curate-step-dialog.js +235 -0
- package/src/lib/components/curate-step-dialog.js.map +1 -0
- package/src/lib/components/curated-step-editor.d.ts +80 -0
- package/src/lib/components/curated-step-editor.js +255 -0
- package/src/lib/components/curated-step-editor.js.map +1 -0
- package/src/lib/components/enroll-dialog.d.ts +15 -0
- package/src/lib/components/enroll-dialog.js +154 -13
- package/src/lib/components/enroll-dialog.js.map +1 -1
- package/src/lib/components/enrollments-table.d.ts +2 -0
- package/src/lib/components/enrollments-table.js +62 -8
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/use-outreach-api.d.ts +8 -1
- package/src/lib/components/use-outreach-api.js +12 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +8 -0
- package/src/lib/constants/api-routes.js +6 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/compose.d.ts +12 -2
- package/src/lib/engine/compose.js +32 -6
- package/src/lib/engine/compose.js.map +1 -1
- package/src/lib/engine/curation-prompt.d.ts +73 -0
- package/src/lib/engine/curation-prompt.js +151 -0
- package/src/lib/engine/curation-prompt.js.map +1 -0
- package/src/lib/engine/enrollment-activity.d.ts +24 -0
- package/src/lib/engine/enrollment-activity.js +25 -0
- package/src/lib/engine/enrollment-activity.js.map +1 -1
- package/src/lib/engine/sequence-validation.d.ts +10 -1
- package/src/lib/engine/sequence-validation.js +54 -0
- package/src/lib/engine/sequence-validation.js.map +1 -1
- package/src/lib/enrollment/step-overrides.d.ts +53 -0
- package/src/lib/enrollment/step-overrides.js +140 -0
- package/src/lib/enrollment/step-overrides.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +69 -2
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +36 -0
- package/src/lib/model/outreach.types.js +18 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/step-preview.d.ts +3 -1
- package/src/lib/model/step-preview.js +6 -3
- package/src/lib/model/step-preview.js.map +1 -1
- package/src/lib/model/stored-records.d.ts +9 -1
- package/src/lib/model/stored-records.js +47 -2
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/routes/curate-routes.d.ts +63 -0
- package/src/lib/routes/curate-routes.js +424 -0
- package/src/lib/routes/curate-routes.js.map +1 -0
- package/src/lib/routes/enroll-routes.d.ts +7 -0
- package/src/lib/routes/enroll-routes.js +58 -4
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/preview-routes.js +15 -1
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +17 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/runtime/send-job.js +2 -0
- package/src/lib/runtime/send-job.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\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} 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 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\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: [] })\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: [] }\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 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(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 = step.templateId ? String(input.templateBody ?? '') : 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 }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","rewriteOutreachBodyLinks","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","String","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","stepIndex","enrollment","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","subject","trackedLinks","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","body","tracked","rewriteLink","links","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CAmC4C,GAE5C,SAASA,qBAAqB,QAAQ,kCAAiC;AACvE,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAEEC,qBAAqB,QAChB,6CAA4C;AACnD,SAASC,gBAAgB,QAAQ,qCAAoC;AAMrE,SAASC,wBAAwB,QAAQ,sBAAkB;AAC3D,SAASC,6BAA6B,QAAQ,aAAS;AACvD,SACEC,mBAAmB,EACnBC,4BAA4B,EAC5BC,6BAA6B,QACxB,2BAAuB;AAE9B,mDAAmD,GACnD,OAAO,MAAMC,wBAAwB,uDAAsD;AAE3F;;;;;CAKC,GACD,OAAO,SAASC,8BAA8BC,UAAkB;IAC9D,OAAO,CAAC,2BAA2B,EAAEA,WAAW,CAAC,CAAC;AACpD;AA2FA,MAAMC,UAAU,CAACC,QACf,OAAOA,UAAU,WAAWA,MAAMC,OAAO,CAAC,QAAQ,KAAKC,IAAI,KAAK;AAElE;;;;;CAKC,GACD,OAAO,SAASC,sBACdC,QAAmG;;IAEnG,MAAMC,gBAAgBC,eAAOF,4BAAAA,SAAUC,aAAa,mBAAI,IACrDE,KAAK,CAAC,cACNC,GAAG,CAAC,CAACC,OAASV,QAAQU,MAAMR,OAAO,CAAC,OAAO,KAC3CS,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,IAAI,CAACP,eAAe;QAClB,OAAO;YACLQ,QAAQ;YACRC,OAAO;gBACLC,MAAM;gBACNC,SACE;YACJ;QACF;IACF;IACA,MAAMC,YAAYlB,QAAQK,4BAAAA,SAAUa,SAAS;IAC7C,IAAI,CAACA,WAAW;QACd,OAAO;YACLJ,QAAQ;YACRC,OAAO;gBACLC,MAAM;gBACNC,SACE;YACJ;QACF;IACF;IACA,MAAMlB,aAAaC,QAAQK,4BAAAA,SAAUc,SAAS,KAAKD;IACnD,OAAO;QACLJ,QAAQ,GAAGI,UAAU,GAAG,EAAEZ,cAAc,EAAE,EAAER,8BAA8BC,YAAY,CAAC,EAAEF,uBAAuB;QAChHkB,OAAO;IACT;AACF;AAEA,qFAAqF,GACrF,OAAO,SAASK,wBAAwBnB,KAAc;IACpD,MAAMoB,OAAOd,OAAON,gBAAAA,QAAS,IAAIE,IAAI,GAAGD,OAAO,CAAC,OAAO,IAAIA,OAAO,CAAC,OAAO;IAC1E,IAAI,CAACmB,QAAQ,SAASC,IAAI,CAACD,OAAO,OAAO;IACzC,OAAO,CAAC,CAAC,EAAEA,KAAK,CAAC,CAAC;AACpB;AAEA,SAASE,eAAetB,KAAc;IACpC,MAAMuB,MAAMjB,OAAON,gBAAAA,QAAS,IAAIE,IAAI;IACpC,IAAI,CAACqB,KAAK,OAAOC;IACjB,IAAI,SAASH,IAAI,CAACE,MAAM,OAAO;IAC/B,IAAI;QACF,MAAME,MAAM,IAAIC,IAAIH;QACpB,OAAOE,IAAIE,QAAQ,KAAK,WAAWF,IAAIG,IAAI,GAAG;IAChD,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,SAASC,kBAAkB7B,KAAc;IACvC,MAAMuB,MAAMjB,OAAON,gBAAAA,QAAS,IAAIE,IAAI;IACpC,IAAI,CAACqB,KAAK,OAAOC;IACjB,IAAI,SAASH,IAAI,CAACE,MAAM,OAAO;IAC/B,IAAI,CAAC,YAAYF,IAAI,CAACE,MAAM;QAC1B,MAAMO,UAAU3C,sBAAsBoC;QACtC,OAAOO,UAAU,CAAC,OAAO,EAAEA,QAAQ,oBAAoB,CAAC,GAAG;IAC7D;IACA,IAAIC,SAASR,IAAIS,KAAK,CAAC,UAAUC,MAAM,EAAE1B,KAAK,CAAC,IAAI,CAAC,EAAE;IACtD,IAAI;QACFwB,SAASG,mBAAmBH;IAC9B,EAAE,eAAM;QACN,OAAO;IACT;IACA,OAAO5C,sBAAsB4C,UAAUR,MAAM;AAC/C;AAEA,MAAMY,SAAS,CACbpB,MACAC,UAC2B,CAAA;QAAEoB,OAAO;QAAMtB,OAAO;YAAEC;YAAMC;QAAQ;QAAGqB,kBAAkB,EAAE;IAAC,CAAA;AAE3F,kEAAkE,GAClE,OAAO,SAASC,qBAAqBC,KAAgC;cAgB7DA,qBA0CkCA,qBAAmCC;QAtD7DD,iBACIA,mBAaVA;IAjBR,MAAM,EAAE1B,MAAM,EAAEC,KAAK,EAAE,GAAGX,sBAAsBoC,MAAME,WAAW;IACjE,IAAI3B,OAAO,OAAO;QAAEsB,OAAO;QAAMtB;QAAOuB,kBAAkB,EAAE;IAAC;IAE7D,MAAMK,iBAAQH,kBAAAA,MAAMI,QAAQ,qBAAdJ,gBAAgBG,KAAK,mBAAI,EAAE;IACzC,MAAME,aAAYL,oBAAAA,MAAMM,UAAU,qBAAhBN,kBAAkBK,SAAS;IAC7C,MAAMJ,OAAOE,KAAK,CAACE,UAAU;IAC7B,IAAIJ,CAAAA,wBAAAA,KAAMM,IAAI,MAAK,SAAS;QAC1B,OAAOX,OAAO,qBAAqB;IACrC;IACA,MAAMY,KAAK5D,sBAAsBoD,MAAMM,UAAU,CAACT,KAAK;IACvD,IAAI,CAACW,IAAI;QACP,OAAOZ,OAAO,qBAAqB;IACrC;IAEA,MAAMa,UAA2B,cAC3BT,eAAAA,MAAMU,KAAK,YAAXV,eAAe,CAAC;QACpBW,OAAO,uBACDX,gBAAAA,MAAMU,KAAK,qBAAXV,cAAaW,KAAK,oBAAI,CAAC;YAC3B,CAACxD,6BAA6B,EAAEF,8BAA8B+C,MAAMM,UAAU,CAACM,YAAY;;;IAG/F,MAAMC,aAAuB,EAAE;IAC/B,MAAMC,SAAS,CAACC;QACd,MAAMC,SAASlE,sBAAsBiE,MAAMN;QAC3C,KAAK,MAAMQ,OAAOD,OAAOH,UAAU,CAAE,IAAI,CAACA,WAAWK,QAAQ,CAACD,MAAMJ,WAAWM,IAAI,CAACF;QACpF,OAAOD,OAAOD,IAAI;IACpB;IAEA,MAAMlB,QAA+B;QAAEW;QAAIY,SAAS;QAAIL,MAAM;QAAIM,cAAc,EAAE;IAAC;IACnF,IAAInE,oBAAoBiD,OAAOE,YAAY;YAErBL,8BAGIA;QAJxB,MAAMsB,gBAAgBvE,iBAAiBiD,MAAMM,UAAU,CAACgB,aAAa;QACrE,MAAMC,aAAa,EAACvB,+BAAAA,MAAMM,UAAU,CAACiB,UAAU,YAA3BvB,+BAA+B,EAAE,EAClD/B,GAAG,CAACW,yBACJT,MAAM,CAAC,CAACqD,KAAqBA,OAAO;QACvC,MAAMC,WAAW1D,QAAOiC,kCAAAA,MAAMM,UAAU,CAACoB,aAAa,YAA9B1B,kCAAkC,IAAIrC,IAAI;QAClE,IAAI,CAAC2D,iBAAiB,CAACC,WAAW7B,MAAM,IAAI,CAAC+B,UAAU;YACrD,OAAO7B,OACL,kBACA;QAEJ;QACAC,MAAMuB,OAAO,GAAG,CAAC,IAAI,EAAEE,eAAe,CAAC7B,KAAK,CAAC,GAAG5C;QAChDgD,MAAM4B,QAAQ,GAAGA;QACjB5B,MAAM8B,SAAS,GAAGJ,UAAU,CAACA,WAAW7B,MAAM,GAAG,EAAE;QACnDG,MAAM+B,UAAU,GAAGL,WAAWlD,IAAI,CAAC;IACrC,OAAO;YACiC4B;QAAtC,MAAMmB,UAAU5D,QAAQsD,OAAO/C,QAAOkC,gBAAAA,KAAKmB,OAAO,YAAZnB,gBAAgB;QACtD,IAAI,CAACmB,SAAS,OAAOxB,OAAO,mBAAmB;QAC/C,IAAIxC,8BAA8BgE,UAAU;YAC1C,OAAOxB,OACL,wBACA;QAEJ;QACAC,MAAMuB,OAAO,GAAGA,QAAQ3B,KAAK,CAAC,GAAG5C;IACnC;IAEA,MAAMgF,SAAS5B,KAAK6B,UAAU,GAAG/D,QAAOiC,sBAAAA,MAAM+B,YAAY,YAAlB/B,sBAAsB,MAAMjC,QAAOkC,aAAAA,KAAK+B,IAAI,YAAT/B,aAAa;IACxF,MAAM+B,OAAOlB,OAAOe,QACjBnE,OAAO,CAAC,UAAU,MAClBM,KAAK,CAAC,MACNC,GAAG,CAAC,CAACC,OAASA,KAAKR,OAAO,CAAC,WAAW,KACtCW,IAAI,CAAC,MACLX,OAAO,CAAC,cAAc;IACzB,IAAI,CAACsE,KAAKrE,IAAI,IAAI,OAAOiC,OAAO,gBAAgB;IAChD;;;;;;GAMC,GACD,MAAMqC,UAAUjC,MAAMkC,WAAW,GAC7BlF,yBAAyBgF,MAAMhC,MAAMkC,WAAW,IAChD;QAAEnB,MAAMiB;QAAMG,OAAO,EAAE;IAAC;IAC5BtC,MAAMwB,YAAY,GAAGY,QAAQE,KAAK;IAClCtC,MAAMkB,IAAI,GAAG,GAAGkB,QAAQlB,IAAI,CAAC,IAAI,EAAEzC,QAAQ;IAE3C,MAAMY,MAAMH,eAAeiB,MAAMoC,kBAAkB;IACnD,IAAIlD,QAAQ,MAAM;QAChB,OAAOU,OAAO,2BAA2B;IAC3C;IACA,IAAIV,KAAKW,MAAMuC,kBAAkB,GAAGlD;IACpC,MAAMmD,SAAS/C,kBAAkBU,MAAMsC,qBAAqB;IAC5D,IAAID,WAAW,MAAM;QACnB,OAAOzC,OAAO,8BAA8B;IAC9C;IACA,IAAIyC,QAAQxC,MAAMyC,qBAAqB,GAAGD;IAE1C,OAAO;QAAExC;QAAOtB,OAAO;QAAMuB,kBAAkBe;IAAW;AAC5D"}
|
|
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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"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** The standing instructions. Kept as one string so it caches as a prefix. */
|
|
18
|
+
export declare const OUTREACH_CURATION_SYSTEM: string;
|
|
19
|
+
/** The most of a lead's notes the prompt carries. */
|
|
20
|
+
export declare const OUTREACH_CURATION_NOTES_MAX = 600;
|
|
21
|
+
/** What an email address or a phone number in free text becomes before it leaves. */
|
|
22
|
+
export declare const OUTREACH_CURATION_EMAIL_PLACEHOLDER = "[email address]";
|
|
23
|
+
export declare const OUTREACH_CURATION_PHONE_PLACEHOLDER = "[phone number]";
|
|
24
|
+
/** Free text a person typed, with every address and number replaced. */
|
|
25
|
+
export declare function redactOutreachCurationText(text: unknown): string;
|
|
26
|
+
/** What the CRM holds about the person, as the prompt states it. */
|
|
27
|
+
export interface OutreachCurationFacts {
|
|
28
|
+
name: string;
|
|
29
|
+
company: string;
|
|
30
|
+
title: string;
|
|
31
|
+
website: string;
|
|
32
|
+
tags: readonly string[];
|
|
33
|
+
/** How the person came to the workspace: a form, an import, a booking. */
|
|
34
|
+
sources: readonly string[];
|
|
35
|
+
/** The campaigns the person is filed under, by name. */
|
|
36
|
+
campaigns: readonly string[];
|
|
37
|
+
/** A lead's notes, when it has any. */
|
|
38
|
+
notes: string;
|
|
39
|
+
/** Whether the person is a lead or a contact. */
|
|
40
|
+
record: 'lead' | 'contact';
|
|
41
|
+
siteName: string;
|
|
42
|
+
/** The sender, as the mailbox names them. */
|
|
43
|
+
senderName: string;
|
|
44
|
+
personalLine: string;
|
|
45
|
+
}
|
|
46
|
+
/** One email step as the prompt states it. */
|
|
47
|
+
export interface OutreachCurationStep {
|
|
48
|
+
stepIndex: number;
|
|
49
|
+
/** Which email this is, one-based, of how many the sequence sends. */
|
|
50
|
+
position: number;
|
|
51
|
+
count: number;
|
|
52
|
+
subject: string;
|
|
53
|
+
body: string;
|
|
54
|
+
/** Whether the email starts a thread; an in-thread email is answered with no subject. */
|
|
55
|
+
startsThread: boolean;
|
|
56
|
+
}
|
|
57
|
+
/** The prompt: the sender, the person, the reason, and each email as written. */
|
|
58
|
+
export declare function outreachCurationPrompt(facts: OutreachCurationFacts, steps: readonly OutreachCurationStep[]): string;
|
|
59
|
+
/** One drafted email, as the answer named it. */
|
|
60
|
+
export interface OutreachCurationDraft {
|
|
61
|
+
stepIndex: number;
|
|
62
|
+
/** `null` for an in-thread email, which has none. */
|
|
63
|
+
subject: string | null;
|
|
64
|
+
body: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The drafts in the model's answer, one per step asked for, or `null` when
|
|
68
|
+
* the answer is not what was asked for. A code fence around the JSON is
|
|
69
|
+
* forgiven; a step the answer left out, or named twice, is not — the member
|
|
70
|
+
* would be shown a draft for one email and a template for another without
|
|
71
|
+
* knowing which the model skipped.
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseOutreachCurationAnswer(text: string, steps: readonly OutreachCurationStep[]): OutreachCurationDraft[] | null;
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
* WHAT THE AI IS ASKED, WHEN IT DRAFTS ONE PERSON'S EMAILS (AGL-3324).
|
|
18
|
+
*
|
|
19
|
+
* A sequence's step is one template for everyone plus the personal line.
|
|
20
|
+
* Curating asks the workspace's AI for this one person's copy of each email
|
|
21
|
+
* step: the step's own words as the skeleton, the person's record as the
|
|
22
|
+
* facts, the rep's personal line as the reason for writing now. What comes
|
|
23
|
+
* back is a draft the member reads, edits and confirms — never a send.
|
|
24
|
+
*
|
|
25
|
+
* ## The playbook rides in the standing instructions
|
|
26
|
+
*
|
|
27
|
+
* The outbound playbook's rules a sequence author is trusted with, an AI
|
|
28
|
+
* draft is not: list price and never a discount, a cohort, a slot count or a
|
|
29
|
+
* deadline; no invented anecdotes or statistics; one link at most, and only
|
|
30
|
+
* the link the step already carries; plain text; the footer left to the
|
|
31
|
+
* composer, which appends it to every email. They are stated here, and the
|
|
32
|
+
* validator (`sequence-validation.ts`) refuses a draft that breaks one
|
|
33
|
+
* anyway, because an instruction is not a guarantee.
|
|
34
|
+
*
|
|
35
|
+
* The standing block is byte-identical from one call to the next, so a
|
|
36
|
+
* provider that caches a prefix can; everything about the person rides in
|
|
37
|
+
* the prompt. Pure and client-safe: the route builds the prompt, the stored
|
|
38
|
+
* override keeps it for the audit log, and a spec pins the words.
|
|
39
|
+
*
|
|
40
|
+
* ## What the prompt does not carry
|
|
41
|
+
*
|
|
42
|
+
* No email address and no phone number: the person's address is what the
|
|
43
|
+
* email is SENT to, never something the draft needs to say, and an address
|
|
44
|
+
* or a number typed into a lead's notes or the personal line is replaced
|
|
45
|
+
* by a placeholder before the text leaves — the same line the CRM's own
|
|
46
|
+
* record facts hold (AGL-2917), so the published Anthropic row's words
|
|
47
|
+
* about CRM records stay true of this door.
|
|
48
|
+
*==========================================*/ /** The standing instructions. Kept as one string so it caches as a prefix. */ export const OUTREACH_CURATION_SYSTEM = [
|
|
49
|
+
'You draft one-to-one sales emails for a person on a sales team, one person at a time.',
|
|
50
|
+
'You are given the emails of a sequence as its author wrote them, the facts a CRM holds about one recipient, and a sentence the sender wrote about why they are writing to this recipient now.',
|
|
51
|
+
'Rewrite each email for this one recipient. Keep the author’s structure, intent, length and voice; make it read as though the sender wrote it for this person, using the facts given.',
|
|
52
|
+
'Rules, which the emails are checked against before anything is kept:',
|
|
53
|
+
'- Plain text only. No markdown, no HTML, no bold, no headings, no bullet symbols.',
|
|
54
|
+
'- One link at most in an email, and only a link the author’s email already carries, unchanged. Never add a link.',
|
|
55
|
+
'- List price only. Never a discount, a coupon, a promo code, a cohort, a slot count, a deadline or “limited time”.',
|
|
56
|
+
'- Never invent facts, anecdotes, statistics, customer names, results or claims. Use only the facts given; where a fact is missing, leave it out rather than guess. No placeholders in brackets.',
|
|
57
|
+
'- Do not write a signature block, a postal address, a company footer or an unsubscribe line. They are appended by the system.',
|
|
58
|
+
'- Keep any {{merge.field}} token the author used for the sender ({{sender.firstName}}, {{sender.name}}) exactly as written. You may write the recipient’s own name and company directly.',
|
|
59
|
+
'- An email marked as a reply in the thread has no subject: answer it with an empty subject.',
|
|
60
|
+
'- Never mention that an AI drafted the email.',
|
|
61
|
+
'Answer with JSON only, no code fence and no commentary: {"steps":[{"stepIndex":<number>,"subject":"<text>","body":"<text>"}]} — one entry per email given, in the order given, with the body’s line breaks as \\n.'
|
|
62
|
+
].join('\n');
|
|
63
|
+
/** The most of a lead's notes the prompt carries. */ export const OUTREACH_CURATION_NOTES_MAX = 600;
|
|
64
|
+
/** What an email address or a phone number in free text becomes before it leaves. */ export const OUTREACH_CURATION_EMAIL_PLACEHOLDER = '[email address]';
|
|
65
|
+
export const OUTREACH_CURATION_PHONE_PLACEHOLDER = '[phone number]';
|
|
66
|
+
const EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
67
|
+
/** Seven or more digits with the separators a typed number carries, an optional leading `+`. */ const PHONE_NUMBER = /(?:\+?\d[\d\s().-]{6,}\d)/g;
|
|
68
|
+
/** Free text a person typed, with every address and number replaced. */ export function redactOutreachCurationText(text) {
|
|
69
|
+
return String(text != null ? text : '').replace(EMAIL_ADDRESS, OUTREACH_CURATION_EMAIL_PLACEHOLDER).replace(PHONE_NUMBER, (match)=>match.replace(/\D/g, '').length >= 7 ? OUTREACH_CURATION_PHONE_PLACEHOLDER : match);
|
|
70
|
+
}
|
|
71
|
+
const clean = (value)=>String(value != null ? value : '').replace(/\s+/g, ' ').trim();
|
|
72
|
+
function factLines(facts) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
const fact = (label, value)=>{
|
|
75
|
+
const text = clean(value);
|
|
76
|
+
if (text) lines.push(`${label}: ${text}`);
|
|
77
|
+
};
|
|
78
|
+
fact('Name', facts.name);
|
|
79
|
+
fact('Company', facts.company);
|
|
80
|
+
fact('Title', facts.title);
|
|
81
|
+
fact('Website', facts.website);
|
|
82
|
+
if (facts.tags.length) lines.push(`Tags: ${facts.tags.map(clean).filter(Boolean).join(', ')}`);
|
|
83
|
+
if (facts.sources.length) lines.push(`How they came to us: ${facts.sources.map(clean).filter(Boolean).join(', ')}`);
|
|
84
|
+
if (facts.campaigns.length) lines.push(`Campaigns: ${facts.campaigns.map(clean).filter(Boolean).join(', ')}`);
|
|
85
|
+
const notes = redactOutreachCurationText(facts.notes).trim();
|
|
86
|
+
if (notes) lines.push(`Notes: ${notes.slice(0, OUTREACH_CURATION_NOTES_MAX)}`);
|
|
87
|
+
lines.push(`Record: ${facts.record === 'lead' ? 'a lead' : 'a contact'}`);
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
/** The prompt: the sender, the person, the reason, and each email as written. */ export function outreachCurationPrompt(facts, steps) {
|
|
91
|
+
var _ref;
|
|
92
|
+
var _steps_;
|
|
93
|
+
const parts = [];
|
|
94
|
+
parts.push(`Sender: ${clean(facts.senderName) || 'the sender'}${clean(facts.siteName) ? ` at ${clean(facts.siteName)}` : ''}`);
|
|
95
|
+
parts.push('');
|
|
96
|
+
parts.push('Recipient:');
|
|
97
|
+
parts.push(...factLines(facts));
|
|
98
|
+
parts.push('');
|
|
99
|
+
const line = clean(redactOutreachCurationText(facts.personalLine));
|
|
100
|
+
parts.push(`Why the sender is writing now: ${line || '(the sender wrote no personal line)'}`);
|
|
101
|
+
parts.push('');
|
|
102
|
+
parts.push(`Emails to rewrite (${steps.length} of the sequence’s ${(_ref = (_steps_ = steps[0]) == null ? void 0 : _steps_.count) != null ? _ref : steps.length}):`);
|
|
103
|
+
for (const step of steps){
|
|
104
|
+
parts.push('');
|
|
105
|
+
parts.push(`--- Email ${step.position} of ${step.count} · stepIndex ${step.stepIndex} · ${step.startsThread ? 'starts the thread' : 'a reply in the thread (no subject)'} ---`);
|
|
106
|
+
if (step.startsThread) parts.push(`Subject: ${step.subject}`);
|
|
107
|
+
parts.push('Body:');
|
|
108
|
+
parts.push(step.body);
|
|
109
|
+
}
|
|
110
|
+
return parts.join('\n');
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The drafts in the model's answer, one per step asked for, or `null` when
|
|
114
|
+
* the answer is not what was asked for. A code fence around the JSON is
|
|
115
|
+
* forgiven; a step the answer left out, or named twice, is not — the member
|
|
116
|
+
* would be shown a draft for one email and a template for another without
|
|
117
|
+
* knowing which the model skipped.
|
|
118
|
+
*/ export function parseOutreachCurationAnswer(text, steps) {
|
|
119
|
+
const raw = String(text != null ? text : '').trim();
|
|
120
|
+
const unfenced = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
121
|
+
const start = unfenced.indexOf('{');
|
|
122
|
+
const end = unfenced.lastIndexOf('}');
|
|
123
|
+
if (start < 0 || end <= start) return null;
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(unfenced.slice(start, end + 1));
|
|
127
|
+
} catch (unused) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const entries = parsed == null ? void 0 : parsed.steps;
|
|
131
|
+
if (!Array.isArray(entries)) return null;
|
|
132
|
+
const byIndex = new Map();
|
|
133
|
+
for (const entry of entries){
|
|
134
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
135
|
+
const record = entry;
|
|
136
|
+
const stepIndex = Number(record['stepIndex']);
|
|
137
|
+
const step = steps.find((candidate)=>candidate.stepIndex === stepIndex);
|
|
138
|
+
if (!step || byIndex.has(stepIndex)) return null;
|
|
139
|
+
const body = typeof record['body'] === 'string' ? record['body'].replace(/\r\n?/g, '\n').trim() : '';
|
|
140
|
+
const subject = step.startsThread && typeof record['subject'] === 'string' ? clean(record['subject']) : null;
|
|
141
|
+
byIndex.set(stepIndex, {
|
|
142
|
+
stepIndex,
|
|
143
|
+
subject,
|
|
144
|
+
body
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (byIndex.size !== steps.length) return null;
|
|
148
|
+
return steps.map((step)=>byIndex.get(step.stepIndex));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
//# sourceMappingURL=curation-prompt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/curation-prompt.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 THE AI IS ASKED, WHEN IT DRAFTS ONE PERSON'S EMAILS (AGL-3324).\n *\n * A sequence's step is one template for everyone plus the personal line.\n * Curating asks the workspace's AI for this one person's copy of each email\n * step: the step's own words as the skeleton, the person's record as the\n * facts, the rep's personal line as the reason for writing now. What comes\n * back is a draft the member reads, edits and confirms — never a send.\n *\n * ## The playbook rides in the standing instructions\n *\n * The outbound playbook's rules a sequence author is trusted with, an AI\n * draft is not: list price and never a discount, a cohort, a slot count or a\n * deadline; no invented anecdotes or statistics; one link at most, and only\n * the link the step already carries; plain text; the footer left to the\n * composer, which appends it to every email. They are stated here, and the\n * validator (`sequence-validation.ts`) refuses a draft that breaks one\n * anyway, because an instruction is not a guarantee.\n *\n * The standing block is byte-identical from one call to the next, so a\n * provider that caches a prefix can; everything about the person rides in\n * the prompt. Pure and client-safe: the route builds the prompt, the stored\n * override keeps it for the audit log, and a spec pins the words.\n *\n * ## What the prompt does not carry\n *\n * No email address and no phone number: the person's address is what the\n * email is SENT to, never something the draft needs to say, and an address\n * or a number typed into a lead's notes or the personal line is replaced\n * by a placeholder before the text leaves — the same line the CRM's own\n * record facts hold (AGL-2917), so the published Anthropic row's words\n * about CRM records stay true of this door.\n *==========================================*/\n\n/** The standing instructions. Kept as one string so it caches as a prefix. */\nexport const OUTREACH_CURATION_SYSTEM = [\n 'You draft one-to-one sales emails for a person on a sales team, one person at a time.',\n 'You are given the emails of a sequence as its author wrote them, the facts a CRM holds about one recipient, and a sentence the sender wrote about why they are writing to this recipient now.',\n 'Rewrite each email for this one recipient. Keep the author’s structure, intent, length and voice; make it read as though the sender wrote it for this person, using the facts given.',\n 'Rules, which the emails are checked against before anything is kept:',\n '- Plain text only. No markdown, no HTML, no bold, no headings, no bullet symbols.',\n '- One link at most in an email, and only a link the author’s email already carries, unchanged. Never add a link.',\n '- List price only. Never a discount, a coupon, a promo code, a cohort, a slot count, a deadline or “limited time”.',\n '- Never invent facts, anecdotes, statistics, customer names, results or claims. Use only the facts given; where a fact is missing, leave it out rather than guess. No placeholders in brackets.',\n '- Do not write a signature block, a postal address, a company footer or an unsubscribe line. They are appended by the system.',\n '- Keep any {{merge.field}} token the author used for the sender ({{sender.firstName}}, {{sender.name}}) exactly as written. You may write the recipient’s own name and company directly.',\n '- An email marked as a reply in the thread has no subject: answer it with an empty subject.',\n '- Never mention that an AI drafted the email.',\n 'Answer with JSON only, no code fence and no commentary: {\"steps\":[{\"stepIndex\":<number>,\"subject\":\"<text>\",\"body\":\"<text>\"}]} — one entry per email given, in the order given, with the body’s line breaks as \\\\n.',\n].join('\\n')\n\n/** The most of a lead's notes the prompt carries. */\nexport const OUTREACH_CURATION_NOTES_MAX = 600\n\n/** What an email address or a phone number in free text becomes before it leaves. */\nexport const OUTREACH_CURATION_EMAIL_PLACEHOLDER = '[email address]'\nexport const OUTREACH_CURATION_PHONE_PLACEHOLDER = '[phone number]'\n\nconst EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g\n/** Seven or more digits with the separators a typed number carries, an optional leading `+`. */\nconst PHONE_NUMBER = /(?:\\+?\\d[\\d\\s().-]{6,}\\d)/g\n\n/** Free text a person typed, with every address and number replaced. */\nexport function redactOutreachCurationText(text: unknown): string {\n return String(text ?? '')\n .replace(EMAIL_ADDRESS, OUTREACH_CURATION_EMAIL_PLACEHOLDER)\n .replace(PHONE_NUMBER, (match) => (match.replace(/\\D/g, '').length >= 7 ? OUTREACH_CURATION_PHONE_PLACEHOLDER : match))\n}\n\n/** What the CRM holds about the person, as the prompt states it. */\nexport interface OutreachCurationFacts {\n name: string\n company: string\n title: string\n website: string\n tags: readonly string[]\n /** How the person came to the workspace: a form, an import, a booking. */\n sources: readonly string[]\n /** The campaigns the person is filed under, by name. */\n campaigns: readonly string[]\n /** A lead's notes, when it has any. */\n notes: string\n /** Whether the person is a lead or a contact. */\n record: 'lead' | 'contact'\n siteName: string\n /** The sender, as the mailbox names them. */\n senderName: string\n personalLine: string\n}\n\n/** One email step as the prompt states it. */\nexport interface OutreachCurationStep {\n stepIndex: number\n /** Which email this is, one-based, of how many the sequence sends. */\n position: number\n count: number\n subject: string\n body: string\n /** Whether the email starts a thread; an in-thread email is answered with no subject. */\n startsThread: boolean\n}\n\nconst clean = (value: unknown): string => String(value ?? '').replace(/\\s+/g, ' ').trim()\n\nfunction factLines(facts: OutreachCurationFacts): string[] {\n const lines: string[] = []\n const fact = (label: string, value: string) => {\n const text = clean(value)\n if (text) lines.push(`${label}: ${text}`)\n }\n fact('Name', facts.name)\n fact('Company', facts.company)\n fact('Title', facts.title)\n fact('Website', facts.website)\n if (facts.tags.length) lines.push(`Tags: ${facts.tags.map(clean).filter(Boolean).join(', ')}`)\n if (facts.sources.length) lines.push(`How they came to us: ${facts.sources.map(clean).filter(Boolean).join(', ')}`)\n if (facts.campaigns.length) lines.push(`Campaigns: ${facts.campaigns.map(clean).filter(Boolean).join(', ')}`)\n const notes = redactOutreachCurationText(facts.notes).trim()\n if (notes) lines.push(`Notes: ${notes.slice(0, OUTREACH_CURATION_NOTES_MAX)}`)\n lines.push(`Record: ${facts.record === 'lead' ? 'a lead' : 'a contact'}`)\n return lines\n}\n\n/** The prompt: the sender, the person, the reason, and each email as written. */\nexport function outreachCurationPrompt(\n facts: OutreachCurationFacts,\n steps: readonly OutreachCurationStep[],\n): string {\n const parts: string[] = []\n parts.push(`Sender: ${clean(facts.senderName) || 'the sender'}${clean(facts.siteName) ? ` at ${clean(facts.siteName)}` : ''}`)\n parts.push('')\n parts.push('Recipient:')\n parts.push(...factLines(facts))\n parts.push('')\n const line = clean(redactOutreachCurationText(facts.personalLine))\n parts.push(`Why the sender is writing now: ${line || '(the sender wrote no personal line)'}`)\n parts.push('')\n parts.push(`Emails to rewrite (${steps.length} of the sequence’s ${steps[0]?.count ?? steps.length}):`)\n for (const step of steps) {\n parts.push('')\n parts.push(\n `--- Email ${step.position} of ${step.count} · stepIndex ${step.stepIndex} · ${\n step.startsThread ? 'starts the thread' : 'a reply in the thread (no subject)'\n } ---`,\n )\n if (step.startsThread) parts.push(`Subject: ${step.subject}`)\n parts.push('Body:')\n parts.push(step.body)\n }\n return parts.join('\\n')\n}\n\n/** One drafted email, as the answer named it. */\nexport interface OutreachCurationDraft {\n stepIndex: number\n /** `null` for an in-thread email, which has none. */\n subject: string | null\n body: string\n}\n\n/**\n * The drafts in the model's answer, one per step asked for, or `null` when\n * the answer is not what was asked for. A code fence around the JSON is\n * forgiven; a step the answer left out, or named twice, is not — the member\n * would be shown a draft for one email and a template for another without\n * knowing which the model skipped.\n */\nexport function parseOutreachCurationAnswer(\n text: string,\n steps: readonly OutreachCurationStep[],\n): OutreachCurationDraft[] | null {\n const raw = String(text ?? '').trim()\n const unfenced = raw.replace(/^```(?:json)?\\s*/i, '').replace(/\\s*```$/, '')\n const start = unfenced.indexOf('{')\n const end = unfenced.lastIndexOf('}')\n if (start < 0 || end <= start) return null\n let parsed: unknown\n try {\n parsed = JSON.parse(unfenced.slice(start, end + 1))\n } catch {\n return null\n }\n const entries = (parsed as { steps?: unknown } | null)?.steps\n if (!Array.isArray(entries)) return null\n const byIndex = new Map<number, OutreachCurationDraft>()\n for (const entry of entries) {\n if (!entry || typeof entry !== 'object') return null\n const record = entry as Record<string, unknown>\n const stepIndex = Number(record['stepIndex'])\n const step = steps.find((candidate) => candidate.stepIndex === stepIndex)\n if (!step || byIndex.has(stepIndex)) return null\n const body = typeof record['body'] === 'string' ? record['body'].replace(/\\r\\n?/g, '\\n').trim() : ''\n const subject = step.startsThread && typeof record['subject'] === 'string' ? clean(record['subject']) : null\n byIndex.set(stepIndex, { stepIndex, subject, body })\n }\n if (byIndex.size !== steps.length) return null\n return steps.map((step) => byIndex.get(step.stepIndex) as OutreachCurationDraft)\n}\n"],"names":["OUTREACH_CURATION_SYSTEM","join","OUTREACH_CURATION_NOTES_MAX","OUTREACH_CURATION_EMAIL_PLACEHOLDER","OUTREACH_CURATION_PHONE_PLACEHOLDER","EMAIL_ADDRESS","PHONE_NUMBER","redactOutreachCurationText","text","String","replace","match","length","clean","value","trim","factLines","facts","lines","fact","label","push","name","company","title","website","tags","map","filter","Boolean","sources","campaigns","notes","slice","record","outreachCurationPrompt","steps","parts","senderName","siteName","line","personalLine","count","step","position","stepIndex","startsThread","subject","body","parseOutreachCurationAnswer","raw","unfenced","start","indexOf","end","lastIndexOf","parsed","JSON","parse","entries","Array","isArray","byIndex","Map","entry","Number","find","candidate","has","set","size","get"],"mappings":"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"}
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
* and "<sequence> stopped" when a reply or an opt-out ends it. A bounce
|
|
27
27
|
* files nothing here — the send's own entry already reads Bounced with
|
|
28
28
|
* the server's words (AGL-3245), and a second line would say it twice.
|
|
29
|
+
* A third entry (AGL-3324) says a member curated one step for the person,
|
|
30
|
+
* and who wrote the words, so the send that follows can be read for what
|
|
31
|
+
* it was.
|
|
29
32
|
*
|
|
30
33
|
* Pure, so the route and the runtime compose the same words and the
|
|
31
34
|
* backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can
|
|
@@ -51,6 +54,27 @@ export declare function outreachEnrolledEntry(input: {
|
|
|
51
54
|
sequenceName: string;
|
|
52
55
|
campaignNames: readonly string[];
|
|
53
56
|
}): OutreachTimelineEntry;
|
|
57
|
+
/**
|
|
58
|
+
* The key one curated step's entry is filed under (AGL-3324): once per
|
|
59
|
+
* confirmation, so a step curated again after a reply files again — each is
|
|
60
|
+
* a fact about what went out, and a second line is a second fact.
|
|
61
|
+
*/
|
|
62
|
+
export declare function outreachCuratedEntryKey(enrollmentId: string, stepIndex: number, atMs: number): string;
|
|
63
|
+
/**
|
|
64
|
+
* "Curated step 2 — AI draft, edited by Casey" (AGL-3324): who wrote this
|
|
65
|
+
* person's copy of the step. An AI draft the member kept as it came reads
|
|
66
|
+
* "AI draft, confirmed by"; words the member wrote themselves read "written
|
|
67
|
+
* by". A member with no name is named by their address, and one with
|
|
68
|
+
* neither is "a member".
|
|
69
|
+
*/
|
|
70
|
+
export declare function outreachCuratedEntry(input: {
|
|
71
|
+
enrollmentId: string;
|
|
72
|
+
stepIndex: number;
|
|
73
|
+
source: 'ai' | 'member';
|
|
74
|
+
edited: boolean;
|
|
75
|
+
memberName: string | null | undefined;
|
|
76
|
+
atMs: number;
|
|
77
|
+
}): OutreachTimelineEntry;
|
|
54
78
|
/** Why a sequence stopped, in the two ways a person ends one. */
|
|
55
79
|
export type OutreachStoppedReason = 'replied' | 'opted_out';
|
|
56
80
|
/** "<sequence> stopped: they replied" — the line under the last send. */
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* and "<sequence> stopped" when a reply or an opt-out ends it. A bounce
|
|
26
26
|
* files nothing here — the send's own entry already reads Bounced with
|
|
27
27
|
* the server's words (AGL-3245), and a second line would say it twice.
|
|
28
|
+
* A third entry (AGL-3324) says a member curated one step for the person,
|
|
29
|
+
* and who wrote the words, so the send that follows can be read for what
|
|
30
|
+
* it was.
|
|
28
31
|
*
|
|
29
32
|
* Pure, so the route and the runtime compose the same words and the
|
|
30
33
|
* backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can
|
|
@@ -46,6 +49,28 @@
|
|
|
46
49
|
body: campaigns.length ? `Enrolled in ${sequence}\nFiled under ${campaigns.join(', ')}` : `Enrolled in ${sequence}`
|
|
47
50
|
};
|
|
48
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The key one curated step's entry is filed under (AGL-3324): once per
|
|
54
|
+
* confirmation, so a step curated again after a reply files again — each is
|
|
55
|
+
* a fact about what went out, and a second line is a second fact.
|
|
56
|
+
*/ export function outreachCuratedEntryKey(enrollmentId, stepIndex, atMs) {
|
|
57
|
+
return `curated:${enrollmentId}:${stepIndex}:${atMs}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* "Curated step 2 — AI draft, edited by Casey" (AGL-3324): who wrote this
|
|
61
|
+
* person's copy of the step. An AI draft the member kept as it came reads
|
|
62
|
+
* "AI draft, confirmed by"; words the member wrote themselves read "written
|
|
63
|
+
* by". A member with no name is named by their address, and one with
|
|
64
|
+
* neither is "a member".
|
|
65
|
+
*/ export function outreachCuratedEntry(input) {
|
|
66
|
+
var _input_memberName;
|
|
67
|
+
const member = String((_input_memberName = input.memberName) != null ? _input_memberName : '').trim() || 'a member';
|
|
68
|
+
const how = input.source === 'ai' ? input.edited ? `AI draft, edited by ${member}` : `AI draft, confirmed by ${member}` : `written by ${member}`;
|
|
69
|
+
return {
|
|
70
|
+
dedupeKey: outreachCuratedEntryKey(input.enrollmentId, input.stepIndex, input.atMs),
|
|
71
|
+
body: `Curated step ${input.stepIndex + 1} — ${how}`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
49
74
|
/** "<sequence> stopped: they replied" — the line under the last send. */ export function outreachStoppedEntry(input) {
|
|
50
75
|
const sequence = input.sequenceName.trim() || 'The sequence';
|
|
51
76
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-activity.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 SAYS ON THE PERSON'S TIMELINE ABOUT ITSELF (AGL-3274).\n *\n * The sends and the replies were already there (AGL-2981); the two facts\n * that frame them were not. A reader of a lead's Activity saw an email go\n * out with no line saying the person had been put in a sequence, and saw\n * the sequence's last email with no line saying why there was no next one.\n * Two entries close that: \"Enrolled in <sequence>\" when the enroll route\n * takes the person, with the campaigns the enrollment carried them into,\n * and \"<sequence> stopped\" when a reply or an opt-out ends it. A bounce\n * files nothing here — the send's own entry already reads Bounced with\n * the server's words (AGL-3245), and a second line would say it twice.\n *\n * Pure, so the route and the runtime compose the same words and the\n * backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can\n * restate them and be pinned against this file. Each entry has ONE key\n * per enrollment, so a run that files again finds what it wrote.\n */\n\n/** The author every entry the runtime files is signed with. */\nexport const OUTREACH_TIMELINE_BY_NAME = 'Sequences'\n\n/** An entry as the timeline seam takes it: the caller's key, the words. */\nexport interface OutreachTimelineEntry {\n dedupeKey: string\n body: string\n}\n\n/** The key the enrollment's own entry is filed under, restated by the backfill. */\nexport function outreachEnrolledEntryKey(enrollmentId: string): string {\n return `enrolled:${enrollmentId}`\n}\n\n/**\n * \"Enrolled in <sequence>\", and the campaigns it carried the person into\n * when the sequence was in any, named. A sequence with no name — never\n * stored, but a document can lose one — reads \"a sequence\".\n */\nexport function outreachEnrolledEntry(input: {\n enrollmentId: string\n sequenceName: string\n campaignNames: readonly string[]\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'a sequence'\n const campaigns = input.campaignNames.map((name) => name.trim()).filter(Boolean)\n return {\n dedupeKey: outreachEnrolledEntryKey(input.enrollmentId),\n body: campaigns.length\n ? `Enrolled in ${sequence}\\nFiled under ${campaigns.join(', ')}`\n : `Enrolled in ${sequence}`,\n }\n}\n\n/** Why a sequence stopped, in the two ways a person ends one. */\nexport type OutreachStoppedReason = 'replied' | 'opted_out'\n\n/** \"<sequence> stopped: they replied\" — the line under the last send. */\nexport function outreachStoppedEntry(input: {\n enrollmentId: string\n sequenceName: string\n reason: OutreachStoppedReason\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'The sequence'\n return {\n dedupeKey: `stopped:${input.enrollmentId}`,\n body: `${sequence} stopped: ${input.reason === 'replied' ? 'they replied' : 'they opted out'}`,\n }\n}\n"],"names":["OUTREACH_TIMELINE_BY_NAME","outreachEnrolledEntryKey","enrollmentId","outreachEnrolledEntry","input","sequence","sequenceName","trim","campaigns","campaignNames","map","name","filter","Boolean","dedupeKey","body","length","join","outreachStoppedEntry","reason"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-activity.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 SAYS ON THE PERSON'S TIMELINE ABOUT ITSELF (AGL-3274).\n *\n * The sends and the replies were already there (AGL-2981); the two facts\n * that frame them were not. A reader of a lead's Activity saw an email go\n * out with no line saying the person had been put in a sequence, and saw\n * the sequence's last email with no line saying why there was no next one.\n * Two entries close that: \"Enrolled in <sequence>\" when the enroll route\n * takes the person, with the campaigns the enrollment carried them into,\n * and \"<sequence> stopped\" when a reply or an opt-out ends it. A bounce\n * files nothing here — the send's own entry already reads Bounced with\n * the server's words (AGL-3245), and a second line would say it twice.\n * A third entry (AGL-3324) says a member curated one step for the person,\n * and who wrote the words, so the send that follows can be read for what\n * it was.\n *\n * Pure, so the route and the runtime compose the same words and the\n * backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can\n * restate them and be pinned against this file. Each entry has ONE key\n * per enrollment, so a run that files again finds what it wrote.\n */\n\n/** The author every entry the runtime files is signed with. */\nexport const OUTREACH_TIMELINE_BY_NAME = 'Sequences'\n\n/** An entry as the timeline seam takes it: the caller's key, the words. */\nexport interface OutreachTimelineEntry {\n dedupeKey: string\n body: string\n}\n\n/** The key the enrollment's own entry is filed under, restated by the backfill. */\nexport function outreachEnrolledEntryKey(enrollmentId: string): string {\n return `enrolled:${enrollmentId}`\n}\n\n/**\n * \"Enrolled in <sequence>\", and the campaigns it carried the person into\n * when the sequence was in any, named. A sequence with no name — never\n * stored, but a document can lose one — reads \"a sequence\".\n */\nexport function outreachEnrolledEntry(input: {\n enrollmentId: string\n sequenceName: string\n campaignNames: readonly string[]\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'a sequence'\n const campaigns = input.campaignNames.map((name) => name.trim()).filter(Boolean)\n return {\n dedupeKey: outreachEnrolledEntryKey(input.enrollmentId),\n body: campaigns.length\n ? `Enrolled in ${sequence}\\nFiled under ${campaigns.join(', ')}`\n : `Enrolled in ${sequence}`,\n }\n}\n\n/**\n * The key one curated step's entry is filed under (AGL-3324): once per\n * confirmation, so a step curated again after a reply files again — each is\n * a fact about what went out, and a second line is a second fact.\n */\nexport function outreachCuratedEntryKey(enrollmentId: string, stepIndex: number, atMs: number): string {\n return `curated:${enrollmentId}:${stepIndex}:${atMs}`\n}\n\n/**\n * \"Curated step 2 — AI draft, edited by Casey\" (AGL-3324): who wrote this\n * person's copy of the step. An AI draft the member kept as it came reads\n * \"AI draft, confirmed by\"; words the member wrote themselves read \"written\n * by\". A member with no name is named by their address, and one with\n * neither is \"a member\".\n */\nexport function outreachCuratedEntry(input: {\n enrollmentId: string\n stepIndex: number\n source: 'ai' | 'member'\n edited: boolean\n memberName: string | null | undefined\n atMs: number\n}): OutreachTimelineEntry {\n const member = String(input.memberName ?? '').trim() || 'a member'\n const how =\n input.source === 'ai'\n ? input.edited\n ? `AI draft, edited by ${member}`\n : `AI draft, confirmed by ${member}`\n : `written by ${member}`\n return {\n dedupeKey: outreachCuratedEntryKey(input.enrollmentId, input.stepIndex, input.atMs),\n body: `Curated step ${input.stepIndex + 1} — ${how}`,\n }\n}\n\n/** Why a sequence stopped, in the two ways a person ends one. */\nexport type OutreachStoppedReason = 'replied' | 'opted_out'\n\n/** \"<sequence> stopped: they replied\" — the line under the last send. */\nexport function outreachStoppedEntry(input: {\n enrollmentId: string\n sequenceName: string\n reason: OutreachStoppedReason\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'The sequence'\n return {\n dedupeKey: `stopped:${input.enrollmentId}`,\n body: `${sequence} stopped: ${input.reason === 'replied' ? 'they replied' : 'they opted out'}`,\n }\n}\n"],"names":["OUTREACH_TIMELINE_BY_NAME","outreachEnrolledEntryKey","enrollmentId","outreachEnrolledEntry","input","sequence","sequenceName","trim","campaigns","campaignNames","map","name","filter","Boolean","dedupeKey","body","length","join","outreachCuratedEntryKey","stepIndex","atMs","outreachCuratedEntry","member","String","memberName","how","source","edited","outreachStoppedEntry","reason"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC,GAED,6DAA6D,GAC7D,OAAO,MAAMA,4BAA4B,YAAW;AAQpD,iFAAiF,GACjF,OAAO,SAASC,yBAAyBC,YAAoB;IAC3D,OAAO,CAAC,SAAS,EAAEA,cAAc;AACnC;AAEA;;;;CAIC,GACD,OAAO,SAASC,sBAAsBC,KAIrC;IACC,MAAMC,WAAWD,MAAME,YAAY,CAACC,IAAI,MAAM;IAC9C,MAAMC,YAAYJ,MAAMK,aAAa,CAACC,GAAG,CAAC,CAACC,OAASA,KAAKJ,IAAI,IAAIK,MAAM,CAACC;IACxE,OAAO;QACLC,WAAWb,yBAAyBG,MAAMF,YAAY;QACtDa,MAAMP,UAAUQ,MAAM,GAClB,CAAC,YAAY,EAAEX,SAAS,cAAc,EAAEG,UAAUS,IAAI,CAAC,OAAO,GAC9D,CAAC,YAAY,EAAEZ,UAAU;IAC/B;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASa,wBAAwBhB,YAAoB,EAAEiB,SAAiB,EAAEC,IAAY;IAC3F,OAAO,CAAC,QAAQ,EAAElB,aAAa,CAAC,EAAEiB,UAAU,CAAC,EAAEC,MAAM;AACvD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,qBAAqBjB,KAOpC;QACuBA;IAAtB,MAAMkB,SAASC,QAAOnB,oBAAAA,MAAMoB,UAAU,YAAhBpB,oBAAoB,IAAIG,IAAI,MAAM;IACxD,MAAMkB,MACJrB,MAAMsB,MAAM,KAAK,OACbtB,MAAMuB,MAAM,GACV,CAAC,oBAAoB,EAAEL,QAAQ,GAC/B,CAAC,uBAAuB,EAAEA,QAAQ,GACpC,CAAC,WAAW,EAAEA,QAAQ;IAC5B,OAAO;QACLR,WAAWI,wBAAwBd,MAAMF,YAAY,EAAEE,MAAMe,SAAS,EAAEf,MAAMgB,IAAI;QAClFL,MAAM,CAAC,aAAa,EAAEX,MAAMe,SAAS,GAAG,EAAE,GAAG,EAAEM,KAAK;IACtD;AACF;AAKA,uEAAuE,GACvE,OAAO,SAASG,qBAAqBxB,KAIpC;IACC,MAAMC,WAAWD,MAAME,YAAY,CAACC,IAAI,MAAM;IAC9C,OAAO;QACLO,WAAW,CAAC,QAAQ,EAAEV,MAAMF,YAAY,EAAE;QAC1Ca,MAAM,GAAGV,SAAS,UAAU,EAAED,MAAMyB,MAAM,KAAK,YAAY,iBAAiB,kBAAkB;IAChG;AACF"}
|
|
@@ -34,7 +34,7 @@ export declare const OUTREACH_SEQUENCE_NAME_MAX = 120;
|
|
|
34
34
|
/** The longest title a task step carries. */
|
|
35
35
|
export declare const OUTREACH_TASK_TITLE_MAX = 200;
|
|
36
36
|
export type OutreachValidationSeverity = 'error' | 'warning';
|
|
37
|
-
export type OutreachValidationCode = 'name_required' | 'name_too_long' | 'mailbox_required' | 'steps_required' | 'too_many_steps' | 'too_many_email_steps' | 'step_id_required' | 'duplicate_step_id' | 'unknown_step_kind' | 'delay_invalid' | 'subject_required' | 'subject_too_long' | 'subject_reply_prefix' | 'body_required' | 'body_and_template' | 'body_too_long' | 'unknown_merge_field' | 'personal_line_unused' | 'task_kind_invalid' | 'task_title_required' | 'task_title_too_long' | 'window_days_invalid' | 'window_hours_invalid' | 'countries_required' | 'country_invalid' | 'legal_name_required' | 'postal_address_required';
|
|
37
|
+
export type OutreachValidationCode = 'name_required' | 'name_too_long' | 'mailbox_required' | 'steps_required' | 'too_many_steps' | 'too_many_email_steps' | 'step_id_required' | 'duplicate_step_id' | 'unknown_step_kind' | 'delay_invalid' | 'subject_required' | 'subject_too_long' | 'subject_reply_prefix' | 'body_required' | 'body_and_template' | 'body_too_long' | 'unknown_merge_field' | 'personal_line_unused' | 'task_kind_invalid' | 'task_title_required' | 'task_title_too_long' | 'window_days_invalid' | 'window_hours_invalid' | 'countries_required' | 'country_invalid' | 'legal_name_required' | 'postal_address_required' | 'too_many_links' | 'discount_language' | 'not_plain_text';
|
|
38
38
|
export interface OutreachValidationIssue {
|
|
39
39
|
/** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */
|
|
40
40
|
path: string;
|
|
@@ -78,6 +78,15 @@ export declare function validateOutreachSequenceSettings(settings: OutreachSeque
|
|
|
78
78
|
export declare function validateOutreachSteps(steps: readonly OutreachSequenceStep[] | null | undefined, path?: string): OutreachValidationIssue[];
|
|
79
79
|
/** Everything a stored sequence has to be before it may be saved. */
|
|
80
80
|
export declare function validateOutreachSequence(sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined): OutreachValidationIssue[];
|
|
81
|
+
export interface OutreachCuratedStepInput {
|
|
82
|
+
/** The subject, when the copy carries one. Read only where the email starts a thread. */
|
|
83
|
+
subject?: string | null;
|
|
84
|
+
body?: string | null;
|
|
85
|
+
/** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */
|
|
86
|
+
startsThread: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** Everything one person's copy of a step has to be before it may be stored — see the section note. */
|
|
89
|
+
export declare function validateOutreachCuratedStep(input: OutreachCuratedStepInput, path?: string): OutreachValidationIssue[];
|
|
81
90
|
/**
|
|
82
91
|
* The organization's footer fields: a legal name and a postal address, the
|
|
83
92
|
* two things the law requires every commercial email to carry.
|
|
@@ -33,6 +33,7 @@ import { readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubs
|
|
|
33
33
|
import { CRM_MERGE_FIELDS, crmMergeFieldsIn } from "@aglyn/aglyn/app-utils/crm-email-templates";
|
|
34
34
|
import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
|
|
35
35
|
import { OUTREACH_DEFAULT_ALLOWED_COUNTRIES, OUTREACH_MAX_EMAIL_STEPS, OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS, OUTREACH_MAX_STEPS, OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS, OUTREACH_TASK_KINDS } from "../model/outreach.types.js";
|
|
36
|
+
import { outreachBodyLinks } from "./click-tracking.js";
|
|
36
37
|
/** The merge field the rep's signal sentence is written into a step with. */ export const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine';
|
|
37
38
|
/**
|
|
38
39
|
* Every field an Outreach step may use: the CRM's, which the composer fills
|
|
@@ -274,6 +275,59 @@ function emailStepIssues(steps, step, index, path) {
|
|
|
274
275
|
issues.push(...validateOutreachSequenceSettings(sequence == null ? void 0 : sequence.settings));
|
|
275
276
|
return issues;
|
|
276
277
|
}
|
|
278
|
+
/*==========================================
|
|
279
|
+
* A CURATED STEP (AGL-3324).
|
|
280
|
+
*
|
|
281
|
+
* One person's own copy of a step is held to everything a step is held to —
|
|
282
|
+
* a subject where the email starts a thread, a body, the lengths, the merge
|
|
283
|
+
* fields — and to the outbound playbook's rules the sequence editor trusts
|
|
284
|
+
* its author with but an AI draft cannot be trusted with: one link at most,
|
|
285
|
+
* list price only, plain text. Each is an ERROR: a draft that breaks one is
|
|
286
|
+
* refused rather than stored, whoever wrote it.
|
|
287
|
+
*==========================================*/ /**
|
|
288
|
+
* Words the playbook keeps out of every message: a discount, a coupon, a
|
|
289
|
+
* cohort, a slot count, a deadline. Matched on word boundaries, so "count"
|
|
290
|
+
* and "expired domain" pass and "coupon" and "20% off" do not.
|
|
291
|
+
*/ const DISCOUNT_LANGUAGE = /\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;
|
|
292
|
+
/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */ const MARKDOWN = /\*\*[^*\n]+\*\*|\[[^\]\n]+\]\([^)\s]+\)|^#{1,6}\s|<\/?[a-z][^>]*>/im;
|
|
293
|
+
/** Everything one person's copy of a step has to be before it may be stored — see the section note. */ export function validateOutreachCuratedStep(input, path = 'override') {
|
|
294
|
+
const issues = [];
|
|
295
|
+
const subject = asText(input.subject);
|
|
296
|
+
if (input.startsThread && input.subject !== undefined && input.subject !== null) {
|
|
297
|
+
if (!subject.trim()) {
|
|
298
|
+
issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'));
|
|
299
|
+
} else if (outreachSubjectHasReplyPrefix(subject)) {
|
|
300
|
+
issues.push(error(`${path}.subject`, 'subject_reply_prefix', 'Remove the "Re:" or "Fwd:" — this email starts a conversation, it doesn\'t answer one.'));
|
|
301
|
+
}
|
|
302
|
+
if (subject.length > CRM_EMAIL_SUBJECT_MAX) {
|
|
303
|
+
issues.push(error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`));
|
|
304
|
+
}
|
|
305
|
+
if (DISCOUNT_LANGUAGE.test(subject)) {
|
|
306
|
+
issues.push(error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'));
|
|
307
|
+
}
|
|
308
|
+
issues.push(...mergeFieldWarnings(subject, `${path}.subject`));
|
|
309
|
+
}
|
|
310
|
+
const body = asText(input.body);
|
|
311
|
+
if (!body.trim()) {
|
|
312
|
+
issues.push(error(`${path}.body`, 'body_required', 'Write the email.'));
|
|
313
|
+
return issues;
|
|
314
|
+
}
|
|
315
|
+
if (body.length > CRM_EMAIL_BODY_MAX) {
|
|
316
|
+
issues.push(error(`${path}.body`, 'body_too_long', `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`));
|
|
317
|
+
}
|
|
318
|
+
const links = outreachBodyLinks(body);
|
|
319
|
+
if (links.length > 1) {
|
|
320
|
+
issues.push(error(`${path}.body`, 'too_many_links', `This email carries ${links.length} links. A sequence email carries one at most.`));
|
|
321
|
+
}
|
|
322
|
+
if (DISCOUNT_LANGUAGE.test(body)) {
|
|
323
|
+
issues.push(error(`${path}.body`, 'discount_language', 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.'));
|
|
324
|
+
}
|
|
325
|
+
if (MARKDOWN.test(body)) {
|
|
326
|
+
issues.push(error(`${path}.body`, 'not_plain_text', 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.'));
|
|
327
|
+
}
|
|
328
|
+
issues.push(...mergeFieldWarnings(body, `${path}.body`));
|
|
329
|
+
return issues;
|
|
330
|
+
}
|
|
277
331
|
/**
|
|
278
332
|
* The organization's footer fields: a legal name and a postal address, the
|
|
279
333
|
* two things the law requires every commercial email to carry.
|