@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.
Files changed (61) hide show
  1. package/README.md +7 -2
  2. package/package.json +14 -14
  3. package/src/lib/components/enrollment-actions.d.ts +11 -4
  4. package/src/lib/components/enrollment-actions.js +16 -0
  5. package/src/lib/components/enrollment-actions.js.map +1 -1
  6. package/src/lib/components/enrollment-detail.js +40 -13
  7. package/src/lib/components/enrollment-detail.js.map +1 -1
  8. package/src/lib/components/mailbox-card.js +13 -2
  9. package/src/lib/components/mailbox-card.js.map +1 -1
  10. package/src/lib/components/record-reply-button.d.ts +52 -0
  11. package/src/lib/components/record-reply-button.js +92 -0
  12. package/src/lib/components/record-reply-button.js.map +1 -0
  13. package/src/lib/components/reply-dialog.d.ts +67 -0
  14. package/src/lib/components/reply-dialog.js +353 -0
  15. package/src/lib/components/reply-dialog.js.map +1 -0
  16. package/src/lib/components/use-outreach-api.d.ts +5 -1
  17. package/src/lib/components/use-outreach-api.js +10 -0
  18. package/src/lib/components/use-outreach-api.js.map +1 -1
  19. package/src/lib/constants/api-routes.d.ts +8 -0
  20. package/src/lib/constants/api-routes.js +6 -0
  21. package/src/lib/constants/api-routes.js.map +1 -1
  22. package/src/lib/engine/index.d.ts +1 -0
  23. package/src/lib/engine/index.js +1 -0
  24. package/src/lib/engine/index.js.map +1 -1
  25. package/src/lib/engine/manual-reply.d.ts +122 -0
  26. package/src/lib/engine/manual-reply.js +311 -0
  27. package/src/lib/engine/manual-reply.js.map +1 -0
  28. package/src/lib/model/enrollment-history.d.ts +2 -0
  29. package/src/lib/model/enrollment-history.js +10 -5
  30. package/src/lib/model/enrollment-history.js.map +1 -1
  31. package/src/lib/model/enrollment-timeline.d.ts +3 -1
  32. package/src/lib/model/enrollment-timeline.js +90 -9
  33. package/src/lib/model/enrollment-timeline.js.map +1 -1
  34. package/src/lib/model/outreach-api.d.ts +68 -2
  35. package/src/lib/model/outreach-api.js.map +1 -1
  36. package/src/lib/model/outreach.types.d.ts +91 -3
  37. package/src/lib/model/outreach.types.js +6 -0
  38. package/src/lib/model/outreach.types.js.map +1 -1
  39. package/src/lib/model/stored-records.d.ts +7 -1
  40. package/src/lib/model/stored-records.js +37 -1
  41. package/src/lib/model/stored-records.js.map +1 -1
  42. package/src/lib/plugin.d.ts +4 -0
  43. package/src/lib/plugin.js +17 -0
  44. package/src/lib/plugin.js.map +1 -1
  45. package/src/lib/routes/register-routes.d.ts +7 -0
  46. package/src/lib/routes/register-routes.js +26 -0
  47. package/src/lib/routes/register-routes.js.map +1 -1
  48. package/src/lib/routes/reply-routes.d.ts +92 -0
  49. package/src/lib/routes/reply-routes.js +644 -0
  50. package/src/lib/routes/reply-routes.js.map +1 -0
  51. package/src/lib/runtime/click-events.d.ts +13 -0
  52. package/src/lib/runtime/click-events.js +19 -6
  53. package/src/lib/runtime/click-events.js.map +1 -1
  54. package/src/lib/runtime/click-link.d.ts +8 -0
  55. package/src/lib/runtime/click-link.js +7 -0
  56. package/src/lib/runtime/click-link.js.map +1 -1
  57. package/src/lib/runtime/sync-job.js +14 -0
  58. package/src/lib/runtime/sync-job.js.map +1 -1
  59. package/src/lib/transport/send-message.d.ts +11 -0
  60. package/src/lib/transport/send-message.js +22 -0
  61. package/src/lib/transport/send-message.js.map +1 -1
