@aglyn/plugins-outreach 1.0.0-beta.274 → 1.0.0-beta.277
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/README.md +7 -2
- package/package.json +14 -14
- package/src/lib/components/enrollment-actions.d.ts +11 -4
- package/src/lib/components/enrollment-actions.js +16 -0
- package/src/lib/components/enrollment-actions.js.map +1 -1
- package/src/lib/components/enrollment-detail.js +40 -13
- package/src/lib/components/enrollment-detail.js.map +1 -1
- package/src/lib/components/mailbox-card.js +13 -2
- package/src/lib/components/mailbox-card.js.map +1 -1
- package/src/lib/components/record-reply-button.d.ts +52 -0
- package/src/lib/components/record-reply-button.js +92 -0
- package/src/lib/components/record-reply-button.js.map +1 -0
- package/src/lib/components/reply-dialog.d.ts +67 -0
- package/src/lib/components/reply-dialog.js +353 -0
- package/src/lib/components/reply-dialog.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +5 -1
- package/src/lib/components/use-outreach-api.js +10 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +8 -0
- package/src/lib/constants/api-routes.js +6 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/index.d.ts +1 -0
- package/src/lib/engine/index.js +1 -0
- package/src/lib/engine/index.js.map +1 -1
- package/src/lib/engine/manual-reply.d.ts +122 -0
- package/src/lib/engine/manual-reply.js +311 -0
- package/src/lib/engine/manual-reply.js.map +1 -0
- package/src/lib/model/enrollment-history.d.ts +2 -0
- package/src/lib/model/enrollment-history.js +10 -5
- package/src/lib/model/enrollment-history.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +3 -1
- package/src/lib/model/enrollment-timeline.js +90 -9
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +68 -2
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +91 -3
- package/src/lib/model/outreach.types.js +6 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/stored-records.d.ts +7 -1
- package/src/lib/model/stored-records.js +37 -1
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/plugin.d.ts +4 -0
- package/src/lib/plugin.js +17 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/routes/register-routes.d.ts +7 -0
- package/src/lib/routes/register-routes.js +26 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/reply-routes.d.ts +92 -0
- package/src/lib/routes/reply-routes.js +644 -0
- package/src/lib/routes/reply-routes.js.map +1 -0
- package/src/lib/runtime/click-events.d.ts +13 -0
- package/src/lib/runtime/click-events.js +19 -6
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-link.d.ts +8 -0
- package/src/lib/runtime/click-link.js +7 -0
- package/src/lib/runtime/click-link.js.map +1 -1
- package/src/lib/runtime/sync-job.js +14 -0
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/transport/send-message.d.ts +11 -0
- package/src/lib/transport/send-message.js +22 -0
- package/src/lib/transport/send-message.js.map +1 -1
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
*/ /*==========================================
|
|
18
|
+
* ANSWERING A PERSON WHO WROTE BACK (AGL-4020).
|
|
19
|
+
*
|
|
20
|
+
* A person who answers a sequence email is answered from the same mailbox,
|
|
21
|
+
* in the same thread, by a member: the reply is a one-to-one email, never a
|
|
22
|
+
* step of the sequence. This module is the engine's half of it — what the
|
|
23
|
+
* person wrote, as the enrollment keeps it, and the answer, as the
|
|
24
|
+
* transport sends it — and decides nothing about storage or delivery.
|
|
25
|
+
*
|
|
26
|
+
* ## What the person wrote
|
|
27
|
+
*
|
|
28
|
+
* {@link outreachInboundMessagesOf} keeps the messages of a thread the
|
|
29
|
+
* classifier reads as theirs: a reply, an automatic answer, an opt-out. Each
|
|
30
|
+
* carries its sender, its time, its `Message-ID` and `References` (what an
|
|
31
|
+
* answer threads by), and its text with the quoted history trimmed, the way
|
|
32
|
+
* the record's timeline keeps it (`emailExcerpt`).
|
|
33
|
+
*
|
|
34
|
+
* ## The answer
|
|
35
|
+
*
|
|
36
|
+
* {@link composeOutreachManualReply} writes it as every mail client writes a
|
|
37
|
+
* reply: `Re:` the subject of the message it answers, `In-Reply-To` that
|
|
38
|
+
* message's `Message-ID`, and `References` the whole chain — the sequence's
|
|
39
|
+
* own sends, what the person's message already named, and the message
|
|
40
|
+
* itself — so it lands in the same thread in the person's mailbox and in
|
|
41
|
+
* ours. Plain text, the member's words first and the message answered quoted
|
|
42
|
+
* beneath. No footer and no `List-Unsubscribe`: the person wrote first, and
|
|
43
|
+
* this is the answer a person would type in their own mail client.
|
|
44
|
+
*
|
|
45
|
+
* On a sequence that tracks link clicks, the links in the member's words go
|
|
46
|
+
* out as the same short links a step's do (AGL-4022), through the caller's
|
|
47
|
+
* minter — never the links in the quoted message, which are theirs.
|
|
48
|
+
*
|
|
49
|
+
* ## What still refuses
|
|
50
|
+
*
|
|
51
|
+
* {@link outreachManualReplyRefusal}: an address or a domain on the
|
|
52
|
+
* do-not-contact list, a person who opted out or whose address bounced, and
|
|
53
|
+
* a mailbox that is paused, waiting to be reconnected, or gone. The cold
|
|
54
|
+
* rules — the allowed countries, the company-domain rule, the personal line,
|
|
55
|
+
* the daily cap — do not apply to someone who wrote first, and are not
|
|
56
|
+
* asked.
|
|
57
|
+
*==========================================*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
58
|
+
import { CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
|
|
59
|
+
import { emailAddressOf, emailExcerpt, threadSubject } from "@aglyn/aglyn/app-utils/email-text";
|
|
60
|
+
import { OUTREACH_INBOUND_KINDS, OUTREACH_INBOUND_MESSAGES_MAX } from "../model/outreach.types.js";
|
|
61
|
+
import { rewriteOutreachBodyLinks } from "./click-tracking.js";
|
|
62
|
+
import { outreachMessageIdHeader } from "./compose.js";
|
|
63
|
+
import { outreachEmailDomain } from "./do-not-contact-domain.js";
|
|
64
|
+
import { classifyOutreachMessage } from "./thread-classification.js";
|
|
65
|
+
import { outreachHeader } from "./thread-message.js";
|
|
66
|
+
/** The most a member may write in one answer. */ export const OUTREACH_MANUAL_REPLY_BODY_MAX = 10000;
|
|
67
|
+
/** The most of an answer the enrollment and the record keep: the timeline's excerpt length. */ export const OUTREACH_MANUAL_REPLY_STORED_MAX = 4000;
|
|
68
|
+
/** The most `Message-ID`s one answer's `References` names: the first, and the latest. */ const REFERENCES_MAX = 20;
|
|
69
|
+
/** The display name in a `From` value — `"Ada Lovelace" <ada@example.com>` answers `Ada Lovelace` — or `''`. */ export function outreachDisplayNameOf(value) {
|
|
70
|
+
const raw = String(value != null ? value : '').trim();
|
|
71
|
+
const angled = raw.lastIndexOf('<');
|
|
72
|
+
if (angled <= 0) return '';
|
|
73
|
+
return raw.slice(0, angled).trim().replace(/^"(.*)"$/, '$1').replace(/\\(.)/g, '$1').trim();
|
|
74
|
+
}
|
|
75
|
+
/** Every `Message-ID` a `References` or `In-Reply-To` value names, in header form, in order, once each. */ export function outreachMessageIdList(value) {
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const match of String(value != null ? value : '').matchAll(/<[^<>\s]+>/g)){
|
|
78
|
+
const id = outreachMessageIdHeader(match[0]);
|
|
79
|
+
if (id && !found.includes(id)) found.push(id);
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
/** A message's text as the record keeps it: the text part, else the HTML's words, else the preview. */ function bodyOf(message) {
|
|
84
|
+
var _message_textBody, _message_htmlBody, _message_snippet;
|
|
85
|
+
return emailExcerpt((_message_textBody = message.textBody) != null ? _message_textBody : '', (_message_htmlBody = message.htmlBody) != null ? _message_htmlBody : '') || emailExcerpt((_message_snippet = message.snippet) != null ? _message_snippet : '');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The person's own messages among a thread's — see the module note — oldest
|
|
89
|
+
* first. A message with no sender address is left out: it could be neither
|
|
90
|
+
* shown as theirs nor answered.
|
|
91
|
+
*/ export function outreachInboundMessagesOf(messages, context) {
|
|
92
|
+
const kept = [];
|
|
93
|
+
for (const message of messages){
|
|
94
|
+
var _message_threadId, _message_subject;
|
|
95
|
+
if (!(message == null ? void 0 : message.id)) continue;
|
|
96
|
+
const classification = classifyOutreachMessage(message, context);
|
|
97
|
+
if (!OUTREACH_INBOUND_KINDS.includes(classification.kind)) continue;
|
|
98
|
+
const from = emailAddressOf(message.from);
|
|
99
|
+
if (!from) continue;
|
|
100
|
+
const replyTo = emailAddressOf(outreachHeader(message, 'Reply-To'));
|
|
101
|
+
kept.push({
|
|
102
|
+
id: message.id,
|
|
103
|
+
threadId: (_message_threadId = message.threadId) != null ? _message_threadId : null,
|
|
104
|
+
messageId: outreachMessageIdHeader(outreachHeader(message, 'Message-ID')),
|
|
105
|
+
references: outreachMessageIdList(outreachHeader(message, 'References')),
|
|
106
|
+
from,
|
|
107
|
+
fromName: outreachDisplayNameOf(message.from),
|
|
108
|
+
replyTo: replyTo && replyTo !== from ? replyTo : null,
|
|
109
|
+
subject: String((_message_subject = message.subject) != null ? _message_subject : '').replace(/\s+/g, ' ').trim(),
|
|
110
|
+
atMs: classification.atMs,
|
|
111
|
+
kind: classification.kind,
|
|
112
|
+
body: bodyOf(message)
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return kept.sort((a, b)=>a.atMs - b.atMs);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* What an enrollment keeps once more of the person's messages are read: one
|
|
119
|
+
* entry per message — a re-read replaces the earlier copy — oldest first,
|
|
120
|
+
* the last {@link OUTREACH_INBOUND_MESSAGES_MAX}.
|
|
121
|
+
*/ export function mergeOutreachInboundMessages(stored, found) {
|
|
122
|
+
const byId = new Map();
|
|
123
|
+
for (const message of [
|
|
124
|
+
...stored != null ? stored : [],
|
|
125
|
+
...found
|
|
126
|
+
])byId.set(message.id, message);
|
|
127
|
+
return [
|
|
128
|
+
...byId.values()
|
|
129
|
+
].sort((a, b)=>a.atMs - b.atMs).slice(-OUTREACH_INBOUND_MESSAGES_MAX);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The message an answer replies to: the one named, when the enrollment
|
|
133
|
+
* kept it, else the newest the person wrote. `null` when they wrote nothing
|
|
134
|
+
* the enrollment kept.
|
|
135
|
+
*/ export function outreachMessageToAnswer(inbound, id) {
|
|
136
|
+
var _messages_find, _messages_at;
|
|
137
|
+
const messages = [
|
|
138
|
+
...inbound != null ? inbound : []
|
|
139
|
+
].sort((a, b)=>a.atMs - b.atMs);
|
|
140
|
+
if (id) return (_messages_find = messages.find((message)=>message.id === id)) != null ? _messages_find : null;
|
|
141
|
+
return (_messages_at = messages.at(-1)) != null ? _messages_at : null;
|
|
142
|
+
}
|
|
143
|
+
/** Whether a member may answer this enrollment at all: the person wrote something it kept, or a reply stopped it. */ export function outreachEnrollmentHasInbound(enrollment) {
|
|
144
|
+
var _enrollment_inboundMessages;
|
|
145
|
+
return Boolean((_enrollment_inboundMessages = enrollment.inboundMessages) == null ? void 0 : _enrollment_inboundMessages.length) || enrollment.stopReason === 'reply' || enrollment.status === 'replied';
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The answer's `References` — see the module note: the sequence's own sends,
|
|
149
|
+
* then what the answered message named, then the message itself, each once.
|
|
150
|
+
* A long chain keeps its first id and its latest, as RFC 5322 §3.6.4 allows.
|
|
151
|
+
*/ export function outreachReplyReferences(sentMessageIds, answering) {
|
|
152
|
+
const chain = [];
|
|
153
|
+
for (const id of [
|
|
154
|
+
...sentMessageIds,
|
|
155
|
+
...answering.references,
|
|
156
|
+
answering.messageId
|
|
157
|
+
]){
|
|
158
|
+
const header = outreachMessageIdHeader(id);
|
|
159
|
+
if (header && !chain.includes(header)) chain.push(header);
|
|
160
|
+
}
|
|
161
|
+
return chain.length > REFERENCES_MAX ? [
|
|
162
|
+
chain[0],
|
|
163
|
+
...chain.slice(-(REFERENCES_MAX - 1))
|
|
164
|
+
] : chain;
|
|
165
|
+
}
|
|
166
|
+
/** `Re:` the subject of the message answered, else of the thread the sequence started. */ export function outreachReplySubject(answering, enrollmentThreadSubject) {
|
|
167
|
+
const base = threadSubject(answering.subject) || threadSubject(enrollmentThreadSubject);
|
|
168
|
+
return (base ? `Re: ${base}` : 'Re:').slice(0, CRM_EMAIL_SUBJECT_MAX);
|
|
169
|
+
}
|
|
170
|
+
/** "Tue, Oct 21, 2026, 8:02 AM", in the mailbox's zone. */ function quotedTime(atMs, timeZone) {
|
|
171
|
+
const options = {
|
|
172
|
+
weekday: 'short',
|
|
173
|
+
month: 'short',
|
|
174
|
+
day: 'numeric',
|
|
175
|
+
year: 'numeric',
|
|
176
|
+
hour: 'numeric',
|
|
177
|
+
minute: '2-digit'
|
|
178
|
+
};
|
|
179
|
+
// Plain spaces: newer ICU puts a narrow no-break space before "AM".
|
|
180
|
+
const format = (zone)=>new Intl.DateTimeFormat('en-US', _extends({}, options, {
|
|
181
|
+
timeZone: zone
|
|
182
|
+
})).format(atMs).replace(/[\u00a0\u202f]/g, ' ');
|
|
183
|
+
try {
|
|
184
|
+
return format(timeZone || 'UTC');
|
|
185
|
+
} catch (unused) {
|
|
186
|
+
return format('UTC');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** The answered message beneath the answer, quoted the way mail clients quote: `On …, … wrote:` and `> ` lines. */ export function outreachReplyQuote(answering, timeZone) {
|
|
190
|
+
const who = answering.fromName ? `${answering.fromName} <${answering.from}>` : answering.from;
|
|
191
|
+
const lines = answering.body.split('\n').map((line)=>line ? `> ${line}` : '>');
|
|
192
|
+
return [
|
|
193
|
+
`On ${quotedTime(answering.atMs, timeZone)}, ${who} wrote:`,
|
|
194
|
+
...lines
|
|
195
|
+
].join('\n');
|
|
196
|
+
}
|
|
197
|
+
/** Writes the answer — see the module note. */ export function composeOutreachManualReply(input) {
|
|
198
|
+
var _input_body, _input_answering_replyTo, _input_enrollment_messageIds;
|
|
199
|
+
const words = String((_input_body = input.body) != null ? _input_body : '').replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '').replace(/^\n+|\n+$/g, '');
|
|
200
|
+
if (!words.trim()) return {
|
|
201
|
+
reply: null,
|
|
202
|
+
error: {
|
|
203
|
+
code: 'empty',
|
|
204
|
+
message: 'Write your answer before sending it.'
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
if (words.length > OUTREACH_MANUAL_REPLY_BODY_MAX) {
|
|
208
|
+
return {
|
|
209
|
+
reply: null,
|
|
210
|
+
error: {
|
|
211
|
+
code: 'too_long',
|
|
212
|
+
message: `An answer can be at most ${OUTREACH_MANUAL_REPLY_BODY_MAX.toLocaleString('en-US')} characters.`
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const to = normalizeContactEmail((_input_answering_replyTo = input.answering.replyTo) != null ? _input_answering_replyTo : input.answering.from);
|
|
217
|
+
if (!to) {
|
|
218
|
+
return {
|
|
219
|
+
reply: null,
|
|
220
|
+
error: {
|
|
221
|
+
code: 'invalid_recipient',
|
|
222
|
+
message: 'Their message names no address to answer.'
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const rewritten = input.rewriteLink ? rewriteOutreachBodyLinks(words, input.rewriteLink) : {
|
|
227
|
+
text: words,
|
|
228
|
+
links: []
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
reply: {
|
|
232
|
+
to,
|
|
233
|
+
subject: outreachReplySubject(input.answering, input.enrollment.threadSubject),
|
|
234
|
+
text: `${rewritten.text}\n\n${outreachReplyQuote(input.answering, input.timeZone)}`,
|
|
235
|
+
body: rewritten.text.slice(0, OUTREACH_MANUAL_REPLY_STORED_MAX),
|
|
236
|
+
trackedLinks: rewritten.links,
|
|
237
|
+
threadId: input.answering.threadId,
|
|
238
|
+
inReplyTo: input.answering.messageId,
|
|
239
|
+
references: outreachReplyReferences((_input_enrollment_messageIds = input.enrollment.messageIds) != null ? _input_enrollment_messageIds : [], input.answering)
|
|
240
|
+
},
|
|
241
|
+
error: null
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** The address a mailbox sends as. */ export const outreachMailboxAddress = (mailbox)=>mailbox.sendAs || mailbox.email;
|
|
245
|
+
/**
|
|
246
|
+
* Why an answer cannot go now, in the member's words, or `null` — see the
|
|
247
|
+
* module note. The person's own state first, then the lists, then the
|
|
248
|
+
* mailbox, then who may send from it.
|
|
249
|
+
*/ export function outreachManualReplyRefusal(input) {
|
|
250
|
+
const { enrollment, mailbox } = input;
|
|
251
|
+
if (enrollment.status === 'opted_out') {
|
|
252
|
+
return {
|
|
253
|
+
code: 'opted-out',
|
|
254
|
+
message: 'They asked not to be emailed, so no answer can be sent to them.'
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (enrollment.status === 'bounced') {
|
|
258
|
+
return {
|
|
259
|
+
code: 'bounced',
|
|
260
|
+
message: 'Their address bounced, so an answer would not reach them.'
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (input.doNotContact === true) {
|
|
264
|
+
return {
|
|
265
|
+
code: 'do-not-contact',
|
|
266
|
+
message: `${enrollment.email} is on your do-not-contact list. Take it off the list on the Compliance page to answer them.`
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (input.doNotContactDomain === true) {
|
|
270
|
+
var _outreachEmailDomain;
|
|
271
|
+
const domain = (_outreachEmailDomain = outreachEmailDomain(enrollment.email)) != null ? _outreachEmailDomain : 'Their domain';
|
|
272
|
+
return {
|
|
273
|
+
code: 'do-not-contact',
|
|
274
|
+
message: `${domain} is on your do-not-contact list. Take it off the list on the Compliance page to answer them.`
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (input.doNotContact === null || input.doNotContactDomain === null) {
|
|
278
|
+
return {
|
|
279
|
+
code: 'unchecked',
|
|
280
|
+
message: 'The do-not-contact list could not be checked. Try again in a moment.'
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (!mailbox) {
|
|
284
|
+
return {
|
|
285
|
+
code: 'mailbox-unavailable',
|
|
286
|
+
message: 'The mailbox that wrote to them is no longer connected, so the answer cannot go from it.'
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const address = outreachMailboxAddress(mailbox);
|
|
290
|
+
if (mailbox.status === 'paused') {
|
|
291
|
+
return {
|
|
292
|
+
code: 'mailbox-unavailable',
|
|
293
|
+
message: `${address} is paused. Resume it on the Mailboxes page to answer from it.`
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (mailbox.status !== 'connected') {
|
|
297
|
+
return {
|
|
298
|
+
code: 'mailbox-unavailable',
|
|
299
|
+
message: `${address} needs to be connected again. Reconnect it on the Mailboxes page to answer from it.`
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (!input.mayUseMailbox) {
|
|
303
|
+
return {
|
|
304
|
+
code: 'permission',
|
|
305
|
+
message: `Only the member who connected ${address}, or an organization owner or admin, can answer from it.`
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
//# sourceMappingURL=manual-reply.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/manual-reply.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * ANSWERING A PERSON WHO WROTE BACK (AGL-4020).\n *\n * A person who answers a sequence email is answered from the same mailbox,\n * in the same thread, by a member: the reply is a one-to-one email, never a\n * step of the sequence. This module is the engine's half of it — what the\n * person wrote, as the enrollment keeps it, and the answer, as the\n * transport sends it — and decides nothing about storage or delivery.\n *\n * ## What the person wrote\n *\n * {@link outreachInboundMessagesOf} keeps the messages of a thread the\n * classifier reads as theirs: a reply, an automatic answer, an opt-out. Each\n * carries its sender, its time, its `Message-ID` and `References` (what an\n * answer threads by), and its text with the quoted history trimmed, the way\n * the record's timeline keeps it (`emailExcerpt`).\n *\n * ## The answer\n *\n * {@link composeOutreachManualReply} writes it as every mail client writes a\n * reply: `Re:` the subject of the message it answers, `In-Reply-To` that\n * message's `Message-ID`, and `References` the whole chain — the sequence's\n * own sends, what the person's message already named, and the message\n * itself — so it lands in the same thread in the person's mailbox and in\n * ours. Plain text, the member's words first and the message answered quoted\n * beneath. No footer and no `List-Unsubscribe`: the person wrote first, and\n * this is the answer a person would type in their own mail client.\n *\n * On a sequence that tracks link clicks, the links in the member's words go\n * out as the same short links a step's do (AGL-4022), through the caller's\n * minter — never the links in the quoted message, which are theirs.\n *\n * ## What still refuses\n *\n * {@link outreachManualReplyRefusal}: an address or a domain on the\n * do-not-contact list, a person who opted out or whose address bounced, and\n * a mailbox that is paused, waiting to be reconnected, or gone. The cold\n * rules — the allowed countries, the company-domain rule, the personal line,\n * the daily cap — do not apply to someone who wrote first, and are not\n * asked.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { emailAddressOf, emailExcerpt, threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport {\n OUTREACH_INBOUND_KINDS,\n OUTREACH_INBOUND_MESSAGES_MAX,\n type OutreachEnrollment,\n type OutreachInboundKind,\n type OutreachInboundMessage,\n type OutreachMailbox,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { outreachMessageIdHeader } from './compose'\nimport { outreachEmailDomain } from './do-not-contact-domain'\nimport { classifyOutreachMessage, type OutreachMessageContext } from './thread-classification'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\n/** The most a member may write in one answer. */\nexport const OUTREACH_MANUAL_REPLY_BODY_MAX = 10_000\n\n/** The most of an answer the enrollment and the record keep: the timeline's excerpt length. */\nexport const OUTREACH_MANUAL_REPLY_STORED_MAX = 4_000\n\n/** The most `Message-ID`s one answer's `References` names: the first, and the latest. */\nconst REFERENCES_MAX = 20\n\n/** The display name in a `From` value — `\"Ada Lovelace\" <ada@example.com>` answers `Ada Lovelace` — or `''`. */\nexport function outreachDisplayNameOf(value: unknown): string {\n const raw = String(value ?? '').trim()\n const angled = raw.lastIndexOf('<')\n if (angled <= 0) return ''\n return raw\n .slice(0, angled)\n .trim()\n .replace(/^\"(.*)\"$/, '$1')\n .replace(/\\\\(.)/g, '$1')\n .trim()\n}\n\n/** Every `Message-ID` a `References` or `In-Reply-To` value names, in header form, in order, once each. */\nexport function outreachMessageIdList(value: unknown): string[] {\n const found: string[] = []\n for (const match of String(value ?? '').matchAll(/<[^<>\\s]+>/g)) {\n const id = outreachMessageIdHeader(match[0])\n if (id && !found.includes(id)) found.push(id)\n }\n return found\n}\n\n/** A message's text as the record keeps it: the text part, else the HTML's words, else the preview. */\nfunction bodyOf(message: OutreachThreadMessage): string {\n return emailExcerpt(message.textBody ?? '', message.htmlBody ?? '') || emailExcerpt(message.snippet ?? '')\n}\n\n/**\n * The person's own messages among a thread's — see the module note — oldest\n * first. A message with no sender address is left out: it could be neither\n * shown as theirs nor answered.\n */\nexport function outreachInboundMessagesOf(\n messages: readonly OutreachThreadMessage[],\n context: OutreachMessageContext,\n): OutreachInboundMessage[] {\n const kept: OutreachInboundMessage[] = []\n for (const message of messages) {\n if (!message?.id) continue\n const classification = classifyOutreachMessage(message, context)\n if (!(OUTREACH_INBOUND_KINDS as readonly string[]).includes(classification.kind)) continue\n const from = emailAddressOf(message.from)\n if (!from) continue\n const replyTo = emailAddressOf(outreachHeader(message, 'Reply-To'))\n kept.push({\n id: message.id,\n threadId: message.threadId ?? null,\n messageId: outreachMessageIdHeader(outreachHeader(message, 'Message-ID')),\n references: outreachMessageIdList(outreachHeader(message, 'References')),\n from,\n fromName: outreachDisplayNameOf(message.from),\n replyTo: replyTo && replyTo !== from ? replyTo : null,\n subject: String(message.subject ?? '').replace(/\\s+/g, ' ').trim(),\n atMs: classification.atMs,\n kind: classification.kind as OutreachInboundKind,\n body: bodyOf(message),\n })\n }\n return kept.sort((a, b) => a.atMs - b.atMs)\n}\n\n/**\n * What an enrollment keeps once more of the person's messages are read: one\n * entry per message — a re-read replaces the earlier copy — oldest first,\n * the last {@link OUTREACH_INBOUND_MESSAGES_MAX}.\n */\nexport function mergeOutreachInboundMessages(\n stored: readonly OutreachInboundMessage[] | null | undefined,\n found: readonly OutreachInboundMessage[],\n): OutreachInboundMessage[] {\n const byId = new Map<string, OutreachInboundMessage>()\n for (const message of [...(stored ?? []), ...found]) byId.set(message.id, message)\n return [...byId.values()].sort((a, b) => a.atMs - b.atMs).slice(-OUTREACH_INBOUND_MESSAGES_MAX)\n}\n\n/**\n * The message an answer replies to: the one named, when the enrollment\n * kept it, else the newest the person wrote. `null` when they wrote nothing\n * the enrollment kept.\n */\nexport function outreachMessageToAnswer(\n inbound: readonly OutreachInboundMessage[] | null | undefined,\n id?: string | null,\n): OutreachInboundMessage | null {\n const messages = [...(inbound ?? [])].sort((a, b) => a.atMs - b.atMs)\n if (id) return messages.find((message) => message.id === id) ?? null\n return messages.at(-1) ?? null\n}\n\n/** Whether a member may answer this enrollment at all: the person wrote something it kept, or a reply stopped it. */\nexport function outreachEnrollmentHasInbound(\n enrollment: Pick<OutreachEnrollment, 'inboundMessages' | 'stopReason' | 'status'>,\n): boolean {\n return Boolean(enrollment.inboundMessages?.length) || enrollment.stopReason === 'reply' || enrollment.status === 'replied'\n}\n\n/**\n * The answer's `References` — see the module note: the sequence's own sends,\n * then what the answered message named, then the message itself, each once.\n * A long chain keeps its first id and its latest, as RFC 5322 §3.6.4 allows.\n */\nexport function outreachReplyReferences(\n sentMessageIds: readonly string[],\n answering: Pick<OutreachInboundMessage, 'references' | 'messageId'>,\n): string[] {\n const chain: string[] = []\n for (const id of [...sentMessageIds, ...answering.references, answering.messageId]) {\n const header = outreachMessageIdHeader(id)\n if (header && !chain.includes(header)) chain.push(header)\n }\n return chain.length > REFERENCES_MAX ? [chain[0], ...chain.slice(-(REFERENCES_MAX - 1))] : chain\n}\n\n/** `Re:` the subject of the message answered, else of the thread the sequence started. */\nexport function outreachReplySubject(\n answering: Pick<OutreachInboundMessage, 'subject'>,\n enrollmentThreadSubject: string | null | undefined,\n): string {\n const base = threadSubject(answering.subject) || threadSubject(enrollmentThreadSubject)\n return (base ? `Re: ${base}` : 'Re:').slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\n/** \"Tue, Oct 21, 2026, 8:02 AM\", in the mailbox's zone. */\nfunction quotedTime(atMs: number, timeZone: string | null | undefined): string {\n const options: Intl.DateTimeFormatOptions = {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n }\n // Plain spaces: newer ICU puts a narrow no-break space before \"AM\".\n const format = (zone: string) =>\n new Intl.DateTimeFormat('en-US', { ...options, timeZone: zone }).format(atMs).replace(/[\\u00a0\\u202f]/g, ' ')\n try {\n return format(timeZone || 'UTC')\n } catch {\n return format('UTC')\n }\n}\n\n/** The answered message beneath the answer, quoted the way mail clients quote: `On …, … wrote:` and `> ` lines. */\nexport function outreachReplyQuote(\n answering: Pick<OutreachInboundMessage, 'atMs' | 'from' | 'fromName' | 'body'>,\n timeZone: string | null | undefined,\n): string {\n const who = answering.fromName ? `${answering.fromName} <${answering.from}>` : answering.from\n const lines = answering.body.split('\\n').map((line) => (line ? `> ${line}` : '>'))\n return [`On ${quotedTime(answering.atMs, timeZone)}, ${who} wrote:`, ...lines].join('\\n')\n}\n\n/** An answer, written: what the transport sends and the records keep. */\nexport interface OutreachManualReply {\n to: string\n subject: string\n /** The whole text sent: the member's words, then the quote. */\n text: string\n /** The member's words alone, as sent — tracked links in place — bounded, as the records keep them. */\n body: string\n /**\n * The destinations the minter rewrote, in the order a short link's\n * `linkIndex` names them (AGL-4022). Empty when nothing was rewritten.\n */\n trackedLinks: string[]\n /** The provider thread it is filed into; `null` lets the provider thread it by its headers. */\n threadId: string | null\n /** The answered message's `Message-ID`; `null` when it carried none. */\n inReplyTo: string | null\n references: string[]\n}\n\nexport type OutreachManualReplyErrorCode = 'empty' | 'too_long' | 'invalid_recipient'\n\nexport interface OutreachManualReplyCompose {\n reply: OutreachManualReply | null\n error: { code: OutreachManualReplyErrorCode; message: string } | null\n}\n\n/** Writes the answer — see the module note. */\nexport function composeOutreachManualReply(input: {\n enrollment: Pick<OutreachEnrollment, 'messageIds' | 'threadSubject'>\n answering: OutreachInboundMessage\n body: unknown\n /** The mailbox's zone, for the quote's time. */\n timeZone: string | null | undefined\n /**\n * Mints the short link for the link at `index` in the member's words, or\n * `null` to leave it as written (AGL-4022). Absent — a sequence that does\n * not track clicks — sends every link as written.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n}): OutreachManualReplyCompose {\n const words = String(input.body ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[ \\t]+$/gm, '')\n .replace(/^\\n+|\\n+$/g, '')\n if (!words.trim()) return { reply: null, error: { code: 'empty', message: 'Write your answer before sending it.' } }\n if (words.length > OUTREACH_MANUAL_REPLY_BODY_MAX) {\n return {\n reply: null,\n error: {\n code: 'too_long',\n message: `An answer can be at most ${OUTREACH_MANUAL_REPLY_BODY_MAX.toLocaleString('en-US')} characters.`,\n },\n }\n }\n const to = normalizeContactEmail(input.answering.replyTo ?? input.answering.from)\n if (!to) {\n return { reply: null, error: { code: 'invalid_recipient', message: 'Their message names no address to answer.' } }\n }\n const rewritten = input.rewriteLink ? rewriteOutreachBodyLinks(words, input.rewriteLink) : { text: words, links: [] }\n return {\n reply: {\n to,\n subject: outreachReplySubject(input.answering, input.enrollment.threadSubject),\n text: `${rewritten.text}\\n\\n${outreachReplyQuote(input.answering, input.timeZone)}`,\n body: rewritten.text.slice(0, OUTREACH_MANUAL_REPLY_STORED_MAX),\n trackedLinks: rewritten.links,\n threadId: input.answering.threadId,\n inReplyTo: input.answering.messageId,\n references: outreachReplyReferences(input.enrollment.messageIds ?? [], input.answering),\n },\n error: null,\n }\n}\n\nexport type OutreachManualReplyRefusalCode =\n | 'opted-out'\n | 'bounced'\n | 'do-not-contact'\n | 'unchecked'\n | 'mailbox-unavailable'\n | 'permission'\n\nexport interface OutreachManualReplyRefusal {\n code: OutreachManualReplyRefusalCode\n message: string\n}\n\n/** The address a mailbox sends as. */\nexport const outreachMailboxAddress = (mailbox: Pick<OutreachMailbox, 'sendAs' | 'email'>): string =>\n mailbox.sendAs || mailbox.email\n\n/**\n * Why an answer cannot go now, in the member's words, or `null` — see the\n * module note. The person's own state first, then the lists, then the\n * mailbox, then who may send from it.\n */\nexport function outreachManualReplyRefusal(input: {\n enrollment: Pick<OutreachEnrollment, 'status' | 'email'>\n /** Every address the answer would reach, each looked up. */\n doNotContact: boolean | null\n doNotContactDomain: boolean | null\n mailbox: Pick<OutreachMailbox, 'status' | 'sendAs' | 'email'> | null\n /** The member is the mailbox's own, or an organization owner or admin. */\n mayUseMailbox: boolean\n}): OutreachManualReplyRefusal | null {\n const { enrollment, mailbox } = input\n if (enrollment.status === 'opted_out') {\n return { code: 'opted-out', message: 'They asked not to be emailed, so no answer can be sent to them.' }\n }\n if (enrollment.status === 'bounced') {\n return { code: 'bounced', message: 'Their address bounced, so an answer would not reach them.' }\n }\n if (input.doNotContact === true) {\n return {\n code: 'do-not-contact',\n message: `${enrollment.email} is on your do-not-contact list. Take it off the list on the Compliance page to answer them.`,\n }\n }\n if (input.doNotContactDomain === true) {\n const domain = outreachEmailDomain(enrollment.email) ?? 'Their domain'\n return {\n code: 'do-not-contact',\n message: `${domain} is on your do-not-contact list. Take it off the list on the Compliance page to answer them.`,\n }\n }\n if (input.doNotContact === null || input.doNotContactDomain === null) {\n return { code: 'unchecked', message: 'The do-not-contact list could not be checked. Try again in a moment.' }\n }\n if (!mailbox) {\n return {\n code: 'mailbox-unavailable',\n message: 'The mailbox that wrote to them is no longer connected, so the answer cannot go from it.',\n }\n }\n const address = outreachMailboxAddress(mailbox)\n if (mailbox.status === 'paused') {\n return {\n code: 'mailbox-unavailable',\n message: `${address} is paused. Resume it on the Mailboxes page to answer from it.`,\n }\n }\n if (mailbox.status !== 'connected') {\n return {\n code: 'mailbox-unavailable',\n message: `${address} needs to be connected again. Reconnect it on the Mailboxes page to answer from it.`,\n }\n }\n if (!input.mayUseMailbox) {\n return {\n code: 'permission',\n message: `Only the member who connected ${address}, or an organization owner or admin, can answer from it.`,\n }\n }\n return null\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","emailAddressOf","emailExcerpt","threadSubject","OUTREACH_INBOUND_KINDS","OUTREACH_INBOUND_MESSAGES_MAX","rewriteOutreachBodyLinks","outreachMessageIdHeader","outreachEmailDomain","classifyOutreachMessage","outreachHeader","OUTREACH_MANUAL_REPLY_BODY_MAX","OUTREACH_MANUAL_REPLY_STORED_MAX","REFERENCES_MAX","outreachDisplayNameOf","value","raw","String","trim","angled","lastIndexOf","slice","replace","outreachMessageIdList","found","match","matchAll","id","includes","push","bodyOf","message","textBody","htmlBody","snippet","outreachInboundMessagesOf","messages","context","kept","classification","kind","from","replyTo","threadId","messageId","references","fromName","subject","atMs","body","sort","a","b","mergeOutreachInboundMessages","stored","byId","Map","set","values","outreachMessageToAnswer","inbound","find","at","outreachEnrollmentHasInbound","enrollment","Boolean","inboundMessages","length","stopReason","status","outreachReplyReferences","sentMessageIds","answering","chain","header","outreachReplySubject","enrollmentThreadSubject","base","quotedTime","timeZone","options","weekday","month","day","year","hour","minute","format","zone","Intl","DateTimeFormat","outreachReplyQuote","who","lines","split","map","line","join","composeOutreachManualReply","input","words","reply","error","code","toLocaleString","to","rewritten","rewriteLink","text","links","trackedLinks","inReplyTo","messageIds","outreachMailboxAddress","mailbox","sendAs","email","outreachManualReplyRefusal","doNotContact","doNotContactDomain","domain","address","mayUseMailbox"],"mappings":"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"}
|
|
@@ -25,6 +25,8 @@ export declare function outreachClickHistoryRow(input: {
|
|
|
25
25
|
stepIndex: number;
|
|
26
26
|
human: boolean;
|
|
27
27
|
machineReason: OutreachClickMachineReason | null;
|
|
28
|
+
/** A link in a member's answer in the thread (AGL-4022), not a step's. */
|
|
29
|
+
answer?: boolean;
|
|
28
30
|
}): Record<string, unknown>;
|
|
29
31
|
/**
|
|
30
32
|
* An open row as the short-link route writes it for a tracking image
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
16
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { OUTREACH_OPEN_SOURCES } from "../engine/open-source.js";
|
|
17
18
|
/**
|
|
18
19
|
* ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.
|
|
19
20
|
*
|
|
@@ -42,14 +43,16 @@ const ACTIONS = [
|
|
|
42
43
|
'do_not_contact'
|
|
43
44
|
];
|
|
44
45
|
/** A click row as the click route writes it. */ export function outreachClickHistoryRow(input) {
|
|
45
|
-
return {
|
|
46
|
+
return _extends({
|
|
46
47
|
kind: 'click',
|
|
47
48
|
atMs: input.atMs,
|
|
48
49
|
url: input.url,
|
|
49
50
|
stepIndex: input.stepIndex,
|
|
50
51
|
human: input.human,
|
|
51
52
|
machineReason: input.human ? null : input.machineReason
|
|
52
|
-
}
|
|
53
|
+
}, input.answer ? {
|
|
54
|
+
answer: true
|
|
55
|
+
} : {});
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
55
58
|
* An open row as the short-link route writes it for a tracking image
|
|
@@ -92,7 +95,7 @@ const ACTIONS = [
|
|
|
92
95
|
const stepIndex = Number(data['stepIndex']);
|
|
93
96
|
const human = data['human'] === true;
|
|
94
97
|
const reason = data['machineReason'];
|
|
95
|
-
return {
|
|
98
|
+
return _extends({
|
|
96
99
|
id,
|
|
97
100
|
kind: 'click',
|
|
98
101
|
atMs,
|
|
@@ -100,7 +103,9 @@ const ACTIONS = [
|
|
|
100
103
|
stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,
|
|
101
104
|
human,
|
|
102
105
|
machineReason: human ? null : MACHINE_REASONS.includes(reason) ? reason : null
|
|
103
|
-
}
|
|
106
|
+
}, data['answer'] === true ? {
|
|
107
|
+
answer: true
|
|
108
|
+
} : {});
|
|
104
109
|
}
|
|
105
110
|
if (data['kind'] === 'open') {
|
|
106
111
|
const stepIndex = Number(data['stepIndex']);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-history.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OutreachClickMachineReason } from '../engine/click-tracking'\nimport { OUTREACH_OPEN_SOURCES, type OutreachOpenSource } from '../engine/open-source'\nimport type { OutreachOpenMachineReason } from '../engine/open-tracking'\nimport type {\n OutreachEnrollmentHistoryEntry,\n OutreachHistoryAction,\n} from './outreach.types'\n\n/**\n * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.\n *\n * Client-safe, beside the other readers, because the click route and the\n * member-action route write these rows on the server and the detail view\n * reads them in the browser, and both must agree on the shape. Read a field\n * at a time: a row that names no time, or no kind this model knows, is\n * dropped rather than shown as something that happened.\n */\n\nconst MACHINE_REASONS: readonly OutreachClickMachineReason[] = ['agent', 'too_soon', 'method']\nconst OPEN_MACHINE_REASONS: readonly OutreachOpenMachineReason[] = [\n 'agent',\n 'too_soon',\n 'method',\n 'image_proxy',\n 'privacy_proxy',\n 'scanner',\n]\nconst ACTIONS: readonly OutreachHistoryAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\n/** A click row as the click route writes it. */\nexport function outreachClickHistoryRow(input: {\n atMs: number\n url: string | null\n stepIndex: number\n human: boolean\n machineReason: OutreachClickMachineReason | null\n}): Record<string, unknown> {\n return {\n kind: 'click',\n atMs: input.atMs,\n url: input.url,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n }\n}\n\n/**\n * An open row as the short-link route writes it for a tracking image\n * (AGL-3395), with the evidence it was judged on (AGL-3488): the agent the\n * fetch sent, and the network it came from — never its address.\n */\nexport function outreachOpenHistoryRow(input: {\n atMs: number\n stepIndex: number\n human: boolean\n machineReason: OutreachOpenMachineReason | null\n userAgent?: string | null\n source?: OutreachOpenSource | null\n}): Record<string, unknown> {\n return {\n kind: 'open',\n atMs: input.atMs,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n userAgent: input.userAgent ?? null,\n source: input.source ?? null,\n }\n}\n\n/** A member's act as the action route writes it. */\nexport function outreachActionHistoryRow(input: {\n atMs: number\n action: OutreachHistoryAction\n byUid: string\n detail: string | null\n}): Record<string, unknown> {\n const detail = typeof input.detail === 'string' ? input.detail.replace(/\\s+/g, ' ').trim().slice(0, 500) : ''\n return {\n kind: 'action',\n atMs: input.atMs,\n action: input.action,\n byUid: input.byUid,\n detail: detail || null,\n }\n}\n\n/**\n * A row id that sorts by time and cannot collide: two clicks in the same\n * millisecond — a scanner fetching every link at once — are two rows.\n */\nexport function outreachHistoryEntryId(atMs: number, random: () => number = Math.random): string {\n const suffix = Math.floor(random() * 36 ** 6)\n .toString(36)\n .padStart(6, '0')\n return `${String(Math.max(0, Math.floor(atMs))).padStart(15, '0')}-${suffix}`\n}\n\n/** A stored history row in its model shape, or `null` for one nothing can be said about. */\nexport function readOutreachHistoryEntry(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollmentHistoryEntry | null {\n if (!data) return null\n const atMs = data['atMs']\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n if (data['kind'] === 'click') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'click',\n atMs,\n url: typeof data['url'] === 'string' && data['url'] ? data['url'] : null,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachClickMachineReason)\n : null,\n }\n }\n if (data['kind'] === 'open') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'open',\n atMs,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (OPEN_MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachOpenMachineReason)\n : null,\n // Absent on every row from before AGL-3488, which recorded neither.\n userAgent: typeof data['userAgent'] === 'string' && data['userAgent'] ? data['userAgent'] : null,\n source: (OUTREACH_OPEN_SOURCES as readonly unknown[]).includes(data['source'])\n ? (data['source'] as OutreachOpenSource)\n : null,\n }\n }\n if (data['kind'] === 'action') {\n const action = data['action']\n if (!(ACTIONS as readonly unknown[]).includes(action)) return null\n return {\n id,\n kind: 'action',\n atMs,\n action: action as OutreachHistoryAction,\n byUid: typeof data['byUid'] === 'string' ? data['byUid'] : '',\n detail: typeof data['detail'] === 'string' && data['detail'] ? data['detail'] : null,\n }\n }\n return null\n}\n"],"names":["OUTREACH_OPEN_SOURCES","MACHINE_REASONS","OPEN_MACHINE_REASONS","ACTIONS","outreachClickHistoryRow","input","kind","atMs","url","stepIndex","human","machineReason","outreachOpenHistoryRow","userAgent","source","outreachActionHistoryRow","detail","replace","trim","slice","action","byUid","outreachHistoryEntryId","random","Math","suffix","floor","toString","padStart","String","max","readOutreachHistoryEntry","id","data","Number","isFinite","reason","isInteger","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-history.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OutreachClickMachineReason } from '../engine/click-tracking'\nimport { OUTREACH_OPEN_SOURCES, type OutreachOpenSource } from '../engine/open-source'\nimport type { OutreachOpenMachineReason } from '../engine/open-tracking'\nimport type {\n OutreachEnrollmentHistoryEntry,\n OutreachHistoryAction,\n} from './outreach.types'\n\n/**\n * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.\n *\n * Client-safe, beside the other readers, because the click route and the\n * member-action route write these rows on the server and the detail view\n * reads them in the browser, and both must agree on the shape. Read a field\n * at a time: a row that names no time, or no kind this model knows, is\n * dropped rather than shown as something that happened.\n */\n\nconst MACHINE_REASONS: readonly OutreachClickMachineReason[] = ['agent', 'too_soon', 'method']\nconst OPEN_MACHINE_REASONS: readonly OutreachOpenMachineReason[] = [\n 'agent',\n 'too_soon',\n 'method',\n 'image_proxy',\n 'privacy_proxy',\n 'scanner',\n]\nconst ACTIONS: readonly OutreachHistoryAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\n/** A click row as the click route writes it. */\nexport function outreachClickHistoryRow(input: {\n atMs: number\n url: string | null\n stepIndex: number\n human: boolean\n machineReason: OutreachClickMachineReason | null\n /** A link in a member's answer in the thread (AGL-4022), not a step's. */\n answer?: boolean\n}): Record<string, unknown> {\n return {\n kind: 'click',\n atMs: input.atMs,\n url: input.url,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n ...(input.answer ? { answer: true } : {}),\n }\n}\n\n/**\n * An open row as the short-link route writes it for a tracking image\n * (AGL-3395), with the evidence it was judged on (AGL-3488): the agent the\n * fetch sent, and the network it came from — never its address.\n */\nexport function outreachOpenHistoryRow(input: {\n atMs: number\n stepIndex: number\n human: boolean\n machineReason: OutreachOpenMachineReason | null\n userAgent?: string | null\n source?: OutreachOpenSource | null\n}): Record<string, unknown> {\n return {\n kind: 'open',\n atMs: input.atMs,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n userAgent: input.userAgent ?? null,\n source: input.source ?? null,\n }\n}\n\n/** A member's act as the action route writes it. */\nexport function outreachActionHistoryRow(input: {\n atMs: number\n action: OutreachHistoryAction\n byUid: string\n detail: string | null\n}): Record<string, unknown> {\n const detail = typeof input.detail === 'string' ? input.detail.replace(/\\s+/g, ' ').trim().slice(0, 500) : ''\n return {\n kind: 'action',\n atMs: input.atMs,\n action: input.action,\n byUid: input.byUid,\n detail: detail || null,\n }\n}\n\n/**\n * A row id that sorts by time and cannot collide: two clicks in the same\n * millisecond — a scanner fetching every link at once — are two rows.\n */\nexport function outreachHistoryEntryId(atMs: number, random: () => number = Math.random): string {\n const suffix = Math.floor(random() * 36 ** 6)\n .toString(36)\n .padStart(6, '0')\n return `${String(Math.max(0, Math.floor(atMs))).padStart(15, '0')}-${suffix}`\n}\n\n/** A stored history row in its model shape, or `null` for one nothing can be said about. */\nexport function readOutreachHistoryEntry(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollmentHistoryEntry | null {\n if (!data) return null\n const atMs = data['atMs']\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n if (data['kind'] === 'click') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'click',\n atMs,\n url: typeof data['url'] === 'string' && data['url'] ? data['url'] : null,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachClickMachineReason)\n : null,\n ...(data['answer'] === true ? { answer: true as const } : {}),\n }\n }\n if (data['kind'] === 'open') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'open',\n atMs,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (OPEN_MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachOpenMachineReason)\n : null,\n // Absent on every row from before AGL-3488, which recorded neither.\n userAgent: typeof data['userAgent'] === 'string' && data['userAgent'] ? data['userAgent'] : null,\n source: (OUTREACH_OPEN_SOURCES as readonly unknown[]).includes(data['source'])\n ? (data['source'] as OutreachOpenSource)\n : null,\n }\n }\n if (data['kind'] === 'action') {\n const action = data['action']\n if (!(ACTIONS as readonly unknown[]).includes(action)) return null\n return {\n id,\n kind: 'action',\n atMs,\n action: action as OutreachHistoryAction,\n byUid: typeof data['byUid'] === 'string' ? data['byUid'] : '',\n detail: typeof data['detail'] === 'string' && data['detail'] ? data['detail'] : null,\n }\n }\n return null\n}\n"],"names":["OUTREACH_OPEN_SOURCES","MACHINE_REASONS","OPEN_MACHINE_REASONS","ACTIONS","outreachClickHistoryRow","input","kind","atMs","url","stepIndex","human","machineReason","answer","outreachOpenHistoryRow","userAgent","source","outreachActionHistoryRow","detail","replace","trim","slice","action","byUid","outreachHistoryEntryId","random","Math","suffix","floor","toString","padStart","String","max","readOutreachHistoryEntry","id","data","Number","isFinite","reason","isInteger","includes"],"mappings":"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"}
|
|
@@ -20,7 +20,7 @@ import type { OutreachOpenSource } from '../engine/open-source';
|
|
|
20
20
|
import { type OutreachMailGateway } from '../engine/mail-gateway';
|
|
21
21
|
import { type OutreachEnrollment, type OutreachEnrollmentHistoryEntry, type OutreachSequenceStep } from './outreach.types';
|
|
22
22
|
/** What kind of thing an entry is, which the view draws an icon for. */
|
|
23
|
-
export type OutreachTimelineKind = 'enrolled' | 'skipped' | 'curated' | 'sent' | 'task' | 'click' | 'scanner' | 'earlier-clicks' | 'open' | 'machine-open' | 'paused' | 'resumed' | 'stopped' | 'do-not-contact' | 'replied' | 'bounced' | 'opted-out' | 'held' | 'released' | 'failed' | 'finished';
|
|
23
|
+
export type OutreachTimelineKind = 'enrolled' | 'skipped' | 'curated' | 'sent' | 'task' | 'click' | 'scanner' | 'earlier-clicks' | 'open' | 'machine-open' | 'paused' | 'resumed' | 'stopped' | 'do-not-contact' | 'replied' | 'auto-reply' | 'answered' | 'bounced' | 'opted-out' | 'held' | 'released' | 'failed' | 'finished';
|
|
24
24
|
/** How loudly the view draws an entry. */
|
|
25
25
|
export type OutreachTimelineTone = 'default' | 'success' | 'info' | 'warning' | 'error';
|
|
26
26
|
/** One line of the timeline. */
|
|
@@ -81,6 +81,8 @@ export interface OutreachEnrollmentFigures {
|
|
|
81
81
|
opens: number;
|
|
82
82
|
machineOpens: number;
|
|
83
83
|
replies: number;
|
|
84
|
+
/** Answers members sent them in the thread (AGL-4020), never counted in `emailsSent`. */
|
|
85
|
+
answers: number;
|
|
84
86
|
/** Whether anything has happened to them beyond being enrolled. */
|
|
85
87
|
anything: boolean;
|
|
86
88
|
}
|