@aglyn/plugins-outreach 1.0.0-beta.196 → 1.0.0-beta.198
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/compliance-section.d.ts +3 -1
- package/src/lib/components/compliance-section.js +110 -45
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/enroll-dialog.d.ts +7 -1
- package/src/lib/components/enroll-dialog.js +106 -3
- package/src/lib/components/enroll-dialog.js.map +1 -1
- package/src/lib/components/mailbox-card.js +11 -2
- package/src/lib/components/mailbox-card.js.map +1 -1
- package/src/lib/components/outreach-ui.js +1 -0
- package/src/lib/components/outreach-ui.js.map +1 -1
- package/src/lib/components/sequence-detail.js +1 -0
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-editor.d.ts +2 -0
- package/src/lib/components/sequence-editor.js +51 -26
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/sequence-step-card.d.ts +7 -0
- package/src/lib/components/sequence-step-card.js +37 -14
- package/src/lib/components/sequence-step-card.js.map +1 -1
- package/src/lib/components/sequences-section.js +12 -3
- package/src/lib/components/sequences-section.js.map +1 -1
- package/src/lib/components/step-test-dialog.d.ts +31 -0
- package/src/lib/components/step-test-dialog.js +247 -0
- package/src/lib/components/step-test-dialog.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +8 -1
- package/src/lib/components/use-outreach-api.js +4 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +6 -1
- package/src/lib/components/use-outreach-data.js +6 -3
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +7 -0
- package/src/lib/constants/api-routes.js +6 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/delivery-status.d.ts +8 -0
- package/src/lib/engine/delivery-status.js +10 -5
- package/src/lib/engine/delivery-status.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +2 -0
- package/src/lib/engine/enrollment-state.js +2 -2
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/engine/gates.d.ts +28 -1
- package/src/lib/engine/gates.js +28 -1
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/mail-gateway.d.ts +97 -0
- package/src/lib/engine/mail-gateway.js +226 -0
- package/src/lib/engine/mail-gateway.js.map +1 -0
- package/src/lib/engine/mailbox-health.d.ts +3 -3
- package/src/lib/engine/mailbox-health.js +17 -11
- package/src/lib/engine/mailbox-health.js.map +1 -1
- package/src/lib/engine/thread-classification.d.ts +2 -0
- package/src/lib/engine/thread-classification.js +2 -1
- package/src/lib/engine/thread-classification.js.map +1 -1
- package/src/lib/enrollment/enroll-people.d.ts +6 -1
- package/src/lib/enrollment/enroll-people.js +18 -4
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/gate-lookups.d.ts +7 -0
- package/src/lib/enrollment/gate-lookups.js +11 -2
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +35 -1
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +85 -3
- package/src/lib/model/outreach.types.js +21 -2
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/step-preview.d.ts +7 -1
- package/src/lib/model/step-preview.js +3 -2
- package/src/lib/model/step-preview.js.map +1 -1
- package/src/lib/routes/enroll-routes.js +23 -4
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.js +15 -1
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +2 -2
- package/src/lib/routes/platform-deps.js +2 -2
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/preview-routes.d.ts +24 -0
- package/src/lib/routes/preview-routes.js +99 -95
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/register-routes.d.ts +7 -0
- package/src/lib/routes/register-routes.js +27 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +6 -0
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/routes/step-test-routes.d.ts +88 -0
- package/src/lib/routes/step-test-routes.js +275 -0
- package/src/lib/routes/step-test-routes.js.map +1 -0
- package/src/lib/runtime/click-events.js +10 -0
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-link.d.ts +11 -0
- package/src/lib/runtime/click-link.js +9 -4
- package/src/lib/runtime/click-link.js.map +1 -1
- package/src/lib/runtime/click-route.js +3 -0
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/gateway-ledger.d.ts +46 -0
- package/src/lib/runtime/gateway-ledger.js +113 -0
- package/src/lib/runtime/gateway-ledger.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.js +3 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +6 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +48 -0
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.d.ts +2 -0
- package/src/lib/runtime/sync-job.js +40 -3
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/domain-intel-store.d.ts +107 -0
- package/src/lib/storage/domain-intel-store.js +320 -0
- package/src/lib/storage/domain-intel-store.js.map +1 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
18
|
+
import { hasOutreachValidationErrors, validateOutreachSteps } from "../engine/sequence-validation.js";
|
|
19
|
+
import { mailboxRef } from "../mailboxes/mailbox-credentials.js";
|
|
20
|
+
import { outreachLocalDay } from "../mailboxes/mailbox-settings.js";
|
|
21
|
+
import { markOutreachMailboxReconnectRequired } from "../mailboxes/mailbox-transport.js";
|
|
22
|
+
import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
|
|
23
|
+
import { previewOutreachStep } from "../model/step-preview.js";
|
|
24
|
+
import { newOutreachLinkId, OUTREACH_TEST_LINK_ENROLLMENT, outreachStoredLink } from "../runtime/click-link.js";
|
|
25
|
+
import { readStoredOutreachMailbox } from "../storage/outreach-records.js";
|
|
26
|
+
import { GmailTransportError } from "../transport/gmail-errors.js";
|
|
27
|
+
import { Rfc5322MessageError } from "../transport/rfc5322.js";
|
|
28
|
+
import { sendComposedOutreachEmail } from "../transport/send-message.js";
|
|
29
|
+
import { readOutreachStepRender } from "./preview-routes.js";
|
|
30
|
+
import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from "./route-http.js";
|
|
31
|
+
/**
|
|
32
|
+
* A TEST OF ONE STEP, IN A REAL INBOX (AGL-3325): `outreach/steps/test`.
|
|
33
|
+
*
|
|
34
|
+
* The Mailboxes page's test proves a mailbox can send; this proves what a
|
|
35
|
+
* step LOOKS like once it has been through Gmail. It is the preview's email
|
|
36
|
+
* — the same reader, the same person or sample person, the same personal
|
|
37
|
+
* line, the same footer — sent through the sequence's mailbox to the member
|
|
38
|
+
* who asked, or to an address they typed, with `[Test] ` in front of the
|
|
39
|
+
* subject so it never reads as real mail.
|
|
40
|
+
*
|
|
41
|
+
* ## Rendered as a send, sent as a test
|
|
42
|
+
*
|
|
43
|
+
* A later step in a thread is written as `Re:` the thread's subject, as the
|
|
44
|
+
* preview writes it, but sent as a new message: there is no thread to
|
|
45
|
+
* answer, and the preview's placeholder message id must not leave the
|
|
46
|
+
* server. When the sequence counts clicks, every link is rewritten to a
|
|
47
|
+
* short link as a real send's would be, so the member sees the link their
|
|
48
|
+
* recipients will see — but each is stored as a TEST link, which the click
|
|
49
|
+
* route follows and never records (`click-link.ts`). A member clicking their
|
|
50
|
+
* own test must not move the sequence's `clicks` or `machineClicks`.
|
|
51
|
+
*
|
|
52
|
+
* Merge fields the person has nothing for are sent empty and named in the
|
|
53
|
+
* answer, where a real send would pause: the point of a test is to see the
|
|
54
|
+
* gap, not to be refused it.
|
|
55
|
+
*
|
|
56
|
+
* ## Counted on nothing but the mailbox's tests
|
|
57
|
+
*
|
|
58
|
+
* A test is a real Gmail send and spends the account's own limits. It is
|
|
59
|
+
* not a person emailed: it moves neither the sequence's `stats` nor the
|
|
60
|
+
* mailbox's `health.daily.<day>.sent`, it reserves nothing of the daily cap,
|
|
61
|
+
* it files nothing on anyone's record, and it enrolls nobody. It is tallied
|
|
62
|
+
* on `health.daily.<day>.tests`, which the Mailboxes card shows as "Tests
|
|
63
|
+
* today", and held to {@link OUTREACH_STEP_TESTS_PER_HOUR} a mailbox.
|
|
64
|
+
*
|
|
65
|
+
* ## Who, and from what
|
|
66
|
+
*
|
|
67
|
+
* The mailbox's own member, or an organization owner or admin — the people
|
|
68
|
+
* who may edit and activate the sequence. A paused mailbox, or one waiting
|
|
69
|
+
* to be reconnected, refuses and says which; a member who cannot send is
|
|
70
|
+
* told where to fix it rather than shown a Google error.
|
|
71
|
+
*/ /** Tests one mailbox may send in an hour: a few steps for a few people. */ export const OUTREACH_STEP_TESTS_PER_HOUR = 20;
|
|
72
|
+
/** What every test's subject starts with. */ export const OUTREACH_TEST_SUBJECT_PREFIX = '[Test] ';
|
|
73
|
+
const RECONNECT_SENTENCE = "This sequence's mailbox needs to be connected again before it can send. Reconnect it on the Mailboxes page.";
|
|
74
|
+
/** Why the mailbox cannot send a test now, in a sentence, or `null`. */ function mailboxRefusal(mailbox) {
|
|
75
|
+
if (!mailbox) return 'This sequence has no mailbox. Choose one and save before sending a test.';
|
|
76
|
+
if (mailbox.status === 'paused') {
|
|
77
|
+
return "This sequence's mailbox is paused. Resume it on the Mailboxes page before sending a test.";
|
|
78
|
+
}
|
|
79
|
+
if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') return RECONNECT_SENTENCE;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
export function createOutreachStepTestRoute(deps, testDeps) {
|
|
83
|
+
return async (request)=>{
|
|
84
|
+
if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
|
|
85
|
+
const body = await readOutreachJsonBody(request);
|
|
86
|
+
// The address the member typed, when they typed one — read before any
|
|
87
|
+
// record, so a typo is answered before a Gmail call.
|
|
88
|
+
const named = typeof body['to'] === 'string' ? body['to'].trim() : '';
|
|
89
|
+
const typed = named ? normalizeContactEmail(named) : null;
|
|
90
|
+
if (named && !typed) {
|
|
91
|
+
return outreachRefusal(400, 'invalid-request', `"${named}" isn't a valid email address.`);
|
|
92
|
+
}
|
|
93
|
+
const loaded = await readOutreachStepRender(deps, request, body);
|
|
94
|
+
if (loaded instanceof Response) return loaded;
|
|
95
|
+
const { caller, sequence, stepIndex, mailbox } = loaded;
|
|
96
|
+
const refusedBy = mailboxRefusal(mailbox);
|
|
97
|
+
if (refusedBy || !mailbox) return outreachRefusal(409, 'mailbox-unavailable', refusedBy != null ? refusedBy : RECONNECT_SENTENCE);
|
|
98
|
+
if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {
|
|
99
|
+
return outreachRefusal(403, 'permission', "Only the member who connected this sequence's mailbox, or an organization owner or admin, can send a test from it.");
|
|
100
|
+
}
|
|
101
|
+
const to = typed != null ? typed : caller.email;
|
|
102
|
+
if (!to) return outreachRefusal(400, 'invalid-request', 'Type an address to send the test to.');
|
|
103
|
+
// The rules every sequence email keeps, asked of the stored steps: a
|
|
104
|
+
// sequence saved under an older rule is refused rather than sent broken.
|
|
105
|
+
const issues = validateOutreachSteps(sequence.steps);
|
|
106
|
+
if (hasOutreachValidationErrors(issues)) {
|
|
107
|
+
return outreachRefusal(400, 'invalid-sequence', "Fix the sequence's steps before sending a test.", {
|
|
108
|
+
issues
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const limited = await testDeps.consumeRateLimit(`outreach-step-test:${mailbox.id}`, {
|
|
112
|
+
limit: OUTREACH_STEP_TESTS_PER_HOUR,
|
|
113
|
+
windowMs: 60 * 60 * 1000
|
|
114
|
+
});
|
|
115
|
+
if (!limited.allowed) {
|
|
116
|
+
return outreachRefusal(429, 'rate-limited', `A mailbox can send ${OUTREACH_STEP_TESTS_PER_HOUR} tests an hour. Try again later.`);
|
|
117
|
+
}
|
|
118
|
+
const firestore = deps.firestore();
|
|
119
|
+
const nowMs = deps.now();
|
|
120
|
+
const sender = {
|
|
121
|
+
address: mailbox.sendAs || mailbox.email,
|
|
122
|
+
name: mailbox.displayName || null
|
|
123
|
+
};
|
|
124
|
+
/*
|
|
125
|
+
* The links, as a real send would rewrite them (AGL-3239, AGL-3297) and
|
|
126
|
+
* marked as a test's: the member sees what a recipient sees, and their
|
|
127
|
+
* own click counts for nothing.
|
|
128
|
+
*/ let linkOrigin = null;
|
|
129
|
+
if (sequence.settings.trackClicks && testDeps.clickLinkOrigin) {
|
|
130
|
+
linkOrigin = await testDeps.clickLinkOrigin({
|
|
131
|
+
orgId: caller.orgId,
|
|
132
|
+
senderAddress: sender.address
|
|
133
|
+
}).catch(()=>null);
|
|
134
|
+
}
|
|
135
|
+
const shortLinks = [];
|
|
136
|
+
const rewriteLink = sequence.settings.trackClicks ? (link)=>{
|
|
137
|
+
const doc = outreachStoredLink({
|
|
138
|
+
orgId: caller.orgId,
|
|
139
|
+
enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,
|
|
140
|
+
stepIndex,
|
|
141
|
+
linkIndex: link.index,
|
|
142
|
+
url: link.url,
|
|
143
|
+
test: true
|
|
144
|
+
}, nowMs);
|
|
145
|
+
const id = newOutreachLinkId();
|
|
146
|
+
const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null;
|
|
147
|
+
if (!doc || !url) return null;
|
|
148
|
+
shortLinks.push({
|
|
149
|
+
id,
|
|
150
|
+
doc: _extends({}, doc, {
|
|
151
|
+
sequenceId: sequence.id
|
|
152
|
+
})
|
|
153
|
+
});
|
|
154
|
+
return url;
|
|
155
|
+
} : null;
|
|
156
|
+
let composed = previewOutreachStep(_extends({}, loaded.input, {
|
|
157
|
+
rewriteLink
|
|
158
|
+
}));
|
|
159
|
+
if (composed.error || !composed.email) {
|
|
160
|
+
var _ref;
|
|
161
|
+
var _composed_error;
|
|
162
|
+
return outreachRefusal(409, 'invalid-sequence', (_ref = (_composed_error = composed.error) == null ? void 0 : _composed_error.message) != null ? _ref : "This email can't be written yet.");
|
|
163
|
+
}
|
|
164
|
+
if (shortLinks.length) {
|
|
165
|
+
try {
|
|
166
|
+
const batch = firestore.batch();
|
|
167
|
+
for (const link of shortLinks){
|
|
168
|
+
batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc);
|
|
169
|
+
}
|
|
170
|
+
await batch.commit();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// A link that resolves nowhere is worse than one shown as written:
|
|
173
|
+
// the test goes out with the step's own links.
|
|
174
|
+
console.error('[outreach] the test links could not be stored; the test carries the links as written', error);
|
|
175
|
+
composed = previewOutreachStep(_extends({}, loaded.input, {
|
|
176
|
+
rewriteLink: null
|
|
177
|
+
}));
|
|
178
|
+
if (composed.error || !composed.email) {
|
|
179
|
+
var _ref1;
|
|
180
|
+
var _composed_error1;
|
|
181
|
+
return outreachRefusal(409, 'invalid-sequence', (_ref1 = (_composed_error1 = composed.error) == null ? void 0 : _composed_error1.message) != null ? _ref1 : "This email can't be written yet.");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const opened = await testDeps.openMailbox(mailbox.id);
|
|
186
|
+
if (opened.ok === false) {
|
|
187
|
+
if (opened.reason === 'not-configured') {
|
|
188
|
+
return outreachRefusal(503, 'mailbox-unavailable', "Google mailboxes aren't set up on this deployment.");
|
|
189
|
+
}
|
|
190
|
+
await markOutreachMailboxReconnectRequired(firestore, {
|
|
191
|
+
orgId: caller.orgId,
|
|
192
|
+
mailboxId: mailbox.id,
|
|
193
|
+
errorCode: opened.reason,
|
|
194
|
+
nowMs
|
|
195
|
+
});
|
|
196
|
+
return outreachRefusal(409, 'mailbox-unavailable', RECONNECT_SENTENCE);
|
|
197
|
+
}
|
|
198
|
+
/*
|
|
199
|
+
* The preview's email, addressed to the member: `[Test]` in front, and a
|
|
200
|
+
* `Re:` step sent as a new message — no thread id, no `In-Reply-To`, no
|
|
201
|
+
* `References` — since the thread it answers exists only on screen. The
|
|
202
|
+
* unsubscribe header is left off: it names an enrollment, and there is
|
|
203
|
+
* none.
|
|
204
|
+
*/ const email = {
|
|
205
|
+
to,
|
|
206
|
+
subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,
|
|
207
|
+
text: composed.email.text,
|
|
208
|
+
trackedLinks: composed.email.trackedLinks
|
|
209
|
+
};
|
|
210
|
+
let subject;
|
|
211
|
+
try {
|
|
212
|
+
subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof GmailTransportError && error.reconnectRequired) {
|
|
215
|
+
await markOutreachMailboxReconnectRequired(firestore, {
|
|
216
|
+
orgId: caller.orgId,
|
|
217
|
+
mailboxId: mailbox.id,
|
|
218
|
+
errorCode: error.code,
|
|
219
|
+
nowMs
|
|
220
|
+
});
|
|
221
|
+
return outreachRefusal(409, 'mailbox-unavailable', 'Google refused this mailbox’s access. Connect it again.');
|
|
222
|
+
}
|
|
223
|
+
if (error instanceof Rfc5322MessageError) {
|
|
224
|
+
return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`);
|
|
225
|
+
}
|
|
226
|
+
if (!(error instanceof GmailTransportError)) throw error;
|
|
227
|
+
return outreachRefusal(502, 'google-unavailable', error.retryable ? 'Google did not answer. Try again in a moment.' : `Google refused the request: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs);
|
|
230
|
+
return outreachOk({
|
|
231
|
+
ok: true,
|
|
232
|
+
stepIndex,
|
|
233
|
+
sentTo: to,
|
|
234
|
+
subject,
|
|
235
|
+
sentAtMs: nowMs,
|
|
236
|
+
testsToday,
|
|
237
|
+
unresolvedFields: composed.unresolvedFields
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* One more test on the mailbox's local day, in `health.daily.<day>.tests`
|
|
243
|
+
* beside the day's sends and never in them. Bookkeeping beside the act: the
|
|
244
|
+
* email has left, so a failure is logged and answered as no count.
|
|
245
|
+
*/ async function countTest(firestore, orgId, mailbox, nowMs) {
|
|
246
|
+
const ref = mailboxRef(firestore, orgId, mailbox.id);
|
|
247
|
+
try {
|
|
248
|
+
return await firestore.runTransaction(async (transaction)=>{
|
|
249
|
+
var _ref;
|
|
250
|
+
var _stored_health_daily, _stored_health;
|
|
251
|
+
const snapshot = await transaction.get(ref);
|
|
252
|
+
const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined);
|
|
253
|
+
if (!stored) return 0;
|
|
254
|
+
const day = outreachLocalDay(nowMs, stored.timezone);
|
|
255
|
+
const counts = (_ref = (_stored_health = stored.health) == null ? void 0 : (_stored_health_daily = _stored_health.daily) == null ? void 0 : _stored_health_daily[day]) != null ? _ref : {
|
|
256
|
+
sent: 0,
|
|
257
|
+
bounces: 0,
|
|
258
|
+
replies: 0
|
|
259
|
+
};
|
|
260
|
+
const tests = Math.max(0, Number(counts.tests) || 0) + 1;
|
|
261
|
+
transaction.update(ref, {
|
|
262
|
+
[`health.daily.${day}`]: _extends({}, counts, {
|
|
263
|
+
tests
|
|
264
|
+
}),
|
|
265
|
+
updatedAtMs: nowMs
|
|
266
|
+
});
|
|
267
|
+
return tests;
|
|
268
|
+
});
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.warn('[outreach] a test send could not be counted on its mailbox', error);
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
//# sourceMappingURL=step-test-routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/step-test-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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { ComposedOutreachEmail } from '../engine/compose'\nimport { hasOutreachValidationErrors, validateOutreachSteps } from '../engine/sequence-validation'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type { OutreachStepTestResponse } from '../model/outreach-api'\nimport { OUTREACH_COLLECTIONS, type OutreachMailbox } from '../model/outreach.types'\nimport { previewOutreachStep } from '../model/step-preview'\nimport {\n newOutreachLinkId,\n OUTREACH_TEST_LINK_ENROLLMENT,\n outreachStoredLink,\n type OutreachStoredLink,\n} from '../runtime/click-link'\nimport { readStoredOutreachMailbox } from '../storage/outreach-records'\nimport { GmailTransportError } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendComposedOutreachEmail } from '../transport/send-message'\nimport { readOutreachStepRender } from './preview-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from './route-http'\n\n/**\n * A TEST OF ONE STEP, IN A REAL INBOX (AGL-3325): `outreach/steps/test`.\n *\n * The Mailboxes page's test proves a mailbox can send; this proves what a\n * step LOOKS like once it has been through Gmail. It is the preview's email\n * — the same reader, the same person or sample person, the same personal\n * line, the same footer — sent through the sequence's mailbox to the member\n * who asked, or to an address they typed, with `[Test] ` in front of the\n * subject so it never reads as real mail.\n *\n * ## Rendered as a send, sent as a test\n *\n * A later step in a thread is written as `Re:` the thread's subject, as the\n * preview writes it, but sent as a new message: there is no thread to\n * answer, and the preview's placeholder message id must not leave the\n * server. When the sequence counts clicks, every link is rewritten to a\n * short link as a real send's would be, so the member sees the link their\n * recipients will see — but each is stored as a TEST link, which the click\n * route follows and never records (`click-link.ts`). A member clicking their\n * own test must not move the sequence's `clicks` or `machineClicks`.\n *\n * Merge fields the person has nothing for are sent empty and named in the\n * answer, where a real send would pause: the point of a test is to see the\n * gap, not to be refused it.\n *\n * ## Counted on nothing but the mailbox's tests\n *\n * A test is a real Gmail send and spends the account's own limits. It is\n * not a person emailed: it moves neither the sequence's `stats` nor the\n * mailbox's `health.daily.<day>.sent`, it reserves nothing of the daily cap,\n * it files nothing on anyone's record, and it enrolls nobody. It is tallied\n * on `health.daily.<day>.tests`, which the Mailboxes card shows as \"Tests\n * today\", and held to {@link OUTREACH_STEP_TESTS_PER_HOUR} a mailbox.\n *\n * ## Who, and from what\n *\n * The mailbox's own member, or an organization owner or admin — the people\n * who may edit and activate the sequence. A paused mailbox, or one waiting\n * to be reconnected, refuses and says which; a member who cannot send is\n * told where to fix it rather than shown a Google error.\n */\n\n/** Tests one mailbox may send in an hour: a few steps for a few people. */\nexport const OUTREACH_STEP_TESTS_PER_HOUR = 20\n\n/** What every test's subject starts with. */\nexport const OUTREACH_TEST_SUBJECT_PREFIX = '[Test] '\n\n/** The route's reach beyond the shared route deps: the mailbox's Gmail, the limit, the links. */\nexport interface OutreachStepTestDeps {\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n /** The short tracking link for a stored link's id, or `null` when none can be made. */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /** The click-tracking origin for mail sent as `senderAddress` (AGL-3306), or `null` for the console's. */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n\n/** What the route answers: the response the console reads, and the fields the test sent empty. */\nexport interface OutreachStepTestAnswer extends OutreachStepTestResponse {\n unresolvedFields: string[]\n}\n\nconst RECONNECT_SENTENCE =\n \"This sequence's mailbox needs to be connected again before it can send. Reconnect it on the Mailboxes page.\"\n\n/** Why the mailbox cannot send a test now, in a sentence, or `null`. */\nfunction mailboxRefusal(mailbox: OutreachMailbox | null): string | null {\n if (!mailbox) return 'This sequence has no mailbox. Choose one and save before sending a test.'\n if (mailbox.status === 'paused') {\n return \"This sequence's mailbox is paused. Resume it on the Mailboxes page before sending a test.\"\n }\n if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') return RECONNECT_SENTENCE\n return null\n}\n\nexport function createOutreachStepTestRoute(\n deps: OutreachRouteDeps,\n testDeps: OutreachStepTestDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n // The address the member typed, when they typed one — read before any\n // record, so a typo is answered before a Gmail call.\n const named = typeof body['to'] === 'string' ? body['to'].trim() : ''\n const typed = named ? normalizeContactEmail(named) : null\n if (named && !typed) {\n return outreachRefusal(400, 'invalid-request', `\"${named}\" isn't a valid email address.`)\n }\n const loaded = await readOutreachStepRender(deps, request, body)\n if (loaded instanceof Response) return loaded\n const { caller, sequence, stepIndex, mailbox } = loaded\n\n const refusedBy = mailboxRefusal(mailbox)\n if (refusedBy || !mailbox) return outreachRefusal(409, 'mailbox-unavailable', refusedBy ?? RECONNECT_SENTENCE)\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return outreachRefusal(\n 403,\n 'permission',\n \"Only the member who connected this sequence's mailbox, or an organization owner or admin, can send a test from it.\",\n )\n }\n const to = typed ?? caller.email\n if (!to) return outreachRefusal(400, 'invalid-request', 'Type an address to send the test to.')\n // The rules every sequence email keeps, asked of the stored steps: a\n // sequence saved under an older rule is refused rather than sent broken.\n const issues = validateOutreachSteps(sequence.steps)\n if (hasOutreachValidationErrors(issues)) {\n return outreachRefusal(400, 'invalid-sequence', \"Fix the sequence's steps before sending a test.\", { issues })\n }\n const limited = await testDeps.consumeRateLimit(`outreach-step-test:${mailbox.id}`, {\n limit: OUTREACH_STEP_TESTS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_STEP_TESTS_PER_HOUR} tests an hour. Try again later.`,\n )\n }\n\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const sender = { address: mailbox.sendAs || mailbox.email, name: mailbox.displayName || null }\n /*\n * The links, as a real send would rewrite them (AGL-3239, AGL-3297) and\n * marked as a test's: the member sees what a recipient sees, and their\n * own click counts for nothing.\n */\n let linkOrigin: string | null = null\n if (sequence.settings.trackClicks && testDeps.clickLinkOrigin) {\n linkOrigin = await testDeps\n .clickLinkOrigin({ orgId: caller.orgId, senderAddress: sender.address })\n .catch(() => null)\n }\n const shortLinks: Array<{ id: string; doc: OutreachStoredLink }> = []\n const rewriteLink = sequence.settings.trackClicks\n ? (link: { url: string; index: number }) => {\n const doc = outreachStoredLink(\n {\n orgId: caller.orgId,\n enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,\n stepIndex,\n linkIndex: link.index,\n url: link.url,\n test: true,\n },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (!doc || !url) return null\n shortLinks.push({ id, doc: { ...doc, sequenceId: sequence.id } })\n return url\n }\n : null\n let composed = previewOutreachStep({ ...loaded.input, rewriteLink })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n if (shortLinks.length) {\n try {\n const batch = firestore.batch()\n for (const link of shortLinks) {\n batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc)\n }\n await batch.commit()\n } catch (error) {\n // A link that resolves nowhere is worse than one shown as written:\n // the test goes out with the step's own links.\n console.error('[outreach] the test links could not be stored; the test carries the links as written', error)\n composed = previewOutreachStep({ ...loaded.input, rewriteLink: null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n }\n }\n\n const opened = await testDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', \"Google mailboxes aren't set up on this deployment.\")\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', RECONNECT_SENTENCE)\n }\n /*\n * The preview's email, addressed to the member: `[Test]` in front, and a\n * `Re:` step sent as a new message — no thread id, no `In-Reply-To`, no\n * `References` — since the thread it answers exists only on screen. The\n * unsubscribe header is left off: it names an enrollment, and there is\n * none.\n */\n const email: ComposedOutreachEmail = {\n to,\n subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,\n text: composed.email.text,\n trackedLinks: composed.email.trackedLinks,\n }\n let subject: string\n try {\n subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject\n } catch (error) {\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', 'Google refused this mailbox’s access. Connect it again.')\n }\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`)\n }\n if (!(error instanceof GmailTransportError)) throw error\n return outreachRefusal(\n 502,\n 'google-unavailable',\n error.retryable ? 'Google did not answer. Try again in a moment.' : `Google refused the request: ${error.message}`,\n )\n }\n\n const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs)\n return outreachOk({\n ok: true,\n stepIndex,\n sentTo: to,\n subject,\n sentAtMs: nowMs,\n testsToday,\n unresolvedFields: composed.unresolvedFields,\n } satisfies OutreachStepTestAnswer)\n }\n}\n\n/**\n * One more test on the mailbox's local day, in `health.daily.<day>.tests`\n * beside the day's sends and never in them. Bookkeeping beside the act: the\n * email has left, so a failure is logged and answered as no count.\n */\nasync function countTest(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<number> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return 0\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const tests = Math.max(0, Number(counts.tests) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, tests }, updatedAtMs: nowMs })\n return tests\n })\n } catch (error) {\n console.warn('[outreach] a test send could not be counted on its mailbox', error)\n return 0\n }\n}\n"],"names":["normalizeContactEmail","hasOutreachValidationErrors","validateOutreachSteps","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","OUTREACH_COLLECTIONS","previewOutreachStep","newOutreachLinkId","OUTREACH_TEST_LINK_ENROLLMENT","outreachStoredLink","readStoredOutreachMailbox","GmailTransportError","Rfc5322MessageError","sendComposedOutreachEmail","readOutreachStepRender","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachJsonBody","OUTREACH_STEP_TESTS_PER_HOUR","OUTREACH_TEST_SUBJECT_PREFIX","RECONNECT_SENTENCE","mailboxRefusal","mailbox","status","createOutreachStepTestRoute","deps","testDeps","request","method","body","named","trim","typed","loaded","Response","caller","sequence","stepIndex","refusedBy","connectedByUid","uid","isOrgAdmin","to","email","issues","steps","limited","consumeRateLimit","id","limit","windowMs","allowed","firestore","nowMs","now","sender","address","sendAs","name","displayName","linkOrigin","settings","trackClicks","clickLinkOrigin","orgId","senderAddress","catch","shortLinks","rewriteLink","link","doc","enrollmentId","linkIndex","index","url","test","clickLinkUrl","push","sequenceId","composed","input","error","message","length","batch","create","collection","links","commit","console","opened","openMailbox","ok","reason","mailboxId","errorCode","subject","text","trackedLinks","client","reconnectRequired","code","retryable","testsToday","countTest","sentTo","sentAtMs","unresolvedFields","ref","runTransaction","transaction","stored","snapshot","get","exists","data","undefined","day","timezone","counts","health","daily","sent","bounces","replies","tests","Math","max","Number","update","updatedAtMs","warn"],"mappings":"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"}
|
|
@@ -48,6 +48,16 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
|
|
|
48
48
|
*/ export async function recordOutreachClick(deps, input) {
|
|
49
49
|
const firestore = deps.firestore();
|
|
50
50
|
const { orgId, enrollmentId, stepIndex, url } = input.target;
|
|
51
|
+
// A test link (AGL-3325) names no enrollment and counts on nothing —
|
|
52
|
+
// held here too, so no caller can record one by reaching past the route.
|
|
53
|
+
if (input.target.test) {
|
|
54
|
+
return {
|
|
55
|
+
human: false,
|
|
56
|
+
machineReason: null,
|
|
57
|
+
first: false,
|
|
58
|
+
enrollment: null
|
|
59
|
+
};
|
|
60
|
+
}
|
|
51
61
|
const nowMs = deps.now();
|
|
52
62
|
const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId);
|
|
53
63
|
const outcome = await firestore.runTransaction(async (transaction)=>{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.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 {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { readOutreachEngagement } from '../model/enrollment-engagement'\nimport {\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Three places are written, because three different questions are asked of\n * them and no one document answers all three:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone.\n * 2. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 3. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried the link was sent, or `null` when it cannot be found. */\nfunction sentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = sentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: campaignLinkKey(url) ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n }\n : { ...held, machineClicks: held.machineClicks + 1 }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n transaction.update(ref, { engagement })\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","readOutreachEngagement","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","sentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","human","machineReason","first","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","clicks","lastClickAtMs","lastClickUrl","machineClicks","update","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","key","collection","set","unattributedClicks","merge","links","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.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 {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { readOutreachEngagement } from '../model/enrollment-engagement'\nimport {\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Three places are written, because three different questions are asked of\n * them and no one document answers all three:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone.\n * 2. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 3. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried the link was sent, or `null` when it cannot be found. */\nfunction sentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = sentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: campaignLinkKey(url) ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n }\n : { ...held, machineClicks: held.machineClicks + 1 }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n transaction.update(ref, { engagement })\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","readOutreachEngagement","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","sentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","clicks","lastClickAtMs","lastClickUrl","machineClicks","update","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","key","collection","set","unattributedClicks","merge","links","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,yCAAwC;AAC/C,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,kBAAkB,QAAQ,gCAA4B;AAC/D,SACEC,kBAAkB,QAEb,8BAA0B;AACjC,SAASC,sBAAsB,QAAQ,oCAAgC;AACvE,SACEC,yBAAyB,QAGpB,6BAAyB;AAChC,SACEC,sBAAsB,EACtBC,qBAAqB,EACrBC,4BAA4B,QACvB,iCAA6B;AAkCpC,qFAAqF,GACrF,SAASC,SAASC,UAA8B,EAAEC,SAAiB;QACjDD;IAAhB,MAAME,WAAUF,0BAAAA,WAAWG,WAAW,YAAtBH,0BAA0B,EAAE;IAC5C,IAAK,IAAII,QAAQF,QAAQG,MAAM,GAAG,GAAGD,SAAS,GAAGA,SAAS,EAAG;QAC3D,MAAME,SAASJ,OAAO,CAACE,MAAM;QAC7B,IAAIE,CAAAA,0BAAAA,OAAQL,SAAS,MAAKA,aAAaK,OAAOC,IAAI,KAAK,SAAS;YAC9D,OAAO,OAAOD,OAAOE,IAAI,KAAK,YAAYC,OAAOC,QAAQ,CAACJ,OAAOE,IAAI,IAAIF,OAAOE,IAAI,GAAG;QACzF;IACF;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeG,oBACpBC,IAAiE,EACjEC,KAIC;IAED,MAAMC,YAAYF,KAAKE,SAAS;IAChC,MAAM,EAAEC,KAAK,EAAEC,YAAY,EAAEf,SAAS,EAAEgB,GAAG,EAAE,GAAGJ,MAAMK,MAAM;IAC5D,qEAAqE;IACrE,yEAAyE;IACzE,IAAIL,MAAMK,MAAM,CAACC,IAAI,EAAE;QACrB,OAAO;YAAEC,OAAO;YAAOC,eAAe;YAAMC,OAAO;YAAOtB,YAAY;QAAK;IAC7E;IACA,MAAMuB,QAAQX,KAAKY,GAAG;IACtB,MAAMC,MAAM5B,sBAAsBiB,WAAWC,OAAO,eAAeW,GAAG,CAACV;IAEvE,MAAMW,UAAU,MAAMb,UAAUc,cAAc,CAAC,OAAOC;YAoB9BC,sBAEFxC;QArBpB,MAAMyC,WAAW,MAAMF,YAAYG,GAAG,CAACP;QACvC,MAAMzB,aAAaF,6BACjBkB,cACAe,SAASE,MAAM,GAAGF,SAASG,IAAI,KAAKC;QAEtC,IAAI,CAACnC,YAAY;YACf,OAAO;gBAAEoB,OAAO;gBAAOC,eAAe;gBAAMC,OAAO;gBAAOtB,YAAY;YAAK;QAC7E;QACA,MAAMoC,OAAOrC,SAASC,YAAYC;QAClC,MAAMoC,YAAY5C,mBAAmB;YACnC6C,QAAQzB,MAAMyB,MAAM;YACpBC,WAAW1B,MAAM0B,SAAS;YAC1BC,aAAaJ,SAAS,OAAO,OAAOb,QAAQa;QAC9C;QACA,MAAMN,OAAOpC,uBAAuBM,WAAWyC,UAAU;QACzD,MAAMnB,QAAQe,UAAUjB,KAAK,IAAIU,KAAKY,cAAc,KAAK;QACzD,MAAMD,aAA2CJ,UAAUjB,KAAK,GAC5D;YACEuB,QAAQb,KAAKa,MAAM,GAAG;YACtBD,cAAc,GAAEZ,uBAAAA,KAAKY,cAAc,YAAnBZ,uBAAuBP;YACvCqB,eAAerB;YACfsB,YAAY,GAAEvD,mBAAAA,gBAAgB2B,gBAAhB3B,mBAAwBwC,KAAKe,YAAY;YACvDC,eAAehB,KAAKgB,aAAa;QACnC,IACA,aAAKhB;YAAMgB,eAAehB,KAAKgB,aAAa,GAAG;;QACnD;;;;;KAKC,GACDjB,YAAYkB,MAAM,CAACtB,KAAK;YAAEgB;QAAW;QACrC,OAAO,aAAKJ;YAAWf;YAAOtB,YAAY,aAAKA;gBAAYyC;;;IAC7D;IAEA,IAAI,CAACd,QAAQ3B,UAAU,EAAE,OAAO2B;IAEhC,MAAMqB,QAAQC,GAAG,CAAC;QAChBC,kBAAkBpC,WAAWa,SAAS;YACpCZ;YACAoC,YAAYxB,QAAQ3B,UAAU,CAACmD,UAAU;YACzC5B;QACF;QACAI,QAAQP,KAAK,GAAGgC,eAAetC,WAAW;YAAEC;YAAOoC,YAAYxB,QAAQ3B,UAAU,CAACmD,UAAU;YAAElC;QAAI,KAAK;QACvGU,QAAQL,KAAK,GAAG+B,eAAezC,MAAM;YAAEG;YAAOf,YAAY2B,QAAQ3B,UAAU;YAAEiB;YAAKM;QAAM,KAAK;KAC/F;IACD,OAAOI;AACT;AAEA;;;;;;;CAOC,GACD,eAAeuB,kBACbpC,SAAsC,EACtCa,OAA6B,EAC7Bd,KAA2D;IAE3D,IAAI,CAACA,MAAMsC,UAAU,EAAE;IACvB,MAAMG,QAAiC3B,QAAQP,KAAK,GAChD;QACE,gBAAgB7B,WAAWgE,SAAS,CAAC;QACrC,uBAAuB1C,MAAMU,KAAK;OAC9BI,QAAQL,KAAK,GAAG;QAAE,sBAAsB/B,WAAWgE,SAAS,CAAC;IAAG,IAAI,CAAC,KAE3E;QAAE,uBAAuBhE,WAAWgE,SAAS,CAAC;IAAG;IACrD,MAAM1D,sBAAsBiB,WAAWD,MAAME,KAAK,EAAE,aACjDW,GAAG,CAACb,MAAMsC,UAAU,EACpBJ,MAAM,CAACO,OACPE,KAAK,CAAC,CAACC;QACN,qEAAqE;QACrE,8DAA8D;QAC9DC,QAAQC,IAAI,CAAC,2DAA2DF;IAC1E;AACJ;AAEA;;;;;;;;;;CAUC,GACD,eAAeL,eACbtC,SAAsC,EACtCD,KAAyD;IAEzD,IAAI,CAACA,MAAMsC,UAAU,EAAE;IACvB,MAAMS,MAAMtE,gBAAgBuB,MAAMI,GAAG;IACrC,MAAMQ,MAAM5B,sBAAsBiB,WAAWD,MAAME,KAAK,EAAE,aACvDW,GAAG,CAACb,MAAMsC,UAAU,EACpBU,UAAU,CAAClE,yBAAyB,CAAC,EAAE,EACvC+B,GAAG,CAAC/B,yBAAyB,CAAC,EAAE;IACnC,IAAI,CAACiE,KAAK;QACR,MAAMnC,IACHqC,GAAG,CAAC;YAAEC,oBAAoBxE,WAAWgE,SAAS,CAAC;QAAG,GAAG;YAAES,OAAO;QAAK,GACnER,KAAK,CAAC,IAAMrB;QACf;IACF;IACA,IAAI;QACF,MAAMrB,UAAUc,cAAc,CAAC,OAAOC;gBAEHE;YADjC,MAAMA,WAAW,MAAMF,YAAYG,GAAG,CAACP;YACvC,MAAMwC,QAASlC,SAASE,MAAM,IAAGF,iBAAAA,SAASG,IAAI,uBAAbH,cAAiB,CAAC,QAAQ,GAAG;YAG9D,MAAMD,OAAOmC,yBAAAA,KAAO,CAACC,oBAAoBN,KAAK;YAC9C,IAAI,CAAC9B,QAAQqC,OAAOC,IAAI,CAACH,gBAAAA,QAAS,CAAC,GAAG5D,MAAM,IAAIhB,0BAA0B;gBACxEwC,YAAYiC,GAAG,CAACrC,KAAK;oBAAE4C,gBAAgB9E,WAAWgE,SAAS,CAAC;gBAAG,GAAG;oBAAES,OAAO;gBAAK;gBAChF;YACF;YACAnC,YAAYiC,GAAG,CACbrC,KACA;gBACEwC,OAAO;oBACL,CAACC,oBAAoBN,KAAK,EAAE;wBAC1B3C,KAAK2C;wBACLjB,QAAQ,AAAClC,CAAAA,OAAOqB,wBAAAA,KAAMa,MAAM,KAAK,CAAA,IAAK;oBACxC;gBACF;YACF,GACA;gBAAEqB,OAAO;YAAK;QAElB;IACF,EAAE,OAAOP,OAAO;QACdC,QAAQC,IAAI,CAAC,4DAA4DF;IAC3E;AACF;AAEA;;;;;;;CAOC,GACD,SAASS,oBAAoBN,GAAW;IACtC,OAAOU,OAAOC,IAAI,CAACX,KAAK,QAAQY,QAAQ,CAAC;AAC3C;AAEA;;;;;;CAMC,GACD,eAAenB,eACbzC,IAA2C,EAC3CC,KAAoF;IAEpF,MAAM4D,SAAS7D,KAAK8D,QAAQ;IAC5B,IAAI,CAACD,QAAQ;IACb,IAAI;YAQ8CnF;QAPhD,MAAMqF,UAAU,MAAMF,OAAOG,WAAW,CAAC;YACvC7D,OAAOF,MAAME,KAAK;YAClB8D,QAAQhE,MAAMb,UAAU,CAAC6E,MAAM;YAC/BC,MAAMlF,uBAAuBiB,MAAMb,UAAU;YAC7C+E,gBAAgBvF;YAChBe,MAAM;YACNC,MAAMK,MAAMU,KAAK;YACjByD,MAAM,CAAC,qCAAqC,GAAE1F,mBAAAA,gBAAgBuB,MAAMI,GAAG,aAAzB3B,mBAA8BuB,MAAMI,GAAG,EAAE;YACvFgE,OAAO;YACPC,QAAQ;QACV;QACA,IAAIP,QAAQQ,EAAE,KAAK,OAAO;YACxBzB,QAAQC,IAAI,CAAC,CAAC,mDAAmD,EAAEgB,QAAQlB,KAAK,EAAE;QACpF;IACF,EAAE,OAAOA,OAAO;QACdC,QAAQD,KAAK,CAAC,kDAAkDA;IAClE;AACF"}
|
|
@@ -81,7 +81,16 @@ export interface OutreachClickTarget {
|
|
|
81
81
|
linkIndex: number;
|
|
82
82
|
/** Where the recipient asked to go. */
|
|
83
83
|
url: string;
|
|
84
|
+
/**
|
|
85
|
+
* A link in a TEST of a step (AGL-3325), sent to a member and never to a
|
|
86
|
+
* person: it is followed exactly as any other and recorded nowhere, so
|
|
87
|
+
* the member's own click never moves a sequence's `clicks` or
|
|
88
|
+
* `machineClicks`. Its `enrollmentId` is {@link OUTREACH_TEST_LINK_ENROLLMENT}.
|
|
89
|
+
*/
|
|
90
|
+
test?: boolean;
|
|
84
91
|
}
|
|
92
|
+
/** The enrollment a test link names, since a test enrolls nobody. */
|
|
93
|
+
export declare const OUTREACH_TEST_LINK_ENROLLMENT = "test";
|
|
85
94
|
/** A destination we will sign, or `null` for one we will not. */
|
|
86
95
|
export declare function outreachClickTargetUrl(value: unknown): string | null;
|
|
87
96
|
/** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */
|
|
@@ -114,6 +123,8 @@ export declare function newOutreachLinkId(random?: (size: number) => Uint8Array)
|
|
|
114
123
|
export interface OutreachStoredLink extends OutreachClickTarget {
|
|
115
124
|
v: 1;
|
|
116
125
|
createdAtMs: number;
|
|
126
|
+
/** Which sequence a TEST link's step belongs to (AGL-3325); a real send names its enrollment instead. */
|
|
127
|
+
sequenceId?: string;
|
|
117
128
|
}
|
|
118
129
|
/**
|
|
119
130
|
* The document for one link, or `null` when the target is not one we would
|
|
@@ -69,6 +69,7 @@ import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
|
|
|
69
69
|
* to linkify very long ones. Past this the link is left alone: an unmeasured
|
|
70
70
|
* click beats a link that arrives broken.
|
|
71
71
|
*/ export const OUTREACH_CLICK_TARGET_MAX = 512;
|
|
72
|
+
/** The enrollment a test link names, since a test enrolls nobody. */ export const OUTREACH_TEST_LINK_ENROLLMENT = 'test';
|
|
72
73
|
/** A document id as a token may carry one. */ const DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/;
|
|
73
74
|
const base64url = (text)=>Buffer.from(text, 'utf8').toString('base64url');
|
|
74
75
|
/** A destination we will sign, or `null` for one we will not. */ export function outreachClickTargetUrl(value) {
|
|
@@ -193,7 +194,7 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
|
|
|
193
194
|
if (!url) return null;
|
|
194
195
|
if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null;
|
|
195
196
|
if (!index(target.stepIndex) || !index(target.linkIndex)) return null;
|
|
196
|
-
return {
|
|
197
|
+
return _extends({
|
|
197
198
|
v: 1,
|
|
198
199
|
orgId: target.orgId,
|
|
199
200
|
enrollmentId: target.enrollmentId,
|
|
@@ -201,7 +202,9 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
|
|
|
201
202
|
linkIndex: target.linkIndex,
|
|
202
203
|
url,
|
|
203
204
|
createdAtMs: nowMs
|
|
204
|
-
}
|
|
205
|
+
}, target.test ? {
|
|
206
|
+
test: true
|
|
207
|
+
} : {});
|
|
205
208
|
}
|
|
206
209
|
/**
|
|
207
210
|
* What a stored link names, or `null` for a document that is missing or not
|
|
@@ -218,13 +221,15 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
|
|
|
218
221
|
if (!url) return null;
|
|
219
222
|
if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null;
|
|
220
223
|
if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null;
|
|
221
|
-
return {
|
|
224
|
+
return _extends({
|
|
222
225
|
orgId,
|
|
223
226
|
enrollmentId,
|
|
224
227
|
stepIndex: body['stepIndex'],
|
|
225
228
|
linkIndex: body['linkIndex'],
|
|
226
229
|
url
|
|
227
|
-
}
|
|
230
|
+
}, body['test'] === true ? {
|
|
231
|
+
test: true
|
|
232
|
+
} : {});
|
|
228
233
|
}
|
|
229
234
|
/**
|
|
230
235
|
* The short link for an id, or `null` when there is no HTTPS origin to put it
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-link.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 { randomBytes } from 'node:crypto'\nimport {\n signedLinkSignature,\n signedLinkSignatureMatches,\n} from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\n\n/**\n * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239,\n * AGL-3297).\n *\n * ## Short, and stored\n *\n * Every send since AGL-3297 carries `/api/outreach/l/<id>`: a random\n * {@link OUTREACH_LINK_ID_LENGTH}-character id naming an `outreachLinks`\n * document that holds the organization, the enrollment, the step, the link's\n * index and the destination. The send writes those documents before the\n * email leaves, and the route reads one to answer.\n *\n * The first design put all of that in a signed token instead, so the\n * redirect needed no read. It cost a ~350-character link, which in a\n * one-to-one plain-text email reads as bulk marketing — the whole thing a\n * sequence is not. A read per click is the cheaper of the two.\n *\n * The signed links already in inboxes keep working: the `click` route still\n * verifies and follows them, and {@link readOutreachClickToken} stays for it.\n * Nothing mints one any more.\n *\n * ## It cannot be turned into an open redirect\n *\n * The destination comes only from the stored document, which only the\n * sending runtime writes, and only for a URL written in that sequence's own\n * step; clients cannot read or write the collection at all. An id that names\n * no document is refused rather than followed. For the signed links, the\n * signature covers the destination, to the same end.\n *\n * ## It names ids, never an address\n *\n * A tracking URL lands in the recipient's history, in their gateway's logs\n * and in ours. The short link carries nothing but a random id; the stored\n * document names the enrollment, which names the person — a lookup that is\n * ours to make, not a bystander's to read off a URL.\n *\n * ## The id is unguessable\n *\n * Ten characters of base 62 from the platform's CSPRNG — about 59.5 bits. A\n * guess that lands resolves to a destination someone else was sent, and\n * would count a click that is recorded as the one it is (see\n * `click-events.ts`); it grants nothing else.\n */\n\n/** The purpose Outreach's click links are signed under. */\nexport const OUTREACH_CLICK_PURPOSE = 'outreach-click'\n\n/** The signed link's path on the console — answered, no longer minted. */\nexport const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`\n\n/** The short link's path on the console, before the id (AGL-3297). */\nexport const OUTREACH_SHORT_LINK_PATH = `/api/${OUTREACH_API_ROUTES.shortLink.replace(/\\/:linkId$/, '')}`\n\n/**\n * The longest destination a tracking link will carry.\n *\n * A token is a URL inside a URL, and mail clients wrap, truncate and refuse\n * to linkify very long ones. Past this the link is left alone: an unmeasured\n * click beats a link that arrives broken.\n */\nexport const OUTREACH_CLICK_TARGET_MAX = 512\n\n/** What a click token names. */\nexport interface OutreachClickTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the link. */\n stepIndex: number\n /** The link's index among the ones that step rewrote. */\n linkIndex: number\n /** Where the recipient asked to go. */\n url: string\n}\n\n/** A document id as a token may carry one. */\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/\n\nconst base64url = (text: string): string => Buffer.from(text, 'utf8').toString('base64url')\n\n/** A destination we will sign, or `null` for one we will not. */\nexport function outreachClickTargetUrl(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n if (!raw || raw.length > OUTREACH_CLICK_TARGET_MAX) return null\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return null\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.href\n}\n\n/** Whether an index is one a token may carry. */\nconst index = (value: unknown): boolean =>\n typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 1000\n\n/** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */\nexport function mintOutreachClickToken(\n target: OutreachClickTarget,\n secret?: string,\n): string | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n const payload = base64url(\n JSON.stringify({\n v: 1,\n o: target.orgId,\n e: target.enrollmentId,\n s: target.stepIndex,\n i: target.linkIndex,\n u: url,\n }),\n )\n const signature =\n secret === undefined\n ? signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload)\n : signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload, secret)\n return signature ? `${payload}.${signature}` : null\n}\n\n/** What a token names, or `null` for one that is malformed or not ours. */\nexport function readOutreachClickToken(\n token: unknown,\n secret?: string,\n): OutreachClickTarget | null {\n const raw = typeof token === 'string' ? token.trim() : ''\n const dot = raw.indexOf('.')\n // Generous, because the payload carries a destination — but bounded, so an\n // arbitrarily long string never reaches the HMAC.\n if (dot <= 0 || raw.length > 2048) return null\n const payload = raw.slice(0, dot)\n const signature = raw.slice(dot + 1)\n if (\n !signedLinkSignatureMatches({\n purpose: OUTREACH_CLICK_PURPOSE,\n payload,\n signature,\n ...(secret === undefined ? {} : { secret }),\n })\n ) {\n return null\n }\n try {\n const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['o'] ?? '')\n const enrollmentId = String(body['e'] ?? '')\n const stepIndex = body['s']\n const linkIndex = body['i']\n // Checked again on the way OUT, not only on the way in: the signature\n // says we minted this payload, and these say it is still one we will act\n // on — which is what keeps a destination that was signable under an\n // older rule from being followed under this one.\n const url = outreachClickTargetUrl(body['u'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(stepIndex) || !index(linkIndex)) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: stepIndex as number,\n linkIndex: linkIndex as number,\n url,\n }\n } catch {\n return null\n }\n}\n\n/**\n * The tracking URL for one link on the console at `origin`, or `null` when\n * the origin is not HTTPS, the destination is not one we sign, or there is\n * no secret to sign with — no link rather than one that points at nothing.\n *\n * A `null` leaves the recipient with the destination the step actually\n * wrote, which is what `rewriteOutreachBodyLinks` does with it.\n */\nexport function outreachClickUrl(input: {\n origin: string | null | undefined\n target: OutreachClickTarget\n secret?: string\n}): string | null {\n let origin: URL\n try {\n origin = new URL(String(input.origin ?? ''))\n } catch {\n return null\n }\n if (origin.protocol !== 'https:') return null\n const token = mintOutreachClickToken(input.target, input.secret)\n return token ? `${origin.origin}${OUTREACH_CLICK_PATH}?t=${token}` : null\n}\n\n/*==========================================\n * THE SHORT LINK (AGL-3297).\n *=========================================*/\n\n/** How many characters a short link's id has. */\nexport const OUTREACH_LINK_ID_LENGTH = 10\n\nconst LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n\n/** A short link's id as the route will read one. */\nconst LINK_ID = new RegExp(`^[A-Za-z0-9]{${OUTREACH_LINK_ID_LENGTH}}$`)\n\n/** Whether a value is shaped like a short link's id. */\nexport function isOutreachLinkId(value: unknown): value is string {\n return typeof value === 'string' && LINK_ID.test(value)\n}\n\n/**\n * A fresh short link id: base 62 from the CSPRNG, by rejection sampling so\n * every character is equally likely (62 does not divide 256).\n */\nexport function newOutreachLinkId(random: (size: number) => Uint8Array = randomBytes): string {\n let id = ''\n while (id.length < OUTREACH_LINK_ID_LENGTH) {\n for (const byte of random(OUTREACH_LINK_ID_LENGTH * 2)) {\n if (byte >= 248) continue\n id += LINK_ID_ALPHABET[byte % 62]\n if (id.length === OUTREACH_LINK_ID_LENGTH) break\n }\n }\n return id\n}\n\n/** What an `outreachLinks/{id}` document holds. */\nexport interface OutreachStoredLink extends OutreachClickTarget {\n v: 1\n createdAtMs: number\n}\n\n/**\n * The document for one link, or `null` when the target is not one we would\n * follow — the same checks the signed token applied at minting.\n */\nexport function outreachStoredLink(target: OutreachClickTarget, nowMs: number): OutreachStoredLink | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n return {\n v: 1,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n linkIndex: target.linkIndex,\n url,\n createdAtMs: nowMs,\n }\n}\n\n/**\n * What a stored link names, or `null` for a document that is missing or not\n * one we will act on. Checked on the way OUT, as the token is: the document\n * was written by us, and these say it is still one we will follow.\n */\nexport function readOutreachStoredLink(data: unknown): OutreachClickTarget | null {\n if (!data || typeof data !== 'object') return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n const url = outreachClickTargetUrl(body['url'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n linkIndex: body['linkIndex'] as number,\n url,\n }\n}\n\n/**\n * The short link for an id, or `null` when there is no HTTPS origin to put it\n * on or the id is not one — no link rather than one that points at nothing. A\n * `null` leaves the destination the step wrote.\n *\n * On the sending domain's own click-tracking host (AGL-3306) when the org has\n * verified one — `https://links.<domain>/<id>`, which that host answers\n * exactly as the console route — and on the console's\n * `/api/outreach/l/<id>` otherwise, which every link keeps answering on.\n */\nexport function outreachShortLinkUrl(input: {\n origin: string | null | undefined\n linkId: string\n /** `https://links.<domain>`, from `resolveTrackingLinkOrigin`, or nothing. */\n trackingOrigin?: string | null\n}): string | null {\n if (!isOutreachLinkId(input.linkId)) return null\n const tracking = httpsOrigin(input.trackingOrigin)\n if (tracking) return `${tracking}/${input.linkId}`\n const origin = httpsOrigin(input.origin)\n return origin ? `${origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}` : null\n}\n\n/** An HTTPS origin, or `null` for anything else. */\nfunction httpsOrigin(value: string | null | undefined): string | null {\n if (!value) return null\n try {\n const url = new URL(String(value))\n return url.protocol === 'https:' ? url.origin : null\n } catch {\n return null\n }\n}\n"],"names":["randomBytes","signedLinkSignature","signedLinkSignatureMatches","OUTREACH_API_ROUTES","OUTREACH_CLICK_PURPOSE","OUTREACH_CLICK_PATH","click","OUTREACH_SHORT_LINK_PATH","shortLink","replace","OUTREACH_CLICK_TARGET_MAX","DOCUMENT_ID","base64url","text","Buffer","from","toString","outreachClickTargetUrl","value","raw","String","trim","length","url","URL","protocol","href","index","Number","isInteger","mintOutreachClickToken","target","secret","test","orgId","enrollmentId","stepIndex","linkIndex","payload","JSON","stringify","v","o","e","s","i","u","signature","undefined","readOutreachClickToken","token","dot","indexOf","slice","purpose","body","parse","outreachClickUrl","input","origin","OUTREACH_LINK_ID_LENGTH","LINK_ID_ALPHABET","LINK_ID","RegExp","isOutreachLinkId","newOutreachLinkId","random","id","byte","outreachStoredLink","nowMs","createdAtMs","readOutreachStoredLink","data","outreachShortLinkUrl","linkId","tracking","httpsOrigin","trackingOrigin"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-link.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 { randomBytes } from 'node:crypto'\nimport {\n signedLinkSignature,\n signedLinkSignatureMatches,\n} from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\n\n/**\n * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239,\n * AGL-3297).\n *\n * ## Short, and stored\n *\n * Every send since AGL-3297 carries `/api/outreach/l/<id>`: a random\n * {@link OUTREACH_LINK_ID_LENGTH}-character id naming an `outreachLinks`\n * document that holds the organization, the enrollment, the step, the link's\n * index and the destination. The send writes those documents before the\n * email leaves, and the route reads one to answer.\n *\n * The first design put all of that in a signed token instead, so the\n * redirect needed no read. It cost a ~350-character link, which in a\n * one-to-one plain-text email reads as bulk marketing — the whole thing a\n * sequence is not. A read per click is the cheaper of the two.\n *\n * The signed links already in inboxes keep working: the `click` route still\n * verifies and follows them, and {@link readOutreachClickToken} stays for it.\n * Nothing mints one any more.\n *\n * ## It cannot be turned into an open redirect\n *\n * The destination comes only from the stored document, which only the\n * sending runtime writes, and only for a URL written in that sequence's own\n * step; clients cannot read or write the collection at all. An id that names\n * no document is refused rather than followed. For the signed links, the\n * signature covers the destination, to the same end.\n *\n * ## It names ids, never an address\n *\n * A tracking URL lands in the recipient's history, in their gateway's logs\n * and in ours. The short link carries nothing but a random id; the stored\n * document names the enrollment, which names the person — a lookup that is\n * ours to make, not a bystander's to read off a URL.\n *\n * ## The id is unguessable\n *\n * Ten characters of base 62 from the platform's CSPRNG — about 59.5 bits. A\n * guess that lands resolves to a destination someone else was sent, and\n * would count a click that is recorded as the one it is (see\n * `click-events.ts`); it grants nothing else.\n */\n\n/** The purpose Outreach's click links are signed under. */\nexport const OUTREACH_CLICK_PURPOSE = 'outreach-click'\n\n/** The signed link's path on the console — answered, no longer minted. */\nexport const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`\n\n/** The short link's path on the console, before the id (AGL-3297). */\nexport const OUTREACH_SHORT_LINK_PATH = `/api/${OUTREACH_API_ROUTES.shortLink.replace(/\\/:linkId$/, '')}`\n\n/**\n * The longest destination a tracking link will carry.\n *\n * A token is a URL inside a URL, and mail clients wrap, truncate and refuse\n * to linkify very long ones. Past this the link is left alone: an unmeasured\n * click beats a link that arrives broken.\n */\nexport const OUTREACH_CLICK_TARGET_MAX = 512\n\n/** What a click token names. */\nexport interface OutreachClickTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the link. */\n stepIndex: number\n /** The link's index among the ones that step rewrote. */\n linkIndex: number\n /** Where the recipient asked to go. */\n url: string\n /**\n * A link in a TEST of a step (AGL-3325), sent to a member and never to a\n * person: it is followed exactly as any other and recorded nowhere, so\n * the member's own click never moves a sequence's `clicks` or\n * `machineClicks`. Its `enrollmentId` is {@link OUTREACH_TEST_LINK_ENROLLMENT}.\n */\n test?: boolean\n}\n\n/** The enrollment a test link names, since a test enrolls nobody. */\nexport const OUTREACH_TEST_LINK_ENROLLMENT = 'test'\n\n/** A document id as a token may carry one. */\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/\n\nconst base64url = (text: string): string => Buffer.from(text, 'utf8').toString('base64url')\n\n/** A destination we will sign, or `null` for one we will not. */\nexport function outreachClickTargetUrl(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n if (!raw || raw.length > OUTREACH_CLICK_TARGET_MAX) return null\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return null\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.href\n}\n\n/** Whether an index is one a token may carry. */\nconst index = (value: unknown): boolean =>\n typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 1000\n\n/** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */\nexport function mintOutreachClickToken(\n target: OutreachClickTarget,\n secret?: string,\n): string | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n const payload = base64url(\n JSON.stringify({\n v: 1,\n o: target.orgId,\n e: target.enrollmentId,\n s: target.stepIndex,\n i: target.linkIndex,\n u: url,\n }),\n )\n const signature =\n secret === undefined\n ? signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload)\n : signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload, secret)\n return signature ? `${payload}.${signature}` : null\n}\n\n/** What a token names, or `null` for one that is malformed or not ours. */\nexport function readOutreachClickToken(\n token: unknown,\n secret?: string,\n): OutreachClickTarget | null {\n const raw = typeof token === 'string' ? token.trim() : ''\n const dot = raw.indexOf('.')\n // Generous, because the payload carries a destination — but bounded, so an\n // arbitrarily long string never reaches the HMAC.\n if (dot <= 0 || raw.length > 2048) return null\n const payload = raw.slice(0, dot)\n const signature = raw.slice(dot + 1)\n if (\n !signedLinkSignatureMatches({\n purpose: OUTREACH_CLICK_PURPOSE,\n payload,\n signature,\n ...(secret === undefined ? {} : { secret }),\n })\n ) {\n return null\n }\n try {\n const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['o'] ?? '')\n const enrollmentId = String(body['e'] ?? '')\n const stepIndex = body['s']\n const linkIndex = body['i']\n // Checked again on the way OUT, not only on the way in: the signature\n // says we minted this payload, and these say it is still one we will act\n // on — which is what keeps a destination that was signable under an\n // older rule from being followed under this one.\n const url = outreachClickTargetUrl(body['u'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(stepIndex) || !index(linkIndex)) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: stepIndex as number,\n linkIndex: linkIndex as number,\n url,\n }\n } catch {\n return null\n }\n}\n\n/**\n * The tracking URL for one link on the console at `origin`, or `null` when\n * the origin is not HTTPS, the destination is not one we sign, or there is\n * no secret to sign with — no link rather than one that points at nothing.\n *\n * A `null` leaves the recipient with the destination the step actually\n * wrote, which is what `rewriteOutreachBodyLinks` does with it.\n */\nexport function outreachClickUrl(input: {\n origin: string | null | undefined\n target: OutreachClickTarget\n secret?: string\n}): string | null {\n let origin: URL\n try {\n origin = new URL(String(input.origin ?? ''))\n } catch {\n return null\n }\n if (origin.protocol !== 'https:') return null\n const token = mintOutreachClickToken(input.target, input.secret)\n return token ? `${origin.origin}${OUTREACH_CLICK_PATH}?t=${token}` : null\n}\n\n/*==========================================\n * THE SHORT LINK (AGL-3297).\n *=========================================*/\n\n/** How many characters a short link's id has. */\nexport const OUTREACH_LINK_ID_LENGTH = 10\n\nconst LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n\n/** A short link's id as the route will read one. */\nconst LINK_ID = new RegExp(`^[A-Za-z0-9]{${OUTREACH_LINK_ID_LENGTH}}$`)\n\n/** Whether a value is shaped like a short link's id. */\nexport function isOutreachLinkId(value: unknown): value is string {\n return typeof value === 'string' && LINK_ID.test(value)\n}\n\n/**\n * A fresh short link id: base 62 from the CSPRNG, by rejection sampling so\n * every character is equally likely (62 does not divide 256).\n */\nexport function newOutreachLinkId(random: (size: number) => Uint8Array = randomBytes): string {\n let id = ''\n while (id.length < OUTREACH_LINK_ID_LENGTH) {\n for (const byte of random(OUTREACH_LINK_ID_LENGTH * 2)) {\n if (byte >= 248) continue\n id += LINK_ID_ALPHABET[byte % 62]\n if (id.length === OUTREACH_LINK_ID_LENGTH) break\n }\n }\n return id\n}\n\n/** What an `outreachLinks/{id}` document holds. */\nexport interface OutreachStoredLink extends OutreachClickTarget {\n v: 1\n createdAtMs: number\n /** Which sequence a TEST link's step belongs to (AGL-3325); a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/**\n * The document for one link, or `null` when the target is not one we would\n * follow — the same checks the signed token applied at minting.\n */\nexport function outreachStoredLink(target: OutreachClickTarget, nowMs: number): OutreachStoredLink | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n return {\n v: 1,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n linkIndex: target.linkIndex,\n url,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n }\n}\n\n/**\n * What a stored link names, or `null` for a document that is missing or not\n * one we will act on. Checked on the way OUT, as the token is: the document\n * was written by us, and these say it is still one we will follow.\n */\nexport function readOutreachStoredLink(data: unknown): OutreachClickTarget | null {\n if (!data || typeof data !== 'object') return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n const url = outreachClickTargetUrl(body['url'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n linkIndex: body['linkIndex'] as number,\n url,\n ...(body['test'] === true ? { test: true } : {}),\n }\n}\n\n/**\n * The short link for an id, or `null` when there is no HTTPS origin to put it\n * on or the id is not one — no link rather than one that points at nothing. A\n * `null` leaves the destination the step wrote.\n *\n * On the sending domain's own click-tracking host (AGL-3306) when the org has\n * verified one — `https://links.<domain>/<id>`, which that host answers\n * exactly as the console route — and on the console's\n * `/api/outreach/l/<id>` otherwise, which every link keeps answering on.\n */\nexport function outreachShortLinkUrl(input: {\n origin: string | null | undefined\n linkId: string\n /** `https://links.<domain>`, from `resolveTrackingLinkOrigin`, or nothing. */\n trackingOrigin?: string | null\n}): string | null {\n if (!isOutreachLinkId(input.linkId)) return null\n const tracking = httpsOrigin(input.trackingOrigin)\n if (tracking) return `${tracking}/${input.linkId}`\n const origin = httpsOrigin(input.origin)\n return origin ? `${origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}` : null\n}\n\n/** An HTTPS origin, or `null` for anything else. */\nfunction httpsOrigin(value: string | null | undefined): string | null {\n if (!value) return null\n try {\n const url = new URL(String(value))\n return url.protocol === 'https:' ? url.origin : null\n } catch {\n return null\n }\n}\n"],"names":["randomBytes","signedLinkSignature","signedLinkSignatureMatches","OUTREACH_API_ROUTES","OUTREACH_CLICK_PURPOSE","OUTREACH_CLICK_PATH","click","OUTREACH_SHORT_LINK_PATH","shortLink","replace","OUTREACH_CLICK_TARGET_MAX","OUTREACH_TEST_LINK_ENROLLMENT","DOCUMENT_ID","base64url","text","Buffer","from","toString","outreachClickTargetUrl","value","raw","String","trim","length","url","URL","protocol","href","index","Number","isInteger","mintOutreachClickToken","target","secret","test","orgId","enrollmentId","stepIndex","linkIndex","payload","JSON","stringify","v","o","e","s","i","u","signature","undefined","readOutreachClickToken","token","dot","indexOf","slice","purpose","body","parse","outreachClickUrl","input","origin","OUTREACH_LINK_ID_LENGTH","LINK_ID_ALPHABET","LINK_ID","RegExp","isOutreachLinkId","newOutreachLinkId","random","id","byte","outreachStoredLink","nowMs","createdAtMs","readOutreachStoredLink","data","outreachShortLinkUrl","linkId","tracking","httpsOrigin","trackingOrigin"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,WAAW,QAAQ,cAAa;AACzC,SACEC,mBAAmB,EACnBC,0BAA0B,QACrB,yDAAwD;AAC/D,SAASC,mBAAmB,QAAQ,6BAAyB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GAED,yDAAyD,GACzD,OAAO,MAAMC,yBAAyB,iBAAgB;AAEtD,wEAAwE,GACxE,OAAO,MAAMC,sBAAsB,CAAC,KAAK,EAAEF,oBAAoBG,KAAK,EAAE,CAAA;AAEtE,oEAAoE,GACpE,OAAO,MAAMC,2BAA2B,CAAC,KAAK,EAAEJ,oBAAoBK,SAAS,CAACC,OAAO,CAAC,cAAc,KAAK,CAAA;AAEzG;;;;;;CAMC,GACD,OAAO,MAAMC,4BAA4B,IAAG;AAqB5C,mEAAmE,GACnE,OAAO,MAAMC,gCAAgC,OAAM;AAEnD,4CAA4C,GAC5C,MAAMC,cAAc;AAEpB,MAAMC,YAAY,CAACC,OAAyBC,OAAOC,IAAI,CAACF,MAAM,QAAQG,QAAQ,CAAC;AAE/E,+DAA+D,GAC/D,OAAO,SAASC,uBAAuBC,KAAc;IACnD,MAAMC,MAAMC,OAAOF,gBAAAA,QAAS,IAAIG,IAAI;IACpC,IAAI,CAACF,OAAOA,IAAIG,MAAM,GAAGb,2BAA2B,OAAO;IAC3D,IAAIc;IACJ,IAAI;QACFA,MAAM,IAAIC,IAAIL;IAChB,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAII,IAAIE,QAAQ,KAAK,WAAWF,IAAIE,QAAQ,KAAK,UAAU,OAAO;IAClE,OAAOF,IAAIG,IAAI;AACjB;AAEA,+CAA+C,GAC/C,MAAMC,QAAQ,CAACT,QACb,OAAOA,UAAU,YAAYU,OAAOC,SAAS,CAACX,UAAUA,SAAS,KAAKA,QAAQ;AAEhF,4FAA4F,GAC5F,OAAO,SAASY,uBACdC,MAA2B,EAC3BC,MAAe;IAEf,MAAMT,MAAMN,uBAAuBc,OAAOR,GAAG;IAC7C,IAAI,CAACA,KAAK,OAAO;IACjB,IAAI,CAACZ,YAAYsB,IAAI,CAACF,OAAOG,KAAK,KAAK,CAACvB,YAAYsB,IAAI,CAACF,OAAOI,YAAY,GAAG,OAAO;IACtF,IAAI,CAACR,MAAMI,OAAOK,SAAS,KAAK,CAACT,MAAMI,OAAOM,SAAS,GAAG,OAAO;IACjE,MAAMC,UAAU1B,UACd2B,KAAKC,SAAS,CAAC;QACbC,GAAG;QACHC,GAAGX,OAAOG,KAAK;QACfS,GAAGZ,OAAOI,YAAY;QACtBS,GAAGb,OAAOK,SAAS;QACnBS,GAAGd,OAAOM,SAAS;QACnBS,GAAGvB;IACL;IAEF,MAAMwB,YACJf,WAAWgB,YACPhD,oBAAoBG,wBAAwBmC,WAC5CtC,oBAAoBG,wBAAwBmC,SAASN;IAC3D,OAAOe,YAAY,GAAGT,QAAQ,CAAC,EAAES,WAAW,GAAG;AACjD;AAEA,yEAAyE,GACzE,OAAO,SAASE,uBACdC,KAAc,EACdlB,MAAe;IAEf,MAAMb,MAAM,OAAO+B,UAAU,WAAWA,MAAM7B,IAAI,KAAK;IACvD,MAAM8B,MAAMhC,IAAIiC,OAAO,CAAC;IACxB,2EAA2E;IAC3E,kDAAkD;IAClD,IAAID,OAAO,KAAKhC,IAAIG,MAAM,GAAG,MAAM,OAAO;IAC1C,MAAMgB,UAAUnB,IAAIkC,KAAK,CAAC,GAAGF;IAC7B,MAAMJ,YAAY5B,IAAIkC,KAAK,CAACF,MAAM;IAClC,IACE,CAAClD,2BAA2B;QAC1BqD,SAASnD;QACTmC;QACAS;OACIf,WAAWgB,YAAY,CAAC,IAAI;QAAEhB;IAAO,KAE3C;QACA,OAAO;IACT;IACA,IAAI;YAGmBuB,SACOA;QAH5B,MAAMA,OAAOhB,KAAKiB,KAAK,CAAC1C,OAAOC,IAAI,CAACuB,SAAS,aAAatB,QAAQ,CAAC;QACnE,IAAIuC,IAAI,CAAC,IAAI,KAAK,GAAG,OAAO;QAC5B,MAAMrB,QAAQd,QAAOmC,UAAAA,IAAI,CAAC,IAAI,YAATA,UAAa;QAClC,MAAMpB,eAAef,QAAOmC,UAAAA,IAAI,CAAC,IAAI,YAATA,UAAa;QACzC,MAAMnB,YAAYmB,IAAI,CAAC,IAAI;QAC3B,MAAMlB,YAAYkB,IAAI,CAAC,IAAI;QAC3B,sEAAsE;QACtE,yEAAyE;QACzE,oEAAoE;QACpE,iDAAiD;QACjD,MAAMhC,MAAMN,uBAAuBsC,IAAI,CAAC,IAAI;QAC5C,IAAI,CAAChC,KAAK,OAAO;QACjB,IAAI,CAACZ,YAAYsB,IAAI,CAACC,UAAU,CAACvB,YAAYsB,IAAI,CAACE,eAAe,OAAO;QACxE,IAAI,CAACR,MAAMS,cAAc,CAACT,MAAMU,YAAY,OAAO;QACnD,OAAO;YACLH;YACAC;YACAC,WAAWA;YACXC,WAAWA;YACXd;QACF;IACF,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASkC,iBAAiBC,KAIhC;IACC,IAAIC;IACJ,IAAI;YACsBD;QAAxBC,SAAS,IAAInC,IAAIJ,QAAOsC,gBAAAA,MAAMC,MAAM,YAAZD,gBAAgB;IAC1C,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAIC,OAAOlC,QAAQ,KAAK,UAAU,OAAO;IACzC,MAAMyB,QAAQpB,uBAAuB4B,MAAM3B,MAAM,EAAE2B,MAAM1B,MAAM;IAC/D,OAAOkB,QAAQ,GAAGS,OAAOA,MAAM,GAAGvD,oBAAoB,GAAG,EAAE8C,OAAO,GAAG;AACvE;AAEA;;2CAE2C,GAE3C,+CAA+C,GAC/C,OAAO,MAAMU,0BAA0B,GAAE;AAEzC,MAAMC,mBAAmB;AAEzB,kDAAkD,GAClD,MAAMC,UAAU,IAAIC,OAAO,CAAC,aAAa,EAAEH,wBAAwB,EAAE,CAAC;AAEtE,sDAAsD,GACtD,OAAO,SAASI,iBAAiB9C,KAAc;IAC7C,OAAO,OAAOA,UAAU,YAAY4C,QAAQ7B,IAAI,CAACf;AACnD;AAEA;;;CAGC,GACD,OAAO,SAAS+C,kBAAkBC,SAAuCnE,WAAW;IAClF,IAAIoE,KAAK;IACT,MAAOA,GAAG7C,MAAM,GAAGsC,wBAAyB;QAC1C,KAAK,MAAMQ,QAAQF,OAAON,0BAA0B,GAAI;YACtD,IAAIQ,QAAQ,KAAK;YACjBD,MAAMN,gBAAgB,CAACO,OAAO,GAAG;YACjC,IAAID,GAAG7C,MAAM,KAAKsC,yBAAyB;QAC7C;IACF;IACA,OAAOO;AACT;AAUA;;;CAGC,GACD,OAAO,SAASE,mBAAmBtC,MAA2B,EAAEuC,KAAa;IAC3E,MAAM/C,MAAMN,uBAAuBc,OAAOR,GAAG;IAC7C,IAAI,CAACA,KAAK,OAAO;IACjB,IAAI,CAACZ,YAAYsB,IAAI,CAACF,OAAOG,KAAK,KAAK,CAACvB,YAAYsB,IAAI,CAACF,OAAOI,YAAY,GAAG,OAAO;IACtF,IAAI,CAACR,MAAMI,OAAOK,SAAS,KAAK,CAACT,MAAMI,OAAOM,SAAS,GAAG,OAAO;IACjE,OAAO;QACLI,GAAG;QACHP,OAAOH,OAAOG,KAAK;QACnBC,cAAcJ,OAAOI,YAAY;QACjCC,WAAWL,OAAOK,SAAS;QAC3BC,WAAWN,OAAOM,SAAS;QAC3Bd;QACAgD,aAAaD;OACTvC,OAAOE,IAAI,GAAG;QAAEA,MAAM;IAAK,IAAI,CAAC;AAExC;AAEA;;;;CAIC,GACD,OAAO,SAASuC,uBAAuBC,IAAa;QAI7BlB,aACOA;IAJ5B,IAAI,CAACkB,QAAQ,OAAOA,SAAS,UAAU,OAAO;IAC9C,MAAMlB,OAAOkB;IACb,IAAIlB,IAAI,CAAC,IAAI,KAAK,GAAG,OAAO;IAC5B,MAAMrB,QAAQd,QAAOmC,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;IACtC,MAAMpB,eAAef,QAAOmC,qBAAAA,IAAI,CAAC,eAAe,YAApBA,qBAAwB;IACpD,MAAMhC,MAAMN,uBAAuBsC,IAAI,CAAC,MAAM;IAC9C,IAAI,CAAChC,KAAK,OAAO;IACjB,IAAI,CAACZ,YAAYsB,IAAI,CAACC,UAAU,CAACvB,YAAYsB,IAAI,CAACE,eAAe,OAAO;IACxE,IAAI,CAACR,MAAM4B,IAAI,CAAC,YAAY,KAAK,CAAC5B,MAAM4B,IAAI,CAAC,YAAY,GAAG,OAAO;IACnE,OAAO;QACLrB;QACAC;QACAC,WAAWmB,IAAI,CAAC,YAAY;QAC5BlB,WAAWkB,IAAI,CAAC,YAAY;QAC5BhC;OACIgC,IAAI,CAAC,OAAO,KAAK,OAAO;QAAEtB,MAAM;IAAK,IAAI,CAAC;AAElD;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASyC,qBAAqBhB,KAKpC;IACC,IAAI,CAACM,iBAAiBN,MAAMiB,MAAM,GAAG,OAAO;IAC5C,MAAMC,WAAWC,YAAYnB,MAAMoB,cAAc;IACjD,IAAIF,UAAU,OAAO,GAAGA,SAAS,CAAC,EAAElB,MAAMiB,MAAM,EAAE;IAClD,MAAMhB,SAASkB,YAAYnB,MAAMC,MAAM;IACvC,OAAOA,SAAS,GAAGA,SAASrD,yBAAyB,CAAC,EAAEoD,MAAMiB,MAAM,EAAE,GAAG;AAC3E;AAEA,kDAAkD,GAClD,SAASE,YAAY3D,KAAgC;IACnD,IAAI,CAACA,OAAO,OAAO;IACnB,IAAI;QACF,MAAMK,MAAM,IAAIC,IAAIJ,OAAOF;QAC3B,OAAOK,IAAIE,QAAQ,KAAK,WAAWF,IAAIoC,MAAM,GAAG;IAClD,EAAE,eAAM;QACN,OAAO;IACT;AACF"}
|
|
@@ -89,6 +89,9 @@ const brokenLink = ()=>outreachUnsubscribePage({
|
|
|
89
89
|
Location: target.url
|
|
90
90
|
})
|
|
91
91
|
});
|
|
92
|
+
// A link in a test of a step (AGL-3325) is followed and never counted:
|
|
93
|
+
// the member clicking their own test is not a recipient acting.
|
|
94
|
+
if (target.test) return redirect;
|
|
92
95
|
try {
|
|
93
96
|
const outcome = await recordOutreachClick(deps, {
|
|
94
97
|
target,
|