@aglyn/plugins-crm 1.0.0-beta.203 → 1.0.0-beta.205

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 (40) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-detail-page.js +7 -0
  3. package/src/lib/components/contact-detail-page.js.map +1 -1
  4. package/src/lib/components/crm-email-check.d.ts +70 -0
  5. package/src/lib/components/crm-email-check.js +135 -0
  6. package/src/lib/components/crm-email-check.js.map +1 -0
  7. package/src/lib/components/crm-send-email-dialog.js +12 -0
  8. package/src/lib/components/crm-send-email-dialog.js.map +1 -1
  9. package/src/lib/components/csv-import-drawer.js +5 -1
  10. package/src/lib/components/csv-import-drawer.js.map +1 -1
  11. package/src/lib/components/lead-properties-card.js +7 -1
  12. package/src/lib/components/lead-properties-card.js.map +1 -1
  13. package/src/lib/components/use-crm-api.d.ts +1 -1
  14. package/src/lib/components/use-crm-api.js.map +1 -1
  15. package/src/lib/declarations.server.js +15 -0
  16. package/src/lib/declarations.server.js.map +1 -1
  17. package/src/lib/model/lead-filters.d.ts +1 -1
  18. package/src/lib/server/capture-contact.js +8 -2
  19. package/src/lib/server/capture-contact.js.map +1 -1
  20. package/src/lib/server/contacts-import.js +18 -2
  21. package/src/lib/server/contacts-import.js.map +1 -1
  22. package/src/lib/server/email-check.d.ts +51 -0
  23. package/src/lib/server/email-check.js +193 -0
  24. package/src/lib/server/email-check.js.map +1 -0
  25. package/src/lib/server/email-send.js +8 -0
  26. package/src/lib/server/email-send.js.map +1 -1
  27. package/src/lib/server/import-context.d.ts +2 -0
  28. package/src/lib/server/import-context.js +1 -0
  29. package/src/lib/server/import-context.js.map +1 -1
  30. package/src/lib/server/leads-import.js +16 -2
  31. package/src/lib/server/leads-import.js.map +1 -1
  32. package/src/lib/server/record-email-state.js +21 -4
  33. package/src/lib/server/record-email-state.js.map +1 -1
  34. package/src/lib/server/task-routes.d.ts +2 -0
  35. package/src/lib/server/task-routes.js +9 -0
  36. package/src/lib/server/task-routes.js.map +1 -1
  37. package/src/lib/server-deal-stage.js +18 -2
  38. package/src/lib/server-deal-stage.js.map +1 -1
  39. package/src/lib/server.js +23 -2
  40. package/src/lib/server.js.map +1 -1
