@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.
- 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 +66 -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 +108 -0
- package/src/lib/engine/manual-reply.js +301 -0
- package/src/lib/engine/manual-reply.js.map +1 -0
- package/src/lib/model/enrollment-timeline.d.ts +3 -1
- package/src/lib/model/enrollment-timeline.js +83 -7
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +66 -2
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +85 -3
- package/src/lib/model/outreach.types.js +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 +6 -0
- package/src/lib/routes/register-routes.js +18 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/reply-routes.d.ts +76 -0
- package/src/lib/routes/reply-routes.js +563 -0
- package/src/lib/routes/reply-routes.js.map +1 -0
- 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,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
|