@aglyn/plugins-outreach 1.0.0-beta.181 → 1.0.0-beta.183

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 (42) hide show
  1. package/README.md +2 -1
  2. package/package.json +14 -13
  3. package/src/lib/components/link-domains-card.d.ts +28 -0
  4. package/src/lib/components/link-domains-card.js +285 -0
  5. package/src/lib/components/link-domains-card.js.map +1 -0
  6. package/src/lib/components/mailboxes-section.js +5 -1
  7. package/src/lib/components/mailboxes-section.js.map +1 -1
  8. package/src/lib/components/use-outreach-api.d.ts +5 -1
  9. package/src/lib/components/use-outreach-api.js +10 -0
  10. package/src/lib/components/use-outreach-api.js.map +1 -1
  11. package/src/lib/constants/api-routes.d.ts +6 -0
  12. package/src/lib/constants/api-routes.js +5 -0
  13. package/src/lib/constants/api-routes.js.map +1 -1
  14. package/src/lib/engine/sequence-validation.js +4 -1
  15. package/src/lib/engine/sequence-validation.js.map +1 -1
  16. package/src/lib/model/outreach-api.d.ts +48 -1
  17. package/src/lib/model/outreach-api.js.map +1 -1
  18. package/src/lib/routes/link-domain-routes.d.ts +65 -0
  19. package/src/lib/routes/link-domain-routes.js +142 -0
  20. package/src/lib/routes/link-domain-routes.js.map +1 -0
  21. package/src/lib/routes/platform-deps.d.ts +2 -1
  22. package/src/lib/routes/platform-deps.js +2 -1
  23. package/src/lib/routes/platform-deps.js.map +1 -1
  24. package/src/lib/routes/register-routes.d.ts +3 -0
  25. package/src/lib/routes/register-routes.js +19 -0
  26. package/src/lib/routes/register-routes.js.map +1 -1
  27. package/src/lib/runtime/click-link.d.ts +10 -3
  28. package/src/lib/runtime/click-link.js +18 -8
  29. package/src/lib/runtime/click-link.js.map +1 -1
  30. package/src/lib/runtime/platform-runtime-deps.js +13 -3
  31. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  32. package/src/lib/runtime/runtime-deps.d.ts +15 -3
  33. package/src/lib/runtime/runtime-deps.js.map +1 -1
  34. package/src/lib/runtime/send-job.js +22 -2
  35. package/src/lib/runtime/send-job.js.map +1 -1
  36. package/src/lib/runtime/unsubscribe-link.js +17 -9
  37. package/src/lib/runtime/unsubscribe-link.js.map +1 -1
  38. package/src/lib/transport/rfc5322.d.ts +6 -2
  39. package/src/lib/transport/rfc5322.js +6 -2
  40. package/src/lib/transport/rfc5322.js.map +1 -1
  41. package/src/lib/transport/send-message.js +11 -8
  42. package/src/lib/transport/send-message.js.map +1 -1