@@ -0,0 +1,644 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { randomUUID } from "node:crypto";
18
+ import { composeOutreachManualReply, mergeOutreachInboundMessages, outreachEnrollmentHasInbound, outreachInboundMessagesOf, outreachMailboxAddress, outreachManualReplyRefusal, outreachMessageToAnswer, outreachReplySubject } from "../engine/manual-reply.js";
19
+ import { outreachThreadMessageFromGmail } from "../engine/thread-message.js";
20
+ import { mailboxRef } from "../mailboxes/mailbox-credentials.js";
21
+ import { outreachLocalDay } from "../mailboxes/mailbox-settings.js";
22
+ import { markOutreachMailboxReconnectRequired } from "../mailboxes/mailbox-transport.js";
23
+ import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
24
+ import { newOutreachLinkId, outreachStoredLink } from "../runtime/click-link.js";
25
+ import { fileOutreachEmail } from "../runtime/timeline.js";
26
+ import { outreachUnsubscribeMailbox } from "../runtime/unsubscribe-link.js";
27
+ import { lookupOutreachDoNotContact, lookupOutreachDoNotContactDomains } from "../storage/do-not-contact-store.js";
28
+ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
29
+ import { GmailTransportError, googleFailureAnswer, isReconnectRequired } from "../transport/gmail-errors.js";
30
+ import { Rfc5322MessageError } from "../transport/rfc5322.js";
31
+ import { sendOutreachReply } from "../transport/send-message.js";
32
+ import { outreachRouteGate } from "./route-gate.js";
33
+ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocumentId, readOutreachJsonBody } from "./route-http.js";
34
+ /**
35
+ * ANSWERING A PERSON WHO WROTE BACK (AGL-4020): `outreach/enrollments/thread`
36
+ * and `outreach/enrollments/reply`.
37
+ *
38
+ * A person who answered a sequence email is answered by a member, from the
39
+ * enrollment's own mailbox, in the same thread — never from the platform's
40
+ * mail service, which carries no prospecting, and never as a new thread.
41
+ * The engine writes the answer (`engine/manual-reply.ts`); these routes read
42
+ * what it answers, check what still refuses, and send it.
43
+ *
44
+ * ## What the person wrote
45
+ *
46
+ * The sync keeps every message of theirs on the enrollment. A reply synced
47
+ * before they were kept is on the enrollment only as an id, so when the
48
+ * enrollment says someone wrote back and keeps nothing they wrote, the
49
+ * `thread` route reads the enrollment's threads in the mailbox — and, when
50
+ * they answered in a thread of their own, the messages from their address
51
+ * since they were enrolled — and keeps what it finds. That read is made only
52
+ * for a member who may send from the mailbox: nobody reads another member's
53
+ * mail through these routes.
54
+ *
55
+ * ## The answer is no step of the sequence
56
+ *
57
+ * It is kept as a `manual_reply` step record and filed on the person's CRM
58
+ * record. It never restarts the sequence or moves its step; it never counts
59
+ * against the mailbox's daily cap, joins the sends a bounce rate is judged
60
+ * over, or bumps the sequence's figures. The mailbox counts it apart, on
61
+ * `health.daily.<day>.manualReplies`, so it can never pause the mailbox.
62
+ * Its `Message-ID` joins the enrollment's when it went into the current
63
+ * thread, so a later step of an enrollment still running answers it, and
64
+ * the enrollment's `lastSentAtMs` moves, which keeps the thread watched for
65
+ * what the person writes next.
66
+ *
67
+ * ## Its links, on a sequence that tracks them
68
+ *
69
+ * The links in the member's words go out as the short links a step's do
70
+ * (AGL-4022), on the mailbox domain's link host: minted with the answer's
71
+ * reply key, stored before the send, and kept on the `manual_reply` record.
72
+ * A click on one is the person's — on their enrollment and their record —
73
+ * and never the sequence's. A link that cannot be stored goes out as
74
+ * written: an answer whose links work beats one held for a counter.
75
+ *
76
+ * ## Once
77
+ *
78
+ * The composer mints a key per answer, which becomes its `Message-ID`. The
79
+ * answer is claimed in a transaction before it is sent: a retry of the same
80
+ * answer that already went is answered with its record, and a second answer
81
+ * while one is in flight is refused. A send that fails hands the claim back.
82
+ */ /** Answers one mailbox may send in an hour: a conversation, not a campaign. */ export const OUTREACH_MANUAL_REPLIES_PER_HOUR = 30;
83
+ /** How long a claim on an answer holds before another answer may go. */ const REPLY_CLAIM_STALE_MS = 2 * 60000;
84
+ /** The most of an enrollment's threads one read opens, newest first. */ const THREADS_READ_MAX = 5;
85
+ /** The most messages from the person's address one read opens, outside those threads. */ const OWN_THREAD_MESSAGES_MAX = 10;
86
+ /** A reply key as the composer mints one: a UUID, or anything as plain. */ const REPLY_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
87
+ /** The enrollment, its sequence's click setting, and the mailbox an answer goes from: its own, else its sequence's. */ async function loadEnrollment(firestore, orgId, enrollmentId) {
88
+ const snapshot = await outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId).get();
89
+ const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
90
+ if (!enrollment) return null;
91
+ const stored = enrollment.sequenceId ? await outreachOrgCollection(firestore, orgId, 'sequences').doc(enrollment.sequenceId).get() : null;
92
+ const sequence = stored ? readStoredOutreachSequence(stored.id, stored.exists ? stored.data() : undefined) : null;
93
+ const tracksLinks = (sequence == null ? void 0 : sequence.settings.trackClicks) === true;
94
+ const mailboxId = enrollment.mailboxId || (sequence == null ? void 0 : sequence.mailboxId) || '';
95
+ if (!mailboxId) return {
96
+ enrollment,
97
+ mailbox: null,
98
+ tracksLinks
99
+ };
100
+ const box = await mailboxRef(firestore, orgId, mailboxId).get();
101
+ return {
102
+ enrollment,
103
+ mailbox: readStoredOutreachMailbox(mailboxId, box.exists ? box.data() : undefined),
104
+ tracksLinks
105
+ };
106
+ }
107
+ /** The member is the mailbox's own, or an organization owner or admin. */ const mayUseMailbox = (caller, mailbox)=>Boolean(mailbox) && ((mailbox == null ? void 0 : mailbox.connectedByUid) === caller.uid || caller.isOrgAdmin);
108
+ /** The mailbox's own addresses: the account's, and every alias it sends as. */ const selfAddressesOf = (mailbox)=>{
109
+ var _mailbox_sendAsOptions;
110
+ return [
111
+ mailbox.email,
112
+ mailbox.sendAs,
113
+ ...((_mailbox_sendAsOptions = mailbox.sendAsOptions) != null ? _mailbox_sendAsOptions : []).map((option)=>option.email)
114
+ ].filter(Boolean);
115
+ };
116
+ /**
117
+ * What the person wrote, read from the mailbox: the enrollment's threads,
118
+ * newest first, and — when none holds a message of theirs — the messages
119
+ * from their address since they were enrolled.
120
+ */ async function readMailboxInbound(client, enrollment, mailbox) {
121
+ var _ref, _enrollment_gmailThreadIds;
122
+ var _outreachUnsubscribeMailbox;
123
+ const context = {
124
+ selfAddresses: selfAddressesOf(mailbox),
125
+ unsubscribeAddress: (_ref = (_outreachUnsubscribeMailbox = outreachUnsubscribeMailbox(mailbox.email)) == null ? void 0 : _outreachUnsubscribeMailbox.address) != null ? _ref : null,
126
+ threadSubject: enrollment.threadSubject
127
+ };
128
+ const threads = [
129
+ ...new Set([
130
+ ...(_enrollment_gmailThreadIds = enrollment.gmailThreadIds) != null ? _enrollment_gmailThreadIds : [],
131
+ enrollment.gmailThreadId
132
+ ].filter((id)=>Boolean(id)))
133
+ ].reverse().slice(0, THREADS_READ_MAX);
134
+ const messages = [];
135
+ for (const threadId of threads){
136
+ const thread = await client.getFullThread(threadId);
137
+ messages.push(...thread.messages.map(outreachThreadMessageFromGmail));
138
+ }
139
+ let found = outreachInboundMessagesOf(messages, context);
140
+ if (!found.length && enrollment.email) {
141
+ const listed = await client.searchMessages({
142
+ afterMs: enrollment.createdAtMs || 0,
143
+ from: [
144
+ enrollment.email
145
+ ]
146
+ }, {
147
+ maxResults: OWN_THREAD_MESSAGES_MAX
148
+ });
149
+ const own = [];
150
+ for (const stub of listed.messages.slice(0, OWN_THREAD_MESSAGES_MAX)){
151
+ own.push(outreachThreadMessageFromGmail(await client.getFullMessage(stub.id)));
152
+ }
153
+ found = outreachInboundMessagesOf(own, context);
154
+ }
155
+ return found;
156
+ }
157
+ /** Keeps what the mailbox held on the enrollment; answers the enrollment as stored. */ async function keepInbound(firestore, orgId, enrollment, found, nowMs) {
158
+ const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollment.id);
159
+ return firestore.runTransaction(async (transaction)=>{
160
+ const snapshot = await transaction.get(ref);
161
+ const current = readStoredOutreachEnrollment(enrollment.id, snapshot.exists ? snapshot.data() : undefined);
162
+ if (!current) return enrollment;
163
+ const inboundMessages = mergeOutreachInboundMessages(current.inboundMessages, found);
164
+ transaction.update(ref, {
165
+ inboundMessages,
166
+ updatedAtMs: nowMs
167
+ });
168
+ return _extends({}, current, {
169
+ inboundMessages,
170
+ updatedAtMs: nowMs
171
+ });
172
+ });
173
+ }
174
+ /**
175
+ * The enrollment with what the person wrote: as kept, or — when it says
176
+ * they wrote back and keeps nothing — read from the mailbox and kept.
177
+ */ async function withInbound(deps, replyDeps, caller, loaded) {
178
+ var _enrollment_inboundMessages;
179
+ const { enrollment, mailbox } = loaded;
180
+ const unread = !((_enrollment_inboundMessages = enrollment.inboundMessages) == null ? void 0 : _enrollment_inboundMessages.length) && outreachEnrollmentHasInbound(enrollment) && mailbox !== null && (mailbox.status === 'connected' || mailbox.status === 'paused') && mayUseMailbox(caller, mailbox);
181
+ if (!unread || !mailbox) return {
182
+ ok: true,
183
+ enrollment,
184
+ readMailbox: false
185
+ };
186
+ const firestore = deps.firestore();
187
+ const nowMs = deps.now();
188
+ const opened = await replyDeps.openMailbox(mailbox.id);
189
+ if (opened.ok === false) {
190
+ if (opened.reason === 'not-configured') return {
191
+ ok: true,
192
+ enrollment,
193
+ readMailbox: false
194
+ };
195
+ await markOutreachMailboxReconnectRequired(firestore, {
196
+ orgId: caller.orgId,
197
+ mailboxId: mailbox.id,
198
+ errorCode: opened.reason,
199
+ nowMs
200
+ });
201
+ mailbox.status = 'reconnect_required';
202
+ return {
203
+ ok: true,
204
+ enrollment,
205
+ readMailbox: false
206
+ };
207
+ }
208
+ let found;
209
+ try {
210
+ found = await readMailboxInbound(opened.client, enrollment, mailbox);
211
+ } catch (error) {
212
+ if (isReconnectRequired(error)) {
213
+ await markOutreachMailboxReconnectRequired(firestore, {
214
+ orgId: caller.orgId,
215
+ mailboxId: mailbox.id,
216
+ errorCode: error instanceof GmailTransportError ? error.code : 'invalid_grant',
217
+ nowMs
218
+ });
219
+ mailbox.status = 'reconnect_required';
220
+ return {
221
+ ok: true,
222
+ enrollment,
223
+ readMailbox: false
224
+ };
225
+ }
226
+ if (!(error instanceof GmailTransportError)) throw error;
227
+ const answer = googleFailureAnswer(error, 'reply thread read');
228
+ return {
229
+ ok: false,
230
+ response: outreachRefusal(answer.status, answer.reason, answer.error)
231
+ };
232
+ }
233
+ if (!found.length) return {
234
+ ok: true,
235
+ enrollment,
236
+ readMailbox: true
237
+ };
238
+ return {
239
+ ok: true,
240
+ enrollment: await keepInbound(firestore, caller.orgId, enrollment, found, nowMs),
241
+ readMailbox: true
242
+ };
243
+ }
244
+ /** Every gate an answer still passes — see `outreachManualReplyRefusal`. */ async function refusalFor(firestore, caller, enrollment, mailbox, to) {
245
+ const recipients = [
246
+ ...new Set([
247
+ enrollment.email,
248
+ to
249
+ ].filter((address)=>Boolean(address)))
250
+ ];
251
+ const [addresses, domains] = await Promise.all([
252
+ lookupOutreachDoNotContact(firestore, caller.orgId, recipients),
253
+ lookupOutreachDoNotContactDomains(firestore, caller.orgId, recipients)
254
+ ]);
255
+ const fold = (answers)=>{
256
+ const values = recipients.map((address)=>{
257
+ var _answers_get;
258
+ return (_answers_get = answers.get(address)) != null ? _answers_get : null;
259
+ });
260
+ if (values.includes(true)) return true;
261
+ return values.includes(null) ? null : false;
262
+ };
263
+ return outreachManualReplyRefusal({
264
+ enrollment,
265
+ doNotContact: fold(addresses),
266
+ doNotContactDomain: fold(domains),
267
+ mailbox,
268
+ mayUseMailbox: mayUseMailbox(caller, mailbox)
269
+ });
270
+ }
271
+ /** Where an answer to the newest message would go, from what, under what subject. */ function draftFor(enrollment, mailbox, tracksLinks) {
272
+ var _answering_replyTo, _mailbox_provider;
273
+ const answering = outreachMessageToAnswer(enrollment.inboundMessages);
274
+ if (!answering || !mailbox) return null;
275
+ return {
276
+ answerId: answering.id,
277
+ tracksLinks,
278
+ to: (_answering_replyTo = answering.replyTo) != null ? _answering_replyTo : answering.from,
279
+ subject: outreachReplySubject(answering, enrollment.threadSubject),
280
+ // A mailbox connected before Microsoft ones existed names no provider: it is Google's.
281
+ from: {
282
+ address: outreachMailboxAddress(mailbox),
283
+ name: mailbox.displayName || '',
284
+ provider: (_mailbox_provider = mailbox.provider) != null ? _mailbox_provider : 'google'
285
+ }
286
+ };
287
+ }
288
+ const REFUSAL_STATUS = {
289
+ 'opted-out': 409,
290
+ bounced: 409,
291
+ 'do-not-contact': 409,
292
+ unchecked: 503,
293
+ 'mailbox-unavailable': 409,
294
+ permission: 403
295
+ };
296
+ /** `outreach/enrollments/thread` — see the module note. */ export function createOutreachEnrollmentThreadRoute(deps, replyDeps) {
297
+ return async (request)=>{
298
+ var _enrollment_inboundMessages;
299
+ if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
300
+ const body = await readOutreachJsonBody(request);
301
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
302
+ if (caller instanceof Response) return caller;
303
+ const enrollmentId = readOutreachDocumentId(body['enrollmentId']);
304
+ if (!enrollmentId) return outreachRefusal(400, 'invalid-request', 'Name the enrollment.');
305
+ const firestore = deps.firestore();
306
+ const loaded = await loadEnrollment(firestore, caller.orgId, enrollmentId);
307
+ if (!loaded) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.');
308
+ const read = await withInbound(deps, replyDeps, caller, loaded);
309
+ if (read.ok === false) return read.response;
310
+ const { enrollment, readMailbox } = read;
311
+ const draft = draftFor(enrollment, loaded.mailbox, loaded.tracksLinks);
312
+ const refusal = draft ? await refusalFor(firestore, caller, enrollment, loaded.mailbox, draft.to) : null;
313
+ return outreachOk({
314
+ ok: true,
315
+ enrollment,
316
+ inboundMessages: (_enrollment_inboundMessages = enrollment.inboundMessages) != null ? _enrollment_inboundMessages : [],
317
+ draft,
318
+ refusal,
319
+ readMailbox
320
+ });
321
+ };
322
+ }
323
+ /** `outreach/enrollments/reply` — see the module note. */ export function createOutreachEnrollmentReplyRoute(deps, replyDeps) {
324
+ return async (request)=>{
325
+ if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
326
+ const body = await readOutreachJsonBody(request);
327
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
328
+ if (caller instanceof Response) return caller;
329
+ const enrollmentId = readOutreachDocumentId(body['enrollmentId']);
330
+ if (!enrollmentId) return outreachRefusal(400, 'invalid-request', 'Name the enrollment.');
331
+ const named = typeof body['replyKey'] === 'string' ? body['replyKey'].trim() : '';
332
+ if (named && !REPLY_KEY.test(named)) return outreachRefusal(400, 'invalid-request', 'That reply key is not one the composer mints.');
333
+ const replyKey = named || randomUUID();
334
+ const answerId = typeof body['answerId'] === 'string' ? body['answerId'].trim() : null;
335
+ const firestore = deps.firestore();
336
+ const loaded = await loadEnrollment(firestore, caller.orgId, enrollmentId);
337
+ if (!loaded) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.');
338
+ const read = await withInbound(deps, replyDeps, caller, loaded);
339
+ if (read.ok === false) return read.response;
340
+ const { mailbox } = loaded;
341
+ const answering = outreachMessageToAnswer(read.enrollment.inboundMessages, answerId);
342
+ if (!answering) {
343
+ return outreachRefusal(409, 'nothing-to-answer', answerId ? 'That message of theirs is not one this enrollment kept.' : 'Nothing they wrote could be read, so there is nothing to answer here yet.');
344
+ }
345
+ const composed = composeOutreachManualReply({
346
+ enrollment: read.enrollment,
347
+ answering,
348
+ body: body['body'],
349
+ timeZone: mailbox == null ? void 0 : mailbox.timezone
350
+ });
351
+ if (composed.error || !composed.reply) {
352
+ var _ref;
353
+ var _composed_error;
354
+ return outreachRefusal(400, 'invalid-request', (_ref = (_composed_error = composed.error) == null ? void 0 : _composed_error.message) != null ? _ref : 'The answer could not be written.');
355
+ }
356
+ const reply = composed.reply;
357
+ const refused = await refusalFor(firestore, caller, read.enrollment, mailbox, reply.to);
358
+ if (refused || !mailbox) {
359
+ const refusal = refused != null ? refused : {
360
+ code: 'mailbox-unavailable',
361
+ message: 'The mailbox is no longer connected.'
362
+ };
363
+ return outreachRefusal(REFUSAL_STATUS[refusal.code], refusal.code === 'permission' ? 'permission' : 'reply-refused', refusal.message);
364
+ }
365
+ const limited = await replyDeps.consumeRateLimit(`outreach-manual-reply:${mailbox.id}`, {
366
+ limit: OUTREACH_MANUAL_REPLIES_PER_HOUR,
367
+ windowMs: 60 * 60000
368
+ });
369
+ if (!limited.allowed) {
370
+ return outreachRefusal(429, 'rate-limited', `A mailbox can send ${OUTREACH_MANUAL_REPLIES_PER_HOUR} answers an hour. Try again later.`);
371
+ }
372
+ const sender = {
373
+ address: outreachMailboxAddress(mailbox),
374
+ name: mailbox.displayName || null
375
+ };
376
+ const domain = sender.address.slice(sender.address.lastIndexOf('@') + 1);
377
+ const messageId = `<${replyKey}@${domain}>`;
378
+ const ref = outreachOrgCollection(firestore, caller.orgId, 'enrollments').doc(enrollmentId);
379
+ // Claimed before it is sent: a retry of this answer, or a second one in flight.
380
+ const claim = await firestore.runTransaction(async (transaction)=>{
381
+ var _current_stepRecords;
382
+ const snapshot = await transaction.get(ref);
383
+ const current = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
384
+ if (!current) return {
385
+ kind: 'gone'
386
+ };
387
+ const sent = ((_current_stepRecords = current.stepRecords) != null ? _current_stepRecords : []).find((record)=>record.kind === 'manual_reply' && (record.replyKey === replyKey || record.messageId === messageId));
388
+ if (sent) return {
389
+ kind: 'sent',
390
+ enrollment: current,
391
+ record: sent
392
+ };
393
+ const held = current.replyClaim;
394
+ if (held && deps.now() - held.atMs < REPLY_CLAIM_STALE_MS) return {
395
+ kind: 'busy'
396
+ };
397
+ transaction.update(ref, {
398
+ replyClaim: {
399
+ messageId,
400
+ byUid: caller.uid,
401
+ atMs: deps.now()
402
+ }
403
+ });
404
+ return {
405
+ kind: 'claimed'
406
+ };
407
+ });
408
+ if (claim.kind === 'gone') return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.');
409
+ if (claim.kind === 'busy') {
410
+ return outreachRefusal(409, 'reply-in-flight', 'An answer to them is already being sent. Wait a moment and look again.');
411
+ }
412
+ if (claim.kind === 'sent') {
413
+ var _claim_record_to, _claim_record_subject;
414
+ return outreachOk({
415
+ ok: true,
416
+ enrollment: claim.enrollment,
417
+ record: claim.record,
418
+ sentTo: (_claim_record_to = claim.record.to) != null ? _claim_record_to : reply.to,
419
+ subject: (_claim_record_subject = claim.record.subject) != null ? _claim_record_subject : reply.subject,
420
+ alreadySent: true
421
+ });
422
+ }
423
+ const release = ()=>ref.update({
424
+ replyClaim: null
425
+ }).catch((error)=>{
426
+ console.error('[outreach] an answer’s claim could not be handed back', error);
427
+ });
428
+ const nowMs = deps.now();
429
+ const sending = await withTrackedLinks(firestore, replyDeps, {
430
+ orgId: caller.orgId,
431
+ loaded,
432
+ answering,
433
+ body: body['body'],
434
+ replyKey,
435
+ senderAddress: sender.address,
436
+ untracked: reply,
437
+ nowMs
438
+ });
439
+ const opened = await replyDeps.openMailbox(mailbox.id);
440
+ if (opened.ok === false) {
441
+ await release();
442
+ if (opened.reason === 'not-configured') {
443
+ return outreachRefusal(503, 'mailbox-unavailable', 'Mailboxes are not set up on this deployment.');
444
+ }
445
+ await markOutreachMailboxReconnectRequired(firestore, {
446
+ orgId: caller.orgId,
447
+ mailboxId: mailbox.id,
448
+ errorCode: opened.reason,
449
+ nowMs
450
+ });
451
+ return outreachRefusal(409, 'mailbox-unavailable', `${sender.address} needs to be connected again. Reconnect it on the Mailboxes page to answer from it.`);
452
+ }
453
+ let sent;
454
+ try {
455
+ sent = await sendOutreachReply(opened.client, sending, sender, {
456
+ messageIdLocalPart: replyKey,
457
+ messageIdDomain: domain
458
+ });
459
+ } catch (error) {
460
+ await release();
461
+ if (error instanceof Rfc5322MessageError) {
462
+ return outreachRefusal(400, 'invalid-request', `The answer could not be written: ${error.message}`);
463
+ }
464
+ if (error instanceof GmailTransportError && error.reconnectRequired) {
465
+ await markOutreachMailboxReconnectRequired(firestore, {
466
+ orgId: caller.orgId,
467
+ mailboxId: mailbox.id,
468
+ errorCode: error.code,
469
+ nowMs
470
+ });
471
+ return outreachRefusal(409, 'mailbox-unavailable', `The mail provider refused ${sender.address}’s access. Reconnect it on the Mailboxes page to answer from it.`);
472
+ }
473
+ const answer = googleFailureAnswer(error, 'manual reply send');
474
+ return outreachRefusal(answer.status, answer.reason, answer.error);
475
+ }
476
+ const atMs = deps.now();
477
+ const record = _extends({
478
+ stepIndex: read.enrollment.stepIndex,
479
+ stepId: '',
480
+ kind: 'manual_reply',
481
+ atMs,
482
+ byUid: caller.uid,
483
+ from: sender.address,
484
+ to: reply.to,
485
+ body: sending.body,
486
+ replyKey,
487
+ gmailMessageId: sent.gmailMessageId,
488
+ gmailThreadId: sent.threadId,
489
+ messageId: sent.messageId,
490
+ subject: sent.subject
491
+ }, reply.inReplyTo ? {
492
+ inReplyTo: reply.inReplyTo
493
+ } : {}, sending.trackedLinks.length ? {
494
+ links: sending.trackedLinks
495
+ } : {});
496
+ const enrollment = await firestore.runTransaction(async (transaction)=>{
497
+ var _current_stepRecords;
498
+ const snapshot = await transaction.get(ref);
499
+ const current = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
500
+ if (!current) return null;
501
+ const patch = {
502
+ stepRecords: [
503
+ ...(_current_stepRecords = current.stepRecords) != null ? _current_stepRecords : [],
504
+ record
505
+ ].slice(-20),
506
+ replyClaim: null,
507
+ // The thread is watched for what they write next, and the
508
+ // enrollments table reads this as the last thing that happened.
509
+ lastSentAtMs: atMs,
510
+ updatedAtMs: atMs
511
+ };
512
+ if (sent.threadId && !current.gmailThreadIds.includes(sent.threadId)) {
513
+ patch['gmailThreadIds'] = [
514
+ ...current.gmailThreadIds,
515
+ sent.threadId
516
+ ];
517
+ }
518
+ // Into the current thread: a later step answers this answer.
519
+ if (sent.threadId && sent.threadId === current.gmailThreadId && !current.messageIds.includes(sent.messageId)) {
520
+ patch['messageIds'] = [
521
+ ...current.messageIds,
522
+ sent.messageId
523
+ ];
524
+ }
525
+ transaction.update(ref, patch);
526
+ return _extends({}, current, patch);
527
+ });
528
+ await countManualReply(firestore, caller.orgId, mailbox, atMs);
529
+ await fileOutreachEmail(deps, {
530
+ orgId: caller.orgId,
531
+ hostId: read.enrollment.hostId,
532
+ link: outreachEnrollmentLink(read.enrollment),
533
+ direction: 'outbound',
534
+ subject: sent.subject,
535
+ from: sender.address,
536
+ to: reply.to,
537
+ messageId: sent.messageId,
538
+ inReplyTo: reply.inReplyTo,
539
+ body: sending.body,
540
+ atMs,
541
+ byUid: caller.uid,
542
+ byName: mailbox.connectedByUid === caller.uid ? mailbox.displayName || null : null
543
+ });
544
+ return outreachOk({
545
+ ok: true,
546
+ enrollment: enrollment != null ? enrollment : read.enrollment,
547
+ record,
548
+ sentTo: reply.to,
549
+ subject: sent.subject,
550
+ alreadySent: false
551
+ });
552
+ };
553
+ }
554
+ /**
555
+ * The answer as it goes out (AGL-4022): on a sequence that tracks link
556
+ * clicks, written again with its links minted as short links on the mailbox
557
+ * domain's link host, each stored before the send and named by the answer's
558
+ * reply key; otherwise — or when the links cannot be stored — exactly as
559
+ * the member wrote it.
560
+ */ async function withTrackedLinks(firestore, replyDeps, input) {
561
+ var _loaded_mailbox;
562
+ const { loaded, nowMs } = input;
563
+ if (!loaded.tracksLinks) return input.untracked;
564
+ let origin = null;
565
+ if (replyDeps.clickLinkOrigin) {
566
+ origin = await replyDeps.clickLinkOrigin({
567
+ orgId: input.orgId,
568
+ senderAddress: input.senderAddress
569
+ }).catch((error)=>{
570
+ console.warn('[outreach] the click-tracking host could not be read; an answer’s links use the app address', error);
571
+ return null;
572
+ });
573
+ }
574
+ const minted = [];
575
+ const tracked = composeOutreachManualReply({
576
+ enrollment: loaded.enrollment,
577
+ answering: input.answering,
578
+ body: input.body,
579
+ timeZone: (_loaded_mailbox = loaded.mailbox) == null ? void 0 : _loaded_mailbox.timezone,
580
+ rewriteLink: (link)=>{
581
+ const doc = outreachStoredLink({
582
+ orgId: input.orgId,
583
+ enrollmentId: loaded.enrollment.id,
584
+ stepIndex: loaded.enrollment.stepIndex,
585
+ linkIndex: link.index,
586
+ url: link.url,
587
+ answerKey: input.replyKey
588
+ }, nowMs);
589
+ const id = newOutreachLinkId();
590
+ const url = doc ? replyDeps.clickLinkUrl(id, origin) : null;
591
+ if (!doc || !url) return null;
592
+ minted.push({
593
+ id,
594
+ doc
595
+ });
596
+ return url;
597
+ }
598
+ }).reply;
599
+ if (!tracked || !minted.length) return input.untracked;
600
+ try {
601
+ const batch = firestore.batch();
602
+ for (const link of minted)batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc);
603
+ await batch.commit();
604
+ return tracked;
605
+ } catch (error) {
606
+ // A link that resolves nowhere is worse than one as written.
607
+ console.error('[outreach] an answer’s short links could not be stored; it carries its links as written', error);
608
+ return input.untracked;
609
+ }
610
+ }
611
+ /**
612
+ * One more answer on the mailbox's local day, in
613
+ * `health.daily.<day>.manualReplies` beside the day's sends and never in
614
+ * them. Bookkeeping beside the act: the answer has left, so a failure is
615
+ * logged.
616
+ */ async function countManualReply(firestore, orgId, mailbox, nowMs) {
617
+ const ref = mailboxRef(firestore, orgId, mailbox.id);
618
+ try {
619
+ await firestore.runTransaction(async (transaction)=>{
620
+ var _ref;
621
+ var _stored_health_daily, _stored_health;
622
+ const snapshot = await transaction.get(ref);
623
+ const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined);
624
+ if (!stored) return;
625
+ const day = outreachLocalDay(nowMs, stored.timezone);
626
+ 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 : {
627
+ sent: 0,
628
+ bounces: 0,
629
+ replies: 0
630
+ };
631
+ const manualReplies = Math.max(0, Number(counts.manualReplies) || 0) + 1;
632
+ transaction.update(ref, {
633
+ [`health.daily.${day}`]: _extends({}, counts, {
634
+ manualReplies
635
+ }),
636
+ updatedAtMs: nowMs
637
+ });
638
+ });
639
+ } catch (error) {
640
+ console.warn('[outreach] an answer could not be counted on its mailbox', error);
641
+ }
642
+ }
643
+
644
+ //# sourceMappingURL=reply-routes.js.map