@aglyn/plugins-crm 1.0.0-beta.203 → 1.0.0-beta.205
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/contact-detail-page.js +7 -0
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/crm-email-check.d.ts +70 -0
- package/src/lib/components/crm-email-check.js +135 -0
- package/src/lib/components/crm-email-check.js.map +1 -0
- package/src/lib/components/crm-send-email-dialog.js +12 -0
- package/src/lib/components/crm-send-email-dialog.js.map +1 -1
- package/src/lib/components/csv-import-drawer.js +5 -1
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +7 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/use-crm-api.d.ts +1 -1
- package/src/lib/components/use-crm-api.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/lead-filters.d.ts +1 -1
- package/src/lib/server/capture-contact.js +8 -2
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/contacts-import.js +18 -2
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/email-check.d.ts +51 -0
- package/src/lib/server/email-check.js +193 -0
- package/src/lib/server/email-check.js.map +1 -0
- package/src/lib/server/email-send.js +8 -0
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/import-context.d.ts +2 -0
- package/src/lib/server/import-context.js +1 -0
- package/src/lib/server/import-context.js.map +1 -1
- package/src/lib/server/leads-import.js +16 -2
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.js +21 -4
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/task-routes.d.ts +2 -0
- package/src/lib/server/task-routes.js +9 -0
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/server-deal-stage.js +18 -2
- package/src/lib/server-deal-stage.js.map +1 -1
- package/src/lib/server.js +23 -2
- package/src/lib/server.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n crmNewRecordListFields,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/** A company an import row named, as the CRM stores it. */\ninterface ImportCompany {\n id: string\n name: string\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompany(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, ImportCompany>,\n tally: { created: number },\n): Promise<ImportCompany | null> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n // The name as the record spells it, not as this row typed it, so the\n // contact's facet reads the same as the company page.\n const found = { id: seen.id, name: String(seen.get('name') ?? name) }\n cache.set(fields.nameLower, found)\n return found\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return null\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...fields, visibleTo: context.scopeTokens }),\n })\n tally.created += 1\n const made = { id: created.id, name }\n cache.set(fields.nameLower, made)\n return made\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, ImportCompany>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const company = row.companyName\n ? await resolveCompany(context, row.companyName, companies, companyTally)\n : null\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n // The link AND the name: the merge fields read the facet's\n // `companyName`, and a link written alone renders as nothing.\n ...(company ? { companyId: company.id, companyName: company.name } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n if (verdict.created) created += 1\n else merged += 1\n }\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","crmNewRecordListFields","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompany","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","found","id","String","set","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","made","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","company","companyName","source","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","companyId","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","result","received","sort","a","b","companiesCreated","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n crmNewRecordListFields,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\n// The leaf, so a spec that stands a partial barrel in still reaches it.\nimport {\n countUndeliverableEmails,\n findUndeliverableEmails,\n IMPORT_MAIL_CHECK_GRACE_MS,\n settleWithin,\n} from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/** A company an import row named, as the CRM stores it. */\ninterface ImportCompany {\n id: string\n name: string\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompany(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, ImportCompany>,\n tally: { created: number },\n): Promise<ImportCompany | null> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n // The name as the record spells it, not as this row typed it, so the\n // contact's facet reads the same as the company page.\n const found = { id: seen.id, name: String(seen.get('name') ?? name) }\n cache.set(fields.nameLower, found)\n return found\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return null\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...fields, visibleTo: context.scopeTokens }),\n })\n tally.created += 1\n const made = { id: created.id, name }\n cache.set(fields.nameLower, made)\n return made\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n // The addresses' mail servers (AGL-3328), asked while the rows are\n // written — see the leads import. The check `upsertHostContact` queues\n // stamps the records; this is the count the result states.\n const mailServers = findUndeliverableEmails(normalized.map((entry) => entry.row.email))\n const imported: string[] = []\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, ImportCompany>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const company = row.companyName\n ? await resolveCompany(context, row.companyName, companies, companyTally)\n : null\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n actor: { kind: 'member', uid: context.uid, email: context.email },\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n // The link AND the name: the merge fields read the facet's\n // `companyName`, and a link written alone renders as nothing.\n ...(company ? { companyId: company.id, companyName: company.name } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n imported.push(row.email)\n if (verdict.created) created += 1\n else merged += 1\n }\n const noMailServer = countUndeliverableEmails(\n imported,\n await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS),\n )\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n ...(noMailServer !== undefined ? { noMailServer } : {}),\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","crmNewRecordListFields","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","countUndeliverableEmails","findUndeliverableEmails","IMPORT_MAIL_CHECK_GRACE_MS","settleWithin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompany","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","found","id","String","set","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","made","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","mailServers","imported","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","company","companyName","source","actor","kind","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","companyId","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","noMailServer","result","received","sort","a","b","companiesCreated","undefined","console"],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* `POST /api/crm/email-check` — what the platform knows about one address
|
|
19
|
+
* before somebody writes to it (AGL-3328).
|
|
20
|
+
*
|
|
21
|
+
* Body: `{ hostId, email }`, or `{ orgId, hostId?, email }` at the
|
|
22
|
+
* organization level. Answers `{ ok: true, checked, code, message, gateway,
|
|
23
|
+
* chip }` — see `readAddressDeliverability`.
|
|
24
|
+
*
|
|
25
|
+
* Two surfaces ask it. A record page draws the gateway chip beside the
|
|
26
|
+
* address: which mail gateway stands in front of it, and what that gateway
|
|
27
|
+
* did with this site's mail this week. The one-to-one composer warns before
|
|
28
|
+
* Send: a domain with no mail server, or a gateway that refused this site's
|
|
29
|
+
* sending domain twice this month and delivered nothing.
|
|
30
|
+
*
|
|
31
|
+
* ## Why a route
|
|
32
|
+
*
|
|
33
|
+
* The answer is DNS and the platform ledger. The browser does neither: the
|
|
34
|
+
* MX cache and the ledger are server-written and closed to members, and a
|
|
35
|
+
* cold domain is looked up here, once, into the cache every organization
|
|
36
|
+
* reads. The ledger is read for the sending domain of the site the mail
|
|
37
|
+
* would leave from — the mounted site, or at the organization level a site
|
|
38
|
+
* of that organization the body names — so a member reads their own
|
|
39
|
+
* sender's standing and never another workspace's.
|
|
40
|
+
*
|
|
41
|
+
* ## Who may ask
|
|
42
|
+
*
|
|
43
|
+
* Whoever may read the site's people: `data.manage`, the key the contacts
|
|
44
|
+
* read rule gates on, reaching the site; at the organization level an
|
|
45
|
+
* org-wide member holding it. Staff pass, as on every CRM route.
|
|
46
|
+
*/
|
|
47
|
+
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
48
|
+
/** The route key, as `registerCrmConsoleApi` registers it. */
|
|
49
|
+
export declare const CRM_EMAIL_CHECK_ROUTE = "crm/email-check";
|
|
50
|
+
export declare const crmEmailCheckHandler: PluginApiHandler;
|
|
51
|
+
export default crmEmailCheckHandler;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* `POST /api/crm/email-check` — what the platform knows about one address
|
|
19
|
+
* before somebody writes to it (AGL-3328).
|
|
20
|
+
*
|
|
21
|
+
* Body: `{ hostId, email }`, or `{ orgId, hostId?, email }` at the
|
|
22
|
+
* organization level. Answers `{ ok: true, checked, code, message, gateway,
|
|
23
|
+
* chip }` — see `readAddressDeliverability`.
|
|
24
|
+
*
|
|
25
|
+
* Two surfaces ask it. A record page draws the gateway chip beside the
|
|
26
|
+
* address: which mail gateway stands in front of it, and what that gateway
|
|
27
|
+
* did with this site's mail this week. The one-to-one composer warns before
|
|
28
|
+
* Send: a domain with no mail server, or a gateway that refused this site's
|
|
29
|
+
* sending domain twice this month and delivered nothing.
|
|
30
|
+
*
|
|
31
|
+
* ## Why a route
|
|
32
|
+
*
|
|
33
|
+
* The answer is DNS and the platform ledger. The browser does neither: the
|
|
34
|
+
* MX cache and the ledger are server-written and closed to members, and a
|
|
35
|
+
* cold domain is looked up here, once, into the cache every organization
|
|
36
|
+
* reads. The ledger is read for the sending domain of the site the mail
|
|
37
|
+
* would leave from — the mounted site, or at the organization level a site
|
|
38
|
+
* of that organization the body names — so a member reads their own
|
|
39
|
+
* sender's standing and never another workspace's.
|
|
40
|
+
*
|
|
41
|
+
* ## Who may ask
|
|
42
|
+
*
|
|
43
|
+
* Whoever may read the site's people: `data.manage`, the key the contacts
|
|
44
|
+
* read rule gates on, reaching the site; at the organization level an
|
|
45
|
+
* org-wide member holding it. Staff pass, as on every CRM route.
|
|
46
|
+
*/ import { isOrgWideMember, normalizeContactEmail } from "@aglyn/aglyn/server";
|
|
47
|
+
import { firebaseAdmin, getOrgForHost, hostSendingIdentity, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
48
|
+
// The leaf, so a spec that stands a partial barrel in still reaches it.
|
|
49
|
+
import { readAddressDeliverability } from "@aglyn/tenant-data-admin/server/capture-email-check";
|
|
50
|
+
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
51
|
+
import { authorizeOrgCaller, readCrmRouteScope } from "./org-caller.js";
|
|
52
|
+
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
53
|
+
/** The route key, as `registerCrmConsoleApi` registers it. */ export const CRM_EMAIL_CHECK_ROUTE = 'crm/email-check';
|
|
54
|
+
const READ_REFUSAL = 'Checking an address requires the data.manage permission on this site';
|
|
55
|
+
/** A site-level reader: a verified session reaching the site with `data.manage`. */ async function authorizeSiteReader(req, hostId) {
|
|
56
|
+
var _req_headers_authorization, _resolved_org;
|
|
57
|
+
const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
|
|
58
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
59
|
+
if (!idToken) return {
|
|
60
|
+
ok: false,
|
|
61
|
+
status: 401,
|
|
62
|
+
error: 'Unauthenticated'
|
|
63
|
+
};
|
|
64
|
+
let decoded;
|
|
65
|
+
try {
|
|
66
|
+
decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (isRefusedIdToken(error)) return {
|
|
69
|
+
ok: false,
|
|
70
|
+
status: 401,
|
|
71
|
+
error: 'Unauthenticated'
|
|
72
|
+
};
|
|
73
|
+
console.error('[crm] email-check could not verify the reader', error);
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 500,
|
|
77
|
+
error: 'The sign-in could not be checked. Try again.'
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const resolved = await getOrgForHost(hostId).catch(()=>null);
|
|
81
|
+
if (!resolved) return {
|
|
82
|
+
ok: false,
|
|
83
|
+
status: 404,
|
|
84
|
+
error: 'Unknown site'
|
|
85
|
+
};
|
|
86
|
+
if (decoded.staff !== true) {
|
|
87
|
+
var _ref;
|
|
88
|
+
var _member_hostAccess;
|
|
89
|
+
const membership = await resolveOrgMembership(decoded.uid, resolved.orgId).catch(()=>null);
|
|
90
|
+
const member = (_ref = membership == null ? void 0 : membership.member) != null ? _ref : null;
|
|
91
|
+
const reaches = isOrgWideMember(member) || Boolean(member == null ? void 0 : (_member_hostAccess = member.hostAccess) == null ? void 0 : _member_hostAccess[hostId]);
|
|
92
|
+
const allowed = member && reaches && await memberHasOrgPermission(resolved.orgId, member, 'data.manage');
|
|
93
|
+
if (!allowed) return {
|
|
94
|
+
ok: false,
|
|
95
|
+
status: 403,
|
|
96
|
+
error: READ_REFUSAL
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
orgId: resolved.orgId,
|
|
102
|
+
org: (_resolved_org = resolved.org) != null ? _resolved_org : {}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The address mail from `hostId` leaves on, or `null` when the site has no
|
|
107
|
+
* identity in effect — then no ledger is read, and only the MX is.
|
|
108
|
+
*/ async function sendingAddressOf(hostId) {
|
|
109
|
+
var _identity_from;
|
|
110
|
+
if (!hostId) return null;
|
|
111
|
+
const identity = await hostSendingIdentity(hostId).catch(()=>null);
|
|
112
|
+
return identity && !identity.refusal ? (_identity_from = identity.from) != null ? _identity_from : null : null;
|
|
113
|
+
}
|
|
114
|
+
export const crmEmailCheckHandler = async (req, res)=>{
|
|
115
|
+
var _req_body;
|
|
116
|
+
if (req.method !== 'POST') {
|
|
117
|
+
res.setHeader('Allow', 'POST');
|
|
118
|
+
res.status(405).json({
|
|
119
|
+
error: 'Method not allowed'
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const body = (_req_body = req.body) != null ? _req_body : {};
|
|
124
|
+
const scope = readCrmRouteScope(body);
|
|
125
|
+
if (!scope) {
|
|
126
|
+
res.status(400).json({
|
|
127
|
+
error: 'Missing hostId'
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const email = normalizeContactEmail(body['email']);
|
|
132
|
+
if (!email) {
|
|
133
|
+
res.status(400).json({
|
|
134
|
+
error: 'Name the address to check.'
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
let sendingHostId = '';
|
|
140
|
+
let org;
|
|
141
|
+
if (scope.level === 'org') {
|
|
142
|
+
const caller = await authorizeOrgCaller(req, scope.orgId, {
|
|
143
|
+
needs: 'data.manage',
|
|
144
|
+
refusal: 'Checking an address at the organization level requires the data.manage permission across the whole workspace'
|
|
145
|
+
});
|
|
146
|
+
if (caller.ok === false) {
|
|
147
|
+
res.status(caller.status).json({
|
|
148
|
+
error: caller.error
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
org = caller.org;
|
|
153
|
+
// A site named beside the org is used only when it is the org's own:
|
|
154
|
+
// its sending domain's ledger is that workspace's standing.
|
|
155
|
+
if (scope.hostId) {
|
|
156
|
+
const owner = await getOrgForHost(scope.hostId).catch(()=>null);
|
|
157
|
+
if ((owner == null ? void 0 : owner.orgId) === scope.orgId) sendingHostId = scope.hostId;
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
const reader = await authorizeSiteReader(req, scope.hostId);
|
|
161
|
+
if (reader.ok === false) {
|
|
162
|
+
res.status(reader.status).json({
|
|
163
|
+
error: reader.error
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
sendingHostId = scope.hostId;
|
|
168
|
+
org = reader.org;
|
|
169
|
+
}
|
|
170
|
+
// The plan after the reader, as every CRM read asks it (AGL-2851): the
|
|
171
|
+
// record pages and the composer this serves are the suite's.
|
|
172
|
+
const suite = crmSuiteRefusal(org, 'Checking an address');
|
|
173
|
+
if (suite) {
|
|
174
|
+
res.status(suite.status).json(suite.body);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const answer = await readAddressDeliverability({
|
|
178
|
+
email,
|
|
179
|
+
from: await sendingAddressOf(sendingHostId)
|
|
180
|
+
});
|
|
181
|
+
res.status(200).json(_extends({
|
|
182
|
+
ok: true
|
|
183
|
+
}, answer));
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error('[crm] email-check failed', scope, error);
|
|
186
|
+
res.status(500).json({
|
|
187
|
+
error: 'The address could not be checked.'
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
export default crmEmailCheckHandler;
|
|
192
|
+
|
|
193
|
+
//# sourceMappingURL=email-check.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/email-check.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/email-check` — what the platform knows about one address\n * before somebody writes to it (AGL-3328).\n *\n * Body: `{ hostId, email }`, or `{ orgId, hostId?, email }` at the\n * organization level. Answers `{ ok: true, checked, code, message, gateway,\n * chip }` — see `readAddressDeliverability`.\n *\n * Two surfaces ask it. A record page draws the gateway chip beside the\n * address: which mail gateway stands in front of it, and what that gateway\n * did with this site's mail this week. The one-to-one composer warns before\n * Send: a domain with no mail server, or a gateway that refused this site's\n * sending domain twice this month and delivered nothing.\n *\n * ## Why a route\n *\n * The answer is DNS and the platform ledger. The browser does neither: the\n * MX cache and the ledger are server-written and closed to members, and a\n * cold domain is looked up here, once, into the cache every organization\n * reads. The ledger is read for the sending domain of the site the mail\n * would leave from — the mounted site, or at the organization level a site\n * of that organization the body names — so a member reads their own\n * sender's standing and never another workspace's.\n *\n * ## Who may ask\n *\n * Whoever may read the site's people: `data.manage`, the key the contacts\n * read rule gates on, reaching the site; at the organization level an\n * org-wide member holding it. Staff pass, as on every CRM route.\n */\n\nimport {\n type AglynOrgMember,\n isOrgWideMember,\n normalizeContactEmail,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, so a spec that stands a partial barrel in still reaches it.\nimport { readAddressDeliverability } from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { authorizeOrgCaller, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_EMAIL_CHECK_ROUTE = 'crm/email-check'\n\ntype Refusal = { ok: false; status: number; error: string }\n\nconst READ_REFUSAL = 'Checking an address requires the data.manage permission on this site'\n\n/** A site-level reader: a verified session reaching the site with `data.manage`. */\nasync function authorizeSiteReader(\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ ok: true; orgId: string; org: Record<string, unknown> } | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let decoded: { uid: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] email-check could not verify the reader', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return { ok: false, status: 404, error: 'Unknown site' }\n if (decoded.staff !== true) {\n const membership = await resolveOrgMembership(decoded.uid, resolved.orgId).catch(() => null)\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n const reaches = isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member && reaches && (await memberHasOrgPermission(resolved.orgId, member, 'data.manage'))\n if (!allowed) return { ok: false, status: 403, error: READ_REFUSAL }\n }\n return { ok: true, orgId: resolved.orgId, org: (resolved.org ?? {}) as Record<string, unknown> }\n}\n\n/**\n * The address mail from `hostId` leaves on, or `null` when the site has no\n * identity in effect — then no ledger is read, and only the MX is.\n */\nasync function sendingAddressOf(hostId: string): Promise<string | null> {\n if (!hostId) return null\n const identity = await hostSendingIdentity(hostId).catch(() => null)\n return identity && !identity.refusal ? (identity.from ?? null) : null\n}\n\nexport const crmEmailCheckHandler: 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 scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body['email'])\n if (!email) {\n res.status(400).json({ error: 'Name the address to check.' })\n return\n }\n try {\n let sendingHostId = ''\n let org: Record<string, unknown>\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: 'Checking an address at the organization level requires the data.manage permission across the whole workspace',\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n org = caller.org as Record<string, unknown>\n // A site named beside the org is used only when it is the org's own:\n // its sending domain's ledger is that workspace's standing.\n if (scope.hostId) {\n const owner = await getOrgForHost(scope.hostId).catch(() => null)\n if (owner?.orgId === scope.orgId) sendingHostId = scope.hostId\n }\n } else {\n const reader = await authorizeSiteReader(req, scope.hostId)\n if (reader.ok === false) {\n res.status(reader.status).json({ error: reader.error })\n return\n }\n sendingHostId = scope.hostId\n org = reader.org\n }\n // The plan after the reader, as every CRM read asks it (AGL-2851): the\n // record pages and the composer this serves are the suite's.\n const suite = crmSuiteRefusal(org, 'Checking an address')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const answer = await readAddressDeliverability({ email, from: await sendingAddressOf(sendingHostId) })\n res.status(200).json({ ok: true, ...answer })\n } catch (error) {\n console.error('[crm] email-check failed', scope, error)\n res.status(500).json({ error: 'The address could not be checked.' })\n }\n}\n\nexport default crmEmailCheckHandler\n"],"names":["isOrgWideMember","normalizeContactEmail","firebaseAdmin","getOrgForHost","hostSendingIdentity","memberHasOrgPermission","resolveOrgMembership","readAddressDeliverability","isRefusedIdToken","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","CRM_EMAIL_CHECK_ROUTE","READ_REFUSAL","authorizeSiteReader","req","hostId","resolved","authorization","String","headers","idToken","startsWith","slice","length","undefined","ok","status","error","decoded","app","auth","verifyIdToken","console","catch","staff","member","membership","uid","orgId","reaches","Boolean","hostAccess","allowed","org","sendingAddressOf","identity","refusal","from","crmEmailCheckHandler","res","method","setHeader","json","body","scope","email","sendingHostId","level","caller","needs","owner","reader","suite","answer"],"mappings":"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"}
|
|
@@ -319,6 +319,14 @@ const refuse = (status, error, extra = {})=>({
|
|
|
319
319
|
reason: 'suppressed'
|
|
320
320
|
});
|
|
321
321
|
}
|
|
322
|
+
// The deliverability preflight (AGL-3328): the recipient's domain takes
|
|
323
|
+
// no mail. A standing fact about the address, said in the engine's words
|
|
324
|
+
// — not a provider fault the rep should retry.
|
|
325
|
+
if (reason === 'undeliverable' || reason === 'gateway-held') {
|
|
326
|
+
return refuse(409, detail || 'This address cannot receive email.', {
|
|
327
|
+
reason: 'undeliverable'
|
|
328
|
+
});
|
|
329
|
+
}
|
|
322
330
|
if (reason === 'rate-limited') {
|
|
323
331
|
const retryAtMs = Number(result.retryAtMs);
|
|
324
332
|
return refuse(503, 'The mail provider asked us to slow down. Try again in a minute.', {
|