@@ -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 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 — each read\n // across the sending site's consent group, so leaving one site of a\n // declared group stops this one writing too.\n const sendable = await filterSendableForHost(\n hostId,\n [recipient.email],\n undefined,\n group,\n )\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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"}
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 // The deliverability preflight (AGL-3328): the recipient's domain takes\n // no mail. A standing fact about the address, said in the engine's words\n // — not a provider fault the rep should retry.\n if (reason === 'undeliverable' || reason === 'gateway-held') {\n return refuse(409, detail || 'This address cannot receive email.', { reason: 'undeliverable' })\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 — each read\n // across the sending site's consent group, so leaving one site of a\n // declared group stops this one writing too.\n const sendable = await filterSendableForHost(\n hostId,\n [recipient.email],\n undefined,\n group,\n )\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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"}
@@ -41,6 +41,8 @@ import { type PluginApiHandler } from '@aglyn/aglyn/server';
41
41
  export type ImportContext = {
42
42
  ok: true;
43
43
  uid: string;
44
+ /** The importer's address, for who set off the runs it causes (AGL-3376). */
45
+ email: string | null;
44
46
  hostId: string;
45
47
  orgId: string;
46
48
  org: Record<string, unknown>;
@@ -119,6 +119,7 @@ import { crmSuiteRefusal } from "./suite-gate.js";
119
119
  return {
120
120
  ok: true,
121
121
  uid: decoded.uid,
122
+ email: typeof decoded.email === 'string' ? decoded.email : null,
122
123
  hostId,
123
124
  orgId,
124
125
  org,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/import-context.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 * WHAT EVERY CSV IMPORT ROUTE ASKS BEFORE IT READS A ROW (AGL-2602, shared\n * by the companies import since AGL-2621).\n *\n * Who is asking and on whose behalf, whether the request is the size a\n * chunk is allowed to be, and — when a row names an owner — which member\n * of the org that address is. The contacts route settled each answer; the\n * companies route needs the same three and must not spell them\n * differently, because two gates for one permission is how one of them\n * comes to be wider.\n *\n * ## Who may call an import\n *\n * The same two gates the rules put on every CRM collection: a site role\n * on the host the request names, AND the organization's `data.manage`\n * permission resolved through the member's custom role and overrides. The\n * Admin SDK evaluates no rules, so this is the enforcement rather than an\n * echo of it — and `data.manage` rather than the raw role because an owner\n * who unticked \"Manage data\" on a custom role meant it. A staff token\n * passes both gates the way it does on every console route: support\n * importing a customer's file for them is the support act this exists for.\n */\n\nimport {\n consentGroupScope,\n crmScopeTokens,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n listOrgMembers,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** Everything an import needs about who is asking, or the refusal to send back. */\nexport type ImportContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n org: Record<string, unknown>\n /**\n * The stamp a record CREATED by this import carries — the org, when\n * the org widened its default, and otherwise the capturing group.\n */\n scopeTokens: string[]\n /**\n * The tokens a record must overlap to be SEEN from this site: the\n * group's own, plus `org`, because an org-wide record is visible to\n * every site in the account. The reader's set is wider than the\n * creator's stamp, exactly as it is on the collection listeners.\n */\n readTokens: string[]\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\nexport type ImportRequest = Parameters<PluginApiHandler>[0]\n\n/** Who is asking, and on whose behalf. */\nexport async function resolveImportContext(req: ImportRequest): Promise<ImportContext> {\n const hostId = String(req.body?.hostId ?? '')\n if (!hostId) {\n return { ok: false, status: 400, body: { error: 'Missing hostId' } }\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n const orgId = String(resolved?.orgId ?? '')\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no CRM.' },\n }\n }\n const org = (resolved?.org ?? {}) as Record<string, unknown>\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n if (\n !staff &&\n (!member ||\n (member as { orgSuspended?: boolean }).orgSuspended === true ||\n !(await memberHasOrgPermission(orgId, member, 'data.manage')))\n ) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Your organization role does not allow managing contacts and ' +\n 'companies, so it cannot import them.',\n },\n }\n }\n // Every file this context serves — contacts, companies, deals, tasks and\n // leads — is the CRM's to import, whoever is asking (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Importing from a CSV file')\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n const group = await consentGroupForSite(hostId)\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n org,\n scopeTokens: crmScopeTokens(org, group),\n readTokens: [ORG_SCOPE_TOKEN, ...consentGroupScope(group)],\n }\n}\n\n/** The rows a request may carry, or the refusal to send back. */\nexport function readImportRows<R>(\n req: ImportRequest,\n limits: { maxBodyBytes: number; chunkSize: number },\n): { rows: R[] } | { status: number; error: string } {\n if ((req.rawBody?.length ?? 0) > limits.maxBodyBytes) {\n return {\n status: 413,\n error: 'That request is too large. Import the file in smaller pieces.',\n }\n }\n const rows = req.body?.rows\n if (!Array.isArray(rows) || rows.length === 0) {\n return { status: 400, error: 'No rows to import.' }\n }\n if (rows.length > limits.chunkSize) {\n return {\n status: 400,\n error: `At most ${limits.chunkSize} rows per request.`,\n }\n }\n if (!rows.every((row) => row && typeof row === 'object' && !Array.isArray(row))) {\n return { status: 400, error: 'Every row must be an object.' }\n }\n return { rows: rows as R[] }\n}\n\n/**\n * The org's members by address, read only when a row names an owner.\n *\n * A file with no owner column costs no roster read; a file with one costs\n * exactly one, however many rows it has.\n */\nexport async function ownerDirectory(\n orgId: string,\n rows: readonly { ownerEmail?: string }[],\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!rows.some((row) => row.ownerEmail)) return directory\n const members = await listOrgMembers(orgId)\n for (const member of members) {\n const email = String(member.email ?? '')\n .trim()\n .toLowerCase()\n if (email && member.$id) directory.set(email, String(member.$id))\n }\n return directory\n}\n"],"names":["consentGroupScope","crmScopeTokens","ORG_SCOPE_TOKEN","consentGroupForSite","firebaseAdmin","getOrgForHost","listOrgMembers","memberHasOrgPermission","resolveOrgMembership","crmSuiteRefusal","resolveImportContext","req","hostSnapshot","hostId","String","body","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","staff","firestore","collection","doc","get","exists","memberRole","uid","resolved","catch","orgId","org","membership","member","orgSuspended","suite","group","scopeTokens","readTokens","readImportRows","limits","rawBody","maxBodyBytes","rows","Array","isArray","chunkSize","every","row","ownerDirectory","directory","Map","some","ownerEmail","members","email","trim","toLowerCase","$id","set"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/import-context.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 * WHAT EVERY CSV IMPORT ROUTE ASKS BEFORE IT READS A ROW (AGL-2602, shared\n * by the companies import since AGL-2621).\n *\n * Who is asking and on whose behalf, whether the request is the size a\n * chunk is allowed to be, and — when a row names an owner — which member\n * of the org that address is. The contacts route settled each answer; the\n * companies route needs the same three and must not spell them\n * differently, because two gates for one permission is how one of them\n * comes to be wider.\n *\n * ## Who may call an import\n *\n * The same two gates the rules put on every CRM collection: a site role\n * on the host the request names, AND the organization's `data.manage`\n * permission resolved through the member's custom role and overrides. The\n * Admin SDK evaluates no rules, so this is the enforcement rather than an\n * echo of it — and `data.manage` rather than the raw role because an owner\n * who unticked \"Manage data\" on a custom role meant it. A staff token\n * passes both gates the way it does on every console route: support\n * importing a customer's file for them is the support act this exists for.\n */\n\nimport {\n consentGroupScope,\n crmScopeTokens,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n listOrgMembers,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** Everything an import needs about who is asking, or the refusal to send back. */\nexport type ImportContext =\n | {\n ok: true\n uid: string\n /** The importer's address, for who set off the runs it causes (AGL-3376). */\n email: string | null\n hostId: string\n orgId: string\n org: Record<string, unknown>\n /**\n * The stamp a record CREATED by this import carries — the org, when\n * the org widened its default, and otherwise the capturing group.\n */\n scopeTokens: string[]\n /**\n * The tokens a record must overlap to be SEEN from this site: the\n * group's own, plus `org`, because an org-wide record is visible to\n * every site in the account. The reader's set is wider than the\n * creator's stamp, exactly as it is on the collection listeners.\n */\n readTokens: string[]\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\nexport type ImportRequest = Parameters<PluginApiHandler>[0]\n\n/** Who is asking, and on whose behalf. */\nexport async function resolveImportContext(req: ImportRequest): Promise<ImportContext> {\n const hostId = String(req.body?.hostId ?? '')\n if (!hostId) {\n return { ok: false, status: 400, body: { error: 'Missing hostId' } }\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n const orgId = String(resolved?.orgId ?? '')\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no CRM.' },\n }\n }\n const org = (resolved?.org ?? {}) as Record<string, unknown>\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n if (\n !staff &&\n (!member ||\n (member as { orgSuspended?: boolean }).orgSuspended === true ||\n !(await memberHasOrgPermission(orgId, member, 'data.manage')))\n ) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Your organization role does not allow managing contacts and ' +\n 'companies, so it cannot import them.',\n },\n }\n }\n // Every file this context serves — contacts, companies, deals, tasks and\n // leads — is the CRM's to import, whoever is asking (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Importing from a CSV file')\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n const group = await consentGroupForSite(hostId)\n return {\n ok: true,\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n hostId,\n orgId,\n org,\n scopeTokens: crmScopeTokens(org, group),\n readTokens: [ORG_SCOPE_TOKEN, ...consentGroupScope(group)],\n }\n}\n\n/** The rows a request may carry, or the refusal to send back. */\nexport function readImportRows<R>(\n req: ImportRequest,\n limits: { maxBodyBytes: number; chunkSize: number },\n): { rows: R[] } | { status: number; error: string } {\n if ((req.rawBody?.length ?? 0) > limits.maxBodyBytes) {\n return {\n status: 413,\n error: 'That request is too large. Import the file in smaller pieces.',\n }\n }\n const rows = req.body?.rows\n if (!Array.isArray(rows) || rows.length === 0) {\n return { status: 400, error: 'No rows to import.' }\n }\n if (rows.length > limits.chunkSize) {\n return {\n status: 400,\n error: `At most ${limits.chunkSize} rows per request.`,\n }\n }\n if (!rows.every((row) => row && typeof row === 'object' && !Array.isArray(row))) {\n return { status: 400, error: 'Every row must be an object.' }\n }\n return { rows: rows as R[] }\n}\n\n/**\n * The org's members by address, read only when a row names an owner.\n *\n * A file with no owner column costs no roster read; a file with one costs\n * exactly one, however many rows it has.\n */\nexport async function ownerDirectory(\n orgId: string,\n rows: readonly { ownerEmail?: string }[],\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!rows.some((row) => row.ownerEmail)) return directory\n const members = await listOrgMembers(orgId)\n for (const member of members) {\n const email = String(member.email ?? '')\n .trim()\n .toLowerCase()\n if (email && member.$id) directory.set(email, String(member.$id))\n }\n return directory\n}\n"],"names":["consentGroupScope","crmScopeTokens","ORG_SCOPE_TOKEN","consentGroupForSite","firebaseAdmin","getOrgForHost","listOrgMembers","memberHasOrgPermission","resolveOrgMembership","crmSuiteRefusal","resolveImportContext","req","hostSnapshot","hostId","String","body","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","staff","firestore","collection","doc","get","exists","memberRole","uid","resolved","catch","orgId","org","membership","member","orgSuspended","suite","group","email","scopeTokens","readTokens","readImportRows","limits","rawBody","maxBodyBytes","rows","Array","isArray","chunkSize","every","row","ownerDirectory","directory","Map","some","ownerEmail","members","trim","toLowerCase","$id","set"],"mappings":"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"}
@@ -77,6 +77,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
77
77
  import { LEAD_IMPORT_CHUNK_SIZE, LEAD_IMPORT_MAX_BODY_BYTES, normalizeLeadImportRow } from "../model/crm-lead-import.js";
78
78
  import { addHostLead, firebaseAdmin, orgLeadsForHost, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
79
79
  import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
80
+ // The leaf, for the reason the webhook imports the delivery log from its
81
+ // leaf: a spec that stands a partial barrel in must still reach it.
82
+ import { countUndeliverableEmails, findUndeliverableEmails, IMPORT_MAIL_CHECK_GRACE_MS, settleWithin } from "@aglyn/tenant-data-admin/server/capture-email-check";
80
83
  import { FieldValue } from "firebase-admin/firestore";
81
84
  import { ownerDirectory, readImportRows, resolveImportContext } from "./import-context.js";
82
85
  import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-picklist.js";
@@ -203,6 +206,13 @@ import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-pi
203
206
  key
204
207
  });
205
208
  });
