@aglyn/tenant-data-admin 1.0.0-beta.219 → 1.0.0-beta.220
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/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- package/src/lib/server/api-v1-kit.d.ts +7 -0
- package/src/lib/server/api-v1-kit.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +1 -2
- package/src/lib/server/api-v1-resources.js +0 -1
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.js +2 -2
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +3 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.js +1 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +9 -1
- package/src/lib/server/member-email-aliases.js +25 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/notifications.d.ts +14 -0
- package/src/lib/server/notifications.js +29 -0
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js +35 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.js +2 -2
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +1 -3
- package/src/tenancy.js +1 -7
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/contact-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.js.map +0 -1
- package/src/lib/server/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -168
- package/src/lib/server/crm-inbound-email.js +0 -282
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.js.map +0 -1
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-booking-activity.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 checkEntitlement,\n consentGroupForHost,\n CRM_COLLECTIONS,\n crmTaskListFields,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n crmActivityLogHasRoom,\n crmBookingFollowUpDueMs,\n crmBookingFollowUpTitle,\n crmBookingMeetingBody,\n crmScopeTokens,\n isHostPluginEnabled,\n parseCrmBookingRef,\n readContactFacet,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { findContactByEmail } from './contact-email-index'\nimport { readLeadForHost } from './host-visitor-records'\nimport { countCrmActivitiesForRecord } from './crm-records'\n\n/**\n * WHAT A BOOKING FILES ON THE CRM RECORD (AGL-2660).\n *\n * A confirmed booking is a meeting the record has on its calendar, and the\n * timeline should say so without a rep logging it by hand. So once a\n * booking's row is written — at creation for a free service, on payment\n * for a paid one — the record is found and a `meeting` activity filed under\n * it, with a follow-up task beside it when the service asks for one.\n *\n * ## Which record\n *\n * The reference the booking link carried wins: `crmRef` names the contact,\n * lead or deal the rep dropped the link from, and a booking made through\n * that link belongs to that record even when the booker typed a different\n * address. Without one — a booking taken off the widget cold — the booker's\n * address is looked up through the org's address index, narrowed to this\n * site, exactly as the automation steps resolve an event's contact. A\n * reference to a record the site cannot see, or that no longer exists,\n * falls through to the address rather than filing under nobody.\n *\n * ## Where it lives\n *\n * In the admin library rather than the Bookings plugin, because the row is\n * a CRM record: its scope, its ceiling and its collections are the CRM's,\n * and a plugin that stamped its own would be a second creator to keep in\n * step with `crmScopeTokens`. The Bookings plugin passes what it already\n * holds and reads the outcome; the CRM plugin is never imported.\n *\n * Firestore is a parameter, as in `crm-records.ts`: the callers hold one,\n * and a module that reached for the Admin app itself would drag it into\n * every spec that only wants the arithmetic.\n *\n * ## Failure posture\n *\n * **Never throws.** A booking that could not be filed on the CRM is still\n * a booking — the guest has the slot and the confirmation — so a failure\n * here is logged and reported in the outcome, never raised into the\n * request that took the booking or the webhook that confirmed it.\n */\n\n/** The CRM plugin's id, as `enabledPlugins` and `disabledPlugins` name it. */\nconst CRM_PLUGIN_ID = 'crm'\n\nexport interface CrmBookingRecordInput {\n hostId: string\n /**\n * The owning org, as `getOrgForHost` returns it — the plugin set, the\n * consent groups and the scope default are all read off it.\n */\n org: Record<string, unknown> | null | undefined\n orgId: string\n /** The booking as its row was written. */\n booking: {\n id: string\n serviceId?: string\n serviceName?: string\n email?: string\n startsAtMs: number\n endsAtMs: number\n /** The `crm` query value the link carried, kept on the row. */\n crmRef?: string | null\n }\n /** The service as stored: its two CRM switches and its timezone. */\n service:\n | {\n name?: string\n timezone?: string\n crmFollowUpTask?: boolean\n crmMeetingActivity?: boolean\n }\n | null\n | undefined\n /**\n * The host document's plugin fields, when the caller already holds them;\n * read here otherwise. The deny-list is per site, and a site that has\n * switched the CRM off must file nothing.\n */\n host?: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null\n}\n\nexport type CrmBookingRecordOutcome =\n | {\n filed: false\n reason:\n /** The CRM plugin does not run on this site. */\n | 'crm-off'\n /**\n * The org's plan does not carry the CRM suite, whose activities and\n * tasks these are (AGL-2787).\n */\n | 'not-entitled'\n /** Neither the reference nor the address named a record. */\n | 'no-record'\n /** A meeting for this booking is already on the timeline. */\n | 'already-filed'\n /** The service asks for neither a meeting nor a follow-up. */\n | 'nothing-to-file'\n /** A read or a write threw; logged, and nothing is promised. */\n | 'failed'\n }\n | {\n filed: true\n /** How the record was found. */\n matchedBy: 'crmRef' | 'email'\n link: CrmActivityLink\n /** The meeting's id, or `null` when the service files no meeting. */\n activityId: string | null\n /** The follow-up's id, or `null` when the service asks for none. */\n taskId: string | null\n }\n\ninterface ResolvedRecord {\n link: CrmActivityLink\n matchedBy: 'crmRef' | 'email'\n /** Whoever holds the relationship, for the follow-up's assignee. */\n ownerUid?: string\n}\n\n/**\n * The record a booking belongs to — the reference first, the address second.\n */\nasync function resolveBookingRecord(\n firestore: FirebaseFirestore.Firestore,\n input: CrmBookingRecordInput,\n groupId: string,\n): Promise<ResolvedRecord | null> {\n const { hostId, orgId } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contactsRef = orgRef.collection('contacts')\n const ref = parseCrmBookingRef(input.booking.crmRef)\n if (ref?.kind === 'contact') {\n const contact = await contactsRef.doc(ref.id).get()\n if (contact.exists && visibleToHost(contact.get('visibleTo'), hostId)) {\n const facet = readContactFacet(contact.data() ?? {}, groupId)\n return {\n link: {\n contactId: contact.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n },\n matchedBy: 'crmRef',\n ...(facet.ownerUid ? { ownerUid: facet.ownerUid } : {}),\n }\n }\n }\n if (ref?.kind === 'deal') {\n const deal = await orgRef.collection(CRM_COLLECTIONS.deals).doc(ref.id).get()\n if (deal.exists && visibleToHost(deal.get('visibleTo'), hostId)) {\n const contactId = String(deal.get('contactId') ?? '')\n const companyId = String(deal.get('companyId') ?? '')\n const ownerUid = String(deal.get('ownerUid') ?? '')\n return {\n link: {\n dealId: deal.id,\n ...(contactId ? { contactId } : {}),\n ...(companyId ? { companyId } : {}),\n },\n matchedBy: 'crmRef',\n ...(ownerUid ? { ownerUid } : {}),\n }\n }\n }\n if (ref?.kind === 'lead') {\n /*\n * A lead carries its own `visibleTo` now (AGL-3275) — it used to be\n * host-scoped by path, and the comment here said so. The seam reads the\n * org row, and the site has to be allowed to see it before a booking is\n * filed against it, or one brand's booking would name another's lead.\n */\n const lead = await readLeadForHost(hostId, ref.id)\n if (lead && visibleToHost(lead.get('visibleTo'), hostId)) {\n const ownerUid = String(lead.get('ownerUid') ?? '')\n return {\n link: { leadId: lead.id },\n matchedBy: 'crmRef',\n ...(ownerUid ? { ownerUid } : {}),\n }\n }\n }\n const hit = await findContactByEmail(contactsRef, input.booking.email, { hostId })\n if (!hit) return null\n const facet = readContactFacet(hit.data() ?? {}, groupId)\n return {\n link: {\n contactId: hit.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n },\n matchedBy: 'email',\n ...(facet.ownerUid ? { ownerUid: facet.ownerUid } : {}),\n }\n}\n\n/**\n * Files what a confirmed booking owes the CRM: the meeting, and the\n * follow-up when the service asks for one. See the module comment.\n */\nexport async function recordCrmBooking(\n firestore: FirebaseFirestore.Firestore,\n input: CrmBookingRecordInput,\n): Promise<CrmBookingRecordOutcome> {\n const { hostId, orgId, booking, service } = input\n try {\n if (!hostId || !orgId || !booking?.id) return { filed: false, reason: 'failed' }\n const wantsMeeting = service?.crmMeetingActivity !== false\n const wantsTask = service?.crmFollowUpTask === true\n if (!wantsMeeting && !wantsTask) return { filed: false, reason: 'nothing-to-file' }\n\n const host =\n input.host === undefined\n ? ((await firestore.collection('hosts').doc(hostId).get()).data() as\n | { disabledPlugins?: string[]; enabledPlugins?: string[] }\n | undefined) ?? null\n : input.host\n if (!isHostPluginEnabled(input.org ?? null, host, CRM_PLUGIN_ID)) {\n return { filed: false, reason: 'crm-off' }\n }\n if (!checkEntitlement(input.org as Parameters<typeof checkEntitlement>[0], 'crm')) {\n return { filed: false, reason: 'not-entitled' }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n const activities = orgRef.collection(CRM_COLLECTIONS.activities)\n // One meeting per booking, whatever path re-enters: the paid webhook is\n // redelivered for days after any 500, and its own guard is the status,\n // which this row is not part of.\n const already = await activities.where('bookingId', '==', booking.id).limit(1).get()\n if (!already.empty) return { filed: false, reason: 'already-filed' }\n\n const group = consentGroupForHost(input.org ?? null, hostId)\n const record = await resolveBookingRecord(firestore, input, group.groupId)\n if (!record) return { filed: false, reason: 'no-record' }\n\n const visibleTo = crmScopeTokens(input.org ?? null, group)\n const serviceName = String(service?.name ?? booking.serviceName ?? '').trim()\n const timezone = service?.timezone || undefined\n\n let activityId: string | null = null\n if (wantsMeeting) {\n // The per-record ceiling (AGL-2611): a full log refuses the meeting\n // but not the follow-up — a task is owed either way.\n const logged = await countCrmActivitiesForRecord(orgRef, record.link)\n if (crmActivityLogHasRoom(logged)) {\n const activity: CrmActivity = {\n kind: 'meeting',\n body: crmBookingMeetingBody({\n serviceName,\n startsAtMs: booking.startsAtMs,\n timezone,\n }),\n // When it HAPPENS, which is the slot — not when it was booked.\n atMs: booking.startsAtMs,\n byUid: '',\n ...record.link,\n bookingId: booking.id,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const written = await activities.add(activity)\n activityId = written.id\n }\n }\n\n let taskId: string | null = null\n if (wantsTask) {\n const task: CrmTask = {\n title: crmBookingFollowUpTitle(serviceName),\n kind: 'todo',\n priority: 'normal',\n status: 'open',\n dueAtMs: crmBookingFollowUpDueMs(booking.endsAtMs, timezone),\n ...(record.ownerUid ? { assigneeUid: record.ownerUid } : {}),\n createdByUid: '',\n ...record.link,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n const written = await orgRef\n .collection(CRM_COLLECTIONS.tasks)\n .add({ ...task, ...crmTaskListFields(task) })\n taskId = written.id\n }\n\n return { filed: true, matchedBy: record.matchedBy, link: record.link, activityId, taskId }\n } catch (error) {\n console.error('[crm] booking could not be filed', hostId, booking?.id, error)\n return { filed: false, reason: 'failed' }\n }\n}\n"],"names":["checkEntitlement","consentGroupForHost","CRM_COLLECTIONS","crmTaskListFields","crmActivityLogHasRoom","crmBookingFollowUpDueMs","crmBookingFollowUpTitle","crmBookingMeetingBody","crmScopeTokens","isHostPluginEnabled","parseCrmBookingRef","readContactFacet","visibleToHost","FieldValue","findContactByEmail","readLeadForHost","countCrmActivitiesForRecord","CRM_PLUGIN_ID","resolveBookingRecord","firestore","input","groupId","hit","hostId","orgId","orgRef","collection","doc","contactsRef","ref","booking","crmRef","kind","contact","id","get","exists","facet","data","link","contactId","companyId","matchedBy","ownerUid","deal","deals","String","dealId","lead","leadId","email","recordCrmBooking","service","filed","reason","wantsMeeting","crmMeetingActivity","wantsTask","crmFollowUpTask","host","undefined","org","activities","already","where","limit","empty","group","record","visibleTo","serviceName","name","trim","timezone","activityId","logged","activity","body","startsAtMs","atMs","byUid","bookingId","createdAt","serverTimestamp","updatedAt","written","add","taskId","task","title","priority","status","dueAtMs","endsAtMs","assigneeUid","createdByUid","tasks","error","console"],"mappings":"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"}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2026 Aglyn LLC
|
|
4
|
-
*
|
|
5
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
* you may not use this file except in compliance with the License.
|
|
7
|
-
* You may obtain a copy of the License at
|
|
8
|
-
*
|
|
9
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
*
|
|
11
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
* See the License for the specific language governing permissions and
|
|
15
|
-
* limitations under the License.
|
|
16
|
-
*/
|
|
17
|
-
import { type CrmActivityLink, type CrmEmailDirection, type CrmInboundCandidate, type CrmInboundMember } from '@aglyn/aglyn/server';
|
|
18
|
-
import type { ReceivedEmail } from '@aglyn/shared-util-email';
|
|
19
|
-
/**
|
|
20
|
-
* EMAIL CAPTURE (AGL-2657): the half with Firestore.
|
|
21
|
-
*
|
|
22
|
-
* The org's capture token — minted once, replaced on a rotation, found
|
|
23
|
-
* again from a message's recipient — and the filing of one received
|
|
24
|
-
* message: which record it was with, whether it has been filed before, and
|
|
25
|
-
* the row it becomes. The pure rules are `@aglyn/aglyn`'s
|
|
26
|
-
* `crm-inbound.ts`; this module is the reads and the one write.
|
|
27
|
-
*
|
|
28
|
-
* Firestore is a parameter throughout, as in `crm-records.ts` and
|
|
29
|
-
* `crm-email-activity.ts`: the routes hold one, and a module that reached
|
|
30
|
-
* for the Admin app itself would drag it into every spec that only wants
|
|
31
|
-
* the matching. The roster is read through that same store by
|
|
32
|
-
* `loadCrmInboundRoster`, and the caller writes the org's feed line, so a
|
|
33
|
-
* spec of this module needs a store and nothing else.
|
|
34
|
-
*/
|
|
35
|
-
/**
|
|
36
|
-
* The workspace's email capture address, as the org document carries it
|
|
37
|
-
* under `crmInbound` (AGL-2657): `crm+<token>@<capture domain>`.
|
|
38
|
-
*
|
|
39
|
-
* One token per organization, minted lazily by `crm/inbound-address` the
|
|
40
|
-
* first time a member asks for the address, and replaced by the same route
|
|
41
|
-
* on a rotation. Every site of the organization shares it: a captured
|
|
42
|
-
* message is filed on the record that matches its correspondent, and the
|
|
43
|
-
* record says which site it belongs to, so the address need not.
|
|
44
|
-
*
|
|
45
|
-
* The CRM's field, not the platform's: the organization type does not name
|
|
46
|
-
* it, and this module is its only reader and writer. Server-owned and
|
|
47
|
-
* member-readable: the token IS the address, so a client that could write
|
|
48
|
-
* it could point another workspace's mail here.
|
|
49
|
-
*/
|
|
50
|
-
export interface OrgCrmInbound {
|
|
51
|
-
/** The url-safe secret in the local part; `crm+<token>@…`. */
|
|
52
|
-
token: string;
|
|
53
|
-
/** When the first token was minted, epoch ms. */
|
|
54
|
-
createdAtMs: number;
|
|
55
|
-
/** When the token was last replaced, epoch ms; absent until it has been. */
|
|
56
|
-
rotatedAtMs?: number;
|
|
57
|
-
}
|
|
58
|
-
/** The most candidate addresses one message is matched by. */
|
|
59
|
-
export declare const CRM_INBOUND_CANDIDATE_MAX = 6;
|
|
60
|
-
/** The most sites whose leads are consulted for one message. */
|
|
61
|
-
export declare const CRM_INBOUND_LEAD_HOST_MAX = 50;
|
|
62
|
-
export interface CrmInboundTokenResult {
|
|
63
|
-
token: string;
|
|
64
|
-
createdAtMs: number;
|
|
65
|
-
rotatedAtMs?: number;
|
|
66
|
-
/** True when this call replaced a token that existed. */
|
|
67
|
-
rotated: boolean;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* The org's capture token, minted the first time anybody asks and kept
|
|
71
|
-
* thereafter; with `rotate`, replaced. A transaction, so two members
|
|
72
|
-
* opening the settings page at once mint one token and not two, and a
|
|
73
|
-
* rotation lands on the token the reader saw.
|
|
74
|
-
*/
|
|
75
|
-
export declare function ensureCrmInboundToken(firestore: FirebaseFirestore.Firestore, orgId: string, options?: {
|
|
76
|
-
rotate?: boolean;
|
|
77
|
-
nowMs?: number;
|
|
78
|
-
}): Promise<CrmInboundTokenResult>;
|
|
79
|
-
/**
|
|
80
|
-
* The org a capture token names, or `null`. One equality on
|
|
81
|
-
* `crmInbound.token`, which Firestore indexes on its own; a token that is
|
|
82
|
-
* not one by shape is refused before the query, so a malformed local part
|
|
83
|
-
* costs no read.
|
|
84
|
-
*/
|
|
85
|
-
export declare function findOrgByCrmInboundToken(firestore: FirebaseFirestore.Firestore, token: unknown): Promise<{
|
|
86
|
-
orgId: string;
|
|
87
|
-
org: Record<string, unknown>;
|
|
88
|
-
} | null>;
|
|
89
|
-
/**
|
|
90
|
-
* Every site of the organization, by id, up to the ceiling — the set a
|
|
91
|
-
* message's lead lookup sweeps. One equality on `hosts.orgId`.
|
|
92
|
-
*/
|
|
93
|
-
export declare function crmInboundHostIds(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<string[]>;
|
|
94
|
-
/** The record one of a message's addresses resolved to. */
|
|
95
|
-
export interface CrmInboundMatch {
|
|
96
|
-
email: string;
|
|
97
|
-
direction: CrmEmailDirection;
|
|
98
|
-
kind: 'contact' | 'lead';
|
|
99
|
-
/** Where the row is filed. */
|
|
100
|
-
link: CrmActivityLink;
|
|
101
|
-
/** The site the row is stamped with — the record's own. */
|
|
102
|
-
hostId: string;
|
|
103
|
-
/** Who may list the row — the record's own tokens. */
|
|
104
|
-
visibleTo: string[];
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* The first candidate that is a contact of the org — through the address
|
|
108
|
-
* index, so a merged record's alternate address answers — or a lead on any
|
|
109
|
-
* of its sites. Candidates are tried in the order the rule gave them and
|
|
110
|
-
* the first hit wins; a contact is looked for before a lead at each
|
|
111
|
-
* address, because a converted lead's address is its contact's.
|
|
112
|
-
*/
|
|
113
|
-
export declare function matchCrmInboundCorrespondent(firestore: FirebaseFirestore.Firestore, input: {
|
|
114
|
-
orgId: string;
|
|
115
|
-
org: Record<string, unknown>;
|
|
116
|
-
candidates: readonly CrmInboundCandidate[];
|
|
117
|
-
hostIds: readonly string[];
|
|
118
|
-
}): Promise<CrmInboundMatch | null>;
|
|
119
|
-
/**
|
|
120
|
-
* The workspace's roster as the filer matches against it: the member
|
|
121
|
-
* documents and every member's stored alias document, two reads under the
|
|
122
|
-
* org, reduced by `crmInboundRoster` to each member's sign-in address and
|
|
123
|
-
* the aliases they have confirmed (AGL-2975).
|
|
124
|
-
*/
|
|
125
|
-
export declare function loadCrmInboundRoster(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<CrmInboundMember[]>;
|
|
126
|
-
export type CrmInboundFileOutcome =
|
|
127
|
-
/** A row was written. */
|
|
128
|
-
{
|
|
129
|
-
outcome: 'filed';
|
|
130
|
-
activityId: string;
|
|
131
|
-
match: CrmInboundMatch;
|
|
132
|
-
}
|
|
133
|
-
/** The message had been filed before; nothing written. */
|
|
134
|
-
| {
|
|
135
|
-
outcome: 'duplicate';
|
|
136
|
-
activityId: string;
|
|
137
|
-
}
|
|
138
|
-
/** No address on the message is a record of the org; nothing written. */
|
|
139
|
-
| {
|
|
140
|
-
outcome: 'unmatched';
|
|
141
|
-
senderDomain: string | null;
|
|
142
|
-
}
|
|
143
|
-
/** The record's activity log is at its ceiling; nothing written. */
|
|
144
|
-
| {
|
|
145
|
-
outcome: 'ceiling';
|
|
146
|
-
match: CrmInboundMatch;
|
|
147
|
-
};
|
|
148
|
-
/**
|
|
149
|
-
* Files one received message on the record it was with (AGL-2657).
|
|
150
|
-
*
|
|
151
|
-
* The order is the cost: the candidate rule is arithmetic; the match is
|
|
152
|
-
* the reads that decide everything after it; a message filed before is
|
|
153
|
-
* answered off one read of the row it would write, before the ceiling's
|
|
154
|
-
* aggregate; the ceiling comes last and refuses rather than dropping a row
|
|
155
|
-
* on a full log. The write is a `create`, so two deliveries racing past
|
|
156
|
-
* the read still yield one row. The BODY never leaves this function except
|
|
157
|
-
* as the bounded excerpt in the row.
|
|
158
|
-
*/
|
|
159
|
-
export declare function fileCrmInboundEmail(firestore: FirebaseFirestore.Firestore, input: {
|
|
160
|
-
orgId: string;
|
|
161
|
-
org: Record<string, unknown>;
|
|
162
|
-
message: ReceivedEmail;
|
|
163
|
-
/** The capture domain, so the capture address is never the correspondent. */
|
|
164
|
-
domain: string;
|
|
165
|
-
/** The roster, from `loadCrmInboundRoster`: sign-in addresses and confirmed aliases. */
|
|
166
|
-
members: readonly CrmInboundMember[];
|
|
167
|
-
hostIds: readonly string[];
|
|
168
|
-
}): Promise<CrmInboundFileOutcome>;
|
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
/**
|
|
3
|
-
* @license
|
|
4
|
-
* Copyright 2026 Aglyn LLC
|
|
5
|
-
*
|
|
6
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
-
* you may not use this file except in compliance with the License.
|
|
8
|
-
* You may obtain a copy of the License at
|
|
9
|
-
*
|
|
10
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
-
*
|
|
12
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
-
* See the License for the specific language governing permissions and
|
|
16
|
-
* limitations under the License.
|
|
17
|
-
*/ import { buildCrmCapturedEmailActivity, consentGroupForHost, contactCaptureHostIds, crmActivityLogHasRoom, crmCapturedEmailKey, crmInboundCandidates, crmInboundExcerpt, crmInboundRoster, crmScopeTokens, emailAddressOf, emailDomainOf, findMemberByEmailAddress, forwardedSection, htmlToPlainText, isCrmInboundAddress, isCrmInboundToken, mintCrmInboundToken, personKey, readContactFacet } from "@aglyn/aglyn/server";
|
|
18
|
-
import { MEMBER_EMAIL_ALIASES_COLLECTION } from "@aglyn/aglyn/app-utils/member-email-aliases";
|
|
19
|
-
import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
20
|
-
import { findContactByEmail } from "./contact-email-index.js";
|
|
21
|
-
import { createCrmEmailActivity, crmCapturedEmailActivityRef, newCrmActivityRef } from "./crm-email-activity.js";
|
|
22
|
-
import { countCrmActivitiesForRecord } from "./crm-records.js";
|
|
23
|
-
/** The most candidate addresses one message is matched by. */ export const CRM_INBOUND_CANDIDATE_MAX = 6;
|
|
24
|
-
/** The most sites whose leads are consulted for one message. */ export const CRM_INBOUND_LEAD_HOST_MAX = 50;
|
|
25
|
-
/**
|
|
26
|
-
* The org's capture token, minted the first time anybody asks and kept
|
|
27
|
-
* thereafter; with `rotate`, replaced. A transaction, so two members
|
|
28
|
-
* opening the settings page at once mint one token and not two, and a
|
|
29
|
-
* rotation lands on the token the reader saw.
|
|
30
|
-
*/ export async function ensureCrmInboundToken(firestore, orgId, options = {}) {
|
|
31
|
-
var _options_nowMs;
|
|
32
|
-
const rotate = options.rotate === true;
|
|
33
|
-
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
34
|
-
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
35
|
-
return firestore.runTransaction(async (tx)=>{
|
|
36
|
-
var _snapshot_get;
|
|
37
|
-
const snapshot = await tx.get(orgRef);
|
|
38
|
-
if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`);
|
|
39
|
-
const current = (_snapshot_get = snapshot.get('crmInbound')) != null ? _snapshot_get : null;
|
|
40
|
-
const held = isCrmInboundToken(current == null ? void 0 : current.token);
|
|
41
|
-
if (held && !rotate) {
|
|
42
|
-
const createdAtMs = Number(current == null ? void 0 : current.createdAtMs);
|
|
43
|
-
return _extends({
|
|
44
|
-
token: String(current == null ? void 0 : current.token),
|
|
45
|
-
createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs
|
|
46
|
-
}, typeof (current == null ? void 0 : current.rotatedAtMs) === 'number' ? {
|
|
47
|
-
rotatedAtMs: current.rotatedAtMs
|
|
48
|
-
} : {}, {
|
|
49
|
-
rotated: false
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
const createdAtMs = held && Number.isFinite(Number(current == null ? void 0 : current.createdAtMs)) && Number(current == null ? void 0 : current.createdAtMs) > 0 ? Number(current == null ? void 0 : current.createdAtMs) : nowMs;
|
|
53
|
-
const next = _extends({
|
|
54
|
-
token: mintCrmInboundToken(),
|
|
55
|
-
createdAtMs
|
|
56
|
-
}, held ? {
|
|
57
|
-
rotatedAtMs: nowMs
|
|
58
|
-
} : {});
|
|
59
|
-
tx.update(orgRef, {
|
|
60
|
-
crmInbound: next
|
|
61
|
-
});
|
|
62
|
-
return _extends({}, next, {
|
|
63
|
-
rotated: held
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* The org a capture token names, or `null`. One equality on
|
|
69
|
-
* `crmInbound.token`, which Firestore indexes on its own; a token that is
|
|
70
|
-
* not one by shape is refused before the query, so a malformed local part
|
|
71
|
-
* costs no read.
|
|
72
|
-
*/ export async function findOrgByCrmInboundToken(firestore, token) {
|
|
73
|
-
var _doc_data;
|
|
74
|
-
if (!isCrmInboundToken(token)) return null;
|
|
75
|
-
const page = await firestore.collection('orgs').where('crmInbound.token', '==', token).limit(1).get();
|
|
76
|
-
const doc = page.docs[0];
|
|
77
|
-
if (!doc) return null;
|
|
78
|
-
return {
|
|
79
|
-
orgId: doc.id,
|
|
80
|
-
org: _extends({
|
|
81
|
-
$id: doc.id
|
|
82
|
-
}, (_doc_data = doc.data()) != null ? _doc_data : {})
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Every site of the organization, by id, up to the ceiling — the set a
|
|
87
|
-
* message's lead lookup sweeps. One equality on `hosts.orgId`.
|
|
88
|
-
*/ export async function crmInboundHostIds(firestore, orgId) {
|
|
89
|
-
const page = await firestore.collection('hosts').where('orgId', '==', orgId).limit(CRM_INBOUND_LEAD_HOST_MAX).get();
|
|
90
|
-
return page.docs.map((doc)=>String(doc.id));
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* The first candidate that is a contact of the org — through the address
|
|
94
|
-
* index, so a merged record's alternate address answers — or a lead on any
|
|
95
|
-
* of its sites. Candidates are tried in the order the rule gave them and
|
|
96
|
-
* the first hit wins; a contact is looked for before a lead at each
|
|
97
|
-
* address, because a converted lead's address is its contact's.
|
|
98
|
-
*/ export async function matchCrmInboundCorrespondent(firestore, input) {
|
|
99
|
-
const { orgId, org } = input;
|
|
100
|
-
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
101
|
-
const contacts = orgRef.collection('contacts');
|
|
102
|
-
const tokensOf = (record)=>{
|
|
103
|
-
const raw = record['visibleTo'];
|
|
104
|
-
return Array.isArray(raw) ? raw.map((token)=>String(token)) : [];
|
|
105
|
-
};
|
|
106
|
-
for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)){
|
|
107
|
-
const contact = await findContactByEmail(contacts, candidate.email);
|
|
108
|
-
if (contact) {
|
|
109
|
-
var _contact_data, _record_hostId;
|
|
110
|
-
const record = (_contact_data = contact.data()) != null ? _contact_data : {};
|
|
111
|
-
const hostId = String((_record_hostId = record['hostId']) != null ? _record_hostId : '').trim() || contactCaptureHostIds(record)[0] || '';
|
|
112
|
-
const link = {
|
|
113
|
-
contactId: contact.id
|
|
114
|
-
};
|
|
115
|
-
if (hostId) {
|
|
116
|
-
// The company the capturing site files this person under, so the
|
|
117
|
-
// row shows on the company's log as a sent email's does.
|
|
118
|
-
const group = consentGroupForHost(org, hostId);
|
|
119
|
-
const facet = readContactFacet(record, group.groupId);
|
|
120
|
-
if (facet.companyId) link.companyId = facet.companyId;
|
|
121
|
-
}
|
|
122
|
-
return {
|
|
123
|
-
email: candidate.email,
|
|
124
|
-
direction: candidate.direction,
|
|
125
|
-
kind: 'contact',
|
|
126
|
-
link,
|
|
127
|
-
hostId,
|
|
128
|
-
visibleTo: tokensOf(record)
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
const key = personKey(candidate.email);
|
|
132
|
-
if (!key) continue;
|
|
133
|
-
/*
|
|
134
|
-
* ONE ROW, then which of the named sites may see it (AGL-3275).
|
|
135
|
-
*
|
|
136
|
-
* This used to read a lead per site because there was one per site. There
|
|
137
|
-
* is one now, so the read is single and the LOOP is what it always really
|
|
138
|
-
* was: deciding whose mailbox this reply belongs to. A site that cannot
|
|
139
|
-
* see the row is not that site, which is what `visibleToHost` answers —
|
|
140
|
-
* and skipping it is what keeps an agency's clients apart on a shared row.
|
|
141
|
-
*/ const lead = await orgRef.collection('leads').doc(key).get();
|
|
142
|
-
for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)){
|
|
143
|
-
var _lead_get;
|
|
144
|
-
if (!lead.exists) break;
|
|
145
|
-
if (!visibleToHost(lead.get('visibleTo'), hostId)) continue;
|
|
146
|
-
const link = {
|
|
147
|
-
leadId: lead.id
|
|
148
|
-
};
|
|
149
|
-
// A converted lead's mail belongs on the contact it became as well.
|
|
150
|
-
const converted = String((_lead_get = lead.get('convertedContactId')) != null ? _lead_get : '').trim();
|
|
151
|
-
if (converted) link.contactId = converted;
|
|
152
|
-
return {
|
|
153
|
-
email: candidate.email,
|
|
154
|
-
direction: candidate.direction,
|
|
155
|
-
kind: 'lead',
|
|
156
|
-
link,
|
|
157
|
-
hostId,
|
|
158
|
-
// A lead carries no tokens of its own: the site's scope, as every
|
|
159
|
-
// record created from that site is stamped.
|
|
160
|
-
visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId))
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* The workspace's roster as the filer matches against it: the member
|
|
168
|
-
* documents and every member's stored alias document, two reads under the
|
|
169
|
-
* org, reduced by `crmInboundRoster` to each member's sign-in address and
|
|
170
|
-
* the aliases they have confirmed (AGL-2975).
|
|
171
|
-
*/ export async function loadCrmInboundRoster(firestore, orgId) {
|
|
172
|
-
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
173
|
-
const [members, aliases] = await Promise.all([
|
|
174
|
-
orgRef.collection('members').get(),
|
|
175
|
-
orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).get()
|
|
176
|
-
]);
|
|
177
|
-
const rows = members.docs.map((doc)=>{
|
|
178
|
-
var _doc_data;
|
|
179
|
-
return _extends({}, (_doc_data = doc.data()) != null ? _doc_data : {}, {
|
|
180
|
-
$id: doc.id
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
const aliasDocuments = new Map(aliases.docs.map((doc)=>[
|
|
184
|
-
String(doc.id),
|
|
185
|
-
doc.data()
|
|
186
|
-
]));
|
|
187
|
-
return crmInboundRoster(rows, aliasDocuments);
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Files one received message on the record it was with (AGL-2657).
|
|
191
|
-
*
|
|
192
|
-
* The order is the cost: the candidate rule is arithmetic; the match is
|
|
193
|
-
* the reads that decide everything after it; a message filed before is
|
|
194
|
-
* answered off one read of the row it would write, before the ceiling's
|
|
195
|
-
* aggregate; the ceiling comes last and refuses rather than dropping a row
|
|
196
|
-
* on a full log. The write is a `create`, so two deliveries racing past
|
|
197
|
-
* the read still yield one row. The BODY never leaves this function except
|
|
198
|
-
* as the bounded excerpt in the row.
|
|
199
|
-
*/ export async function fileCrmInboundEmail(firestore, input) {
|
|
200
|
-
var _ref, _message_to_map_find, _ref1, _ref2;
|
|
201
|
-
const { orgId, org, message, domain } = input;
|
|
202
|
-
const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(/\r\n?/g, '\n');
|
|
203
|
-
const forwarded = forwardedSection(plain);
|
|
204
|
-
const { sender, senderIsMember, candidates } = crmInboundCandidates({
|
|
205
|
-
from: message.from,
|
|
206
|
-
to: [
|
|
207
|
-
...message.to,
|
|
208
|
-
...message.receivedFor
|
|
209
|
-
],
|
|
210
|
-
cc: [
|
|
211
|
-
...message.cc,
|
|
212
|
-
...message.bcc
|
|
213
|
-
],
|
|
214
|
-
forwardedFrom: (_ref = forwarded == null ? void 0 : forwarded.from) != null ? _ref : undefined,
|
|
215
|
-
domain,
|
|
216
|
-
members: input.members
|
|
217
|
-
});
|
|
218
|
-
const senderDomain = emailDomainOf(message.from);
|
|
219
|
-
if (!candidates.length) return {
|
|
220
|
-
outcome: 'unmatched',
|
|
221
|
-
senderDomain
|
|
222
|
-
};
|
|
223
|
-
const match = await matchCrmInboundCorrespondent(firestore, {
|
|
224
|
-
orgId,
|
|
225
|
-
org,
|
|
226
|
-
candidates,
|
|
227
|
-
hostIds: input.hostIds
|
|
228
|
-
});
|
|
229
|
-
if (!match) return {
|
|
230
|
-
outcome: 'unmatched',
|
|
231
|
-
senderDomain
|
|
232
|
-
};
|
|
233
|
-
const key = crmCapturedEmailKey(message.messageId, message.id);
|
|
234
|
-
const ref = key ? crmCapturedEmailActivityRef(firestore, orgId, key) : newCrmActivityRef(firestore, orgId);
|
|
235
|
-
if (key) {
|
|
236
|
-
const existing = await ref.get();
|
|
237
|
-
if (existing.exists) return {
|
|
238
|
-
outcome: 'duplicate',
|
|
239
|
-
activityId: ref.id
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
243
|
-
if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {
|
|
244
|
-
return {
|
|
245
|
-
outcome: 'ceiling',
|
|
246
|
-
match
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
// The member the From is — by sign-in address, or by an alias exactly one
|
|
250
|
-
// member confirmed. A shared alias names the team and no one person.
|
|
251
|
-
const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null;
|
|
252
|
-
// The address the message was written to, when it is worth showing: the
|
|
253
|
-
// correspondent for a copied send, else the first recipient that is not
|
|
254
|
-
// the capture address.
|
|
255
|
-
const to = match.direction === 'outbound' ? match.email : (_message_to_map_find = message.to.map((value)=>emailAddressOf(value)).find((value)=>value && !isCrmInboundAddress(value, domain))) != null ? _message_to_map_find : null;
|
|
256
|
-
const activity = buildCrmCapturedEmailActivity({
|
|
257
|
-
direction: match.direction,
|
|
258
|
-
subject: message.subject,
|
|
259
|
-
excerpt: crmInboundExcerpt(message.text, message.html),
|
|
260
|
-
from: sender != null ? sender : message.from,
|
|
261
|
-
to,
|
|
262
|
-
messageId: message.messageId || message.id,
|
|
263
|
-
inReplyTo: message.inReplyTo,
|
|
264
|
-
atMs: message.receivedAtMs,
|
|
265
|
-
byUid: (_ref1 = author == null ? void 0 : author.uid) != null ? _ref1 : '',
|
|
266
|
-
byName: (_ref2 = author == null ? void 0 : author.name) != null ? _ref2 : null,
|
|
267
|
-
link: match.link,
|
|
268
|
-
hostId: match.hostId,
|
|
269
|
-
visibleTo: match.visibleTo
|
|
270
|
-
});
|
|
271
|
-
const created = await createCrmEmailActivity(ref, activity);
|
|
272
|
-
return created === 'created' ? {
|
|
273
|
-
outcome: 'filed',
|
|
274
|
-
activityId: ref.id,
|
|
275
|
-
match
|
|
276
|
-
} : {
|
|
277
|
-
outcome: 'duplicate',
|
|
278
|
-
activityId: ref.id
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
//# sourceMappingURL=crm-inbound-email.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-inbound-email.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 buildCrmCapturedEmailActivity,\n consentGroupForHost,\n contactCaptureHostIds,\n crmActivityLogHasRoom,\n crmCapturedEmailKey,\n crmInboundCandidates,\n crmInboundExcerpt,\n crmInboundRoster,\n type CrmActivityLink,\n type CrmEmailDirection,\n type CrmInboundCandidate,\n type CrmInboundMember,\n type CrmInboundRosterRow,\n crmScopeTokens,\n emailAddressOf,\n emailDomainOf,\n findMemberByEmailAddress,\n forwardedSection,\n htmlToPlainText,\n isCrmInboundAddress,\n isCrmInboundToken,\n mintCrmInboundToken,\n personKey,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { MEMBER_EMAIL_ALIASES_COLLECTION } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport type { ReceivedEmail } from '@aglyn/shared-util-email'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from './contact-email-index'\nimport {\n createCrmEmailActivity,\n crmCapturedEmailActivityRef,\n newCrmActivityRef,\n} from './crm-email-activity'\nimport { countCrmActivitiesForRecord } from './crm-records'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the half with Firestore.\n *\n * The org's capture token — minted once, replaced on a rotation, found\n * again from a message's recipient — and the filing of one received\n * message: which record it was with, whether it has been filed before, and\n * the row it becomes. The pure rules are `@aglyn/aglyn`'s\n * `crm-inbound.ts`; this module is the reads and the one write.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts` and\n * `crm-email-activity.ts`: the routes hold one, and a module that reached\n * for the Admin app itself would drag it into every spec that only wants\n * the matching. The roster is read through that same store by\n * `loadCrmInboundRoster`, and the caller writes the org's feed line, so a\n * spec of this module needs a store and nothing else.\n */\n\n/**\n * The workspace's email capture address, as the org document carries it\n * under `crmInbound` (AGL-2657): `crm+<token>@<capture domain>`.\n *\n * One token per organization, minted lazily by `crm/inbound-address` the\n * first time a member asks for the address, and replaced by the same route\n * on a rotation. Every site of the organization shares it: a captured\n * message is filed on the record that matches its correspondent, and the\n * record says which site it belongs to, so the address need not.\n *\n * The CRM's field, not the platform's: the organization type does not name\n * it, and this module is its only reader and writer. Server-owned and\n * member-readable: the token IS the address, so a client that could write\n * it could point another workspace's mail here.\n */\nexport interface OrgCrmInbound {\n /** The url-safe secret in the local part; `crm+<token>@…`. */\n token: string\n /** When the first token was minted, epoch ms. */\n createdAtMs: number\n /** When the token was last replaced, epoch ms; absent until it has been. */\n rotatedAtMs?: number\n}\n\n/** The most candidate addresses one message is matched by. */\nexport const CRM_INBOUND_CANDIDATE_MAX = 6\n\n/** The most sites whose leads are consulted for one message. */\nexport const CRM_INBOUND_LEAD_HOST_MAX = 50\n\nexport interface CrmInboundTokenResult {\n token: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced a token that existed. */\n rotated: boolean\n}\n\n/**\n * The org's capture token, minted the first time anybody asks and kept\n * thereafter; with `rotate`, replaced. A transaction, so two members\n * opening the settings page at once mint one token and not two, and a\n * rotation lands on the token the reader saw.\n */\nexport async function ensureCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n options: { rotate?: boolean; nowMs?: number } = {},\n): Promise<CrmInboundTokenResult> {\n const rotate = options.rotate === true\n const nowMs = options.nowMs ?? Date.now()\n const orgRef = firestore.collection('orgs').doc(orgId)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(orgRef)\n if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`)\n const current = (snapshot.get('crmInbound') ?? null) as Partial<OrgCrmInbound> | null\n const held = isCrmInboundToken(current?.token)\n if (held && !rotate) {\n const createdAtMs = Number(current?.createdAtMs)\n return {\n token: String(current?.token),\n createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs,\n ...(typeof current?.rotatedAtMs === 'number' ? { rotatedAtMs: current.rotatedAtMs } : {}),\n rotated: false,\n }\n }\n const createdAtMs =\n held && Number.isFinite(Number(current?.createdAtMs)) && Number(current?.createdAtMs) > 0\n ? Number(current?.createdAtMs)\n : nowMs\n const next: OrgCrmInbound = {\n token: mintCrmInboundToken(),\n createdAtMs,\n ...(held ? { rotatedAtMs: nowMs } : {}),\n }\n tx.update(orgRef, { crmInbound: next })\n return { ...next, rotated: held }\n })\n}\n\n/**\n * The org a capture token names, or `null`. One equality on\n * `crmInbound.token`, which Firestore indexes on its own; a token that is\n * not one by shape is refused before the query, so a malformed local part\n * costs no read.\n */\nexport async function findOrgByCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n token: unknown,\n): Promise<{ orgId: string; org: Record<string, unknown> } | null> {\n if (!isCrmInboundToken(token)) return null\n const page = await firestore\n .collection('orgs')\n .where('crmInbound.token', '==', token)\n .limit(1)\n .get()\n const doc = page.docs[0]\n if (!doc) return null\n return { orgId: doc.id, org: { $id: doc.id, ...(doc.data() ?? {}) } }\n}\n\n/**\n * Every site of the organization, by id, up to the ceiling — the set a\n * message's lead lookup sweeps. One equality on `hosts.orgId`.\n */\nexport async function crmInboundHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const page = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(CRM_INBOUND_LEAD_HOST_MAX)\n .get()\n return page.docs.map((doc) => String(doc.id))\n}\n\n/** The record one of a message's addresses resolved to. */\nexport interface CrmInboundMatch {\n email: string\n direction: CrmEmailDirection\n kind: 'contact' | 'lead'\n /** Where the row is filed. */\n link: CrmActivityLink\n /** The site the row is stamped with — the record's own. */\n hostId: string\n /** Who may list the row — the record's own tokens. */\n visibleTo: string[]\n}\n\n/**\n * The first candidate that is a contact of the org — through the address\n * index, so a merged record's alternate address answers — or a lead on any\n * of its sites. Candidates are tried in the order the rule gave them and\n * the first hit wins; a contact is looked for before a lead at each\n * address, because a converted lead's address is its contact's.\n */\nexport async function matchCrmInboundCorrespondent(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n candidates: readonly CrmInboundCandidate[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundMatch | null> {\n const { orgId, org } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contacts = orgRef.collection('contacts')\n const tokensOf = (record: Record<string, unknown>): string[] => {\n const raw = record['visibleTo']\n return Array.isArray(raw) ? raw.map((token) => String(token)) : []\n }\n for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)) {\n const contact = await findContactByEmail(contacts, candidate.email)\n if (contact) {\n const record = (contact.data() ?? {}) as Record<string, unknown>\n const hostId =\n String(record['hostId'] ?? '').trim() || contactCaptureHostIds(record)[0] || ''\n const link: CrmActivityLink = { contactId: contact.id }\n if (hostId) {\n // The company the capturing site files this person under, so the\n // row shows on the company's log as a sent email's does.\n const group = consentGroupForHost(org, hostId)\n const facet = readContactFacet(record, group.groupId)\n if (facet.companyId) link.companyId = facet.companyId\n }\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'contact',\n link,\n hostId,\n visibleTo: tokensOf(record),\n }\n }\n const key = personKey(candidate.email)\n if (!key) continue\n /*\n * ONE ROW, then which of the named sites may see it (AGL-3275).\n *\n * This used to read a lead per site because there was one per site. There\n * is one now, so the read is single and the LOOP is what it always really\n * was: deciding whose mailbox this reply belongs to. A site that cannot\n * see the row is not that site, which is what `visibleToHost` answers —\n * and skipping it is what keeps an agency's clients apart on a shared row.\n */\n const lead = await orgRef.collection('leads').doc(key).get()\n for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)) {\n if (!lead.exists) break\n if (!visibleToHost(lead.get('visibleTo') as string[] | undefined, hostId)) continue\n const link: CrmActivityLink = { leadId: lead.id }\n // A converted lead's mail belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'lead',\n link,\n hostId,\n // A lead carries no tokens of its own: the site's scope, as every\n // record created from that site is stamped.\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n }\n return null\n}\n\n/**\n * The workspace's roster as the filer matches against it: the member\n * documents and every member's stored alias document, two reads under the\n * org, reduced by `crmInboundRoster` to each member's sign-in address and\n * the aliases they have confirmed (AGL-2975).\n */\nexport async function loadCrmInboundRoster(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<CrmInboundMember[]> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [members, aliases] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).get(),\n ])\n const rows: CrmInboundRosterRow[] = members.docs.map((doc) => ({\n ...((doc.data() ?? {}) as Omit<CrmInboundRosterRow, '$id'>),\n $id: doc.id,\n }))\n const aliasDocuments = new Map<string, unknown>(\n aliases.docs.map((doc) => [String(doc.id), doc.data()]),\n )\n return crmInboundRoster(rows, aliasDocuments)\n}\n\nexport type CrmInboundFileOutcome =\n /** A row was written. */\n | { outcome: 'filed'; activityId: string; match: CrmInboundMatch }\n /** The message had been filed before; nothing written. */\n | { outcome: 'duplicate'; activityId: string }\n /** No address on the message is a record of the org; nothing written. */\n | { outcome: 'unmatched'; senderDomain: string | null }\n /** The record's activity log is at its ceiling; nothing written. */\n | { outcome: 'ceiling'; match: CrmInboundMatch }\n\n/**\n * Files one received message on the record it was with (AGL-2657).\n *\n * The order is the cost: the candidate rule is arithmetic; the match is\n * the reads that decide everything after it; a message filed before is\n * answered off one read of the row it would write, before the ceiling's\n * aggregate; the ceiling comes last and refuses rather than dropping a row\n * on a full log. The write is a `create`, so two deliveries racing past\n * the read still yield one row. The BODY never leaves this function except\n * as the bounded excerpt in the row.\n */\nexport async function fileCrmInboundEmail(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n message: ReceivedEmail\n /** The capture domain, so the capture address is never the correspondent. */\n domain: string\n /** The roster, from `loadCrmInboundRoster`: sign-in addresses and confirmed aliases. */\n members: readonly CrmInboundMember[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundFileOutcome> {\n const { orgId, org, message, domain } = input\n const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(\n /\\r\\n?/g,\n '\\n',\n )\n const forwarded = forwardedSection(plain)\n const { sender, senderIsMember, candidates } = crmInboundCandidates({\n from: message.from,\n to: [...message.to, ...message.receivedFor],\n cc: [...message.cc, ...message.bcc],\n forwardedFrom: forwarded?.from ?? undefined,\n domain,\n members: input.members,\n })\n const senderDomain = emailDomainOf(message.from)\n if (!candidates.length) return { outcome: 'unmatched', senderDomain }\n\n const match = await matchCrmInboundCorrespondent(firestore, {\n orgId,\n org,\n candidates,\n hostIds: input.hostIds,\n })\n if (!match) return { outcome: 'unmatched', senderDomain }\n\n const key = crmCapturedEmailKey(message.messageId, message.id)\n const ref = key\n ? crmCapturedEmailActivityRef(firestore, orgId, key)\n : newCrmActivityRef(firestore, orgId)\n if (key) {\n const existing = await ref.get()\n if (existing.exists) return { outcome: 'duplicate', activityId: ref.id }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {\n return { outcome: 'ceiling', match }\n }\n\n // The member the From is — by sign-in address, or by an alias exactly one\n // member confirmed. A shared alias names the team and no one person.\n const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null\n // The address the message was written to, when it is worth showing: the\n // correspondent for a copied send, else the first recipient that is not\n // the capture address.\n const to =\n match.direction === 'outbound'\n ? match.email\n : (message.to\n .map((value) => emailAddressOf(value))\n .find((value) => value && !isCrmInboundAddress(value, domain)) ?? null)\n const activity = buildCrmCapturedEmailActivity({\n direction: match.direction,\n subject: message.subject,\n excerpt: crmInboundExcerpt(message.text, message.html),\n from: sender ?? message.from,\n to,\n messageId: message.messageId || message.id,\n inReplyTo: message.inReplyTo,\n atMs: message.receivedAtMs,\n byUid: author?.uid ?? '',\n byName: author?.name ?? null,\n link: match.link,\n hostId: match.hostId,\n visibleTo: match.visibleTo,\n })\n const created = await createCrmEmailActivity(ref, activity)\n return created === 'created'\n ? { outcome: 'filed', activityId: ref.id, match }\n : { outcome: 'duplicate', activityId: ref.id }\n}\n"],"names":["buildCrmCapturedEmailActivity","consentGroupForHost","contactCaptureHostIds","crmActivityLogHasRoom","crmCapturedEmailKey","crmInboundCandidates","crmInboundExcerpt","crmInboundRoster","crmScopeTokens","emailAddressOf","emailDomainOf","findMemberByEmailAddress","forwardedSection","htmlToPlainText","isCrmInboundAddress","isCrmInboundToken","mintCrmInboundToken","personKey","readContactFacet","MEMBER_EMAIL_ALIASES_COLLECTION","visibleToHost","findContactByEmail","createCrmEmailActivity","crmCapturedEmailActivityRef","newCrmActivityRef","countCrmActivitiesForRecord","CRM_INBOUND_CANDIDATE_MAX","CRM_INBOUND_LEAD_HOST_MAX","ensureCrmInboundToken","firestore","orgId","options","rotate","nowMs","Date","now","orgRef","collection","doc","runTransaction","tx","snapshot","get","exists","Error","current","held","token","createdAtMs","Number","String","isFinite","rotatedAtMs","rotated","next","update","crmInbound","findOrgByCrmInboundToken","page","where","limit","docs","id","org","$id","data","crmInboundHostIds","map","matchCrmInboundCorrespondent","input","contacts","tokensOf","record","raw","Array","isArray","candidate","candidates","slice","contact","email","hostId","trim","link","contactId","group","facet","groupId","companyId","direction","kind","visibleTo","key","lead","hostIds","leadId","converted","loadCrmInboundRoster","members","aliases","Promise","all","rows","aliasDocuments","Map","fileCrmInboundEmail","message","domain","plain","text","html","replace","forwarded","sender","senderIsMember","from","to","receivedFor","cc","bcc","forwardedFrom","undefined","senderDomain","length","outcome","match","messageId","ref","existing","activityId","author","value","find","activity","subject","excerpt","inReplyTo","atMs","receivedAtMs","byUid","uid","byName","name","created"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-topic-confirmation.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\n/**\n * DOUBLE OPT-IN — the durable half.\n *\n * `email-topics.ts` in the framework states the policy and owns the three\n * states an entry can be in; this writes them, because writing them needs\n * Firestore.\n *\n * ## The record it writes is the one the send path already reads\n *\n * `hosts/{hostId}/topicOptOuts/{emailKey}` holds a map keyed by topic, and\n * `filterTopicSendable` reads it on every topic-filtered send. A pending\n * confirmation is therefore a THIRD state of a fact the send path already\n * consults rather than a new document nobody has a reason to look at — which\n * is the difference between ActiveCampaign's quarantine, where an unconfirmed\n * subscriber genuinely cannot be mailed, and Customer.io's recipe, whose own\n * documentation warns that it \"doesn't automatically check this attribute\n * before sending messages\".\n *\n * ## Nothing here is a deletion\n *\n * A confirmation stamps `confirmedAt` beside the `pendingAt` it answers,\n * rather than clearing the pending mark. The pair IS the evidence — when we\n * asked and when they answered — and it is the evidence a consent question\n * needs, which is the same argument `writeTopicOptOuts` makes for keeping an\n * opt-out entry after somebody rejoins.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n doubleOptInExpired,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { isEmailTopicId } from '@aglyn/aglyn/app-utils/email-topics'\nimport firebaseAdmin from './firebase-admin'\nimport {\n emailSuppressionKey,\n releaseEmailForConfirmedOptIn,\n} from './email-suppression'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nfunction optOutDoc(hostId: string, key: string, firestore?: any) {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n}\n\n/** What asking for a confirmation did. */\nexport type PendingConfirmationResult =\n /** Recorded. The address is not mailable about this topic until confirmed. */\n | 'pending'\n /** Already confirmed, or never needed confirming. Nothing was written. */\n | 'already-subscribed'\n /**\n * They left this stream. Nothing was written and nothing may be sent.\n *\n * A signup form cannot re-arm mail to somebody who unsubscribed by asking\n * them again: the confirmation request is itself a message, and sending it\n * would be mailing somebody who told this site to stop.\n */\n | 'opted-out'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Puts an address in the quarantine for one topic and returns the moment it\n * entered, which is what the confirmation link is signed and dated from.\n *\n * ## Re-asking does not restamp — inside the window\n *\n * A second signup from the same address inside the window keeps the original\n * `pendingAt`, so the 72-hour expiry measures from when we first asked. The\n * alternative lets somebody — or a script — hold an address in a permanently\n * fresh pending state by resubmitting a form, which is a way to keep a\n * confirmation link alive indefinitely.\n *\n * Once the window has passed, a signup is a new question and is stamped now.\n * An expired request admits nobody and its link no longer acts, so keeping\n * its moment would date the new link from the old one — expired before it is\n * sent — and \"sign up again\", which the expired-link page and the docs tell\n * the person to do, could never succeed. With a consent group waiting on its\n * sites' confirmations, that stranded request would hold the stream from\n * every site of the group.\n */\nexport async function recordPendingTopicConfirmation(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<{ result: PendingConfirmationResult; pendingAtMs: number | null }> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) {\n return { result: 'unusable', pendingAtMs: null }\n }\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n const address = String(email).trim().toLowerCase()\n let result: PendingConfirmationResult = 'pending'\n let pendingAtMs: number | null = nowMs\n await (options?.firestore ?? defaultFirestore()).runTransaction(\n async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') {\n result = 'opted-out'\n pendingAtMs = null\n return\n }\n if (state === 'pending') {\n // Already in the quarantine. Keep the original moment while that\n // request can still be confirmed — see above; past it, fall through\n // and ask again.\n const askedAtMs = Number(existing?.pendingAt) || nowMs\n if (!doubleOptInExpired(askedAtMs, nowMs)) {\n result = 'pending'\n pendingAtMs = askedAtMs\n return\n }\n }\n if (existing?.confirmedAt) {\n result = 'already-subscribed'\n pendingAtMs = null\n return\n }\n transaction.set(\n ref,\n {\n email: address,\n topics: {\n ...stored,\n [topicId]: {\n ...(existing ?? {}),\n pendingAt: nowMs,\n confirmedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n },\n )\n return { result, pendingAtMs }\n}\n\n/** What a click on a confirmation link did. */\nexport type ConfirmTopicResult =\n /** They are now a subscriber to this topic. */\n | 'confirmed'\n /** They already were, and a second click changes nothing. */\n | 'already-confirmed'\n /** The request is older than the window, so the link no longer acts. */\n | 'expired'\n /** They left this stream. A confirmation link may not undo that. */\n | 'opted-out'\n /** Nothing was pending for this address and topic. */\n | 'not-pending'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Honors a confirmation click.\n *\n * ## An expired link does not admit anybody, and does not clear anything\n *\n * `expired` leaves the record exactly as it was: still pending, still\n * unmailable. Expiry is a property of the LINK, and a lapse that quietly\n * admitted the address, or quietly cleared the request, would make the window\n * a delay somebody could wait out rather than a gate.\n *\n * ## A confirmation cannot reverse an unsubscribe\n *\n * Same rule as `releaseSiteSuppression`: the recipient's later, more explicit\n * act stands, and a link minted before it must not undo it.\n *\n * ## A confirmation DOES reverse a hard bounce\n *\n * The one thing a completed round trip is evidence of. A `bounce` entry on\n * the platform list claims mail addressed here is permanently refused, and a\n * click on a link that was delivered to that mailbox is the only evidence\n * that could refute it. Without this, an address recorded as permanently\n * bounced — often a full mailbox or a misconfigured receiving server that a\n * provider reported as permanent — can never re-subscribe by any route, and\n * the double opt-in it just completed would enrol somebody the send path then\n * silently drops.\n *\n * The refusal above and the release here are the same rule seen from two\n * sides: a stated intention stands and a measurement does not. Which reasons\n * qualify is decided by `OPT_IN_RELEASABLE_REASONS`, not here — an unsubscribe\n * or a spam complaint is never among them, or this function would be a way to\n * launder a complaint through a signup form.\n *\n * The release is best-effort and deliberately after the transaction: the\n * confirmation is what the person is waiting for, and a suppression list that\n * could not be written must not cost them the subscription they just made.\n */\nexport async function confirmTopicSubscription(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<ConfirmTopicResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable'\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n /*\n * The verdict comes OUT of the transaction rather than through a closure\n * variable beside it. A transaction body can be retried, so a variable the\n * caller reads afterwards describes whichever attempt ran last rather than\n * the one that committed.\n */\n const result: ConfirmTopicResult = await (\n options?.firestore ?? defaultFirestore()\n ).runTransaction(async (transaction: any): Promise<ConfirmTopicResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') return 'opted-out'\n if (state === 'subscribed') {\n return existing?.confirmedAt ? 'already-confirmed' : 'not-pending'\n }\n if (doubleOptInExpired(Number(existing?.pendingAt), nowMs)) {\n return 'expired'\n }\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: { ...(existing ?? {}), confirmedAt: nowMs },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return 'confirmed'\n })\n if (result === 'confirmed') {\n await releaseEmailForConfirmedOptIn({\n email,\n hostId,\n topicId,\n ...(options?.firestore ? { firestore: options.firestore } : {}),\n })\n }\n return result\n}\n\n/**\n * Whether this site confirms new subscriptions by default.\n *\n * One host-document field, read at the moment somebody signs up and nowhere\n * else — the send path never asks, because by then the answer is already on\n * the person's own record as a pending mark or its absence. A read on every\n * send would be a read per recipient to re-derive a decision that was made\n * once.\n *\n * Fails to `false`, which is off. A default that a failed read could switch\n * ON would quarantine every new signup on a site whose owner never asked for\n * confirmations, and they would have no way to tell why their list stopped\n * growing.\n */\nexport async function siteRequiresDoubleOptIn(\n hostId: string,\n firestore?: any,\n): Promise<boolean> {\n if (!hostId) return false\n try {\n const snapshot = await (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .get()\n return snapshot.exists && snapshot.get('emailDoubleOptIn') === true\n } catch (error) {\n console.error('[email-topics] double opt-in setting read failed', error)\n return false\n }\n}\n\n/** What asking to leave one topic did. */\nexport type TopicOptOutResult =\n /** Recorded now. The address is not mailable about this topic. */\n | 'opted-out'\n /** They had already left it; the moment they left is kept. */\n | 'already-opted-out'\n /** Not an address, or not a topic id. Nothing was written. */\n | 'unusable'\n\n/**\n * Takes one address off one topic on one site (AGL-2981) — the opt-out a\n * sender that is not the preference page records: a sales email's\n * unsubscribe link, a reply that asks to be left alone.\n *\n * The same record the preference page writes (`writeTopicOptOuts` in the\n * email plugin) and the send path reads, on the same rules:\n *\n * - an address that already left keeps the moment it left — a second\n * request is not a later one;\n * - the entry is carried forward rather than replaced, so a confirmation\n * the person once gave stays on the record beside the opt-out;\n * - a rejoin stamped earlier is cleared, because leaving is now the more\n * recent act.\n *\n * A transaction, and its verdict comes out of it rather than through a\n * variable beside it: a retried attempt must not describe itself as the one\n * that committed.\n */\nexport async function recordTopicOptOut(\n hostId: string,\n email: string,\n topicId: string,\n options?: { firestore?: any },\n): Promise<TopicOptOutResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable'\n const db = options?.firestore ?? defaultFirestore()\n const ref = optOutDoc(hostId, key, db)\n return db.runTransaction(async (transaction: any): Promise<TopicOptOutResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const previous = stored[topicId]\n if (readTopicSubscriptionState(previous) === 'opted-out') return 'already-opted-out'\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return 'opted-out'\n })\n}\n"],"names":["FieldValue","doubleOptInExpired","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","isEmailTopicId","firebaseAdmin","emailSuppressionKey","releaseEmailForConfirmedOptIn","defaultFirestore","app","firestore","optOutDoc","hostId","key","collection","doc","recordPendingTopicConfirmation","email","topicId","options","result","pendingAtMs","nowMs","Date","now","ref","address","String","trim","toLowerCase","runTransaction","transaction","snapshot","get","stored","exists","existing","state","askedAtMs","Number","pendingAt","confirmedAt","set","topics","updatedAt","serverTimestamp","createdAt","merge","confirmTopicSubscription","siteRequiresDoubleOptIn","error","console","recordTopicOptOut","db","previous","optedOutAt","resubscribedAt"],"mappings":"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"}
|
|
File without changes
|