@aglyn/plugins-outreach 1.0.0-beta.273 → 1.0.0-beta.275

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