209
+ /*
210
+ * THE ADDRESSES' MAIL SERVERS (AGL-3328), asked now so the answers
211
+ * arrive while the rows are written, and read after the last one for
212
+ * the result's "N addresses have no mail server". The records
213
+ * themselves are stamped by the check `addHostLead` queues.
214
+ */ const mailServers = findUndeliverableEmails(normalized.map((entry)=>entry.row.email));
215
+ const imported = [];
206
216
  const firestore = firebaseAdmin.app().firestore();
207
217
  const hostRef = firestore.collection('hosts').doc(context.hostId);
208
218
  const leadsRef = await orgLeadsForHost(context.hostId);
@@ -324,6 +334,7 @@ import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-pi
324
334
  // Leads list searches and filters by (AGL-3321).
325
335
  await restampCrmListFieldsAt(leadsRef.doc(key), 'leads');
326
336
  }
337
+ imported.push(row.email);
327
338
  if (isNew) {
328
339
  created += 1;
329
340
  createdHere += 1;
@@ -331,7 +342,8 @@ import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-pi
331
342
  merged += 1;
332
343
  }
333
344
  }
334
- const result = {
345
+ const noMailServer = countUndeliverableEmails(imported, await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS));
346
+ const result = _extends({
335
347
  received: read.rows.length,
336
348
  created,
337
349
  merged,
@@ -340,7 +352,9 @@ import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-pi
340
352
  ownersUnresolved: [
341
353
  ...ownersUnresolved
342
354
  ]
343
- };
355
+ }, noMailServer !== undefined ? {
356
+ noMailServer
357
+ } : {});
344
358
  return res.status(200).json(result);