@@ -66,6 +66,12 @@ export declare const OUTREACH_API_ROUTES: {
66
66
  * (AGL-3244); `POST` — add one, or take one off.
67
67
  */
68
68
  readonly doNotContactDomains: "outreach/do-not-contact/domains";
69
+ /**
70
+ * `GET ?orgId` — each connected mailbox domain's click-tracking host
71
+ * (AGL-3306), `links.<domain>`; `POST` — set one up, check it, or remove
72
+ * it. Setting up and removing are an owner's or admin's.
73
+ */
74
+ readonly linkDomains: "outreach/link-domains";
69
75
  /** `POST` — one email of a saved sequence, for a contact or a sample person. */
70
76
  readonly preview: "outreach/preview";
71
77
  /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */
@@ -51,6 +51,11 @@
51
51
  * `GET ?orgId` — the domains on the organization's do-not-contact list
52
52
  * (AGL-3244); `POST` — add one, or take one off.
53
53
  */ doNotContactDomains: 'outreach/do-not-contact/domains',
54
+ /**
55
+ * `GET ?orgId` — each connected mailbox domain's click-tracking host
56
+ * (AGL-3306), `links.<domain>`; `POST` — set one up, check it, or remove
57
+ * it. Setting up and removing are an owner's or admin's.
58
+ */ linkDomains: 'outreach/link-domains',
54
59
  /** `POST` — one email of a saved sequence, for a contact or a sample person. */ preview: 'outreach/preview',
55
60
  // The one route a RECIPIENT reaches (AGL-2981): no session, a signed
56
61
  // token instead, and registered as a recipient link so it answers whether
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/constants/api-routes.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 * Outreach's console API routes, as the dispatcher keys them (AGL-2974).\n *\n * One table both halves import, because the server registers the path and the\n * client fetches it, and a route spelled twice is a route that 404s the day\n * one spelling changes. Every path sits under the `outreach` prefix that\n * `plugins.config.json` gives this plugin, which is what lets the console's\n * `/api/[...pluginApi]` dispatcher gate a request on the plugin being enabled\n * and released before the handler runs. Client-safe: constants only.\n */\nexport const OUTREACH_API_ROUTES = {\n /** `GET` — proves the server bundle loaded and registered; see `server.ts`. */\n ping: 'outreach/ping',\n // Mailboxes (AGL-2978). Every one names its org (`orgId`, query or body)\n // so the dispatcher's release gate asks about that organization.\n /** `GET ?orgId` — whether this deployment can connect a Google mailbox. */\n mailboxesAvailability: 'outreach/mailboxes/availability',\n /** `POST` — Google's consent address for a new connect. */\n mailboxesConnect: 'outreach/mailboxes/connect',\n /**\n * `GET` — Google's redirect back. It carries no bearer token: the signed\n * state names the org and the member, and the route hands the code to the\n * Mailboxes page in a fragment rather than acting on it.\n */\n mailboxesOAuthCallback: 'outreach/mailboxes/oauth/callback',\n /** `POST` — finishes a connect with the member's own session. */\n mailboxesConnectComplete: 'outreach/mailboxes/connect/complete',\n /** `POST` — send-as, display name, daily cap, sending window, timezone. */\n mailboxesSettings: 'outreach/mailboxes/settings',\n /** `POST` — pause or resume. */\n mailboxesStatus: 'outreach/mailboxes/status',\n /** `POST` — a plain-text test to the account's own address. */\n mailboxesTest: 'outreach/mailboxes/test',\n /** `POST` — revoke the grant at Google and delete the mailbox. */\n mailboxesDisconnect: 'outreach/mailboxes/disconnect',\n // Settings, sequences and enrollments (AGL-2980). Every one names its org\n // (`orgId`, query or body); the contract is `model/outreach-api.ts`.\n /** `GET ?orgId` — the compliance settings; `POST` — save them. */\n settings: 'outreach/settings',\n /** `POST` — create a sequence, or save an edit to one. */\n sequencesSave: 'outreach/sequences/save',\n /** `POST` — activate, pause or archive a sequence. */\n sequencesStatus: 'outreach/sequences/status',\n /** `POST` — delete a draft nobody was enrolled in. */\n sequencesDelete: 'outreach/sequences/delete',\n /** `POST` — who a saved view or a search would enroll, and where each stands. */\n enrollPreview: 'outreach/enroll/preview',\n /** `POST` — enroll them, every gate checked again. */\n enroll: 'outreach/enroll',\n /** `POST` — pause, resume, stop, or mark do-not-contact. */\n enrollmentsAction: 'outreach/enrollments/action',\n /**\n * `GET ?orgId` — the domains on the organization's do-not-contact list\n * (AGL-3244); `POST` — add one, or take one off.\n */\n doNotContactDomains: 'outreach/do-not-contact/domains',\n /** `POST` — one email of a saved sequence, for a contact or a sample person. */\n preview: 'outreach/preview',\n // The one route a RECIPIENT reaches (AGL-2981): no session, a signed\n // token instead, and registered as a recipient link so it answers whether\n // or not Outreach is released for the organization now.\n /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */\n unsubscribe: 'outreach/unsubscribe',\n /**\n * `GET` — a link in a tracked sequence email (AGL-3239). `?t=<signed\n * token>`; answers a redirect to the destination the token names, and\n * records the click afterwards. A recipient link like the unsubscribe\n * one: no session, and it answers whether or not Outreach is released to\n * the organization now, because a link in an email that already left must\n * not stop working when a rollout is paused.\n */\n click: 'outreach/click',\n /**\n * `GET` — the short tracking link (AGL-3297): `outreach/l/<id>`, where the\n * id names a stored `outreachLinks` document holding the destination. It\n * replaced the signed `click` link for every send since; `click` keeps\n * answering the links already in inboxes. A recipient link, for `click`'s\n * reasons.\n */\n shortLink: 'outreach/l/:linkId',\n} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","doNotContactDomains","preview","unsubscribe","click","shortLink"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GACD,OAAO,MAAMA,sBAAsB;IACjC,6EAA6E,GAC7EC,MAAM;IACN,yEAAyE;IACzE,iEAAiE;IACjE,yEAAyE,GACzEC,uBAAuB;IACvB,yDAAyD,GACzDC,kBAAkB;IAClB;;;;GAIC,GACDC,wBAAwB;IACxB,+DAA+D,GAC/DC,0BAA0B;IAC1B,yEAAyE,GACzEC,mBAAmB;IACnB,8BAA8B,GAC9BC,iBAAiB;IACjB,6DAA6D,GAC7DC,eAAe;IACf,gEAAgE,GAChEC,qBAAqB;IACrB,0EAA0E;IAC1E,qEAAqE;IACrE,gEAAgE,GAChEC,UAAU;IACV,wDAAwD,GACxDC,eAAe;IACf,oDAAoD,GACpDC,iBAAiB;IACjB,oDAAoD,GACpDC,iBAAiB;IACjB,+EAA+E,GAC/EC,eAAe;IACf,oDAAoD,GACpDC,QAAQ;IACR,0DAA0D,GAC1DC,mBAAmB;IACnB;;;GAGC,GACDC,qBAAqB;IACrB,8EAA8E,GAC9EC,SAAS;IACT,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;IACP;;;;;;GAMC,GACDC,WAAW;AACb,EAAU"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/constants/api-routes.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 * Outreach's console API routes, as the dispatcher keys them (AGL-2974).\n *\n * One table both halves import, because the server registers the path and the\n * client fetches it, and a route spelled twice is a route that 404s the day\n * one spelling changes. Every path sits under the `outreach` prefix that\n * `plugins.config.json` gives this plugin, which is what lets the console's\n * `/api/[...pluginApi]` dispatcher gate a request on the plugin being enabled\n * and released before the handler runs. Client-safe: constants only.\n */\nexport const OUTREACH_API_ROUTES = {\n /** `GET` — proves the server bundle loaded and registered; see `server.ts`. */\n ping: 'outreach/ping',\n // Mailboxes (AGL-2978). Every one names its org (`orgId`, query or body)\n // so the dispatcher's release gate asks about that organization.\n /** `GET ?orgId` — whether this deployment can connect a Google mailbox. */\n mailboxesAvailability: 'outreach/mailboxes/availability',\n /** `POST` — Google's consent address for a new connect. */\n mailboxesConnect: 'outreach/mailboxes/connect',\n /**\n * `GET` — Google's redirect back. It carries no bearer token: the signed\n * state names the org and the member, and the route hands the code to the\n * Mailboxes page in a fragment rather than acting on it.\n */\n mailboxesOAuthCallback: 'outreach/mailboxes/oauth/callback',\n /** `POST` — finishes a connect with the member's own session. */\n mailboxesConnectComplete: 'outreach/mailboxes/connect/complete',\n /** `POST` — send-as, display name, daily cap, sending window, timezone. */\n mailboxesSettings: 'outreach/mailboxes/settings',\n /** `POST` — pause or resume. */\n mailboxesStatus: 'outreach/mailboxes/status',\n /** `POST` — a plain-text test to the account's own address. */\n mailboxesTest: 'outreach/mailboxes/test',\n /** `POST` — revoke the grant at Google and delete the mailbox. */\n mailboxesDisconnect: 'outreach/mailboxes/disconnect',\n // Settings, sequences and enrollments (AGL-2980). Every one names its org\n // (`orgId`, query or body); the contract is `model/outreach-api.ts`.\n /** `GET ?orgId` — the compliance settings; `POST` — save them. */\n settings: 'outreach/settings',\n /** `POST` — create a sequence, or save an edit to one. */\n sequencesSave: 'outreach/sequences/save',\n /** `POST` — activate, pause or archive a sequence. */\n sequencesStatus: 'outreach/sequences/status',\n /** `POST` — delete a draft nobody was enrolled in. */\n sequencesDelete: 'outreach/sequences/delete',\n /** `POST` — who a saved view or a search would enroll, and where each stands. */\n enrollPreview: 'outreach/enroll/preview',\n /** `POST` — enroll them, every gate checked again. */\n enroll: 'outreach/enroll',\n /** `POST` — pause, resume, stop, or mark do-not-contact. */\n enrollmentsAction: 'outreach/enrollments/action',\n /**\n * `GET ?orgId` — the domains on the organization's do-not-contact list\n * (AGL-3244); `POST` — add one, or take one off.\n */\n doNotContactDomains: 'outreach/do-not-contact/domains',\n /**\n * `GET ?orgId` — each connected mailbox domain's click-tracking host\n * (AGL-3306), `links.<domain>`; `POST` — set one up, check it, or remove\n * it. Setting up and removing are an owner's or admin's.\n */\n linkDomains: 'outreach/link-domains',\n /** `POST` — one email of a saved sequence, for a contact or a sample person. */\n preview: 'outreach/preview',\n // The one route a RECIPIENT reaches (AGL-2981): no session, a signed\n // token instead, and registered as a recipient link so it answers whether\n // or not Outreach is released for the organization now.\n /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */\n unsubscribe: 'outreach/unsubscribe',\n /**\n * `GET` — a link in a tracked sequence email (AGL-3239). `?t=<signed\n * token>`; answers a redirect to the destination the token names, and\n * records the click afterwards. A recipient link like the unsubscribe\n * one: no session, and it answers whether or not Outreach is released to\n * the organization now, because a link in an email that already left must\n * not stop working when a rollout is paused.\n */\n click: 'outreach/click',\n /**\n * `GET` — the short tracking link (AGL-3297): `outreach/l/<id>`, where the\n * id names a stored `outreachLinks` document holding the destination. It\n * replaced the signed `click` link for every send since; `click` keeps\n * answering the links already in inboxes. A recipient link, for `click`'s\n * reasons.\n */\n shortLink: 'outreach/l/:linkId',\n} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","doNotContactDomains","linkDomains","preview","unsubscribe","click","shortLink"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GACD,OAAO,MAAMA,sBAAsB;IACjC,6EAA6E,GAC7EC,MAAM;IACN,yEAAyE;IACzE,iEAAiE;IACjE,yEAAyE,GACzEC,uBAAuB;IACvB,yDAAyD,GACzDC,kBAAkB;IAClB;;;;GAIC,GACDC,wBAAwB;IACxB,+DAA+D,GAC/DC,0BAA0B;IAC1B,yEAAyE,GACzEC,mBAAmB;IACnB,8BAA8B,GAC9BC,iBAAiB;IACjB,6DAA6D,GAC7DC,eAAe;IACf,gEAAgE,GAChEC,qBAAqB;IACrB,0EAA0E;IAC1E,qEAAqE;IACrE,gEAAgE,GAChEC,UAAU;IACV,wDAAwD,GACxDC,eAAe;IACf,oDAAoD,GACpDC,iBAAiB;IACjB,oDAAoD,GACpDC,iBAAiB;IACjB,+EAA+E,GAC/EC,eAAe;IACf,oDAAoD,GACpDC,QAAQ;IACR,0DAA0D,GAC1DC,mBAAmB;IACnB;;;GAGC,GACDC,qBAAqB;IACrB;;;;GAIC,GACDC,aAAa;IACb,8EAA8E,GAC9EC,SAAS;IACT,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;IACP;;;;;;GAMC,GACDC,WAAW;AACb,EAAU"}
@@ -29,6 +29,7 @@
29
29
  * first email that never uses the personal line the rep writes at
30
30
  * enrollment.
31
31
  *==========================================*/ import { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
32
+ import { readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubscribe";
32
33
  import { CRM_MERGE_FIELDS, crmMergeFieldsIn } from "@aglyn/aglyn/app-utils/crm-email-templates";
33
34
  import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
34
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";
@@ -141,7 +142,9 @@ const WEEKDAY_NAMES = [
141
142
  trackClicks: data['trackClicks'] === true,
142
143
  // Absent is off (AGL-3296): a sequence is one-to-one mail, and every
143
144
  // sequence written before the setting existed reads as headerless.
144
- listUnsubscribe: data['listUnsubscribe'] === true
145
+ // Read by the shared reader campaigns use too (AGL-3307), with the
146
+ // sequence's own default.
147
+ listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false)
145
148
  };
146
149
  }
147
150
  export function validateOutreachSequenceSettings(settings, path = 'settings') {
@@ -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 {\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'\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\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 listUnsubscribe: data['listUnsubscribe'] === true,\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 * 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","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","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","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'\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\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 * 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","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","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"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"}
@@ -28,7 +28,7 @@ import type { OutreachComplianceIssue } from './compliance-settings';
28
28
  import type { OutreachAttestationKind, OutreachComplianceSettingsDocument, OutreachDoNotContactDomainEntry, OutreachEnrollment, OutreachSequence } from './outreach.types';
29
29
  import type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft';
30
30
  /** Every reason these routes refuse with, for a caller to branch on. */
31
- export type OutreachRouteRefusalReason = 'unauthenticated' | 'email-unverified' | 'org-required' | 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement' | 'method-not-allowed' | 'invalid-request' | 'invalid-domain' | 'invalid-settings' | 'invalid-sequence' | 'sequence-not-found' | 'sequence-archived' | 'sequence-not-active' | 'activation-refused' | 'delete-refused' | 'mailbox-unavailable' | 'enrollment-not-found' | 'transition-refused' | 'view-not-found' | 'view-unsupported' | 'contact-not-found';
31
+ export type OutreachRouteRefusalReason = 'unauthenticated' | 'email-unverified' | 'org-required' | 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement' | 'method-not-allowed' | 'invalid-request' | 'invalid-domain' | 'invalid-settings' | 'invalid-sequence' | 'sequence-not-found' | 'sequence-archived' | 'sequence-not-active' | 'activation-refused' | 'delete-refused' | 'mailbox-unavailable' | 'enrollment-not-found' | 'transition-refused' | 'view-not-found' | 'view-unsupported' | 'contact-not-found' | 'link-domain-refused';
32
32
  /** A refusal, in the one shape every route answers with. */
33
33
  export interface OutreachRouteRefusal {
34
34
  /** A sentence written for the person reading it. */
@@ -237,6 +237,53 @@ export interface OutreachDoNotContactDomainResponse extends OutreachDoNotContact
237
237
  /** The domain as the list spells it. */
238
238
  domain: string;
239
239
  }
240
+ /** Where a mailbox domain's click-tracking host stands. */
241
+ export type OutreachLinkDomainStatus = 'not-set-up' | 'requested' | 'records-issued' | 'verified' | 'failed';
242
+ /** One DNS record the member publishes, as the sending-domain cards print them. */
243
+ export interface OutreachLinkDomainRecord {
244
+ type: string;
245
+ name: string;
246
+ value: string;
247
+ required: boolean;
248
+ note: string;
249
+ }
250
+ /**
251
+ * One sending domain of the organization's connected mailboxes, and its
252
+ * `links.` host: what tracked links in mail from it read now, and what is
253
+ * left to do for them to read `https://links.<domain>/<id>`.
254
+ */
255
+ export interface OutreachLinkDomain {
256
+ /** The mailboxes' sending domain. */
257
+ domain: string;
258
+ /** `links.<domain>`. */
259
+ host: string;
260
+ status: OutreachLinkDomainStatus;
261
+ /** The records to publish; empty until the host is set up. */
262
+ records: OutreachLinkDomainRecord[];
263
+ /** Why it is not verified, in a sentence; `null` when nothing is wrong. */
264
+ detail: string | null;
265
+ /** How a tracked link in mail from this domain starts today. */
266
+ linkPrefix: string | null;
267
+ checkedAtMs: number | null;
268
+ verifiedAtMs: number | null;
269
+ }
270
+ /** `GET outreach/link-domains?orgId` */
271
+ export interface OutreachLinkDomainsResponse {
272
+ ok: true;
273
+ domains: OutreachLinkDomain[];
274
+ /** Whether the member may set a host up, check it, or remove it. */
275
+ canManage: boolean;
276
+ }
277
+ /** `POST outreach/link-domains` */
278
+ export interface OutreachLinkDomainRequest {
279
+ orgId: string;
280
+ domain: string;
281
+ action: 'set-up' | 'check' | 'remove';
282
+ }
283
+ export interface OutreachLinkDomainResponse extends OutreachLinkDomainsResponse {
284
+ /** The domain the action was for. */
285
+ domain: string;
286
+ }
240
287
  /** `POST outreach/preview` — one email of a saved sequence, as a person would get it. */
241
288
  export interface OutreachPreviewRequest {
242
289
  orgId: string;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/outreach-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE CONTRACT OF OUTREACH'S SETTINGS, SEQUENCE AND ENROLLMENT ROUTES\n * (AGL-2980): what the console sends, what the routes answer, and every\n * reason one refuses. Client-safe — types and the batch limit only.\n *\n * Every request names its organization (`orgId`, in the query of a `GET`\n * and the JSON body of a `POST`): the routes serve an organization-level\n * surface that names no site.\n */\n\nimport type { OutreachComposeError } from '../engine/compose'\nimport type { OutreachComplianceIssue } from './compliance-settings'\nimport type {\n OutreachAttestationKind,\n OutreachComplianceSettingsDocument,\n OutreachDoNotContactDomainEntry,\n OutreachEnrollment,\n OutreachSequence,\n} from './outreach.types'\nimport type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft'\n\n/** Every reason these routes refuse with, for a caller to branch on. */\nexport type OutreachRouteRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'invalid-domain'\n | 'invalid-settings'\n | 'invalid-sequence'\n | 'sequence-not-found'\n | 'sequence-archived'\n | 'sequence-not-active'\n | 'activation-refused'\n | 'delete-refused'\n | 'mailbox-unavailable'\n | 'enrollment-not-found'\n | 'transition-refused'\n | 'view-not-found'\n | 'view-unsupported'\n | 'contact-not-found'\n\n/** A refusal, in the one shape every route answers with. */\nexport interface OutreachRouteRefusal {\n /** A sentence written for the person reading it. */\n error: string\n reason: OutreachRouteRefusalReason\n /** The field-level reasons behind an `invalid-*` or `activation-refused` refusal. */\n issues?: ReadonlyArray<OutreachComplianceIssue | OutreachSequenceIssue>\n}\n\n/*==========================================\n * COMPLIANCE SETTINGS\n *==========================================*/\n\n/** `GET outreach/settings?orgId` — and the answer to a save. */\nexport interface OutreachSettingsResponse {\n ok: true\n settings: OutreachComplianceSettingsDocument\n}\n\n/** `POST outreach/settings` */\nexport interface OutreachSettingsSaveRequest {\n orgId: string\n legalName: string\n brandName: string\n postalAddress: string\n allowedCountries: string[]\n}\n\n/** The answer to a save: the settings as stored, and whether anything changed. */\nexport interface OutreachSettingsSaveResponse extends OutreachSettingsResponse {\n changed: boolean\n}\n\n/*==========================================\n * SEQUENCES\n *==========================================*/\n\n/** `POST outreach/sequences/save` — a new sequence without `sequenceId`, an edit with one. */\nexport interface OutreachSequenceSaveRequest {\n orgId: string\n sequenceId?: string\n sequence: OutreachSequenceDraft\n}\n\nexport interface OutreachSequenceSaveResponse {\n ok: true\n sequence: OutreachSequence\n created: boolean\n /** What the save allowed but probably isn't what was meant. */\n warnings: OutreachSequenceIssue[]\n}\n\n/** What `outreach/sequences/status` does to a sequence. */\nexport type OutreachSequenceAction = 'activate' | 'pause' | 'archive'\n\n/** `POST outreach/sequences/status` */\nexport interface OutreachSequenceStatusRequest {\n orgId: string\n sequenceId: string\n action: OutreachSequenceAction\n}\n\nexport interface OutreachSequenceStatusResponse {\n ok: true\n sequence: OutreachSequence\n /** The open enrollments an archive stopped; `0` for anything else. */\n stoppedEnrollments: number\n}\n\n/** `POST outreach/sequences/delete` — a draft nobody was ever enrolled in. */\nexport interface OutreachSequenceDeleteRequest {\n orgId: string\n sequenceId: string\n}\n\n/*==========================================\n * ENROLLING\n *==========================================*/\n\n/** The most people one preview or one enroll reads. */\nexport const OUTREACH_ENROLL_BATCH_MAX = 50\n\n/**\n * Which CRM record a person to enroll is (AGL-3234): a contact by its\n * document id, or a lead the sequence's site holds by its person key.\n */\nexport type OutreachPersonRef = { kind: 'contact'; id: string } | { kind: 'lead'; id: string }\n\n/**\n * Where the people to enroll come from: a saved Contacts or Leads view,\n * contacts picked by search, or leads picked from the sequence's site.\n */\nexport type OutreachEnrollSource =\n | { kind: 'view'; viewId: string }\n | { kind: 'contacts'; contactIds: string[] }\n | { kind: 'leads'; leadIds: string[] }\n\n/** `POST outreach/enroll/preview` */\nexport interface OutreachEnrollPreviewRequest {\n orgId: string\n sequenceId: string\n source: OutreachEnrollSource\n}\n\n/**\n * Where one person stands:\n *\n * - `eligible` — enrolled on Confirm as they are;\n * - `needs_confirmation` — enrolled once the rep writes the personal line\n * and ticks the attestations `requires` names;\n * - `blocked` — refused whatever the rep confirms, for the `blocks` given.\n */\nexport type OutreachEnrollPreviewStatus = 'eligible' | 'needs_confirmation' | 'blocked'\n\n/** One reason a person can't be enrolled, in a sentence a rep can act on. */\nexport interface OutreachEnrollBlock {\n code: string\n reason: string\n}\n\nexport interface OutreachEnrollPreviewPerson {\n /** The key the dialog and the confirm name this person by: the record's own id. */\n personId: string\n /** The record the person is (AGL-3234). */\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n name: string\n /** The address the steps would go to, or `null` when the contact has none. */\n email: string | null\n /** No inbound capture behind them — the heavier rules apply. */\n cold: boolean\n /** ISO-3166-1 alpha-2, when anything names one. */\n country: string | null\n status: OutreachEnrollPreviewStatus\n /** Why a `blocked` person is refused; empty otherwise. */\n blocks: OutreachEnrollBlock[]\n /** What the rep must supply before a `needs_confirmation` person is enrolled. */\n requires: {\n personalLine: boolean\n attestations: OutreachAttestationKind[]\n }\n}\n\nexport interface OutreachEnrollPreviewResponse {\n ok: true\n people: OutreachEnrollPreviewPerson[]\n /** Everyone the source named, before the batch limit. */\n total: number\n /** True when the source named more people than one enrollment reads. */\n truncated: boolean\n}\n\n/** One person to enroll, with what the rep supplied for them: a contact by id, or a lead by key. */\nexport interface OutreachEnrollPersonRequest {\n contactId?: string\n leadId?: string\n personalLine?: string\n attestations?: OutreachAttestationKind[]\n}\n\n/** `POST outreach/enroll` — the gates are checked again, here, before anything is written. */\nexport interface OutreachEnrollRequest {\n orgId: string\n sequenceId: string\n people: OutreachEnrollPersonRequest[]\n}\n\ninterface OutreachEnrollOutcomePerson {\n personId: string\n target: 'contact' | 'lead'\n contactId: string\n leadId: string | null\n email: string | null\n}\n\nexport type OutreachEnrollOutcome =\n | (OutreachEnrollOutcomePerson & { outcome: 'enrolled'; enrollmentId: string })\n | (OutreachEnrollOutcomePerson & { outcome: 'blocked'; blocks: OutreachEnrollBlock[] })\n\nexport interface OutreachEnrollResponse {\n ok: true\n results: OutreachEnrollOutcome[]\n enrolled: number\n}\n\n/*==========================================\n * ENROLLMENTS\n *==========================================*/\n\n/** What a member may do to one enrollment. */\nexport type OutreachEnrollmentAction = 'pause' | 'resume' | 'stop' | 'do_not_contact'\n\n/** `POST outreach/enrollments/action` */\nexport interface OutreachEnrollmentActionRequest {\n orgId: string\n enrollmentId: string\n action: OutreachEnrollmentAction\n /** Why, for a stop or a do-not-contact. */\n detail?: string\n}\n\nexport interface OutreachEnrollmentActionResponse {\n ok: true\n /** False when the enrollment was already where the action would put it. */\n changed: boolean\n enrollment: OutreachEnrollment\n /** Other open enrollments of the same address a do-not-contact stopped. */\n stoppedOthers: number\n}\n\n/*==========================================\n * DO NOT CONTACT: DOMAINS (AGL-3244)\n *==========================================*/\n\n/** `GET outreach/do-not-contact/domains?orgId` — and the answer to a change. */\nexport interface OutreachDoNotContactDomainsResponse {\n ok: true\n /** Every domain on the list, alphabetically. */\n domains: OutreachDoNotContactDomainEntry[]\n}\n\n/** `POST outreach/do-not-contact/domains` */\nexport interface OutreachDoNotContactDomainRequest {\n orgId: string\n action: 'add' | 'remove'\n /** The domain, or an address at it. */\n domain: string\n /** Why, for an add. */\n detail?: string\n}\n\n/** The answer to a change: the list as it stands, and whether anything changed. */\nexport interface OutreachDoNotContactDomainResponse extends OutreachDoNotContactDomainsResponse {\n changed: boolean\n /** The domain as the list spells it. */\n domain: string\n}\n\n/*==========================================\n * PREVIEW\n *==========================================*/\n\n/** `POST outreach/preview` — one email of a saved sequence, as a person would get it. */\nexport interface OutreachPreviewRequest {\n orgId: string\n sequenceId: string\n /** The contact to write it to; a sample person when absent. */\n contactId?: string\n /** Or a lead the sequence's site holds (AGL-3234), written to as the contact it would be. */\n leadId?: string\n personalLine?: string\n /** Which step; the first email when absent. */\n stepIndex?: number\n}\n\nexport interface OutreachPreviewResponse {\n ok: true\n stepIndex: number\n subject: string\n /** The plain text, footer included. */\n text: string\n unresolvedFields: string[]\n /** Why it could not be written, when it could not. */\n error: OutreachComposeError | null\n}\n"],"names":["OUTREACH_ENROLL_BATCH_MAX"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAkHD;;4CAE4C,GAE5C,qDAAqD,GACrD,OAAO,MAAMA,4BAA4B,GAAE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/outreach-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE CONTRACT OF OUTREACH'S SETTINGS, SEQUENCE AND ENROLLMENT ROUTES\n * (AGL-2980): what the console sends, what the routes answer, and every\n * reason one refuses. Client-safe — types and the batch limit only.\n *\n * Every request names its organization (`orgId`, in the query of a `GET`\n * and the JSON body of a `POST`): the routes serve an organization-level\n * surface that names no site.\n */\n\nimport type { OutreachComposeError } from '../engine/compose'\nimport type { OutreachComplianceIssue } from './compliance-settings'\nimport type {\n OutreachAttestationKind,\n OutreachComplianceSettingsDocument,\n OutreachDoNotContactDomainEntry,\n OutreachEnrollment,\n OutreachSequence,\n} from './outreach.types'\nimport type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft'\n\n/** Every reason these routes refuse with, for a caller to branch on. */\nexport type OutreachRouteRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'invalid-domain'\n | 'invalid-settings'\n | 'invalid-sequence'\n | 'sequence-not-found'\n | 'sequence-archived'\n | 'sequence-not-active'\n | 'activation-refused'\n | 'delete-refused'\n | 'mailbox-unavailable'\n | 'enrollment-not-found'\n | 'transition-refused'\n | 'view-not-found'\n | 'view-unsupported'\n | 'contact-not-found'\n | 'link-domain-refused'\n\n/** A refusal, in the one shape every route answers with. */\nexport interface OutreachRouteRefusal {\n /** A sentence written for the person reading it. */\n error: string\n reason: OutreachRouteRefusalReason\n /** The field-level reasons behind an `invalid-*` or `activation-refused` refusal. */\n issues?: ReadonlyArray<OutreachComplianceIssue | OutreachSequenceIssue>\n}\n\n/*==========================================\n * COMPLIANCE SETTINGS\n *==========================================*/\n\n/** `GET outreach/settings?orgId` — and the answer to a save. */\nexport interface OutreachSettingsResponse {\n ok: true\n settings: OutreachComplianceSettingsDocument\n}\n\n/** `POST outreach/settings` */\nexport interface OutreachSettingsSaveRequest {\n orgId: string\n legalName: string\n brandName: string\n postalAddress: string\n allowedCountries: string[]\n}\n\n/** The answer to a save: the settings as stored, and whether anything changed. */\nexport interface OutreachSettingsSaveResponse extends OutreachSettingsResponse {\n changed: boolean\n}\n\n/*==========================================\n * SEQUENCES\n *==========================================*/\n\n/** `POST outreach/sequences/save` — a new sequence without `sequenceId`, an edit with one. */\nexport interface OutreachSequenceSaveRequest {\n orgId: string\n sequenceId?: string\n sequence: OutreachSequenceDraft\n}\n\nexport interface OutreachSequenceSaveResponse {\n ok: true\n sequence: OutreachSequence\n created: boolean\n /** What the save allowed but probably isn't what was meant. */\n warnings: OutreachSequenceIssue[]\n}\n\n/** What `outreach/sequences/status` does to a sequence. */\nexport type OutreachSequenceAction = 'activate' | 'pause' | 'archive'\n\n/** `POST outreach/sequences/status` */\nexport interface OutreachSequenceStatusRequest {\n orgId: string\n sequenceId: string\n action: OutreachSequenceAction\n}\n\nexport interface OutreachSequenceStatusResponse {\n ok: true\n sequence: OutreachSequence\n /** The open enrollments an archive stopped; `0` for anything else. */\n stoppedEnrollments: number\n}\n\n/** `POST outreach/sequences/delete` — a draft nobody was ever enrolled in. */\nexport interface OutreachSequenceDeleteRequest {\n orgId: string\n sequenceId: string\n}\n\n/*==========================================\n * ENROLLING\n *==========================================*/\n\n/** The most people one preview or one enroll reads. */\nexport const OUTREACH_ENROLL_BATCH_MAX = 50\n\n/**\n * Which CRM record a person to enroll is (AGL-3234): a contact by its\n * document id, or a lead the sequence's site holds by its person key.\n */\nexport type OutreachPersonRef = { kind: 'contact'; id: string } | { kind: 'lead'; id: string }\n\n/**\n * Where the people to enroll come from: a saved Contacts or Leads view,\n * contacts picked by search, or leads picked from the sequence's site.\n */\nexport type OutreachEnrollSource =\n | { kind: 'view'; viewId: string }\n | { kind: 'contacts'; contactIds: string[] }\n | { kind: 'leads'; leadIds: string[] }\n\n/** `POST outreach/enroll/preview` */\nexport interface OutreachEnrollPreviewRequest {\n orgId: string\n sequenceId: string\n source: OutreachEnrollSource\n}\n\n/**\n * Where one person stands:\n *\n * - `eligible` — enrolled on Confirm as they are;\n * - `needs_confirmation` — enrolled once the rep writes the personal line\n * and ticks the attestations `requires` names;\n * - `blocked` — refused whatever the rep confirms, for the `blocks` given.\n */\nexport type OutreachEnrollPreviewStatus = 'eligible' | 'needs_confirmation' | 'blocked'\n\n/** One reason a person can't be enrolled, in a sentence a rep can act on. */\nexport interface OutreachEnrollBlock {\n code: string\n reason: string\n}\n\nexport interface OutreachEnrollPreviewPerson {\n /** The key the dialog and the confirm name this person by: the record's own id. */\n personId: string\n /** The record the person is (AGL-3234). */\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n name: string\n /** The address the steps would go to, or `null` when the contact has none. */\n email: string | null\n /** No inbound capture behind them — the heavier rules apply. */\n cold: boolean\n /** ISO-3166-1 alpha-2, when anything names one. */\n country: string | null\n status: OutreachEnrollPreviewStatus\n /** Why a `blocked` person is refused; empty otherwise. */\n blocks: OutreachEnrollBlock[]\n /** What the rep must supply before a `needs_confirmation` person is enrolled. */\n requires: {\n personalLine: boolean\n attestations: OutreachAttestationKind[]\n }\n}\n\nexport interface OutreachEnrollPreviewResponse {\n ok: true\n people: OutreachEnrollPreviewPerson[]\n /** Everyone the source named, before the batch limit. */\n total: number\n /** True when the source named more people than one enrollment reads. */\n truncated: boolean\n}\n\n/** One person to enroll, with what the rep supplied for them: a contact by id, or a lead by key. */\nexport interface OutreachEnrollPersonRequest {\n contactId?: string\n leadId?: string\n personalLine?: string\n attestations?: OutreachAttestationKind[]\n}\n\n/** `POST outreach/enroll` — the gates are checked again, here, before anything is written. */\nexport interface OutreachEnrollRequest {\n orgId: string\n sequenceId: string\n people: OutreachEnrollPersonRequest[]\n}\n\ninterface OutreachEnrollOutcomePerson {\n personId: string\n target: 'contact' | 'lead'\n contactId: string\n leadId: string | null\n email: string | null\n}\n\nexport type OutreachEnrollOutcome =\n | (OutreachEnrollOutcomePerson & { outcome: 'enrolled'; enrollmentId: string })\n | (OutreachEnrollOutcomePerson & { outcome: 'blocked'; blocks: OutreachEnrollBlock[] })\n\nexport interface OutreachEnrollResponse {\n ok: true\n results: OutreachEnrollOutcome[]\n enrolled: number\n}\n\n/*==========================================\n * ENROLLMENTS\n *==========================================*/\n\n/** What a member may do to one enrollment. */\nexport type OutreachEnrollmentAction = 'pause' | 'resume' | 'stop' | 'do_not_contact'\n\n/** `POST outreach/enrollments/action` */\nexport interface OutreachEnrollmentActionRequest {\n orgId: string\n enrollmentId: string\n action: OutreachEnrollmentAction\n /** Why, for a stop or a do-not-contact. */\n detail?: string\n}\n\nexport interface OutreachEnrollmentActionResponse {\n ok: true\n /** False when the enrollment was already where the action would put it. */\n changed: boolean\n enrollment: OutreachEnrollment\n /** Other open enrollments of the same address a do-not-contact stopped. */\n stoppedOthers: number\n}\n\n/*==========================================\n * DO NOT CONTACT: DOMAINS (AGL-3244)\n *==========================================*/\n\n/** `GET outreach/do-not-contact/domains?orgId` — and the answer to a change. */\nexport interface OutreachDoNotContactDomainsResponse {\n ok: true\n /** Every domain on the list, alphabetically. */\n domains: OutreachDoNotContactDomainEntry[]\n}\n\n/** `POST outreach/do-not-contact/domains` */\nexport interface OutreachDoNotContactDomainRequest {\n orgId: string\n action: 'add' | 'remove'\n /** The domain, or an address at it. */\n domain: string\n /** Why, for an add. */\n detail?: string\n}\n\n/** The answer to a change: the list as it stands, and whether anything changed. */\nexport interface OutreachDoNotContactDomainResponse extends OutreachDoNotContactDomainsResponse {\n changed: boolean\n /** The domain as the list spells it. */\n domain: string\n}\n\n/*==========================================\n * LINK DOMAINS (AGL-3306)\n *==========================================*/\n\n/** Where a mailbox domain's click-tracking host stands. */\nexport type OutreachLinkDomainStatus = 'not-set-up' | 'requested' | 'records-issued' | 'verified' | 'failed'\n\n/** One DNS record the member publishes, as the sending-domain cards print them. */\nexport interface OutreachLinkDomainRecord {\n type: string\n name: string\n value: string\n required: boolean\n note: string\n}\n\n/**\n * One sending domain of the organization's connected mailboxes, and its\n * `links.` host: what tracked links in mail from it read now, and what is\n * left to do for them to read `https://links.<domain>/<id>`.\n */\nexport interface OutreachLinkDomain {\n /** The mailboxes' sending domain. */\n domain: string\n /** `links.<domain>`. */\n host: string\n status: OutreachLinkDomainStatus\n /** The records to publish; empty until the host is set up. */\n records: OutreachLinkDomainRecord[]\n /** Why it is not verified, in a sentence; `null` when nothing is wrong. */\n detail: string | null\n /** How a tracked link in mail from this domain starts today. */\n linkPrefix: string | null\n checkedAtMs: number | null\n verifiedAtMs: number | null\n}\n\n/** `GET outreach/link-domains?orgId` */\nexport interface OutreachLinkDomainsResponse {\n ok: true\n domains: OutreachLinkDomain[]\n /** Whether the member may set a host up, check it, or remove it. */\n canManage: boolean\n}\n\n/** `POST outreach/link-domains` */\nexport interface OutreachLinkDomainRequest {\n orgId: string\n domain: string\n action: 'set-up' | 'check' | 'remove'\n}\n\nexport interface OutreachLinkDomainResponse extends OutreachLinkDomainsResponse {\n /** The domain the action was for. */\n domain: string\n}\n\n/*==========================================\n * PREVIEW\n *==========================================*/\n\n/** `POST outreach/preview` — one email of a saved sequence, as a person would get it. */\nexport interface OutreachPreviewRequest {\n orgId: string\n sequenceId: string\n /** The contact to write it to; a sample person when absent. */\n contactId?: string\n /** Or a lead the sequence's site holds (AGL-3234), written to as the contact it would be. */\n leadId?: string\n personalLine?: string\n /** Which step; the first email when absent. */\n stepIndex?: number\n}\n\nexport interface OutreachPreviewResponse {\n ok: true\n stepIndex: number\n subject: string\n /** The plain text, footer included. */\n text: string\n unresolvedFields: string[]\n /** Why it could not be written, when it could not. */\n error: OutreachComposeError | null\n}\n"],"names":["OUTREACH_ENROLL_BATCH_MAX"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAmHD;;4CAE4C,GAE5C,qDAAqD,GACrD,OAAO,MAAMA,4BAA4B,GAAE"}
@@ -0,0 +1,65 @@
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
+ import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
18
+ import { type TrackingHostRecord, type TrackingHostResult } from '@aglyn/shared-util-email';
19
+ import type { OutreachRouteDeps } from './route-deps';
20
+ /**
21
+ * THE LINK DOMAINS ROUTE (AGL-3306): `outreach/link-domains`.
22
+ *
23
+ * A tracked sequence link reads `https://links.<the mailbox's domain>/<id>`
24
+ * once the organization has set that host up and it has verified — the same
25
+ * `links.` label, the same CNAME-and-check process, as a sending domain's
26
+ * campaign click tracking, with this deployment in place of the mail
27
+ * provider (`tracking-host.ts`). Until then it reads the console's own
28
+ * `/api/outreach/l/<id>`, as it always has.
29
+ *
30
+ * `GET ?orgId` lists one row per sending domain of the organization's
31
+ * connected mailboxes, plus any host still set up for a domain no mailbox
32
+ * sends as any more (so it can be removed). `POST` sets one up — the domain
33
+ * driver attaches the host — checks it, or removes it. Only a domain a
34
+ * mailbox of the organization sends as may be set up: the host is a claim
35
+ * about mail the organization actually sends.
36
+ *
37
+ * Setting up and removing change what this deployment serves, so they are
38
+ * an owner's or an admin's; checking changes nothing but the verdict, and
39
+ * the list is everyone's who may use Sequences.
40
+ */
41
+ /** The platform's tracking-host store (`@aglyn/tenant-data-admin`); specs build their own. */
42
+ export interface OutreachTrackingHosts {
43
+ list(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<TrackingHostRecord[]>;
44
+ setUp(input: HostInput): Promise<TrackingHostResult>;
45
+ verify(input: HostInput): Promise<TrackingHostResult>;
46
+ remove(input: HostInput): Promise<TrackingHostResult>;
47
+ }
48
+ interface HostInput {
49
+ firestore: FirebaseFirestore.Firestore;
50
+ orgId: string;
51
+ domain: string;
52
+ nowMs: number;
53
+ }
54
+ export interface OutreachLinkDomainRouteDeps {
55
+ hosts(): Promise<OutreachTrackingHosts>;
56
+ /** The console's own HTTPS origin, for the link prefix a domain without a host uses. */
57
+ consoleOrigin(): string | null;
58
+ }
59
+ /** The activity lines a change writes. */
60
+ export declare const OUTREACH_LINK_DOMAIN_ACTIVITY: {
61
+ readonly 'set-up': (host: string) => string;
62
+ readonly remove: (host: string) => string;
63
+ };
64
+ export declare function createOutreachLinkDomainsRoute(deps: OutreachRouteDeps, links: OutreachLinkDomainRouteDeps): PluginWebApiHandler;
65
+ export {};
@@ -0,0 +1,142 @@
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 { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { sendingTrackingHost, TRACKING_HOST_DETAIL, trackingHostDnsRecords, trackingHostLinkOrigin, validateTrackingHostDomain } from "@aglyn/shared-util-email";
18
+ import { OUTREACH_SHORT_LINK_PATH } from "../runtime/click-link.js";
19
+ import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
20
+ import { readStoredOutreachMailbox } from "../model/stored-records.js";
21
+ import { outreachRouteGate } from "./route-gate.js";
22
+ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from "./route-http.js";
23
+ /** The activity lines a change writes. */ export const OUTREACH_LINK_DOMAIN_ACTIVITY = {
24
+ 'set-up': (host)=>`Set up ${host} as a Sequences link domain`,
25
+ remove: (host)=>`Removed ${host} as a Sequences link domain`
26
+ };
27
+ /** The sending domain of every connected mailbox, alphabetically. */ async function mailboxDomains(firestore, orgId) {
28
+ const snapshot = await firestore.collection('orgs').doc(orgId).collection(OUTREACH_COLLECTIONS.mailboxes).get();
29
+ const domains = new Set();
30
+ for (const doc of snapshot.docs){
31
+ const mailbox = readStoredOutreachMailbox(doc.id, doc.data());
32
+ if (!mailbox) continue;
33
+ const domain = validateTrackingHostDomain(mailbox.sendAs || mailbox.email).domain;
34
+ if (domain) domains.add(domain);
35
+ }
36
+ return [
37
+ ...domains
38
+ ].sort();
39
+ }
40
+ function row(domain, record, consoleOrigin) {
41
+ var _ref, _ref1, _ref2, _ref3;
42
+ const origin = trackingHostLinkOrigin(record);
43
+ const detailKey = record == null ? void 0 : record.lastDetail;
44
+ return {
45
+ domain,
46
+ host: (_ref = record == null ? void 0 : record.host) != null ? _ref : sendingTrackingHost(domain),
47
+ status: (_ref1 = record == null ? void 0 : record.status) != null ? _ref1 : 'not-set-up',
48
+ records: record ? trackingHostDnsRecords(record).map(({ type, name, value, required, note })=>({
49
+ type,
50
+ name,
51
+ value,
52
+ required,
53
+ note
54
+ })) : [],
55
+ detail: detailKey && detailKey in TRACKING_HOST_DETAIL ? TRACKING_HOST_DETAIL[detailKey] : null,
56
+ linkPrefix: origin ? `${origin}/` : consoleOrigin ? `${consoleOrigin}${OUTREACH_SHORT_LINK_PATH}/` : null,
57
+ checkedAtMs: (_ref2 = record == null ? void 0 : record.lastCheckedAtMs) != null ? _ref2 : null,
58
+ verifiedAtMs: (_ref3 = record == null ? void 0 : record.verifiedAtMs) != null ? _ref3 : null
59
+ };
60
+ }
61
+ async function listing(deps, links, gate) {
62
+ const firestore = deps.firestore();
63
+ const [domains, records] = await Promise.all([
64
+ mailboxDomains(firestore, gate.orgId),
65
+ (await links.hosts()).list(firestore, gate.orgId)
66
+ ]);
67
+ const byDomain = new Map(records.map((record)=>[
68
+ record.domain,
69
+ record
70
+ ]));
71
+ const all = [
72
+ ...new Set([
73
+ ...domains,
74
+ ...byDomain.keys()
75
+ ])
76
+ ].sort();
77
+ const consoleOrigin = links.consoleOrigin();
78
+ return {
79
+ ok: true,
80
+ domains: all.map((domain)=>{
81
+ var _byDomain_get;
82
+ return row(domain, (_byDomain_get = byDomain.get(domain)) != null ? _byDomain_get : null, consoleOrigin);
83
+ }),
84
+ canManage: gate.isOrgAdmin
85
+ };
86
+ }
87
+ export function createOutreachLinkDomainsRoute(deps, links) {
88
+ return async (request)=>{
89
+ var _body_domain;
90
+ if (request.method === 'GET') {
91
+ const gate = await outreachRouteGate(request, new URL(request.url).searchParams.get('orgId'), deps.gate);
92
+ if (gate instanceof Response) return gate;
93
+ return outreachOk(await listing(deps, links, gate));
94
+ }
95
+ if (request.method !== 'POST') return outreachMethodNotAllowed('GET, POST');
96
+ const body = await readOutreachJsonBody(request);
97
+ const gate = await outreachRouteGate(request, body['orgId'], deps.gate);
98
+ if (gate instanceof Response) return gate;
99
+ const action = body['action'];
100
+ if (action !== 'set-up' && action !== 'check' && action !== 'remove') {
101
+ return outreachRefusal(400, 'invalid-request', 'Say whether to set the link domain up, check it, or remove it.');
102
+ }
103
+ if (action !== 'check' && !gate.isOrgAdmin) {
104
+ return outreachRefusal(403, 'permission', 'Only an owner or an admin can change link domains.');
105
+ }
106
+ const checked = validateTrackingHostDomain(String((_body_domain = body['domain']) != null ? _body_domain : ''));
107
+ if (!checked.domain) {
108
+ var _checked_error;
109
+ return outreachRefusal(400, 'invalid-domain', (_checked_error = checked.error) != null ? _checked_error : 'Type a domain, such as example.com.');
110
+ }
111
+ const domain = checked.domain;
112
+ const firestore = deps.firestore();
113
+ if (action === 'set-up' && !(await mailboxDomains(firestore, gate.orgId)).includes(domain)) {
114
+ return outreachRefusal(400, 'invalid-domain', `No connected mailbox sends as ${domain}. A link domain follows the address your sequences send from.`);
115
+ }
116
+ const hosts = await links.hosts();
117
+ const input = {
118
+ firestore,
119
+ orgId: gate.orgId,
120
+ domain,
121
+ nowMs: deps.now()
122
+ };
123
+ const result = action === 'set-up' ? await hosts.setUp(input) : action === 'check' ? await hosts.verify(input) : await hosts.remove(input);
124
+ if (result.error) {
125
+ return outreachRefusal(result.status >= 400 ? result.status : 409, 'link-domain-refused', result.error);
126
+ }
127
+ if (action !== 'check') {
128
+ await deps.logOrgActivity(gate.orgId, {
129
+ uid: gate.uid,
130
+ email: gate.email
131
+ }, OUTREACH_LINK_DOMAIN_ACTIVITY[action](sendingTrackingHost(domain)), {
132
+ type: 'org',
133
+ id: gate.orgId
134
+ });
135
+ }
136
+ return outreachOk(_extends({}, await listing(deps, links, gate), {
137
+ domain
138
+ }));
139
+ };
140
+ }
141
+
142
+ //# sourceMappingURL=link-domain-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/link-domain-routes.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 type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport {\n sendingTrackingHost,\n TRACKING_HOST_DETAIL,\n trackingHostDnsRecords,\n trackingHostLinkOrigin,\n validateTrackingHostDomain,\n type TrackingHostRecord,\n type TrackingHostResult,\n} from '@aglyn/shared-util-email'\nimport { OUTREACH_SHORT_LINK_PATH } from '../runtime/click-link'\nimport type {\n OutreachLinkDomain,\n OutreachLinkDomainRequest,\n OutreachLinkDomainResponse,\n OutreachLinkDomainsResponse,\n} from '../model/outreach-api'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport { readStoredOutreachMailbox } from '../model/stored-records'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * THE LINK DOMAINS ROUTE (AGL-3306): `outreach/link-domains`.\n *\n * A tracked sequence link reads `https://links.<the mailbox's domain>/<id>`\n * once the organization has set that host up and it has verified — the same\n * `links.` label, the same CNAME-and-check process, as a sending domain's\n * campaign click tracking, with this deployment in place of the mail\n * provider (`tracking-host.ts`). Until then it reads the console's own\n * `/api/outreach/l/<id>`, as it always has.\n *\n * `GET ?orgId` lists one row per sending domain of the organization's\n * connected mailboxes, plus any host still set up for a domain no mailbox\n * sends as any more (so it can be removed). `POST` sets one up — the domain\n * driver attaches the host — checks it, or removes it. Only a domain a\n * mailbox of the organization sends as may be set up: the host is a claim\n * about mail the organization actually sends.\n *\n * Setting up and removing change what this deployment serves, so they are\n * an owner's or an admin's; checking changes nothing but the verdict, and\n * the list is everyone's who may use Sequences.\n */\n\n/** The platform's tracking-host store (`@aglyn/tenant-data-admin`); specs build their own. */\nexport interface OutreachTrackingHosts {\n list(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<TrackingHostRecord[]>\n setUp(input: HostInput): Promise<TrackingHostResult>\n verify(input: HostInput): Promise<TrackingHostResult>\n remove(input: HostInput): Promise<TrackingHostResult>\n}\n\ninterface HostInput {\n firestore: FirebaseFirestore.Firestore\n orgId: string\n domain: string\n nowMs: number\n}\n\nexport interface OutreachLinkDomainRouteDeps {\n hosts(): Promise<OutreachTrackingHosts>\n /** The console's own HTTPS origin, for the link prefix a domain without a host uses. */\n consoleOrigin(): string | null\n}\n\n/** The activity lines a change writes. */\nexport const OUTREACH_LINK_DOMAIN_ACTIVITY = {\n 'set-up': (host: string) => `Set up ${host} as a Sequences link domain`,\n remove: (host: string) => `Removed ${host} as a Sequences link domain`,\n} as const\n\n/** The sending domain of every connected mailbox, alphabetically. */\nasync function mailboxDomains(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<string[]> {\n const snapshot = await firestore.collection('orgs').doc(orgId).collection(OUTREACH_COLLECTIONS.mailboxes).get()\n const domains = new Set<string>()\n for (const doc of snapshot.docs) {\n const mailbox = readStoredOutreachMailbox(doc.id, doc.data())\n if (!mailbox) continue\n const domain = validateTrackingHostDomain(mailbox.sendAs || mailbox.email).domain\n if (domain) domains.add(domain)\n }\n return [...domains].sort()\n}\n\nfunction row(domain: string, record: TrackingHostRecord | null, consoleOrigin: string | null): OutreachLinkDomain {\n const origin = trackingHostLinkOrigin(record)\n const detailKey = record?.lastDetail as keyof typeof TRACKING_HOST_DETAIL | null | undefined\n return {\n domain,\n host: record?.host ?? sendingTrackingHost(domain),\n status: record?.status ?? 'not-set-up',\n records: record\n ? trackingHostDnsRecords(record).map(({ type, name, value, required, note }) => ({\n type,\n name,\n value,\n required,\n note,\n }))\n : [],\n detail: detailKey && detailKey in TRACKING_HOST_DETAIL ? TRACKING_HOST_DETAIL[detailKey] : null,\n linkPrefix: origin ? `${origin}/` : consoleOrigin ? `${consoleOrigin}${OUTREACH_SHORT_LINK_PATH}/` : null,\n checkedAtMs: record?.lastCheckedAtMs ?? null,\n verifiedAtMs: record?.verifiedAtMs ?? null,\n }\n}\n\nasync function listing(\n deps: OutreachRouteDeps,\n links: OutreachLinkDomainRouteDeps,\n gate: OutreachRouteCaller,\n): Promise<OutreachLinkDomainsResponse> {\n const firestore = deps.firestore()\n const [domains, records] = await Promise.all([\n mailboxDomains(firestore, gate.orgId),\n (await links.hosts()).list(firestore, gate.orgId),\n ])\n const byDomain = new Map(records.map((record) => [record.domain, record]))\n const all = [...new Set([...domains, ...byDomain.keys()])].sort()\n const consoleOrigin = links.consoleOrigin()\n return {\n ok: true,\n domains: all.map((domain) => row(domain, byDomain.get(domain) ?? null, consoleOrigin)),\n canManage: gate.isOrgAdmin,\n }\n}\n\nexport function createOutreachLinkDomainsRoute(\n deps: OutreachRouteDeps,\n links: OutreachLinkDomainRouteDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method === 'GET') {\n const gate = await outreachRouteGate(request, new URL(request.url).searchParams.get('orgId'), deps.gate)\n if (gate instanceof Response) return gate\n return outreachOk(await listing(deps, links, gate))\n }\n if (request.method !== 'POST') return outreachMethodNotAllowed('GET, POST')\n\n const body = await readOutreachJsonBody(request)\n const gate = await outreachRouteGate(request, body['orgId'], deps.gate)\n if (gate instanceof Response) return gate\n const action = body['action'] as OutreachLinkDomainRequest['action']\n if (action !== 'set-up' && action !== 'check' && action !== 'remove') {\n return outreachRefusal(400, 'invalid-request', 'Say whether to set the link domain up, check it, or remove it.')\n }\n if (action !== 'check' && !gate.isOrgAdmin) {\n return outreachRefusal(403, 'permission', 'Only an owner or an admin can change link domains.')\n }\n const checked = validateTrackingHostDomain(String(body['domain'] ?? ''))\n if (!checked.domain) {\n return outreachRefusal(400, 'invalid-domain', checked.error ?? 'Type a domain, such as example.com.')\n }\n const domain = checked.domain\n const firestore = deps.firestore()\n if (action === 'set-up' && !(await mailboxDomains(firestore, gate.orgId)).includes(domain)) {\n return outreachRefusal(\n 400,\n 'invalid-domain',\n `No connected mailbox sends as ${domain}. A link domain follows the address your sequences send from.`,\n )\n }\n\n const hosts = await links.hosts()\n const input = { firestore, orgId: gate.orgId, domain, nowMs: deps.now() }\n const result =\n action === 'set-up' ? await hosts.setUp(input) : action === 'check' ? await hosts.verify(input) : await hosts.remove(input)\n if (result.error) {\n return outreachRefusal(result.status >= 400 ? result.status : 409, 'link-domain-refused', result.error)\n }\n if (action !== 'check') {\n await deps.logOrgActivity(\n gate.orgId,\n { uid: gate.uid, email: gate.email },\n OUTREACH_LINK_DOMAIN_ACTIVITY[action](sendingTrackingHost(domain)),\n { type: 'org', id: gate.orgId },\n )\n }\n return outreachOk({ ...(await listing(deps, links, gate)), domain } satisfies OutreachLinkDomainResponse)\n }\n}\n"],"names":["sendingTrackingHost","TRACKING_HOST_DETAIL","trackingHostDnsRecords","trackingHostLinkOrigin","validateTrackingHostDomain","OUTREACH_SHORT_LINK_PATH","OUTREACH_COLLECTIONS","readStoredOutreachMailbox","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachJsonBody","OUTREACH_LINK_DOMAIN_ACTIVITY","host","remove","mailboxDomains","firestore","orgId","snapshot","collection","doc","mailboxes","get","domains","Set","docs","mailbox","id","data","domain","sendAs","email","add","sort","row","record","consoleOrigin","origin","detailKey","lastDetail","status","records","map","type","name","value","required","note","detail","linkPrefix","checkedAtMs","lastCheckedAtMs","verifiedAtMs","listing","deps","links","gate","Promise","all","hosts","list","byDomain","Map","keys","ok","canManage","isOrgAdmin","createOutreachLinkDomainsRoute","request","body","method","URL","url","searchParams","Response","action","checked","String","error","includes","input","nowMs","now","result","setUp","verify","logOrgActivity","uid"],"mappings":"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"}