@aglyn/plugins-outreach 1.0.0-beta.275 → 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.
@@ -20,10 +20,12 @@ import { outreachThreadMessageFromGmail } from "../engine/thread-message.js";
20
20
  import { mailboxRef } from "../mailboxes/mailbox-credentials.js";
21
21
  import { outreachLocalDay } from "../mailboxes/mailbox-settings.js";
22
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";
23
25
  import { fileOutreachEmail } from "../runtime/timeline.js";
24
26
  import { outreachUnsubscribeMailbox } from "../runtime/unsubscribe-link.js";
25
27
  import { lookupOutreachDoNotContact, lookupOutreachDoNotContactDomains } from "../storage/do-not-contact-store.js";
26
- import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox } from "../storage/outreach-records.js";
28
+ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
27
29
  import { GmailTransportError, googleFailureAnswer, isReconnectRequired } from "../transport/gmail-errors.js";
28
30
  import { Rfc5322MessageError } from "../transport/rfc5322.js";
29
31
  import { sendOutreachReply } from "../transport/send-message.js";
@@ -62,6 +64,15 @@ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocu
62
64
  * the enrollment's `lastSentAtMs` moves, which keeps the thread watched for
63
65
  * what the person writes next.
64
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
+ *
65
76
  * ## Once
66
77
  *
67
78
  * The composer mints a key per answer, which becomes its `Message-ID`. The
@@ -73,24 +84,24 @@ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocu
73
84
  /** The most of an enrollment's threads one read opens, newest first. */ const THREADS_READ_MAX = 5;
74
85
  /** The most messages from the person's address one read opens, outside those threads. */ const OWN_THREAD_MESSAGES_MAX = 10;
75
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}$/;
76
- /** The enrollment, and the mailbox an answer goes from: its own, else its sequence's. */ async function loadEnrollment(firestore, orgId, enrollmentId) {
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) {
77
88
  const snapshot = await outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId).get();
78
89
  const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
79
90
  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
- }
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) || '';
86
95
  if (!mailboxId) return {
87
96
  enrollment,
88
- mailbox: null
97
+ mailbox: null,
98
+ tracksLinks
89
99
  };
90
100
  const box = await mailboxRef(firestore, orgId, mailboxId).get();
91
101
  return {
92
102
  enrollment,
93
- mailbox: readStoredOutreachMailbox(mailboxId, box.exists ? box.data() : undefined)
103
+ mailbox: readStoredOutreachMailbox(mailboxId, box.exists ? box.data() : undefined),
104
+ tracksLinks
94
105
  };
95
106
  }
96
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);
@@ -257,12 +268,13 @@ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocu
257
268
  mayUseMailbox: mayUseMailbox(caller, mailbox)
258
269
  });
259
270
  }
