@aglyn/plugins-crm 1.0.0-beta.165 → 1.0.0-beta.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +7 -1
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/crm-console-page.js +12 -7
  5. package/src/lib/components/crm-console-page.js.map +1 -1
  6. package/src/lib/components/crm-glance-card.js +4 -2
  7. package/src/lib/components/crm-glance-card.js.map +1 -1
  8. package/src/lib/components/lead-campaigns-card.js +7 -1
  9. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  10. package/src/lib/components/lead-detail-page.js +1 -1
  11. package/src/lib/components/lead-detail-page.js.map +1 -1
  12. package/src/lib/components/lead-properties-card.js +1 -1
  13. package/src/lib/components/lead-properties-card.js.map +1 -1
  14. package/src/lib/components/lead-unqualify-dialog.js +7 -1
  15. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  16. package/src/lib/components/leads-bulk-bar.d.ts +1 -2
  17. package/src/lib/components/leads-bulk-bar.js +8 -3
  18. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  19. package/src/lib/components/leads-section.js +27 -15
  20. package/src/lib/components/leads-section.js.map +1 -1
  21. package/src/lib/components/reports/lead-counts.d.ts +16 -9
  22. package/src/lib/components/reports/lead-counts.js +29 -18
  23. package/src/lib/components/reports/lead-counts.js.map +1 -1
  24. package/src/lib/components/reports/lead-funnel-card.js +44 -40
  25. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  26. package/src/lib/hooks/use-org-leads.d.ts +29 -32
  27. package/src/lib/hooks/use-org-leads.js +58 -68
  28. package/src/lib/hooks/use-org-leads.js.map +1 -1
  29. package/src/lib/model/crm-routes.d.ts +9 -6
  30. package/src/lib/model/crm-routes.js +9 -6
  31. package/src/lib/model/crm-routes.js.map +1 -1
  32. package/src/lib/server/capture-contact.js +11 -5
  33. package/src/lib/server/capture-contact.js.map +1 -1
  34. package/src/lib/server/convert-open-lead.js +6 -2
  35. package/src/lib/server/convert-open-lead.js.map +1 -1
  36. package/src/lib/server/email-send.js +3 -3
  37. package/src/lib/server/email-send.js.map +1 -1
  38. package/src/lib/server/erase-person.js +25 -7
  39. package/src/lib/server/erase-person.js.map +1 -1
  40. package/src/lib/server/lead-campaign-carry.js +3 -2
  41. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  42. package/src/lib/server/lead-create.js +8 -6
  43. package/src/lib/server/lead-create.js.map +1 -1
  44. package/src/lib/server/leads-import.js +2 -2
  45. package/src/lib/server/leads-import.js.map +1 -1
  46. package/src/lib/server/record-email-state.js +10 -0
  47. package/src/lib/server/record-email-state.js.map +1 -1
  48. package/src/lib/server/record-facts.js +3 -3
  49. package/src/lib/server/record-facts.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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 {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLead,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(request.hostId)\n .collection('leads')\n .doc(key)\n .get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n const leadRef = hostRef.collection('leads').doc(key)\n const created = !(await leadRef.get()).exists\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? `form:${formId}`\n : request.interaction.source\n const stored = await addHostLead({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLead","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","app","firestore","collection","doc","get","exists","lead","data","convertedContactId","hostRef","leadRef","created","formId","entryPointOf","detail","stored","name","marketingConsent","campaignTouchOf","campaignTouch","touch","leadId","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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 {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLead,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? `form:${formId}`\n : request.interaction.source\n const stored = await addHostLead({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLead","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","stored","name","marketingConsent","campaignTouchOf","campaignTouch","touch","leadId","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
@@ -40,7 +40,7 @@
40
40
  * Never throws: the capture that made the contact has already happened,
41
41
  * and a lead that could not be stamped is one somebody converts by hand.
42
42
  */ import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
43
- import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
43
+ import { firebaseAdmin, getOrgForHost, leadForWrite } from "@aglyn/tenant-data-admin";
44
44
  import { handOffLeadRecords } from "@aglyn/tenant-runtime/hand-off-lead";
45
45
  import { FieldValue } from "firebase-admin/firestore";
46
46
  /**
@@ -54,7 +54,11 @@ import { FieldValue } from "firebase-admin/firestore";
54
54
  try {
55
55
  var _snapshot_data;
56
56
  const firestore = firebaseAdmin.app().firestore();
57
- const leadRef = firestore.collection('hosts').doc(input.hostId).collection('leads').doc(key);
57
+ /*
58
+ * The org row, with any not-yet-backfilled predecessor carried onto it
59
+ * first (AGL-3275) — a conversion must not stamp `convertedContactId` on
60
+ * a host row that a later capture would then write past on the org.
61
+ */ const { ref: leadRef } = await leadForWrite(input.hostId, key);
58
62
  const snapshot = await leadRef.get();
59
63
  if (!snapshot.exists) return false;
60
64
  const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-open-lead.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 * An open lead, closed as converted because the person became a\n * relationship on their own (AGL-3232).\n *\n * Salesforce closes a lead when somebody converts it. Aglyn has a door that\n * makes a person a contact without anybody deciding anything — a member\n * account — and a lead left open behind it is a rep chasing a member. So a\n * relationship capture asks here, after the contact exists: the site's\n * lead for the address, if it is still open, is stamped converted onto\n * that contact, the way the convert dialog stamps one, with `convertedBy`\n * saying which door did it. The CRM's own module, because a lead is the\n * CRM's record (docs/PACKAGES.md rule 3): the capture writer beside it\n * calls it, and the one-record backfill stamps the same shape by hand.\n *\n * Only an OPEN lead: one already converted names its contact and must keep\n * naming it, and one closed as unqualified was a decision — a person the\n * team judged not real who then buys something is a fact for the contact's\n * timeline, not a reason to rewrite the team's verdict.\n *\n * Once stamped, the lead hands what was filed on it to the contact — its\n * activities, its tasks, and every plugin's records about it — exactly as\n * the convert dialog's conversion does (AGL-3233), so a lead closed by a\n * purchase leaves the same timeline behind as one a rep converted.\n *\n * Never throws: the capture that made the contact has already happened,\n * and a lead that could not be stamped is one somebody converts by hand.\n */\n\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** Which door closed the lead, recorded on it beside the conversion stamp. */\nexport type LeadAutoConvertedBy = 'signup' | 'purchase' | 'backfill'\n\nexport interface ConvertOpenLeadOntoContactInput {\n hostId: string\n email: unknown\n /** `orgs/{orgId}/contacts/{contactId}` — the relationship the lead became. */\n contactId: string\n by: LeadAutoConvertedBy\n}\n\n/**\n * Stamp the site's open lead for `email` as converted onto `contactId`.\n * `true` when a lead was stamped; `false` when the site holds none, holds\n * one that is not open, or the write failed.\n */\nexport async function convertOpenLeadOntoContact(\n input: ConvertOpenLeadOntoContactInput,\n): Promise<boolean> {\n const email = normalizeContactEmail(input.email)\n const key = email ? personKey(email) : null\n if (!key || !input.contactId) return false\n try {\n const firestore = firebaseAdmin.app().firestore()\n const leadRef = firestore\n .collection('hosts')\n .doc(input.hostId)\n .collection('leads')\n .doc(key)\n const snapshot = await leadRef.get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId || !isCrmLeadOpen(lead)) return false\n await leadRef.set(\n {\n status: 'qualified',\n convertedContactId: input.contactId,\n convertedAtMs: Date.now(),\n convertedBy: input.by,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n const resolved = await getOrgForHost(input.hostId)\n if (resolved) {\n await handOffLeadRecords({\n firestore,\n orgId: resolved.orgId,\n hostId: input.hostId,\n leadId: key,\n contactId: input.contactId,\n email,\n by: input.by,\n })\n }\n return true\n } catch (error) {\n console.error('convertOpenLeadOntoContact failed', input.hostId, error)\n return false\n }\n}\n\nexport default convertOpenLeadOntoContact\n"],"names":["isCrmLeadOpen","normalizeContactEmail","personKey","firebaseAdmin","getOrgForHost","handOffLeadRecords","FieldValue","convertOpenLeadOntoContact","input","email","key","contactId","snapshot","firestore","app","leadRef","collection","doc","hostId","get","exists","lead","data","convertedContactId","set","status","convertedAtMs","Date","now","convertedBy","by","updatedAt","serverTimestamp","merge","resolved","orgId","leadId","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAEEA,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,aAAa,EAAEC,aAAa,QAAQ,2BAA0B;AACvE,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,UAAU,QAAQ,2BAA0B;AAarD;;;;CAIC,GACD,OAAO,eAAeC,2BACpBC,KAAsC;IAEtC,MAAMC,QAAQR,sBAAsBO,MAAMC,KAAK;IAC/C,MAAMC,MAAMD,QAAQP,UAAUO,SAAS;IACvC,IAAI,CAACC,OAAO,CAACF,MAAMG,SAAS,EAAE,OAAO;IACrC,IAAI;YASYC;QARd,MAAMC,YAAYV,cAAcW,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UACbG,UAAU,CAAC,SACXC,GAAG,CAACT,MAAMU,MAAM,EAChBF,UAAU,CAAC,SACXC,GAAG,CAACP;QACP,MAAME,WAAW,MAAMG,QAAQI,GAAG;QAClC,IAAI,CAACP,SAASQ,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAQT,iBAAAA,SAASU,IAAI,cAAbV,iBAAmB,CAAC;QAClC,IAAIS,KAAKE,kBAAkB,IAAI,CAACvB,cAAcqB,OAAO,OAAO;QAC5D,MAAMN,QAAQS,GAAG,CACf;YACEC,QAAQ;YACRF,oBAAoBf,MAAMG,SAAS;YACnCe,eAAeC,KAAKC,GAAG;YACvBC,aAAarB,MAAMsB,EAAE;YACrBC,WAAWzB,WAAW0B,eAAe;QACvC,GACA;YAAEC,OAAO;QAAK;QAEhB,MAAMC,WAAW,MAAM9B,cAAcI,MAAMU,MAAM;QACjD,IAAIgB,UAAU;YACZ,MAAM7B,mBAAmB;gBACvBQ;gBACAsB,OAAOD,SAASC,KAAK;gBACrBjB,QAAQV,MAAMU,MAAM;gBACpBkB,QAAQ1B;gBACRC,WAAWH,MAAMG,SAAS;gBAC1BF;gBACAqB,IAAItB,MAAMsB,EAAE;YACd;QACF;QACA,OAAO;IACT,EAAE,OAAOO,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqC7B,MAAMU,MAAM,EAAEmB;QACjE,OAAO;IACT;AACF;AAEA,eAAe9B,2BAA0B"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-open-lead.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 * An open lead, closed as converted because the person became a\n * relationship on their own (AGL-3232).\n *\n * Salesforce closes a lead when somebody converts it. Aglyn has a door that\n * makes a person a contact without anybody deciding anything — a member\n * account — and a lead left open behind it is a rep chasing a member. So a\n * relationship capture asks here, after the contact exists: the site's\n * lead for the address, if it is still open, is stamped converted onto\n * that contact, the way the convert dialog stamps one, with `convertedBy`\n * saying which door did it. The CRM's own module, because a lead is the\n * CRM's record (docs/PACKAGES.md rule 3): the capture writer beside it\n * calls it, and the one-record backfill stamps the same shape by hand.\n *\n * Only an OPEN lead: one already converted names its contact and must keep\n * naming it, and one closed as unqualified was a decision — a person the\n * team judged not real who then buys something is a fact for the contact's\n * timeline, not a reason to rewrite the team's verdict.\n *\n * Once stamped, the lead hands what was filed on it to the contact — its\n * activities, its tasks, and every plugin's records about it — exactly as\n * the convert dialog's conversion does (AGL-3233), so a lead closed by a\n * purchase leaves the same timeline behind as one a rep converted.\n *\n * Never throws: the capture that made the contact has already happened,\n * and a lead that could not be stamped is one somebody converts by hand.\n */\n\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost, leadForWrite } from '@aglyn/tenant-data-admin'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** Which door closed the lead, recorded on it beside the conversion stamp. */\nexport type LeadAutoConvertedBy = 'signup' | 'purchase' | 'backfill'\n\nexport interface ConvertOpenLeadOntoContactInput {\n hostId: string\n email: unknown\n /** `orgs/{orgId}/contacts/{contactId}` — the relationship the lead became. */\n contactId: string\n by: LeadAutoConvertedBy\n}\n\n/**\n * Stamp the site's open lead for `email` as converted onto `contactId`.\n * `true` when a lead was stamped; `false` when the site holds none, holds\n * one that is not open, or the write failed.\n */\nexport async function convertOpenLeadOntoContact(\n input: ConvertOpenLeadOntoContactInput,\n): Promise<boolean> {\n const email = normalizeContactEmail(input.email)\n const key = email ? personKey(email) : null\n if (!key || !input.contactId) return false\n try {\n const firestore = firebaseAdmin.app().firestore()\n /*\n * The org row, with any not-yet-backfilled predecessor carried onto it\n * first (AGL-3275) — a conversion must not stamp `convertedContactId` on\n * a host row that a later capture would then write past on the org.\n */\n const { ref: leadRef } = await leadForWrite(input.hostId, key)\n const snapshot = await leadRef.get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId || !isCrmLeadOpen(lead)) return false\n await leadRef.set(\n {\n status: 'qualified',\n convertedContactId: input.contactId,\n convertedAtMs: Date.now(),\n convertedBy: input.by,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n const resolved = await getOrgForHost(input.hostId)\n if (resolved) {\n await handOffLeadRecords({\n firestore,\n orgId: resolved.orgId,\n hostId: input.hostId,\n leadId: key,\n contactId: input.contactId,\n email,\n by: input.by,\n })\n }\n return true\n } catch (error) {\n console.error('convertOpenLeadOntoContact failed', input.hostId, error)\n return false\n }\n}\n\nexport default convertOpenLeadOntoContact\n"],"names":["isCrmLeadOpen","normalizeContactEmail","personKey","firebaseAdmin","getOrgForHost","leadForWrite","handOffLeadRecords","FieldValue","convertOpenLeadOntoContact","input","email","key","contactId","snapshot","firestore","app","ref","leadRef","hostId","get","exists","lead","data","convertedContactId","set","status","convertedAtMs","Date","now","convertedBy","by","updatedAt","serverTimestamp","merge","resolved","orgId","leadId","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAEEA,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,aAAa,EAAEC,aAAa,EAAEC,YAAY,QAAQ,2BAA0B;AACrF,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,UAAU,QAAQ,2BAA0B;AAarD;;;;CAIC,GACD,OAAO,eAAeC,2BACpBC,KAAsC;IAEtC,MAAMC,QAAQT,sBAAsBQ,MAAMC,KAAK;IAC/C,MAAMC,MAAMD,QAAQR,UAAUQ,SAAS;IACvC,IAAI,CAACC,OAAO,CAACF,MAAMG,SAAS,EAAE,OAAO;IACrC,IAAI;YAUYC;QATd,MAAMC,YAAYX,cAAcY,GAAG,GAAGD,SAAS;QAC/C;;;;KAIC,GACD,MAAM,EAAEE,KAAKC,OAAO,EAAE,GAAG,MAAMZ,aAAaI,MAAMS,MAAM,EAAEP;QAC1D,MAAME,WAAW,MAAMI,QAAQE,GAAG;QAClC,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAQR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QAClC,IAAIQ,KAAKE,kBAAkB,IAAI,CAACvB,cAAcqB,OAAO,OAAO;QAC5D,MAAMJ,QAAQO,GAAG,CACf;YACEC,QAAQ;YACRF,oBAAoBd,MAAMG,SAAS;YACnCc,eAAeC,KAAKC,GAAG;YACvBC,aAAapB,MAAMqB,EAAE;YACrBC,WAAWxB,WAAWyB,eAAe;QACvC,GACA;YAAEC,OAAO;QAAK;QAEhB,MAAMC,WAAW,MAAM9B,cAAcK,MAAMS,MAAM;QACjD,IAAIgB,UAAU;YACZ,MAAM5B,mBAAmB;gBACvBQ;gBACAqB,OAAOD,SAASC,KAAK;gBACrBjB,QAAQT,MAAMS,MAAM;gBACpBkB,QAAQzB;gBACRC,WAAWH,MAAMG,SAAS;gBAC1BF;gBACAoB,IAAIrB,MAAMqB,EAAE;YACd;QACF;QACA,OAAO;IACT,EAAE,OAAOO,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqC5B,MAAMS,MAAM,EAAEmB;QACjE,OAAO;IACT;AACF;AAEA,eAAe7B,2BAA0B"}
@@ -16,7 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { buildCrmEmailActivity, contactCaptureHostIds, CRM_ACTIVITY_LOG_FULL_MESSAGE, CRM_COLLECTIONS, CRM_EMAIL_BODY_MAX, CRM_EMAIL_CONTEXT, CRM_EMAIL_SUBJECT_MAX, crmActivityLogHasRoom, crmEmailDeliveryTags, emailStateRefusal, crmMergeFieldsIn, crmScopeTokens, isOrgWideMember, normalizeContactEmail, readContactFacet, readEmailState, readMarketingBasis, renderCrmMergeFields, resolveOrgEntitlements, visibleToHost } from "@aglyn/aglyn/server";
18
18
  import { isEmailConfigured, sendEmail, sendFailureReason } from "@aglyn/shared-util-email";
19
- import { consentGroupForSite, consumeRateLimit, countCrmActivitiesForRecord, filterSendableForHost, firebaseAdmin, getHostDocAdmin, getOrgForHost, hostSendingIdentity, logOrgActivity, memberHasOrgPermission, newCrmActivityRef, orgDataCollectionForHost, readOrgEmailRamp, recordEmailSends, releaseCrmEmailSend, reserveCrmEmailSend, resolveOrgMembership, writeCrmEmailActivity } from "@aglyn/tenant-data-admin";
19
+ import { consentGroupForSite, consumeRateLimit, countCrmActivitiesForRecord, filterSendableForHost, firebaseAdmin, getHostDocAdmin, getOrgForHost, hostSendingIdentity, logOrgActivity, memberHasOrgPermission, newCrmActivityRef, orgDataCollectionForHost, readLeadForHost, readOrgEmailRamp, recordEmailSends, releaseCrmEmailSend, reserveCrmEmailSend, resolveOrgMembership, writeCrmEmailActivity } from "@aglyn/tenant-data-admin";
20
20
  import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
21
21
  import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.js";
22
22
  import { crmSuiteRefusal } from "./suite-gate.js";
@@ -261,8 +261,8 @@ const refuse = (status, error, extra = {})=>({
261
261
  if (ids.leadId) {
262
262
  var _lead_data, _lead_get;
263
263
  if (!hostId) return refuse(400, 'Name the site the lead lives under.');
264
- const lead = await firestore.collection('hosts').doc(hostId).collection('leads').doc(ids.leadId).get();
265
- if (!lead.exists) return refuse(404, 'Unknown lead');
264
+ const lead = await readLeadForHost(hostId, ids.leadId);
265
+ if (!lead) return refuse(404, 'Unknown lead');
266
266
  const email = normalizeContactEmail(lead.get('email'));
267
267
  if (!email) return refuse(400, 'This lead has no email address.');
268
268
  const record = (_lead_data = lead.data()) != null ? _lead_data : {};
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/email-send.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 type AglynOrgBilling,\n type AglynOrgMember,\n buildCrmEmailActivity,\n contactCaptureHostIds,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n CRM_EMAIL_BODY_MAX,\n CRM_EMAIL_CONTEXT,\n CRM_EMAIL_SUBJECT_MAX,\n type CrmActivityLink,\n crmActivityLogHasRoom,\n crmEmailDeliveryTags,\n emailStateRefusal,\n type CrmMergeContext,\n crmMergeFieldsIn,\n crmScopeTokens,\n isOrgWideMember,\n normalizeContactEmail,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n readContactFacet,\n readEmailState,\n readMarketingBasis,\n renderCrmMergeFields,\n resolveOrgEntitlements,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n isEmailConfigured,\n sendEmail,\n sendFailureReason,\n type SendEmailResult,\n} from '@aglyn/shared-util-email'\nimport {\n consentGroupForSite,\n consumeRateLimit,\n countCrmActivitiesForRecord,\n filterSendableForHost,\n firebaseAdmin,\n getHostDocAdmin,\n getOrgForHost,\n hostSendingIdentity,\n logOrgActivity,\n memberHasOrgPermission,\n newCrmActivityRef,\n orgDataCollectionForHost,\n readOrgEmailRamp,\n recordEmailSends,\n releaseCrmEmailSend,\n reserveCrmEmailSend,\n resolveOrgMembership,\n writeCrmEmailActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n orgHostIds,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/email-send` — one email to one person, from their record\n * (AGL-2615).\n *\n * Body: `{ hostId, contactId? | leadId? | dealId?, subject, body }`. Answers\n * `{ ok: true, activityId, to, from, logged }` once the provider accepted\n * the message, and a refusal with a `reason` the dialog can act on\n * otherwise. The subject and the body may carry merge fields —\n * `{{contact.firstName}}`, `{{deal.amount}}` (AGL-2658) — which this route\n * fills from the record before the send, so the timeline row is the letter\n * as it left; see `crm-email-templates.ts` for the grammar.\n *\n * ## What it is, and what it is not\n *\n * A message a teammate wrote to one person they have a relationship with —\n * a reply to a lead, a follow-up on a deal. It is not marketing: no\n * campaign audience, no unsubscribe pair, no frequency window, no consent\n * split. What it does owe is everything a bounce or a complaint owes the\n * shared sending domain, which is why BOTH suppression lists are consulted\n * and why a person's own stated refusal — a `declined` basis — is a hard\n * stop even though the message is not the kind of mail that basis governs.\n * The recipient asked this site not to write; the kind of writing is not\n * the point.\n *\n * ## Why the recipient is resolved here and not taken from the body\n *\n * The dialog shows the address the record carries, and this route reads it\n * again off the record the caller named, through the same visibility check\n * every other CRM route makes. A `to` in the body would make this a route\n * that sends the site's mail to any address a member types, which is a\n * different product with a different abuse surface.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, … }` from the org-level hub. The caller is authorized\n * by the org — an org-wide member holding `data.manage` — and the record is\n * read with no site's visibility to check, because an org-wide member reads\n * every row. What the org level cannot do away with is the SITE the message\n * leaves from: the sending identity, the suppression list and the person's\n * stated refusal are each a site's. So the org variant still names one —\n * the record's own capturing site or the site the reader picked, in the\n * body; failing that the record's own site read off the document; failing\n * that the org's only site — and refuses, with a `site` reason, when the\n * org has several and none was named. The row it writes is visible to\n * whoever may see the RECORD, so an email to a person another site\n * captured shows on their timeline under that site's hub; the act is\n * logged in the org's feed.\n *\n * ## The order of the gates\n *\n * Cheap and certain first. A malformed body and a missing token cost\n * nothing; the per-user pace is one counter; the record is one read and\n * decides everything after it; the ceiling one aggregate; the two\n * suppression lists two keyed reads; the sending identity next, because it\n * is the most expensive and the one a workspace fixes once. The daily cap\n * comes LAST and is the one gate that writes before the provider is called\n * (AGL-2645): the send is reserved on today's counter inside a transaction,\n * so two reps at the last slot cannot both pass one reading of it, and a\n * message the provider refuses gives the slot back. After the provider has\n * accepted, two more things are written, neither able to fail the send:\n * the activity row and the org's cost meter.\n *\n * ## The new-workspace ramp is part of that cap, not a gate beside it\n *\n * A message from a workspace created this morning lands on the sending\n * domain every other tenant's receipts leave on, so the plan's day is held\n * to the share of it the workspace has earned — the same ramp the campaign\n * path is paced by, `readOrgEmailRamp`, taken to the allowance this surface\n * meters instead of the campaign ceiling. It is handed to the reservation\n * rather than checked before it, so the ceiling and the count it is judged\n * against come out of the one transactional read; a ramp consulted\n * separately would be a second ceiling and two reps could pass it at once.\n * A workspace past its first week is not ramped and pays nothing to find\n * that out.\n */\n\n/** How many one-to-one emails one person may send in one minute. */\nexport const CRM_EMAIL_SENDS_PER_MINUTE = 20\nconst CRM_EMAIL_RATE_WINDOW_MS = 60_000\n\n/** What the route says when the plan carries no one-to-one email at all. */\nexport const CRM_EMAIL_NOT_INCLUDED_MESSAGE =\n 'Your plan does not include one-to-one email. Upgrade in Billing to email ' +\n 'people from their records.'\n\n/** What the route says at the daily cap; `included` is the plan's figure. */\nexport function crmEmailCapReachedMessage(included: number): string {\n return (\n `Today's one-to-one email limit (${included.toLocaleString('en-US')}) is ` +\n 'reached. It resets at midnight UTC — see Billing for the count.'\n )\n}\n\n/**\n * What the route says when the NEW-WORKSPACE RAMP is the ceiling that was\n * reached, rather than the plan's own.\n *\n * The campaign path's wording, pointed at this surface: the number, what was\n * spent against it, that nothing was sent or counted, and when it lifts. A\n * workspace told only \"limit reached\" against a figure far below the one its\n * plan page advertises would read it as a billing fault.\n */\nexport function crmEmailRampReachedMessage(\n ceiling: number,\n used: number,\n): string {\n return (\n `This workspace may send ${ceiling.toLocaleString('en-US')} one-to-one ` +\n `email${ceiling === 1 ? '' : 's'} a day while it establishes a sending ` +\n `history, and has sent ${used.toLocaleString('en-US')} today. Nothing ` +\n 'has been sent and nothing has been counted — the allowance grows as ' +\n \"the workspace sends, and is the plan's in full after its first week. \" +\n 'It resets at midnight UTC.'\n )\n}\n\n/** What the route says when the ramp's own history could not be read. */\nexport const CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE =\n \"This workspace's sending history could not be read just now, so how much \" +\n 'of its first-week email allowance it has earned is not known. Nothing has ' +\n 'been sent and nothing has been counted — try again in a moment.'\n\nexport const CRM_EMAIL_SUPPRESSED_MESSAGE =\n 'This address has bounced or reported a message as spam, so it cannot be ' +\n 'emailed.'\n\nexport const CRM_EMAIL_DECLINED_MESSAGE =\n 'This person asked not to be emailed by this site.'\n\nexport const CRM_EMAIL_RATE_MESSAGE =\n 'You have sent too many emails in the last minute. Wait a moment and try again.'\n\n/** What the org variant says when no site was named and the org has several. */\nexport const CRM_EMAIL_PICK_SITE_MESSAGE =\n 'Pick the site this email leaves from — its sending address is what the ' +\n 'message is sent as.'\n\ntype Refusal = { ok: false; status: number; body: Record<string, unknown> }\n\nconst refuse = (\n status: number,\n error: string,\n extra: Record<string, unknown> = {},\n): Refusal => ({ ok: false, status, body: { error, ...extra } })\n\n/** One typed field, trimmed and bounded. */\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\ninterface Sender {\n ok: true\n uid: string\n /** The address replies go to — the token's, never the body's. */\n email: string\n /** The name in front of the sending address, when the token carries one. */\n name: string\n orgId: string\n org: Partial<AglynOrgBilling>\n}\n\n/**\n * Who is sending, and whether they may: a verified ID token, then\n * `data.manage` on the site's org through the same three-layer read the\n * create route makes, admitted only for a site the member reaches. Staff\n * pass the permission check as they do on every CRM route, and send as\n * themselves.\n */\nasync function authorizeSender(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Sender | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; name?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] email-send could not verify the sender', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const org = (resolved.org ?? {}) as Partial<AglynOrgBilling>\n if (decoded.staff !== true) {\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Sending email from a record requires the data.manage permission on this site',\n )\n }\n }\n const email = normalizeContactEmail(decoded.email)\n if (!email) {\n // A reply-to is owed and the token is the only place it can honestly\n // come from; an account with no address cannot be replied to.\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: decoded.uid,\n email,\n name: typed(decoded.name, 120),\n orgId,\n org,\n }\n}\n\n/**\n * The org variant's sender (AGL-2634): authorized by the org for an\n * org-wide member holding `data.manage`, with the same reply-to rule.\n */\nasync function authorizeOrgSender(\n req: PluginApiRequest,\n orgId: string,\n): Promise<Sender | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: 'data.manage',\n refusal:\n 'Sending email at the organization level requires the data.manage ' +\n 'permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n const email = normalizeContactEmail(caller.email)\n if (!email) {\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: caller.uid,\n email,\n name: caller.name,\n orgId,\n org: caller.org as Partial<AglynOrgBilling>,\n }\n}\n\ninterface Recipient {\n ok: true\n email: string\n /** The document the consent basis is read off — the contact, or the lead. */\n record: Record<string, unknown>\n /** The contact document, when one was read — where the company link is. */\n contact: Record<string, unknown> | null\n link: CrmActivityLink\n /**\n * The site the record itself names — a contact's first capturing site, a\n * deal's own, a lead's — which is the site an org-level send leaves from\n * when the body named none.\n */\n siteHint: string\n /** The deal document, when one was named — what `{{deal.*}}` reads (AGL-2658). */\n deal: Record<string, unknown> | null\n /** The lead document, when the recipient is a lead — what `{{lead.*}}` reads. */\n lead: Record<string, unknown> | null\n}\n\n/**\n * The person the named record is about, as THIS site may see them.\n *\n * A deal names its contact; a lead carries its own address; a contact is\n * itself. Each is looked up by id and then checked against what the site\n * may read — the Admin SDK evaluates no rules, and a member of site A must\n * not be able to mail site B's contact by guessing an id. The links the\n * activity is filed under are whatever resolved: a deal's email is also the\n * contact's, so it lands on both timelines.\n *\n * At the organization level there is no site to check against and none is\n * checked: the caller was admitted as an org-wide member, who reads every\n * row. A lead still needs its site named — a lead lives under one.\n */\nasync function resolveRecipient(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n ids: { contactId: string; leadId: string; dealId: string },\n): Promise<Recipient | Refusal> {\n const link: CrmActivityLink = {}\n const { hostId } = scope\n const scoped = scope.level === 'site'\n const visible = (tokens: unknown) =>\n !scoped || visibleToHost(tokens as readonly string[], hostId)\n let contactId = ids.contactId\n let siteHint = ''\n let dealRecord: Record<string, unknown> | null = null\n\n if (ids.dealId) {\n const deal = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.deals)\n .doc(ids.dealId)\n .get()\n if (!deal.exists || !visible(deal.get('visibleTo'))) {\n return refuse(404, 'Unknown deal')\n }\n link.dealId = deal.id\n dealRecord = (deal.data() ?? {}) as Record<string, unknown>\n siteHint = typed(deal.get('hostId'), 128)\n contactId = contactId || String(deal.get('contactId') ?? '').trim()\n if (!contactId) {\n return refuse(400, 'This deal names no contact to email.')\n }\n }\n\n if (contactId) {\n const contacts = scoped\n ? await orgDataCollectionForHost(hostId, 'contacts')\n : firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await contacts.doc(contactId).get()\n if (!contact.exists || !visible(contact.get('visibleTo'))) {\n return refuse(404, 'Unknown contact')\n }\n const email = normalizeContactEmail(contact.get('email'))\n if (!email) return refuse(400, 'This contact has no email address.')\n const record = (contact.data() ?? {}) as Record<string, unknown>\n link.contactId = contact.id\n if (ids.leadId) link.leadId = ids.leadId\n siteHint =\n siteHint || typed(record['hostId'], 128) || contactCaptureHostIds(record)[0] || ''\n return { ok: true, email, record, contact: record, link, siteHint, deal: dealRecord, lead: null }\n }\n\n if (ids.leadId) {\n if (!hostId) return refuse(400, 'Name the site the lead lives under.')\n const lead = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('leads')\n .doc(ids.leadId)\n .get()\n if (!lead.exists) return refuse(404, 'Unknown lead')\n const email = normalizeContactEmail(lead.get('email'))\n if (!email) return refuse(400, 'This lead has no email address.')\n const record = (lead.data() ?? {}) as Record<string, unknown>\n link.leadId = lead.id\n // A converted lead's email belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return { ok: true, email, record, contact: null, link, siteHint: hostId, deal: null, lead: record }\n }\n\n return refuse(400, 'Name a contact, lead or deal to email.')\n}\n\n/**\n * The site an org-level send leaves from (AGL-2634): the body's, else the\n * record's own, else the org's only site — and a refusal the dialog turns\n * into a Site picker when the org has several and none was named.\n */\nasync function resolveSendingSite(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n siteHint: string,\n): Promise<{ ok: true; hostId: string } | Refusal> {\n if (scope.hostId) return { ok: true, hostId: scope.hostId }\n if (siteHint) return { ok: true, hostId: siteHint }\n const hosts = await orgHostIds(firestore, orgId)\n if (hosts.length === 1) return { ok: true, hostId: hosts[0] }\n return refuse(409, CRM_EMAIL_PICK_SITE_MESSAGE, { reason: 'site' })\n}\n\n/** The HTTP shape of a send the provider did not accept. */\nfunction sendRefusal(result: SendEmailResult): Refusal {\n const reason = sendFailureReason(result)\n const detail = String((result as { detail?: unknown }).detail ?? '')\n if (reason === 'unverified-domain') {\n return refuse(409, detail || 'This site has no verified sending identity.', {\n reason: 'sending-identity',\n })\n }\n if (reason === 'suppressed' || reason === 'unengaged') {\n return refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' })\n }\n if (reason === 'rate-limited') {\n const retryAtMs = Number((result as { retryAtMs?: unknown }).retryAtMs)\n return refuse(\n 503,\n 'The mail provider asked us to slow down. Try again in a minute.',\n {\n reason: 'provider-rate',\n retryAfterSeconds: Number.isFinite(retryAtMs)\n ? Math.max(1, Math.ceil((retryAtMs - Date.now()) / 1000))\n : 60,\n },\n )\n }\n if (reason === 'unconfigured') {\n return refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n })\n }\n return refuse(502, 'The email could not be sent.', { reason: 'send-failed' })\n}\n\n/** What the route says when a subject or a message is nothing once its fields are filled. */\nexport const CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE =\n 'The subject and the message are empty once their merge fields are filled ' +\n 'in. Check the fields against this record.'\n\n/**\n * What `{{…}}` reads from, for this send (AGL-2658): the documents already\n * in hand — the contact, the deal, the lead — the sender off their token,\n * and the site's name, read only when a field asks for it. The contact's\n * facet is the SENDING site's group's, because a name, a job title and a\n * company are one holder's knowledge of a person.\n */\nasync function mergeContextFor(options: {\n recipient: Recipient\n sender: Sender\n hostId: string\n groupId: string\n fields: readonly string[]\n}): Promise<CrmMergeContext> {\n const { recipient, sender, hostId, groupId, fields } = options\n const wantsSite = fields.some((field) => field.startsWith('site.'))\n const host = wantsSite ? await getHostDocAdmin(hostId).catch(() => null) : null\n return {\n contact: recipient.contact,\n contactGroupId: groupId,\n lead: recipient.lead,\n deal: recipient.deal,\n sender: { name: sender.name, email: sender.email },\n site: { name: typed(host?.['displayName'], 200) },\n }\n}\n\nfunction answer(res: PluginApiResponse, refusal: Refusal): void {\n const retry = refusal.body['retryAfterSeconds']\n if (typeof retry === 'number') res.setHeader('Retry-After', String(retry))\n res.status(refusal.status).json(refusal.body)\n}\n\nexport const crmEmailSendHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const routeScope = readCrmRouteScope(body)\n if (!routeScope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const ids = {\n contactId: typed(body['contactId'], 128),\n leadId: typed(body['leadId'], 128),\n dealId: typed(body['dealId'], 128),\n }\n if (!ids.contactId && !ids.leadId && !ids.dealId) {\n res.status(400).json({ error: 'Name a contact, lead or deal to email.' })\n return\n }\n const draftSubject = typed(body['subject'], CRM_EMAIL_SUBJECT_MAX)\n if (!draftSubject) {\n res.status(400).json({ error: 'Enter a subject.' })\n return\n }\n // Paragraphs are what the dialog offers, and a plain-text part is what a\n // paragraph is; line endings are normalized so the stored body and the\n // sent body are one string.\n const draftText = String(body['body'] ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .trim()\n .slice(0, CRM_EMAIL_BODY_MAX)\n if (!draftText) {\n res.status(400).json({ error: 'Write the message.' })\n return\n }\n\n try {\n const sender =\n routeScope.level === 'org'\n ? await authorizeOrgSender(req, routeScope.orgId)\n : await authorizeSender(req, routeScope.hostId)\n if (sender.ok === false) return answer(res, sender)\n const { orgId, org } = sender\n /*\n * THE PLAN, before the pace and the cap (AGL-2787). One-to-one email is\n * the suite's; a plan without the suite is refused as such, rather than\n * reaching the daily cap and being told about a zero it has no way to\n * raise short of the upgrade this names.\n */\n const suite = crmSuiteRefusal(org, 'Emailing one person from their record')\n if (suite) return answer(res, { ok: false, status: suite.status, body: suite.body })\n\n /*\n * THE PER-USER PACE, on its own key. The console dispatcher already\n * counts this write against the person's general console budget; this\n * is the narrower bucket the feature owes — twenty a minute is a rep\n * writing, and past it is a script — keyed by the uid the token proved\n * so one person cannot spend the bucket of everyone on their address.\n */\n const rate = await consumeRateLimit(`crm-email-send:uid:${sender.uid}`, {\n limit: CRM_EMAIL_SENDS_PER_MINUTE,\n windowMs: CRM_EMAIL_RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n return answer(\n res,\n refuse(429, CRM_EMAIL_RATE_MESSAGE, {\n reason: 'rate',\n retryAfterSeconds: Math.max(\n 1,\n Math.ceil((rate.resetMs - Date.now()) / 1000),\n ),\n }),\n )\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const recipient = await resolveRecipient(firestore, routeScope, orgId, ids)\n if (recipient.ok === false) return answer(res, recipient)\n // The site the message leaves from — the mounted one under a site, and\n // at the organization level whatever the body, the record or the org\n // can answer.\n const site = await resolveSendingSite(firestore, routeScope, orgId, recipient.siteHint)\n if (site.ok === false) return answer(res, site)\n const { hostId } = site\n const group = await consentGroupForSite(hostId, org as Record<string, unknown>)\n // The company the sending site files this person under, so the email\n // shows on the company's log as the automation step's rows do.\n if (recipient.contact) {\n const facet = readContactFacet(recipient.contact, group.groupId)\n if (facet.companyId) recipient.link.companyId = facet.companyId\n }\n\n /*\n * MERGE FIELDS (AGL-2658), filled here rather than in the dialog: the\n * dialog previews with the same resolver, but what leaves and what is\n * logged is rendered by the route, off the documents it read to find\n * the recipient, so a preview the rep did not look at cannot differ\n * from the letter. A subject or a message that is nothing once its\n * fields are filled is refused before any gate is spent on it.\n */\n const fields = crmMergeFieldsIn(`${draftSubject}\\n${draftText}`)\n const merge = fields.length\n ? await mergeContextFor({ recipient, sender, hostId, groupId: group.groupId, fields })\n : null\n const subject = merge\n ? renderCrmMergeFields(draftSubject, merge).trim().slice(0, CRM_EMAIL_SUBJECT_MAX)\n : draftSubject\n const text = merge\n ? renderCrmMergeFields(draftText, merge).trim().slice(0, CRM_EMAIL_BODY_MAX)\n : draftText\n if (!subject || !text) {\n return answer(res, refuse(400, CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE, { reason: 'merge' }))\n }\n\n // The per-record ceiling, before the send: an email that could not be\n // logged would be a message the timeline never shows, and the timeline\n // is the reason the send lives on the record.\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, recipient.link))) {\n return answer(res, refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE, { reason: 'ceiling' }))\n }\n\n // Both suppression lists, then the person's own refusal.\n const sendable = await filterSendableForHost(hostId, [recipient.email])\n if (!sendable.length) {\n return answer(res, refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' }))\n }\n if (readMarketingBasis(recipient.record, group).basis === 'declined') {\n return answer(res, refuse(409, CRM_EMAIL_DECLINED_MESSAGE, { reason: 'declined' }))\n }\n // The record's own verdict (AGL-3245): what the lists hold, in the words\n // the page shows — a bounce, a gateway block, a do-not-contact mark the\n // suppression lists do not carry.\n const stateRefusal = emailStateRefusal(readEmailState(recipient.record))\n if (stateRefusal) {\n return answer(res, refuse(409, stateRefusal, { reason: 'email-state' }))\n }\n\n if (!isEmailConfigured()) {\n return answer(\n res,\n refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n }),\n )\n }\n const identity = await hostSendingIdentity(hostId)\n if (identity.refusal) {\n return answer(\n res,\n refuse(409, identity.refusal.message, { reason: 'sending-identity' }),\n )\n }\n\n const now = new Date()\n /*\n * THE NEW-WORKSPACE RAMP, resolved for the ceiling below.\n *\n * A workspace past its first week graduates off the org record alone and\n * pays no read for this. One that is still on the ramp needs its\n * delivery history to know which step it has earned, and when that\n * history is unreadable the step is a floor rather than an answer.\n *\n * A floor is enough for a campaign, which defers what it cannot send\n * today and delivers it tomorrow. It is not enough here: this route\n * admits or refuses ONE message and has no third answer, so a ramp that\n * cannot say what a workspace has earned refuses and says so. The\n * refusal is transient by construction — it lasts as long as the window\n * read is failing — and it reaches only workspaces inside their first\n * week, since every other one graduated before the window was consulted.\n */\n const { ramp, degraded } = await readOrgEmailRamp({\n orgId,\n createdAt: (org as Record<string, unknown>)['createdAt'],\n now: now.getTime(),\n })\n if (degraded) {\n return answer(\n res,\n refuse(503, CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE, {\n reason: 'ramp-unavailable',\n }),\n )\n }\n\n // THE DAILY CAP (AGL-2611), taken as a slot rather than read as a\n // figure (AGL-2645): the transaction reads today's counter, judges it\n // with `checkCrmEmailQuota` against the ramp's share of the plan's day\n // and raises it in one step, so the count the cap enforces is the count\n // this send was admitted on.\n const claim = await reserveCrmEmailSend(firestore, orgId, org, now, ramp)\n if (!claim.ok) {\n const { quota } = claim\n // WHICH ceiling was reached, in the org's own terms. The reservation\n // answers with the number it judged, which is the plan's day unless\n // the ramp held this workspace below it — and a workspace shown a\n // figure under the one its plan page advertises is owed the reason.\n const rampBound =\n quota.included < resolveOrgEntitlements(org).crmEmailsPerDay\n return answer(\n res,\n refuse(\n 409,\n quota.included <= 0\n ? CRM_EMAIL_NOT_INCLUDED_MESSAGE\n : rampBound\n ? crmEmailRampReachedMessage(quota.included, quota.used)\n : crmEmailCapReachedMessage(quota.included),\n {\n reason: rampBound ? 'ramp' : 'quota',\n included: quota.included,\n used: quota.used,\n resetsAtMs: quota.resetsAt.getTime(),\n },\n ),\n )\n }\n\n // The row's id, minted before the send so the delivery webhook can find\n // it from the tags alone; written only once the message has left.\n const activityRef = newCrmActivityRef(firestore, orgId)\n let result: SendEmailResult\n try {\n result = await sendEmail({\n to: recipient.email,\n subject,\n text,\n sendingIdentity: identity,\n audience: 'tenant',\n context: CRM_EMAIL_CONTEXT,\n replyTo: sender.email,\n ...(sender.name ? { fromName: sender.name } : {}),\n tags: crmEmailDeliveryTags({ orgId, hostId, activityId: activityRef.id }),\n })\n } catch (error) {\n // The provider never answered, so no message left: the slot goes\n // back before the failure is reported.\n await releaseCrmEmailSend(firestore, claim.reservation)\n throw error\n }\n if (!result.sent) {\n await releaseCrmEmailSend(firestore, claim.reservation)\n return answer(res, sendRefusal(result))\n }\n\n const sentAtMs = Date.now()\n let logged = true\n /*\n * Who may list the row. Under a site, what a record created there is\n * stamped with. At the organization level the RECORD's own tokens, so\n * an email to a person another site captured shows on their timeline\n * under that site's hub — a row stamped with the sending site alone\n * would be invisible where the person is read.\n */\n const recordTokens = recipient.record['visibleTo']\n const visibleTo =\n routeScope.level === 'org' && Array.isArray(recordTokens) && recordTokens.length\n ? recordTokens.map(String)\n : crmScopeTokens(org as Record<string, unknown>, group)\n try {\n await writeCrmEmailActivity(\n activityRef,\n buildCrmEmailActivity({\n subject,\n body: text,\n to: recipient.email,\n atMs: sentAtMs,\n byUid: sender.uid,\n ...(sender.name ? { byName: sender.name } : {}),\n link: recipient.link,\n hostId,\n visibleTo,\n }),\n )\n } catch (error) {\n // The message has left. A row that could not be written is a gap on\n // the timeline, not a failed send, and it is said so in the answer.\n console.error('[crm] email activity write failed', orgId, activityRef.id, error)\n logged = false\n }\n // The cap's count was taken with the reservation; the org's cost meter\n // for COGS is stamped after delivery, unable to fail the send.\n await recordEmailSends({\n scope: { kind: 'org', orgId },\n count: 1,\n sendClass: 'transactional',\n firestore,\n })\n // The org-level act, in the org's feed: the site variant's is the\n // timeline row itself, under the site the record page is on.\n if (routeScope.level === 'org') {\n await logOrgActivity(\n orgId,\n { uid: sender.uid, email: sender.email },\n 'Sent email',\n recipient.link.contactId\n ? { type: 'contact', id: recipient.link.contactId, name: recipient.email }\n : { type: 'lead', id: recipient.link.leadId, name: recipient.email },\n )\n }\n res.status(200).json({\n ok: true,\n activityId: activityRef.id,\n to: recipient.email,\n from: identity.from,\n logged,\n })\n } catch (error) {\n console.error('[crm] email-send failed', routeScope, error)\n res.status(500).json({ error: 'The email could not be sent.' })\n }\n}\n\nexport default crmEmailSendHandler\n"],"names":["buildCrmEmailActivity","contactCaptureHostIds","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","CRM_EMAIL_BODY_MAX","CRM_EMAIL_CONTEXT","CRM_EMAIL_SUBJECT_MAX","crmActivityLogHasRoom","crmEmailDeliveryTags","emailStateRefusal","crmMergeFieldsIn","crmScopeTokens","isOrgWideMember","normalizeContactEmail","readContactFacet","readEmailState","readMarketingBasis","renderCrmMergeFields","resolveOrgEntitlements","visibleToHost","isEmailConfigured","sendEmail","sendFailureReason","consentGroupForSite","consumeRateLimit","countCrmActivitiesForRecord","filterSendableForHost","firebaseAdmin","getHostDocAdmin","getOrgForHost","hostSendingIdentity","logOrgActivity","memberHasOrgPermission","newCrmActivityRef","orgDataCollectionForHost","readOrgEmailRamp","recordEmailSends","releaseCrmEmailSend","reserveCrmEmailSend","resolveOrgMembership","writeCrmEmailActivity","isRefusedIdToken","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_EMAIL_SENDS_PER_MINUTE","CRM_EMAIL_RATE_WINDOW_MS","CRM_EMAIL_NOT_INCLUDED_MESSAGE","crmEmailCapReachedMessage","included","toLocaleString","crmEmailRampReachedMessage","ceiling","used","CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE","CRM_EMAIL_SUPPRESSED_MESSAGE","CRM_EMAIL_DECLINED_MESSAGE","CRM_EMAIL_RATE_MESSAGE","CRM_EMAIL_PICK_SITE_MESSAGE","refuse","status","error","extra","ok","body","typed","value","max","String","trim","slice","authorizeSender","req","hostId","resolved","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","console","catch","orgId","org","staff","member","membership","uid","reaches","Boolean","hostAccess","allowed","email","name","authorizeOrgSender","caller","needs","refusal","resolveRecipient","firestore","scope","ids","link","scoped","level","visible","tokens","contactId","siteHint","dealRecord","dealId","deal","collection","doc","deals","get","exists","id","data","contact","contacts","record","leadId","lead","converted","resolveSendingSite","hosts","reason","sendRefusal","result","detail","retryAtMs","Number","retryAfterSeconds","isFinite","Math","ceil","Date","now","CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE","mergeContextFor","options","recipient","sender","groupId","fields","wantsSite","some","field","host","contactGroupId","site","answer","res","retry","setHeader","json","crmEmailSendHandler","method","routeScope","draftSubject","draftText","replace","suite","rate","limit","windowMs","resetMs","group","facet","companyId","merge","subject","text","orgRef","sendable","basis","stateRefusal","identity","message","ramp","degraded","createdAt","getTime","claim","quota","rampBound","crmEmailsPerDay","resetsAtMs","resetsAt","activityRef","to","sendingIdentity","audience","context","replyTo","fromName","tags","activityId","reservation","sent","sentAtMs","logged","recordTokens","visibleTo","Array","isArray","map","atMs","byUid","byName","kind","count","sendClass","type","from"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAGEA,qBAAqB,EACrBC,qBAAqB,EACrBC,6BAA6B,EAC7BC,eAAe,EACfC,kBAAkB,EAClBC,iBAAiB,EACjBC,qBAAqB,EAErBC,qBAAqB,EACrBC,oBAAoB,EACpBC,iBAAiB,EAEjBC,gBAAgB,EAChBC,cAAc,EACdC,eAAe,EACfC,qBAAqB,EAIrBC,gBAAgB,EAChBC,cAAc,EACdC,kBAAkB,EAClBC,oBAAoB,EACpBC,sBAAsB,EACtBC,aAAa,QACR,sBAAqB;AAC5B,SACEC,iBAAiB,EACjBC,SAAS,EACTC,iBAAiB,QAEZ,2BAA0B;AACjC,SACEC,mBAAmB,EACnBC,gBAAgB,EAChBC,2BAA2B,EAC3BC,qBAAqB,EACrBC,aAAa,EACbC,eAAe,EACfC,aAAa,EACbC,mBAAmB,EACnBC,cAAc,EACdC,sBAAsB,EACtBC,iBAAiB,EACjBC,wBAAwB,EACxBC,gBAAgB,EAChBC,gBAAgB,EAChBC,mBAAmB,EACnBC,mBAAmB,EACnBC,oBAAoB,EACpBC,qBAAqB,QAChB,2BAA0B;AACjC,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SACEC,kBAAkB,EAElBC,UAAU,EACVC,iBAAiB,QACZ,kBAAc;AACrB,SAASC,eAAe,QAAQ,kBAAc;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0EC,GAED,kEAAkE,GAClE,OAAO,MAAMC,6BAA6B,GAAE;AAC5C,MAAMC,2BAA2B;AAEjC,0EAA0E,GAC1E,OAAO,MAAMC,iCACX,8EACA,6BAA4B;AAE9B,2EAA2E,GAC3E,OAAO,SAASC,0BAA0BC,QAAgB;IACxD,OACE,CAAC,gCAAgC,EAAEA,SAASC,cAAc,CAAC,SAAS,KAAK,CAAC,GAC1E;AAEJ;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,2BACdC,OAAe,EACfC,IAAY;IAEZ,OACE,CAAC,wBAAwB,EAAED,QAAQF,cAAc,CAAC,SAAS,YAAY,CAAC,GACxE,CAAC,KAAK,EAAEE,YAAY,IAAI,KAAK,IAAI,sCAAsC,CAAC,GACxE,CAAC,sBAAsB,EAAEC,KAAKH,cAAc,CAAC,SAAS,gBAAgB,CAAC,GACvE,yEACA,0EACA;AAEJ;AAEA,uEAAuE,GACvE,OAAO,MAAMI,qCACX,8EACA,+EACA,kEAAiE;AAEnE,OAAO,MAAMC,+BACX,6EACA,WAAU;AAEZ,OAAO,MAAMC,6BACX,oDAAmD;AAErD,OAAO,MAAMC,yBACX,iFAAgF;AAElF,8EAA8E,GAC9E,OAAO,MAAMC,8BACX,4EACA,sBAAqB;AAIvB,MAAMC,SAAS,CACbC,QACAC,OACAC,QAAiC,CAAC,CAAC,GACtB,CAAA;QAAEC,IAAI;QAAOH;QAAQI,MAAM;YAAEH;WAAUC;IAAQ,CAAA;AAE9D,0CAA0C,GAC1C,SAASG,MAAMC,KAAc,EAAEC,GAAW;IACxC,OAAOC,OAAOF,gBAAAA,QAAS,IACpBG,IAAI,GACJC,KAAK,CAAC,GAAGH;AACd;AAaA;;;;;;CAMC,GACD,eAAeI,gBACbC,GAAqB,EACrBC,MAAc;QAEeD,4BAkBhBE;IAlBb,MAAMC,gBAAgBP,QAAOI,6BAAAA,IAAII,OAAO,CAACD,aAAa,YAAzBH,6BAA6B;IAC1D,MAAMK,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcL,KAAK,CAAC,UAAUS,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS,OAAOlB,OAAO,KAAK;IACjC,IAAIsB;IACJ,IAAI;QACFA,UAAU,MAAMvD,cAAcwD,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACP;IAC3D,EAAE,OAAOhB,OAAO;QACd,sEAAsE;QACtE,mCAAmC;QACnC,IAAIrB,iBAAiBqB,QAAQ,OAAOF,OAAO,KAAK;QAChD0B,QAAQxB,KAAK,CAAC,gDAAgDA;QAC9D,OAAOF,OAAO,KAAK;IACrB;IACA,MAAMe,WAAW,MAAM9C,cAAc6C,QAAQa,KAAK,CAAC,IAAM;IACzD,IAAI,CAACZ,UAAU,OAAOf,OAAO,KAAK;IAClC,MAAM,EAAE4B,KAAK,EAAE,GAAGb;IAClB,MAAMc,OAAOd,gBAAAA,SAASc,GAAG,YAAZd,gBAAgB,CAAC;IAC9B,IAAIO,QAAQQ,KAAK,KAAK,MAAM;;YAMWC;QALrC,MAAMC,aAAa,MAAMrD,qBAAqB2C,QAAQW,GAAG,EAAEL,OAAOD,KAAK,CACrE,IAAM;QAER,MAAMI,iBAAUC,8BAAAA,WAAYD,MAAM,mBAAI;QACtC,MAAMG,UACJlF,gBAAgB+E,WAAWI,QAAQJ,2BAAAA,qBAAAA,OAAQK,UAAU,qBAAlBL,kBAAoB,CAACjB,OAAO;QACjE,MAAMuB,UACJN,UACAG,WACC,MAAM9D,uBAAuBwD,OAAOG,QAAQ;QAC/C,IAAI,CAACM,SAAS;YACZ,OAAOrC,OACL,KACA;QAEJ;IACF;IACA,MAAMsC,QAAQrF,sBAAsBqE,QAAQgB,KAAK;IACjD,IAAI,CAACA,OAAO;QACV,qEAAqE;QACrE,8DAA8D;QAC9D,OAAOtC,OAAO,KAAK;IACrB;IACA,OAAO;QACLI,IAAI;QACJ6B,KAAKX,QAAQW,GAAG;QAChBK;QACAC,MAAMjC,MAAMgB,QAAQiB,IAAI,EAAE;QAC1BX;QACAC;IACF;AACF;AAEA;;;CAGC,GACD,eAAeW,mBACb3B,GAAqB,EACrBe,KAAa;IAEb,MAAMa,SAAS,MAAM3D,mBAAmB+B,KAAKe,OAAO;QAClDc,OAAO;QACPC,SACE,sEACA;IACJ;IACA,IAAIF,OAAOrC,EAAE,KAAK,OAAO,OAAOJ,OAAOyC,OAAOxC,MAAM,EAAEwC,OAAOvC,KAAK;IAClE,MAAMoC,QAAQrF,sBAAsBwF,OAAOH,KAAK;IAChD,IAAI,CAACA,OAAO;QACV,OAAOtC,OAAO,KAAK;IACrB;IACA,OAAO;QACLI,IAAI;QACJ6B,KAAKQ,OAAOR,GAAG;QACfK;QACAC,MAAME,OAAOF,IAAI;QACjBX;QACAC,KAAKY,OAAOZ,GAAG;IACjB;AACF;AAsBA;;;;;;;;;;;;;CAaC,GACD,eAAee,iBACbC,SAAsC,EACtCC,KAAoB,EACpBlB,KAAa,EACbmB,GAA0D;IAE1D,MAAMC,OAAwB,CAAC;IAC/B,MAAM,EAAElC,MAAM,EAAE,GAAGgC;IACnB,MAAMG,SAASH,MAAMI,KAAK,KAAK;IAC/B,MAAMC,UAAU,CAACC,SACf,CAACH,UAAU1F,cAAc6F,QAA6BtC;IACxD,IAAIuC,YAAYN,IAAIM,SAAS;IAC7B,IAAIC,WAAW;IACf,IAAIC,aAA6C;IAEjD,IAAIR,IAAIS,MAAM,EAAE;YAWAC,YAEkBA;QAZhC,MAAMA,OAAO,MAAMZ,UAChBa,UAAU,CAAC,QACXC,GAAG,CAAC/B,OACJ8B,UAAU,CAACnH,gBAAgBqH,KAAK,EAChCD,GAAG,CAACZ,IAAIS,MAAM,EACdK,GAAG;QACN,IAAI,CAACJ,KAAKK,MAAM,IAAI,CAACX,QAAQM,KAAKI,GAAG,CAAC,eAAe;YACnD,OAAO7D,OAAO,KAAK;QACrB;QACAgD,KAAKQ,MAAM,GAAGC,KAAKM,EAAE;QACrBR,cAAcE,aAAAA,KAAKO,IAAI,cAATP,aAAe,CAAC;QAC9BH,WAAWhD,MAAMmD,KAAKI,GAAG,CAAC,WAAW;QACrCR,YAAYA,aAAa5C,QAAOgD,YAAAA,KAAKI,GAAG,CAAC,wBAATJ,YAAyB,IAAI/C,IAAI;QACjE,IAAI,CAAC2C,WAAW;YACd,OAAOrD,OAAO,KAAK;QACrB;IACF;IAEA,IAAIqD,WAAW;YAUGY;QAThB,MAAMC,WAAWjB,SACb,MAAM3E,yBAAyBwC,QAAQ,cACvC+B,UAAUa,UAAU,CAAC,QAAQC,GAAG,CAAC/B,OAAO8B,UAAU,CAAC;QACvD,MAAMO,UAAU,MAAMC,SAASP,GAAG,CAACN,WAAWQ,GAAG;QACjD,IAAI,CAACI,QAAQH,MAAM,IAAI,CAACX,QAAQc,QAAQJ,GAAG,CAAC,eAAe;YACzD,OAAO7D,OAAO,KAAK;QACrB;QACA,MAAMsC,QAAQrF,sBAAsBgH,QAAQJ,GAAG,CAAC;QAChD,IAAI,CAACvB,OAAO,OAAOtC,OAAO,KAAK;QAC/B,MAAMmE,UAAUF,gBAAAA,QAAQD,IAAI,cAAZC,gBAAkB,CAAC;QACnCjB,KAAKK,SAAS,GAAGY,QAAQF,EAAE;QAC3B,IAAIhB,IAAIqB,MAAM,EAAEpB,KAAKoB,MAAM,GAAGrB,IAAIqB,MAAM;QACxCd,WACEA,YAAYhD,MAAM6D,MAAM,CAAC,SAAS,EAAE,QAAQ9H,sBAAsB8H,OAAO,CAAC,EAAE,IAAI;QAClF,OAAO;YAAE/D,IAAI;YAAMkC;YAAO6B;YAAQF,SAASE;YAAQnB;YAAMM;YAAUG,MAAMF;YAAYc,MAAM;QAAK;IAClG;IAEA,IAAItB,IAAIqB,MAAM,EAAE;YAWEC,YAGSA;QAbzB,IAAI,CAACvD,QAAQ,OAAOd,OAAO,KAAK;QAChC,MAAMqE,OAAO,MAAMxB,UAChBa,UAAU,CAAC,SACXC,GAAG,CAAC7C,QACJ4C,UAAU,CAAC,SACXC,GAAG,CAACZ,IAAIqB,MAAM,EACdP,GAAG;QACN,IAAI,CAACQ,KAAKP,MAAM,EAAE,OAAO9D,OAAO,KAAK;QACrC,MAAMsC,QAAQrF,sBAAsBoH,KAAKR,GAAG,CAAC;QAC7C,IAAI,CAACvB,OAAO,OAAOtC,OAAO,KAAK;QAC/B,MAAMmE,UAAUE,aAAAA,KAAKL,IAAI,cAATK,aAAe,CAAC;QAChCrB,KAAKoB,MAAM,GAAGC,KAAKN,EAAE;QACrB,qEAAqE;QACrE,MAAMO,YAAY7D,QAAO4D,YAAAA,KAAKR,GAAG,CAAC,iCAATQ,YAAkC,IAAI3D,IAAI;QACnE,IAAI4D,WAAWtB,KAAKK,SAAS,GAAGiB;QAChC,OAAO;YAAElE,IAAI;YAAMkC;YAAO6B;YAAQF,SAAS;YAAMjB;YAAMM,UAAUxC;YAAQ2C,MAAM;YAAMY,MAAMF;QAAO;IACpG;IAEA,OAAOnE,OAAO,KAAK;AACrB;AAEA;;;;CAIC,GACD,eAAeuE,mBACb1B,SAAsC,EACtCC,KAAoB,EACpBlB,KAAa,EACb0B,QAAgB;IAEhB,IAAIR,MAAMhC,MAAM,EAAE,OAAO;QAAEV,IAAI;QAAMU,QAAQgC,MAAMhC,MAAM;IAAC;IAC1D,IAAIwC,UAAU,OAAO;QAAElD,IAAI;QAAMU,QAAQwC;IAAS;IAClD,MAAMkB,QAAQ,MAAMzF,WAAW8D,WAAWjB;IAC1C,IAAI4C,MAAMpD,MAAM,KAAK,GAAG,OAAO;QAAEhB,IAAI;QAAMU,QAAQ0D,KAAK,CAAC,EAAE;IAAC;IAC5D,OAAOxE,OAAO,KAAKD,6BAA6B;QAAE0E,QAAQ;IAAO;AACnE;AAEA,0DAA0D,GAC1D,SAASC,YAAYC,MAAuB;QAEpB;IADtB,MAAMF,SAAS/G,kBAAkBiH;IACjC,MAAMC,SAASnE,QAAO,iBAAA,AAACkE,OAAgCC,MAAM,YAAvC,iBAA2C;IACjE,IAAIH,WAAW,qBAAqB;QAClC,OAAOzE,OAAO,KAAK4E,UAAU,+CAA+C;YAC1EH,QAAQ;QACV;IACF;IACA,IAAIA,WAAW,gBAAgBA,WAAW,aAAa;QACrD,OAAOzE,OAAO,KAAKJ,8BAA8B;YAAE6E,QAAQ;QAAa;IAC1E;IACA,IAAIA,WAAW,gBAAgB;QAC7B,MAAMI,YAAYC,OAAO,AAACH,OAAmCE,SAAS;QACtE,OAAO7E,OACL,KACA,mEACA;YACEyE,QAAQ;YACRM,mBAAmBD,OAAOE,QAAQ,CAACH,aAC/BI,KAAKzE,GAAG,CAAC,GAAGyE,KAAKC,IAAI,CAAC,AAACL,CAAAA,YAAYM,KAAKC,GAAG,EAAC,IAAK,SACjD;QACN;IAEJ;IACA,IAAIX,WAAW,gBAAgB;QAC7B,OAAOzE,OAAO,KAAK,+CAA+C;YAChEyE,QAAQ;QACV;IACF;IACA,OAAOzE,OAAO,KAAK,gCAAgC;QAAEyE,QAAQ;IAAc;AAC7E;AAEA,2FAA2F,GAC3F,OAAO,MAAMY,sCACX,8EACA,4CAA2C;AAE7C;;;;;;CAMC,GACD,eAAeC,gBAAgBC,OAM9B;IACC,MAAM,EAAEC,SAAS,EAAEC,MAAM,EAAE3E,MAAM,EAAE4E,OAAO,EAAEC,MAAM,EAAE,GAAGJ;IACvD,MAAMK,YAAYD,OAAOE,IAAI,CAAC,CAACC,QAAUA,MAAM3E,UAAU,CAAC;IAC1D,MAAM4E,OAAOH,YAAY,MAAM5H,gBAAgB8C,QAAQa,KAAK,CAAC,IAAM,QAAQ;IAC3E,OAAO;QACLsC,SAASuB,UAAUvB,OAAO;QAC1B+B,gBAAgBN;QAChBrB,MAAMmB,UAAUnB,IAAI;QACpBZ,MAAM+B,UAAU/B,IAAI;QACpBgC,QAAQ;YAAElD,MAAMkD,OAAOlD,IAAI;YAAED,OAAOmD,OAAOnD,KAAK;QAAC;QACjD2D,MAAM;YAAE1D,MAAMjC,MAAMyF,wBAAAA,IAAM,CAAC,cAAc,EAAE;QAAK;IAClD;AACF;AAEA,SAASG,OAAOC,GAAsB,EAAExD,OAAgB;IACtD,MAAMyD,QAAQzD,QAAQtC,IAAI,CAAC,oBAAoB;IAC/C,IAAI,OAAO+F,UAAU,UAAUD,IAAIE,SAAS,CAAC,eAAe5F,OAAO2F;IACnED,IAAIlG,MAAM,CAAC0C,QAAQ1C,MAAM,EAAEqG,IAAI,CAAC3D,QAAQtC,IAAI;AAC9C;AAEA,OAAO,MAAMkG,sBAAwC,OAAO1F,KAAKsF;QAMjDtF,WAuBWR;IA5BzB,IAAIQ,IAAI2F,MAAM,KAAK,QAAQ;QACzBL,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIlG,MAAM,CAAC,KAAKqG,IAAI,CAAC;YAAEpG,OAAO;QAAqB;QACnD;IACF;IACA,MAAMG,QAAQQ,YAAAA,IAAIR,IAAI,YAARQ,YAAY,CAAC;IAC3B,MAAM4F,aAAazH,kBAAkBqB;IACrC,IAAI,CAACoG,YAAY;QACfN,IAAIlG,MAAM,CAAC,KAAKqG,IAAI,CAAC;YAAEpG,OAAO;QAAiB;QAC/C;IACF;IACA,MAAM6C,MAAM;QACVM,WAAW/C,MAAMD,IAAI,CAAC,YAAY,EAAE;QACpC+D,QAAQ9D,MAAMD,IAAI,CAAC,SAAS,EAAE;QAC9BmD,QAAQlD,MAAMD,IAAI,CAAC,SAAS,EAAE;IAChC;IACA,IAAI,CAAC0C,IAAIM,SAAS,IAAI,CAACN,IAAIqB,MAAM,IAAI,CAACrB,IAAIS,MAAM,EAAE;QAChD2C,IAAIlG,MAAM,CAAC,KAAKqG,IAAI,CAAC;YAAEpG,OAAO;QAAyC;QACvE;IACF;IACA,MAAMwG,eAAepG,MAAMD,IAAI,CAAC,UAAU,EAAE3D;IAC5C,IAAI,CAACgK,cAAc;QACjBP,IAAIlG,MAAM,CAAC,KAAKqG,IAAI,CAAC;YAAEpG,OAAO;QAAmB;QACjD;IACF;IACA,yEAAyE;IACzE,uEAAuE;IACvE,4BAA4B;IAC5B,MAAMyG,YAAYlG,QAAOJ,aAAAA,IAAI,CAAC,OAAO,YAAZA,aAAgB,IACtCuG,OAAO,CAAC,UAAU,MAClBlG,IAAI,GACJC,KAAK,CAAC,GAAGnE;IACZ,IAAI,CAACmK,WAAW;QACdR,IAAIlG,MAAM,CAAC,KAAKqG,IAAI,CAAC;YAAEpG,OAAO;QAAqB;QACnD;IACF;IAEA,IAAI;QACF,MAAMuF,SACJgB,WAAWvD,KAAK,KAAK,QACjB,MAAMV,mBAAmB3B,KAAK4F,WAAW7E,KAAK,IAC9C,MAAMhB,gBAAgBC,KAAK4F,WAAW3F,MAAM;QAClD,IAAI2E,OAAOrF,EAAE,KAAK,OAAO,OAAO8F,OAAOC,KAAKV;QAC5C,MAAM,EAAE7D,KAAK,EAAEC,GAAG,EAAE,GAAG4D;QACvB;;;;;KAKC,GACD,MAAMoB,QAAQ5H,gBAAgB4C,KAAK;QACnC,IAAIgF,OAAO,OAAOX,OAAOC,KAAK;YAAE/F,IAAI;YAAOH,QAAQ4G,MAAM5G,MAAM;YAAEI,MAAMwG,MAAMxG,IAAI;QAAC;QAElF;;;;;;KAMC,GACD,MAAMyG,OAAO,MAAMlJ,iBAAiB,CAAC,mBAAmB,EAAE6H,OAAOxD,GAAG,EAAE,EAAE;YACtE8E,OAAO7H;YACP8H,UAAU7H;QACZ;QACA,IAAI,CAAC2H,KAAKzE,OAAO,EAAE;YACjB,OAAO6D,OACLC,KACAnG,OAAO,KAAKF,wBAAwB;gBAClC2E,QAAQ;gBACRM,mBAAmBE,KAAKzE,GAAG,CACzB,GACAyE,KAAKC,IAAI,CAAC,AAAC4B,CAAAA,KAAKG,OAAO,GAAG9B,KAAKC,GAAG,EAAC,IAAK;YAE5C;QAEJ;QAEA,MAAMvC,YAAY9E,cAAcwD,GAAG,GAAGsB,SAAS;QAC/C,MAAM2C,YAAY,MAAM5C,iBAAiBC,WAAW4D,YAAY7E,OAAOmB;QACvE,IAAIyC,UAAUpF,EAAE,KAAK,OAAO,OAAO8F,OAAOC,KAAKX;QAC/C,uEAAuE;QACvE,qEAAqE;QACrE,cAAc;QACd,MAAMS,OAAO,MAAM1B,mBAAmB1B,WAAW4D,YAAY7E,OAAO4D,UAAUlC,QAAQ;QACtF,IAAI2C,KAAK7F,EAAE,KAAK,OAAO,OAAO8F,OAAOC,KAAKF;QAC1C,MAAM,EAAEnF,MAAM,EAAE,GAAGmF;QACnB,MAAMiB,QAAQ,MAAMvJ,oBAAoBmD,QAAQe;QAChD,qEAAqE;QACrE,+DAA+D;QAC/D,IAAI2D,UAAUvB,OAAO,EAAE;YACrB,MAAMkD,QAAQjK,iBAAiBsI,UAAUvB,OAAO,EAAEiD,MAAMxB,OAAO;YAC/D,IAAIyB,MAAMC,SAAS,EAAE5B,UAAUxC,IAAI,CAACoE,SAAS,GAAGD,MAAMC,SAAS;QACjE;QAEA;;;;;;;KAOC,GACD,MAAMzB,SAAS7I,iBAAiB,GAAG4J,aAAa,EAAE,EAAEC,WAAW;QAC/D,MAAMU,QAAQ1B,OAAOvE,MAAM,GACvB,MAAMkE,gBAAgB;YAAEE;YAAWC;YAAQ3E;YAAQ4E,SAASwB,MAAMxB,OAAO;YAAEC;QAAO,KAClF;QACJ,MAAM2B,UAAUD,QACZhK,qBAAqBqJ,cAAcW,OAAO3G,IAAI,GAAGC,KAAK,CAAC,GAAGjE,yBAC1DgK;QACJ,MAAMa,OAAOF,QACThK,qBAAqBsJ,WAAWU,OAAO3G,IAAI,GAAGC,KAAK,CAAC,GAAGnE,sBACvDmK;QACJ,IAAI,CAACW,WAAW,CAACC,MAAM;YACrB,OAAOrB,OAAOC,KAAKnG,OAAO,KAAKqF,qCAAqC;gBAAEZ,QAAQ;YAAQ;QACxF;QAEA,sEAAsE;QACtE,uEAAuE;QACvE,8CAA8C;QAC9C,MAAM+C,SAAS3E,UAAUa,UAAU,CAAC,QAAQC,GAAG,CAAC/B;QAChD,IAAI,CAACjF,sBAAsB,MAAMkB,4BAA4B2J,QAAQhC,UAAUxC,IAAI,IAAI;YACrF,OAAOkD,OAAOC,KAAKnG,OAAO,KAAK1D,+BAA+B;gBAAEmI,QAAQ;YAAU;QACpF;QAEA,yDAAyD;QACzD,MAAMgD,WAAW,MAAM3J,sBAAsBgD,QAAQ;YAAC0E,UAAUlD,KAAK;SAAC;QACtE,IAAI,CAACmF,SAASrG,MAAM,EAAE;YACpB,OAAO8E,OAAOC,KAAKnG,OAAO,KAAKJ,8BAA8B;gBAAE6E,QAAQ;YAAa;QACtF;QACA,IAAIrH,mBAAmBoI,UAAUrB,MAAM,EAAE+C,OAAOQ,KAAK,KAAK,YAAY;YACpE,OAAOxB,OAAOC,KAAKnG,OAAO,KAAKH,4BAA4B;gBAAE4E,QAAQ;YAAW;QAClF;QACA,yEAAyE;QACzE,wEAAwE;QACxE,kCAAkC;QAClC,MAAMkD,eAAe9K,kBAAkBM,eAAeqI,UAAUrB,MAAM;QACtE,IAAIwD,cAAc;YAChB,OAAOzB,OAAOC,KAAKnG,OAAO,KAAK2H,cAAc;gBAAElD,QAAQ;YAAc;QACvE;QAEA,IAAI,CAACjH,qBAAqB;YACxB,OAAO0I,OACLC,KACAnG,OAAO,KAAK,+CAA+C;gBACzDyE,QAAQ;YACV;QAEJ;QACA,MAAMmD,WAAW,MAAM1J,oBAAoB4C;QAC3C,IAAI8G,SAASjF,OAAO,EAAE;YACpB,OAAOuD,OACLC,KACAnG,OAAO,KAAK4H,SAASjF,OAAO,CAACkF,OAAO,EAAE;gBAAEpD,QAAQ;YAAmB;QAEvE;QAEA,MAAMW,MAAM,IAAID;QAChB;;;;;;;;;;;;;;;KAeC,GACD,MAAM,EAAE2C,IAAI,EAAEC,QAAQ,EAAE,GAAG,MAAMxJ,iBAAiB;YAChDqD;YACAoG,WAAW,AAACnG,GAA+B,CAAC,YAAY;YACxDuD,KAAKA,IAAI6C,OAAO;QAClB;QACA,IAAIF,UAAU;YACZ,OAAO7B,OACLC,KACAnG,OAAO,KAAKL,oCAAoC;gBAC9C8E,QAAQ;YACV;QAEJ;QAEA,kEAAkE;QAClE,sEAAsE;QACtE,uEAAuE;QACvE,wEAAwE;QACxE,6BAA6B;QAC7B,MAAMyD,QAAQ,MAAMxJ,oBAAoBmE,WAAWjB,OAAOC,KAAKuD,KAAK0C;QACpE,IAAI,CAACI,MAAM9H,EAAE,EAAE;YACb,MAAM,EAAE+H,KAAK,EAAE,GAAGD;YAClB,qEAAqE;YACrE,oEAAoE;YACpE,kEAAkE;YAClE,oEAAoE;YACpE,MAAME,YACJD,MAAM7I,QAAQ,GAAGhC,uBAAuBuE,KAAKwG,eAAe;YAC9D,OAAOnC,OACLC,KACAnG,OACE,KACAmI,MAAM7I,QAAQ,IAAI,IACdF,iCACAgJ,YACE5I,2BAA2B2I,MAAM7I,QAAQ,EAAE6I,MAAMzI,IAAI,IACrDL,0BAA0B8I,MAAM7I,QAAQ,GAC9C;gBACEmF,QAAQ2D,YAAY,SAAS;gBAC7B9I,UAAU6I,MAAM7I,QAAQ;gBACxBI,MAAMyI,MAAMzI,IAAI;gBAChB4I,YAAYH,MAAMI,QAAQ,CAACN,OAAO;YACpC;QAGN;QAEA,wEAAwE;QACxE,kEAAkE;QAClE,MAAMO,cAAcnK,kBAAkBwE,WAAWjB;QACjD,IAAI+C;QACJ,IAAI;YACFA,SAAS,MAAMlH,UAAU;gBACvBgL,IAAIjD,UAAUlD,KAAK;gBACnBgF;gBACAC;gBACAmB,iBAAiBd;gBACjBe,UAAU;gBACVC,SAASnM;gBACToM,SAASpD,OAAOnD,KAAK;eACjBmD,OAAOlD,IAAI,GAAG;gBAAEuG,UAAUrD,OAAOlD,IAAI;YAAC,IAAI,CAAC;gBAC/CwG,MAAMnM,qBAAqB;oBAAEgF;oBAAOd;oBAAQkI,YAAYR,YAAYzE,EAAE;gBAAC;;QAE3E,EAAE,OAAO7D,OAAO;YACd,iEAAiE;YACjE,uCAAuC;YACvC,MAAMzB,oBAAoBoE,WAAWqF,MAAMe,WAAW;YACtD,MAAM/I;QACR;QACA,IAAI,CAACyE,OAAOuE,IAAI,EAAE;YAChB,MAAMzK,oBAAoBoE,WAAWqF,MAAMe,WAAW;YACtD,OAAO/C,OAAOC,KAAKzB,YAAYC;QACjC;QAEA,MAAMwE,WAAWhE,KAAKC,GAAG;QACzB,IAAIgE,SAAS;QACb;;;;;;KAMC,GACD,MAAMC,eAAe7D,UAAUrB,MAAM,CAAC,YAAY;QAClD,MAAMmF,YACJ7C,WAAWvD,KAAK,KAAK,SAASqG,MAAMC,OAAO,CAACH,iBAAiBA,aAAajI,MAAM,GAC5EiI,aAAaI,GAAG,CAAChJ,UACjB1D,eAAe8E,KAAgCqF;QACrD,IAAI;YACF,MAAMtI,sBACJ4J,aACApM,sBAAsB;gBACpBkL;gBACAjH,MAAMkH;gBACNkB,IAAIjD,UAAUlD,KAAK;gBACnBoH,MAAMP;gBACNQ,OAAOlE,OAAOxD,GAAG;eACbwD,OAAOlD,IAAI,GAAG;gBAAEqH,QAAQnE,OAAOlD,IAAI;YAAC,IAAI,CAAC;gBAC7CS,MAAMwC,UAAUxC,IAAI;gBACpBlC;gBACAwI;;QAGN,EAAE,OAAOpJ,OAAO;YACd,oEAAoE;YACpE,oEAAoE;YACpEwB,QAAQxB,KAAK,CAAC,qCAAqC0B,OAAO4G,YAAYzE,EAAE,EAAE7D;YAC1EkJ,SAAS;QACX;QACA,uEAAuE;QACvE,+DAA+D;QAC/D,MAAM5K,iBAAiB;YACrBsE,OAAO;gBAAE+G,MAAM;gBAAOjI;YAAM;YAC5BkI,OAAO;YACPC,WAAW;YACXlH;QACF;QACA,kEAAkE;QAClE,6DAA6D;QAC7D,IAAI4D,WAAWvD,KAAK,KAAK,OAAO;YAC9B,MAAM/E,eACJyD,OACA;gBAAEK,KAAKwD,OAAOxD,GAAG;gBAAEK,OAAOmD,OAAOnD,KAAK;YAAC,GACvC,cACAkD,UAAUxC,IAAI,CAACK,SAAS,GACpB;gBAAE2G,MAAM;gBAAWjG,IAAIyB,UAAUxC,IAAI,CAACK,SAAS;gBAAEd,MAAMiD,UAAUlD,KAAK;YAAC,IACvE;gBAAE0H,MAAM;gBAAQjG,IAAIyB,UAAUxC,IAAI,CAACoB,MAAM;gBAAE7B,MAAMiD,UAAUlD,KAAK;YAAC;QAEzE;QACA6D,IAAIlG,MAAM,CAAC,KAAKqG,IAAI,CAAC;YACnBlG,IAAI;YACJ4I,YAAYR,YAAYzE,EAAE;YAC1B0E,IAAIjD,UAAUlD,KAAK;YACnB2H,MAAMrC,SAASqC,IAAI;YACnBb;QACF;IACF,EAAE,OAAOlJ,OAAO;QACdwB,QAAQxB,KAAK,CAAC,2BAA2BuG,YAAYvG;QACrDiG,IAAIlG,MAAM,CAAC,KAAKqG,IAAI,CAAC;YAAEpG,OAAO;QAA+B;IAC/D;AACF,EAAC;AAED,eAAeqG,oBAAmB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/email-send.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 type AglynOrgBilling,\n type AglynOrgMember,\n buildCrmEmailActivity,\n contactCaptureHostIds,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n CRM_EMAIL_BODY_MAX,\n CRM_EMAIL_CONTEXT,\n CRM_EMAIL_SUBJECT_MAX,\n type CrmActivityLink,\n crmActivityLogHasRoom,\n crmEmailDeliveryTags,\n emailStateRefusal,\n type CrmMergeContext,\n crmMergeFieldsIn,\n crmScopeTokens,\n isOrgWideMember,\n normalizeContactEmail,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n readContactFacet,\n readEmailState,\n readMarketingBasis,\n renderCrmMergeFields,\n resolveOrgEntitlements,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n isEmailConfigured,\n sendEmail,\n sendFailureReason,\n type SendEmailResult,\n} from '@aglyn/shared-util-email'\nimport {\n consentGroupForSite,\n consumeRateLimit,\n countCrmActivitiesForRecord,\n filterSendableForHost,\n firebaseAdmin,\n getHostDocAdmin,\n getOrgForHost,\n hostSendingIdentity,\n logOrgActivity,\n memberHasOrgPermission,\n newCrmActivityRef,\n orgDataCollectionForHost,\n readLeadForHost,\n readOrgEmailRamp,\n recordEmailSends,\n releaseCrmEmailSend,\n reserveCrmEmailSend,\n resolveOrgMembership,\n writeCrmEmailActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n orgHostIds,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/email-send` — one email to one person, from their record\n * (AGL-2615).\n *\n * Body: `{ hostId, contactId? | leadId? | dealId?, subject, body }`. Answers\n * `{ ok: true, activityId, to, from, logged }` once the provider accepted\n * the message, and a refusal with a `reason` the dialog can act on\n * otherwise. The subject and the body may carry merge fields —\n * `{{contact.firstName}}`, `{{deal.amount}}` (AGL-2658) — which this route\n * fills from the record before the send, so the timeline row is the letter\n * as it left; see `crm-email-templates.ts` for the grammar.\n *\n * ## What it is, and what it is not\n *\n * A message a teammate wrote to one person they have a relationship with —\n * a reply to a lead, a follow-up on a deal. It is not marketing: no\n * campaign audience, no unsubscribe pair, no frequency window, no consent\n * split. What it does owe is everything a bounce or a complaint owes the\n * shared sending domain, which is why BOTH suppression lists are consulted\n * and why a person's own stated refusal — a `declined` basis — is a hard\n * stop even though the message is not the kind of mail that basis governs.\n * The recipient asked this site not to write; the kind of writing is not\n * the point.\n *\n * ## Why the recipient is resolved here and not taken from the body\n *\n * The dialog shows the address the record carries, and this route reads it\n * again off the record the caller named, through the same visibility check\n * every other CRM route makes. A `to` in the body would make this a route\n * that sends the site's mail to any address a member types, which is a\n * different product with a different abuse surface.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, … }` from the org-level hub. The caller is authorized\n * by the org — an org-wide member holding `data.manage` — and the record is\n * read with no site's visibility to check, because an org-wide member reads\n * every row. What the org level cannot do away with is the SITE the message\n * leaves from: the sending identity, the suppression list and the person's\n * stated refusal are each a site's. So the org variant still names one —\n * the record's own capturing site or the site the reader picked, in the\n * body; failing that the record's own site read off the document; failing\n * that the org's only site — and refuses, with a `site` reason, when the\n * org has several and none was named. The row it writes is visible to\n * whoever may see the RECORD, so an email to a person another site\n * captured shows on their timeline under that site's hub; the act is\n * logged in the org's feed.\n *\n * ## The order of the gates\n *\n * Cheap and certain first. A malformed body and a missing token cost\n * nothing; the per-user pace is one counter; the record is one read and\n * decides everything after it; the ceiling one aggregate; the two\n * suppression lists two keyed reads; the sending identity next, because it\n * is the most expensive and the one a workspace fixes once. The daily cap\n * comes LAST and is the one gate that writes before the provider is called\n * (AGL-2645): the send is reserved on today's counter inside a transaction,\n * so two reps at the last slot cannot both pass one reading of it, and a\n * message the provider refuses gives the slot back. After the provider has\n * accepted, two more things are written, neither able to fail the send:\n * the activity row and the org's cost meter.\n *\n * ## The new-workspace ramp is part of that cap, not a gate beside it\n *\n * A message from a workspace created this morning lands on the sending\n * domain every other tenant's receipts leave on, so the plan's day is held\n * to the share of it the workspace has earned — the same ramp the campaign\n * path is paced by, `readOrgEmailRamp`, taken to the allowance this surface\n * meters instead of the campaign ceiling. It is handed to the reservation\n * rather than checked before it, so the ceiling and the count it is judged\n * against come out of the one transactional read; a ramp consulted\n * separately would be a second ceiling and two reps could pass it at once.\n * A workspace past its first week is not ramped and pays nothing to find\n * that out.\n */\n\n/** How many one-to-one emails one person may send in one minute. */\nexport const CRM_EMAIL_SENDS_PER_MINUTE = 20\nconst CRM_EMAIL_RATE_WINDOW_MS = 60_000\n\n/** What the route says when the plan carries no one-to-one email at all. */\nexport const CRM_EMAIL_NOT_INCLUDED_MESSAGE =\n 'Your plan does not include one-to-one email. Upgrade in Billing to email ' +\n 'people from their records.'\n\n/** What the route says at the daily cap; `included` is the plan's figure. */\nexport function crmEmailCapReachedMessage(included: number): string {\n return (\n `Today's one-to-one email limit (${included.toLocaleString('en-US')}) is ` +\n 'reached. It resets at midnight UTC — see Billing for the count.'\n )\n}\n\n/**\n * What the route says when the NEW-WORKSPACE RAMP is the ceiling that was\n * reached, rather than the plan's own.\n *\n * The campaign path's wording, pointed at this surface: the number, what was\n * spent against it, that nothing was sent or counted, and when it lifts. A\n * workspace told only \"limit reached\" against a figure far below the one its\n * plan page advertises would read it as a billing fault.\n */\nexport function crmEmailRampReachedMessage(\n ceiling: number,\n used: number,\n): string {\n return (\n `This workspace may send ${ceiling.toLocaleString('en-US')} one-to-one ` +\n `email${ceiling === 1 ? '' : 's'} a day while it establishes a sending ` +\n `history, and has sent ${used.toLocaleString('en-US')} today. Nothing ` +\n 'has been sent and nothing has been counted — the allowance grows as ' +\n \"the workspace sends, and is the plan's in full after its first week. \" +\n 'It resets at midnight UTC.'\n )\n}\n\n/** What the route says when the ramp's own history could not be read. */\nexport const CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE =\n \"This workspace's sending history could not be read just now, so how much \" +\n 'of its first-week email allowance it has earned is not known. Nothing has ' +\n 'been sent and nothing has been counted — try again in a moment.'\n\nexport const CRM_EMAIL_SUPPRESSED_MESSAGE =\n 'This address has bounced or reported a message as spam, so it cannot be ' +\n 'emailed.'\n\nexport const CRM_EMAIL_DECLINED_MESSAGE =\n 'This person asked not to be emailed by this site.'\n\nexport const CRM_EMAIL_RATE_MESSAGE =\n 'You have sent too many emails in the last minute. Wait a moment and try again.'\n\n/** What the org variant says when no site was named and the org has several. */\nexport const CRM_EMAIL_PICK_SITE_MESSAGE =\n 'Pick the site this email leaves from — its sending address is what the ' +\n 'message is sent as.'\n\ntype Refusal = { ok: false; status: number; body: Record<string, unknown> }\n\nconst refuse = (\n status: number,\n error: string,\n extra: Record<string, unknown> = {},\n): Refusal => ({ ok: false, status, body: { error, ...extra } })\n\n/** One typed field, trimmed and bounded. */\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\ninterface Sender {\n ok: true\n uid: string\n /** The address replies go to — the token's, never the body's. */\n email: string\n /** The name in front of the sending address, when the token carries one. */\n name: string\n orgId: string\n org: Partial<AglynOrgBilling>\n}\n\n/**\n * Who is sending, and whether they may: a verified ID token, then\n * `data.manage` on the site's org through the same three-layer read the\n * create route makes, admitted only for a site the member reaches. Staff\n * pass the permission check as they do on every CRM route, and send as\n * themselves.\n */\nasync function authorizeSender(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Sender | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; name?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] email-send could not verify the sender', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const org = (resolved.org ?? {}) as Partial<AglynOrgBilling>\n if (decoded.staff !== true) {\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Sending email from a record requires the data.manage permission on this site',\n )\n }\n }\n const email = normalizeContactEmail(decoded.email)\n if (!email) {\n // A reply-to is owed and the token is the only place it can honestly\n // come from; an account with no address cannot be replied to.\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: decoded.uid,\n email,\n name: typed(decoded.name, 120),\n orgId,\n org,\n }\n}\n\n/**\n * The org variant's sender (AGL-2634): authorized by the org for an\n * org-wide member holding `data.manage`, with the same reply-to rule.\n */\nasync function authorizeOrgSender(\n req: PluginApiRequest,\n orgId: string,\n): Promise<Sender | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: 'data.manage',\n refusal:\n 'Sending email at the organization level requires the data.manage ' +\n 'permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n const email = normalizeContactEmail(caller.email)\n if (!email) {\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: caller.uid,\n email,\n name: caller.name,\n orgId,\n org: caller.org as Partial<AglynOrgBilling>,\n }\n}\n\ninterface Recipient {\n ok: true\n email: string\n /** The document the consent basis is read off — the contact, or the lead. */\n record: Record<string, unknown>\n /** The contact document, when one was read — where the company link is. */\n contact: Record<string, unknown> | null\n link: CrmActivityLink\n /**\n * The site the record itself names — a contact's first capturing site, a\n * deal's own, a lead's — which is the site an org-level send leaves from\n * when the body named none.\n */\n siteHint: string\n /** The deal document, when one was named — what `{{deal.*}}` reads (AGL-2658). */\n deal: Record<string, unknown> | null\n /** The lead document, when the recipient is a lead — what `{{lead.*}}` reads. */\n lead: Record<string, unknown> | null\n}\n\n/**\n * The person the named record is about, as THIS site may see them.\n *\n * A deal names its contact; a lead carries its own address; a contact is\n * itself. Each is looked up by id and then checked against what the site\n * may read — the Admin SDK evaluates no rules, and a member of site A must\n * not be able to mail site B's contact by guessing an id. The links the\n * activity is filed under are whatever resolved: a deal's email is also the\n * contact's, so it lands on both timelines.\n *\n * At the organization level there is no site to check against and none is\n * checked: the caller was admitted as an org-wide member, who reads every\n * row. A lead still needs its site named — a lead lives under one.\n */\nasync function resolveRecipient(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n ids: { contactId: string; leadId: string; dealId: string },\n): Promise<Recipient | Refusal> {\n const link: CrmActivityLink = {}\n const { hostId } = scope\n const scoped = scope.level === 'site'\n const visible = (tokens: unknown) =>\n !scoped || visibleToHost(tokens as readonly string[], hostId)\n let contactId = ids.contactId\n let siteHint = ''\n let dealRecord: Record<string, unknown> | null = null\n\n if (ids.dealId) {\n const deal = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.deals)\n .doc(ids.dealId)\n .get()\n if (!deal.exists || !visible(deal.get('visibleTo'))) {\n return refuse(404, 'Unknown deal')\n }\n link.dealId = deal.id\n dealRecord = (deal.data() ?? {}) as Record<string, unknown>\n siteHint = typed(deal.get('hostId'), 128)\n contactId = contactId || String(deal.get('contactId') ?? '').trim()\n if (!contactId) {\n return refuse(400, 'This deal names no contact to email.')\n }\n }\n\n if (contactId) {\n const contacts = scoped\n ? await orgDataCollectionForHost(hostId, 'contacts')\n : firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await contacts.doc(contactId).get()\n if (!contact.exists || !visible(contact.get('visibleTo'))) {\n return refuse(404, 'Unknown contact')\n }\n const email = normalizeContactEmail(contact.get('email'))\n if (!email) return refuse(400, 'This contact has no email address.')\n const record = (contact.data() ?? {}) as Record<string, unknown>\n link.contactId = contact.id\n if (ids.leadId) link.leadId = ids.leadId\n siteHint =\n siteHint || typed(record['hostId'], 128) || contactCaptureHostIds(record)[0] || ''\n return { ok: true, email, record, contact: record, link, siteHint, deal: dealRecord, lead: null }\n }\n\n if (ids.leadId) {\n if (!hostId) return refuse(400, 'Name the site the lead lives under.')\n const lead = await readLeadForHost(hostId, ids.leadId)\n if (!lead) return refuse(404, 'Unknown lead')\n const email = normalizeContactEmail(lead.get('email'))\n if (!email) return refuse(400, 'This lead has no email address.')\n const record = (lead.data() ?? {}) as Record<string, unknown>\n link.leadId = lead.id\n // A converted lead's email belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return { ok: true, email, record, contact: null, link, siteHint: hostId, deal: null, lead: record }\n }\n\n return refuse(400, 'Name a contact, lead or deal to email.')\n}\n\n/**\n * The site an org-level send leaves from (AGL-2634): the body's, else the\n * record's own, else the org's only site — and a refusal the dialog turns\n * into a Site picker when the org has several and none was named.\n */\nasync function resolveSendingSite(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n siteHint: string,\n): Promise<{ ok: true; hostId: string } | Refusal> {\n if (scope.hostId) return { ok: true, hostId: scope.hostId }\n if (siteHint) return { ok: true, hostId: siteHint }\n const hosts = await orgHostIds(firestore, orgId)\n if (hosts.length === 1) return { ok: true, hostId: hosts[0] }\n return refuse(409, CRM_EMAIL_PICK_SITE_MESSAGE, { reason: 'site' })\n}\n\n/** The HTTP shape of a send the provider did not accept. */\nfunction sendRefusal(result: SendEmailResult): Refusal {\n const reason = sendFailureReason(result)\n const detail = String((result as { detail?: unknown }).detail ?? '')\n if (reason === 'unverified-domain') {\n return refuse(409, detail || 'This site has no verified sending identity.', {\n reason: 'sending-identity',\n })\n }\n if (reason === 'suppressed' || reason === 'unengaged') {\n return refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' })\n }\n if (reason === 'rate-limited') {\n const retryAtMs = Number((result as { retryAtMs?: unknown }).retryAtMs)\n return refuse(\n 503,\n 'The mail provider asked us to slow down. Try again in a minute.',\n {\n reason: 'provider-rate',\n retryAfterSeconds: Number.isFinite(retryAtMs)\n ? Math.max(1, Math.ceil((retryAtMs - Date.now()) / 1000))\n : 60,\n },\n )\n }\n if (reason === 'unconfigured') {\n return refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n })\n }\n return refuse(502, 'The email could not be sent.', { reason: 'send-failed' })\n}\n\n/** What the route says when a subject or a message is nothing once its fields are filled. */\nexport const CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE =\n 'The subject and the message are empty once their merge fields are filled ' +\n 'in. Check the fields against this record.'\n\n/**\n * What `{{…}}` reads from, for this send (AGL-2658): the documents already\n * in hand — the contact, the deal, the lead — the sender off their token,\n * and the site's name, read only when a field asks for it. The contact's\n * facet is the SENDING site's group's, because a name, a job title and a\n * company are one holder's knowledge of a person.\n */\nasync function mergeContextFor(options: {\n recipient: Recipient\n sender: Sender\n hostId: string\n groupId: string\n fields: readonly string[]\n}): Promise<CrmMergeContext> {\n const { recipient, sender, hostId, groupId, fields } = options\n const wantsSite = fields.some((field) => field.startsWith('site.'))\n const host = wantsSite ? await getHostDocAdmin(hostId).catch(() => null) : null\n return {\n contact: recipient.contact,\n contactGroupId: groupId,\n lead: recipient.lead,\n deal: recipient.deal,\n sender: { name: sender.name, email: sender.email },\n site: { name: typed(host?.['displayName'], 200) },\n }\n}\n\nfunction answer(res: PluginApiResponse, refusal: Refusal): void {\n const retry = refusal.body['retryAfterSeconds']\n if (typeof retry === 'number') res.setHeader('Retry-After', String(retry))\n res.status(refusal.status).json(refusal.body)\n}\n\nexport const crmEmailSendHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const routeScope = readCrmRouteScope(body)\n if (!routeScope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const ids = {\n contactId: typed(body['contactId'], 128),\n leadId: typed(body['leadId'], 128),\n dealId: typed(body['dealId'], 128),\n }\n if (!ids.contactId && !ids.leadId && !ids.dealId) {\n res.status(400).json({ error: 'Name a contact, lead or deal to email.' })\n return\n }\n const draftSubject = typed(body['subject'], CRM_EMAIL_SUBJECT_MAX)\n if (!draftSubject) {\n res.status(400).json({ error: 'Enter a subject.' })\n return\n }\n // Paragraphs are what the dialog offers, and a plain-text part is what a\n // paragraph is; line endings are normalized so the stored body and the\n // sent body are one string.\n const draftText = String(body['body'] ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .trim()\n .slice(0, CRM_EMAIL_BODY_MAX)\n if (!draftText) {\n res.status(400).json({ error: 'Write the message.' })\n return\n }\n\n try {\n const sender =\n routeScope.level === 'org'\n ? await authorizeOrgSender(req, routeScope.orgId)\n : await authorizeSender(req, routeScope.hostId)\n if (sender.ok === false) return answer(res, sender)\n const { orgId, org } = sender\n /*\n * THE PLAN, before the pace and the cap (AGL-2787). One-to-one email is\n * the suite's; a plan without the suite is refused as such, rather than\n * reaching the daily cap and being told about a zero it has no way to\n * raise short of the upgrade this names.\n */\n const suite = crmSuiteRefusal(org, 'Emailing one person from their record')\n if (suite) return answer(res, { ok: false, status: suite.status, body: suite.body })\n\n /*\n * THE PER-USER PACE, on its own key. The console dispatcher already\n * counts this write against the person's general console budget; this\n * is the narrower bucket the feature owes — twenty a minute is a rep\n * writing, and past it is a script — keyed by the uid the token proved\n * so one person cannot spend the bucket of everyone on their address.\n */\n const rate = await consumeRateLimit(`crm-email-send:uid:${sender.uid}`, {\n limit: CRM_EMAIL_SENDS_PER_MINUTE,\n windowMs: CRM_EMAIL_RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n return answer(\n res,\n refuse(429, CRM_EMAIL_RATE_MESSAGE, {\n reason: 'rate',\n retryAfterSeconds: Math.max(\n 1,\n Math.ceil((rate.resetMs - Date.now()) / 1000),\n ),\n }),\n )\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const recipient = await resolveRecipient(firestore, routeScope, orgId, ids)\n if (recipient.ok === false) return answer(res, recipient)\n // The site the message leaves from — the mounted one under a site, and\n // at the organization level whatever the body, the record or the org\n // can answer.\n const site = await resolveSendingSite(firestore, routeScope, orgId, recipient.siteHint)\n if (site.ok === false) return answer(res, site)\n const { hostId } = site\n const group = await consentGroupForSite(hostId, org as Record<string, unknown>)\n // The company the sending site files this person under, so the email\n // shows on the company's log as the automation step's rows do.\n if (recipient.contact) {\n const facet = readContactFacet(recipient.contact, group.groupId)\n if (facet.companyId) recipient.link.companyId = facet.companyId\n }\n\n /*\n * MERGE FIELDS (AGL-2658), filled here rather than in the dialog: the\n * dialog previews with the same resolver, but what leaves and what is\n * logged is rendered by the route, off the documents it read to find\n * the recipient, so a preview the rep did not look at cannot differ\n * from the letter. A subject or a message that is nothing once its\n * fields are filled is refused before any gate is spent on it.\n */\n const fields = crmMergeFieldsIn(`${draftSubject}\\n${draftText}`)\n const merge = fields.length\n ? await mergeContextFor({ recipient, sender, hostId, groupId: group.groupId, fields })\n : null\n const subject = merge\n ? renderCrmMergeFields(draftSubject, merge).trim().slice(0, CRM_EMAIL_SUBJECT_MAX)\n : draftSubject\n const text = merge\n ? renderCrmMergeFields(draftText, merge).trim().slice(0, CRM_EMAIL_BODY_MAX)\n : draftText\n if (!subject || !text) {\n return answer(res, refuse(400, CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE, { reason: 'merge' }))\n }\n\n // The per-record ceiling, before the send: an email that could not be\n // logged would be a message the timeline never shows, and the timeline\n // is the reason the send lives on the record.\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, recipient.link))) {\n return answer(res, refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE, { reason: 'ceiling' }))\n }\n\n // Both suppression lists, then the person's own refusal.\n const sendable = await filterSendableForHost(hostId, [recipient.email])\n if (!sendable.length) {\n return answer(res, refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' }))\n }\n if (readMarketingBasis(recipient.record, group).basis === 'declined') {\n return answer(res, refuse(409, CRM_EMAIL_DECLINED_MESSAGE, { reason: 'declined' }))\n }\n // The record's own verdict (AGL-3245): what the lists hold, in the words\n // the page shows — a bounce, a gateway block, a do-not-contact mark the\n // suppression lists do not carry.\n const stateRefusal = emailStateRefusal(readEmailState(recipient.record))\n if (stateRefusal) {\n return answer(res, refuse(409, stateRefusal, { reason: 'email-state' }))\n }\n\n if (!isEmailConfigured()) {\n return answer(\n res,\n refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n }),\n )\n }\n const identity = await hostSendingIdentity(hostId)\n if (identity.refusal) {\n return answer(\n res,\n refuse(409, identity.refusal.message, { reason: 'sending-identity' }),\n )\n }\n\n const now = new Date()\n /*\n * THE NEW-WORKSPACE RAMP, resolved for the ceiling below.\n *\n * A workspace past its first week graduates off the org record alone and\n * pays no read for this. One that is still on the ramp needs its\n * delivery history to know which step it has earned, and when that\n * history is unreadable the step is a floor rather than an answer.\n *\n * A floor is enough for a campaign, which defers what it cannot send\n * today and delivers it tomorrow. It is not enough here: this route\n * admits or refuses ONE message and has no third answer, so a ramp that\n * cannot say what a workspace has earned refuses and says so. The\n * refusal is transient by construction — it lasts as long as the window\n * read is failing — and it reaches only workspaces inside their first\n * week, since every other one graduated before the window was consulted.\n */\n const { ramp, degraded } = await readOrgEmailRamp({\n orgId,\n createdAt: (org as Record<string, unknown>)['createdAt'],\n now: now.getTime(),\n })\n if (degraded) {\n return answer(\n res,\n refuse(503, CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE, {\n reason: 'ramp-unavailable',\n }),\n )\n }\n\n // THE DAILY CAP (AGL-2611), taken as a slot rather than read as a\n // figure (AGL-2645): the transaction reads today's counter, judges it\n // with `checkCrmEmailQuota` against the ramp's share of the plan's day\n // and raises it in one step, so the count the cap enforces is the count\n // this send was admitted on.\n const claim = await reserveCrmEmailSend(firestore, orgId, org, now, ramp)\n if (!claim.ok) {\n const { quota } = claim\n // WHICH ceiling was reached, in the org's own terms. The reservation\n // answers with the number it judged, which is the plan's day unless\n // the ramp held this workspace below it — and a workspace shown a\n // figure under the one its plan page advertises is owed the reason.\n const rampBound =\n quota.included < resolveOrgEntitlements(org).crmEmailsPerDay\n return answer(\n res,\n refuse(\n 409,\n quota.included <= 0\n ? CRM_EMAIL_NOT_INCLUDED_MESSAGE\n : rampBound\n ? crmEmailRampReachedMessage(quota.included, quota.used)\n : crmEmailCapReachedMessage(quota.included),\n {\n reason: rampBound ? 'ramp' : 'quota',\n included: quota.included,\n used: quota.used,\n resetsAtMs: quota.resetsAt.getTime(),\n },\n ),\n )\n }\n\n // The row's id, minted before the send so the delivery webhook can find\n // it from the tags alone; written only once the message has left.\n const activityRef = newCrmActivityRef(firestore, orgId)\n let result: SendEmailResult\n try {\n result = await sendEmail({\n to: recipient.email,\n subject,\n text,\n sendingIdentity: identity,\n audience: 'tenant',\n context: CRM_EMAIL_CONTEXT,\n replyTo: sender.email,\n ...(sender.name ? { fromName: sender.name } : {}),\n tags: crmEmailDeliveryTags({ orgId, hostId, activityId: activityRef.id }),\n })\n } catch (error) {\n // The provider never answered, so no message left: the slot goes\n // back before the failure is reported.\n await releaseCrmEmailSend(firestore, claim.reservation)\n throw error\n }\n if (!result.sent) {\n await releaseCrmEmailSend(firestore, claim.reservation)\n return answer(res, sendRefusal(result))\n }\n\n const sentAtMs = Date.now()\n let logged = true\n /*\n * Who may list the row. Under a site, what a record created there is\n * stamped with. At the organization level the RECORD's own tokens, so\n * an email to a person another site captured shows on their timeline\n * under that site's hub — a row stamped with the sending site alone\n * would be invisible where the person is read.\n */\n const recordTokens = recipient.record['visibleTo']\n const visibleTo =\n routeScope.level === 'org' && Array.isArray(recordTokens) && recordTokens.length\n ? recordTokens.map(String)\n : crmScopeTokens(org as Record<string, unknown>, group)\n try {\n await writeCrmEmailActivity(\n activityRef,\n buildCrmEmailActivity({\n subject,\n body: text,\n to: recipient.email,\n atMs: sentAtMs,\n byUid: sender.uid,\n ...(sender.name ? { byName: sender.name } : {}),\n link: recipient.link,\n hostId,\n visibleTo,\n }),\n )\n } catch (error) {\n // The message has left. A row that could not be written is a gap on\n // the timeline, not a failed send, and it is said so in the answer.\n console.error('[crm] email activity write failed', orgId, activityRef.id, error)\n logged = false\n }\n // The cap's count was taken with the reservation; the org's cost meter\n // for COGS is stamped after delivery, unable to fail the send.\n await recordEmailSends({\n scope: { kind: 'org', orgId },\n count: 1,\n sendClass: 'transactional',\n firestore,\n })\n // The org-level act, in the org's feed: the site variant's is the\n // timeline row itself, under the site the record page is on.\n if (routeScope.level === 'org') {\n await logOrgActivity(\n orgId,\n { uid: sender.uid, email: sender.email },\n 'Sent email',\n recipient.link.contactId\n ? { type: 'contact', id: recipient.link.contactId, name: recipient.email }\n : { type: 'lead', id: recipient.link.leadId, name: recipient.email },\n )\n }\n res.status(200).json({\n ok: true,\n activityId: activityRef.id,\n to: recipient.email,\n from: identity.from,\n logged,\n })\n } catch (error) {\n console.error('[crm] email-send failed', routeScope, error)\n res.status(500).json({ error: 'The email could not be sent.' })\n }\n}\n\nexport default crmEmailSendHandler\n"],"names":["buildCrmEmailActivity","contactCaptureHostIds","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","CRM_EMAIL_BODY_MAX","CRM_EMAIL_CONTEXT","CRM_EMAIL_SUBJECT_MAX","crmActivityLogHasRoom","crmEmailDeliveryTags","emailStateRefusal","crmMergeFieldsIn","crmScopeTokens","isOrgWideMember","normalizeContactEmail","readContactFacet","readEmailState","readMarketingBasis","renderCrmMergeFields","resolveOrgEntitlements","visibleToHost","isEmailConfigured","sendEmail","sendFailureReason","consentGroupForSite","consumeRateLimit","countCrmActivitiesForRecord","filterSendableForHost","firebaseAdmin","getHostDocAdmin","getOrgForHost","hostSendingIdentity","logOrgActivity","memberHasOrgPermission","newCrmActivityRef","orgDataCollectionForHost","readLeadForHost","readOrgEmailRamp","recordEmailSends","releaseCrmEmailSend","reserveCrmEmailSend","resolveOrgMembership","writeCrmEmailActivity","isRefusedIdToken","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_EMAIL_SENDS_PER_MINUTE","CRM_EMAIL_RATE_WINDOW_MS","CRM_EMAIL_NOT_INCLUDED_MESSAGE","crmEmailCapReachedMessage","included","toLocaleString","crmEmailRampReachedMessage","ceiling","used","CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE","CRM_EMAIL_SUPPRESSED_MESSAGE","CRM_EMAIL_DECLINED_MESSAGE","CRM_EMAIL_RATE_MESSAGE","CRM_EMAIL_PICK_SITE_MESSAGE","refuse","status","error","extra","ok","body","typed","value","max","String","trim","slice","authorizeSender","req","hostId","resolved","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","console","catch","orgId","org","staff","member","membership","uid","reaches","Boolean","hostAccess","allowed","email","name","authorizeOrgSender","caller","needs","refusal","resolveRecipient","firestore","scope","ids","link","scoped","level","visible","tokens","contactId","siteHint","dealRecord","dealId","deal","collection","doc","deals","get","exists","id","data","contact","contacts","record","leadId","lead","converted","resolveSendingSite","hosts","reason","sendRefusal","result","detail","retryAtMs","Number","retryAfterSeconds","isFinite","Math","ceil","Date","now","CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE","mergeContextFor","options","recipient","sender","groupId","fields","wantsSite","some","field","host","contactGroupId","site","answer","res","retry","setHeader","json","crmEmailSendHandler","method","routeScope","draftSubject","draftText","replace","suite","rate","limit","windowMs","resetMs","group","facet","companyId","merge","subject","text","orgRef","sendable","basis","stateRefusal","identity","message","ramp","degraded","createdAt","getTime","claim","quota","rampBound","crmEmailsPerDay","resetsAtMs","resetsAt","activityRef","to","sendingIdentity","audience","context","replyTo","fromName","tags","activityId","reservation","sent","sentAtMs","logged","recordTokens","visibleTo","Array","isArray","map","atMs","byUid","byName","kind","count","sendClass","type","from"],"mappings":"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"}
@@ -16,7 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { canManageOrg, CONTACT_ERASURE_REQUESTED_FIELD, normalizeContactEmail, PERSON_ERASURES_COLLECTION, personErasureConfirmationMatches, personErasureId } from "@aglyn/aglyn/server";
18
18
  import { personKey } from "@aglyn/aglyn/app-utils/person-key";
19
- import { firebaseAdmin, getOrgForHost, logHostActivity, logOrgActivity, orgDataCollectionForHost, suppressEmailForHostErasure } from "@aglyn/tenant-data-admin";
19
+ import { firebaseAdmin, getOrgForHost, logHostActivity, logOrgActivity, orgDataCollectionForHost, readLeadForHost, suppressEmailForHostErasure } from "@aglyn/tenant-data-admin";
20
20
  import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
21
21
  import { FieldValue } from "firebase-admin/firestore";
22
22
  import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.js";
@@ -171,8 +171,8 @@ import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.
171
171
  }
172
172
  recordEmail = normalizeContactEmail(contact.get('email'));
173
173
  } else {
174
- const lead = await firestore.collection('hosts').doc(hostId).collection('leads').doc(leadId).get();
175
- if (!lead.exists) {
174
+ const lead = await readLeadForHost(hostId, leadId);
175
+ if (!lead) {
176
176
  res.status(404).json({
177
177
  error: 'Unknown lead'
178
178
  });
@@ -241,19 +241,37 @@ import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.
241
241
  merge: true
242
242
  });
243
243
  const hostIds = await orgHostIds(firestore, orgId);
244
- for (const siteId of hostIds){
244
+ /*
245
+ * Suppression stays PER SITE — it is that site's sending list — while the
246
+ * lead marker below is written once, because the lead is one org row now
247
+ * (AGL-3275) rather than one row per site.
248
+ */ for (const siteId of hostIds){
245
249
  await suppressEmailForHostErasure({
246
250
  hostId: siteId,
247
251
  email: recordEmail
248
252
  }).catch((error)=>{
249
253
  console.error('[crm] erase-person suppression write failed', siteId, error);
250
254
  });
251
- const lead = firestore.collection('hosts').doc(siteId).collection('leads').doc(key);
252
- await lead.get().then((snapshot)=>snapshot.exists ? lead.update({
255
+ }
256
+ /*
257
+ * THE ORG ROW, AND EVERY LEGACY ROW STILL STANDING.
258
+ *
259
+ * An erasure may not be the one caller that trusts the migration to be
260
+ * finished: until AGL-3276 has folded every site and AGL-3277 has deleted
261
+ * the fallback, a person can still be held at `hosts/{siteId}/leads`, and
262
+ * a marker that reached only the org row would leave that copy unmarked.
263
+ * So this marks whatever exists, in both homes, and the sweep costs one
264
+ * read per site exactly as it did before.
265
+ */ const eraseLeadAt = async (ref, where)=>{
266
+ await ref.get().then((snapshot)=>snapshot.exists ? ref.update({
253
267
  [CONTACT_ERASURE_REQUESTED_FIELD]: now
254
268
  }) : undefined).catch((error)=>{
255
- console.error('[crm] erase-person lead marker failed', siteId, error);
269
+ console.error('[crm] erase-person lead marker failed', where, error);
256
270
  });
271
+ };
272
+ await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId);
273
+ for (const siteId of hostIds){
274
+ await eraseLeadAt(firestore.collection('hosts').doc(siteId).collection('leads').doc(key), siteId);
257
275
  }
258
276
  try {
259
277
  const contacts = await firestore.collection('orgs').doc(orgId).collection('contacts').where('email', '==', recordEmail).get();