@aglyn/plugins-outreach 1.0.0-beta.274 → 1.0.0-beta.277
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/package.json +14 -14
- package/src/lib/components/enrollment-actions.d.ts +11 -4
- package/src/lib/components/enrollment-actions.js +16 -0
- package/src/lib/components/enrollment-actions.js.map +1 -1
- package/src/lib/components/enrollment-detail.js +40 -13
- package/src/lib/components/enrollment-detail.js.map +1 -1
- package/src/lib/components/mailbox-card.js +13 -2
- package/src/lib/components/mailbox-card.js.map +1 -1
- package/src/lib/components/record-reply-button.d.ts +52 -0
- package/src/lib/components/record-reply-button.js +92 -0
- package/src/lib/components/record-reply-button.js.map +1 -0
- package/src/lib/components/reply-dialog.d.ts +67 -0
- package/src/lib/components/reply-dialog.js +353 -0
- package/src/lib/components/reply-dialog.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +5 -1
- package/src/lib/components/use-outreach-api.js +10 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +8 -0
- package/src/lib/constants/api-routes.js +6 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/index.d.ts +1 -0
- package/src/lib/engine/index.js +1 -0
- package/src/lib/engine/index.js.map +1 -1
- package/src/lib/engine/manual-reply.d.ts +122 -0
- package/src/lib/engine/manual-reply.js +311 -0
- package/src/lib/engine/manual-reply.js.map +1 -0
- package/src/lib/model/enrollment-history.d.ts +2 -0
- package/src/lib/model/enrollment-history.js +10 -5
- package/src/lib/model/enrollment-history.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +3 -1
- package/src/lib/model/enrollment-timeline.js +90 -9
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +68 -2
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +91 -3
- package/src/lib/model/outreach.types.js +6 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/stored-records.d.ts +7 -1
- package/src/lib/model/stored-records.js +37 -1
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/plugin.d.ts +4 -0
- package/src/lib/plugin.js +17 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/routes/register-routes.d.ts +7 -0
- package/src/lib/routes/register-routes.js +26 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/reply-routes.d.ts +92 -0
- package/src/lib/routes/reply-routes.js +644 -0
- package/src/lib/routes/reply-routes.js.map +1 -0
- package/src/lib/runtime/click-events.d.ts +13 -0
- package/src/lib/runtime/click-events.js +19 -6
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-link.d.ts +8 -0
- package/src/lib/runtime/click-link.js +7 -0
- package/src/lib/runtime/click-link.js.map +1 -1
- package/src/lib/runtime/sync-job.js +14 -0
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/transport/send-message.d.ts +11 -0
- package/src/lib/transport/send-message.js +22 -0
- package/src/lib/transport/send-message.js.map +1 -1
|
@@ -0,0 +1 @@
|
|
|
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 =
|
|
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:
|
|
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
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.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 {\n SEND_LINK_ROLLUP_MAX,\n sendLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { outreachLoggedEngagementRows, readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried a link or an image was sent, or `null` when it cannot be found. */\nexport function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = outreachStepSentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = sendLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n ...held,\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `sendLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `SEND_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = sendLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= SEND_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${sendLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["SEND_LINK_ROLLUP_MAX","sendLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","outreachLoggedEngagementRows","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","outreachStepSentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","loggedClicks","loggedMachineClicks","update","clicked","set","collection","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.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 {\n SEND_LINK_ROLLUP_MAX,\n sendLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { outreachLoggedEngagementRows, readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n *\n * A link in a member's ANSWER to a person who wrote back (AGL-4022) is the\n * person's click and not the sequence's: it is written to the enrollment and\n * its history — marked as the answer's — and to their record, but never to\n * the sequence's counters or its link rollup, which report only what the\n * sequence's own steps sent. The route still stamps the person's campaign\n * touch: what they book or fill in next came from the sequence's\n * conversation either way.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried a link or an image was sent, or `null` when it cannot be found. */\nexport function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * When the email that carried a link was sent: the member's answer it was in\n * (AGL-4022), else the step's email; `null` when it cannot be found.\n */\nexport function outreachLinkSentAtMs(\n enrollment: OutreachEnrollment,\n target: Pick<OutreachClickTarget, 'stepIndex' | 'answerKey'>,\n): number | null {\n if (!target.answerKey) return outreachStepSentAtMs(enrollment, target.stepIndex)\n const answer = (enrollment.stepRecords ?? []).find(\n (record) => record?.kind === 'manual_reply' && record.replyKey === target.answerKey,\n )\n return typeof answer?.atMs === 'number' && Number.isFinite(answer.atMs) ? answer.atMs : null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = outreachLinkSentAtMs(enrollment, input.target)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = sendLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n ...held,\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n answer: Boolean(input.target.answerKey),\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n // An answer's link is the person's, never the sequence's (AGL-4022).\n const fromAnswer = Boolean(input.target.answerKey)\n await Promise.all([\n fromAnswer\n ? null\n : bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human && !fromAnswer\n ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url })\n : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs, fromAnswer }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `sendLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `SEND_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = sendLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= SEND_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number; fromAnswer?: boolean },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `${input.fromAnswer ? 'Followed a link in an answer to their reply' : 'Followed a link in a sequence email'}: ${sendLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["SEND_LINK_ROLLUP_MAX","sendLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","outreachLoggedEngagementRows","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","outreachStepSentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","outreachLinkSentAtMs","target","answerKey","answer","find","replyKey","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","loggedClicks","loggedMachineClicks","update","clicked","set","collection","Boolean","fromAnswer","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,oBAAoB,EACpBC,WAAW,QACN,qDAAoD;AAC3D,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,kBAAkB,QAAQ,gCAA4B;AAC/D,SACEC,kBAAkB,QAEb,8BAA0B;AACjC,SAASC,4BAA4B,EAAEC,sBAAsB,QAAQ,oCAAgC;AACrG,SAASC,uBAAuB,EAAEC,sBAAsB,QAAQ,iCAA6B;AAC7F,SACEC,6BAA6B,EAC7BC,2BAA2B,EAC3BC,+BAA+B,EAC/BC,yBAAyB,QAGpB,6BAAyB;AAChC,SACEC,sBAAsB,EACtBC,qBAAqB,EACrBC,4BAA4B,QACvB,iCAA6B;AA+CpC,+FAA+F,GAC/F,OAAO,SAASC,qBAAqBC,UAA8B,EAAEC,SAAiB;QACpED;IAAhB,MAAME,WAAUF,0BAAAA,WAAWG,WAAW,YAAtBH,0BAA0B,EAAE;IAC5C,IAAK,IAAII,QAAQF,QAAQG,MAAM,GAAG,GAAGD,SAAS,GAAGA,SAAS,EAAG;QAC3D,MAAME,SAASJ,OAAO,CAACE,MAAM;QAC7B,IAAIE,CAAAA,0BAAAA,OAAQL,SAAS,MAAKA,aAAaK,OAAOC,IAAI,KAAK,SAAS;YAC9D,OAAO,OAAOD,OAAOE,IAAI,KAAK,YAAYC,OAAOC,QAAQ,CAACJ,OAAOE,IAAI,IAAIF,OAAOE,IAAI,GAAG;QACzF;IACF;IACA,OAAO;AACT;AAEA;;;CAGC,GACD,OAAO,SAASG,qBACdX,UAA8B,EAC9BY,MAA4D;QAG5CZ;IADhB,IAAI,CAACY,OAAOC,SAAS,EAAE,OAAOd,qBAAqBC,YAAYY,OAAOX,SAAS;IAC/E,MAAMa,SAAS,EAACd,0BAAAA,WAAWG,WAAW,YAAtBH,0BAA0B,EAAE,EAAEe,IAAI,CAChD,CAACT,SAAWA,CAAAA,0BAAAA,OAAQC,IAAI,MAAK,kBAAkBD,OAAOU,QAAQ,KAAKJ,OAAOC,SAAS;IAErF,OAAO,QAAOC,0BAAAA,OAAQN,IAAI,MAAK,YAAYC,OAAOC,QAAQ,CAACI,OAAON,IAAI,IAAIM,OAAON,IAAI,GAAG;AAC1F;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeS,oBACpBC,IAAiE,EACjEC,KAIC;IAED,MAAMC,YAAYF,KAAKE,SAAS;IAChC,MAAM,EAAEC,KAAK,EAAEC,YAAY,EAAErB,SAAS,EAAEsB,GAAG,EAAE,GAAGJ,MAAMP,MAAM;IAC5D,qEAAqE;IACrE,yEAAyE;IACzE,IAAIO,MAAMP,MAAM,CAACY,IAAI,EAAE;QACrB,OAAO;YAAEC,OAAO;YAAOC,eAAe;YAAMC,OAAO;YAAO3B,YAAY;QAAK;IAC7E;IACA,MAAM4B,QAAQV,KAAKW,GAAG;IACtB,MAAMC,MAAMjC,sBAAsBuB,WAAWC,OAAO,eAAeU,GAAG,CAACT;IAEvE,MAAMU,UAAU,MAAMZ,UAAUa,cAAc,CAAC,OAAOC;YAqC9BC;QApCtB,MAAMC,WAAW,MAAMF,YAAYG,GAAG,CAACP;QACvC,MAAM9B,aAAaF,6BACjBwB,cACAc,SAASE,MAAM,GAAGF,SAASG,IAAI,KAAKC;QAEtC,IAAI,CAACxC,YAAY;YACf,OAAO;gBAAEyB,OAAO;gBAAOC,eAAe;gBAAMC,OAAO;gBAAO3B,YAAY;YAAK;QAC7E;QACA,MAAMyC,OAAO9B,qBAAqBX,YAAYmB,MAAMP,MAAM;QAC1D,MAAM8B,YAAYvD,mBAAmB;YACnCwD,QAAQxB,MAAMwB,MAAM;YACpBC,WAAWzB,MAAMyB,SAAS;YAC1BC,aAAaJ,SAAS,OAAO,OAAOb,QAAQa;QAC9C;QACA,MAAMN,OAAO9C,uBAAuBW,WAAW8C,UAAU;QACzD,MAAMnB,QAAQe,UAAUjB,KAAK,IAAIU,KAAKY,cAAc,KAAK;QACzD,MAAMC,MAAMhE,YAAYuC;QACxB,qEAAqE;QACrE,MAAM0B,SAAS7D,6BAA6B+C,QAAQzC;QACpD;;;;;;KAMC,GACD,MAAMwD,QACJf,KAAKgB,YAAY,IACjB,CAAChB,KAAKiB,KAAK,CAACC,QAAQ,CAAClB,KAAKgB,YAAY,KACtChB,KAAKiB,KAAK,CAAC/C,MAAM,GAAGb,gCAChB;eAAI2C,KAAKiB,KAAK;YAAEjB,KAAKgB,YAAY;SAAC,GAClChB,KAAKiB,KAAK;QAChB,MAAMN,aAA2CJ,UAAUjB,KAAK,GAC5D,aACKU;YACHmB,QAAQnB,KAAKmB,MAAM,GAAG;YACtBP,cAAc,GAAEZ,uBAAAA,KAAKY,cAAc,YAAnBZ,uBAAuBP;YACvC2B,eAAe3B;YACfuB,YAAY,EAAEH,cAAAA,MAAOb,KAAKgB,YAAY;YACtCK,eAAerB,KAAKqB,aAAa;YACjCJ,OACEJ,OAAO,CAACE,MAAMG,QAAQ,CAACL,QAAQE,MAAM7C,MAAM,GAAGb,gCAC1C;mBAAI0D;gBAAOF;aAAI,GACfE;YACNO,cAActB,KAAKsB,YAAY,GAAIR,CAAAA,SAAS,IAAI,CAAA;YAChDS,qBAAqBvB,KAAKuB,mBAAmB;aAE/C,aACKvB;YACHqB,eAAerB,KAAKqB,aAAa,GAAG;YACpCE,qBAAqBvB,KAAKuB,mBAAmB,GAAIT,CAAAA,SAAS,IAAI,CAAA;;QAEpE;;;;;KAKC,GACD,2FAA2F;QAC3Ff,YAAYyB,MAAM,CAAC7B,KAAKY,UAAUjB,KAAK,GAAG;YAAEqB;YAAYc,SAAS;QAAK,IAAI;YAAEd;QAAW;QACvF;;;;;KAKC,GACD,IAAIG,QAAQ;YACVf,YAAY2B,GAAG,CACb/B,IAAIgC,UAAU,CAACrE,6BAA6BsC,GAAG,CAACxC,uBAAuBqC,SACvEtC,wBAAwB;gBACtBkB,MAAMoB;gBACNL,KAAKyB;gBACL/C;gBACAwB,OAAOiB,UAAUjB,KAAK;gBACtBC,eAAegB,UAAUhB,aAAa;gBACtCZ,QAAQiD,QAAQ5C,MAAMP,MAAM,CAACC,SAAS;YACxC;QAEJ;QACA,OAAO,aAAK6B;YAAWf;YAAO3B,YAAY,aAAKA;gBAAY8C;;;IAC7D;IAEA,IAAI,CAACd,QAAQhC,UAAU,EAAE,OAAOgC;IAEhC,qEAAqE;IACrE,MAAMgC,aAAaD,QAAQ5C,MAAMP,MAAM,CAACC,SAAS;IACjD,MAAMoD,QAAQC,GAAG,CAAC;QAChBF,aACI,OACAG,kBAAkB/C,WAAWY,SAAS;YACpCX;YACA+C,YAAYpC,QAAQhC,UAAU,CAACoE,UAAU;YACzCxC;QACF;QACJI,QAAQP,KAAK,IAAI,CAACuC,aACdK,eAAejD,WAAW;YAAEC;YAAO+C,YAAYpC,QAAQhC,UAAU,CAACoE,UAAU;YAAE7C;QAAI,KAClF;QACJS,QAAQL,KAAK,GAAG2C,eAAepD,MAAM;YAAEG;YAAOrB,YAAYgC,QAAQhC,UAAU;YAAEuB;YAAKK;YAAOoC;QAAW,KAAK;KAC3G;IACD,OAAOhC;AACT;AAEA;;;;;;;CAOC,GACD,eAAemC,kBACb/C,SAAsC,EACtCY,OAA6B,EAC7Bb,KAA2D;IAE3D,IAAI,CAACA,MAAMiD,UAAU,EAAE;IACvB,MAAMG,QAAiCvC,QAAQP,KAAK,GAChD;QACE,gBAAgBxC,WAAWuF,SAAS,CAAC;QACrC,uBAAuBrD,MAAMS,KAAK;OAC9BI,QAAQL,KAAK,GAAG;QAAE,sBAAsB1C,WAAWuF,SAAS,CAAC;IAAG,IAAI,CAAC,KAE3E;QAAE,uBAAuBvF,WAAWuF,SAAS,CAAC;IAAG;IACrD,MAAM3E,sBAAsBuB,WAAWD,MAAME,KAAK,EAAE,aACjDU,GAAG,CAACZ,MAAMiD,UAAU,EACpBT,MAAM,CAACY,OACPE,KAAK,CAAC,CAACC;QACN,qEAAqE;QACrE,8DAA8D;QAC9DC,QAAQC,IAAI,CAAC,2DAA2DF;IAC1E;AACJ;AAEA;;;;;;;;;;CAUC,GACD,eAAeL,eACbjD,SAAsC,EACtCD,KAAyD;IAEzD,IAAI,CAACA,MAAMiD,UAAU,EAAE;IACvB,MAAMpB,MAAMhE,YAAYmC,MAAMI,GAAG;IACjC,MAAMO,MAAMjC,sBAAsBuB,WAAWD,MAAME,KAAK,EAAE,aACvDU,GAAG,CAACZ,MAAMiD,UAAU,EACpBN,UAAU,CAACnE,yBAAyB,CAAC,EAAE,EACvCoC,GAAG,CAACpC,yBAAyB,CAAC,EAAE;IACnC,IAAI,CAACqD,KAAK;QACR,MAAMlB,IACH+B,GAAG,CAAC;YAAEgB,oBAAoB5F,WAAWuF,SAAS,CAAC;QAAG,GAAG;YAAEM,OAAO;QAAK,GACnEL,KAAK,CAAC,IAAMjC;QACf;IACF;IACA,IAAI;QACF,MAAMpB,UAAUa,cAAc,CAAC,OAAOC;gBAEHE;YADjC,MAAMA,WAAW,MAAMF,YAAYG,GAAG,CAACP;YACvC,MAAMsB,QAAShB,SAASE,MAAM,IAAGF,iBAAAA,SAASG,IAAI,uBAAbH,cAAiB,CAAC,QAAQ,GAAG;YAG9D,MAAMD,OAAOiB,yBAAAA,KAAO,CAAC2B,oBAAoB/B,KAAK;YAC9C,IAAI,CAACb,QAAQ6C,OAAOC,IAAI,CAAC7B,gBAAAA,QAAS,CAAC,GAAG/C,MAAM,IAAItB,sBAAsB;gBACpEmD,YAAY2B,GAAG,CAAC/B,KAAK;oBAAEoD,gBAAgBjG,WAAWuF,SAAS,CAAC;gBAAG,GAAG;oBAAEM,OAAO;gBAAK;gBAChF;YACF;YACA5C,YAAY2B,GAAG,CACb/B,KACA;gBACEsB,OAAO;oBACL,CAAC2B,oBAAoB/B,KAAK,EAAE;wBAC1BzB,KAAKyB;wBACLM,QAAQ,AAAC7C,CAAAA,OAAO0B,wBAAAA,KAAMmB,MAAM,KAAK,CAAA,IAAK;oBACxC;gBACF;YACF,GACA;gBAAEwB,OAAO;YAAK;QAElB;IACF,EAAE,OAAOJ,OAAO;QACdC,QAAQC,IAAI,CAAC,4DAA4DF;IAC3E;AACF;AAEA;;;;;;;CAOC,GACD,SAASK,oBAAoB/B,GAAW;IACtC,OAAOmC,OAAOC,IAAI,CAACpC,KAAK,QAAQqC,QAAQ,CAAC;AAC3C;AAEA;;;;;;CAMC,GACD,eAAef,eACbpD,IAA2C,EAC3CC,KAA0G;IAE1G,MAAMmE,SAASpE,KAAKqE,QAAQ;IAC5B,IAAI,CAACD,QAAQ;IACb,IAAI;YAQsHtG;QAPxH,MAAMwG,UAAU,MAAMF,OAAOG,WAAW,CAAC;YACvCpE,OAAOF,MAAME,KAAK;YAClBqE,QAAQvE,MAAMnB,UAAU,CAAC0F,MAAM;YAC/BC,MAAM/F,uBAAuBuB,MAAMnB,UAAU;YAC7C4F,gBAAgB1G;YAChBqB,MAAM;YACNC,MAAMW,MAAMS,KAAK;YACjBiE,MAAM,GAAG1E,MAAM6C,UAAU,GAAG,gDAAgD,sCAAsC,EAAE,GAAEhF,eAAAA,YAAYmC,MAAMI,GAAG,aAArBvC,eAA0BmC,MAAMI,GAAG,EAAE;YAC3JuE,OAAO;YACPC,QAAQ;QACV;QACA,IAAIP,QAAQQ,EAAE,KAAK,OAAO;YACxBrB,QAAQC,IAAI,CAAC,CAAC,mDAAmD,EAAEY,QAAQd,KAAK,EAAE;QACpF;IACF,EAAE,OAAOA,OAAO;QACdC,QAAQD,KAAK,CAAC,kDAAkDA;IAClE;AACF"}
|
|
@@ -88,6 +88,14 @@ export interface OutreachClickTarget {
|
|
|
88
88
|
* `machineClicks`. Its `enrollmentId` is {@link OUTREACH_TEST_LINK_ENROLLMENT}.
|
|
89
89
|
*/
|
|
90
90
|
test?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* A link in a member's ANSWER to a person who wrote back (AGL-4022): the
|
|
93
|
+
* `replyKey` of the answer's `manual_reply` step record. Its `stepIndex`
|
|
94
|
+
* is then the step the enrollment was on, not the step that sent it, and
|
|
95
|
+
* the click is the person's — on their enrollment — and never the
|
|
96
|
+
* sequence's, whose figures count only what its steps sent.
|
|
97
|
+
*/
|
|
98
|
+
answerKey?: string;
|
|
91
99
|
}
|
|
92
100
|
/** The enrollment a test link names, since a test enrolls nobody. */
|
|
93
101
|
export declare const OUTREACH_TEST_LINK_ENROLLMENT = "test";
|
|
@@ -71,6 +71,7 @@ import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
|
|
|
71
71
|
*/ export const OUTREACH_CLICK_TARGET_MAX = 512;
|
|
72
72
|
/** The enrollment a test link names, since a test enrolls nobody. */ export const OUTREACH_TEST_LINK_ENROLLMENT = 'test';
|
|
73
73
|
/** A document id as a token may carry one. */ const DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/;
|
|
74
|
+
/** An answer's reply key, as the reply composer mints one (AGL-4020). */ const ANSWER_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
|
|
74
75
|
const base64url = (text)=>Buffer.from(text, 'utf8').toString('base64url');
|
|
75
76
|
/** A destination we will sign, or `null` for one we will not. */ export function outreachClickTargetUrl(value) {
|
|
76
77
|
const raw = String(value != null ? value : '').trim();
|
|
@@ -194,6 +195,7 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
|
|
|
194
195
|
if (!url) return null;
|
|
195
196
|
if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null;
|
|
196
197
|
if (!index(target.stepIndex) || !index(target.linkIndex)) return null;
|
|
198
|
+
if (target.answerKey !== undefined && !ANSWER_KEY.test(target.answerKey)) return null;
|
|
197
199
|
return _extends({
|
|
198
200
|
v: 1,
|
|
199
201
|
orgId: target.orgId,
|
|
@@ -204,6 +206,8 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
|
|
|
204
206
|
createdAtMs: nowMs
|
|
205
207
|
}, target.test ? {
|
|
206
208
|
test: true
|
|
209
|
+
} : {}, target.answerKey ? {
|
|
210
|
+
answerKey: target.answerKey
|
|
207
211
|
} : {});
|
|
208
212
|
}
|
|
209
213
|
/**
|
|
@@ -223,6 +227,7 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
|
|
|
223
227
|
if (!url) return null;
|
|
224
228
|
if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null;
|
|
225
229
|
if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null;
|
|
230
|
+
const answerKey = body['answerKey'];
|
|
226
231
|
return _extends({
|
|
227
232
|
orgId,
|
|
228
233
|
enrollmentId,
|
|
@@ -231,6 +236,8 @@ const LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01
|
|
|
231
236
|
url
|
|
232
237
|
}, body['test'] === true ? {
|
|
233
238
|
test: true
|
|
239
|
+
} : {}, typeof answerKey === 'string' && ANSWER_KEY.test(answerKey) ? {
|
|
240
|
+
answerKey
|
|
234
241
|
} : {});
|
|
235
242
|
}
|
|
236
243
|
/*==========================================
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-link.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 { randomBytes } from 'node:crypto'\nimport {\n signedLinkSignature,\n signedLinkSignatureMatches,\n} from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\n\n/**\n * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239,\n * AGL-3297).\n *\n * ## Short, and stored\n *\n * Every send since AGL-3297 carries `/api/outreach/l/<id>`: a random\n * {@link OUTREACH_LINK_ID_LENGTH}-character id naming an `outreachLinks`\n * document that holds the organization, the enrollment, the step, the link's\n * index and the destination. The send writes those documents before the\n * email leaves, and the route reads one to answer.\n *\n * The first design put all of that in a signed token instead, so the\n * redirect needed no read. It cost a ~350-character link, which in a\n * one-to-one plain-text email reads as bulk marketing — the whole thing a\n * sequence is not. A read per click is the cheaper of the two.\n *\n * The signed links already in inboxes keep working: the `click` route still\n * verifies and follows them, and {@link readOutreachClickToken} stays for it.\n * Nothing mints one any more.\n *\n * ## It cannot be turned into an open redirect\n *\n * The destination comes only from the stored document, which only the\n * sending runtime writes, and only for a URL written in that sequence's own\n * step; clients cannot read or write the collection at all. An id that names\n * no document is refused rather than followed. For the signed links, the\n * signature covers the destination, to the same end.\n *\n * ## It names ids, never an address\n *\n * A tracking URL lands in the recipient's history, in their gateway's logs\n * and in ours. The short link carries nothing but a random id; the stored\n * document names the enrollment, which names the person — a lookup that is\n * ours to make, not a bystander's to read off a URL.\n *\n * ## The id is unguessable\n *\n * Ten characters of base 62 from the platform's CSPRNG — about 59.5 bits. A\n * guess that lands resolves to a destination someone else was sent, and\n * would count a click that is recorded as the one it is (see\n * `click-events.ts`); it grants nothing else.\n */\n\n/** The purpose Outreach's click links are signed under. */\nexport const OUTREACH_CLICK_PURPOSE = 'outreach-click'\n\n/** The signed link's path on the console — answered, no longer minted. */\nexport const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`\n\n/** The short link's path on the console, before the id (AGL-3297). */\nexport const OUTREACH_SHORT_LINK_PATH = `/api/${OUTREACH_API_ROUTES.shortLink.replace(/\\/:linkId$/, '')}`\n\n/**\n * The longest destination a tracking link will carry.\n *\n * A token is a URL inside a URL, and mail clients wrap, truncate and refuse\n * to linkify very long ones. Past this the link is left alone: an unmeasured\n * click beats a link that arrives broken.\n */\nexport const OUTREACH_CLICK_TARGET_MAX = 512\n\n/** What a click token names. */\nexport interface OutreachClickTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the link. */\n stepIndex: number\n /** The link's index among the ones that step rewrote. */\n linkIndex: number\n /** Where the recipient asked to go. */\n url: string\n /**\n * A link in a TEST of a step (AGL-3325), sent to a member and never to a\n * person: it is followed exactly as any other and recorded nowhere, so\n * the member's own click never moves a sequence's `clicks` or\n * `machineClicks`. Its `enrollmentId` is {@link OUTREACH_TEST_LINK_ENROLLMENT}.\n */\n test?: boolean\n}\n\n/** The enrollment a test link names, since a test enrolls nobody. */\nexport const OUTREACH_TEST_LINK_ENROLLMENT = 'test'\n\n/** A document id as a token may carry one. */\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/\n\nconst base64url = (text: string): string => Buffer.from(text, 'utf8').toString('base64url')\n\n/** A destination we will sign, or `null` for one we will not. */\nexport function outreachClickTargetUrl(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n if (!raw || raw.length > OUTREACH_CLICK_TARGET_MAX) return null\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return null\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.href\n}\n\n/** Whether an index is one a token may carry. */\nconst index = (value: unknown): boolean =>\n typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 1000\n\n/** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */\nexport function mintOutreachClickToken(\n target: OutreachClickTarget,\n secret?: string,\n): string | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n const payload = base64url(\n JSON.stringify({\n v: 1,\n o: target.orgId,\n e: target.enrollmentId,\n s: target.stepIndex,\n i: target.linkIndex,\n u: url,\n }),\n )\n const signature =\n secret === undefined\n ? signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload)\n : signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload, secret)\n return signature ? `${payload}.${signature}` : null\n}\n\n/** What a token names, or `null` for one that is malformed or not ours. */\nexport function readOutreachClickToken(\n token: unknown,\n secret?: string,\n): OutreachClickTarget | null {\n const raw = typeof token === 'string' ? token.trim() : ''\n const dot = raw.indexOf('.')\n // Generous, because the payload carries a destination — but bounded, so an\n // arbitrarily long string never reaches the HMAC.\n if (dot <= 0 || raw.length > 2048) return null\n const payload = raw.slice(0, dot)\n const signature = raw.slice(dot + 1)\n if (\n !signedLinkSignatureMatches({\n purpose: OUTREACH_CLICK_PURPOSE,\n payload,\n signature,\n ...(secret === undefined ? {} : { secret }),\n })\n ) {\n return null\n }\n try {\n const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['o'] ?? '')\n const enrollmentId = String(body['e'] ?? '')\n const stepIndex = body['s']\n const linkIndex = body['i']\n // Checked again on the way OUT, not only on the way in: the signature\n // says we minted this payload, and these say it is still one we will act\n // on — which is what keeps a destination that was signable under an\n // older rule from being followed under this one.\n const url = outreachClickTargetUrl(body['u'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(stepIndex) || !index(linkIndex)) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: stepIndex as number,\n linkIndex: linkIndex as number,\n url,\n }\n } catch {\n return null\n }\n}\n\n/**\n * The tracking URL for one link on the console at `origin`, or `null` when\n * the origin is not HTTPS, the destination is not one we sign, or there is\n * no secret to sign with — no link rather than one that points at nothing.\n *\n * A `null` leaves the recipient with the destination the step actually\n * wrote, which is what `rewriteOutreachBodyLinks` does with it.\n */\nexport function outreachClickUrl(input: {\n origin: string | null | undefined\n target: OutreachClickTarget\n secret?: string\n}): string | null {\n let origin: URL\n try {\n origin = new URL(String(input.origin ?? ''))\n } catch {\n return null\n }\n if (origin.protocol !== 'https:') return null\n const token = mintOutreachClickToken(input.target, input.secret)\n return token ? `${origin.origin}${OUTREACH_CLICK_PATH}?t=${token}` : null\n}\n\n/*==========================================\n * THE SHORT LINK (AGL-3297).\n *=========================================*/\n\n/** How many characters a short link's id has. */\nexport const OUTREACH_LINK_ID_LENGTH = 10\n\nconst LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n\n/** A short link's id as the route will read one. */\nconst LINK_ID = new RegExp(`^[A-Za-z0-9]{${OUTREACH_LINK_ID_LENGTH}}$`)\n\n/** Whether a value is shaped like a short link's id. */\nexport function isOutreachLinkId(value: unknown): value is string {\n return typeof value === 'string' && LINK_ID.test(value)\n}\n\n/**\n * A fresh short link id: base 62 from the CSPRNG, by rejection sampling so\n * every character is equally likely (62 does not divide 256).\n */\nexport function newOutreachLinkId(random: (size: number) => Uint8Array = randomBytes): string {\n let id = ''\n while (id.length < OUTREACH_LINK_ID_LENGTH) {\n for (const byte of random(OUTREACH_LINK_ID_LENGTH * 2)) {\n if (byte >= 248) continue\n id += LINK_ID_ALPHABET[byte % 62]\n if (id.length === OUTREACH_LINK_ID_LENGTH) break\n }\n }\n return id\n}\n\n/** What an `outreachLinks/{id}` document holds. */\nexport interface OutreachStoredLink extends OutreachClickTarget {\n v: 1\n createdAtMs: number\n /** Which sequence a TEST link's step belongs to (AGL-3325); a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/**\n * The document for one link, or `null` when the target is not one we would\n * follow — the same checks the signed token applied at minting.\n */\nexport function outreachStoredLink(target: OutreachClickTarget, nowMs: number): OutreachStoredLink | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n return {\n v: 1,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n linkIndex: target.linkIndex,\n url,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n }\n}\n\n/**\n * What a stored link names, or `null` for a document that is missing or not\n * one we will act on. Checked on the way OUT, as the token is: the document\n * was written by us, and these say it is still one we will follow.\n */\nexport function readOutreachStoredLink(data: unknown): OutreachClickTarget | null {\n if (!data || typeof data !== 'object') return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n // A tracking image's document names no destination, and is never followed.\n if (body['kind'] === OUTREACH_OPEN_KIND) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n const url = outreachClickTargetUrl(body['url'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n linkIndex: body['linkIndex'] as number,\n url,\n ...(body['test'] === true ? { test: true } : {}),\n }\n}\n\n/*==========================================\n * THE TRACKING IMAGE (AGL-3395).\n *\n * A sequence that counts opens gives each email one image, and the image is\n * a short link too: an `outreachLinks` document of kind `open`, minted and\n * stored exactly as a link's is, on the same host. The short-link route\n * reads the document and, finding an image's, answers the image rather than\n * a redirect — so the image needs no route, host or rule a link does not\n * already have, and the email names no address a click does not.\n *\n * It names the enrollment and the step, and no destination: there is\n * nothing to follow.\n *=========================================*/\n\n/** The `kind` an image's document carries; a link's carries none. */\nexport const OUTREACH_OPEN_KIND = 'open'\n\n/** What a tracking image names. */\nexport interface OutreachOpenTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the image. */\n stepIndex: number\n /** An image in a TEST of a step (AGL-3325): answered, and recorded nowhere. */\n test?: boolean\n}\n\n/** What an `outreachLinks/{id}` document holds for a tracking image. */\nexport interface OutreachStoredOpen extends OutreachOpenTarget {\n v: 1\n kind: typeof OUTREACH_OPEN_KIND\n createdAtMs: number\n /** Which sequence a TEST image's step belongs to; a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/** The document for one email's image, or `null` when the target is not one we would record. */\nexport function outreachStoredOpen(target: OutreachOpenTarget, nowMs: number): OutreachStoredOpen | null {\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex)) return null\n return {\n v: 1,\n kind: OUTREACH_OPEN_KIND,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n }\n}\n\n/** Whether a stored document is a tracking image's, whatever else it holds. */\nexport function isOutreachStoredOpen(data: unknown): boolean {\n return Boolean(data) && typeof data === 'object' && (data as Record<string, unknown>)['kind'] === OUTREACH_OPEN_KIND\n}\n\n/** What a stored image names, or `null` for a document that is not one we will record. */\nexport function readOutreachStoredOpen(data: unknown): OutreachOpenTarget | null {\n if (!isOutreachStoredOpen(data)) return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex'])) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n ...(body['test'] === true ? { test: true } : {}),\n }\n}\n\n/**\n * The short link for an id, or `null` when there is no HTTPS origin to put it\n * on or the id is not one — no link rather than one that points at nothing. A\n * `null` leaves the destination the step wrote.\n *\n * On the sending domain's own click-tracking host (AGL-3306) when the org has\n * verified one — `https://links.<domain>/<id>`, which that host answers\n * exactly as the console route — and on the console's\n * `/api/outreach/l/<id>` otherwise, which every link keeps answering on.\n */\nexport function outreachShortLinkUrl(input: {\n origin: string | null | undefined\n linkId: string\n /** `https://links.<domain>`, from `resolveTrackingLinkOrigin`, or nothing. */\n trackingOrigin?: string | null\n}): string | null {\n if (!isOutreachLinkId(input.linkId)) return null\n const tracking = httpsOrigin(input.trackingOrigin)\n if (tracking) return `${tracking}/${input.linkId}`\n const origin = httpsOrigin(input.origin)\n return origin ? `${origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}` : null\n}\n\n/** An HTTPS origin, or `null` for anything else. */\nfunction httpsOrigin(value: string | null | undefined): string | null {\n if (!value) return null\n try {\n const url = new URL(String(value))\n return url.protocol === 'https:' ? url.origin : null\n } catch {\n return null\n }\n}\n"],"names":["randomBytes","signedLinkSignature","signedLinkSignatureMatches","OUTREACH_API_ROUTES","OUTREACH_CLICK_PURPOSE","OUTREACH_CLICK_PATH","click","OUTREACH_SHORT_LINK_PATH","shortLink","replace","OUTREACH_CLICK_TARGET_MAX","OUTREACH_TEST_LINK_ENROLLMENT","DOCUMENT_ID","base64url","text","Buffer","from","toString","outreachClickTargetUrl","value","raw","String","trim","length","url","URL","protocol","href","index","Number","isInteger","mintOutreachClickToken","target","secret","test","orgId","enrollmentId","stepIndex","linkIndex","payload","JSON","stringify","v","o","e","s","i","u","signature","undefined","readOutreachClickToken","token","dot","indexOf","slice","purpose","body","parse","outreachClickUrl","input","origin","OUTREACH_LINK_ID_LENGTH","LINK_ID_ALPHABET","LINK_ID","RegExp","isOutreachLinkId","newOutreachLinkId","random","id","byte","outreachStoredLink","nowMs","createdAtMs","readOutreachStoredLink","data","OUTREACH_OPEN_KIND","outreachStoredOpen","kind","isOutreachStoredOpen","Boolean","readOutreachStoredOpen","outreachShortLinkUrl","linkId","tracking","httpsOrigin","trackingOrigin"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-link.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 { randomBytes } from 'node:crypto'\nimport {\n signedLinkSignature,\n signedLinkSignatureMatches,\n} from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\n\n/**\n * THE LINK A TRACKED SEQUENCE EMAIL CARRIES IN PLACE OF YOURS (AGL-3239,\n * AGL-3297).\n *\n * ## Short, and stored\n *\n * Every send since AGL-3297 carries `/api/outreach/l/<id>`: a random\n * {@link OUTREACH_LINK_ID_LENGTH}-character id naming an `outreachLinks`\n * document that holds the organization, the enrollment, the step, the link's\n * index and the destination. The send writes those documents before the\n * email leaves, and the route reads one to answer.\n *\n * The first design put all of that in a signed token instead, so the\n * redirect needed no read. It cost a ~350-character link, which in a\n * one-to-one plain-text email reads as bulk marketing — the whole thing a\n * sequence is not. A read per click is the cheaper of the two.\n *\n * The signed links already in inboxes keep working: the `click` route still\n * verifies and follows them, and {@link readOutreachClickToken} stays for it.\n * Nothing mints one any more.\n *\n * ## It cannot be turned into an open redirect\n *\n * The destination comes only from the stored document, which only the\n * sending runtime writes, and only for a URL written in that sequence's own\n * step; clients cannot read or write the collection at all. An id that names\n * no document is refused rather than followed. For the signed links, the\n * signature covers the destination, to the same end.\n *\n * ## It names ids, never an address\n *\n * A tracking URL lands in the recipient's history, in their gateway's logs\n * and in ours. The short link carries nothing but a random id; the stored\n * document names the enrollment, which names the person — a lookup that is\n * ours to make, not a bystander's to read off a URL.\n *\n * ## The id is unguessable\n *\n * Ten characters of base 62 from the platform's CSPRNG — about 59.5 bits. A\n * guess that lands resolves to a destination someone else was sent, and\n * would count a click that is recorded as the one it is (see\n * `click-events.ts`); it grants nothing else.\n */\n\n/** The purpose Outreach's click links are signed under. */\nexport const OUTREACH_CLICK_PURPOSE = 'outreach-click'\n\n/** The signed link's path on the console — answered, no longer minted. */\nexport const OUTREACH_CLICK_PATH = `/api/${OUTREACH_API_ROUTES.click}`\n\n/** The short link's path on the console, before the id (AGL-3297). */\nexport const OUTREACH_SHORT_LINK_PATH = `/api/${OUTREACH_API_ROUTES.shortLink.replace(/\\/:linkId$/, '')}`\n\n/**\n * The longest destination a tracking link will carry.\n *\n * A token is a URL inside a URL, and mail clients wrap, truncate and refuse\n * to linkify very long ones. Past this the link is left alone: an unmeasured\n * click beats a link that arrives broken.\n */\nexport const OUTREACH_CLICK_TARGET_MAX = 512\n\n/** What a click token names. */\nexport interface OutreachClickTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the link. */\n stepIndex: number\n /** The link's index among the ones that step rewrote. */\n linkIndex: number\n /** Where the recipient asked to go. */\n url: string\n /**\n * A link in a TEST of a step (AGL-3325), sent to a member and never to a\n * person: it is followed exactly as any other and recorded nowhere, so\n * the member's own click never moves a sequence's `clicks` or\n * `machineClicks`. Its `enrollmentId` is {@link OUTREACH_TEST_LINK_ENROLLMENT}.\n */\n test?: boolean\n /**\n * A link in a member's ANSWER to a person who wrote back (AGL-4022): the\n * `replyKey` of the answer's `manual_reply` step record. Its `stepIndex`\n * is then the step the enrollment was on, not the step that sent it, and\n * the click is the person's — on their enrollment — and never the\n * sequence's, whose figures count only what its steps sent.\n */\n answerKey?: string\n}\n\n/** The enrollment a test link names, since a test enrolls nobody. */\nexport const OUTREACH_TEST_LINK_ENROLLMENT = 'test'\n\n/** A document id as a token may carry one. */\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,160}$/\n\n/** An answer's reply key, as the reply composer mints one (AGL-4020). */\nconst ANSWER_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/\n\nconst base64url = (text: string): string => Buffer.from(text, 'utf8').toString('base64url')\n\n/** A destination we will sign, or `null` for one we will not. */\nexport function outreachClickTargetUrl(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n if (!raw || raw.length > OUTREACH_CLICK_TARGET_MAX) return null\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return null\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.href\n}\n\n/** Whether an index is one a token may carry. */\nconst index = (value: unknown): boolean =>\n typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 1000\n\n/** A token for one link in one step of one enrollment, or `null` when it cannot be signed. */\nexport function mintOutreachClickToken(\n target: OutreachClickTarget,\n secret?: string,\n): string | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n const payload = base64url(\n JSON.stringify({\n v: 1,\n o: target.orgId,\n e: target.enrollmentId,\n s: target.stepIndex,\n i: target.linkIndex,\n u: url,\n }),\n )\n const signature =\n secret === undefined\n ? signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload)\n : signedLinkSignature(OUTREACH_CLICK_PURPOSE, payload, secret)\n return signature ? `${payload}.${signature}` : null\n}\n\n/** What a token names, or `null` for one that is malformed or not ours. */\nexport function readOutreachClickToken(\n token: unknown,\n secret?: string,\n): OutreachClickTarget | null {\n const raw = typeof token === 'string' ? token.trim() : ''\n const dot = raw.indexOf('.')\n // Generous, because the payload carries a destination — but bounded, so an\n // arbitrarily long string never reaches the HMAC.\n if (dot <= 0 || raw.length > 2048) return null\n const payload = raw.slice(0, dot)\n const signature = raw.slice(dot + 1)\n if (\n !signedLinkSignatureMatches({\n purpose: OUTREACH_CLICK_PURPOSE,\n payload,\n signature,\n ...(secret === undefined ? {} : { secret }),\n })\n ) {\n return null\n }\n try {\n const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['o'] ?? '')\n const enrollmentId = String(body['e'] ?? '')\n const stepIndex = body['s']\n const linkIndex = body['i']\n // Checked again on the way OUT, not only on the way in: the signature\n // says we minted this payload, and these say it is still one we will act\n // on — which is what keeps a destination that was signable under an\n // older rule from being followed under this one.\n const url = outreachClickTargetUrl(body['u'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(stepIndex) || !index(linkIndex)) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: stepIndex as number,\n linkIndex: linkIndex as number,\n url,\n }\n } catch {\n return null\n }\n}\n\n/**\n * The tracking URL for one link on the console at `origin`, or `null` when\n * the origin is not HTTPS, the destination is not one we sign, or there is\n * no secret to sign with — no link rather than one that points at nothing.\n *\n * A `null` leaves the recipient with the destination the step actually\n * wrote, which is what `rewriteOutreachBodyLinks` does with it.\n */\nexport function outreachClickUrl(input: {\n origin: string | null | undefined\n target: OutreachClickTarget\n secret?: string\n}): string | null {\n let origin: URL\n try {\n origin = new URL(String(input.origin ?? ''))\n } catch {\n return null\n }\n if (origin.protocol !== 'https:') return null\n const token = mintOutreachClickToken(input.target, input.secret)\n return token ? `${origin.origin}${OUTREACH_CLICK_PATH}?t=${token}` : null\n}\n\n/*==========================================\n * THE SHORT LINK (AGL-3297).\n *=========================================*/\n\n/** How many characters a short link's id has. */\nexport const OUTREACH_LINK_ID_LENGTH = 10\n\nconst LINK_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n\n/** A short link's id as the route will read one. */\nconst LINK_ID = new RegExp(`^[A-Za-z0-9]{${OUTREACH_LINK_ID_LENGTH}}$`)\n\n/** Whether a value is shaped like a short link's id. */\nexport function isOutreachLinkId(value: unknown): value is string {\n return typeof value === 'string' && LINK_ID.test(value)\n}\n\n/**\n * A fresh short link id: base 62 from the CSPRNG, by rejection sampling so\n * every character is equally likely (62 does not divide 256).\n */\nexport function newOutreachLinkId(random: (size: number) => Uint8Array = randomBytes): string {\n let id = ''\n while (id.length < OUTREACH_LINK_ID_LENGTH) {\n for (const byte of random(OUTREACH_LINK_ID_LENGTH * 2)) {\n if (byte >= 248) continue\n id += LINK_ID_ALPHABET[byte % 62]\n if (id.length === OUTREACH_LINK_ID_LENGTH) break\n }\n }\n return id\n}\n\n/** What an `outreachLinks/{id}` document holds. */\nexport interface OutreachStoredLink extends OutreachClickTarget {\n v: 1\n createdAtMs: number\n /** Which sequence a TEST link's step belongs to (AGL-3325); a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/**\n * The document for one link, or `null` when the target is not one we would\n * follow — the same checks the signed token applied at minting.\n */\nexport function outreachStoredLink(target: OutreachClickTarget, nowMs: number): OutreachStoredLink | null {\n const url = outreachClickTargetUrl(target.url)\n if (!url) return null\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex) || !index(target.linkIndex)) return null\n if (target.answerKey !== undefined && !ANSWER_KEY.test(target.answerKey)) return null\n return {\n v: 1,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n linkIndex: target.linkIndex,\n url,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n ...(target.answerKey ? { answerKey: target.answerKey } : {}),\n }\n}\n\n/**\n * What a stored link names, or `null` for a document that is missing or not\n * one we will act on. Checked on the way OUT, as the token is: the document\n * was written by us, and these say it is still one we will follow.\n */\nexport function readOutreachStoredLink(data: unknown): OutreachClickTarget | null {\n if (!data || typeof data !== 'object') return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n // A tracking image's document names no destination, and is never followed.\n if (body['kind'] === OUTREACH_OPEN_KIND) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n const url = outreachClickTargetUrl(body['url'])\n if (!url) return null\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex']) || !index(body['linkIndex'])) return null\n const answerKey = body['answerKey']\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n linkIndex: body['linkIndex'] as number,\n url,\n ...(body['test'] === true ? { test: true } : {}),\n ...(typeof answerKey === 'string' && ANSWER_KEY.test(answerKey) ? { answerKey } : {}),\n }\n}\n\n/*==========================================\n * THE TRACKING IMAGE (AGL-3395).\n *\n * A sequence that counts opens gives each email one image, and the image is\n * a short link too: an `outreachLinks` document of kind `open`, minted and\n * stored exactly as a link's is, on the same host. The short-link route\n * reads the document and, finding an image's, answers the image rather than\n * a redirect — so the image needs no route, host or rule a link does not\n * already have, and the email names no address a click does not.\n *\n * It names the enrollment and the step, and no destination: there is\n * nothing to follow.\n *=========================================*/\n\n/** The `kind` an image's document carries; a link's carries none. */\nexport const OUTREACH_OPEN_KIND = 'open'\n\n/** What a tracking image names. */\nexport interface OutreachOpenTarget {\n orgId: string\n enrollmentId: string\n /** The step whose email carried the image. */\n stepIndex: number\n /** An image in a TEST of a step (AGL-3325): answered, and recorded nowhere. */\n test?: boolean\n}\n\n/** What an `outreachLinks/{id}` document holds for a tracking image. */\nexport interface OutreachStoredOpen extends OutreachOpenTarget {\n v: 1\n kind: typeof OUTREACH_OPEN_KIND\n createdAtMs: number\n /** Which sequence a TEST image's step belongs to; a real send names its enrollment instead. */\n sequenceId?: string\n}\n\n/** The document for one email's image, or `null` when the target is not one we would record. */\nexport function outreachStoredOpen(target: OutreachOpenTarget, nowMs: number): OutreachStoredOpen | null {\n if (!DOCUMENT_ID.test(target.orgId) || !DOCUMENT_ID.test(target.enrollmentId)) return null\n if (!index(target.stepIndex)) return null\n return {\n v: 1,\n kind: OUTREACH_OPEN_KIND,\n orgId: target.orgId,\n enrollmentId: target.enrollmentId,\n stepIndex: target.stepIndex,\n createdAtMs: nowMs,\n ...(target.test ? { test: true } : {}),\n }\n}\n\n/** Whether a stored document is a tracking image's, whatever else it holds. */\nexport function isOutreachStoredOpen(data: unknown): boolean {\n return Boolean(data) && typeof data === 'object' && (data as Record<string, unknown>)['kind'] === OUTREACH_OPEN_KIND\n}\n\n/** What a stored image names, or `null` for a document that is not one we will record. */\nexport function readOutreachStoredOpen(data: unknown): OutreachOpenTarget | null {\n if (!isOutreachStoredOpen(data)) return null\n const body = data as Record<string, unknown>\n if (body['v'] !== 1) return null\n const orgId = String(body['orgId'] ?? '')\n const enrollmentId = String(body['enrollmentId'] ?? '')\n if (!DOCUMENT_ID.test(orgId) || !DOCUMENT_ID.test(enrollmentId)) return null\n if (!index(body['stepIndex'])) return null\n return {\n orgId,\n enrollmentId,\n stepIndex: body['stepIndex'] as number,\n ...(body['test'] === true ? { test: true } : {}),\n }\n}\n\n/**\n * The short link for an id, or `null` when there is no HTTPS origin to put it\n * on or the id is not one — no link rather than one that points at nothing. A\n * `null` leaves the destination the step wrote.\n *\n * On the sending domain's own click-tracking host (AGL-3306) when the org has\n * verified one — `https://links.<domain>/<id>`, which that host answers\n * exactly as the console route — and on the console's\n * `/api/outreach/l/<id>` otherwise, which every link keeps answering on.\n */\nexport function outreachShortLinkUrl(input: {\n origin: string | null | undefined\n linkId: string\n /** `https://links.<domain>`, from `resolveTrackingLinkOrigin`, or nothing. */\n trackingOrigin?: string | null\n}): string | null {\n if (!isOutreachLinkId(input.linkId)) return null\n const tracking = httpsOrigin(input.trackingOrigin)\n if (tracking) return `${tracking}/${input.linkId}`\n const origin = httpsOrigin(input.origin)\n return origin ? `${origin}${OUTREACH_SHORT_LINK_PATH}/${input.linkId}` : null\n}\n\n/** An HTTPS origin, or `null` for anything else. */\nfunction httpsOrigin(value: string | null | undefined): string | null {\n if (!value) return null\n try {\n const url = new URL(String(value))\n return url.protocol === 'https:' ? url.origin : null\n } catch {\n return null\n }\n}\n"],"names":["randomBytes","signedLinkSignature","signedLinkSignatureMatches","OUTREACH_API_ROUTES","OUTREACH_CLICK_PURPOSE","OUTREACH_CLICK_PATH","click","OUTREACH_SHORT_LINK_PATH","shortLink","replace","OUTREACH_CLICK_TARGET_MAX","OUTREACH_TEST_LINK_ENROLLMENT","DOCUMENT_ID","ANSWER_KEY","base64url","text","Buffer","from","toString","outreachClickTargetUrl","value","raw","String","trim","length","url","URL","protocol","href","index","Number","isInteger","mintOutreachClickToken","target","secret","test","orgId","enrollmentId","stepIndex","linkIndex","payload","JSON","stringify","v","o","e","s","i","u","signature","undefined","readOutreachClickToken","token","dot","indexOf","slice","purpose","body","parse","outreachClickUrl","input","origin","OUTREACH_LINK_ID_LENGTH","LINK_ID_ALPHABET","LINK_ID","RegExp","isOutreachLinkId","newOutreachLinkId","random","id","byte","outreachStoredLink","nowMs","answerKey","createdAtMs","readOutreachStoredLink","data","OUTREACH_OPEN_KIND","outreachStoredOpen","kind","isOutreachStoredOpen","Boolean","readOutreachStoredOpen","outreachShortLinkUrl","linkId","tracking","httpsOrigin","trackingOrigin"],"mappings":"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"}
|
|
@@ -21,6 +21,7 @@ import { readOutreachDeliveryReport } from "../engine/delivery-status.js";
|
|
|
21
21
|
import { outreachEmailDomain } from "../engine/do-not-contact-domain.js";
|
|
22
22
|
import { outreachStoppedEntry } from "../engine/enrollment-activity.js";
|
|
23
23
|
import { isOutreachGatewayBlock, outreachGatewayBlockDetail } from "../engine/gateway-block.js";
|
|
24
|
+
import { mergeOutreachInboundMessages, outreachInboundMessagesOf } from "../engine/manual-reply.js";
|
|
24
25
|
import { effectiveOutreachWindow, postponeOutreachForAutoReply } from "../engine/schedule.js";
|
|
25
26
|
import { decideOutreachThread } from "../engine/thread-classification.js";
|
|
26
27
|
import { outreachHeader, outreachThreadMessageFromGmail } from "../engine/thread-message.js";
|
|
@@ -48,6 +49,9 @@ import { outreachUnsubscribeMailbox } from "./unsubscribe-link.js";
|
|
|
48
49
|
* - a REPLY stops the enrollment, files the message on the contact's
|
|
49
50
|
* timeline (once per `Message-ID`, under the capture address's own id for
|
|
50
51
|
* it) and gives the rep the task "Reply from <name>";
|
|
52
|
+
* - every message the person wrote — a reply, an automatic answer, an
|
|
53
|
+
* opt-out — is kept on the enrollment, its sender, time, threading ids
|
|
54
|
+
* and trimmed text, for the console to show and answer (AGL-4020);
|
|
51
55
|
* - an OUT-OF-OFFICE postpones the next step a working week from the reply;
|
|
52
56
|
* - an OPT-OUT — a reply asking to be left alone, or a message to the
|
|
53
57
|
* `+unsubscribe` address the `mailto:` names — puts the address on the
|
|
@@ -402,6 +406,15 @@ async function syncMailbox(deps, firestore, input) {
|
|
|
402
406
|
});
|
|
403
407
|
const freshIds = decision.classifications.map((classification)=>classification.messageId).filter(Boolean);
|
|
404
408
|
if (!freshIds.length) return;
|
|
409
|
+
// What they wrote, kept on the enrollment so the console can show it and
|
|
410
|
+
// answer it in the thread (AGL-4020) — every message of theirs among
|
|
411
|
+
// these, so a thread read whole also keeps a reply synced before they
|
|
412
|
+
// were kept.
|
|
413
|
+
const inbound = outreachInboundMessagesOf(messages, {
|
|
414
|
+
selfAddresses: context.selfAddresses,
|
|
415
|
+
unsubscribeAddress: context.unsubscribeAddress,
|
|
416
|
+
threadSubject: enrollment.threadSubject
|
|
417
|
+
});
|
|
405
418
|
const decidedBy = decision.decidedBy;
|
|
406
419
|
const decidingMessage = decidedBy ? (_messages_find = messages.find((message)=>message.id === decidedBy.messageId)) != null ? _messages_find : null : null;
|
|
407
420
|
const lastSentMessageId = (_enrollment_messageIds_ = enrollment.messageIds[enrollment.messageIds.length - 1]) != null ? _enrollment_messageIds_ : null;
|
|
@@ -617,6 +630,7 @@ async function syncMailbox(deps, firestore, input) {
|
|
|
617
630
|
...freshIds
|
|
618
631
|
].slice(-ENROLLMENT_HANDLED_MAX)
|
|
619
632
|
};
|
|
633
|
+
if (inbound.length) extra['inboundMessages'] = mergeOutreachInboundMessages(current.inboundMessages, inbound);
|
|
620
634
|
const waiting = current.status === 'active' || current.status === 'paused';
|
|
621
635
|
if (postponedTo !== null && waiting && current.nextDueAtMs !== null && postponedTo > current.nextDueAtMs) {
|
|
622
636
|
extra['nextDueAtMs'] = postponedTo;
|