@aglyn/plugins-crm 1.0.0-beta.161 → 1.0.0-beta.163
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/contact-detail-page.js +5 -0
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +24 -2
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contacts-section.js +5 -0
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-email-state-chip.d.ts +17 -0
- package/src/lib/components/crm-email-state-chip.js +61 -0
- package/src/lib/components/crm-email-state-chip.js.map +1 -0
- package/src/lib/components/crm-send-email-button.d.ts +7 -1
- package/src/lib/components/crm-send-email-button.js +7 -3
- package/src/lib/components/crm-send-email-button.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +8 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +51 -5
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +179 -11
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.d.ts +2 -0
- package/src/lib/components/new-lead-drawer.js +27 -1
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/constants/contact-filters.js +19 -1
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/declarations.server.js +15 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +6 -0
- package/src/lib/model/contact-record.js +1 -0
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/crm-lead-import.d.ts +11 -2
- package/src/lib/model/crm-lead-import.js +22 -2
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +21 -1
- package/src/lib/model/lead-filters.js +30 -1
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/server/email-send.js +10 -1
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.d.ts +50 -0
- package/src/lib/server/lead-campaign-carry.js +75 -0
- package/src/lib/server/lead-campaign-carry.js.map +1 -0
- package/src/lib/server/lead-create.d.ts +13 -0
- package/src/lib/server/lead-create.js +30 -0
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/leads-import.js +50 -5
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.d.ts +52 -0
- package/src/lib/server/record-email-state.js +118 -0
- package/src/lib/server/record-email-state.js.map +1 -0
- package/src/lib/server/record-timeline.d.ts +5 -1
- package/src/lib/server/record-timeline.js +26 -1
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server.js +5 -0
- package/src/lib/server.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-lead-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 * BRINGING A SPREADSHEET OF LEADS INTO THE CRM — the pure half (AGL-2701).\n *\n * The contact import's three stages (`crm-import.ts`) over the lead\n * vocabulary. A lead is the one CRM record a person does not type: it is\n * what a capture door wrote when somebody signed up, booked or submitted a\n * form, plus the working state the team wrote on top. A file therefore\n * carries HALF a lead — the person, and the team's annotations — and the\n * capture half is the door's to write.\n *\n * ## The address is the identity, because it is the document id\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey}`, and `personKey` is\n * the sha256 of the normalized address. So the address is not merely the\n * one required cell: it is the row's name. A cell the normalizer cannot\n * read is refused as `invalid-email`, and two rows of one file that\n * normalize to the same address are one person — the second is skipped as\n * `duplicate` rather than merged onto the first, for the reason the\n * contacts import gives: one person reported once created and once merged\n * is a number the operator cannot reconcile against the rows they sent.\n *\n * ## What a file may NOT say\n *\n * The export's `Sources`, `First seen`, `Last seen` and `Captures` columns\n * are the capture door's own record of what the visitor did, and\n * `Converted` is stamped by `crm/lead-convert` after the contact exists.\n * None of them is a field here, so the mapping leaves those columns\n * unmapped rather than mis-mapping them — a file that could write\n * \"first seen\" could rewrite the history it is supposed to describe. The\n * organization-level file's `Site` column is left out for a second reason:\n * the site is the drawer's question and the route's permission is granted\n * for that ONE site, so a cell must not be able to redirect a row to\n * another.\n *\n * ## Qualified is a conversion, not a status a cell can claim\n *\n * `qualified` means a lead BECAME a contact, and `convertedContactId`\n * beside it names the contact that was really created. Only the convert\n * route writes the pair, so the list's own status control offers new,\n * working and unqualified and nothing else. A cell naming `qualified` is\n * dropped and reported here for the same reason: a status claiming a\n * conversion that never happened would show a contact link to nowhere.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The address is the only cell that can make a row unstorable. An\n * unreadable status, or an unqualified reason with no unqualified status\n * to belong to, is left off the lead and REPORTED under\n * {@link LeadImportRow.dropped}, so the operator learns that a column went\n * nowhere rather than discovering it weeks later.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadProfile,\n type CrmLeadStatus,\n isCrmLeadStatus,\n normalizeCrmLeadTags,\n normalizeCompanyWebsite,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n} from '@aglyn/aglyn/app-utils/csv-import'\n\n/** The shared ceilings, under this collection's names. */\nexport const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\nexport const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\nexport const LEAD_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\nexport const LEAD_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/** The same caps the lead's own cards hold a typed value to. */\nexport const LEAD_IMPORT_NOTES_MAX = 4000\nexport const LEAD_IMPORT_REASON_MAX = 500\n\n/** A person's name, capped where every other import caps one. */\nconst NAME_MAX = 120\n\n/**\n * The statuses a file may set.\n *\n * The list's own status control offers exactly these three, and for the\n * reason given above: `qualified` is written by the conversion, beside the\n * contact it produced.\n */\nexport const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[] =\n CRM_LEAD_STATUSES.filter((status) => status !== 'qualified')\n\n/**\n * The fields a column may be mapped to, in the order the mapping menu\n * lists them.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * lead source, the address parts and tags — is what a list from another\n * tool actually carries, and the reason a lead can be worked without a\n * contact beside it. They read under the same labels the contacts import\n * uses, so one spreadsheet maps the same way into either.\n */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'company',\n 'jobTitle',\n 'phone',\n 'website',\n 'leadSource',\n 'status',\n 'ownerEmail',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'unqualifiedReason',\n 'notes',\n] as const\n\nexport type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n company: 'Company name',\n jobTitle: 'Job title',\n phone: 'Phone',\n website: 'Website',\n leadSource: 'Lead source',\n status: 'Status (new, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n unqualifiedReason: 'Unqualified reason',\n notes: 'Notes',\n}\n\n/**\n * Header aliases per field, matched after the shared header normalization.\n *\n * First in each list is the header this CRM's own leads export writes, so\n * an export re-imports without a hand mapping; the export's Sources, First\n * seen, Last seen, Captures, Converted and Site columns are deliberately\n * absent from every list, so they land on \"Do not import\".\n */\nconst FIELD_ALIASES: Record<LeadImportField, readonly string[]> = {\n email: ['email', 'email address', 'e mail', 'mail', 'contact email', 'work email'],\n name: ['name', 'full name', 'lead', 'lead name', 'contact', 'contact name', 'person'],\n company: [\n 'company',\n 'company name',\n 'organization',\n 'organisation',\n 'account',\n 'account name',\n 'employer',\n ],\n jobTitle: ['job title', 'title', 'position', 'role', 'headline'],\n phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'telephone', 'tel', 'cell'],\n website: ['website', 'web site', 'url', 'company website', 'company domain', 'domain'],\n leadSource: ['lead source', 'source', 'origin', 'channel', 'campaign'],\n status: ['status', 'lead status', 'stage', 'lead stage'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'suite', 'apartment'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'state or region', 'region', 'province', 'county'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code'],\n addressCountry: ['country', 'country code'],\n tags: ['tags', 'tag', 'labels', 'lists'],\n unqualifiedReason: [\n 'unqualified reason',\n 'reason',\n 'disqualified reason',\n 'lost reason',\n 'reason lost',\n ],\n notes: ['notes', 'note', 'description', 'comments', 'details'],\n}\n\nconst FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES)\n\n/** Column index → field. A column absent from the map is not imported. */\nexport type LeadImportMapping = Record<number, LeadImportField>\n\n/** A proposed mapping from a file's header row, each field taken at most once. */\nexport function guessLeadImportMapping(columns: readonly string[]): LeadImportMapping {\n return guessImportMapping(columns, LEAD_IMPORT_FIELDS, FIELD_ALIAS_KEYS)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim. `unknown` because the\n * server reads this off an untrusted body.\n */\nexport type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>\n\n/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */\nexport function mapLeadImportRow(\n cells: readonly string[],\n mapping: Record<number, LeadImportField | `custom:${string}`>,\n): LeadImportRawRow {\n const { custom: _custom, ...row } = mapImportRow(cells, mapping)\n return row\n}\n\nexport type LeadImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'lead-ceiling'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string> = {\n 'invalid-email': 'No usable email address',\n duplicate: 'The same address appears earlier in this file',\n 'lead-ceiling': 'This site is at the platform lead limit',\n 'write-failed': 'Could not be saved',\n}\n\n/** One row, ready for the server to resolve and write. */\nexport interface LeadImportRow {\n /** Normalized — the address `personKey` derives the document id from. */\n email: string\n name?: string\n /**\n * The lead's own profile, already normalized the way the record stores\n * it (AGL-3231): a key present is a value to write, a key absent leaves\n * an existing lead's value alone. Never a clear — a blank cell in a file\n * is a cell nobody filled, not a decision to erase what the site knows.\n */\n profile: CrmLeadProfile\n /** Absent leaves an existing lead's status alone and a new one reading as `new`. */\n status?: CrmLeadStatus\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n /** Only ever set beside `status: 'unqualified'` — the pair is one fact. */\n unqualifiedReason?: string\n notes?: string\n dropped: ImportDroppedValue[]\n}\n\nexport type LeadImportRowVerdict =\n | { ok: true; row: LeadImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A status cell by id or by label — `working`, `Working` and `WORKING` are\n * one status. `null` for a cell naming no status a file may set, which\n * includes the well-spelled `Qualified` the export writes for a converted\n * lead.\n */\nexport function parseImportLeadStatus(value: unknown): CrmLeadStatus | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const byId = isCrmLeadStatus(text) ? text : null\n const byLabel =\n LEAD_IMPORT_STATUSES.find(\n (status) => CRM_LEAD_STATUS_LABELS[status].toLowerCase() === text,\n ) ?? null\n const status = byId ?? byLabel\n return status && LEAD_IMPORT_STATUSES.includes(status) ? status : null\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be. Refused here only for an unusable address, because the address is\n * the document id; the owner is refused by the server, which alone can\n * look them up.\n */\nexport function normalizeLeadImportRow(raw: LeadImportRawRow): LeadImportRowVerdict {\n const emailText = importTextValue(raw.email, 320) ?? ''\n const email = normalizeContactEmail(emailText)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: emailText }\n }\n const dropped: ImportDroppedValue[] = []\n const drop = (field: LeadImportField, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n const row: LeadImportRow = { email, profile: {}, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\n\n /*\n * The profile (AGL-3231), field by field, each through the normalizer\n * the record's own card runs. A phone or a website the normalizer cannot\n * read is dropped and reported rather than stored as typed, because the\n * record renders both as links; the country is the one address part the\n * address normalizer drops silently — a typed name is not a code — so it\n * is the one part the report has to name.\n */\n const company = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (company) row.profile.company = company\n const jobTitle = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (jobTitle) row.profile.jobTitle = jobTitle\n const leadSource = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)?.replace(\n /\\s+/g,\n ' ',\n )\n if (leadSource) row.profile.leadSource = leadSource\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.profile.phone = phone\n else drop('phone', phoneText)\n }\n const websiteText = importTextValue(raw.website, 320)\n if (websiteText) {\n const website = normalizeCompanyWebsite(websiteText)\n if (website) row.profile.website = website\n else drop('website', websiteText)\n }\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.profile.address = address\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n const tags = normalizeCrmLeadTags(\n String(raw.tags ?? '')\n .split(/[,|]/)\n .map((tag) => tag.trim()),\n )\n if (tags.length) row.profile.tags = tags\n\n const statusText = importTextValue(raw.status, 32)\n if (statusText) {\n const status = parseImportLeadStatus(statusText)\n if (status) row.status = status\n else drop('status', statusText)\n }\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n /*\n * The reason belongs to the status: it is what an unqualified lead was\n * closed FOR, and on a lead that is not unqualified it describes\n * nothing. A row that carries one without the other keeps the status and\n * reports the reason, so a mis-mapped column is visible rather than\n * stored where nothing reads it.\n */\n const reasonText = importTextValue(raw.unqualifiedReason, LEAD_IMPORT_REASON_MAX)\n if (reasonText) {\n if (row.status === 'unqualified') row.unqualifiedReason = reasonText\n else drop('unqualifiedReason', reasonText)\n }\n\n const notes = importTextValue(raw.notes, LEAD_IMPORT_NOTES_MAX)\n if (notes) row.notes = notes\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by the address. */\nexport interface LeadImportSkippedRow {\n index: number\n email: string\n reason: LeadImportSkipReason\n}\n\n/** What one request did. The drawer sums these across a file. */\nexport type LeadImportChunkResult = ImportChunkResult<LeadImportSkippedRow>\n\nexport function emptyLeadImportResult(): LeadImportChunkResult {\n return emptyImportResult<LeadImportSkippedRow>()\n}\n\nexport function mergeLeadImportResults(\n total: LeadImportChunkResult,\n chunk: LeadImportChunkResult,\n offset = 0,\n): LeadImportChunkResult {\n return mergeImportResults(total, chunk, offset)\n}\n\n/** The skipped rows as a file the operator can fix and re-import. */\nexport function leadImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: LeadImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS)\n}\n"],"names":["normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","CRM_LEAD_TEXT_MAX","isCrmLeadStatus","normalizeCrmLeadTags","normalizeCompanyWebsite","normalizeAddress","normalizePhone","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","LEAD_IMPORT_MAX_ROWS","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","LEAD_IMPORT_PREVIEW_ROWS","LEAD_IMPORT_NOTES_MAX","LEAD_IMPORT_REASON_MAX","NAME_MAX","LEAD_IMPORT_STATUSES","filter","status","LEAD_IMPORT_FIELDS","LEAD_IMPORT_FIELD_LABELS","email","name","company","jobTitle","phone","website","leadSource","ownerEmail","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","profile","replace","phoneText","websiteText","address","line1","line2","city","state","postalCode","country","countryText","split","map","tag","length","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-lead-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 * BRINGING A SPREADSHEET OF LEADS INTO THE CRM — the pure half (AGL-2701).\n *\n * The contact import's three stages (`crm-import.ts`) over the lead\n * vocabulary. A lead is the one CRM record a person does not type: it is\n * what a capture door wrote when somebody signed up, booked or submitted a\n * form, plus the working state the team wrote on top. A file therefore\n * carries HALF a lead — the person, and the team's annotations — and the\n * capture half is the door's to write.\n *\n * ## The address is the identity, because it is the document id\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey}`, and `personKey` is\n * the sha256 of the normalized address. So the address is not merely the\n * one required cell: it is the row's name. A cell the normalizer cannot\n * read is refused as `invalid-email`, and two rows of one file that\n * normalize to the same address are one person — the second is skipped as\n * `duplicate` rather than merged onto the first, for the reason the\n * contacts import gives: one person reported once created and once merged\n * is a number the operator cannot reconcile against the rows they sent.\n *\n * ## What a file may NOT say\n *\n * The export's `Sources`, `First seen`, `Last seen` and `Captures` columns\n * are the capture door's own record of what the visitor did, and\n * `Converted` is stamped by `crm/lead-convert` after the contact exists.\n * None of them is a field here, so the mapping leaves those columns\n * unmapped rather than mis-mapping them — a file that could write\n * \"first seen\" could rewrite the history it is supposed to describe. The\n * organization-level file's `Site` column is left out for a second reason:\n * the site is the drawer's question and the route's permission is granted\n * for that ONE site, so a cell must not be able to redirect a row to\n * another.\n *\n * ## Qualified is a conversion, not a status a cell can claim\n *\n * `qualified` means a lead BECAME a contact, and `convertedContactId`\n * beside it names the contact that was really created. Only the convert\n * route writes the pair, so the list's own status control offers new,\n * working and unqualified and nothing else. A cell naming `qualified` is\n * dropped and reported here for the same reason: a status claiming a\n * conversion that never happened would show a contact link to nowhere.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The address is the only cell that can make a row unstorable. An\n * unreadable status, or an unqualified reason with no unqualified status\n * to belong to, is left off the lead and REPORTED under\n * {@link LeadImportRow.dropped}, so the operator learns that a column went\n * nowhere rather than discovering it weeks later.\n */\n\nimport { CAMPAIGN_MEMBERSHIP_CAP } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadProfile,\n type CrmLeadStatus,\n isCrmLeadStatus,\n normalizeCrmLeadTags,\n normalizeCompanyWebsite,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n} from '@aglyn/aglyn/app-utils/csv-import'\n\n/** The shared ceilings, under this collection's names. */\nexport const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\nexport const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\nexport const LEAD_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\nexport const LEAD_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/** The same caps the lead's own cards hold a typed value to. */\nexport const LEAD_IMPORT_NOTES_MAX = 4000\nexport const LEAD_IMPORT_REASON_MAX = 500\n\n/** A person's name, capped where every other import caps one. */\nconst NAME_MAX = 120\n\n/**\n * The statuses a file may set.\n *\n * The list's own status control offers exactly these three, and for the\n * reason given above: `qualified` is written by the conversion, beside the\n * contact it produced.\n */\nexport const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[] =\n CRM_LEAD_STATUSES.filter((status) => status !== 'qualified')\n\n/**\n * The fields a column may be mapped to, in the order the mapping menu\n * lists them.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * lead source, the address parts and tags — is what a list from another\n * tool actually carries, and the reason a lead can be worked without a\n * contact beside it. They read under the same labels the contacts import\n * uses, so one spreadsheet maps the same way into either.\n */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'company',\n 'jobTitle',\n 'phone',\n 'website',\n 'leadSource',\n 'status',\n 'ownerEmail',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'campaigns',\n 'unqualifiedReason',\n 'notes',\n] as const\n\nexport type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n company: 'Company name',\n jobTitle: 'Job title',\n phone: 'Phone',\n website: 'Website',\n leadSource: 'Lead source',\n status: 'Status (new, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n campaigns: 'Campaigns (by name, comma or | separated)',\n unqualifiedReason: 'Unqualified reason',\n notes: 'Notes',\n}\n\n/**\n * Header aliases per field, matched after the shared header normalization.\n *\n * First in each list is the header this CRM's own leads export writes, so\n * an export re-imports without a hand mapping; the export's Sources, First\n * seen, Last seen, Captures, Converted and Site columns are deliberately\n * absent from every list, so they land on \"Do not import\".\n */\nconst FIELD_ALIASES: Record<LeadImportField, readonly string[]> = {\n email: ['email', 'email address', 'e mail', 'mail', 'contact email', 'work email'],\n name: ['name', 'full name', 'lead', 'lead name', 'contact', 'contact name', 'person'],\n company: [\n 'company',\n 'company name',\n 'organization',\n 'organisation',\n 'account',\n 'account name',\n 'employer',\n ],\n jobTitle: ['job title', 'title', 'position', 'role', 'headline'],\n phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'telephone', 'tel', 'cell'],\n website: ['website', 'web site', 'url', 'company website', 'company domain', 'domain'],\n leadSource: ['lead source', 'source', 'origin', 'channel', 'campaign'],\n status: ['status', 'lead status', 'stage', 'lead stage'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'suite', 'apartment'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'state or region', 'region', 'province', 'county'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code'],\n addressCountry: ['country', 'country code'],\n tags: ['tags', 'tag', 'labels', 'lists'],\n // `campaign` alone stays the lead source's: another tool's \"Campaign\"\n // column names where the person came from, not one of this site's\n // containers (AGL-3254).\n campaigns: ['campaigns', 'campaign names', 'in campaigns', 'email campaigns'],\n unqualifiedReason: [\n 'unqualified reason',\n 'reason',\n 'disqualified reason',\n 'lost reason',\n 'reason lost',\n ],\n notes: ['notes', 'note', 'description', 'comments', 'details'],\n}\n\nconst FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES)\n\n/** Column index → field. A column absent from the map is not imported. */\nexport type LeadImportMapping = Record<number, LeadImportField>\n\n/** A proposed mapping from a file's header row, each field taken at most once. */\nexport function guessLeadImportMapping(columns: readonly string[]): LeadImportMapping {\n return guessImportMapping(columns, LEAD_IMPORT_FIELDS, FIELD_ALIAS_KEYS)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim. `unknown` because the\n * server reads this off an untrusted body.\n */\nexport type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>\n\n/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */\nexport function mapLeadImportRow(\n cells: readonly string[],\n mapping: Record<number, LeadImportField | `custom:${string}`>,\n): LeadImportRawRow {\n const { custom: _custom, ...row } = mapImportRow(cells, mapping)\n return row\n}\n\nexport type LeadImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'lead-ceiling'\n | 'campaign-unknown'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string> = {\n 'invalid-email': 'No usable email address',\n duplicate: 'The same address appears earlier in this file',\n 'lead-ceiling': 'This site is at the platform lead limit',\n 'campaign-unknown': 'Names a campaign this site does not have',\n 'write-failed': 'Could not be saved',\n}\n\n/** The most campaigns one row may name — the membership field's own cap. */\nexport const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP\n\n/** One row, ready for the server to resolve and write. */\nexport interface LeadImportRow {\n /** Normalized — the address `personKey` derives the document id from. */\n email: string\n name?: string\n /**\n * The lead's own profile, already normalized the way the record stores\n * it (AGL-3231): a key present is a value to write, a key absent leaves\n * an existing lead's value alone. Never a clear — a blank cell in a file\n * is a cell nobody filled, not a decision to erase what the site knows.\n */\n profile: CrmLeadProfile\n /**\n * The campaigns the row files the lead under (AGL-3254), by NAME as the\n * file wrote them: the server resolves each against the site's own\n * containers and refuses the row whole when one is not there, because a\n * row filed under half its campaigns is a row nobody asked for.\n */\n campaigns?: string[]\n /** Absent leaves an existing lead's status alone and a new one reading as `new`. */\n status?: CrmLeadStatus\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n /** Only ever set beside `status: 'unqualified'` — the pair is one fact. */\n unqualifiedReason?: string\n notes?: string\n dropped: ImportDroppedValue[]\n}\n\nexport type LeadImportRowVerdict =\n | { ok: true; row: LeadImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A status cell by id or by label — `working`, `Working` and `WORKING` are\n * one status. `null` for a cell naming no status a file may set, which\n * includes the well-spelled `Qualified` the export writes for a converted\n * lead.\n */\nexport function parseImportLeadStatus(value: unknown): CrmLeadStatus | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const byId = isCrmLeadStatus(text) ? text : null\n const byLabel =\n LEAD_IMPORT_STATUSES.find(\n (status) => CRM_LEAD_STATUS_LABELS[status].toLowerCase() === text,\n ) ?? null\n const status = byId ?? byLabel\n return status && LEAD_IMPORT_STATUSES.includes(status) ? status : null\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be. Refused here only for an unusable address, because the address is\n * the document id; the owner is refused by the server, which alone can\n * look them up.\n */\nexport function normalizeLeadImportRow(raw: LeadImportRawRow): LeadImportRowVerdict {\n const emailText = importTextValue(raw.email, 320) ?? ''\n const email = normalizeContactEmail(emailText)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: emailText }\n }\n const dropped: ImportDroppedValue[] = []\n const drop = (field: LeadImportField, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n const row: LeadImportRow = { email, profile: {}, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\n\n /*\n * The profile (AGL-3231), field by field, each through the normalizer\n * the record's own card runs. A phone or a website the normalizer cannot\n * read is dropped and reported rather than stored as typed, because the\n * record renders both as links; the country is the one address part the\n * address normalizer drops silently — a typed name is not a code — so it\n * is the one part the report has to name.\n */\n const company = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (company) row.profile.company = company\n const jobTitle = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (jobTitle) row.profile.jobTitle = jobTitle\n const leadSource = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)?.replace(\n /\\s+/g,\n ' ',\n )\n if (leadSource) row.profile.leadSource = leadSource\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.profile.phone = phone\n else drop('phone', phoneText)\n }\n const websiteText = importTextValue(raw.website, 320)\n if (websiteText) {\n const website = normalizeCompanyWebsite(websiteText)\n if (website) row.profile.website = website\n else drop('website', websiteText)\n }\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.profile.address = address\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n const tags = normalizeCrmLeadTags(\n String(raw.tags ?? '')\n .split(/[,|]/)\n .map((tag) => tag.trim()),\n )\n if (tags.length) row.profile.tags = tags\n\n // Names, trimmed and deduplicated as typed; the server matches them to\n // the site's campaigns without regard to case.\n const campaigns = [\n ...new Set(\n String(raw.campaigns ?? '')\n .split(/[,|]/)\n .map((name) => name.trim().replace(/\\s+/g, ' '))\n .filter(Boolean),\n ),\n ].slice(0, LEAD_IMPORT_CAMPAIGNS_MAX)\n if (campaigns.length) row.campaigns = campaigns\n\n const statusText = importTextValue(raw.status, 32)\n if (statusText) {\n const status = parseImportLeadStatus(statusText)\n if (status) row.status = status\n else drop('status', statusText)\n }\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n /*\n * The reason belongs to the status: it is what an unqualified lead was\n * closed FOR, and on a lead that is not unqualified it describes\n * nothing. A row that carries one without the other keeps the status and\n * reports the reason, so a mis-mapped column is visible rather than\n * stored where nothing reads it.\n */\n const reasonText = importTextValue(raw.unqualifiedReason, LEAD_IMPORT_REASON_MAX)\n if (reasonText) {\n if (row.status === 'unqualified') row.unqualifiedReason = reasonText\n else drop('unqualifiedReason', reasonText)\n }\n\n const notes = importTextValue(raw.notes, LEAD_IMPORT_NOTES_MAX)\n if (notes) row.notes = notes\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by the address. */\nexport interface LeadImportSkippedRow {\n index: number\n email: string\n reason: LeadImportSkipReason\n}\n\n/** What one request did. The drawer sums these across a file. */\nexport type LeadImportChunkResult = ImportChunkResult<LeadImportSkippedRow>\n\nexport function emptyLeadImportResult(): LeadImportChunkResult {\n return emptyImportResult<LeadImportSkippedRow>()\n}\n\nexport function mergeLeadImportResults(\n total: LeadImportChunkResult,\n chunk: LeadImportChunkResult,\n offset = 0,\n): LeadImportChunkResult {\n return mergeImportResults(total, chunk, offset)\n}\n\n/** The skipped rows as a file the operator can fix and re-import. */\nexport function leadImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: LeadImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS)\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_CAP","normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","CRM_LEAD_TEXT_MAX","isCrmLeadStatus","normalizeCrmLeadTags","normalizeCompanyWebsite","normalizeAddress","normalizePhone","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","LEAD_IMPORT_MAX_ROWS","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","LEAD_IMPORT_PREVIEW_ROWS","LEAD_IMPORT_NOTES_MAX","LEAD_IMPORT_REASON_MAX","NAME_MAX","LEAD_IMPORT_STATUSES","filter","status","LEAD_IMPORT_FIELDS","LEAD_IMPORT_FIELD_LABELS","email","name","company","jobTitle","phone","website","leadSource","ownerEmail","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","campaigns","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","LEAD_IMPORT_CAMPAIGNS_MAX","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","profile","replace","phoneText","websiteText","address","line1","line2","city","state","postalCode","country","countryText","split","map","tag","length","Set","Boolean","slice","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type CrmLeadFields, type CrmLeadStatus } from '@aglyn/aglyn';
|
|
17
|
+
import { type EmailStateStatus, type CrmLeadFields, type CrmLeadStatus } from '@aglyn/aglyn';
|
|
18
18
|
/**
|
|
19
19
|
* What the Leads section's `Show` control offers (AGL-2608): the two
|
|
20
20
|
* aggregate views and each status on its own. `open` is the default — the
|
|
@@ -27,6 +27,26 @@ export declare const LEAD_FILTERS: readonly LeadFilter[];
|
|
|
27
27
|
export declare const LEAD_FILTER_LABELS: Record<LeadFilter, string>;
|
|
28
28
|
/** Whether a lead belongs in a filter's view. An absent status is `new`. */
|
|
29
29
|
export declare function leadMatchesFilter(lead: Pick<CrmLeadFields, 'status'>, filter: LeadFilter): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The Leads section's `Email` control (AGL-3245): every lead, the ones a
|
|
32
|
+
* member must not email — any verdict but `ok` — the ones nothing has been
|
|
33
|
+
* said about, or one verdict on its own. Beside `Show`, not inside it: a
|
|
34
|
+
* bounce does not move a lead out of Open, and a queue of bounced leads is
|
|
35
|
+
* a queue of people to reach some other way.
|
|
36
|
+
*/
|
|
37
|
+
export type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus;
|
|
38
|
+
export declare const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[];
|
|
39
|
+
export declare const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string>;
|
|
40
|
+
/** Whether a lead belongs in an email filter's view. */
|
|
41
|
+
export declare function leadMatchesEmailFilter(lead: Readonly<Record<string, unknown>>, filter: LeadEmailFilter): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The Leads section's `Campaign` control (AGL-3254): every lead, or the
|
|
44
|
+
* ones filed under one campaign — read off the lead's own `campaignIds`,
|
|
45
|
+
* the field every campaign member carries. `''` is every lead, which is
|
|
46
|
+
* what the control opens on; the ids are the site's containers', and the
|
|
47
|
+
* section resolves their names for the menu.
|
|
48
|
+
*/
|
|
49
|
+
export declare function leadMatchesCampaignFilter(lead: Readonly<Record<string, unknown>>, campaignId: string): boolean;
|
|
30
50
|
/**
|
|
31
51
|
* Whether a lead answers a search term.
|
|
32
52
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
15
|
* See the License for the specific language governing permissions and
|
|
15
16
|
* limitations under the License.
|
|
16
|
-
*/ import { CRM_LEAD_STATUS_LABELS, crmLeadStatus, isCrmLeadOpen } from "@aglyn/aglyn";
|
|
17
|
+
*/ import { EMAIL_STATE_LABELS, EMAIL_STATE_STATUSES, CRM_LEAD_STATUS_LABELS, emailStateForbidsEmail, crmLeadStatus, isCrmLeadOpen, readCampaignIds, readEmailState } from "@aglyn/aglyn";
|
|
17
18
|
export const LEAD_FILTERS = [
|
|
18
19
|
'open',
|
|
19
20
|
'new',
|
|
@@ -35,6 +36,34 @@ export const LEAD_FILTER_LABELS = {
|
|
|
35
36
|
if (filter === 'open') return isCrmLeadOpen(lead);
|
|
36
37
|
return crmLeadStatus(lead) === filter;
|
|
37
38
|
}
|
|
39
|
+
export const LEAD_EMAIL_FILTERS = [
|
|
40
|
+
'any',
|
|
41
|
+
'problem',
|
|
42
|
+
...EMAIL_STATE_STATUSES,
|
|
43
|
+
'none'
|
|
44
|
+
];
|
|
45
|
+
export const LEAD_EMAIL_FILTER_LABELS = _extends({
|
|
46
|
+
any: 'Any',
|
|
47
|
+
problem: 'Cannot be emailed',
|
|
48
|
+
none: 'Nothing known'
|
|
49
|
+
}, EMAIL_STATE_LABELS);
|
|
50
|
+
/** Whether a lead belongs in an email filter's view. */ export function leadMatchesEmailFilter(lead, filter) {
|
|
51
|
+
if (filter === 'any') return true;
|
|
52
|
+
const state = readEmailState(lead);
|
|
53
|
+
if (filter === 'none') return state === null;
|
|
54
|
+
if (filter === 'problem') return emailStateForbidsEmail(state);
|
|
55
|
+
return (state == null ? void 0 : state.status) === filter;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The Leads section's `Campaign` control (AGL-3254): every lead, or the
|
|
59
|
+
* ones filed under one campaign — read off the lead's own `campaignIds`,
|
|
60
|
+
* the field every campaign member carries. `''` is every lead, which is
|
|
61
|
+
* what the control opens on; the ids are the site's containers', and the
|
|
62
|
+
* section resolves their names for the menu.
|
|
63
|
+
*/ export function leadMatchesCampaignFilter(lead, campaignId) {
|
|
64
|
+
if (!campaignId) return true;
|
|
65
|
+
return readCampaignIds(lead).includes(campaignId);
|
|
66
|
+
}
|
|
38
67
|
/**
|
|
39
68
|
* The fields the Leads section's search box reads (AGL-3246): who the lead
|
|
40
69
|
* is and where they work — the words a person types to find one — plus the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.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 CRM_LEAD_STATUS_LABELS,\n type CrmLeadFields,\n type CrmLeadStatus,\n crmLeadStatus,\n isCrmLeadOpen,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nconst LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n const words = term.trim().toLowerCase().split(/\\s+/).filter(Boolean)\n if (!words.length) return true\n const values = [\n ...LEAD_SEARCH_FIELDS.map((field) => lead[field]),\n ...(Array.isArray(lead['tags']) ? lead['tags'] : []),\n ]\n .filter((value): value is string => typeof value === 'string' && value !== '')\n .map((value) => value.toLowerCase())\n return words.every((word) => values.some((value) => value.includes(word)))\n}\n"],"names":["CRM_LEAD_STATUS_LABELS","crmLeadStatus","isCrmLeadOpen","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","words","trim","toLowerCase","split","Boolean","length","values","map","field","Array","isArray","value","every","word","some"
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.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 EMAIL_STATE_LABELS,\n EMAIL_STATE_STATUSES,\n CRM_LEAD_STATUS_LABELS,\n type EmailStateStatus,\n type CrmLeadFields,\n type CrmLeadStatus,\n emailStateForbidsEmail,\n crmLeadStatus,\n isCrmLeadOpen,\n readCampaignIds,\n readEmailState,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The Leads section's `Email` control (AGL-3245): every lead, the ones a\n * member must not email — any verdict but `ok` — the ones nothing has been\n * said about, or one verdict on its own. Beside `Show`, not inside it: a\n * bounce does not move a lead out of Open, and a queue of bounced leads is\n * a queue of people to reach some other way.\n */\nexport type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus\n\nexport const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[] = [\n 'any',\n 'problem',\n ...EMAIL_STATE_STATUSES,\n 'none',\n]\n\nexport const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string> = {\n any: 'Any',\n problem: 'Cannot be emailed',\n none: 'Nothing known',\n ...EMAIL_STATE_LABELS,\n}\n\n/** Whether a lead belongs in an email filter's view. */\nexport function leadMatchesEmailFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: LeadEmailFilter,\n): boolean {\n if (filter === 'any') return true\n const state = readEmailState(lead)\n if (filter === 'none') return state === null\n if (filter === 'problem') return emailStateForbidsEmail(state)\n return state?.status === filter\n}\n\n/**\n * The Leads section's `Campaign` control (AGL-3254): every lead, or the\n * ones filed under one campaign — read off the lead's own `campaignIds`,\n * the field every campaign member carries. `''` is every lead, which is\n * what the control opens on; the ids are the site's containers', and the\n * section resolves their names for the menu.\n */\nexport function leadMatchesCampaignFilter(\n lead: Readonly<Record<string, unknown>>,\n campaignId: string,\n): boolean {\n if (!campaignId) return true\n return readCampaignIds(lead).includes(campaignId)\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nconst LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n const words = term.trim().toLowerCase().split(/\\s+/).filter(Boolean)\n if (!words.length) return true\n const values = [\n ...LEAD_SEARCH_FIELDS.map((field) => lead[field]),\n ...(Array.isArray(lead['tags']) ? lead['tags'] : []),\n ]\n .filter((value): value is string => typeof value === 'string' && value !== '')\n .map((value) => value.toLowerCase())\n return words.every((word) => values.some((value) => value.includes(word)))\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","emailStateForbidsEmail","crmLeadStatus","isCrmLeadOpen","readCampaignIds","readEmailState","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","leadMatchesEmailFilter","state","status","leadMatchesCampaignFilter","campaignId","includes","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","words","trim","toLowerCase","split","Boolean","length","values","map","field","Array","isArray","value","every","word","some"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kBAAkB,EAClBC,oBAAoB,EACpBC,sBAAsB,EAItBC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,eAAe,EACfC,cAAc,QACT,eAAc;AAWrB,OAAO,MAAMC,eAAsC;IACjD;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,OAAO,MAAMC,qBAAiD;IAC5DC,MAAM;IACNC,KAAK;IACLC,KAAKV,uBAAuBU,GAAG;IAC/BC,SAASX,uBAAuBW,OAAO;IACvCC,WAAWZ,uBAAuBY,SAAS;IAC3CC,aAAab,uBAAuBa,WAAW;AACjD,EAAC;AAED,0EAA0E,GAC1E,OAAO,SAASC,kBACdC,IAAmC,EACnCC,MAAkB;IAElB,IAAIA,WAAW,OAAO,OAAO;IAC7B,IAAIA,WAAW,QAAQ,OAAOb,cAAcY;IAC5C,OAAOb,cAAca,UAAUC;AACjC;AAWA,OAAO,MAAMC,qBAAiD;IAC5D;IACA;OACGlB;IACH;CACD,CAAA;AAED,OAAO,MAAMmB,2BAA4D;IACvEC,KAAK;IACLC,SAAS;IACTC,MAAM;GACHvB,oBACJ;AAED,sDAAsD,GACtD,OAAO,SAASwB,uBACdP,IAAuC,EACvCC,MAAuB;IAEvB,IAAIA,WAAW,OAAO,OAAO;IAC7B,MAAMO,QAAQlB,eAAeU;IAC7B,IAAIC,WAAW,QAAQ,OAAOO,UAAU;IACxC,IAAIP,WAAW,WAAW,OAAOf,uBAAuBsB;IACxD,OAAOA,CAAAA,yBAAAA,MAAOC,MAAM,MAAKR;AAC3B;AAEA;;;;;;CAMC,GACD,OAAO,SAASS,0BACdV,IAAuC,EACvCW,UAAkB;IAElB,IAAI,CAACA,YAAY,OAAO;IACxB,OAAOtB,gBAAgBW,MAAMY,QAAQ,CAACD;AACxC;AAEA;;;;;;CAMC,GACD,MAAME,qBAAqB;IAAC;IAAQ;IAAS;IAAW;CAAW;AAEnE;;;;;;;CAOC,GACD,OAAO,SAASC,kBACdd,IAAuC,EACvCe,IAAY;IAEZ,MAAMC,QAAQD,KAAKE,IAAI,GAAGC,WAAW,GAAGC,KAAK,CAAC,OAAOlB,MAAM,CAACmB;IAC5D,IAAI,CAACJ,MAAMK,MAAM,EAAE,OAAO;IAC1B,MAAMC,SAAS;WACVT,mBAAmBU,GAAG,CAAC,CAACC,QAAUxB,IAAI,CAACwB,MAAM;WAC5CC,MAAMC,OAAO,CAAC1B,IAAI,CAAC,OAAO,IAAIA,IAAI,CAAC,OAAO,GAAG,EAAE;KACpD,CACEC,MAAM,CAAC,CAAC0B,QAA2B,OAAOA,UAAU,YAAYA,UAAU,IAC1EJ,GAAG,CAAC,CAACI,QAAUA,MAAMT,WAAW;IACnC,OAAOF,MAAMY,KAAK,CAAC,CAACC,OAASP,OAAOQ,IAAI,CAAC,CAACH,QAAUA,MAAMf,QAAQ,CAACiB;AACrE"}
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { buildCrmEmailActivity, contactCaptureHostIds, CRM_ACTIVITY_LOG_FULL_MESSAGE, CRM_COLLECTIONS, CRM_EMAIL_BODY_MAX, CRM_EMAIL_CONTEXT, CRM_EMAIL_SUBJECT_MAX, crmActivityLogHasRoom, crmEmailDeliveryTags, crmMergeFieldsIn, crmScopeTokens, isOrgWideMember, normalizeContactEmail, readContactFacet, readMarketingBasis, renderCrmMergeFields, resolveOrgEntitlements, visibleToHost } from "@aglyn/aglyn/server";
|
|
17
|
+
*/ import { buildCrmEmailActivity, contactCaptureHostIds, CRM_ACTIVITY_LOG_FULL_MESSAGE, CRM_COLLECTIONS, CRM_EMAIL_BODY_MAX, CRM_EMAIL_CONTEXT, CRM_EMAIL_SUBJECT_MAX, crmActivityLogHasRoom, crmEmailDeliveryTags, emailStateRefusal, crmMergeFieldsIn, crmScopeTokens, isOrgWideMember, normalizeContactEmail, readContactFacet, readEmailState, readMarketingBasis, renderCrmMergeFields, resolveOrgEntitlements, visibleToHost } from "@aglyn/aglyn/server";
|
|
18
18
|
import { isEmailConfigured, sendEmail, sendFailureReason } from "@aglyn/shared-util-email";
|
|
19
19
|
import { consentGroupForSite, consumeRateLimit, countCrmActivitiesForRecord, filterSendableForHost, firebaseAdmin, getHostDocAdmin, getOrgForHost, hostSendingIdentity, logOrgActivity, memberHasOrgPermission, newCrmActivityRef, orgDataCollectionForHost, readOrgEmailRamp, recordEmailSends, releaseCrmEmailSend, reserveCrmEmailSend, resolveOrgMembership, writeCrmEmailActivity } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
@@ -502,6 +502,15 @@ export const crmEmailSendHandler = async (req, res)=>{
|
|
|
502
502
|
reason: 'declined'
|
|
503
503
|
}));
|
|
504
504
|
}
|
|
505
|
+
// The record's own verdict (AGL-3245): what the lists hold, in the words
|
|
506
|
+
// the page shows — a bounce, a gateway block, a do-not-contact mark the
|
|
507
|
+
// suppression lists do not carry.
|
|
508
|
+
const stateRefusal = emailStateRefusal(readEmailState(recipient.record));
|
|
509
|
+
if (stateRefusal) {
|
|
510
|
+
return answer(res, refuse(409, stateRefusal, {
|
|
511
|
+
reason: 'email-state'
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
505
514
|
if (!isEmailConfigured()) {
|
|
506
515
|
return answer(res, refuse(501, 'Email is not configured on this deployment.', {
|
|
507
516
|
reason: 'unconfigured'
|
|
@@ -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 type CrmMergeContext,\n crmMergeFieldsIn,\n crmScopeTokens,\n isOrgWideMember,\n normalizeContactEmail,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n readContactFacet,\n readMarketingBasis,\n renderCrmMergeFields,\n resolveOrgEntitlements,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n isEmailConfigured,\n sendEmail,\n sendFailureReason,\n type SendEmailResult,\n} from '@aglyn/shared-util-email'\nimport {\n consentGroupForSite,\n consumeRateLimit,\n countCrmActivitiesForRecord,\n filterSendableForHost,\n firebaseAdmin,\n getHostDocAdmin,\n getOrgForHost,\n hostSendingIdentity,\n logOrgActivity,\n memberHasOrgPermission,\n newCrmActivityRef,\n orgDataCollectionForHost,\n readOrgEmailRamp,\n recordEmailSends,\n releaseCrmEmailSend,\n reserveCrmEmailSend,\n resolveOrgMembership,\n writeCrmEmailActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n orgHostIds,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/email-send` — one email to one person, from their record\n * (AGL-2615).\n *\n * Body: `{ hostId, contactId? | leadId? | dealId?, subject, body }`. Answers\n * `{ ok: true, activityId, to, from, logged }` once the provider accepted\n * the message, and a refusal with a `reason` the dialog can act on\n * otherwise. The subject and the body may carry merge fields —\n * `{{contact.firstName}}`, `{{deal.amount}}` (AGL-2658) — which this route\n * fills from the record before the send, so the timeline row is the letter\n * as it left; see `crm-email-templates.ts` for the grammar.\n *\n * ## What it is, and what it is not\n *\n * A message a teammate wrote to one person they have a relationship with —\n * a reply to a lead, a follow-up on a deal. It is not marketing: no\n * campaign audience, no unsubscribe pair, no frequency window, no consent\n * split. What it does owe is everything a bounce or a complaint owes the\n * shared sending domain, which is why BOTH suppression lists are consulted\n * and why a person's own stated refusal — a `declined` basis — is a hard\n * stop even though the message is not the kind of mail that basis governs.\n * The recipient asked this site not to write; the kind of writing is not\n * the point.\n *\n * ## Why the recipient is resolved here and not taken from the body\n *\n * The dialog shows the address the record carries, and this route reads it\n * again off the record the caller named, through the same visibility check\n * every other CRM route makes. A `to` in the body would make this a route\n * that sends the site's mail to any address a member types, which is a\n * different product with a different abuse surface.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, … }` from the org-level hub. The caller is authorized\n * by the org — an org-wide member holding `data.manage` — and the record is\n * read with no site's visibility to check, because an org-wide member reads\n * every row. What the org level cannot do away with is the SITE the message\n * leaves from: the sending identity, the suppression list and the person's\n * stated refusal are each a site's. So the org variant still names one —\n * the record's own capturing site or the site the reader picked, in the\n * body; failing that the record's own site read off the document; failing\n * that the org's only site — and refuses, with a `site` reason, when the\n * org has several and none was named. The row it writes is visible to\n * whoever may see the RECORD, so an email to a person another site\n * captured shows on their timeline under that site's hub; the act is\n * logged in the org's feed.\n *\n * ## The order of the gates\n *\n * Cheap and certain first. A malformed body and a missing token cost\n * nothing; the per-user pace is one counter; the record is one read and\n * decides everything after it; the ceiling one aggregate; the two\n * suppression lists two keyed reads; the sending identity next, because it\n * is the most expensive and the one a workspace fixes once. The daily cap\n * comes LAST and is the one gate that writes before the provider is called\n * (AGL-2645): the send is reserved on today's counter inside a transaction,\n * so two reps at the last slot cannot both pass one reading of it, and a\n * message the provider refuses gives the slot back. After the provider has\n * accepted, two more things are written, neither able to fail the send:\n * the activity row and the org's cost meter.\n *\n * ## The new-workspace ramp is part of that cap, not a gate beside it\n *\n * A message from a workspace created this morning lands on the sending\n * domain every other tenant's receipts leave on, so the plan's day is held\n * to the share of it the workspace has earned — the same ramp the campaign\n * path is paced by, `readOrgEmailRamp`, taken to the allowance this surface\n * meters instead of the campaign ceiling. It is handed to the reservation\n * rather than checked before it, so the ceiling and the count it is judged\n * against come out of the one transactional read; a ramp consulted\n * separately would be a second ceiling and two reps could pass it at once.\n * A workspace past its first week is not ramped and pays nothing to find\n * that out.\n */\n\n/** How many one-to-one emails one person may send in one minute. */\nexport const CRM_EMAIL_SENDS_PER_MINUTE = 20\nconst CRM_EMAIL_RATE_WINDOW_MS = 60_000\n\n/** What the route says when the plan carries no one-to-one email at all. */\nexport const CRM_EMAIL_NOT_INCLUDED_MESSAGE =\n 'Your plan does not include one-to-one email. Upgrade in Billing to email ' +\n 'people from their records.'\n\n/** What the route says at the daily cap; `included` is the plan's figure. */\nexport function crmEmailCapReachedMessage(included: number): string {\n return (\n `Today's one-to-one email limit (${included.toLocaleString('en-US')}) is ` +\n 'reached. It resets at midnight UTC — see Billing for the count.'\n )\n}\n\n/**\n * What the route says when the NEW-WORKSPACE RAMP is the ceiling that was\n * reached, rather than the plan's own.\n *\n * The campaign path's wording, pointed at this surface: the number, what was\n * spent against it, that nothing was sent or counted, and when it lifts. A\n * workspace told only \"limit reached\" against a figure far below the one its\n * plan page advertises would read it as a billing fault.\n */\nexport function crmEmailRampReachedMessage(\n ceiling: number,\n used: number,\n): string {\n return (\n `This workspace may send ${ceiling.toLocaleString('en-US')} one-to-one ` +\n `email${ceiling === 1 ? '' : 's'} a day while it establishes a sending ` +\n `history, and has sent ${used.toLocaleString('en-US')} today. Nothing ` +\n 'has been sent and nothing has been counted — the allowance grows as ' +\n \"the workspace sends, and is the plan's in full after its first week. \" +\n 'It resets at midnight UTC.'\n )\n}\n\n/** What the route says when the ramp's own history could not be read. */\nexport const CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE =\n \"This workspace's sending history could not be read just now, so how much \" +\n 'of its first-week email allowance it has earned is not known. Nothing has ' +\n 'been sent and nothing has been counted — try again in a moment.'\n\nexport const CRM_EMAIL_SUPPRESSED_MESSAGE =\n 'This address has bounced or reported a message as spam, so it cannot be ' +\n 'emailed.'\n\nexport const CRM_EMAIL_DECLINED_MESSAGE =\n 'This person asked not to be emailed by this site.'\n\nexport const CRM_EMAIL_RATE_MESSAGE =\n 'You have sent too many emails in the last minute. Wait a moment and try again.'\n\n/** What the org variant says when no site was named and the org has several. */\nexport const CRM_EMAIL_PICK_SITE_MESSAGE =\n 'Pick the site this email leaves from — its sending address is what the ' +\n 'message is sent as.'\n\ntype Refusal = { ok: false; status: number; body: Record<string, unknown> }\n\nconst refuse = (\n status: number,\n error: string,\n extra: Record<string, unknown> = {},\n): Refusal => ({ ok: false, status, body: { error, ...extra } })\n\n/** One typed field, trimmed and bounded. */\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\ninterface Sender {\n ok: true\n uid: string\n /** The address replies go to — the token's, never the body's. */\n email: string\n /** The name in front of the sending address, when the token carries one. */\n name: string\n orgId: string\n org: Partial<AglynOrgBilling>\n}\n\n/**\n * Who is sending, and whether they may: a verified ID token, then\n * `data.manage` on the site's org through the same three-layer read the\n * create route makes, admitted only for a site the member reaches. Staff\n * pass the permission check as they do on every CRM route, and send as\n * themselves.\n */\nasync function authorizeSender(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Sender | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; name?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] email-send could not verify the sender', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const org = (resolved.org ?? {}) as Partial<AglynOrgBilling>\n if (decoded.staff !== true) {\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Sending email from a record requires the data.manage permission on this site',\n )\n }\n }\n const email = normalizeContactEmail(decoded.email)\n if (!email) {\n // A reply-to is owed and the token is the only place it can honestly\n // come from; an account with no address cannot be replied to.\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: decoded.uid,\n email,\n name: typed(decoded.name, 120),\n orgId,\n org,\n }\n}\n\n/**\n * The org variant's sender (AGL-2634): authorized by the org for an\n * org-wide member holding `data.manage`, with the same reply-to rule.\n */\nasync function authorizeOrgSender(\n req: PluginApiRequest,\n orgId: string,\n): Promise<Sender | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: 'data.manage',\n refusal:\n 'Sending email at the organization level requires the data.manage ' +\n 'permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n const email = normalizeContactEmail(caller.email)\n if (!email) {\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: caller.uid,\n email,\n name: caller.name,\n orgId,\n org: caller.org as Partial<AglynOrgBilling>,\n }\n}\n\ninterface Recipient {\n ok: true\n email: string\n /** The document the consent basis is read off — the contact, or the lead. */\n record: Record<string, unknown>\n /** The contact document, when one was read — where the company link is. */\n contact: Record<string, unknown> | null\n link: CrmActivityLink\n /**\n * The site the record itself names — a contact's first capturing site, a\n * deal's own, a lead's — which is the site an org-level send leaves from\n * when the body named none.\n */\n siteHint: string\n /** The deal document, when one was named — what `{{deal.*}}` reads (AGL-2658). */\n deal: Record<string, unknown> | null\n /** The lead document, when the recipient is a lead — what `{{lead.*}}` reads. */\n lead: Record<string, unknown> | null\n}\n\n/**\n * The person the named record is about, as THIS site may see them.\n *\n * A deal names its contact; a lead carries its own address; a contact is\n * itself. Each is looked up by id and then checked against what the site\n * may read — the Admin SDK evaluates no rules, and a member of site A must\n * not be able to mail site B's contact by guessing an id. The links the\n * activity is filed under are whatever resolved: a deal's email is also the\n * contact's, so it lands on both timelines.\n *\n * At the organization level there is no site to check against and none is\n * checked: the caller was admitted as an org-wide member, who reads every\n * row. A lead still needs its site named — a lead lives under one.\n */\nasync function resolveRecipient(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n ids: { contactId: string; leadId: string; dealId: string },\n): Promise<Recipient | Refusal> {\n const link: CrmActivityLink = {}\n const { hostId } = scope\n const scoped = scope.level === 'site'\n const visible = (tokens: unknown) =>\n !scoped || visibleToHost(tokens as readonly string[], hostId)\n let contactId = ids.contactId\n let siteHint = ''\n let dealRecord: Record<string, unknown> | null = null\n\n if (ids.dealId) {\n const deal = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.deals)\n .doc(ids.dealId)\n .get()\n if (!deal.exists || !visible(deal.get('visibleTo'))) {\n return refuse(404, 'Unknown deal')\n }\n link.dealId = deal.id\n dealRecord = (deal.data() ?? {}) as Record<string, unknown>\n siteHint = typed(deal.get('hostId'), 128)\n contactId = contactId || String(deal.get('contactId') ?? '').trim()\n if (!contactId) {\n return refuse(400, 'This deal names no contact to email.')\n }\n }\n\n if (contactId) {\n const contacts = scoped\n ? await orgDataCollectionForHost(hostId, 'contacts')\n : firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await contacts.doc(contactId).get()\n if (!contact.exists || !visible(contact.get('visibleTo'))) {\n return refuse(404, 'Unknown contact')\n }\n const email = normalizeContactEmail(contact.get('email'))\n if (!email) return refuse(400, 'This contact has no email address.')\n const record = (contact.data() ?? {}) as Record<string, unknown>\n link.contactId = contact.id\n if (ids.leadId) link.leadId = ids.leadId\n siteHint =\n siteHint || typed(record['hostId'], 128) || contactCaptureHostIds(record)[0] || ''\n return { ok: true, email, record, contact: record, link, siteHint, deal: dealRecord, lead: null }\n }\n\n if (ids.leadId) {\n if (!hostId) return refuse(400, 'Name the site the lead lives under.')\n const lead = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('leads')\n .doc(ids.leadId)\n .get()\n if (!lead.exists) return refuse(404, 'Unknown lead')\n const email = normalizeContactEmail(lead.get('email'))\n if (!email) return refuse(400, 'This lead has no email address.')\n const record = (lead.data() ?? {}) as Record<string, unknown>\n link.leadId = lead.id\n // A converted lead's email belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return { ok: true, email, record, contact: null, link, siteHint: hostId, deal: null, lead: record }\n }\n\n return refuse(400, 'Name a contact, lead or deal to email.')\n}\n\n/**\n * The site an org-level send leaves from (AGL-2634): the body's, else the\n * record's own, else the org's only site — and a refusal the dialog turns\n * into a Site picker when the org has several and none was named.\n */\nasync function resolveSendingSite(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n siteHint: string,\n): Promise<{ ok: true; hostId: string } | Refusal> {\n if (scope.hostId) return { ok: true, hostId: scope.hostId }\n if (siteHint) return { ok: true, hostId: siteHint }\n const hosts = await orgHostIds(firestore, orgId)\n if (hosts.length === 1) return { ok: true, hostId: hosts[0] }\n return refuse(409, CRM_EMAIL_PICK_SITE_MESSAGE, { reason: 'site' })\n}\n\n/** The HTTP shape of a send the provider did not accept. */\nfunction sendRefusal(result: SendEmailResult): Refusal {\n const reason = sendFailureReason(result)\n const detail = String((result as { detail?: unknown }).detail ?? '')\n if (reason === 'unverified-domain') {\n return refuse(409, detail || 'This site has no verified sending identity.', {\n reason: 'sending-identity',\n })\n }\n if (reason === 'suppressed' || reason === 'unengaged') {\n return refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' })\n }\n if (reason === 'rate-limited') {\n const retryAtMs = Number((result as { retryAtMs?: unknown }).retryAtMs)\n return refuse(\n 503,\n 'The mail provider asked us to slow down. Try again in a minute.',\n {\n reason: 'provider-rate',\n retryAfterSeconds: Number.isFinite(retryAtMs)\n ? Math.max(1, Math.ceil((retryAtMs - Date.now()) / 1000))\n : 60,\n },\n )\n }\n if (reason === 'unconfigured') {\n return refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n })\n }\n return refuse(502, 'The email could not be sent.', { reason: 'send-failed' })\n}\n\n/** What the route says when a subject or a message is nothing once its fields are filled. */\nexport const CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE =\n 'The subject and the message are empty once their merge fields are filled ' +\n 'in. Check the fields against this record.'\n\n/**\n * What `{{…}}` reads from, for this send (AGL-2658): the documents already\n * in hand — the contact, the deal, the lead — the sender off their token,\n * and the site's name, read only when a field asks for it. The contact's\n * facet is the SENDING site's group's, because a name, a job title and a\n * company are one holder's knowledge of a person.\n */\nasync function mergeContextFor(options: {\n recipient: Recipient\n sender: Sender\n hostId: string\n groupId: string\n fields: readonly string[]\n}): Promise<CrmMergeContext> {\n const { recipient, sender, hostId, groupId, fields } = options\n const wantsSite = fields.some((field) => field.startsWith('site.'))\n const host = wantsSite ? await getHostDocAdmin(hostId).catch(() => null) : null\n return {\n contact: recipient.contact,\n contactGroupId: groupId,\n lead: recipient.lead,\n deal: recipient.deal,\n sender: { name: sender.name, email: sender.email },\n site: { name: typed(host?.['displayName'], 200) },\n }\n}\n\nfunction answer(res: PluginApiResponse, refusal: Refusal): void {\n const retry = refusal.body['retryAfterSeconds']\n if (typeof retry === 'number') res.setHeader('Retry-After', String(retry))\n res.status(refusal.status).json(refusal.body)\n}\n\nexport const crmEmailSendHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const routeScope = readCrmRouteScope(body)\n if (!routeScope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const ids = {\n contactId: typed(body['contactId'], 128),\n leadId: typed(body['leadId'], 128),\n dealId: typed(body['dealId'], 128),\n }\n if (!ids.contactId && !ids.leadId && !ids.dealId) {\n res.status(400).json({ error: 'Name a contact, lead or deal to email.' })\n return\n }\n const draftSubject = typed(body['subject'], CRM_EMAIL_SUBJECT_MAX)\n if (!draftSubject) {\n res.status(400).json({ error: 'Enter a subject.' })\n return\n }\n // Paragraphs are what the dialog offers, and a plain-text part is what a\n // paragraph is; line endings are normalized so the stored body and the\n // sent body are one string.\n const draftText = String(body['body'] ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .trim()\n .slice(0, CRM_EMAIL_BODY_MAX)\n if (!draftText) {\n res.status(400).json({ error: 'Write the message.' })\n return\n }\n\n try {\n const sender =\n routeScope.level === 'org'\n ? await authorizeOrgSender(req, routeScope.orgId)\n : await authorizeSender(req, routeScope.hostId)\n if (sender.ok === false) return answer(res, sender)\n const { orgId, org } = sender\n /*\n * THE PLAN, before the pace and the cap (AGL-2787). One-to-one email is\n * the suite's; a plan without the suite is refused as such, rather than\n * reaching the daily cap and being told about a zero it has no way to\n * raise short of the upgrade this names.\n */\n const suite = crmSuiteRefusal(org, 'Emailing one person from their record')\n if (suite) return answer(res, { ok: false, status: suite.status, body: suite.body })\n\n /*\n * THE PER-USER PACE, on its own key. The console dispatcher already\n * counts this write against the person's general console budget; this\n * is the narrower bucket the feature owes — twenty a minute is a rep\n * writing, and past it is a script — keyed by the uid the token proved\n * so one person cannot spend the bucket of everyone on their address.\n */\n const rate = await consumeRateLimit(`crm-email-send:uid:${sender.uid}`, {\n limit: CRM_EMAIL_SENDS_PER_MINUTE,\n windowMs: CRM_EMAIL_RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n return answer(\n res,\n refuse(429, CRM_EMAIL_RATE_MESSAGE, {\n reason: 'rate',\n retryAfterSeconds: Math.max(\n 1,\n Math.ceil((rate.resetMs - Date.now()) / 1000),\n ),\n }),\n )\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const recipient = await resolveRecipient(firestore, routeScope, orgId, ids)\n if (recipient.ok === false) return answer(res, recipient)\n // The site the message leaves from — the mounted one under a site, and\n // at the organization level whatever the body, the record or the org\n // can answer.\n const site = await resolveSendingSite(firestore, routeScope, orgId, recipient.siteHint)\n if (site.ok === false) return answer(res, site)\n const { hostId } = site\n const group = await consentGroupForSite(hostId, org as Record<string, unknown>)\n // The company the sending site files this person under, so the email\n // shows on the company's log as the automation step's rows do.\n if (recipient.contact) {\n const facet = readContactFacet(recipient.contact, group.groupId)\n if (facet.companyId) recipient.link.companyId = facet.companyId\n }\n\n /*\n * MERGE FIELDS (AGL-2658), filled here rather than in the dialog: the\n * dialog previews with the same resolver, but what leaves and what is\n * logged is rendered by the route, off the documents it read to find\n * the recipient, so a preview the rep did not look at cannot differ\n * from the letter. A subject or a message that is nothing once its\n * fields are filled is refused before any gate is spent on it.\n */\n const fields = crmMergeFieldsIn(`${draftSubject}\\n${draftText}`)\n const merge = fields.length\n ? await mergeContextFor({ recipient, sender, hostId, groupId: group.groupId, fields })\n : null\n const subject = merge\n ? renderCrmMergeFields(draftSubject, merge).trim().slice(0, CRM_EMAIL_SUBJECT_MAX)\n : draftSubject\n const text = merge\n ? renderCrmMergeFields(draftText, merge).trim().slice(0, CRM_EMAIL_BODY_MAX)\n : draftText\n if (!subject || !text) {\n return answer(res, refuse(400, CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE, { reason: 'merge' }))\n }\n\n // The per-record ceiling, before the send: an email that could not be\n // logged would be a message the timeline never shows, and the timeline\n // is the reason the send lives on the record.\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, recipient.link))) {\n return answer(res, refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE, { reason: 'ceiling' }))\n }\n\n // Both suppression lists, then the person's own refusal.\n const sendable = await filterSendableForHost(hostId, [recipient.email])\n if (!sendable.length) {\n return answer(res, refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' }))\n }\n if (readMarketingBasis(recipient.record, group).basis === 'declined') {\n return answer(res, refuse(409, CRM_EMAIL_DECLINED_MESSAGE, { reason: 'declined' }))\n }\n\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","crmMergeFieldsIn","crmScopeTokens","isOrgWideMember","normalizeContactEmail","readContactFacet","readMarketingBasis","renderCrmMergeFields","resolveOrgEntitlements","visibleToHost","isEmailConfigured","sendEmail","sendFailureReason","consentGroupForSite","consumeRateLimit","countCrmActivitiesForRecord","filterSendableForHost","firebaseAdmin","getHostDocAdmin","getOrgForHost","hostSendingIdentity","logOrgActivity","memberHasOrgPermission","newCrmActivityRef","orgDataCollectionForHost","readOrgEmailRamp","recordEmailSends","releaseCrmEmailSend","reserveCrmEmailSend","resolveOrgMembership","writeCrmEmailActivity","isRefusedIdToken","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_EMAIL_SENDS_PER_MINUTE","CRM_EMAIL_RATE_WINDOW_MS","CRM_EMAIL_NOT_INCLUDED_MESSAGE","crmEmailCapReachedMessage","included","toLocaleString","crmEmailRampReachedMessage","ceiling","used","CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE","CRM_EMAIL_SUPPRESSED_MESSAGE","CRM_EMAIL_DECLINED_MESSAGE","CRM_EMAIL_RATE_MESSAGE","CRM_EMAIL_PICK_SITE_MESSAGE","refuse","status","error","extra","ok","body","typed","value","max","String","trim","slice","authorizeSender","req","hostId","resolved","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","console","catch","orgId","org","staff","member","membership","uid","reaches","Boolean","hostAccess","allowed","email","name","authorizeOrgSender","caller","needs","refusal","resolveRecipient","firestore","scope","ids","link","scoped","level","visible","tokens","contactId","siteHint","dealRecord","dealId","deal","collection","doc","deals","get","exists","id","data","contact","contacts","record","leadId","lead","converted","resolveSendingSite","hosts","reason","sendRefusal","result","detail","retryAtMs","Number","retryAfterSeconds","isFinite","Math","ceil","Date","now","CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE","mergeContextFor","options","recipient","sender","groupId","fields","wantsSite","some","field","host","contactGroupId","site","answer","res","retry","setHeader","json","crmEmailSendHandler","method","routeScope","draftSubject","draftText","replace","suite","rate","limit","windowMs","resetMs","group","facet","companyId","merge","subject","text","orgRef","sendable","basis","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 readOrgEmailRamp,\n recordEmailSends,\n releaseCrmEmailSend,\n reserveCrmEmailSend,\n resolveOrgMembership,\n writeCrmEmailActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n orgHostIds,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/email-send` — one email to one person, from their record\n * (AGL-2615).\n *\n * Body: `{ hostId, contactId? | leadId? | dealId?, subject, body }`. Answers\n * `{ ok: true, activityId, to, from, logged }` once the provider accepted\n * the message, and a refusal with a `reason` the dialog can act on\n * otherwise. The subject and the body may carry merge fields —\n * `{{contact.firstName}}`, `{{deal.amount}}` (AGL-2658) — which this route\n * fills from the record before the send, so the timeline row is the letter\n * as it left; see `crm-email-templates.ts` for the grammar.\n *\n * ## What it is, and what it is not\n *\n * A message a teammate wrote to one person they have a relationship with —\n * a reply to a lead, a follow-up on a deal. It is not marketing: no\n * campaign audience, no unsubscribe pair, no frequency window, no consent\n * split. What it does owe is everything a bounce or a complaint owes the\n * shared sending domain, which is why BOTH suppression lists are consulted\n * and why a person's own stated refusal — a `declined` basis — is a hard\n * stop even though the message is not the kind of mail that basis governs.\n * The recipient asked this site not to write; the kind of writing is not\n * the point.\n *\n * ## Why the recipient is resolved here and not taken from the body\n *\n * The dialog shows the address the record carries, and this route reads it\n * again off the record the caller named, through the same visibility check\n * every other CRM route makes. A `to` in the body would make this a route\n * that sends the site's mail to any address a member types, which is a\n * different product with a different abuse surface.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, … }` from the org-level hub. The caller is authorized\n * by the org — an org-wide member holding `data.manage` — and the record is\n * read with no site's visibility to check, because an org-wide member reads\n * every row. What the org level cannot do away with is the SITE the message\n * leaves from: the sending identity, the suppression list and the person's\n * stated refusal are each a site's. So the org variant still names one —\n * the record's own capturing site or the site the reader picked, in the\n * body; failing that the record's own site read off the document; failing\n * that the org's only site — and refuses, with a `site` reason, when the\n * org has several and none was named. The row it writes is visible to\n * whoever may see the RECORD, so an email to a person another site\n * captured shows on their timeline under that site's hub; the act is\n * logged in the org's feed.\n *\n * ## The order of the gates\n *\n * Cheap and certain first. A malformed body and a missing token cost\n * nothing; the per-user pace is one counter; the record is one read and\n * decides everything after it; the ceiling one aggregate; the two\n * suppression lists two keyed reads; the sending identity next, because it\n * is the most expensive and the one a workspace fixes once. The daily cap\n * comes LAST and is the one gate that writes before the provider is called\n * (AGL-2645): the send is reserved on today's counter inside a transaction,\n * so two reps at the last slot cannot both pass one reading of it, and a\n * message the provider refuses gives the slot back. After the provider has\n * accepted, two more things are written, neither able to fail the send:\n * the activity row and the org's cost meter.\n *\n * ## The new-workspace ramp is part of that cap, not a gate beside it\n *\n * A message from a workspace created this morning lands on the sending\n * domain every other tenant's receipts leave on, so the plan's day is held\n * to the share of it the workspace has earned — the same ramp the campaign\n * path is paced by, `readOrgEmailRamp`, taken to the allowance this surface\n * meters instead of the campaign ceiling. It is handed to the reservation\n * rather than checked before it, so the ceiling and the count it is judged\n * against come out of the one transactional read; a ramp consulted\n * separately would be a second ceiling and two reps could pass it at once.\n * A workspace past its first week is not ramped and pays nothing to find\n * that out.\n */\n\n/** How many one-to-one emails one person may send in one minute. */\nexport const CRM_EMAIL_SENDS_PER_MINUTE = 20\nconst CRM_EMAIL_RATE_WINDOW_MS = 60_000\n\n/** What the route says when the plan carries no one-to-one email at all. */\nexport const CRM_EMAIL_NOT_INCLUDED_MESSAGE =\n 'Your plan does not include one-to-one email. Upgrade in Billing to email ' +\n 'people from their records.'\n\n/** What the route says at the daily cap; `included` is the plan's figure. */\nexport function crmEmailCapReachedMessage(included: number): string {\n return (\n `Today's one-to-one email limit (${included.toLocaleString('en-US')}) is ` +\n 'reached. It resets at midnight UTC — see Billing for the count.'\n )\n}\n\n/**\n * What the route says when the NEW-WORKSPACE RAMP is the ceiling that was\n * reached, rather than the plan's own.\n *\n * The campaign path's wording, pointed at this surface: the number, what was\n * spent against it, that nothing was sent or counted, and when it lifts. A\n * workspace told only \"limit reached\" against a figure far below the one its\n * plan page advertises would read it as a billing fault.\n */\nexport function crmEmailRampReachedMessage(\n ceiling: number,\n used: number,\n): string {\n return (\n `This workspace may send ${ceiling.toLocaleString('en-US')} one-to-one ` +\n `email${ceiling === 1 ? '' : 's'} a day while it establishes a sending ` +\n `history, and has sent ${used.toLocaleString('en-US')} today. Nothing ` +\n 'has been sent and nothing has been counted — the allowance grows as ' +\n \"the workspace sends, and is the plan's in full after its first week. \" +\n 'It resets at midnight UTC.'\n )\n}\n\n/** What the route says when the ramp's own history could not be read. */\nexport const CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE =\n \"This workspace's sending history could not be read just now, so how much \" +\n 'of its first-week email allowance it has earned is not known. Nothing has ' +\n 'been sent and nothing has been counted — try again in a moment.'\n\nexport const CRM_EMAIL_SUPPRESSED_MESSAGE =\n 'This address has bounced or reported a message as spam, so it cannot be ' +\n 'emailed.'\n\nexport const CRM_EMAIL_DECLINED_MESSAGE =\n 'This person asked not to be emailed by this site.'\n\nexport const CRM_EMAIL_RATE_MESSAGE =\n 'You have sent too many emails in the last minute. Wait a moment and try again.'\n\n/** What the org variant says when no site was named and the org has several. */\nexport const CRM_EMAIL_PICK_SITE_MESSAGE =\n 'Pick the site this email leaves from — its sending address is what the ' +\n 'message is sent as.'\n\ntype Refusal = { ok: false; status: number; body: Record<string, unknown> }\n\nconst refuse = (\n status: number,\n error: string,\n extra: Record<string, unknown> = {},\n): Refusal => ({ ok: false, status, body: { error, ...extra } })\n\n/** One typed field, trimmed and bounded. */\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\ninterface Sender {\n ok: true\n uid: string\n /** The address replies go to — the token's, never the body's. */\n email: string\n /** The name in front of the sending address, when the token carries one. */\n name: string\n orgId: string\n org: Partial<AglynOrgBilling>\n}\n\n/**\n * Who is sending, and whether they may: a verified ID token, then\n * `data.manage` on the site's org through the same three-layer read the\n * create route makes, admitted only for a site the member reaches. Staff\n * pass the permission check as they do on every CRM route, and send as\n * themselves.\n */\nasync function authorizeSender(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Sender | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; name?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] email-send could not verify the sender', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const org = (resolved.org ?? {}) as Partial<AglynOrgBilling>\n if (decoded.staff !== true) {\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Sending email from a record requires the data.manage permission on this site',\n )\n }\n }\n const email = normalizeContactEmail(decoded.email)\n if (!email) {\n // A reply-to is owed and the token is the only place it can honestly\n // come from; an account with no address cannot be replied to.\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: decoded.uid,\n email,\n name: typed(decoded.name, 120),\n orgId,\n org,\n }\n}\n\n/**\n * The org variant's sender (AGL-2634): authorized by the org for an\n * org-wide member holding `data.manage`, with the same reply-to rule.\n */\nasync function authorizeOrgSender(\n req: PluginApiRequest,\n orgId: string,\n): Promise<Sender | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: 'data.manage',\n refusal:\n 'Sending email at the organization level requires the data.manage ' +\n 'permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n const email = normalizeContactEmail(caller.email)\n if (!email) {\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: caller.uid,\n email,\n name: caller.name,\n orgId,\n org: caller.org as Partial<AglynOrgBilling>,\n }\n}\n\ninterface Recipient {\n ok: true\n email: string\n /** The document the consent basis is read off — the contact, or the lead. */\n record: Record<string, unknown>\n /** The contact document, when one was read — where the company link is. */\n contact: Record<string, unknown> | null\n link: CrmActivityLink\n /**\n * The site the record itself names — a contact's first capturing site, a\n * deal's own, a lead's — which is the site an org-level send leaves from\n * when the body named none.\n */\n siteHint: string\n /** The deal document, when one was named — what `{{deal.*}}` reads (AGL-2658). */\n deal: Record<string, unknown> | null\n /** The lead document, when the recipient is a lead — what `{{lead.*}}` reads. */\n lead: Record<string, unknown> | null\n}\n\n/**\n * The person the named record is about, as THIS site may see them.\n *\n * A deal names its contact; a lead carries its own address; a contact is\n * itself. Each is looked up by id and then checked against what the site\n * may read — the Admin SDK evaluates no rules, and a member of site A must\n * not be able to mail site B's contact by guessing an id. The links the\n * activity is filed under are whatever resolved: a deal's email is also the\n * contact's, so it lands on both timelines.\n *\n * At the organization level there is no site to check against and none is\n * checked: the caller was admitted as an org-wide member, who reads every\n * row. A lead still needs its site named — a lead lives under one.\n */\nasync function resolveRecipient(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n ids: { contactId: string; leadId: string; dealId: string },\n): Promise<Recipient | Refusal> {\n const link: CrmActivityLink = {}\n const { hostId } = scope\n const scoped = scope.level === 'site'\n const visible = (tokens: unknown) =>\n !scoped || visibleToHost(tokens as readonly string[], hostId)\n let contactId = ids.contactId\n let siteHint = ''\n let dealRecord: Record<string, unknown> | null = null\n\n if (ids.dealId) {\n const deal = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.deals)\n .doc(ids.dealId)\n .get()\n if (!deal.exists || !visible(deal.get('visibleTo'))) {\n return refuse(404, 'Unknown deal')\n }\n link.dealId = deal.id\n dealRecord = (deal.data() ?? {}) as Record<string, unknown>\n siteHint = typed(deal.get('hostId'), 128)\n contactId = contactId || String(deal.get('contactId') ?? '').trim()\n if (!contactId) {\n return refuse(400, 'This deal names no contact to email.')\n }\n }\n\n if (contactId) {\n const contacts = scoped\n ? await orgDataCollectionForHost(hostId, 'contacts')\n : firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await contacts.doc(contactId).get()\n if (!contact.exists || !visible(contact.get('visibleTo'))) {\n return refuse(404, 'Unknown contact')\n }\n const email = normalizeContactEmail(contact.get('email'))\n if (!email) return refuse(400, 'This contact has no email address.')\n const record = (contact.data() ?? {}) as Record<string, unknown>\n link.contactId = contact.id\n if (ids.leadId) link.leadId = ids.leadId\n siteHint =\n siteHint || typed(record['hostId'], 128) || contactCaptureHostIds(record)[0] || ''\n return { ok: true, email, record, contact: record, link, siteHint, deal: dealRecord, lead: null }\n }\n\n if (ids.leadId) {\n if (!hostId) return refuse(400, 'Name the site the lead lives under.')\n const lead = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('leads')\n .doc(ids.leadId)\n .get()\n if (!lead.exists) return refuse(404, 'Unknown lead')\n const email = normalizeContactEmail(lead.get('email'))\n if (!email) return refuse(400, 'This lead has no email address.')\n const record = (lead.data() ?? {}) as Record<string, unknown>\n link.leadId = lead.id\n // A converted lead's email belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return { ok: true, email, record, contact: null, link, siteHint: hostId, deal: null, lead: record }\n }\n\n return refuse(400, 'Name a contact, lead or deal to email.')\n}\n\n/**\n * The site an org-level send leaves from (AGL-2634): the body's, else the\n * record's own, else the org's only site — and a refusal the dialog turns\n * into a Site picker when the org has several and none was named.\n */\nasync function resolveSendingSite(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n siteHint: string,\n): Promise<{ ok: true; hostId: string } | Refusal> {\n if (scope.hostId) return { ok: true, hostId: scope.hostId }\n if (siteHint) return { ok: true, hostId: siteHint }\n const hosts = await orgHostIds(firestore, orgId)\n if (hosts.length === 1) return { ok: true, hostId: hosts[0] }\n return refuse(409, CRM_EMAIL_PICK_SITE_MESSAGE, { reason: 'site' })\n}\n\n/** The HTTP shape of a send the provider did not accept. */\nfunction sendRefusal(result: SendEmailResult): Refusal {\n const reason = sendFailureReason(result)\n const detail = String((result as { detail?: unknown }).detail ?? '')\n if (reason === 'unverified-domain') {\n return refuse(409, detail || 'This site has no verified sending identity.', {\n reason: 'sending-identity',\n })\n }\n if (reason === 'suppressed' || reason === 'unengaged') {\n return refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' })\n }\n if (reason === 'rate-limited') {\n const retryAtMs = Number((result as { retryAtMs?: unknown }).retryAtMs)\n return refuse(\n 503,\n 'The mail provider asked us to slow down. Try again in a minute.',\n {\n reason: 'provider-rate',\n retryAfterSeconds: Number.isFinite(retryAtMs)\n ? Math.max(1, Math.ceil((retryAtMs - Date.now()) / 1000))\n : 60,\n },\n )\n }\n if (reason === 'unconfigured') {\n return refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n })\n }\n return refuse(502, 'The email could not be sent.', { reason: 'send-failed' })\n}\n\n/** What the route says when a subject or a message is nothing once its fields are filled. */\nexport const CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE =\n 'The subject and the message are empty once their merge fields are filled ' +\n 'in. Check the fields against this record.'\n\n/**\n * What `{{…}}` reads from, for this send (AGL-2658): the documents already\n * in hand — the contact, the deal, the lead — the sender off their token,\n * and the site's name, read only when a field asks for it. The contact's\n * facet is the SENDING site's group's, because a name, a job title and a\n * company are one holder's knowledge of a person.\n */\nasync function mergeContextFor(options: {\n recipient: Recipient\n sender: Sender\n hostId: string\n groupId: string\n fields: readonly string[]\n}): Promise<CrmMergeContext> {\n const { recipient, sender, hostId, groupId, fields } = options\n const wantsSite = fields.some((field) => field.startsWith('site.'))\n const host = wantsSite ? await getHostDocAdmin(hostId).catch(() => null) : null\n return {\n contact: recipient.contact,\n contactGroupId: groupId,\n lead: recipient.lead,\n deal: recipient.deal,\n sender: { name: sender.name, email: sender.email },\n site: { name: typed(host?.['displayName'], 200) },\n }\n}\n\nfunction answer(res: PluginApiResponse, refusal: Refusal): void {\n const retry = refusal.body['retryAfterSeconds']\n if (typeof retry === 'number') res.setHeader('Retry-After', String(retry))\n res.status(refusal.status).json(refusal.body)\n}\n\nexport const crmEmailSendHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const routeScope = readCrmRouteScope(body)\n if (!routeScope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const ids = {\n contactId: typed(body['contactId'], 128),\n leadId: typed(body['leadId'], 128),\n dealId: typed(body['dealId'], 128),\n }\n if (!ids.contactId && !ids.leadId && !ids.dealId) {\n res.status(400).json({ error: 'Name a contact, lead or deal to email.' })\n return\n }\n const draftSubject = typed(body['subject'], CRM_EMAIL_SUBJECT_MAX)\n if (!draftSubject) {\n res.status(400).json({ error: 'Enter a subject.' })\n return\n }\n // Paragraphs are what the dialog offers, and a plain-text part is what a\n // paragraph is; line endings are normalized so the stored body and the\n // sent body are one string.\n const draftText = String(body['body'] ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .trim()\n .slice(0, CRM_EMAIL_BODY_MAX)\n if (!draftText) {\n res.status(400).json({ error: 'Write the message.' })\n return\n }\n\n try {\n const sender =\n routeScope.level === 'org'\n ? await authorizeOrgSender(req, routeScope.orgId)\n : await authorizeSender(req, routeScope.hostId)\n if (sender.ok === false) return answer(res, sender)\n const { orgId, org } = sender\n /*\n * THE PLAN, before the pace and the cap (AGL-2787). One-to-one email is\n * the suite's; a plan without the suite is refused as such, rather than\n * reaching the daily cap and being told about a zero it has no way to\n * raise short of the upgrade this names.\n */\n const suite = crmSuiteRefusal(org, 'Emailing one person from their record')\n if (suite) return answer(res, { ok: false, status: suite.status, body: suite.body })\n\n /*\n * THE PER-USER PACE, on its own key. The console dispatcher already\n * counts this write against the person's general console budget; this\n * is the narrower bucket the feature owes — twenty a minute is a rep\n * writing, and past it is a script — keyed by the uid the token proved\n * so one person cannot spend the bucket of everyone on their address.\n */\n const rate = await consumeRateLimit(`crm-email-send:uid:${sender.uid}`, {\n limit: CRM_EMAIL_SENDS_PER_MINUTE,\n windowMs: CRM_EMAIL_RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n return answer(\n res,\n refuse(429, CRM_EMAIL_RATE_MESSAGE, {\n reason: 'rate',\n retryAfterSeconds: Math.max(\n 1,\n Math.ceil((rate.resetMs - Date.now()) / 1000),\n ),\n }),\n )\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const recipient = await resolveRecipient(firestore, routeScope, orgId, ids)\n if (recipient.ok === false) return answer(res, recipient)\n // The site the message leaves from — the mounted one under a site, and\n // at the organization level whatever the body, the record or the org\n // can answer.\n const site = await resolveSendingSite(firestore, routeScope, orgId, recipient.siteHint)\n if (site.ok === false) return answer(res, site)\n const { hostId } = site\n const group = await consentGroupForSite(hostId, org as Record<string, unknown>)\n // The company the sending site files this person under, so the email\n // shows on the company's log as the automation step's rows do.\n if (recipient.contact) {\n const facet = readContactFacet(recipient.contact, group.groupId)\n if (facet.companyId) recipient.link.companyId = facet.companyId\n }\n\n /*\n * MERGE FIELDS (AGL-2658), filled here rather than in the dialog: the\n * dialog previews with the same resolver, but what leaves and what is\n * logged is rendered by the route, off the documents it read to find\n * the recipient, so a preview the rep did not look at cannot differ\n * from the letter. A subject or a message that is nothing once its\n * fields are filled is refused before any gate is spent on it.\n */\n const fields = crmMergeFieldsIn(`${draftSubject}\\n${draftText}`)\n const merge = fields.length\n ? await mergeContextFor({ recipient, sender, hostId, groupId: group.groupId, fields })\n : null\n const subject = merge\n ? renderCrmMergeFields(draftSubject, merge).trim().slice(0, CRM_EMAIL_SUBJECT_MAX)\n : draftSubject\n const text = merge\n ? renderCrmMergeFields(draftText, merge).trim().slice(0, CRM_EMAIL_BODY_MAX)\n : draftText\n if (!subject || !text) {\n return answer(res, refuse(400, CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE, { reason: 'merge' }))\n }\n\n // The per-record ceiling, before the send: an email that could not be\n // logged would be a message the timeline never shows, and the timeline\n // is the reason the send lives on the record.\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, recipient.link))) {\n return answer(res, refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE, { reason: 'ceiling' }))\n }\n\n // Both suppression lists, then the person's own refusal.\n const sendable = await filterSendableForHost(hostId, [recipient.email])\n if (!sendable.length) {\n return answer(res, refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' }))\n }\n if (readMarketingBasis(recipient.record, group).basis === 'declined') {\n return answer(res, refuse(409, CRM_EMAIL_DECLINED_MESSAGE, { reason: 'declined' }))\n }\n // The record's own verdict (AGL-3245): what the lists hold, in the words\n // the page shows — a bounce, a gateway block, a do-not-contact mark the\n // suppression lists do not carry.\n const stateRefusal = emailStateRefusal(readEmailState(recipient.record))\n if (stateRefusal) {\n return answer(res, refuse(409, stateRefusal, { reason: 'email-state' }))\n }\n\n if (!isEmailConfigured()) {\n return answer(\n res,\n refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n }),\n )\n }\n const identity = await hostSendingIdentity(hostId)\n if (identity.refusal) {\n return answer(\n res,\n refuse(409, identity.refusal.message, { reason: 'sending-identity' }),\n )\n }\n\n const now = new Date()\n /*\n * THE NEW-WORKSPACE RAMP, resolved for the ceiling below.\n *\n * A workspace past its first week graduates off the org record alone and\n * pays no read for this. One that is still on the ramp needs its\n * delivery history to know which step it has earned, and when that\n * history is unreadable the step is a floor rather than an answer.\n *\n * A floor is enough for a campaign, which defers what it cannot send\n * today and delivers it tomorrow. It is not enough here: this route\n * admits or refuses ONE message and has no third answer, so a ramp that\n * cannot say what a workspace has earned refuses and says so. The\n * refusal is transient by construction — it lasts as long as the window\n * read is failing — and it reaches only workspaces inside their first\n * week, since every other one graduated before the window was consulted.\n */\n const { ramp, degraded } = await readOrgEmailRamp({\n orgId,\n createdAt: (org as Record<string, unknown>)['createdAt'],\n now: now.getTime(),\n })\n if (degraded) {\n return answer(\n res,\n refuse(503, CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE, {\n reason: 'ramp-unavailable',\n }),\n )\n }\n\n // THE DAILY CAP (AGL-2611), taken as a slot rather than read as a\n // figure (AGL-2645): the transaction reads today's counter, judges it\n // with `checkCrmEmailQuota` against the ramp's share of the plan's day\n // and raises it in one step, so the count the cap enforces is the count\n // this send was admitted on.\n const claim = await reserveCrmEmailSend(firestore, orgId, org, now, ramp)\n if (!claim.ok) {\n const { quota } = claim\n // WHICH ceiling was reached, in the org's own terms. The reservation\n // answers with the number it judged, which is the plan's day unless\n // the ramp held this workspace below it — and a workspace shown a\n // figure under the one its plan page advertises is owed the reason.\n const rampBound =\n quota.included < resolveOrgEntitlements(org).crmEmailsPerDay\n return answer(\n res,\n refuse(\n 409,\n quota.included <= 0\n ? CRM_EMAIL_NOT_INCLUDED_MESSAGE\n : rampBound\n ? crmEmailRampReachedMessage(quota.included, quota.used)\n : crmEmailCapReachedMessage(quota.included),\n {\n reason: rampBound ? 'ramp' : 'quota',\n included: quota.included,\n used: quota.used,\n resetsAtMs: quota.resetsAt.getTime(),\n },\n ),\n )\n }\n\n // The row's id, minted before the send so the delivery webhook can find\n // it from the tags alone; written only once the message has left.\n const activityRef = newCrmActivityRef(firestore, orgId)\n let result: SendEmailResult\n try {\n result = await sendEmail({\n to: recipient.email,\n subject,\n text,\n sendingIdentity: identity,\n audience: 'tenant',\n context: CRM_EMAIL_CONTEXT,\n replyTo: sender.email,\n ...(sender.name ? { fromName: sender.name } : {}),\n tags: crmEmailDeliveryTags({ orgId, hostId, activityId: activityRef.id }),\n })\n } catch (error) {\n // The provider never answered, so no message left: the slot goes\n // back before the failure is reported.\n await releaseCrmEmailSend(firestore, claim.reservation)\n throw error\n }\n if (!result.sent) {\n await releaseCrmEmailSend(firestore, claim.reservation)\n return answer(res, sendRefusal(result))\n }\n\n const sentAtMs = Date.now()\n let logged = true\n /*\n * Who may list the row. Under a site, what a record created there is\n * stamped with. At the organization level the RECORD's own tokens, so\n * an email to a person another site captured shows on their timeline\n * under that site's hub — a row stamped with the sending site alone\n * would be invisible where the person is read.\n */\n const recordTokens = recipient.record['visibleTo']\n const visibleTo =\n routeScope.level === 'org' && Array.isArray(recordTokens) && recordTokens.length\n ? recordTokens.map(String)\n : crmScopeTokens(org as Record<string, unknown>, group)\n try {\n await writeCrmEmailActivity(\n activityRef,\n buildCrmEmailActivity({\n subject,\n body: text,\n to: recipient.email,\n atMs: sentAtMs,\n byUid: sender.uid,\n ...(sender.name ? { byName: sender.name } : {}),\n link: recipient.link,\n hostId,\n visibleTo,\n }),\n )\n } catch (error) {\n // The message has left. A row that could not be written is a gap on\n // the timeline, not a failed send, and it is said so in the answer.\n console.error('[crm] email activity write failed', orgId, activityRef.id, error)\n logged = false\n }\n // The cap's count was taken with the reservation; the org's cost meter\n // for COGS is stamped after delivery, unable to fail the send.\n await recordEmailSends({\n scope: { kind: 'org', orgId },\n count: 1,\n sendClass: 'transactional',\n firestore,\n })\n // The org-level act, in the org's feed: the site variant's is the\n // timeline row itself, under the site the record page is on.\n if (routeScope.level === 'org') {\n await logOrgActivity(\n orgId,\n { uid: sender.uid, email: sender.email },\n 'Sent email',\n recipient.link.contactId\n ? { type: 'contact', id: recipient.link.contactId, name: recipient.email }\n : { type: 'lead', id: recipient.link.leadId, name: recipient.email },\n )\n }\n res.status(200).json({\n ok: true,\n activityId: activityRef.id,\n to: recipient.email,\n from: identity.from,\n logged,\n })\n } catch (error) {\n console.error('[crm] email-send failed', routeScope, error)\n res.status(500).json({ error: 'The email could not be sent.' })\n }\n}\n\nexport default crmEmailSendHandler\n"],"names":["buildCrmEmailActivity","contactCaptureHostIds","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","CRM_EMAIL_BODY_MAX","CRM_EMAIL_CONTEXT","CRM_EMAIL_SUBJECT_MAX","crmActivityLogHasRoom","crmEmailDeliveryTags","emailStateRefusal","crmMergeFieldsIn","crmScopeTokens","isOrgWideMember","normalizeContactEmail","readContactFacet","readEmailState","readMarketingBasis","renderCrmMergeFields","resolveOrgEntitlements","visibleToHost","isEmailConfigured","sendEmail","sendFailureReason","consentGroupForSite","consumeRateLimit","countCrmActivitiesForRecord","filterSendableForHost","firebaseAdmin","getHostDocAdmin","getOrgForHost","hostSendingIdentity","logOrgActivity","memberHasOrgPermission","newCrmActivityRef","orgDataCollectionForHost","readOrgEmailRamp","recordEmailSends","releaseCrmEmailSend","reserveCrmEmailSend","resolveOrgMembership","writeCrmEmailActivity","isRefusedIdToken","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_EMAIL_SENDS_PER_MINUTE","CRM_EMAIL_RATE_WINDOW_MS","CRM_EMAIL_NOT_INCLUDED_MESSAGE","crmEmailCapReachedMessage","included","toLocaleString","crmEmailRampReachedMessage","ceiling","used","CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE","CRM_EMAIL_SUPPRESSED_MESSAGE","CRM_EMAIL_DECLINED_MESSAGE","CRM_EMAIL_RATE_MESSAGE","CRM_EMAIL_PICK_SITE_MESSAGE","refuse","status","error","extra","ok","body","typed","value","max","String","trim","slice","authorizeSender","req","hostId","resolved","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","console","catch","orgId","org","staff","member","membership","uid","reaches","Boolean","hostAccess","allowed","email","name","authorizeOrgSender","caller","needs","refusal","resolveRecipient","firestore","scope","ids","link","scoped","level","visible","tokens","contactId","siteHint","dealRecord","dealId","deal","collection","doc","deals","get","exists","id","data","contact","contacts","record","leadId","lead","converted","resolveSendingSite","hosts","reason","sendRefusal","result","detail","retryAtMs","Number","retryAfterSeconds","isFinite","Math","ceil","Date","now","CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE","mergeContextFor","options","recipient","sender","groupId","fields","wantsSite","some","field","host","contactGroupId","site","answer","res","retry","setHeader","json","crmEmailSendHandler","method","routeScope","draftSubject","draftText","replace","suite","rate","limit","windowMs","resetMs","group","facet","companyId","merge","subject","text","orgRef","sendable","basis","stateRefusal","identity","message","ramp","degraded","createdAt","getTime","claim","quota","rampBound","crmEmailsPerDay","resetsAtMs","resetsAt","activityRef","to","sendingIdentity","audience","context","replyTo","fromName","tags","activityId","reservation","sent","sentAtMs","logged","recordTokens","visibleTo","Array","isArray","map","atMs","byUid","byName","kind","count","sendClass","type","from"],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginLeadConversionReport, PluginLeadConversionRequest } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion';
|
|
18
|
+
/**
|
|
19
|
+
* WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).
|
|
20
|
+
*
|
|
21
|
+
* A lead is filed under a site's campaigns at the top of its own document;
|
|
22
|
+
* a contact holds the same membership inside the site's consent-group
|
|
23
|
+
* facet, because a contact is shared across the org and a campaign is one
|
|
24
|
+
* site's. When the lead becomes a contact, the campaigns go with it — added
|
|
25
|
+
* to what the contact already carries there with `arrayUnion`, never in
|
|
26
|
+
* place of it — so the campaign's page goes on listing the person after
|
|
27
|
+
* they became a contact, and a lead a sequence enrolled under a campaign
|
|
28
|
+
* does not leave the campaign by converting.
|
|
29
|
+
*
|
|
30
|
+
* The CRM's own module, because a lead and a contact are the CRM's records
|
|
31
|
+
* (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's
|
|
32
|
+
* lead-conversion seam, which every door that converts a lead — the
|
|
33
|
+
* dialog, the REST API, a sign-up, a purchase — reaches through
|
|
34
|
+
* `handOffLeadRecords`. Never throws: the conversion has happened, and a
|
|
35
|
+
* membership that could not be carried is one the contact's page can pick
|
|
36
|
+
* by hand.
|
|
37
|
+
*/
|
|
38
|
+
export declare function carryLeadCampaignsToContact(firestore: FirebaseFirestore.Firestore, request: Pick<PluginLeadConversionRequest, 'orgId' | 'hostId' | 'leadId' | 'contactId'>): Promise<PluginLeadConversionReport>;
|
|
39
|
+
/**
|
|
40
|
+
* The carry as the lead-conversion seam calls it: on the Admin SDK's own
|
|
41
|
+
* Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for
|
|
42
|
+
* two reasons that point the same way. That file must stay light — it runs
|
|
43
|
+
* at the boot of every server process — so it defers this module and must
|
|
44
|
+
* not import the data layer itself. And a library the plugin imports
|
|
45
|
+
* statically in thirty files cannot also be lazy-loaded in one:
|
|
46
|
+
* `enforce-module-boundaries` refuses every static import of a library the
|
|
47
|
+
* project lazy-loads anywhere. This module is the deferred one; the SDK
|
|
48
|
+
* comes with it, the way `capture-contact` brings its own.
|
|
49
|
+
*/
|
|
50
|
+
export declare function carryLeadCampaignsOnConversion(request: Pick<PluginLeadConversionRequest, 'orgId' | 'hostId' | 'leadId' | 'contactId'>): Promise<PluginLeadConversionReport>;
|