260
- /** Where an answer to the newest message would go, from what, under what subject. */ function draftFor(enrollment, mailbox) {
271
+ /** Where an answer to the newest message would go, from what, under what subject. */ function draftFor(enrollment, mailbox, tracksLinks) {
261
272
  var _answering_replyTo, _mailbox_provider;
262
273
  const answering = outreachMessageToAnswer(enrollment.inboundMessages);
263
274
  if (!answering || !mailbox) return null;
264
275
  return {
265
276
  answerId: answering.id,
277
+ tracksLinks,
266
278
  to: (_answering_replyTo = answering.replyTo) != null ? _answering_replyTo : answering.from,
267
279
  subject: outreachReplySubject(answering, enrollment.threadSubject),
268
280
  // A mailbox connected before Microsoft ones existed names no provider: it is Google's.
@@ -296,7 +308,7 @@ const REFUSAL_STATUS = {
296
308
  const read = await withInbound(deps, replyDeps, caller, loaded);
297
309
  if (read.ok === false) return read.response;
298
310
  const { enrollment, readMailbox } = read;
299
- const draft = draftFor(enrollment, loaded.mailbox);
311
+ const draft = draftFor(enrollment, loaded.mailbox, loaded.tracksLinks);
300
312
  const refusal = draft ? await refusalFor(firestore, caller, enrollment, loaded.mailbox, draft.to) : null;
301
313
  return outreachOk({
302
314
  ok: true,
@@ -414,6 +426,16 @@ const REFUSAL_STATUS = {
414
426
  console.error('[outreach] an answer’s claim could not be handed back', error);
415
427
  });
416
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
+ });
417
439
  const opened = await replyDeps.openMailbox(mailbox.id);
418
440
  if (opened.ok === false) {
419
441
  await release();
@@ -430,7 +452,7 @@ const REFUSAL_STATUS = {
430
452
  }
431
453
  let sent;
432
454
  try {
433
- sent = await sendOutreachReply(opened.client, reply, sender, {
455
+ sent = await sendOutreachReply(opened.client, sending, sender, {
434
456
  messageIdLocalPart: replyKey,
435
457
  messageIdDomain: domain
436
458
  });
@@ -460,7 +482,7 @@ const REFUSAL_STATUS = {
460
482
  byUid: caller.uid,
461
483
  from: sender.address,
462
484
  to: reply.to,
463
- body: reply.body,
485
+ body: sending.body,
464
486
  replyKey,
465
487
  gmailMessageId: sent.gmailMessageId,
466
488
  gmailThreadId: sent.threadId,
@@ -468,6 +490,8 @@ const REFUSAL_STATUS = {
468
490
  subject: sent.subject
469
491
  }, reply.inReplyTo ? {
470
492
  inReplyTo: reply.inReplyTo
493
+ } : {}, sending.trackedLinks.length ? {
494
+ links: sending.trackedLinks
471
495
  } : {});
472
496
  const enrollment = await firestore.runTransaction(async (transaction)=>{
473
497
  var _current_stepRecords;
@@ -512,7 +536,7 @@ const REFUSAL_STATUS = {
512
536
  to: reply.to,
513
537
  messageId: sent.messageId,
514
538
  inReplyTo: reply.inReplyTo,
515
- body: reply.body,
539
+ body: sending.body,
516
540
  atMs,
517
541
  byUid: caller.uid,
518
542
  byName: mailbox.connectedByUid === caller.uid ? mailbox.displayName || null : null
@@ -527,6 +551,63 @@ const REFUSAL_STATUS = {
527
551
  });
528
552
  };
529
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
+ }
530
611
  /**
531
612
  * One more answer on the mailbox's local day, in
532
613
  * `health.daily.<day>.manualReplies` beside the day's sends and never in
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/reply-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { randomUUID } from 'node:crypto'\nimport {\n composeOutreachManualReply,\n mergeOutreachInboundMessages,\n outreachEnrollmentHasInbound,\n outreachInboundMessagesOf,\n outreachMailboxAddress,\n outreachManualReplyRefusal,\n outreachMessageToAnswer,\n outreachReplySubject,\n type OutreachManualReplyRefusal,\n} from '../engine/manual-reply'\nimport { outreachThreadMessageFromGmail, type OutreachThreadMessage } from '../engine/thread-message'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type {\n OutreachEnrollmentReplyResponse,\n OutreachEnrollmentThreadResponse,\n OutreachReplyDraft,\n} from '../model/outreach-api'\nimport type {\n OutreachEnrollment,\n OutreachInboundMessage,\n OutreachMailbox,\n OutreachStepRecord,\n} from '../model/outreach.types'\nimport { fileOutreachEmail } from '../runtime/timeline'\nimport { outreachUnsubscribeMailbox } from '../runtime/unsubscribe-link'\nimport {\n lookupOutreachDoNotContact,\n lookupOutreachDoNotContactDomains,\n} from '../storage/do-not-contact-store'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n} from '../storage/outreach-records'\nimport type { OutreachMailClient } from '../transport/mail-client'\nimport { GmailTransportError, googleFailureAnswer, isReconnectRequired } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendOutreachReply } from '../transport/send-message'\nimport type { OutreachEnrollRouteDeps } from './enroll-routes'\nimport type { OutreachRouteCaller } from './route-gate'\nimport { outreachRouteGate } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ANSWERING A PERSON WHO WROTE BACK (AGL-4020): `outreach/enrollments/thread`\n * and `outreach/enrollments/reply`.\n *\n * A person who answered a sequence email is answered by a member, from the\n * enrollment's own mailbox, in the same thread — never from the platform's\n * mail service, which carries no prospecting, and never as a new thread.\n * The engine writes the answer (`engine/manual-reply.ts`); these routes read\n * what it answers, check what still refuses, and send it.\n *\n * ## What the person wrote\n *\n * The sync keeps every message of theirs on the enrollment. A reply synced\n * before they were kept is on the enrollment only as an id, so when the\n * enrollment says someone wrote back and keeps nothing they wrote, the\n * `thread` route reads the enrollment's threads in the mailbox — and, when\n * they answered in a thread of their own, the messages from their address\n * since they were enrolled — and keeps what it finds. That read is made only\n * for a member who may send from the mailbox: nobody reads another member's\n * mail through these routes.\n *\n * ## The answer is no step of the sequence\n *\n * It is kept as a `manual_reply` step record and filed on the person's CRM\n * record. It never restarts the sequence or moves its step; it never counts\n * against the mailbox's daily cap, joins the sends a bounce rate is judged\n * over, or bumps the sequence's figures. The mailbox counts it apart, on\n * `health.daily.<day>.manualReplies`, so it can never pause the mailbox.\n * Its `Message-ID` joins the enrollment's when it went into the current\n * thread, so a later step of an enrollment still running answers it, and\n * the enrollment's `lastSentAtMs` moves, which keeps the thread watched for\n * what the person writes next.\n *\n * ## Once\n *\n * The composer mints a key per answer, which becomes its `Message-ID`. The\n * answer is claimed in a transaction before it is sent: a retry of the same\n * answer that already went is answered with its record, and a second answer\n * while one is in flight is refused. A send that fails hands the claim back.\n */\n\n/** Answers one mailbox may send in an hour: a conversation, not a campaign. */\nexport const OUTREACH_MANUAL_REPLIES_PER_HOUR = 30\n\n/** How long a claim on an answer holds before another answer may go. */\nconst REPLY_CLAIM_STALE_MS = 2 * 60_000\n\n/** The most of an enrollment's threads one read opens, newest first. */\nconst THREADS_READ_MAX = 5\n\n/** The most messages from the person's address one read opens, outside those threads. */\nconst OWN_THREAD_MESSAGES_MAX = 10\n\n/** A reply key as the composer mints one: a UUID, or anything as plain. */\nconst REPLY_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/\n\n/** The routes' reach beyond the shared route deps: the mailbox, and the platform's rate limit. */\nexport interface OutreachReplyDeps {\n /** Opens a mailbox's client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\ninterface Loaded {\n enrollment: OutreachEnrollment\n mailbox: OutreachMailbox | null\n}\n\n/** The enrollment, and the mailbox an answer goes from: its own, else its sequence's. */\nasync function loadEnrollment(firestore: Firestore, orgId: string, enrollmentId: string): Promise<Loaded | null> {\n const snapshot = await outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId).get()\n const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return null\n let mailboxId = enrollment.mailboxId\n if (!mailboxId && enrollment.sequenceId) {\n const sequence = await outreachOrgCollection(firestore, orgId, 'sequences').doc(enrollment.sequenceId).get()\n mailboxId = String(sequence.exists ? (sequence.get('mailboxId') ?? '') : '')\n }\n if (!mailboxId) return { enrollment, mailbox: null }\n const box = await mailboxRef(firestore, orgId, mailboxId).get()\n return { enrollment, mailbox: readStoredOutreachMailbox(mailboxId, box.exists ? box.data() : undefined) }\n}\n\n/** The member is the mailbox's own, or an organization owner or admin. */\nconst mayUseMailbox = (caller: OutreachRouteCaller, mailbox: OutreachMailbox | null) =>\n Boolean(mailbox) && (mailbox?.connectedByUid === caller.uid || caller.isOrgAdmin)\n\n/** The mailbox's own addresses: the account's, and every alias it sends as. */\nconst selfAddressesOf = (mailbox: OutreachMailbox) =>\n [mailbox.email, mailbox.sendAs, ...(mailbox.sendAsOptions ?? []).map((option) => option.email)].filter(Boolean)\n\n/**\n * What the person wrote, read from the mailbox: the enrollment's threads,\n * newest first, and — when none holds a message of theirs — the messages\n * from their address since they were enrolled.\n */\nasync function readMailboxInbound(\n client: OutreachMailClient,\n enrollment: OutreachEnrollment,\n mailbox: OutreachMailbox,\n): Promise<OutreachInboundMessage[]> {\n const context = {\n selfAddresses: selfAddressesOf(mailbox),\n unsubscribeAddress: outreachUnsubscribeMailbox(mailbox.email)?.address ?? null,\n threadSubject: enrollment.threadSubject,\n }\n const threads = [\n ...new Set([...(enrollment.gmailThreadIds ?? []), enrollment.gmailThreadId].filter((id): id is string => Boolean(id))),\n ]\n .reverse()\n .slice(0, THREADS_READ_MAX)\n const messages: OutreachThreadMessage[] = []\n for (const threadId of threads) {\n const thread = await client.getFullThread(threadId)\n messages.push(...thread.messages.map(outreachThreadMessageFromGmail))\n }\n let found = outreachInboundMessagesOf(messages, context)\n if (!found.length && enrollment.email) {\n const listed = await client.searchMessages(\n { afterMs: enrollment.createdAtMs || 0, from: [enrollment.email] },\n { maxResults: OWN_THREAD_MESSAGES_MAX },\n )\n const own: OutreachThreadMessage[] = []\n for (const stub of listed.messages.slice(0, OWN_THREAD_MESSAGES_MAX)) {\n own.push(outreachThreadMessageFromGmail(await client.getFullMessage(stub.id)))\n }\n found = outreachInboundMessagesOf(own, context)\n }\n return found\n}\n\n/** Keeps what the mailbox held on the enrollment; answers the enrollment as stored. */\nasync function keepInbound(\n firestore: Firestore,\n orgId: string,\n enrollment: OutreachEnrollment,\n found: readonly OutreachInboundMessage[],\n nowMs: number,\n): Promise<OutreachEnrollment> {\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollment.id)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredOutreachEnrollment(enrollment.id, snapshot.exists ? snapshot.data() : undefined)\n if (!current) return enrollment\n const inboundMessages = mergeOutreachInboundMessages(current.inboundMessages, found)\n transaction.update(ref, { inboundMessages, updatedAtMs: nowMs })\n return { ...current, inboundMessages, updatedAtMs: nowMs }\n })\n}\n\ntype InboundRead =\n | { ok: true; enrollment: OutreachEnrollment; readMailbox: boolean }\n | { ok: false; response: Response }\n\n/**\n * The enrollment with what the person wrote: as kept, or — when it says\n * they wrote back and keeps nothing — read from the mailbox and kept.\n */\nasync function withInbound(\n deps: OutreachEnrollRouteDeps,\n replyDeps: OutreachReplyDeps,\n caller: OutreachRouteCaller,\n loaded: Loaded,\n): Promise<InboundRead> {\n const { enrollment, mailbox } = loaded\n const unread =\n !enrollment.inboundMessages?.length &&\n outreachEnrollmentHasInbound(enrollment) &&\n mailbox !== null &&\n (mailbox.status === 'connected' || mailbox.status === 'paused') &&\n mayUseMailbox(caller, mailbox)\n if (!unread || !mailbox) return { ok: true, enrollment, readMailbox: false }\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const opened = await replyDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') return { ok: true, enrollment, readMailbox: false }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n mailbox.status = 'reconnect_required'\n return { ok: true, enrollment, readMailbox: false }\n }\n let found: OutreachInboundMessage[]\n try {\n found = await readMailboxInbound(opened.client, enrollment, mailbox)\n } catch (error) {\n if (isReconnectRequired(error)) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error instanceof GmailTransportError ? error.code : 'invalid_grant',\n nowMs,\n })\n mailbox.status = 'reconnect_required'\n return { ok: true, enrollment, readMailbox: false }\n }\n if (!(error instanceof GmailTransportError)) throw error\n const answer = googleFailureAnswer(error, 'reply thread read')\n return { ok: false, response: outreachRefusal(answer.status, answer.reason, answer.error) }\n }\n if (!found.length) return { ok: true, enrollment, readMailbox: true }\n return { ok: true, enrollment: await keepInbound(firestore, caller.orgId, enrollment, found, nowMs), readMailbox: true }\n}\n\n/** Every gate an answer still passes — see `outreachManualReplyRefusal`. */\nasync function refusalFor(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n enrollment: OutreachEnrollment,\n mailbox: OutreachMailbox | null,\n to: string | null,\n): Promise<OutreachManualReplyRefusal | null> {\n const recipients = [...new Set([enrollment.email, to].filter((address): address is string => Boolean(address)))]\n const [addresses, domains] = await Promise.all([\n lookupOutreachDoNotContact(firestore, caller.orgId, recipients),\n lookupOutreachDoNotContactDomains(firestore, caller.orgId, recipients),\n ])\n const fold = (answers: Map<string, boolean | null>): boolean | null => {\n const values = recipients.map((address) => answers.get(address) ?? null)\n if (values.includes(true)) return true\n return values.includes(null) ? null : false\n }\n return outreachManualReplyRefusal({\n enrollment,\n doNotContact: fold(addresses),\n doNotContactDomain: fold(domains),\n mailbox,\n mayUseMailbox: mayUseMailbox(caller, mailbox),\n })\n}\n\n/** Where an answer to the newest message would go, from what, under what subject. */\nfunction draftFor(enrollment: OutreachEnrollment, mailbox: OutreachMailbox | null): OutreachReplyDraft | null {\n const answering = outreachMessageToAnswer(enrollment.inboundMessages)\n if (!answering || !mailbox) return null\n return {\n answerId: answering.id,\n to: answering.replyTo ?? answering.from,\n subject: outreachReplySubject(answering, enrollment.threadSubject),\n // A mailbox connected before Microsoft ones existed names no provider: it is Google's.\n from: { address: outreachMailboxAddress(mailbox), name: mailbox.displayName || '', provider: mailbox.provider ?? 'google' },\n }\n}\n\nconst REFUSAL_STATUS: Record<OutreachManualReplyRefusal['code'], number> = {\n 'opted-out': 409,\n bounced: 409,\n 'do-not-contact': 409,\n unchecked: 503,\n 'mailbox-unavailable': 409,\n permission: 403,\n}\n\n/** `outreach/enrollments/thread` — see the module note. */\nexport function createOutreachEnrollmentThreadRoute(\n deps: OutreachEnrollRouteDeps,\n replyDeps: OutreachReplyDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate)\n if (caller instanceof Response) return caller\n const enrollmentId = readOutreachDocumentId(body['enrollmentId'])\n if (!enrollmentId) return outreachRefusal(400, 'invalid-request', 'Name the enrollment.')\n const firestore = deps.firestore()\n const loaded = await loadEnrollment(firestore, caller.orgId, enrollmentId)\n if (!loaded) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n const read = await withInbound(deps, replyDeps, caller, loaded)\n if (read.ok === false) return read.response\n const { enrollment, readMailbox } = read\n const draft = draftFor(enrollment, loaded.mailbox)\n const refusal = draft ? await refusalFor(firestore, caller, enrollment, loaded.mailbox, draft.to) : null\n return outreachOk({\n ok: true,\n enrollment,\n inboundMessages: enrollment.inboundMessages ?? [],\n draft,\n refusal,\n readMailbox,\n } satisfies OutreachEnrollmentThreadResponse)\n }\n}\n\n/** `outreach/enrollments/reply` — see the module note. */\nexport function createOutreachEnrollmentReplyRoute(\n deps: OutreachEnrollRouteDeps,\n replyDeps: OutreachReplyDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate)\n if (caller instanceof Response) return caller\n const enrollmentId = readOutreachDocumentId(body['enrollmentId'])\n if (!enrollmentId) return outreachRefusal(400, 'invalid-request', 'Name the enrollment.')\n const named = typeof body['replyKey'] === 'string' ? body['replyKey'].trim() : ''\n if (named && !REPLY_KEY.test(named)) return outreachRefusal(400, 'invalid-request', 'That reply key is not one the composer mints.')\n const replyKey = named || randomUUID()\n const answerId = typeof body['answerId'] === 'string' ? body['answerId'].trim() : null\n\n const firestore = deps.firestore()\n const loaded = await loadEnrollment(firestore, caller.orgId, enrollmentId)\n if (!loaded) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n const read = await withInbound(deps, replyDeps, caller, loaded)\n if (read.ok === false) return read.response\n const { mailbox } = loaded\n const answering = outreachMessageToAnswer(read.enrollment.inboundMessages, answerId)\n if (!answering) {\n return outreachRefusal(\n 409,\n 'nothing-to-answer',\n answerId\n ? 'That message of theirs is not one this enrollment kept.'\n : 'Nothing they wrote could be read, so there is nothing to answer here yet.',\n )\n }\n const composed = composeOutreachManualReply({\n enrollment: read.enrollment,\n answering,\n body: body['body'],\n timeZone: mailbox?.timezone,\n })\n if (composed.error || !composed.reply) {\n return outreachRefusal(400, 'invalid-request', composed.error?.message ?? 'The answer could not be written.')\n }\n const reply = composed.reply\n const refused = await refusalFor(firestore, caller, read.enrollment, mailbox, reply.to)\n if (refused || !mailbox) {\n const refusal = refused ?? { code: 'mailbox-unavailable' as const, message: 'The mailbox is no longer connected.' }\n return outreachRefusal(\n REFUSAL_STATUS[refusal.code],\n refusal.code === 'permission' ? 'permission' : 'reply-refused',\n refusal.message,\n )\n }\n const limited = await replyDeps.consumeRateLimit(`outreach-manual-reply:${mailbox.id}`, {\n limit: OUTREACH_MANUAL_REPLIES_PER_HOUR,\n windowMs: 60 * 60_000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_MANUAL_REPLIES_PER_HOUR} answers an hour. Try again later.`,\n )\n }\n\n const sender = { address: outreachMailboxAddress(mailbox), name: mailbox.displayName || null }\n const domain = sender.address.slice(sender.address.lastIndexOf('@') + 1)\n const messageId = `<${replyKey}@${domain}>`\n const ref = outreachOrgCollection(firestore, caller.orgId, 'enrollments').doc(enrollmentId)\n\n // Claimed before it is sent: a retry of this answer, or a second one in flight.\n const claim = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!current) return { kind: 'gone' as const }\n const sent = (current.stepRecords ?? []).find(\n (record) => record.kind === 'manual_reply' && (record.replyKey === replyKey || record.messageId === messageId),\n )\n if (sent) return { kind: 'sent' as const, enrollment: current, record: sent }\n const held = current.replyClaim\n if (held && deps.now() - held.atMs < REPLY_CLAIM_STALE_MS) return { kind: 'busy' as const }\n transaction.update(ref, { replyClaim: { messageId, byUid: caller.uid, atMs: deps.now() } })\n return { kind: 'claimed' as const }\n })\n if (claim.kind === 'gone') return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n if (claim.kind === 'busy') {\n return outreachRefusal(409, 'reply-in-flight', 'An answer to them is already being sent. Wait a moment and look again.')\n }\n if (claim.kind === 'sent') {\n return outreachOk({\n ok: true,\n enrollment: claim.enrollment,\n record: claim.record,\n sentTo: claim.record.to ?? reply.to,\n subject: claim.record.subject ?? reply.subject,\n alreadySent: true,\n } satisfies OutreachEnrollmentReplyResponse)\n }\n const release = () =>\n ref.update({ replyClaim: null }).catch((error: unknown) => {\n console.error('[outreach] an answer’s claim could not be handed back', error)\n })\n\n const nowMs = deps.now()\n const opened = await replyDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n await release()\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', 'Mailboxes are not set up on this deployment.')\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n `${sender.address} needs to be connected again. Reconnect it on the Mailboxes page to answer from it.`,\n )\n }\n let sent: Awaited<ReturnType<typeof sendOutreachReply>>\n try {\n sent = await sendOutreachReply(opened.client, reply, sender, {\n messageIdLocalPart: replyKey,\n messageIdDomain: domain,\n })\n } catch (error) {\n await release()\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The answer could not be written: ${error.message}`)\n }\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n `The mail provider refused ${sender.address}’s access. Reconnect it on the Mailboxes page to answer from it.`,\n )\n }\n const answer = googleFailureAnswer(error, 'manual reply send')\n return outreachRefusal(answer.status, answer.reason, answer.error)\n }\n\n const atMs = deps.now()\n const record: OutreachStepRecord = {\n stepIndex: read.enrollment.stepIndex,\n stepId: '',\n kind: 'manual_reply',\n atMs,\n byUid: caller.uid,\n from: sender.address,\n to: reply.to,\n body: reply.body,\n replyKey,\n gmailMessageId: sent.gmailMessageId,\n gmailThreadId: sent.threadId,\n messageId: sent.messageId,\n subject: sent.subject,\n ...(reply.inReplyTo ? { inReplyTo: reply.inReplyTo } : {}),\n }\n const enrollment = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!current) return null\n const patch: Record<string, unknown> = {\n stepRecords: [...(current.stepRecords ?? []), record].slice(-20),\n replyClaim: null,\n // The thread is watched for what they write next, and the\n // enrollments table reads this as the last thing that happened.\n lastSentAtMs: atMs,\n updatedAtMs: atMs,\n }\n if (sent.threadId && !current.gmailThreadIds.includes(sent.threadId)) {\n patch['gmailThreadIds'] = [...current.gmailThreadIds, sent.threadId]\n }\n // Into the current thread: a later step answers this answer.\n if (sent.threadId && sent.threadId === current.gmailThreadId && !current.messageIds.includes(sent.messageId)) {\n patch['messageIds'] = [...current.messageIds, sent.messageId]\n }\n transaction.update(ref, patch)\n return { ...current, ...patch } as OutreachEnrollment\n })\n await countManualReply(firestore, caller.orgId, mailbox, atMs)\n await fileOutreachEmail(deps, {\n orgId: caller.orgId,\n hostId: read.enrollment.hostId,\n link: outreachEnrollmentLink(read.enrollment),\n direction: 'outbound',\n subject: sent.subject,\n from: sender.address,\n to: reply.to,\n messageId: sent.messageId,\n inReplyTo: reply.inReplyTo,\n body: reply.body,\n atMs,\n byUid: caller.uid,\n byName: mailbox.connectedByUid === caller.uid ? mailbox.displayName || null : null,\n })\n return outreachOk({\n ok: true,\n enrollment: enrollment ?? read.enrollment,\n record,\n sentTo: reply.to,\n subject: sent.subject,\n alreadySent: false,\n } satisfies OutreachEnrollmentReplyResponse)\n }\n}\n\n/**\n * One more answer on the mailbox's local day, in\n * `health.daily.<day>.manualReplies` beside the day's sends and never in\n * them. Bookkeeping beside the act: the answer has left, so a failure is\n * logged.\n */\nasync function countManualReply(\n firestore: Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<void> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const manualReplies = Math.max(0, Number(counts.manualReplies) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, manualReplies }, updatedAtMs: nowMs })\n })\n } catch (error) {\n console.warn('[outreach] an answer could not be counted on its mailbox', error)\n }\n}\n"],"names":["randomUUID","composeOutreachManualReply","mergeOutreachInboundMessages","outreachEnrollmentHasInbound","outreachInboundMessagesOf","outreachMailboxAddress","outreachManualReplyRefusal","outreachMessageToAnswer","outreachReplySubject","outreachThreadMessageFromGmail","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","fileOutreachEmail","outreachUnsubscribeMailbox","lookupOutreachDoNotContact","lookupOutreachDoNotContactDomains","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","GmailTransportError","googleFailureAnswer","isReconnectRequired","Rfc5322MessageError","sendOutreachReply","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","OUTREACH_MANUAL_REPLIES_PER_HOUR","REPLY_CLAIM_STALE_MS","THREADS_READ_MAX","OWN_THREAD_MESSAGES_MAX","REPLY_KEY","loadEnrollment","firestore","orgId","enrollmentId","snapshot","doc","get","enrollment","exists","data","undefined","mailboxId","sequenceId","sequence","String","mailbox","box","mayUseMailbox","caller","Boolean","connectedByUid","uid","isOrgAdmin","selfAddressesOf","email","sendAs","sendAsOptions","map","option","filter","readMailboxInbound","client","context","selfAddresses","unsubscribeAddress","address","threadSubject","threads","Set","gmailThreadIds","gmailThreadId","id","reverse","slice","messages","threadId","thread","getFullThread","push","found","length","listed","searchMessages","afterMs","createdAtMs","from","maxResults","own","stub","getFullMessage","keepInbound","nowMs","ref","runTransaction","transaction","current","inboundMessages","update","updatedAtMs","withInbound","deps","replyDeps","loaded","unread","status","ok","readMailbox","now","opened","openMailbox","reason","errorCode","error","code","answer","response","refusalFor","to","recipients","addresses","domains","Promise","all","fold","answers","values","includes","doNotContact","doNotContactDomain","draftFor","answering","answerId","replyTo","subject","name","displayName","provider","REFUSAL_STATUS","bounced","unchecked","permission","createOutreachEnrollmentThreadRoute","request","method","body","gate","Response","read","draft","refusal","createOutreachEnrollmentReplyRoute","named","trim","test","replyKey","composed","timeZone","timezone","reply","message","refused","limited","consumeRateLimit","limit","windowMs","allowed","sender","domain","lastIndexOf","messageId","claim","kind","sent","stepRecords","find","record","held","replyClaim","atMs","byUid","sentTo","alreadySent","release","catch","console","messageIdLocalPart","messageIdDomain","reconnectRequired","stepIndex","stepId","gmailMessageId","inReplyTo","patch","lastSentAtMs","messageIds","countManualReply","hostId","link","direction","byName","stored","day","counts","health","daily","bounces","replies","manualReplies","Math","max","Number","warn"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/reply-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { randomUUID } from 'node:crypto'\nimport {\n composeOutreachManualReply,\n type OutreachManualReply,\n mergeOutreachInboundMessages,\n outreachEnrollmentHasInbound,\n outreachInboundMessagesOf,\n outreachMailboxAddress,\n outreachManualReplyRefusal,\n outreachMessageToAnswer,\n outreachReplySubject,\n type OutreachManualReplyRefusal,\n} from '../engine/manual-reply'\nimport { outreachThreadMessageFromGmail, type OutreachThreadMessage } from '../engine/thread-message'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type {\n OutreachEnrollmentReplyResponse,\n OutreachEnrollmentThreadResponse,\n OutreachReplyDraft,\n} from '../model/outreach-api'\nimport {\n OUTREACH_COLLECTIONS,\n type OutreachEnrollment,\n type OutreachInboundMessage,\n type OutreachMailbox,\n type OutreachStepRecord,\n} from '../model/outreach.types'\nimport { newOutreachLinkId, outreachStoredLink, type OutreachStoredLink } from '../runtime/click-link'\nimport { fileOutreachEmail } from '../runtime/timeline'\nimport { outreachUnsubscribeMailbox } from '../runtime/unsubscribe-link'\nimport {\n lookupOutreachDoNotContact,\n lookupOutreachDoNotContactDomains,\n} from '../storage/do-not-contact-store'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport type { OutreachMailClient } from '../transport/mail-client'\nimport { GmailTransportError, googleFailureAnswer, isReconnectRequired } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendOutreachReply } from '../transport/send-message'\nimport type { OutreachEnrollRouteDeps } from './enroll-routes'\nimport type { OutreachRouteCaller } from './route-gate'\nimport { outreachRouteGate } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ANSWERING A PERSON WHO WROTE BACK (AGL-4020): `outreach/enrollments/thread`\n * and `outreach/enrollments/reply`.\n *\n * A person who answered a sequence email is answered by a member, from the\n * enrollment's own mailbox, in the same thread — never from the platform's\n * mail service, which carries no prospecting, and never as a new thread.\n * The engine writes the answer (`engine/manual-reply.ts`); these routes read\n * what it answers, check what still refuses, and send it.\n *\n * ## What the person wrote\n *\n * The sync keeps every message of theirs on the enrollment. A reply synced\n * before they were kept is on the enrollment only as an id, so when the\n * enrollment says someone wrote back and keeps nothing they wrote, the\n * `thread` route reads the enrollment's threads in the mailbox — and, when\n * they answered in a thread of their own, the messages from their address\n * since they were enrolled — and keeps what it finds. That read is made only\n * for a member who may send from the mailbox: nobody reads another member's\n * mail through these routes.\n *\n * ## The answer is no step of the sequence\n *\n * It is kept as a `manual_reply` step record and filed on the person's CRM\n * record. It never restarts the sequence or moves its step; it never counts\n * against the mailbox's daily cap, joins the sends a bounce rate is judged\n * over, or bumps the sequence's figures. The mailbox counts it apart, on\n * `health.daily.<day>.manualReplies`, so it can never pause the mailbox.\n * Its `Message-ID` joins the enrollment's when it went into the current\n * thread, so a later step of an enrollment still running answers it, and\n * the enrollment's `lastSentAtMs` moves, which keeps the thread watched for\n * what the person writes next.\n *\n * ## Its links, on a sequence that tracks them\n *\n * The links in the member's words go out as the short links a step's do\n * (AGL-4022), on the mailbox domain's link host: minted with the answer's\n * reply key, stored before the send, and kept on the `manual_reply` record.\n * A click on one is the person's — on their enrollment and their record —\n * and never the sequence's. A link that cannot be stored goes out as\n * written: an answer whose links work beats one held for a counter.\n *\n * ## Once\n *\n * The composer mints a key per answer, which becomes its `Message-ID`. The\n * answer is claimed in a transaction before it is sent: a retry of the same\n * answer that already went is answered with its record, and a second answer\n * while one is in flight is refused. A send that fails hands the claim back.\n */\n\n/** Answers one mailbox may send in an hour: a conversation, not a campaign. */\nexport const OUTREACH_MANUAL_REPLIES_PER_HOUR = 30\n\n/** How long a claim on an answer holds before another answer may go. */\nconst REPLY_CLAIM_STALE_MS = 2 * 60_000\n\n/** The most of an enrollment's threads one read opens, newest first. */\nconst THREADS_READ_MAX = 5\n\n/** The most messages from the person's address one read opens, outside those threads. */\nconst OWN_THREAD_MESSAGES_MAX = 10\n\n/** A reply key as the composer mints one: a UUID, or anything as plain. */\nconst REPLY_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/\n\n/** The routes' reach beyond the shared route deps: the mailbox, and the platform's rate limit. */\nexport interface OutreachReplyDeps {\n /** Opens a mailbox's client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n /** The short tracking link for a stored link's id, or `null` when none can be made (AGL-4022). */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /** The click-tracking origin for mail sent as `senderAddress`, or `null` for the console's. */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\ninterface Loaded {\n enrollment: OutreachEnrollment\n mailbox: OutreachMailbox | null\n /** The sequence tracks link clicks, so an answer's links go out as short links (AGL-4022). */\n tracksLinks: boolean\n}\n\n/** The enrollment, its sequence's click setting, and the mailbox an answer goes from: its own, else its sequence's. */\nasync function loadEnrollment(firestore: Firestore, orgId: string, enrollmentId: string): Promise<Loaded | null> {\n const snapshot = await outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId).get()\n const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return null\n const stored = enrollment.sequenceId\n ? await outreachOrgCollection(firestore, orgId, 'sequences').doc(enrollment.sequenceId).get()\n : null\n const sequence = stored ? readStoredOutreachSequence(stored.id, stored.exists ? stored.data() : undefined) : null\n const tracksLinks = sequence?.settings.trackClicks === true\n const mailboxId = enrollment.mailboxId || sequence?.mailboxId || ''\n if (!mailboxId) return { enrollment, mailbox: null, tracksLinks }\n const box = await mailboxRef(firestore, orgId, mailboxId).get()\n return {\n enrollment,\n mailbox: readStoredOutreachMailbox(mailboxId, box.exists ? box.data() : undefined),\n tracksLinks,\n }\n}\n\n/** The member is the mailbox's own, or an organization owner or admin. */\nconst mayUseMailbox = (caller: OutreachRouteCaller, mailbox: OutreachMailbox | null) =>\n Boolean(mailbox) && (mailbox?.connectedByUid === caller.uid || caller.isOrgAdmin)\n\n/** The mailbox's own addresses: the account's, and every alias it sends as. */\nconst selfAddressesOf = (mailbox: OutreachMailbox) =>\n [mailbox.email, mailbox.sendAs, ...(mailbox.sendAsOptions ?? []).map((option) => option.email)].filter(Boolean)\n\n/**\n * What the person wrote, read from the mailbox: the enrollment's threads,\n * newest first, and — when none holds a message of theirs — the messages\n * from their address since they were enrolled.\n */\nasync function readMailboxInbound(\n client: OutreachMailClient,\n enrollment: OutreachEnrollment,\n mailbox: OutreachMailbox,\n): Promise<OutreachInboundMessage[]> {\n const context = {\n selfAddresses: selfAddressesOf(mailbox),\n unsubscribeAddress: outreachUnsubscribeMailbox(mailbox.email)?.address ?? null,\n threadSubject: enrollment.threadSubject,\n }\n const threads = [\n ...new Set([...(enrollment.gmailThreadIds ?? []), enrollment.gmailThreadId].filter((id): id is string => Boolean(id))),\n ]\n .reverse()\n .slice(0, THREADS_READ_MAX)\n const messages: OutreachThreadMessage[] = []\n for (const threadId of threads) {\n const thread = await client.getFullThread(threadId)\n messages.push(...thread.messages.map(outreachThreadMessageFromGmail))\n }\n let found = outreachInboundMessagesOf(messages, context)\n if (!found.length && enrollment.email) {\n const listed = await client.searchMessages(\n { afterMs: enrollment.createdAtMs || 0, from: [enrollment.email] },\n { maxResults: OWN_THREAD_MESSAGES_MAX },\n )\n const own: OutreachThreadMessage[] = []\n for (const stub of listed.messages.slice(0, OWN_THREAD_MESSAGES_MAX)) {\n own.push(outreachThreadMessageFromGmail(await client.getFullMessage(stub.id)))\n }\n found = outreachInboundMessagesOf(own, context)\n }\n return found\n}\n\n/** Keeps what the mailbox held on the enrollment; answers the enrollment as stored. */\nasync function keepInbound(\n firestore: Firestore,\n orgId: string,\n enrollment: OutreachEnrollment,\n found: readonly OutreachInboundMessage[],\n nowMs: number,\n): Promise<OutreachEnrollment> {\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollment.id)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredOutreachEnrollment(enrollment.id, snapshot.exists ? snapshot.data() : undefined)\n if (!current) return enrollment\n const inboundMessages = mergeOutreachInboundMessages(current.inboundMessages, found)\n transaction.update(ref, { inboundMessages, updatedAtMs: nowMs })\n return { ...current, inboundMessages, updatedAtMs: nowMs }\n })\n}\n\ntype InboundRead =\n | { ok: true; enrollment: OutreachEnrollment; readMailbox: boolean }\n | { ok: false; response: Response }\n\n/**\n * The enrollment with what the person wrote: as kept, or — when it says\n * they wrote back and keeps nothing — read from the mailbox and kept.\n */\nasync function withInbound(\n deps: OutreachEnrollRouteDeps,\n replyDeps: OutreachReplyDeps,\n caller: OutreachRouteCaller,\n loaded: Loaded,\n): Promise<InboundRead> {\n const { enrollment, mailbox } = loaded\n const unread =\n !enrollment.inboundMessages?.length &&\n outreachEnrollmentHasInbound(enrollment) &&\n mailbox !== null &&\n (mailbox.status === 'connected' || mailbox.status === 'paused') &&\n mayUseMailbox(caller, mailbox)\n if (!unread || !mailbox) return { ok: true, enrollment, readMailbox: false }\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const opened = await replyDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') return { ok: true, enrollment, readMailbox: false }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n mailbox.status = 'reconnect_required'\n return { ok: true, enrollment, readMailbox: false }\n }\n let found: OutreachInboundMessage[]\n try {\n found = await readMailboxInbound(opened.client, enrollment, mailbox)\n } catch (error) {\n if (isReconnectRequired(error)) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error instanceof GmailTransportError ? error.code : 'invalid_grant',\n nowMs,\n })\n mailbox.status = 'reconnect_required'\n return { ok: true, enrollment, readMailbox: false }\n }\n if (!(error instanceof GmailTransportError)) throw error\n const answer = googleFailureAnswer(error, 'reply thread read')\n return { ok: false, response: outreachRefusal(answer.status, answer.reason, answer.error) }\n }\n if (!found.length) return { ok: true, enrollment, readMailbox: true }\n return { ok: true, enrollment: await keepInbound(firestore, caller.orgId, enrollment, found, nowMs), readMailbox: true }\n}\n\n/** Every gate an answer still passes — see `outreachManualReplyRefusal`. */\nasync function refusalFor(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n enrollment: OutreachEnrollment,\n mailbox: OutreachMailbox | null,\n to: string | null,\n): Promise<OutreachManualReplyRefusal | null> {\n const recipients = [...new Set([enrollment.email, to].filter((address): address is string => Boolean(address)))]\n const [addresses, domains] = await Promise.all([\n lookupOutreachDoNotContact(firestore, caller.orgId, recipients),\n lookupOutreachDoNotContactDomains(firestore, caller.orgId, recipients),\n ])\n const fold = (answers: Map<string, boolean | null>): boolean | null => {\n const values = recipients.map((address) => answers.get(address) ?? null)\n if (values.includes(true)) return true\n return values.includes(null) ? null : false\n }\n return outreachManualReplyRefusal({\n enrollment,\n doNotContact: fold(addresses),\n doNotContactDomain: fold(domains),\n mailbox,\n mayUseMailbox: mayUseMailbox(caller, mailbox),\n })\n}\n\n/** Where an answer to the newest message would go, from what, under what subject. */\nfunction draftFor(\n enrollment: OutreachEnrollment,\n mailbox: OutreachMailbox | null,\n tracksLinks: boolean,\n): OutreachReplyDraft | null {\n const answering = outreachMessageToAnswer(enrollment.inboundMessages)\n if (!answering || !mailbox) return null\n return {\n answerId: answering.id,\n tracksLinks,\n to: answering.replyTo ?? answering.from,\n subject: outreachReplySubject(answering, enrollment.threadSubject),\n // A mailbox connected before Microsoft ones existed names no provider: it is Google's.\n from: { address: outreachMailboxAddress(mailbox), name: mailbox.displayName || '', provider: mailbox.provider ?? 'google' },\n }\n}\n\nconst REFUSAL_STATUS: Record<OutreachManualReplyRefusal['code'], number> = {\n 'opted-out': 409,\n bounced: 409,\n 'do-not-contact': 409,\n unchecked: 503,\n 'mailbox-unavailable': 409,\n permission: 403,\n}\n\n/** `outreach/enrollments/thread` — see the module note. */\nexport function createOutreachEnrollmentThreadRoute(\n deps: OutreachEnrollRouteDeps,\n replyDeps: OutreachReplyDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate)\n if (caller instanceof Response) return caller\n const enrollmentId = readOutreachDocumentId(body['enrollmentId'])\n if (!enrollmentId) return outreachRefusal(400, 'invalid-request', 'Name the enrollment.')\n const firestore = deps.firestore()\n const loaded = await loadEnrollment(firestore, caller.orgId, enrollmentId)\n if (!loaded) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n const read = await withInbound(deps, replyDeps, caller, loaded)\n if (read.ok === false) return read.response\n const { enrollment, readMailbox } = read\n const draft = draftFor(enrollment, loaded.mailbox, loaded.tracksLinks)\n const refusal = draft ? await refusalFor(firestore, caller, enrollment, loaded.mailbox, draft.to) : null\n return outreachOk({\n ok: true,\n enrollment,\n inboundMessages: enrollment.inboundMessages ?? [],\n draft,\n refusal,\n readMailbox,\n } satisfies OutreachEnrollmentThreadResponse)\n }\n}\n\n/** `outreach/enrollments/reply` — see the module note. */\nexport function createOutreachEnrollmentReplyRoute(\n deps: OutreachEnrollRouteDeps,\n replyDeps: OutreachReplyDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate)\n if (caller instanceof Response) return caller\n const enrollmentId = readOutreachDocumentId(body['enrollmentId'])\n if (!enrollmentId) return outreachRefusal(400, 'invalid-request', 'Name the enrollment.')\n const named = typeof body['replyKey'] === 'string' ? body['replyKey'].trim() : ''\n if (named && !REPLY_KEY.test(named)) return outreachRefusal(400, 'invalid-request', 'That reply key is not one the composer mints.')\n const replyKey = named || randomUUID()\n const answerId = typeof body['answerId'] === 'string' ? body['answerId'].trim() : null\n\n const firestore = deps.firestore()\n const loaded = await loadEnrollment(firestore, caller.orgId, enrollmentId)\n if (!loaded) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n const read = await withInbound(deps, replyDeps, caller, loaded)\n if (read.ok === false) return read.response\n const { mailbox } = loaded\n const answering = outreachMessageToAnswer(read.enrollment.inboundMessages, answerId)\n if (!answering) {\n return outreachRefusal(\n 409,\n 'nothing-to-answer',\n answerId\n ? 'That message of theirs is not one this enrollment kept.'\n : 'Nothing they wrote could be read, so there is nothing to answer here yet.',\n )\n }\n const composed = composeOutreachManualReply({\n enrollment: read.enrollment,\n answering,\n body: body['body'],\n timeZone: mailbox?.timezone,\n })\n if (composed.error || !composed.reply) {\n return outreachRefusal(400, 'invalid-request', composed.error?.message ?? 'The answer could not be written.')\n }\n const reply = composed.reply\n const refused = await refusalFor(firestore, caller, read.enrollment, mailbox, reply.to)\n if (refused || !mailbox) {\n const refusal = refused ?? { code: 'mailbox-unavailable' as const, message: 'The mailbox is no longer connected.' }\n return outreachRefusal(\n REFUSAL_STATUS[refusal.code],\n refusal.code === 'permission' ? 'permission' : 'reply-refused',\n refusal.message,\n )\n }\n const limited = await replyDeps.consumeRateLimit(`outreach-manual-reply:${mailbox.id}`, {\n limit: OUTREACH_MANUAL_REPLIES_PER_HOUR,\n windowMs: 60 * 60_000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_MANUAL_REPLIES_PER_HOUR} answers an hour. Try again later.`,\n )\n }\n\n const sender = { address: outreachMailboxAddress(mailbox), name: mailbox.displayName || null }\n const domain = sender.address.slice(sender.address.lastIndexOf('@') + 1)\n const messageId = `<${replyKey}@${domain}>`\n const ref = outreachOrgCollection(firestore, caller.orgId, 'enrollments').doc(enrollmentId)\n\n // Claimed before it is sent: a retry of this answer, or a second one in flight.\n const claim = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!current) return { kind: 'gone' as const }\n const sent = (current.stepRecords ?? []).find(\n (record) => record.kind === 'manual_reply' && (record.replyKey === replyKey || record.messageId === messageId),\n )\n if (sent) return { kind: 'sent' as const, enrollment: current, record: sent }\n const held = current.replyClaim\n if (held && deps.now() - held.atMs < REPLY_CLAIM_STALE_MS) return { kind: 'busy' as const }\n transaction.update(ref, { replyClaim: { messageId, byUid: caller.uid, atMs: deps.now() } })\n return { kind: 'claimed' as const }\n })\n if (claim.kind === 'gone') return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n if (claim.kind === 'busy') {\n return outreachRefusal(409, 'reply-in-flight', 'An answer to them is already being sent. Wait a moment and look again.')\n }\n if (claim.kind === 'sent') {\n return outreachOk({\n ok: true,\n enrollment: claim.enrollment,\n record: claim.record,\n sentTo: claim.record.to ?? reply.to,\n subject: claim.record.subject ?? reply.subject,\n alreadySent: true,\n } satisfies OutreachEnrollmentReplyResponse)\n }\n const release = () =>\n ref.update({ replyClaim: null }).catch((error: unknown) => {\n console.error('[outreach] an answer’s claim could not be handed back', error)\n })\n\n const nowMs = deps.now()\n const sending = await withTrackedLinks(firestore, replyDeps, {\n orgId: caller.orgId,\n loaded,\n answering,\n body: body['body'],\n replyKey,\n senderAddress: sender.address,\n untracked: reply,\n nowMs,\n })\n const opened = await replyDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n await release()\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', 'Mailboxes are not set up on this deployment.')\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n `${sender.address} needs to be connected again. Reconnect it on the Mailboxes page to answer from it.`,\n )\n }\n let sent: Awaited<ReturnType<typeof sendOutreachReply>>\n try {\n sent = await sendOutreachReply(opened.client, sending, sender, {\n messageIdLocalPart: replyKey,\n messageIdDomain: domain,\n })\n } catch (error) {\n await release()\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The answer could not be written: ${error.message}`)\n }\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n `The mail provider refused ${sender.address}’s access. Reconnect it on the Mailboxes page to answer from it.`,\n )\n }\n const answer = googleFailureAnswer(error, 'manual reply send')\n return outreachRefusal(answer.status, answer.reason, answer.error)\n }\n\n const atMs = deps.now()\n const record: OutreachStepRecord = {\n stepIndex: read.enrollment.stepIndex,\n stepId: '',\n kind: 'manual_reply',\n atMs,\n byUid: caller.uid,\n from: sender.address,\n to: reply.to,\n body: sending.body,\n replyKey,\n gmailMessageId: sent.gmailMessageId,\n gmailThreadId: sent.threadId,\n messageId: sent.messageId,\n subject: sent.subject,\n ...(reply.inReplyTo ? { inReplyTo: reply.inReplyTo } : {}),\n ...(sending.trackedLinks.length ? { links: sending.trackedLinks } : {}),\n }\n const enrollment = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!current) return null\n const patch: Record<string, unknown> = {\n stepRecords: [...(current.stepRecords ?? []), record].slice(-20),\n replyClaim: null,\n // The thread is watched for what they write next, and the\n // enrollments table reads this as the last thing that happened.\n lastSentAtMs: atMs,\n updatedAtMs: atMs,\n }\n if (sent.threadId && !current.gmailThreadIds.includes(sent.threadId)) {\n patch['gmailThreadIds'] = [...current.gmailThreadIds, sent.threadId]\n }\n // Into the current thread: a later step answers this answer.\n if (sent.threadId && sent.threadId === current.gmailThreadId && !current.messageIds.includes(sent.messageId)) {\n patch['messageIds'] = [...current.messageIds, sent.messageId]\n }\n transaction.update(ref, patch)\n return { ...current, ...patch } as OutreachEnrollment\n })\n await countManualReply(firestore, caller.orgId, mailbox, atMs)\n await fileOutreachEmail(deps, {\n orgId: caller.orgId,\n hostId: read.enrollment.hostId,\n link: outreachEnrollmentLink(read.enrollment),\n direction: 'outbound',\n subject: sent.subject,\n from: sender.address,\n to: reply.to,\n messageId: sent.messageId,\n inReplyTo: reply.inReplyTo,\n body: sending.body,\n atMs,\n byUid: caller.uid,\n byName: mailbox.connectedByUid === caller.uid ? mailbox.displayName || null : null,\n })\n return outreachOk({\n ok: true,\n enrollment: enrollment ?? read.enrollment,\n record,\n sentTo: reply.to,\n subject: sent.subject,\n alreadySent: false,\n } satisfies OutreachEnrollmentReplyResponse)\n }\n}\n\n/**\n * The answer as it goes out (AGL-4022): on a sequence that tracks link\n * clicks, written again with its links minted as short links on the mailbox\n * domain's link host, each stored before the send and named by the answer's\n * reply key; otherwise — or when the links cannot be stored — exactly as\n * the member wrote it.\n */\nasync function withTrackedLinks(\n firestore: Firestore,\n replyDeps: OutreachReplyDeps,\n input: {\n orgId: string\n loaded: Loaded\n answering: OutreachInboundMessage\n body: unknown\n replyKey: string\n senderAddress: string\n untracked: OutreachManualReply\n nowMs: number\n },\n): Promise<OutreachManualReply> {\n const { loaded, nowMs } = input\n if (!loaded.tracksLinks) return input.untracked\n let origin: string | null = null\n if (replyDeps.clickLinkOrigin) {\n origin = await replyDeps\n .clickLinkOrigin({ orgId: input.orgId, senderAddress: input.senderAddress })\n .catch((error: unknown) => {\n console.warn('[outreach] the click-tracking host could not be read; an answer’s links use the app address', error)\n return null\n })\n }\n const minted: Array<{ id: string; doc: OutreachStoredLink }> = []\n const tracked = composeOutreachManualReply({\n enrollment: loaded.enrollment,\n answering: input.answering,\n body: input.body,\n timeZone: loaded.mailbox?.timezone,\n rewriteLink: (link) => {\n const doc = outreachStoredLink(\n {\n orgId: input.orgId,\n enrollmentId: loaded.enrollment.id,\n stepIndex: loaded.enrollment.stepIndex,\n linkIndex: link.index,\n url: link.url,\n answerKey: input.replyKey,\n },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? replyDeps.clickLinkUrl(id, origin) : null\n if (!doc || !url) return null\n minted.push({ id, doc })\n return url\n },\n }).reply\n if (!tracked || !minted.length) return input.untracked\n try {\n const batch = firestore.batch()\n for (const link of minted) batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc)\n await batch.commit()\n return tracked\n } catch (error) {\n // A link that resolves nowhere is worse than one as written.\n console.error('[outreach] an answer’s short links could not be stored; it carries its links as written', error)\n return input.untracked\n }\n}\n\n/**\n * One more answer on the mailbox's local day, in\n * `health.daily.<day>.manualReplies` beside the day's sends and never in\n * them. Bookkeeping beside the act: the answer has left, so a failure is\n * logged.\n */\nasync function countManualReply(\n firestore: Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<void> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const manualReplies = Math.max(0, Number(counts.manualReplies) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, manualReplies }, updatedAtMs: nowMs })\n })\n } catch (error) {\n console.warn('[outreach] an answer could not be counted on its mailbox', error)\n }\n}\n"],"names":["randomUUID","composeOutreachManualReply","mergeOutreachInboundMessages","outreachEnrollmentHasInbound","outreachInboundMessagesOf","outreachMailboxAddress","outreachManualReplyRefusal","outreachMessageToAnswer","outreachReplySubject","outreachThreadMessageFromGmail","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","OUTREACH_COLLECTIONS","newOutreachLinkId","outreachStoredLink","fileOutreachEmail","outreachUnsubscribeMailbox","lookupOutreachDoNotContact","lookupOutreachDoNotContactDomains","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","readStoredOutreachSequence","GmailTransportError","googleFailureAnswer","isReconnectRequired","Rfc5322MessageError","sendOutreachReply","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","OUTREACH_MANUAL_REPLIES_PER_HOUR","REPLY_CLAIM_STALE_MS","THREADS_READ_MAX","OWN_THREAD_MESSAGES_MAX","REPLY_KEY","loadEnrollment","firestore","orgId","enrollmentId","snapshot","doc","get","enrollment","exists","data","undefined","stored","sequenceId","sequence","id","tracksLinks","settings","trackClicks","mailboxId","mailbox","box","mayUseMailbox","caller","Boolean","connectedByUid","uid","isOrgAdmin","selfAddressesOf","email","sendAs","sendAsOptions","map","option","filter","readMailboxInbound","client","context","selfAddresses","unsubscribeAddress","address","threadSubject","threads","Set","gmailThreadIds","gmailThreadId","reverse","slice","messages","threadId","thread","getFullThread","push","found","length","listed","searchMessages","afterMs","createdAtMs","from","maxResults","own","stub","getFullMessage","keepInbound","nowMs","ref","runTransaction","transaction","current","inboundMessages","update","updatedAtMs","withInbound","deps","replyDeps","loaded","unread","status","ok","readMailbox","now","opened","openMailbox","reason","errorCode","error","code","answer","response","refusalFor","to","recipients","addresses","domains","Promise","all","fold","answers","values","includes","doNotContact","doNotContactDomain","draftFor","answering","answerId","replyTo","subject","name","displayName","provider","REFUSAL_STATUS","bounced","unchecked","permission","createOutreachEnrollmentThreadRoute","request","method","body","gate","Response","read","draft","refusal","createOutreachEnrollmentReplyRoute","named","trim","test","replyKey","composed","timeZone","timezone","reply","message","refused","limited","consumeRateLimit","limit","windowMs","allowed","sender","domain","lastIndexOf","messageId","claim","kind","sent","stepRecords","find","record","held","replyClaim","atMs","byUid","sentTo","alreadySent","release","catch","console","sending","withTrackedLinks","senderAddress","untracked","messageIdLocalPart","messageIdDomain","reconnectRequired","stepIndex","stepId","gmailMessageId","inReplyTo","trackedLinks","links","patch","lastSentAtMs","messageIds","countManualReply","hostId","link","direction","byName","input","origin","clickLinkOrigin","warn","minted","tracked","rewriteLink","linkIndex","index","url","answerKey","clickLinkUrl","batch","create","collection","commit","day","counts","health","daily","bounces","replies","manualReplies","Math","max","Number"],"mappings":"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"}
@@ -43,6 +43,14 @@ import type { OutreachRuntimeDeps } from './runtime-deps';
43
43
  * 4. **The link rollup** — "which of our links did they follow?", one
44
44
  * document holding a bounded map, keyed and capped by the campaign
45
45
  * rollup's own rules so a link means the same thing in both reports.
46
+ *
47
+ * A link in a member's ANSWER to a person who wrote back (AGL-4022) is the
48
+ * person's click and not the sequence's: it is written to the enrollment and
49
+ * its history — marked as the answer's — and to their record, but never to
50
+ * the sequence's counters or its link rollup, which report only what the
51
+ * sequence's own steps sent. The route still stamps the person's campaign
52
+ * touch: what they book or fill in next came from the sequence's
53
+ * conversation either way.
46
54
  */
47
55
  /** What one recorded click turned out to be. */
48
56
  export interface OutreachClickOutcome extends OutreachClickJudgement {
@@ -53,6 +61,11 @@ export interface OutreachClickOutcome extends OutreachClickJudgement {
53
61
  }
54
62
  /** When the step that carried a link or an image was sent, or `null` when it cannot be found. */
55
63
  export declare function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null;
64
+ /**
65
+ * When the email that carried a link was sent: the member's answer it was in
66
+ * (AGL-4022), else the step's email; `null` when it cannot be found.
67
+ */
68
+ export declare function outreachLinkSentAtMs(enrollment: OutreachEnrollment, target: Pick<OutreachClickTarget, 'stepIndex' | 'answerKey'>): number | null;
56
69
  /**
57
70
  * Records one visit to a tracking link.
58
71
  *
@@ -33,6 +33,15 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
33
33
  }
34
34
  return null;
35
35
  }
36
+ /**
37
+ * When the email that carried a link was sent: the member's answer it was in
38
+ * (AGL-4022), else the step's email; `null` when it cannot be found.
39
+ */ export function outreachLinkSentAtMs(enrollment, target) {
40
+ var _enrollment_stepRecords;
41
+ if (!target.answerKey) return outreachStepSentAtMs(enrollment, target.stepIndex);
42
+ const answer = ((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).find((record)=>(record == null ? void 0 : record.kind) === 'manual_reply' && record.replyKey === target.answerKey);
43
+ return typeof (answer == null ? void 0 : answer.atMs) === 'number' && Number.isFinite(answer.atMs) ? answer.atMs : null;
44
+ }
36
45
  /**
37
46
  * Records one visit to a tracking link.
38
47
  *
@@ -73,7 +82,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
73
82
  enrollment: null
74
83
  };
75
84
  }
76
- const sent = outreachStepSentAtMs(enrollment, stepIndex);
85
+ const sent = outreachLinkSentAtMs(enrollment, input.target);
77
86
  const judgement = judgeOutreachClick({
78
87
  method: input.method,
79
88
  userAgent: input.userAgent,
@@ -133,7 +142,8 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
133
142
  url: key,
134
143
  stepIndex,
135
144
  human: judgement.human,
136
- machineReason: judgement.machineReason
145
+ machineReason: judgement.machineReason,
146
+ answer: Boolean(input.target.answerKey)
137
147
  }));
138
148
  }
139
149
  return _extends({}, judgement, {
@@ -144,13 +154,15 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
144
154
  });
145
155
  });
146
156
  if (!outcome.enrollment) return outcome;
157
+ // An answer's link is the person's, never the sequence's (AGL-4022).
158
+ const fromAnswer = Boolean(input.target.answerKey);
147
159
  await Promise.all([
148
- bumpSequenceStats(firestore, outcome, {
160
+ fromAnswer ? null : bumpSequenceStats(firestore, outcome, {
149
161
  orgId,
150
162
  sequenceId: outcome.enrollment.sequenceId,
151
163
  nowMs
152
164
  }),
153
- outcome.human ? bumpLinkRollup(firestore, {
165
+ outcome.human && !fromAnswer ? bumpLinkRollup(firestore, {
154
166
  orgId,
155
167
  sequenceId: outcome.enrollment.sequenceId,
156
168
  url
@@ -159,7 +171,8 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
159
171
  orgId,
160
172
  enrollment: outcome.enrollment,
161
173
  url,
162
- nowMs
174
+ nowMs,
175
+ fromAnswer
163
176
  }) : null
164
177
  ]);
165
178
  return outcome;
@@ -266,7 +279,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
266
279
  sourcePluginId: OUTREACH_PLUGIN_ID,
267
280
  kind: 'note',
268
281
  atMs: input.nowMs,
269
- body: `Followed a link in a sequence email: ${(_sendLinkKey = sendLinkKey(input.url)) != null ? _sendLinkKey : input.url}`,
282
+ body: `${input.fromAnswer ? 'Followed a link in an answer to their reply' : 'Followed a link in a sequence email'}: ${(_sendLinkKey = sendLinkKey(input.url)) != null ? _sendLinkKey : input.url}`,
270
283
  byUid: '',
271
284
  byName: null
272
285
  });