345
359
  } catch (error) {
346
360
  console.error('crm/leads-import failed', error);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.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 * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport {\n addHostLead,\n firebaseAdmin,\n orgLeadsForHost,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org\n * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('emailCampaigns')\n .limit(CAMPAIGN_DIRECTORY_CEILING)\n .get()\n const live = containers.docs.filter((container) => !container.get('deletedAt'))\n const placed = (container: FirebaseFirestore.QueryDocumentSnapshot) =>\n visibleToHost(container.get('visibleTo') as string[] | undefined, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns, leadSources] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n // The org's lead source list (AGL-3298): one read per chunk, for the\n // values rows name and the default a new lead starts from.\n readLeadSourcePicklist(firestore, context.orgId),\n ])\n const beforeById = new Map(before.map((snapshot) => [snapshot.id, snapshot]))\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value\n * the list does not hold is refused whole and named, as a campaign\n * the org does not have is — the drawer's notice has already listed\n * the values the list allows — and a lead the site already held\n * keeps the value it has. A new lead with no value starts from the\n * list's default.\n */\n const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {\n current: beforeById.get(key)?.get('leadSource'),\n created: isNew,\n })\n if (leadSource.ok === false) {\n skipped.push({ index, email: row.email, reason: 'lead-source-unknown' })\n continue\n }\n if (leadSource.write) row.profile.leadSource = leadSource.write\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n // The file's profile, tags, status and lead source are what the\n // Leads list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(leadsRef.doc(key), 'leads')\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","restampCrmListFieldsAt","visibleToHost","FieldValue","ownerDirectory","readImportRows","resolveImportContext","readLeadSourcePicklist","resolveLeadSourceWrite","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","collection","doc","limit","get","live","docs","filter","container","placed","entry","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","app","hostRef","leadsRef","refs","map","namesCampaigns","some","campaigns","owners","before","leadSources","Promise","all","getAll","resolve","beforeById","snapshot","held","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","leadSource","current","write","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2DC,GAED,SACEA,yBAAyB,EAEzBC,kBAAkB,EAClBC,SAAS,QAEJ,sBAAqB;AAC5B,SACEC,sBAAsB,EACtBC,0BAA0B,EAK1BC,sBAAsB,QACjB,8BAA0B;AACjC,SACEC,WAAW,EACXC,aAAa,EACbC,eAAe,EACfC,sBAAsB,QACjB,2BAA0B;AACjC,SAASC,aAAa,QAAQ,sCAAqC;AACnE,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,cAAc,EACdC,cAAc,EACdC,oBAAoB,QACf,sBAAkB;AACzB,SAASC,sBAAsB,EAAEC,sBAAsB,QAAQ,4BAAwB;AAEvF;;;;;;;;;CASC,GACD,MAAMC,qBAAoC;AAE1C;;;;CAIC,GACD,MAAMC,6BAA6B;AAEnC;;;;;;;;;CASC,GACD,eAAeC,kBACbC,SAAsC,EACtCC,KAAa,EACbC,MAAc;IAEd,MAAMC,YAAY,IAAIC;IACtB,IAAI,CAACH,OAAO,OAAOE;IACnB,MAAME,aAAa,MAAML,UACtBM,UAAU,CAAC,QACXC,GAAG,CAACN,OACJK,UAAU,CAAC,kBACXE,KAAK,CAACV,4BACNW,GAAG;IACN,MAAMC,OAAOL,WAAWM,IAAI,CAACC,MAAM,CAAC,CAACC,YAAc,CAACA,UAAUJ,GAAG,CAAC;IAClE,MAAMK,SAAS,CAACD,YACdvB,cAAcuB,UAAUJ,GAAG,CAAC,cAAsCP;IACpE,KAAK,MAAMW,aAAa;WAAIH,KAAKE,MAAM,CAACE;WAAYJ,KAAKE,MAAM,CAAC,CAACG,QAAU,CAACD,OAAOC;KAAQ,CAAE;YACvEF;QAApB,MAAMG,OAAOC,QAAOJ,iBAAAA,UAAUJ,GAAG,CAAC,mBAAdI,iBAAyB,IAAIK,IAAI,GAAGC,WAAW;QACnE,IAAIH,QAAQ,CAACb,UAAUiB,GAAG,CAACJ,OAAOb,UAAUkB,GAAG,CAACL,MAAMH,UAAUS,EAAE;IACpE;IACA,OAAOnB;AACT;AAEA;;;;;CAKC,GACD,SAASoB,aACPC,GAAkB,EAClBC,QAA4B,EAC5BC,WAA8B;IAE9B,MAAMC,SAAkC,aAClCH,IAAII,MAAM,GAAG;QAAEA,QAAQJ,IAAII,MAAM;IAAC,IAAI,CAAC,GACvCH,WAAW;QAAEA;IAAS,IAAI,CAAC,GAC3BD,IAAIK,iBAAiB,GAAG;QAAEA,mBAAmBL,IAAIK,iBAAiB;IAAC,IAAI,CAAC,GACxEL,IAAIM,KAAK,GAAG;QAAEA,OAAON,IAAIM,KAAK;IAAC,IAAI,CAAC,GACrCN,IAAIO,OAAO,EAIVL,YAAYM,MAAM,GAAG;QAAEN,aAAanC,WAAW0C,UAAU,IAAIP;IAAa,IAAI,CAAC;IAErF,OAAOQ,OAAOC,IAAI,CAACR,QAAQK,MAAM,GAAGL,SAAS;AAC/C;AAEA;;;;;CAKC,GACD,OAAO,MAAMS,wBAA0C,OAAOC,KAAKC;IACjE,IAAID,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvB,OAAOF,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,OAAOlD,eAAiC4C,KAAK;QACjDO,cAAc5D;QACd6D,WAAW9D;IACb;IACA,IAAI,WAAW4D,MAAM,OAAOL,IAAIV,MAAM,CAACe,KAAKf,MAAM,EAAEa,IAAI,CAAC;QAAEC,OAAOC,KAAKD,KAAK;IAAC;IAC7E,IAAI;QACF,MAAMI,UAAU,MAAMpD,qBAAqB2C;QAC3C,IAAIS,QAAQC,EAAE,KAAK,OAAO,OAAOT,IAAIV,MAAM,CAACkB,QAAQlB,MAAM,EAAEa,IAAI,CAACK,QAAQE,IAAI;QAE7E,MAAMC,UAAkC,EAAE;QAC1C,MAAMC,UAAkC,CAAC;QACzC,MAAMC,aAAmE,EAAE;QAC3E;;;;;;KAMC,GACD,MAAMC,OAAO,IAAIC;QACjBV,KAAKW,IAAI,CAACC,OAAO,CAAC,CAACC,KAAKC;YACtB,MAAMC,UAAUzE,uBAAuBuE;YACvC,IAAIE,QAAQX,EAAE,KAAK,OAAO;gBACxBE,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOF,QAAQG,KAAK;oBAAEC,QAAQJ,QAAQI,MAAM;gBAAC;gBACnE;YACF;YACA,iEAAiE;YACjE,iCAAiC;YACjC,MAAMC,MAAMjF,UAAU4E,QAAQlC,GAAG,CAACoC,KAAK;YACvC,IAAIR,KAAKhC,GAAG,CAAC2C,MAAM;gBACjBd,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOF,QAAQlC,GAAG,CAACoC,KAAK;oBAAEE,QAAQ;gBAAY;gBACpE;YACF;YACAV,KAAKY,GAAG,CAACD;YACT,KAAK,MAAMhD,SAAS2C,QAAQlC,GAAG,CAAC0B,OAAO,CAAE;oBACfA;gBAAxBA,OAAO,CAACnC,MAAMkD,KAAK,CAAC,GAAG,EAACf,uBAAAA,OAAO,CAACnC,MAAMkD,KAAK,CAAC,YAApBf,uBAAwB,KAAK;YACvD;YACAC,WAAWQ,IAAI,CAAC;gBAAEF;gBAAOjC,KAAKkC,QAAQlC,GAAG;gBAAEuC;YAAI;QACjD;QAEA,MAAM/D,YAAYb,cAAc+E,GAAG,GAAGlE,SAAS;QAC/C,MAAMmE,UAAUnE,UAAUM,UAAU,CAAC,SAASC,GAAG,CAACuC,QAAQ5C,MAAM;QAChE,MAAMkE,WAAW,MAAMhF,gBAAgB0D,QAAQ5C,MAAM;QACrD,MAAMmE,OAAOlB,WAAWmB,GAAG,CAAC,CAACvD,QAAUqD,SAAS7D,GAAG,CAACQ,MAAMgD,GAAG;QAC7D,MAAMQ,iBAAiBpB,WAAWqB,IAAI,CAAC,CAACzD;gBAAUA;oBAAAA,uBAAAA,MAAMS,GAAG,CAACiD,SAAS,qBAAnB1D,qBAAqBiB,MAAM;;QAC7E,MAAM,CAAC0C,QAAQC,QAAQF,WAAWG,YAAY,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACjEtF,eACEsD,QAAQ7C,KAAK,EACbkD,WAAWmB,GAAG,CAAC,CAACvD,QAAUA,MAAMS,GAAG;YAErC6C,KAAKrC,MAAM,GAAGhC,UAAU+E,MAAM,IAAIV,QAAQQ,QAAQG,OAAO,CAAC,EAAE;YAC5DT,iBACIxE,kBAAkBC,WAAW8C,QAAQ7C,KAAK,EAAE6C,QAAQ5C,MAAM,IAC1D2E,QAAQG,OAAO,CAAC,IAAI5E;YACxB,qEAAqE;YACrE,2DAA2D;YAC3DT,uBAAuBK,WAAW8C,QAAQ7C,KAAK;SAChD;QACD,MAAMgF,aAAa,IAAI7E,IAAIuE,OAAOL,GAAG,CAAC,CAACY,WAAa;gBAACA,SAAS5D,EAAE;gBAAE4D;aAAS;QAC3E,MAAMC,OAAO,IAAI9B,IACfsB,OAAO/D,MAAM,CAAC,CAACsE,WAAaA,SAASE,MAAM,EAAEd,GAAG,CAAC,CAACY,WAAaA,SAAS5D,EAAE;QAE5E,MAAM+D,mBAAmB,IAAIhC;QAC7B,IAAIiC,UAAyB;QAC7B,IAAIC,cAAc;QAClB,IAAIC,UAAU;QACd,IAAIC,SAAS;QAEb,KAAK,MAAM,EAAEhC,KAAK,EAAEjC,GAAG,EAAEuC,GAAG,EAAE,IAAIZ,WAAY;gBAGvB3B;gBAeVyD;YAjBX,mEAAmE;YACnE,6CAA6C;YAC7C,MAAMvD,cAAc,EAACF,iBAAAA,IAAIiD,SAAS,YAAbjD,iBAAiB,EAAE,EAAE8C,GAAG,CAAC,CAACtD;oBAASyD;wBAAAA,iBAAAA,UAAUhE,GAAG,CAACO,KAAKG,WAAW,eAA9BsD,iBAAqC;;YAC7F,IAAI/C,YAAY8C,IAAI,CAAC,CAAClD,KAAO,CAACA,KAAK;gBACjC2B,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOpC,IAAIoC,KAAK;oBAAEE,QAAQ;gBAAmB;gBACnE;YACF;YACA,MAAM4B,QAAQ,CAACP,KAAK/D,GAAG,CAAC2C;YACxB;;;;;;;OAOC,GACD,MAAM4B,aAAa/F,uBAAuBgF,aAAapD,IAAIO,OAAO,CAAC4D,UAAU,EAAE;gBAC7EC,OAAO,GAAEX,kBAAAA,WAAWxE,GAAG,CAACsD,yBAAfkB,gBAAqBxE,GAAG,CAAC;gBAClC+E,SAASE;YACX;YACA,IAAIC,WAAW5C,EAAE,KAAK,OAAO;gBAC3BE,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOpC,IAAIoC,KAAK;oBAAEE,QAAQ;gBAAsB;gBACtE;YACF;YACA,IAAI6B,WAAWE,KAAK,EAAErE,IAAIO,OAAO,CAAC4D,UAAU,GAAGA,WAAWE,KAAK;YAC/D;;;;;;;OAOC,GACD,IAAIH,OAAO;gBACT,IAAIJ,YAAY,MAAM;oBACpBA,UAAU,AAAC,CAAA,MAAMlB,SAAS0B,KAAK,GAAGrF,GAAG,EAAC,EAAGsF,IAAI,GAAGD,KAAK;gBACvD;gBACA,IACElH,0BAA0B0G,UAAUC,aAAa1G,oBAC9CmH,QAAQ,EACX;oBACA/C,QAAQU,IAAI,CAAC;wBAAEF;wBAAOG,OAAOpC,IAAIoC,KAAK;wBAAEE,QAAQ;oBAAe;oBAC/D;gBACF;YACF;YACA,IAAIrC;YACJ,IAAID,IAAIyE,UAAU,EAAE;gBAClBxE,WAAWiD,OAAOjE,GAAG,CAACe,IAAIyE,UAAU;gBACpC,IAAI,CAACxE,UAAU4D,iBAAiBrB,GAAG,CAACxC,IAAIyE,UAAU;YACpD;YACA,MAAMC,SAAS,MAAMhH,YAAY;gBAC/BiF;gBACAjE,QAAQ4C,QAAQ5C,MAAM;gBACtBiG,MAAM;oBACJvC,OAAOpC,IAAIoC,KAAK;mBACZpC,IAAIR,IAAI,GAAG;oBAAEA,MAAMQ,IAAIR,IAAI;gBAAC,IAAI,CAAC;oBACrCoF,QAAQvG;;YAEZ;YACA,IAAI,CAACqG,QAAQ;gBACXjD,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOpC,IAAIoC,KAAK;oBAAEE,QAAQ;gBAAe;gBAC/D;YACF;YACA,MAAMuC,UAAU9E,aAAaC,KAAKC,UAAU;mBAAI,IAAI4B,IAAI3B;aAAa;YACrE,IAAI2E,SAAS;gBACX,MAAMjC,SACH7D,GAAG,CAACwD,KACJ1C,GAAG,CACF,aAAKgF;oBAASC,WAAW/G,WAAWgH,eAAe;oBACnD;oBAAEC,OAAO;gBAAK;gBAElB,gEAAgE;gBAChE,iDAAiD;gBACjD,MAAMnH,uBAAuB+E,SAAS7D,GAAG,CAACwD,MAAM;YAClD;YACA,IAAI2B,OAAO;gBACTF,WAAW;gBACXD,eAAe;YACjB,OAAO;gBACLE,UAAU;YACZ;QACF;QAEA,MAAMgB,SAAgC;YACpCC,UAAU/D,KAAKW,IAAI,CAACtB,MAAM;YAC1BwD;YACAC;YACAxC,SAASA,QAAQ0D,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEnD,KAAK,GAAGoD,EAAEpD,KAAK;YACjDP;YACAmC,kBAAkB;mBAAIA;aAAiB;QACzC;QACA,OAAO/C,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAACgE;IAC9B,EAAE,OAAO/D,OAAO;QACdoE,QAAQpE,KAAK,CAAC,2BAA2BA;QACzC,OAAOJ,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAAC;YAAEC,OAAO;QAAiC;IACxE;AACF,EAAC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.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 * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport {\n addHostLead,\n firebaseAdmin,\n orgLeadsForHost,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n// The leaf, for the reason the webhook imports the delivery log from its\n// leaf: a spec that stands a partial barrel in must still reach it.\nimport {\n countUndeliverableEmails,\n findUndeliverableEmails,\n IMPORT_MAIL_CHECK_GRACE_MS,\n settleWithin,\n} from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org\n * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('emailCampaigns')\n .limit(CAMPAIGN_DIRECTORY_CEILING)\n .get()\n const live = containers.docs.filter((container) => !container.get('deletedAt'))\n const placed = (container: FirebaseFirestore.QueryDocumentSnapshot) =>\n visibleToHost(container.get('visibleTo') as string[] | undefined, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n /*\n * THE ADDRESSES' MAIL SERVERS (AGL-3328), asked now so the answers\n * arrive while the rows are written, and read after the last one for\n * the result's \"N addresses have no mail server\". The records\n * themselves are stamped by the check `addHostLead` queues.\n */\n const mailServers = findUndeliverableEmails(normalized.map((entry) => entry.row.email))\n const imported: string[] = []\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns, leadSources] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n // The org's lead source list (AGL-3298): one read per chunk, for the\n // values rows name and the default a new lead starts from.\n readLeadSourcePicklist(firestore, context.orgId),\n ])\n const beforeById = new Map(before.map((snapshot) => [snapshot.id, snapshot]))\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value\n * the list does not hold is refused whole and named, as a campaign\n * the org does not have is — the drawer's notice has already listed\n * the values the list allows — and a lead the site already held\n * keeps the value it has. A new lead with no value starts from the\n * list's default.\n */\n const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {\n current: beforeById.get(key)?.get('leadSource'),\n created: isNew,\n })\n if (leadSource.ok === false) {\n skipped.push({ index, email: row.email, reason: 'lead-source-unknown' })\n continue\n }\n if (leadSource.write) row.profile.leadSource = leadSource.write\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n // The file's profile, tags, status and lead source are what the\n // Leads list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(leadsRef.doc(key), 'leads')\n }\n imported.push(row.email)\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n const noMailServer = countUndeliverableEmails(\n imported,\n await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS),\n )\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n ...(noMailServer !== undefined ? { noMailServer } : {}),\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","restampCrmListFieldsAt","visibleToHost","countUndeliverableEmails","findUndeliverableEmails","IMPORT_MAIL_CHECK_GRACE_MS","settleWithin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","readLeadSourcePicklist","resolveLeadSourceWrite","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","collection","doc","limit","get","live","docs","filter","container","placed","entry","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","mailServers","map","imported","app","hostRef","leadsRef","refs","namesCampaigns","some","campaigns","owners","before","leadSources","Promise","all","getAll","resolve","beforeById","snapshot","held","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","leadSource","current","write","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","noMailServer","result","received","sort","a","b","undefined","console"],"mappings":"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"}
@@ -15,8 +15,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { registerPluginRecordEmailStateWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
18
- import { CRM_EMAIL_STATUS_FIELD, EMAIL_STATE_FIELD, nextEmailState, normalizeContactEmail, personKey, readEmailState } from "@aglyn/aglyn/server";
18
+ import { CRM_EMAIL_STATUS_FIELD, CRM_EMAIL_STATUS_NONE, EMAIL_STATE_FIELD, nextEmailState, normalizeContactEmail, personKey, readEmailState } from "@aglyn/aglyn/server";
19
19
  import { findContactByEmail, firebaseAdmin } from "@aglyn/tenant-data-admin";
20
+ import { FieldValue } from "firebase-admin/firestore";
20
21
  import { BUNDLE_ID } from "../constants/bundle-common.js";
21
22
  const NONE = {
22
23
  records: 0
@@ -26,10 +27,25 @@ const NONE = {
26
27
  * a merge rather than a transaction: two verdicts landing together each
27
28
  * compare against a state at least as old as their own, and the ranking
28
29
  * makes the order of two writes of the same rank immaterial.
29
- */ async function stampRecord(ref, incoming, force) {
30
+ */ async function stampRecord(ref, incoming, force, withdraw = false) {
30
31
  const snapshot = await ref.get();
31
32
  if (!snapshot.exists) return false;
32
33
  const current = readEmailState(snapshot.data());
34
+ /*
35
+ * A WITHDRAWAL takes back the one verdict it names (AGL-3328): the
36
+ * deliverability check's "Would bounce" once the domain takes mail again.
37
+ * Any other standing verdict — a bounce DNS cannot see — is left alone.
38
+ */ if (withdraw) {
39
+ if ((current == null ? void 0 : current.status) !== incoming.status) return false;
40
+ await ref.update({
41
+ [EMAIL_STATE_FIELD]: FieldValue.delete(),
42
+ [CRM_EMAIL_STATUS_FIELD]: CRM_EMAIL_STATUS_NONE
43
+ });
44
+ return true;
45
+ }
46
+ // The deliverability check saying again what the record already holds:
47
+ // the date it was first found is the one worth keeping.
48
+ if (incoming.source === 'check' && (current == null ? void 0 : current.status) === incoming.status) return false;
33
49
  const next = nextEmailState(current, incoming, {
34
50
  force
35
51
  });
@@ -81,11 +97,12 @@ export function createCrmRecordEmailStateWriter(deps) {
81
97
  enrollmentId: request.state.enrollmentId
82
98
  } : {});
83
99
  const force = request.force === true;
100
+ const withdraw = request.withdraw === true;
84
101
  let records = 0;
85
102
  try {
86
103
  const contacts = firestore.collection('orgs').doc(orgId).collection('contacts');
87
104
  const contact = await findContactByEmail(contacts, email);
88
- if (contact && await stampRecord(contact.ref, state, force)) records += 1;
105
+ if (contact && await stampRecord(contact.ref, state, force, withdraw)) records += 1;
89
106
  } catch (error) {
90
107
  console.error('[crm] the contact could not be stamped with an email state', orgId, error);
91
108
  }
@@ -95,7 +112,7 @@ export function createCrmRecordEmailStateWriter(deps) {
95
112
  * verdict on the ADDRESS, and since AGL-3277 there is one document
96
113
  * carrying it — the per-site loop beside this had nothing left to
97
114
  * find once AGL-3276 emptied the host path.
98
- */ if (await stampRecord(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), state, force)) {
115
+ */ if (await stampRecord(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), state, force, withdraw)) {
99
116
  records += 1;
100
117
  }
101
118
  } catch (error) {