@aglyn/plugins-outreach 1.0.0-beta.156 → 1.0.0-beta.157
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -12
- package/src/lib/components/enrollments-table.d.ts +7 -0
- package/src/lib/components/enrollments-table.js +46 -2
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/sequence-detail.js +14 -4
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-editor.js +22 -0
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/sequence-report-card.d.ts +23 -0
- package/src/lib/components/sequence-report-card.js +191 -0
- package/src/lib/components/sequence-report-card.js.map +1 -0
- package/src/lib/components/use-outreach-data.d.ts +11 -0
- package/src/lib/components/use-outreach-data.js +24 -1
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +9 -0
- package/src/lib/constants/api-routes.js +9 -1
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/click-tracking.d.ts +98 -0
- package/src/lib/engine/click-tracking.js +252 -0
- package/src/lib/engine/click-tracking.js.map +1 -0
- package/src/lib/engine/compose.d.ts +24 -0
- package/src/lib/engine/compose.js +15 -2
- package/src/lib/engine/compose.js.map +1 -1
- package/src/lib/engine/sequence-validation.js +4 -1
- package/src/lib/engine/sequence-validation.js.map +1 -1
- package/src/lib/model/enrollment-engagement.d.ts +31 -0
- package/src/lib/model/enrollment-engagement.js +44 -0
- package/src/lib/model/enrollment-engagement.js.map +1 -0
- package/src/lib/model/outreach.types.d.ts +89 -0
- package/src/lib/model/outreach.types.js +25 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-draft.js +5 -1
- package/src/lib/model/sequence-draft.js.map +1 -1
- package/src/lib/model/sequence-report.d.ts +68 -0
- package/src/lib/model/sequence-report.js +118 -0
- package/src/lib/model/sequence-report.js.map +1 -0
- package/src/lib/model/stored-records.d.ts +11 -1
- package/src/lib/model/stored-records.js +34 -1
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +4 -1
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/click-events.d.ts +67 -0
- package/src/lib/runtime/click-events.js +232 -0
- package/src/lib/runtime/click-events.js.map +1 -0
- package/src/lib/runtime/click-link.d.ts +94 -0
- package/src/lib/runtime/click-link.js +158 -0
- package/src/lib/runtime/click-link.js.map +1 -0
- package/src/lib/runtime/click-route.d.ts +19 -0
- package/src/lib/runtime/click-route.js +108 -0
- package/src/lib/runtime/click-route.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.d.ts +2 -0
- package/src/lib/runtime/platform-runtime-deps.js +9 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +11 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +65 -5
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/server.js +8 -0
- package/src/lib/server.js.map +1 -1
|
@@ -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 /** `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} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","preview","unsubscribe"],"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,8EAA8E,GAC9EC,SAAS;IACT,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;
|
|
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 /** `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} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","preview","unsubscribe","click"],"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,8EAA8E,GAC9EC,SAAS;IACT,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;AACT,EAAU"}
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* A URL as it appears in a plain-text body, with where it was found.
|
|
19
|
+
*
|
|
20
|
+
* `end` is exclusive, so `text.slice(start, end)` is exactly `url`.
|
|
21
|
+
*/
|
|
22
|
+
export interface OutreachBodyLink {
|
|
23
|
+
url: string;
|
|
24
|
+
start: number;
|
|
25
|
+
end: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Every http(s) URL in a plain-text body, in the order they appear.
|
|
29
|
+
*
|
|
30
|
+
* Duplicates are returned as the separate occurrences they are: the same
|
|
31
|
+
* destination written twice is two links to rewrite, and each gets its own
|
|
32
|
+
* index so a click says which one was followed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function outreachBodyLinks(text: string): OutreachBodyLink[];
|
|
35
|
+
/** What {@link rewriteOutreachBodyLinks} produced. */
|
|
36
|
+
export interface OutreachRewrittenBody {
|
|
37
|
+
text: string;
|
|
38
|
+
/**
|
|
39
|
+
* The destinations that were rewritten, in the order their tracking links
|
|
40
|
+
* were minted — the index a click token names.
|
|
41
|
+
*
|
|
42
|
+
* Empty when nothing was rewritten, which is what a body with no links, a
|
|
43
|
+
* sequence with tracking off, and a minter that could not sign all read as.
|
|
44
|
+
*/
|
|
45
|
+
links: string[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Rewrites every link in a plain-text body to a tracking link.
|
|
49
|
+
*
|
|
50
|
+
* @param text The rendered step body, footer NOT yet appended.
|
|
51
|
+
* @param mint Answers the tracking URL for the link at `index`, or `null` to
|
|
52
|
+
* leave that one alone. A minter that cannot sign returns `null` for every
|
|
53
|
+
* link and the body goes out exactly as it was written — an email whose
|
|
54
|
+
* links do not work is far worse than an email we cannot measure.
|
|
55
|
+
*/
|
|
56
|
+
export declare function rewriteOutreachBodyLinks(text: string, mint: (link: {
|
|
57
|
+
url: string;
|
|
58
|
+
index: number;
|
|
59
|
+
}) => string | null): OutreachRewrittenBody;
|
|
60
|
+
/**
|
|
61
|
+
* How soon after a send a click is a machine's however it identifies itself.
|
|
62
|
+
*
|
|
63
|
+
* A scanner fetches as the message is delivered. A person has to receive the
|
|
64
|
+
* mail, notice it and read it, and thirty seconds is faster than anyone does
|
|
65
|
+
* that on a cold email — while being long enough to stay wrong only in the
|
|
66
|
+
* rarest case, which is a rate that reads a little low rather than a rate
|
|
67
|
+
* that reads triple.
|
|
68
|
+
*/
|
|
69
|
+
export declare const OUTREACH_CLICK_HUMAN_DELAY_MS = 30000;
|
|
70
|
+
/** Why a click was read as a machine's, or `null` when it was a person's. */
|
|
71
|
+
export type OutreachClickMachineReason = 'agent' | 'too_soon' | 'method';
|
|
72
|
+
/** What one request to a tracking link was. */
|
|
73
|
+
export interface OutreachClickJudgement {
|
|
74
|
+
/** Whether it counts toward the click rate. */
|
|
75
|
+
human: boolean;
|
|
76
|
+
/** Why it does not; `null` when it does. */
|
|
77
|
+
machineReason: OutreachClickMachineReason | null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether one request to a tracking link is a person's.
|
|
81
|
+
*
|
|
82
|
+
* Read before the redirect is *recorded*, never before it is *answered*: a
|
|
83
|
+
* machine is forwarded to the destination exactly as a person is, because a
|
|
84
|
+
* scanner that gets a refusal reports the link as broken and the mail as
|
|
85
|
+
* suspicious.
|
|
86
|
+
*
|
|
87
|
+
* @param input.method The HTTP method. A `HEAD` is a fetch of the headers
|
|
88
|
+
* alone, which no mail client does for a person following a link.
|
|
89
|
+
* @param input.userAgent The request's agent, `''` when it sent none — which
|
|
90
|
+
* is itself a machine, since every browser sends one.
|
|
91
|
+
* @param input.sinceSentMs Milliseconds between the send and this request,
|
|
92
|
+
* or `null` when the send it belongs to could not be found.
|
|
93
|
+
*/
|
|
94
|
+
export declare function judgeOutreachClick(input: {
|
|
95
|
+
method: string;
|
|
96
|
+
userAgent: string | null | undefined;
|
|
97
|
+
sinceSentMs: number | null;
|
|
98
|
+
}): OutreachClickJudgement;
|
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
* CLICKS, IN A PLAIN-TEXT EMAIL (AGL-3239).
|
|
18
|
+
*
|
|
19
|
+
* A marketing campaign gets its opens and clicks from Resend, which rewrites
|
|
20
|
+
* the links and injects a pixel into the HTML part before the message leaves.
|
|
21
|
+
* A sequence has neither half of that: it goes out through the rep's own
|
|
22
|
+
* Gmail, and it goes out as plain text on purpose.
|
|
23
|
+
*
|
|
24
|
+
* ## Why there is no open rate here, and never a zero one
|
|
25
|
+
*
|
|
26
|
+
* An open is measured by a pixel, a pixel is an `<img>`, and an `<img>` needs
|
|
27
|
+
* an HTML part. Giving cold one-to-one email a multipart HTML body is the
|
|
28
|
+
* deliverability posture this plugin was built to avoid, so opens are not
|
|
29
|
+
* measured at all — and the report SAYS SO rather than rendering 0%, exactly
|
|
30
|
+
* as `campaign-report.ts` withholds a rate whose denominator was never
|
|
31
|
+
* recorded. A zero that could only ever have been zero measures our sending
|
|
32
|
+
* code, not the recipient.
|
|
33
|
+
*
|
|
34
|
+
* A click needs no HTML. It needs the destination in the body to be a link of
|
|
35
|
+
* ours that forwards to theirs, which is what this module writes.
|
|
36
|
+
*
|
|
37
|
+
* ## What it will and will not rewrite
|
|
38
|
+
*
|
|
39
|
+
* Only bare `http:`/`https:` URLs, and only in the STEP'S OWN BODY — the
|
|
40
|
+
* rewrite runs on the rendered body before the footer is appended, so the
|
|
41
|
+
* organization's legal name, its postal address and the way out are never
|
|
42
|
+
* touched. `mailto:` is left alone: the opt-out address is the one link in a
|
|
43
|
+
* cold email that must not route through us.
|
|
44
|
+
*==========================================*/ /**
|
|
45
|
+
* A URL as it appears in a plain-text body, with where it was found.
|
|
46
|
+
*
|
|
47
|
+
* `end` is exclusive, so `text.slice(start, end)` is exactly `url`.
|
|
48
|
+
*/ /**
|
|
49
|
+
* Trailing characters a sentence puts after a URL that are not part of it.
|
|
50
|
+
*
|
|
51
|
+
* A plain-text body is prose, so a link is routinely followed by a full stop,
|
|
52
|
+
* a comma or a closing bracket. Taking those into the URL would rewrite a
|
|
53
|
+
* destination nobody wrote and forward the recipient to a 404.
|
|
54
|
+
*/ const TRAILING_PUNCTUATION = /[.,;:!?'"]+$/;
|
|
55
|
+
/**
|
|
56
|
+
* A bare http(s) URL, up to the first whitespace or bracket that never
|
|
57
|
+
* belongs in one.
|
|
58
|
+
*
|
|
59
|
+
* `(` and `)` are NOT excluded, because a real path can contain them — a
|
|
60
|
+
* Wikipedia article is the everyday example — and {@link trimUrlEnd}
|
|
61
|
+
* decides which trailing one is the URL's and which is the sentence's.
|
|
62
|
+
*/ const URL_PATTERN = /https?:\/\/[^\s<>[\]{}"']+/gi;
|
|
63
|
+
/**
|
|
64
|
+
* Trims what the sentence put after the URL, not what the URL contains.
|
|
65
|
+
*
|
|
66
|
+
* A closing bracket is the URL's when the URL opened one, and the
|
|
67
|
+
* sentence's when it did not — which is the usual case, a link inside a
|
|
68
|
+
* parenthetical. Counted rather than merely looked for, so a link that is
|
|
69
|
+
* both parenthesised AND contains brackets loses exactly the outer one.
|
|
70
|
+
*/ function trimUrlEnd(raw) {
|
|
71
|
+
let url = raw.replace(TRAILING_PUNCTUATION, '');
|
|
72
|
+
while(url.endsWith(')')){
|
|
73
|
+
const opened = url.split('(').length - 1;
|
|
74
|
+
const closed = url.split(')').length - 1;
|
|
75
|
+
if (closed <= opened) break;
|
|
76
|
+
url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, '');
|
|
77
|
+
}
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Every http(s) URL in a plain-text body, in the order they appear.
|
|
82
|
+
*
|
|
83
|
+
* Duplicates are returned as the separate occurrences they are: the same
|
|
84
|
+
* destination written twice is two links to rewrite, and each gets its own
|
|
85
|
+
* index so a click says which one was followed.
|
|
86
|
+
*/ export function outreachBodyLinks(text) {
|
|
87
|
+
const body = String(text != null ? text : '');
|
|
88
|
+
const links = [];
|
|
89
|
+
for (const match of body.matchAll(URL_PATTERN)){
|
|
90
|
+
var _match_index;
|
|
91
|
+
const start = (_match_index = match.index) != null ? _match_index : 0;
|
|
92
|
+
const url = trimUrlEnd(match[0]);
|
|
93
|
+
if (!url) continue;
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = new URL(url);
|
|
97
|
+
} catch (unused) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
|
|
101
|
+
links.push({
|
|
102
|
+
url,
|
|
103
|
+
start,
|
|
104
|
+
end: start + url.length
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return links;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Rewrites every link in a plain-text body to a tracking link.
|
|
111
|
+
*
|
|
112
|
+
* @param text The rendered step body, footer NOT yet appended.
|
|
113
|
+
* @param mint Answers the tracking URL for the link at `index`, or `null` to
|
|
114
|
+
* leave that one alone. A minter that cannot sign returns `null` for every
|
|
115
|
+
* link and the body goes out exactly as it was written — an email whose
|
|
116
|
+
* links do not work is far worse than an email we cannot measure.
|
|
117
|
+
*/ export function rewriteOutreachBodyLinks(text, mint) {
|
|
118
|
+
const body = String(text != null ? text : '');
|
|
119
|
+
const found = outreachBodyLinks(body);
|
|
120
|
+
if (!found.length) return {
|
|
121
|
+
text: body,
|
|
122
|
+
links: []
|
|
123
|
+
};
|
|
124
|
+
const links = [];
|
|
125
|
+
let out = '';
|
|
126
|
+
let cursor = 0;
|
|
127
|
+
for (const link of found){
|
|
128
|
+
// The index a token carries is the index in `links`, which counts only
|
|
129
|
+
// the links that were actually rewritten. A minter that skips one must
|
|
130
|
+
// not shift the numbering of the ones after it.
|
|
131
|
+
const tracked = mint({
|
|
132
|
+
url: link.url,
|
|
133
|
+
index: links.length
|
|
134
|
+
});
|
|
135
|
+
out += body.slice(cursor, link.start);
|
|
136
|
+
if (tracked) {
|
|
137
|
+
out += tracked;
|
|
138
|
+
links.push(link.url);
|
|
139
|
+
} else {
|
|
140
|
+
out += link.url;
|
|
141
|
+
}
|
|
142
|
+
cursor = link.end;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
text: out + body.slice(cursor),
|
|
146
|
+
links
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/*==========================================
|
|
150
|
+
* WHO CLICKED: A PERSON, OR A MACHINE.
|
|
151
|
+
*
|
|
152
|
+
* Every corporate mailbox now runs the links in an incoming email before the
|
|
153
|
+
* recipient ever sees it — Microsoft Defender's Safe Links, Proofpoint,
|
|
154
|
+
* Mimecast, Barracuda, and Gmail's own scanning. Each of those fetches is a
|
|
155
|
+
* click by any definition a redirect handler can apply, and on cold B2B mail
|
|
156
|
+
* they outnumber the human ones.
|
|
157
|
+
*
|
|
158
|
+
* Counting them is how a sequence reports a 60% click rate on an email
|
|
159
|
+
* nobody read. So they are counted APART: `clicks` and `uniqueClicks` are
|
|
160
|
+
* the human ones and carry the rate, `machineClicks` is kept beside them and
|
|
161
|
+
* shown, because a number withheld silently is the other way to mislead.
|
|
162
|
+
*
|
|
163
|
+
* This is the same judgement `campaign-revenue.ts` records about Apple's Mail
|
|
164
|
+
* Privacy Protection inflating opens — stated on the screen rather than
|
|
165
|
+
* quietly corrected away.
|
|
166
|
+
*==========================================*/ /**
|
|
167
|
+
* How soon after a send a click is a machine's however it identifies itself.
|
|
168
|
+
*
|
|
169
|
+
* A scanner fetches as the message is delivered. A person has to receive the
|
|
170
|
+
* mail, notice it and read it, and thirty seconds is faster than anyone does
|
|
171
|
+
* that on a cold email — while being long enough to stay wrong only in the
|
|
172
|
+
* rarest case, which is a rate that reads a little low rather than a rate
|
|
173
|
+
* that reads triple.
|
|
174
|
+
*/ export const OUTREACH_CLICK_HUMAN_DELAY_MS = 30000;
|
|
175
|
+
/**
|
|
176
|
+
* Agents that say what they are. Matched case-insensitively as substrings of
|
|
177
|
+
* the user agent, so a version bump does not un-match one.
|
|
178
|
+
*
|
|
179
|
+
* Absence from this list is not evidence of a person — most scanners
|
|
180
|
+
* impersonate a browser, which is what {@link OUTREACH_CLICK_HUMAN_DELAY_MS}
|
|
181
|
+
* is for.
|
|
182
|
+
*/ const MACHINE_AGENTS = [
|
|
183
|
+
'bot',
|
|
184
|
+
'crawler',
|
|
185
|
+
'spider',
|
|
186
|
+
'slurp',
|
|
187
|
+
'preview',
|
|
188
|
+
'scanner',
|
|
189
|
+
'curl',
|
|
190
|
+
'wget',
|
|
191
|
+
'python-requests',
|
|
192
|
+
'java/',
|
|
193
|
+
'go-http-client',
|
|
194
|
+
'okhttp',
|
|
195
|
+
'headlesschrome',
|
|
196
|
+
'phantomjs',
|
|
197
|
+
'proofpoint',
|
|
198
|
+
'mimecast',
|
|
199
|
+
'barracuda',
|
|
200
|
+
'symantec',
|
|
201
|
+
'forcepoint',
|
|
202
|
+
'microsoft office',
|
|
203
|
+
'ms-office',
|
|
204
|
+
'bingpreview',
|
|
205
|
+
'skypeuripreview',
|
|
206
|
+
'google-safety',
|
|
207
|
+
'googleimageproxy',
|
|
208
|
+
'apache-httpclient',
|
|
209
|
+
'axios',
|
|
210
|
+
'node-fetch',
|
|
211
|
+
'urlresolver',
|
|
212
|
+
'safelinks'
|
|
213
|
+
];
|
|
214
|
+
/**
|
|
215
|
+
* Whether one request to a tracking link is a person's.
|
|
216
|
+
*
|
|
217
|
+
* Read before the redirect is *recorded*, never before it is *answered*: a
|
|
218
|
+
* machine is forwarded to the destination exactly as a person is, because a
|
|
219
|
+
* scanner that gets a refusal reports the link as broken and the mail as
|
|
220
|
+
* suspicious.
|
|
221
|
+
*
|
|
222
|
+
* @param input.method The HTTP method. A `HEAD` is a fetch of the headers
|
|
223
|
+
* alone, which no mail client does for a person following a link.
|
|
224
|
+
* @param input.userAgent The request's agent, `''` when it sent none — which
|
|
225
|
+
* is itself a machine, since every browser sends one.
|
|
226
|
+
* @param input.sinceSentMs Milliseconds between the send and this request,
|
|
227
|
+
* or `null` when the send it belongs to could not be found.
|
|
228
|
+
*/ export function judgeOutreachClick(input) {
|
|
229
|
+
var _input_method, _input_userAgent;
|
|
230
|
+
const machine = (machineReason)=>({
|
|
231
|
+
human: false,
|
|
232
|
+
machineReason
|
|
233
|
+
});
|
|
234
|
+
if (String((_input_method = input.method) != null ? _input_method : '').toUpperCase() === 'HEAD') return machine('method');
|
|
235
|
+
const agent = String((_input_userAgent = input.userAgent) != null ? _input_userAgent : '').trim().toLowerCase();
|
|
236
|
+
if (!agent) return machine('agent');
|
|
237
|
+
if (MACHINE_AGENTS.some((needle)=>agent.includes(needle))) return machine('agent');
|
|
238
|
+
/*
|
|
239
|
+
* A click that arrives BEFORE the send it belongs to is a clock disagreeing
|
|
240
|
+
* with itself, not a time traveller, and it is read as a person's: the one
|
|
241
|
+
* thing worse than counting a scanner is refusing to count a real reply to
|
|
242
|
+
* an email because two machines are a second apart.
|
|
243
|
+
*/ if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_CLICK_HUMAN_DELAY_MS) {
|
|
244
|
+
return machine('too_soon');
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
human: true,
|
|
248
|
+
machineReason: null
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
//# sourceMappingURL=click-tracking.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/click-tracking.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * CLICKS, IN A PLAIN-TEXT EMAIL (AGL-3239).\n *\n * A marketing campaign gets its opens and clicks from Resend, which rewrites\n * the links and injects a pixel into the HTML part before the message leaves.\n * A sequence has neither half of that: it goes out through the rep's own\n * Gmail, and it goes out as plain text on purpose.\n *\n * ## Why there is no open rate here, and never a zero one\n *\n * An open is measured by a pixel, a pixel is an `<img>`, and an `<img>` needs\n * an HTML part. Giving cold one-to-one email a multipart HTML body is the\n * deliverability posture this plugin was built to avoid, so opens are not\n * measured at all — and the report SAYS SO rather than rendering 0%, exactly\n * as `campaign-report.ts` withholds a rate whose denominator was never\n * recorded. A zero that could only ever have been zero measures our sending\n * code, not the recipient.\n *\n * A click needs no HTML. It needs the destination in the body to be a link of\n * ours that forwards to theirs, which is what this module writes.\n *\n * ## What it will and will not rewrite\n *\n * Only bare `http:`/`https:` URLs, and only in the STEP'S OWN BODY — the\n * rewrite runs on the rendered body before the footer is appended, so the\n * organization's legal name, its postal address and the way out are never\n * touched. `mailto:` is left alone: the opt-out address is the one link in a\n * cold email that must not route through us.\n *==========================================*/\n\n/**\n * A URL as it appears in a plain-text body, with where it was found.\n *\n * `end` is exclusive, so `text.slice(start, end)` is exactly `url`.\n */\nexport interface OutreachBodyLink {\n url: string\n start: number\n end: number\n}\n\n/**\n * Trailing characters a sentence puts after a URL that are not part of it.\n *\n * A plain-text body is prose, so a link is routinely followed by a full stop,\n * a comma or a closing bracket. Taking those into the URL would rewrite a\n * destination nobody wrote and forward the recipient to a 404.\n */\nconst TRAILING_PUNCTUATION = /[.,;:!?'\"]+$/\n\n/**\n * A bare http(s) URL, up to the first whitespace or bracket that never\n * belongs in one.\n *\n * `(` and `)` are NOT excluded, because a real path can contain them — a\n * Wikipedia article is the everyday example — and {@link trimUrlEnd}\n * decides which trailing one is the URL's and which is the sentence's.\n */\nconst URL_PATTERN = /https?:\\/\\/[^\\s<>[\\]{}\"']+/gi\n\n/**\n * Trims what the sentence put after the URL, not what the URL contains.\n *\n * A closing bracket is the URL's when the URL opened one, and the\n * sentence's when it did not — which is the usual case, a link inside a\n * parenthetical. Counted rather than merely looked for, so a link that is\n * both parenthesised AND contains brackets loses exactly the outer one.\n */\nfunction trimUrlEnd(raw: string): string {\n let url = raw.replace(TRAILING_PUNCTUATION, '')\n while (url.endsWith(')')) {\n const opened = url.split('(').length - 1\n const closed = url.split(')').length - 1\n if (closed <= opened) break\n url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, '')\n }\n return url\n}\n\n/**\n * Every http(s) URL in a plain-text body, in the order they appear.\n *\n * Duplicates are returned as the separate occurrences they are: the same\n * destination written twice is two links to rewrite, and each gets its own\n * index so a click says which one was followed.\n */\nexport function outreachBodyLinks(text: string): OutreachBodyLink[] {\n const body = String(text ?? '')\n const links: OutreachBodyLink[] = []\n for (const match of body.matchAll(URL_PATTERN)) {\n const start = match.index ?? 0\n const url = trimUrlEnd(match[0])\n if (!url) continue\n let parsed: URL\n try {\n parsed = new URL(url)\n } catch {\n continue\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue\n links.push({ url, start, end: start + url.length })\n }\n return links\n}\n\n/** What {@link rewriteOutreachBodyLinks} produced. */\nexport interface OutreachRewrittenBody {\n text: string\n /**\n * The destinations that were rewritten, in the order their tracking links\n * were minted — the index a click token names.\n *\n * Empty when nothing was rewritten, which is what a body with no links, a\n * sequence with tracking off, and a minter that could not sign all read as.\n */\n links: string[]\n}\n\n/**\n * Rewrites every link in a plain-text body to a tracking link.\n *\n * @param text The rendered step body, footer NOT yet appended.\n * @param mint Answers the tracking URL for the link at `index`, or `null` to\n * leave that one alone. A minter that cannot sign returns `null` for every\n * link and the body goes out exactly as it was written — an email whose\n * links do not work is far worse than an email we cannot measure.\n */\nexport function rewriteOutreachBodyLinks(\n text: string,\n mint: (link: { url: string; index: number }) => string | null,\n): OutreachRewrittenBody {\n const body = String(text ?? '')\n const found = outreachBodyLinks(body)\n if (!found.length) return { text: body, links: [] }\n const links: string[] = []\n let out = ''\n let cursor = 0\n for (const link of found) {\n // The index a token carries is the index in `links`, which counts only\n // the links that were actually rewritten. A minter that skips one must\n // not shift the numbering of the ones after it.\n const tracked = mint({ url: link.url, index: links.length })\n out += body.slice(cursor, link.start)\n if (tracked) {\n out += tracked\n links.push(link.url)\n } else {\n out += link.url\n }\n cursor = link.end\n }\n return { text: out + body.slice(cursor), links }\n}\n\n/*==========================================\n * WHO CLICKED: A PERSON, OR A MACHINE.\n *\n * Every corporate mailbox now runs the links in an incoming email before the\n * recipient ever sees it — Microsoft Defender's Safe Links, Proofpoint,\n * Mimecast, Barracuda, and Gmail's own scanning. Each of those fetches is a\n * click by any definition a redirect handler can apply, and on cold B2B mail\n * they outnumber the human ones.\n *\n * Counting them is how a sequence reports a 60% click rate on an email\n * nobody read. So they are counted APART: `clicks` and `uniqueClicks` are\n * the human ones and carry the rate, `machineClicks` is kept beside them and\n * shown, because a number withheld silently is the other way to mislead.\n *\n * This is the same judgement `campaign-revenue.ts` records about Apple's Mail\n * Privacy Protection inflating opens — stated on the screen rather than\n * quietly corrected away.\n *==========================================*/\n\n/**\n * How soon after a send a click is a machine's however it identifies itself.\n *\n * A scanner fetches as the message is delivered. A person has to receive the\n * mail, notice it and read it, and thirty seconds is faster than anyone does\n * that on a cold email — while being long enough to stay wrong only in the\n * rarest case, which is a rate that reads a little low rather than a rate\n * that reads triple.\n */\nexport const OUTREACH_CLICK_HUMAN_DELAY_MS = 30_000\n\n/**\n * Agents that say what they are. Matched case-insensitively as substrings of\n * the user agent, so a version bump does not un-match one.\n *\n * Absence from this list is not evidence of a person — most scanners\n * impersonate a browser, which is what {@link OUTREACH_CLICK_HUMAN_DELAY_MS}\n * is for.\n */\nconst MACHINE_AGENTS = [\n 'bot',\n 'crawler',\n 'spider',\n 'slurp',\n 'preview',\n 'scanner',\n 'curl',\n 'wget',\n 'python-requests',\n 'java/',\n 'go-http-client',\n 'okhttp',\n 'headlesschrome',\n 'phantomjs',\n 'proofpoint',\n 'mimecast',\n 'barracuda',\n 'symantec',\n 'forcepoint',\n 'microsoft office',\n 'ms-office',\n 'bingpreview',\n 'skypeuripreview',\n 'google-safety',\n 'googleimageproxy',\n 'apache-httpclient',\n 'axios',\n 'node-fetch',\n 'urlresolver',\n 'safelinks',\n]\n\n/** Why a click was read as a machine's, or `null` when it was a person's. */\nexport type OutreachClickMachineReason = 'agent' | 'too_soon' | 'method'\n\n/** What one request to a tracking link was. */\nexport interface OutreachClickJudgement {\n /** Whether it counts toward the click rate. */\n human: boolean\n /** Why it does not; `null` when it does. */\n machineReason: OutreachClickMachineReason | null\n}\n\n/**\n * Whether one request to a tracking link is a person's.\n *\n * Read before the redirect is *recorded*, never before it is *answered*: a\n * machine is forwarded to the destination exactly as a person is, because a\n * scanner that gets a refusal reports the link as broken and the mail as\n * suspicious.\n *\n * @param input.method The HTTP method. A `HEAD` is a fetch of the headers\n * alone, which no mail client does for a person following a link.\n * @param input.userAgent The request's agent, `''` when it sent none — which\n * is itself a machine, since every browser sends one.\n * @param input.sinceSentMs Milliseconds between the send and this request,\n * or `null` when the send it belongs to could not be found.\n */\nexport function judgeOutreachClick(input: {\n method: string\n userAgent: string | null | undefined\n sinceSentMs: number | null\n}): OutreachClickJudgement {\n const machine = (machineReason: OutreachClickMachineReason): OutreachClickJudgement => ({\n human: false,\n machineReason,\n })\n if (String(input.method ?? '').toUpperCase() === 'HEAD') return machine('method')\n const agent = String(input.userAgent ?? '').trim().toLowerCase()\n if (!agent) return machine('agent')\n if (MACHINE_AGENTS.some((needle) => agent.includes(needle))) return machine('agent')\n /*\n * A click that arrives BEFORE the send it belongs to is a clock disagreeing\n * with itself, not a time traveller, and it is read as a person's: the one\n * thing worse than counting a scanner is refusing to count a real reply to\n * an email because two machines are a second apart.\n */\n if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_CLICK_HUMAN_DELAY_MS) {\n return machine('too_soon')\n }\n return { human: true, machineReason: null }\n}\n"],"names":["TRAILING_PUNCTUATION","URL_PATTERN","trimUrlEnd","raw","url","replace","endsWith","opened","split","length","closed","slice","outreachBodyLinks","text","body","String","links","match","matchAll","start","index","parsed","URL","protocol","push","end","rewriteOutreachBodyLinks","mint","found","out","cursor","link","tracked","OUTREACH_CLICK_HUMAN_DELAY_MS","MACHINE_AGENTS","judgeOutreachClick","input","machine","machineReason","human","method","toUpperCase","agent","userAgent","trim","toLowerCase","some","needle","includes","sinceSentMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA4B4C,GAE5C;;;;CAIC,GAOD;;;;;;CAMC,GACD,MAAMA,uBAAuB;AAE7B;;;;;;;CAOC,GACD,MAAMC,cAAc;AAEpB;;;;;;;CAOC,GACD,SAASC,WAAWC,GAAW;IAC7B,IAAIC,MAAMD,IAAIE,OAAO,CAACL,sBAAsB;IAC5C,MAAOI,IAAIE,QAAQ,CAAC,KAAM;QACxB,MAAMC,SAASH,IAAII,KAAK,CAAC,KAAKC,MAAM,GAAG;QACvC,MAAMC,SAASN,IAAII,KAAK,CAAC,KAAKC,MAAM,GAAG;QACvC,IAAIC,UAAUH,QAAQ;QACtBH,MAAMA,IAAIO,KAAK,CAAC,GAAG,CAAC,GAAGN,OAAO,CAACL,sBAAsB;IACvD;IACA,OAAOI;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASQ,kBAAkBC,IAAY;IAC5C,MAAMC,OAAOC,OAAOF,eAAAA,OAAQ;IAC5B,MAAMG,QAA4B,EAAE;IACpC,KAAK,MAAMC,SAASH,KAAKI,QAAQ,CAACjB,aAAc;YAChCgB;QAAd,MAAME,SAAQF,eAAAA,MAAMG,KAAK,YAAXH,eAAe;QAC7B,MAAMb,MAAMF,WAAWe,KAAK,CAAC,EAAE;QAC/B,IAAI,CAACb,KAAK;QACV,IAAIiB;QACJ,IAAI;YACFA,SAAS,IAAIC,IAAIlB;QACnB,EAAE,eAAM;YACN;QACF;QACA,IAAIiB,OAAOE,QAAQ,KAAK,WAAWF,OAAOE,QAAQ,KAAK,UAAU;QACjEP,MAAMQ,IAAI,CAAC;YAAEpB;YAAKe;YAAOM,KAAKN,QAAQf,IAAIK,MAAM;QAAC;IACnD;IACA,OAAOO;AACT;AAeA;;;;;;;;CAQC,GACD,OAAO,SAASU,yBACdb,IAAY,EACZc,IAA6D;IAE7D,MAAMb,OAAOC,OAAOF,eAAAA,OAAQ;IAC5B,MAAMe,QAAQhB,kBAAkBE;IAChC,IAAI,CAACc,MAAMnB,MAAM,EAAE,OAAO;QAAEI,MAAMC;QAAME,OAAO,EAAE;IAAC;IAClD,MAAMA,QAAkB,EAAE;IAC1B,IAAIa,MAAM;IACV,IAAIC,SAAS;IACb,KAAK,MAAMC,QAAQH,MAAO;QACxB,uEAAuE;QACvE,uEAAuE;QACvE,gDAAgD;QAChD,MAAMI,UAAUL,KAAK;YAAEvB,KAAK2B,KAAK3B,GAAG;YAAEgB,OAAOJ,MAAMP,MAAM;QAAC;QAC1DoB,OAAOf,KAAKH,KAAK,CAACmB,QAAQC,KAAKZ,KAAK;QACpC,IAAIa,SAAS;YACXH,OAAOG;YACPhB,MAAMQ,IAAI,CAACO,KAAK3B,GAAG;QACrB,OAAO;YACLyB,OAAOE,KAAK3B,GAAG;QACjB;QACA0B,SAASC,KAAKN,GAAG;IACnB;IACA,OAAO;QAAEZ,MAAMgB,MAAMf,KAAKH,KAAK,CAACmB;QAASd;IAAM;AACjD;AAEA;;;;;;;;;;;;;;;;;4CAiB4C,GAE5C;;;;;;;;CAQC,GACD,OAAO,MAAMiB,gCAAgC,MAAM;AAEnD;;;;;;;CAOC,GACD,MAAMC,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAaD;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,mBAAmBC,KAIlC;QAKYA,eACUA;IALrB,MAAMC,UAAU,CAACC,gBAAuE,CAAA;YACtFC,OAAO;YACPD;QACF,CAAA;IACA,IAAIvB,QAAOqB,gBAAAA,MAAMI,MAAM,YAAZJ,gBAAgB,IAAIK,WAAW,OAAO,QAAQ,OAAOJ,QAAQ;IACxE,MAAMK,QAAQ3B,QAAOqB,mBAAAA,MAAMO,SAAS,YAAfP,mBAAmB,IAAIQ,IAAI,GAAGC,WAAW;IAC9D,IAAI,CAACH,OAAO,OAAOL,QAAQ;IAC3B,IAAIH,eAAeY,IAAI,CAAC,CAACC,SAAWL,MAAMM,QAAQ,CAACD,UAAU,OAAOV,QAAQ;IAC5E;;;;;GAKC,GACD,IAAID,MAAMa,WAAW,KAAK,QAAQb,MAAMa,WAAW,IAAI,KAAKb,MAAMa,WAAW,GAAGhB,+BAA+B;QAC7G,OAAOI,QAAQ;IACjB;IACA,OAAO;QAAEE,OAAO;QAAMD,eAAe;IAAK;AAC5C"}
|
|
@@ -45,6 +45,13 @@ export interface ComposedOutreachEmail {
|
|
|
45
45
|
listUnsubscribeUrl?: string;
|
|
46
46
|
/** A `mailto:` URI for `List-Unsubscribe`. */
|
|
47
47
|
listUnsubscribeMailto?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The destinations whose links were rewritten for click tracking, in the
|
|
50
|
+
* order a click token indexes them (AGL-3239). Empty when the sequence
|
|
51
|
+
* does not track clicks, when the body carries no links, and when no
|
|
52
|
+
* tracking link could be minted.
|
|
53
|
+
*/
|
|
54
|
+
trackedLinks: string[];
|
|
48
55
|
}
|
|
49
56
|
export type OutreachComposeErrorCode = 'missing_postal_address' | 'missing_legal_name' | 'not_an_email_step' | 'invalid_recipient' | 'missing_subject' | 'subject_reply_prefix' | 'missing_body' | 'missing_thread' | 'invalid_unsubscribe_url' | 'invalid_unsubscribe_mailto';
|
|
50
57
|
export interface OutreachComposeError {
|
|
@@ -68,6 +75,23 @@ export interface ComposeOutreachEmailInput {
|
|
|
68
75
|
listUnsubscribeUrl?: string | null;
|
|
69
76
|
/** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */
|
|
70
77
|
listUnsubscribeMailto?: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* Answers the tracking link for one link in the body, or `null` to leave
|
|
80
|
+
* that one as it was written (AGL-3239).
|
|
81
|
+
*
|
|
82
|
+
* Absent leaves every link alone, which is what a sequence with
|
|
83
|
+
* `trackClicks` off passes and what every caller that is not the sending
|
|
84
|
+
* runtime passes — a PREVIEW must show the rep the body their recipient
|
|
85
|
+
* reads, and minting a live tracking link for a preview would count the
|
|
86
|
+
* rep's own click against the sequence.
|
|
87
|
+
*
|
|
88
|
+
* It runs on the step's rendered body and never on the footer, so the
|
|
89
|
+
* organization's identification and the way out are never rewritten.
|
|
90
|
+
*/
|
|
91
|
+
rewriteLink?: ((link: {
|
|
92
|
+
url: string;
|
|
93
|
+
index: number;
|
|
94
|
+
}) => string | null) | null;
|
|
71
95
|
}
|
|
72
96
|
export interface OutreachComposeResult {
|
|
73
97
|
email: ComposedOutreachEmail | null;
|
|
@@ -53,6 +53,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
53
53
|
import { CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
|
|
54
54
|
import { resolveCrmMergeFields } from "@aglyn/aglyn/app-utils/crm-email-templates";
|
|
55
55
|
import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
|
|
56
|
+
import { rewriteOutreachBodyLinks } from "./click-tracking.js";
|
|
56
57
|
import { normalizeOutreachPersonalLine } from "./gates.js";
|
|
57
58
|
import { isInThreadEmailStep, OUTREACH_PERSONAL_LINE_FIELD, outreachSubjectHasReplyPrefix } from "./sequence-validation.js";
|
|
58
59
|
/** The way out every email offers, word for word. */ export const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply "no" and I won\'t email again.';
|
|
@@ -171,7 +172,8 @@ const refuse = (code, message)=>({
|
|
|
171
172
|
const email = {
|
|
172
173
|
to,
|
|
173
174
|
subject: '',
|
|
174
|
-
text: ''
|
|
175
|
+
text: '',
|
|
176
|
+
trackedLinks: []
|
|
175
177
|
};
|
|
176
178
|
if (isInThreadEmailStep(steps, stepIndex)) {
|
|
177
179
|
var _input_enrollment_messageIds, _input_enrollment_gmailThreadId;
|
|
@@ -197,7 +199,18 @@ const refuse = (code, message)=>({
|
|
|
197
199
|
const source = step.templateId ? String((_input_templateBody = input.templateBody) != null ? _input_templateBody : '') : String((_step_body = step.body) != null ? _step_body : '');
|
|
198
200
|
const body = render(source).replace(/\r\n?/g, '\n').split('\n').map((line)=>line.replace(/[ \t]+$/, '')).join('\n').replace(/^\n+|\n+$/g, '');
|
|
199
201
|
if (!body.trim()) return refuse('missing_body', 'This email has no body.');
|
|
200
|
-
|
|
202
|
+
/*
|
|
203
|
+
* Click tracking rewrites the STEP'S links and nothing else (AGL-3239):
|
|
204
|
+
* it runs here, on the rendered body, before the footer is appended. The
|
|
205
|
+
* footer's opt-out `mailto:` and the organization's own identification are
|
|
206
|
+
* never rewritten, and the one-click unsubscribe link never appears in a
|
|
207
|
+
* body at all — it rides in `List-Unsubscribe`.
|
|
208
|
+
*/ const tracked = input.rewriteLink ? rewriteOutreachBodyLinks(body, input.rewriteLink) : {
|
|
209
|
+
text: body,
|
|
210
|
+
links: []
|
|
211
|
+
};
|
|
212
|
+
email.trackedLinks = tracked.links;
|
|
213
|
+
email.text = `${tracked.text}\n\n${footer}`;
|
|
201
214
|
const url = unsubscribeUrl(input.listUnsubscribeUrl);
|
|
202
215
|
if (url === null) {
|
|
203
216
|
return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n >\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [] })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [] }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '' }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source = step.templateId ? String(input.templateBody ?? '') : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n email.text = `${body}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","String","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","stepIndex","enrollment","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","subject","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","body","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n /**\n * The destinations whose links were rewritten for click tracking, in the\n * order a click token indexes them (AGL-3239). Empty when the sequence\n * does not track clicks, when the body carries no links, and when no\n * tracking link could be minted.\n */\n trackedLinks: string[]\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n >\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n /**\n * Answers the tracking link for one link in the body, or `null` to leave\n * that one as it was written (AGL-3239).\n *\n * Absent leaves every link alone, which is what a sequence with\n * `trackClicks` off passes and what every caller that is not the sending\n * runtime passes — a PREVIEW must show the rep the body their recipient\n * reads, and minting a live tracking link for a preview would count the\n * rep's own click against the sequence.\n *\n * It runs on the step's rendered body and never on the footer, so the\n * organization's identification and the way out are never rewritten.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [] })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [] }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '', trackedLinks: [] }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source = step.templateId ? String(input.templateBody ?? '') : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n /*\n * Click tracking rewrites the STEP'S links and nothing else (AGL-3239):\n * it runs here, on the rendered body, before the footer is appended. The\n * footer's opt-out `mailto:` and the organization's own identification are\n * never rewritten, and the one-click unsubscribe link never appears in a\n * body at all — it rides in `List-Unsubscribe`.\n */\n const tracked = input.rewriteLink\n ? rewriteOutreachBodyLinks(body, input.rewriteLink)\n : { text: body, links: [] }\n email.trackedLinks = tracked.links\n email.text = `${tracked.text}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","rewriteOutreachBodyLinks","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","String","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","stepIndex","enrollment","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","subject","trackedLinks","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","body","tracked","rewriteLink","links","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
|
|
@@ -135,7 +135,10 @@ const WEEKDAY_NAMES = [
|
|
|
135
135
|
allowedCountries: Array.isArray(data['allowedCountries']) ? countries : [
|
|
136
136
|
...OUTREACH_DEFAULT_ALLOWED_COUNTRIES
|
|
137
137
|
],
|
|
138
|
-
allowCustomers: data['allowCustomers'] === true
|
|
138
|
+
allowCustomers: data['allowCustomers'] === true,
|
|
139
|
+
// Absent is off (AGL-3239). Every sequence written before the setting
|
|
140
|
+
// existed reads as untracked, which is what it was.
|
|
141
|
+
trackClicks: data['trackClicks'] === true
|
|
139
142
|
};
|
|
140
143
|
}
|
|
141
144
|
export function validateOutreachSequenceSettings(settings, path = 'settings') {
|