@aglyn/tenant-data-admin 1.0.0-beta.200 → 1.0.0-beta.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/package.json +8 -7
  2. package/src/lib/server/card-payment-velocity.d.ts +85 -0
  3. package/src/lib/server/card-payment-velocity.js +202 -0
  4. package/src/lib/server/card-payment-velocity.js.map +1 -0
  5. package/src/lib/server/configured-url-fetch.d.ts +42 -0
  6. package/src/lib/server/configured-url-fetch.js +118 -0
  7. package/src/lib/server/configured-url-fetch.js.map +1 -0
  8. package/src/lib/server/dns-probe.d.ts +7 -0
  9. package/src/lib/server/dns-probe.js +52 -0
  10. package/src/lib/server/dns-probe.js.map +1 -1
  11. package/src/lib/server/email-deliverability.d.ts +150 -0
  12. package/src/lib/server/email-deliverability.js +538 -0
  13. package/src/lib/server/email-deliverability.js.map +1 -0
  14. package/src/lib/server/email-sender-reputation.d.ts +2 -11
  15. package/src/lib/server/email-sender-reputation.js +8 -25
  16. package/src/lib/server/email-sender-reputation.js.map +1 -1
  17. package/src/lib/server/email-suppression.d.ts +10 -1
  18. package/src/lib/server/email-suppression.js +4 -3
  19. package/src/lib/server/email-suppression.js.map +1 -1
  20. package/src/lib/server/erase-person.js +31 -3
  21. package/src/lib/server/erase-person.js.map +1 -1
  22. package/src/lib/server/host-visitor-records.d.ts +16 -2
  23. package/src/lib/server/host-visitor-records.js +31 -6
  24. package/src/lib/server/host-visitor-records.js.map +1 -1
  25. package/src/lib/server/hosted-page-review.d.ts +112 -0
  26. package/src/lib/server/hosted-page-review.js +378 -0
  27. package/src/lib/server/hosted-page-review.js.map +1 -0
  28. package/src/lib/server/member-email-aliases.js +18 -7
  29. package/src/lib/server/member-email-aliases.js.map +1 -1
  30. package/src/lib/server/notifications.d.ts +8 -0
  31. package/src/lib/server/notifications.js +92 -17
  32. package/src/lib/server/notifications.js.map +1 -1
  33. package/src/lib/server/org-age.d.ts +27 -0
  34. package/src/lib/server/org-age.js +44 -0
  35. package/src/lib/server/org-age.js.map +1 -0
  36. package/src/lib/server/org-member-notice.js +20 -5
  37. package/src/lib/server/org-member-notice.js.map +1 -1
  38. package/src/lib/server/organizations.d.ts +10 -1
  39. package/src/lib/server/organizations.js +12 -4
  40. package/src/lib/server/organizations.js.map +1 -1
  41. package/src/lib/server/outbound-send-review.d.ts +224 -0
  42. package/src/lib/server/outbound-send-review.js +479 -0
  43. package/src/lib/server/outbound-send-review.js.map +1 -0
  44. package/src/lib/server/payment-fraud-signal.d.ts +156 -0
  45. package/src/lib/server/payment-fraud-signal.js +368 -0
  46. package/src/lib/server/payment-fraud-signal.js.map +1 -0
  47. package/src/lib/server/payment-risk-record.d.ts +59 -0
  48. package/src/lib/server/payment-risk-record.js +76 -0
  49. package/src/lib/server/payment-risk-record.js.map +1 -0
  50. package/src/lib/server/render-system-email.d.ts +176 -0
  51. package/src/lib/server/render-system-email.js +315 -0
  52. package/src/lib/server/render-system-email.js.map +1 -0
  53. package/src/lib/server/sending-domains.d.ts +6 -0
  54. package/src/lib/server/sending-domains.js +98 -6
  55. package/src/lib/server/sending-domains.js.map +1 -1
  56. package/src/lib/server/staff-alert-email.d.ts +2 -1
  57. package/src/lib/server/staff-alert-email.js +16 -5
  58. package/src/lib/server/staff-alert-email.js.map +1 -1
  59. package/src/lib/server/stripe-card-authentication.d.ts +35 -0
  60. package/src/lib/server/stripe-card-authentication.js +63 -0
  61. package/src/lib/server/stripe-card-authentication.js.map +1 -0
  62. package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
  63. package/src/lib/server/tenant-write-lockdown.js +33 -3
  64. package/src/lib/server/tenant-write-lockdown.js.map +1 -1
  65. package/src/tenancy.d.ts +5 -0
  66. package/src/tenancy.js +10 -0
  67. package/src/tenancy.js.map +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/render-system-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n EMAIL_NODE_ROOT_ID,\n SYSTEM_EMAIL_COLLECTION,\n buildDefaultEmailNodeMap,\n getSystemEmailTemplate,\n renderEmailHtml,\n substituteMergeTokens,\n type SystemEmailTemplateDefinition,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\nimport { platformMarketingHostId } from './platform-marketing-consent'\nimport {\n brandMergeTokens,\n collectReferencedComponentIds,\n composeReusableComponentNodes,\n decodeStoredNodes,\n resolveBrandingProfile,\n sanitizeAuthorHtml,\n type ReusableComponentTree,\n} from '@aglyn/aglyn/server'\nimport {\n hostComponentReader,\n loadReferencedComponents,\n type ComponentStoreLike,\n} from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport {\n PLATFORM_BRAND_MERGE_TOKENS,\n buildSystemEmailChrome,\n isPlatformBrandedSend,\n type SystemEmailBrandOptions,\n} from '@aglyn/aglyn/app-utils/system-email-chrome'\n\n// Defined beside the chrome they decide, and re-exported for the callers and\n// specs that reach them through this module.\nexport { isPlatformBrandedSend }\nexport type { SystemEmailBrandOptions }\n\n/**\n * Blanks any `{{token}}` the caller did not supply.\n *\n * `substituteMergeTokens` leaves unknown tokens untouched, which is right for\n * campaigns (an author wants to see the tag they mistyped) but wrong here —\n * a system email goes to a customer, and \"You've been invited to\n * {{org.name}}\" is worse than a gap. Applied only in this resolver so\n * campaign behaviour is unchanged.\n */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Origin a picked image in a system email is fetched from (AGL-1224).\n *\n * A system email is staff-designed and its media lives in the platform\n * library, which the CONSOLE's `/api/media/cdn` route serves — the tenant app\n * mounts the same route, but a recipient of an org invite has no site to\n * resolve against. Same expression `app/layout.tsx` uses for the console's\n * own canonical URL, so the two cannot drift per deploy.\n */\nconst CONSOLE_ORIGIN =\n process.env.NEXT_PUBLIC_CONSOLE_URL ?? 'https://app.aglyn.com'\n\n/**\n * Which copy a system email carries: a design staff published, or the\n * catalog's built-in copy, which is what every key without a usable design\n * sends (AGL-3322).\n */\nexport type SystemEmailSource = 'designed' | 'default'\n\nexport interface RenderedSystemEmail {\n subject: string\n html: string\n text: string\n source: SystemEmailSource\n}\n\n/**\n * A system email loaded once, ready to render for any number of recipients\n * without touching Firestore again (AGL-768): the published staff design, or\n * the catalog's built-in copy when there is none.\n */\nexport interface LoadedSystemEmail {\n definition: SystemEmailTemplateDefinition\n /** The design as stored, each email block still a placement; or the built-in copy's node map. */\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n source: SystemEmailSource\n /**\n * The published definitions of the email blocks `nodes` places, keyed by\n * id, read from the platform marketing site ONCE with the template\n * (AGL-3318), so a batch composes every recipient's copy without another\n * read. Absent when the design places none, when the deployment names no\n * marketing site, and when the read failed; each placement then renders as\n * nothing.\n */\n components?: Record<string, ReusableComponentTree>\n}\n\n/**\n * The email blocks a platform email places, read from the site that owns\n * them (AGL-3318), or `undefined` when there is nothing to read or the read\n * failed.\n *\n * WHICH SITE. A block is a reusable component, and a component belongs to a\n * site; a platform email belongs to none. The header and footer Aglyn's own\n * mail wears are the ones on its marketing site, which the deployment names\n * in `PLATFORM_MARKETING_HOST_ID`, the setting product-email consent already\n * reads. An install that names none reads nothing, and a block placed in its\n * system emails renders as nothing.\n *\n * NEVER A REASON NOT TO SEND. A site's own email fails its whole load when a\n * component read fails, and falls back to its built-in copy. Here the copy is\n * staff's design and the blocks are another site's chrome, so a failed read\n * costs the blocks and nothing else: it is logged, and the design renders\n * inside the coded header and footer instead (see\n * {@link renderLoadedSystemEmail}) rather than being traded for the built-in\n * copy. A password reset whose header could not be read still carries its\n * link.\n */\nasync function loadPlatformEmailBlocks(\n firestore: ComponentStoreLike,\n templateKey: string,\n nodes: Record<string, unknown>,\n): Promise<Record<string, ReusableComponentTree> | undefined> {\n // A design that places nothing costs nothing: no setting read, no read.\n if (!collectReferencedComponentIds(nodes as never).size) return undefined\n const hostId = platformMarketingHostId()\n if (!hostId) return undefined\n try {\n return await loadReferencedComponents(\n nodes,\n hostComponentReader(firestore, hostId),\n {\n onSkipped: (skipped) =>\n console.warn(\n JSON.stringify({\n tag: 'AGL-3318:platform-email-blocks-skipped',\n templateKey,\n hostId,\n skipped,\n }),\n ),\n },\n )\n } catch (error) {\n console.error(\n `system email template ${templateKey}: its email blocks failed to load from ${hostId}`,\n error,\n )\n return undefined\n }\n}\n\n/**\n * Whether one send draws the design's email blocks: a send under the\n * platform's own brand (see {@link isPlatformBrandedSend}) of a design whose\n * blocks the load could read. Every other send renders the stored nodes, each\n * block a placement that draws nothing, inside the coded chrome.\n */\nfunction drawsPlatformBlocks(\n loaded: LoadedSystemEmail,\n merged: Readonly<Record<string, string>>,\n options: SystemEmailBrandOptions,\n): loaded is LoadedSystemEmail & {\n components: Record<string, ReusableComponentTree>\n} {\n return (\n Boolean(loaded.components && Object.keys(loaded.components).length) &&\n isPlatformBrandedSend(merged, options)\n )\n}\n\n/**\n * A key's built-in copy as a loaded email (AGL-3322): the node map the editor\n * seeds a first design with, and the catalog subject. What a key sends until\n * staff publish a design, and whenever the published one cannot be read.\n */\nfunction catalogDefault(\n definition: SystemEmailTemplateDefinition,\n): LoadedSystemEmail {\n return {\n definition,\n nodes: buildDefaultEmailNodeMap(definition),\n subjectTemplate: definition.defaultSubject,\n preheaderTemplate: '',\n source: 'default',\n }\n}\n\n/**\n * Loads what a key sends — ONE Firestore read — ready to render per\n * recipient: the published staff design, or the catalog's built-in copy\n * (AGL-3322) when there is none to use (no document, no published version,\n * an empty node map, or a read failure).\n *\n * `null` only for a key that is unknown or not Resend-delivered, which has\n * nothing here to render. Split out from {@link renderSystemEmail} so a batch\n * send resolves the template once and renders it per recipient, rather than\n * one read each (AGL-768): a Firestore read per invite is fine, one per\n * recipient in a usage-email batch is not.\n *\n * Reads through the Admin SDK, which bypasses the staff-only rules on the\n * collection — the send path runs as the server, not as a signed-in user.\n *\n * A design that places email blocks costs one more read per block, from the\n * platform marketing site, made here with the template so a batch pays it\n * once too (AGL-3318). Whether a recipient's copy expands them is decided per\n * send, by {@link renderLoadedSystemEmail}.\n */\nexport async function loadSystemEmail(\n templateKey: string,\n): Promise<LoadedSystemEmail | null> {\n const definition = getSystemEmailTemplate(templateKey)\n if (!definition) return null\n // A Firebase- or Stripe-delivered email is sent by that service from its\n // own templates, so rendering one here would produce output nothing ever\n // sends (AGL-767).\n if (definition.deliveredBy !== 'resend') return null\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const templateRef = firestore\n .collection(SYSTEM_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return catalogDefault(definition)\n\n const versionId = templateSnapshot.get('versionId')\n // Cleared by \"reset to default\", which nulls the pointer rather than\n // deleting the document so the audit trail survives.\n if (!versionId) return catalogDefault(definition)\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms (AGL-1223). The email besigner compresses like every\n // other besigner document, and versions written before it did are still\n // plain maps — `decodeStoredNodes` returns those unchanged.\n //\n // Reading raw is the failure the guard below cannot catch: over a\n // `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and a staff-authored email renders empty rather than falling\n // back to its built-in copy.\n const nodes = decodeStoredNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) return catalogDefault(definition)\n\n const components = await loadPlatformEmailBlocks(\n firestore,\n templateKey,\n nodes,\n )\n return {\n definition,\n nodes,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n definition.defaultSubject,\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n source: 'designed',\n ...(components && { components }),\n }\n } catch (error) {\n // Never let a template problem block the send, or strip it bare: the\n // recipient gets the built-in copy, in the same header and footer.\n console.error(\n `system email template ${templateKey} failed to load; sending its built-in copy`,\n error,\n )\n return catalogDefault(definition)\n }\n}\n\n/**\n * Renders a loaded email for one recipient's merge values (AGL-768). No\n * Firestore access — call {@link loadSystemEmail} once, then this per\n * recipient. `null` only if the renderer produced no document at all.\n *\n * Which header and footer it wears is decided here, per send, because one\n * batch-loaded design goes to recipients of different brands:\n *\n * - A send under the platform's own brand of a design whose email blocks the\n * load read draws those blocks (AGL-3318), and nothing else: they ARE its\n * header and footer, and a second one around them would draw both.\n * - Every other send is drawn inside {@link buildSystemEmailChrome}'s chrome\n * (AGL-3322): the built-in copy, which places no blocks; a design sent to a\n * white-label org's people, whose blocks stay unexpanded and draw nothing,\n * since they are the platform's; and a design on a deployment that names no\n * marketing site, or whose blocks could not be read. The chrome is in the\n * brand of the send — the org's email logo rides in its header, so a\n * white-label mail carries exactly one logo.\n */\nexport function renderLoadedSystemEmail(\n loaded: LoadedSystemEmail,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): RenderedSystemEmail | null {\n const merged = { ...PLATFORM_BRAND_MERGE_TOKENS, ...merge }\n const blocks = drawsPlatformBlocks(loaded, merged, options)\n const rendered = renderEmailHtml({\n nodes: (blocks\n ? composeReusableComponentNodes(loaded.nodes as never, loaded.components)\n : loaded.nodes) as never,\n // Besigner maps are rooted at '_@_', not renderEmailHtml's default\n // 'root' — without this a designed template rendered empty and the send\n // fell back to built-in copy, so designing did nothing (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merged),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merged),\n merge: merged,\n // The same policy the console's own preview of these nodes applies —\n // `sanitizeCustomHtml` in the email plugin delegates to this exact\n // function, so what staff review and what the recipient receives are one\n // function of one string rather than two that have to be kept in step.\n sanitize: sanitizeAuthorHtml,\n // Without this a staff-picked image resolves to a site-relative CDN path,\n // which is a broken-image box in the recipient's inbox (AGL-1224). The\n // chrome's logo resolves against it too.\n mediaOrigin: CONSOLE_ORIGIN,\n // No `brandLogoUrl`: the org's email logo (AGL-2139) is the chrome\n // header's logo whenever there is one to draw, and a send that draws the\n // platform's blocks is by definition one that carries none.\n ...(blocks\n ? {}\n : {\n chrome: buildSystemEmailChrome({\n definition: loaded.definition,\n merged,\n brandLogoUrl: options.brandLogoUrl,\n brandHomeUrl: options.brandHomeUrl,\n }),\n }),\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merged),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n source: loaded.source,\n }\n}\n\n/**\n * Renders the system email a key sends to one recipient (AGL-750): the\n * published staff design, or the catalog's built-in copy (AGL-3322), in the\n * header and footer of the brand it is sent under.\n *\n * **`null` only for a key that is unknown or not Resend-delivered**, which\n * has nothing here to render. No template document, no published version, an\n * empty node map and a Firestore failure all render the built-in copy — the\n * same copy the editor seeds a first design with — so a missing, half-saved\n * or broken template can never mean a customer receives no email, or a bare\n * one. Every caller still keeps its own hard-coded copy behind this, as the\n * last resort for a render that produced nothing.\n *\n * A single-recipient convenience over {@link loadSystemEmail} +\n * {@link renderLoadedSystemEmail}; a batch should call those two directly so\n * it reads the template once.\n */\nexport async function renderSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n const loaded = await loadSystemEmail(templateKey)\n return loaded ? renderLoadedSystemEmail(loaded, merge, options) : null\n}\n\n/**\n * The email a template sends right now, for the staff test-send endpoint\n * (AGL-766): exactly what a real send of it renders — the published design,\n * or the built-in copy, with the same header and footer — so a test shows\n * what recipients receive. `null` for an unknown or non-Resend key.\n */\nexport async function renderEffectiveSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n return renderSystemEmail(templateKey, merge, options)\n}\n\n/**\n * The brand one system email is sent under (AGL-3367): the merge tokens, the\n * chrome options and the sender name, from one place.\n *\n * `null` is the platform's own brand, and it is the answer for everything\n * the platform says on its own behalf: a staff alert, a legal receipt, an\n * enforcement notice. A workspace-context email passes the workspace's org\n * document, and `resolveBrandingProfile` decides from there: a white-label\n * org gets its own name, logo, home and support in the header and footer,\n * and every other org resolves to the platform profile, so the same call is\n * right for both.\n */\nexport interface SystemEmailBrand {\n merge: Record<string, string>\n options: SystemEmailBrandOptions\n /** The display name to send under; absent sends under the platform's. */\n fromName?: string\n /** The org's own name, for the `{{org.name}}` its copy may use. */\n orgName?: string\n}\n\n/** See {@link SystemEmailBrand}. */\nexport function systemEmailBrand(\n org: Readonly<Record<string, unknown>> | null | undefined,\n): SystemEmailBrand {\n if (!org) return { merge: {}, options: {} }\n const branding = resolveBrandingProfile(org as never)\n const orgName = typeof org['name'] === 'string' ? org['name'].trim() : ''\n return {\n ...(orgName ? { orgName } : {}),\n merge: brandMergeTokens(branding),\n options: {\n brandLogoUrl: branding.emailLogoUrl,\n brandHomeUrl: branding.homeUrl,\n },\n fromName: branding.fromName,\n }\n}\n\n/**\n * {@link systemEmailBrand} for an org read by id, for a sender that holds\n * only the id. A failed or missing read is the platform's brand: the email\n * still goes, and a white-label org's people see the platform's chrome on\n * one message rather than no message.\n */\nexport async function orgSystemEmailBrand(\n orgId: string | null | undefined,\n): Promise<SystemEmailBrand> {\n if (!orgId) return systemEmailBrand(null)\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .get()\n return systemEmailBrand(snapshot.exists ? (snapshot.data() ?? null) : null)\n } catch (error) {\n console.error(`system email brand for org ${orgId} failed to load`, error)\n return systemEmailBrand(null)\n }\n}\n\n/** What a send takes from a rendered system email. */\nexport interface SystemEmailContent {\n subject: string\n text: string\n html?: string\n}\n\n/**\n * The subject, text and HTML to send, with the caller's own copy as the\n * last resort for a render that produced nothing: the shape every sender of\n * a system email repeats, written once.\n */\nexport function systemEmailContent(\n rendered: RenderedSystemEmail | null,\n fallback: { subject: string; text: string },\n): SystemEmailContent {\n return {\n subject: rendered?.subject || fallback.subject,\n text: rendered?.text || fallback.text,\n ...(rendered?.html ? { html: rendered.html } : {}),\n }\n}\n\n/**\n * Renders a key for one recipient in a brand and returns what to send\n * (AGL-3367): {@link renderSystemEmail} and {@link systemEmailContent} in\n * one call, for the senders with one message to build. Never throws; a\n * render that fails sends the fallback copy, which is what it sent before.\n */\nexport async function renderSystemEmailContent(\n templateKey: string,\n merge: Record<string, string>,\n brand: SystemEmailBrand,\n fallback: { subject: string; text: string },\n): Promise<SystemEmailContent> {\n const rendered = await renderSystemEmail(\n templateKey,\n { ...brand.merge, ...merge },\n brand.options,\n ).catch((error) => {\n console.error(`system email ${templateKey} failed to render`, error)\n return null\n })\n return systemEmailContent(rendered, fallback)\n}\n\nexport default renderSystemEmail\n"],"names":["process","EMAIL_NODE_ROOT_ID","SYSTEM_EMAIL_COLLECTION","buildDefaultEmailNodeMap","getSystemEmailTemplate","renderEmailHtml","substituteMergeTokens","firebaseAdmin","platformMarketingHostId","brandMergeTokens","collectReferencedComponentIds","composeReusableComponentNodes","decodeStoredNodes","resolveBrandingProfile","sanitizeAuthorHtml","hostComponentReader","loadReferencedComponents","PLATFORM_BRAND_MERGE_TOKENS","buildSystemEmailChrome","isPlatformBrandedSend","blankUnresolvedTokens","value","replace","CONSOLE_ORIGIN","env","NEXT_PUBLIC_CONSOLE_URL","loadPlatformEmailBlocks","firestore","templateKey","nodes","size","undefined","hostId","onSkipped","skipped","console","warn","JSON","stringify","tag","error","drawsPlatformBlocks","loaded","merged","options","Boolean","components","Object","keys","length","catalogDefault","definition","subjectTemplate","defaultSubject","preheaderTemplate","source","loadSystemEmail","deliveredBy","templateSnapshot","app","templateRef","collection","doc","get","exists","versionId","versionSnapshot","String","renderLoadedSystemEmail","merge","rendered","blocks","rootId","subject","preheader","sanitize","mediaOrigin","chrome","brandLogoUrl","brandHomeUrl","html","text","renderSystemEmail","renderEffectiveSystemEmail","systemEmailBrand","org","branding","orgName","trim","emailLogoUrl","homeUrl","fromName","orgSystemEmailBrand","orgId","snapshot","data","systemEmailContent","fallback","renderSystemEmailContent","brand","catch"],"mappings":"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"}
@@ -360,5 +360,11 @@ export declare function resolveHostSendingIdentity(options: {
360
360
  * declares a cache has also declared how long it may be trusted.
361
361
  */
362
362
  export declare function hostSendingIdentity(hostId: string | null | undefined, cache?: Map<string, SendingIdentityVerdict>): Promise<SendingIdentityVerdict>;
363
+ /**
364
+ * The verdict a suspended site's mail gets: a refusal, whatever identity the
365
+ * site would otherwise have sent on. `sendEmail` reads it and answers
366
+ * `suspended` without calling the provider.
367
+ */
368
+ export declare function suspendedSendingIdentity(scope: string | null | undefined): SendingIdentityVerdict;
363
369
  /** The record keys a surface highlights as outstanding. */
364
370
  export declare function outstandingSendingRecords(record: SendingDomainRecord): SendingDnsRecord[];
@@ -65,9 +65,16 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
65
65
  * stopped there refuses sends, which is the correct behavior for a domain that
66
66
  * has no signing key.
67
67
  */ import { assessDmarc, assessSendingRecords, isPlatformSendingDomain, isSharedSendingDomain, normalizeLocalPart, normalizeSendingDomain, resolveSendingIdentity, safeProviderDetail, SENDING_SUBDOMAIN, sendingDnsRecords, sendingTrackingHost, sendingDomainRequiredRecords, sendingRecordKey, sharedTenantSendingFrom, validateSendingDomain } from "@aglyn/shared-util-email";
68
+ import { lockdownMode } from "@aglyn/aglyn/server";
68
69
  import firebaseAdmin from "./firebase-admin.js";
69
70
  import { lookupCaa, lookupMx, lookupTxt } from "./dns-probe.js";
70
71
  import { getOrgForHost } from "./organizations.js";
72
+ import { getSiteLockdown } from "./tenant-write-lockdown.js";
73
+ // Also INSTALLS the outbound phishing screen's review store on `sendEmail`
74
+ // (AGL-3356): every sender whose identity comes from `hostSendingIdentity`
75
+ // has therefore installed the gate the workspace stamped below is read by.
76
+ import { sendingWorkspaceFor } from "./outbound-send-review.js";
77
+ import { flagLookalikeDomain } from "./hosted-page-review.js";
71
78
  const firestore = ()=>firebaseAdmin.app().firestore();
72
79
  /** Subcollection under the owning org. */ export const SENDING_DOMAINS_COLLECTION = 'sendingDomains';
73
80
  const domainRef = (orgId, domain)=>firestore().collection('orgs').doc(orgId).collection(SENDING_DOMAINS_COLLECTION).doc(domain);
@@ -150,6 +157,15 @@ const domainRef = (orgId, domain)=>firestore().collection('orgs').doc(orgId).col
150
157
  await ref.set(record, {
151
158
  merge: true
152
159
  });
160
+ // A domain that wears a brand (`paypa1.com`) goes to staff in the abuse
161
+ // queue the moment it is claimed — flagged, not refused (AGL-3362). Mail
162
+ // from it is held at the send seam whatever staff decide. Never throws.
163
+ await flagLookalikeDomain({
164
+ kind: 'sending',
165
+ hostId: null,
166
+ orgId: options.orgId,
167
+ domain
168
+ });
153
169
  return {
154
170
  record,
155
171
  error: null,
@@ -489,6 +505,17 @@ export async function listSendingDomains(orgId) {
489
505
  }), {
490
506
  merge: true
491
507
  });
508
+ // Again on verification, the moment it can actually send (AGL-3362): a
509
+ // claim added before this check existed is caught here. One row per
510
+ // domain, so this counts rather than re-alerts.
511
+ if (verified) {
512
+ await flagLookalikeDomain({
513
+ kind: 'sending',
514
+ hostId: null,
515
+ orgId,
516
+ domain
517
+ });
518
+ }
492
519
  return {
493
520
  record: readSendingDomainRecord(await ref.get()),
494
521
  missing: verdict.missing,
@@ -643,7 +670,7 @@ export async function listSendingDomains(orgId) {
643
670
  * a site sending on a domain that no longer authenticates. A caller that
644
671
  * declares a cache has also declared how long it may be trusted.
645
672
  */ export async function hostSendingIdentity(hostId, cache) {
646
- var _ref, _ref1, _ref2, _ref3;
673
+ var _ref, _snapshot_data, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _snapshot_data1;
647
674
  const id = String(hostId != null ? hostId : '').trim();
648
675
  if (!id) {
649
676
  /*
@@ -663,16 +690,81 @@ export async function listSendingDomains(orgId) {
663
690
  if (hit) return hit;
664
691
  const snapshot = await firestore().collection('hosts').doc(id).get().catch(()=>null);
665
692
  const owner = await getOrgForHost(id).catch(()=>null);
666
- const verdict = await resolveHostSendingIdentity({
667
- orgId: (_ref = owner == null ? void 0 : owner.orgId) != null ? _ref : null,
668
- selectedDomain: (_ref1 = snapshot == null ? void 0 : snapshot.get('sendingDomain')) != null ? _ref1 : '',
669
- selectedLocalPart: (_ref2 = snapshot == null ? void 0 : snapshot.get('sendingLocalPart')) != null ? _ref2 : '',
693
+ /*
694
+ * A SUSPENDED SITE SENDS NOTHING (AGL-3356).
695
+ *
696
+ * This is the door every tenant sender but the campaign core uses, so it
697
+ * is where one check covers them all — workflow and automation steps, the
698
+ * CRM's one-off mail, inbox replies, member posts, receipts, reminders.
699
+ * Refusing here rather than at each caller is the same argument this
700
+ * function's own docblock makes about identities: nineteen call sites
701
+ * asked to remember produce a twentieth that does not, and the incident
702
+ * behind this was exactly that — a locked workspace whose workflow could
703
+ * still mail for it.
704
+ *
705
+ * FULL locks only. A read-only lock is a maintenance window whose point
706
+ * is that the site keeps serving and earning, and a receipt for an order
707
+ * taken before it began must still arrive. Staff suspensions for abuse,
708
+ * fraud and billing are full locks, which is the case this closes.
709
+ *
710
+ * The documents read above are handed to the verdict, so the check costs
711
+ * the TTL-cached platform read and nothing else. It inherits the
712
+ * verdict's fail-open posture and its takedown ratchet.
713
+ */ const lock = await getSiteLockdown(id, Date.now(), {
714
+ org: (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null,
715
+ host: (snapshot == null ? void 0 : snapshot.exists) ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : null : null
716
+ });
717
+ if (lock && lockdownMode(lock) === 'full') {
718
+ const suspended = suspendedSendingIdentity(lock.scope);
719
+ cache == null ? void 0 : cache.set(id, suspended);
720
+ return suspended;
721
+ }
722
+ const resolved = await resolveHostSendingIdentity({
723
+ orgId: (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null,
724
+ selectedDomain: (_ref2 = snapshot == null ? void 0 : snapshot.get('sendingDomain')) != null ? _ref2 : '',
725
+ selectedLocalPart: (_ref3 = snapshot == null ? void 0 : snapshot.get('sendingLocalPart')) != null ? _ref3 : '',
670
726
  hostId: id,
671
- poolMember: (_ref3 = snapshot == null ? void 0 : snapshot.get('sendingPoolMember')) != null ? _ref3 : ''
727
+ poolMember: (_ref4 = snapshot == null ? void 0 : snapshot.get('sendingPoolMember')) != null ? _ref4 : ''
728
+ });
729
+ /*
730
+ * THE WORKSPACE, STAMPED FOR THE PHISHING SCREEN (AGL-3356).
731
+ *
732
+ * `sendEmail` screens every tenant message whose identity carries this,
733
+ * so stamping it here is what puts CRM mail, inbox replies, newsletters,
734
+ * member posts, sweeps, flow steps and receipts behind one screen without
735
+ * any of them passing anything. Built from the two documents read above:
736
+ * the screen costs no read of its own.
737
+ */ const verdict = _extends({}, resolved, {
738
+ workspace: sendingWorkspaceFor({
739
+ hostId: id,
740
+ orgId: (_ref5 = owner == null ? void 0 : owner.orgId) != null ? _ref5 : null,
741
+ org: (_ref6 = owner == null ? void 0 : owner.org) != null ? _ref6 : null,
742
+ host: (snapshot == null ? void 0 : snapshot.exists) ? (_snapshot_data1 = snapshot.data()) != null ? _snapshot_data1 : null : null
743
+ })
672
744
  });
673
745
  cache == null ? void 0 : cache.set(id, verdict);
674
746
  return verdict;
675
747
  }
748
+ /**
749
+ * The verdict a suspended site's mail gets: a refusal, whatever identity the
750
+ * site would otherwise have sent on. `sendEmail` reads it and answers
751
+ * `suspended` without calling the provider.
752
+ */ export function suspendedSendingIdentity(scope) {
753
+ const what = scope === 'platform' ? 'Sending is paused platform-wide' : scope === 'org' ? 'This workspace is suspended' : 'This site is suspended';
754
+ const message = `${what}, so no email is sent on its behalf until the suspension is lifted.`;
755
+ return {
756
+ from: null,
757
+ source: null,
758
+ domain: null,
759
+ summary: message,
760
+ refusal: {
761
+ code: 'workspace-suspended',
762
+ domain: null,
763
+ message,
764
+ missing: []
765
+ }
766
+ };
767
+ }
676
768
  /** The record keys a surface highlights as outstanding. */ export function outstandingSendingRecords(record) {
677
769
  var _ref;
678
770
  const missing = new Set((_ref = record == null ? void 0 : record.lastMissing) != null ? _ref : []);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sending-domains.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 * CUSTOM SENDING DOMAINS — durable half.\n *\n * The record, the DNS lookups and the status transitions. The decisions all\n * live in `@aglyn/shared-util-email`'s `sending-domain.ts`, which is pure;\n * this module is the I/O around them, and it holds no policy of its own.\n *\n * ## Per-org record, per-host selection\n *\n * The record is at `orgs/{orgId}/sendingDomains/{domain}`, alongside\n * `orgs/{orgId}/ssoDomains/{domain}` and for the same reason: proving control\n * of a zone is a property of the ORG that proved it, and an agency running\n * four sites on `client.com` should publish the DKIM record once rather than\n * four times.\n *\n * Which identity a given site SENDS on is a separate per-host field,\n * `hosts/{hostId}.sendingDomain`. That split is what answers the two halves of\n * the question at once: per-org verification keeps the DNS chore down, and a\n * per-host selection is what an agency's client actually wants, because the\n * `From:` their recipients see belongs to the site, not to the agency.\n *\n * ## Reads are cheap on purpose\n *\n * `resolveHostSendingIdentity` is on the path of a campaign send, so it reads\n * the host document the caller already has and at most ONE org subcollection\n * document. It never scans, never lists, and never touches DNS — the standing\n * rule against unrequested reads on a hot path applies here more than most,\n * because a campaign resolves an identity once for thousands of messages.\n *\n * ## Why the provider call is not in here\n *\n * Issuing a DKIM key means creating a domain at the mail provider, which needs\n * a credential that can create things — a different one from the send-only\n * `RESEND_API_KEY`, which is exactly why `email-health.ts` can use the domains\n * endpoint as a read-only credential probe.\n *\n * That credential belongs to the console alone, so the driver that reads it\n * lives in the console app — `apps/console/utils/server/` — and not here.\n * **This library is imported by the tenant runtime**, which\n * serves untrusted published sites; a module it can import is a module whose\n * environment read is one bug away from being reachable from a site request.\n * A file in `apps/console` is not importable from the tenant app at all —\n * there is no path mapping to it and nx's module boundaries forbid app→app —\n * so the isolation is structural rather than a convention.\n *\n * What stays here is the seam: {@link recordIssuedSendingDomain} takes what a\n * provider returned, and {@link recordSendingDomainIssueFailure} takes what it\n * refused. `requestSendingDomain` still stops at `requested`, and a domain\n * stopped there refuses sends, which is the correct behavior for a domain that\n * has no signing key.\n */\n\nimport {\n assessDmarc,\n assessSendingRecords,\n isPlatformSendingDomain,\n isSharedSendingDomain,\n normalizeLocalPart,\n normalizeSendingDomain,\n resolveSendingIdentity,\n safeProviderDetail,\n SENDING_SUBDOMAIN,\n sendingDnsRecords,\n sendingTrackingHost,\n sendingDomainRequiredRecords,\n sendingRecordKey,\n sharedTenantSendingFrom,\n validateSendingDomain,\n type DmarcAssessment,\n type SendingDnsRecord,\n type SendingDomainRecord,\n type SendingDomainSelection,\n type SendingIdentityPurpose,\n type SendingIdentityVerdict,\n type SendingVerification,\n} from '@aglyn/shared-util-email'\nimport firebaseAdmin from './firebase-admin'\nimport { lookupCaa, lookupMx, lookupTxt } from './dns-probe'\nimport { getOrgForHost } from './organizations'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Subcollection under the owning org. */\nexport const SENDING_DOMAINS_COLLECTION = 'sendingDomains'\n\nconst domainRef = (orgId: string, domain: string) =>\n firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .doc(domain)\n\n/**\n * The DKIM selector issued to one org for one domain.\n *\n * Includes the org id so two orgs verifying the same name occupy different\n * record names. Without that they would share `resend._domainkey.<domain>`,\n * and whichever verified second would overwrite the first — or, worse, inherit\n * the first's verification without ever publishing anything.\n */\nexport function sendingDkimSelector(orgId: string): string {\n const safe = String(orgId ?? '')\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '')\n .slice(0, 24)\n return `aglyn${safe ? `-${safe}` : ''}`\n}\n\n/** Both keys always present, one always null — `strictNullChecks` is off. */\nexport interface SendingDomainResult {\n record: SendingDomainRecord | null\n error: string | null\n /** The HTTP status a route should answer with. */\n status: number\n}\n\n/**\n * One stored document as a record.\n *\n * Exported so the re-check sweep, which reaches these documents through a\n * collection-group query rather than by org and name, shapes them the same way\n * every other reader here does — a second reader with its own defaults is how\n * two callers come to disagree about what an absent field means.\n */\nexport function readSendingDomainRecord(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): SendingDomainRecord | null {\n if (!snapshot?.exists) return null\n const data = snapshot.data() ?? {}\n return {\n domain: String(data.domain ?? snapshot.id),\n status: data.status ?? 'requested',\n dkimSelector: String(data.dkimSelector ?? ''),\n dkimPublicKey: data.dkimPublicKey ?? null,\n returnPathHost: data.returnPathHost ?? null,\n providerDomainId: data.providerDomainId ?? null,\n trackingTarget: data.trackingTarget ?? null,\n createdAtMs: Number(data.createdAtMs) || null,\n verifiedAtMs: Number(data.verifiedAtMs) || null,\n lastCheckedAtMs: Number(data.lastCheckedAtMs) || null,\n lastIssueError: data.lastIssueError ?? null,\n lastIssueAtMs: Number(data.lastIssueAtMs) || null,\n lastMissing: Array.isArray(data.lastMissing) ? data.lastMissing : null,\n }\n}\n\n/**\n * Start (or re-read) a claim on a sending domain.\n *\n * Idempotent, and deliberately non-destructive on a re-request: an existing\n * record keeps its selector and its key, because reissuing either would\n * invalidate a record the customer may already have published and turn a\n * working setup into a mysterious failure. This mirrors `issueDomainClaim`.\n */\nexport async function requestSendingDomain(options: {\n orgId: string\n domain: string\n}): Promise<SendingDomainResult> {\n const { domain, error } = validateSendingDomain(options?.domain)\n if (!domain) return { record: null, error, status: 400 }\n if (!options?.orgId) {\n return { record: null, error: 'Missing organization', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const existing = await ref.get()\n if (existing.exists) {\n return { record: readSendingDomainRecord(existing), error: null, status: 200 }\n }\n\n const record: SendingDomainRecord = {\n domain,\n status: 'requested',\n dkimSelector: sendingDkimSelector(options.orgId),\n dkimPublicKey: null,\n returnPathHost: null,\n createdAtMs: Date.now(),\n }\n await ref.set(record, { merge: true })\n return { record, error: null, status: 201 }\n}\n\n/**\n * Record the key the mail provider issued, moving the domain to\n * `records-issued` so the customer has something to publish.\n *\n * Separated from {@link requestSendingDomain} because it needs a credential\n * that may not exist. Nothing here calls the provider: the caller supplies\n * what it was given, so a deployment whose key cannot create domains still has\n * a working path — an operator can complete this step by hand — and this\n * module never grows a dependency on a credential it cannot assume. The\n * console holds the credential and the driver that uses it; this library,\n * which the tenant runtime also imports, holds neither.\n *\n * ## `records-issued` is a promise that records exist\n *\n * The write is refused unless the resulting record actually yields a DKIM\n * record with a value. The alternative is the failure this whole feature is\n * arranged against: a status saying the customer has records to publish, next\n * to a records table with an empty DKIM row, which cannot ever verify and\n * reads to the customer as our bug.\n *\n * ## An issued key is never overwritten\n *\n * A second call carrying a DIFFERENT key is refused rather than applied. The\n * customer may already have published the first one, and replacing it turns a\n * finished setup into a domain that silently stops signing. Re-recording the\n * SAME key is a no-op and succeeds, so a retried request is safe.\n *\n * The SELECTOR, by contrast, comes from the provider when it supplies one:\n * the record a customer publishes has to be the name the provider will sign\n * under, and `sendingDkimSelector` only proposes it.\n */\nexport async function recordIssuedSendingDomain(options: {\n orgId: string\n domain: string\n dkimPublicKey: string\n /** The selector the provider issued, when it chose its own. */\n dkimSelector?: string\n returnPathHost?: string\n providerDomainId?: string\n /**\n * The provider's tracking host, when it issued one — see\n * `SendingDomainRecord.trackingTarget`. Absent leaves the domain sending\n * and unmeasured rather than unsent.\n */\n trackingTarget?: string | null\n}): Promise<SendingDomainResult> {\n const domain = normalizeSendingDomain(options?.domain)\n const key = String(options?.dkimPublicKey ?? '').trim()\n if (!domain) {\n return { record: null, error: 'Missing domain', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { record: null, error: 'No claim on that domain', status: 404 }\n }\n\n const existing = readSendingDomainRecord(snapshot)\n const held = String(existing?.dkimPublicKey ?? '').trim()\n if (held && held !== key) {\n return {\n record: existing,\n error:\n 'That domain already has an issued signing key. Release the domain ' +\n 'and add it again to start over.',\n status: 409,\n }\n }\n\n const selector =\n String(options?.dkimSelector ?? '').trim() || existing?.dkimSelector || ''\n const returnPathHost = options.returnPathHost || existing?.returnPathHost\n\n /*\n * The only thing standing between a caller and `records-issued`, and it is\n * asked of the SAME function that prints the records for the customer and\n * that the verifier compares against — not a truthiness check on the key,\n * which would be this write's private opinion of what \"publishable\" means\n * and could agree with nothing else.\n */\n const issued = sendingDomainRequiredRecords({\n domain,\n dkimSelector: selector,\n dkimPublicKey: key,\n returnPathHost,\n })\n if (!issued.some((entry) => entry.purpose === 'dkim')) {\n return {\n record: existing,\n error: 'Refusing to issue records for a domain with no publishable DKIM record',\n status: 400,\n }\n }\n\n await ref.set(\n {\n status: 'records-issued',\n dkimPublicKey: key,\n ...(selector ? { dkimSelector: selector } : {}),\n ...(options.returnPathHost\n ? { returnPathHost: options.returnPathHost }\n : {}),\n ...(options.providerDomainId\n ? { providerDomainId: String(options.providerDomainId) }\n : {}),\n // Only when the provider issued one. Writing an empty value would put a\n // tracking CNAME with no target in front of a customer, which is the\n // blank-record shape `sendingDnsRecords` refuses everywhere else.\n ...(String(options.trackingTarget ?? '').trim()\n ? { trackingTarget: String(options.trackingTarget).trim() }\n : {}),\n // A previous failure is not part of the record once it succeeded.\n lastIssueError: firebaseAdmin.firestore.FieldValue.delete(),\n },\n { merge: true },\n )\n return { record: readSendingDomainRecord(await ref.get()), error: null, status: 200 }\n}\n\n/**\n * Record that the provider did not issue anything, WITHOUT moving the status.\n *\n * The point of a separate function is that there is no path from a provider\n * failure to `records-issued`. A `4xx` or `5xx` means no key exists, so the\n * domain stays `requested` — where it refuses sends, which is correct for a\n * domain that cannot sign — and carries a reason an admin can act on instead\n * of appearing to have finished.\n *\n * `detail` is a short code the caller built from a fixed vocabulary, never a\n * provider's response body. {@link safeProviderDetail} is the second line:\n * an `Authorization` header echoed back by a provider must not become a\n * Firestore document.\n */\nexport async function recordSendingDomainIssueFailure(options: {\n orgId: string\n domain: string\n detail: string\n}): Promise<void> {\n const domain = normalizeSendingDomain(options?.domain)\n if (!options?.orgId || !domain) return\n const ref = domainRef(options.orgId, domain)\n if (!(await ref.get()).exists) return\n await ref.set(\n {\n lastIssueError: safeProviderDetail(options.detail) || 'unknown',\n lastIssueAtMs: Date.now(),\n },\n { merge: true },\n )\n}\n\nexport interface SendingDomainView {\n record: SendingDomainRecord\n /** Exactly what the customer must publish. */\n records: SendingDnsRecord[]\n /** Their DMARC policy, read and never written. */\n dmarc: DmarcAssessment | null\n}\n\n/** One domain plus the records it needs, for a surface that shows both. */\nexport async function getSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<SendingDomainView | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return null\n const record = readSendingDomainRecord(await domainRef(orgId, domain).get())\n if (!record) return null\n return { record, records: sendingDnsRecords(record), dmarc: null }\n}\n\nexport async function listSendingDomains(\n orgId: string,\n): Promise<SendingDomainRecord[]> {\n if (!orgId) return []\n const snapshot = await firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .get()\n return snapshot.docs.map(readSendingDomainRecord).filter(Boolean)\n}\n\n/**\n * Read the customer's DMARC policy.\n *\n * Advisory in both directions: it never blocks verification and it is never\n * written. A domain under `p=reject` with our DKIM unpublished hard-fails\n * every message, and a customer deserves to be told that before they wonder\n * why their campaign vanished.\n */\nexport async function readDmarcPolicy(\n rawDomain: string,\n): Promise<DmarcAssessment | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!domain) return null\n const lookup = await lookupTxt(`_dmarc.${domain}`)\n // An unreachable lookup is not \"they have no policy\" — saying so would tell\n // a customer under p=reject that they have no protection.\n if (!lookup.answered) return null\n return assessDmarc(lookup.records)\n}\n\n/**\n * Start (or read) the hold that keeps a tracked domain's links alive.\n *\n * Stamped on FIRST sighting rather than computed from an orphan timestamp,\n * because there is no reliable one: a label reassignment leaves no record of\n * when it happened, and the reaper re-infers the orphan on every pass. The\n * stamp is the record of when the hold began, so a domain cannot be held\n * forever by a sweep that keeps starting the clock over.\n *\n * @returns the instant the domain may be released, or `null` when there is\n * nothing to stamp it on — a domain with no org record cannot be\n * held, and refusing to release it on the strength of a hold nobody\n * can see would strand a provider slot permanently.\n */\nexport async function holdTrackedSendingDomain(options: {\n orgId: string | null\n domain: string\n nowMs: number\n windowMs: number\n}): Promise<number | null> {\n const orgId = String(options?.orgId ?? '').trim()\n const domain = normalizeSendingDomain(options?.domain)\n if (!orgId || !domain || options.windowMs <= 0) return null\n\n const ref = domainRef(orgId, domain)\n const snapshot = await ref.get().catch(() => null)\n if (!snapshot?.exists) return null\n\n const held = Number(snapshot.get('trackingRetentionUntilMs')) || 0\n if (held > 0) return held\n\n const until = options.nowMs + options.windowMs\n await ref\n .set({ trackingRetentionUntilMs: until }, { merge: true })\n // A stamp we could not write is a hold we cannot prove later, so the\n // caller is told there is none rather than being asked to wait on a\n // deadline that will restart on the next pass.\n .catch(() => null)\n return until\n}\n\n/**\n * Whether this domain publishes a CAA policy that would REFUSE the tracking\n * host's certificate.\n *\n * The question decides whether a customer is shown a CAA record at all, and\n * asking it is the difference between guidance and a footgun. CAA restricts\n * which authorities may issue, and the lookup stops at the first name in the\n * tree publishing any record — so:\n *\n * - A domain publishing NOTHING needs nothing. Any authority may already\n * issue, and handing over a CAA record to paste would be the change that\n * STARTS restricting them, breaking whatever else renews on that name.\n * - A domain that publishes some, none of which name our authority, has to\n * add one ALONGSIDE what it has.\n * - A domain that already names our authority is done.\n *\n * Walks up from the tracking host exactly as a certificate authority does,\n * and stops where a real lookup stops: at the first name with any record.\n *\n * @returns `null` when DNS could not be reached, which is not \"they publish\n * none\" — telling a customer to add a CAA on the strength of an\n * unanswered lookup is how a working zone gets narrowed by mistake.\n */\nexport async function readTrackingCaaNeed(\n rawDomain: string,\n authority: string,\n): Promise<'not-needed' | 'must-add' | 'satisfied' | null> {\n const domain = normalizeSendingDomain(rawDomain)\n const ca = String(authority ?? '').trim().toLowerCase()\n if (!domain || !ca) return null\n\n /*\n * From the tracking host upward. Every label is asked because a CAA on a\n * closer name overrides a broader one — a customer who already scoped a\n * policy to `links.` must not be told to widen their root.\n */\n const labels = sendingTrackingHost(domain).split('.')\n for (let index = 0; index < labels.length - 1; index += 1) {\n const name = labels.slice(index).join('.')\n const lookup = await lookupCaa(name)\n if (!lookup.answered) return null\n if (!lookup.records.length) continue\n // The first name with any record is where issuance is decided.\n const issuers = lookup.records\n .filter((entry) => / issue \"/.test(entry))\n .map((entry) => entry.toLowerCase())\n return issuers.some((entry) => entry.includes(`\"${ca}\"`))\n ? 'satisfied'\n : 'must-add'\n }\n return 'not-needed'\n}\n\n/**\n * Read the live DNS for one record and say what it establishes. Writes\n * nothing.\n *\n * Split out so the two callers that need this answer ask the SAME question of\n * the SAME resolvers: {@link verifySendingDomain}, which acts on it\n * immediately, and the unattended re-check sweep, which counts conclusive\n * failures before it acts. A sweep that assessed the records its own way would\n * be a second opinion on \"are these published\", and the customer would meet\n * whichever one happened to run last.\n *\n * The three lookups run together because they are independent and the caller\n * is waiting on the slowest either way.\n */\nexport async function probeSendingRecords(\n record: SendingDomainRecord,\n): Promise<SendingVerification> {\n const domain = normalizeSendingDomain(record?.domain ?? '')\n const sendHost = `${SENDING_SUBDOMAIN}.${domain}`\n const dkimHost = `${record.dkimSelector}._domainkey.${domain}`\n const [spf, dkim, mx] = await Promise.all([\n lookupTxt(sendHost),\n lookupTxt(dkimHost),\n lookupMx(sendHost),\n ])\n\n return assessSendingRecords(record, {\n spfTxt: spf.records,\n dkimTxt: dkim.records,\n mx: mx.records,\n // All three, not any: a record we could not read is not a record that is\n // absent, and one unreadable lookup is enough to make the whole answer\n // evidence of nothing.\n conclusive: spf.answered && dkim.answered && mx.answered,\n })\n}\n\n/**\n * Check the live DNS and move the record to `verified` or `failed`.\n *\n * Never throws: a sweep across every org must not stop at the first bad zone.\n *\n * An INCONCLUSIVE result writes nothing but the check time. A resolver outage\n * must not un-verify a working domain — which would silently stop that\n * tenant's mail — and must not fail a customer who is midway through\n * publishing either. This is the `hold` arm the SSO drift sweep uses, and the\n * reason `assessSendingRecords` has three outcomes rather than two.\n *\n * Acts on ONE conclusive answer, and that is right for this caller: an admin\n * pressed Verify and is watching the result, so the check is attended and its\n * outcome is immediately visible and immediately retryable. The unattended\n * sweep over already-verified domains is the caller that cannot say that, and\n * `sending-domain-recheck.ts` is where the extra evidence it needs is\n * gathered.\n */\nexport async function verifySendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<{\n record: SendingDomainRecord | null\n missing: string[]\n inconclusive: boolean\n error: string | null\n}> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) {\n return { record: null, missing: [], inconclusive: false, error: 'Invalid domain' }\n }\n\n const ref = domainRef(orgId, domain)\n const record = readSendingDomainRecord(await ref.get())\n if (!record) {\n return {\n record: null,\n missing: [],\n inconclusive: false,\n error: 'No claim on that domain — add it first.',\n }\n }\n\n const verdict = await probeSendingRecords(record)\n\n const now = Date.now()\n if (verdict.status === 'inconclusive') {\n await ref.set({ lastCheckedAtMs: now }, { merge: true })\n return { record, missing: [], inconclusive: true, error: null }\n }\n\n const verified = verdict.status === 'verified'\n await ref.set(\n {\n status: verified ? 'verified' : 'failed',\n lastCheckedAtMs: now,\n ...(verified\n ? {\n verifiedAtMs: now,\n lastMissing: firebaseAdmin.firestore.FieldValue.delete(),\n }\n : { lastMissing: verdict.missing }),\n },\n { merge: true },\n )\n\n return {\n record: readSendingDomainRecord(await ref.get()),\n missing: verdict.missing,\n inconclusive: false,\n error: null,\n }\n}\n\n/**\n * Drop a claim.\n *\n * The host selections pointing at it are NOT rewritten to the platform\n * identity. A site that was sending as its own domain and now has no verified\n * one must refuse, not quietly revert — silently moving a tenant's mail back\n * onto the shared domain is the exact fallback this feature exists to prevent,\n * and doing it during a delete would be no better than doing it during a send.\n */\nexport async function releaseSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<void> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return\n await domainRef(orgId, domain)\n .delete()\n .catch(() => undefined)\n}\n\n/*==========================================\n The read on the send path\n==========================================*/\n\n/**\n * The identity one host sends on, ready to hand to `sendEmail`.\n *\n * Two document reads at most, and none at all for a host that has no\n * selection. The host document is passed in rather than re-fetched because\n * every caller already holds it.\n *\n * ## This function cannot return an `aglyn.com` address, and that is the point\n *\n * Every caller here is resolving mail FOR A SITE — that is what \"host\" in the\n * name means — so every verdict it produces is tenant mail by construction.\n * `audience: 'tenant'` is therefore passed unconditionally and is not a\n * parameter: a caller that could choose would eventually choose wrong, and the\n * wrong choice puts a merchant's list quality onto the domain the platform's\n * own invoices and password resets leave on.\n *\n * A site that has selected NOTHING does not reach `USAGE_EMAIL_FROM` either.\n * All of its mail goes on the shared pool member `hostSharedFrom` assigns it,\n * marketing included. What keeps the pool usable is measurement rather than\n * prohibition: a pooled campaign is graded against the stricter reputation\n * thresholds, so the site earning complaints is the one that stops.\n */\nexport async function resolveHostSendingIdentity(options: {\n orgId: string | null | undefined\n /** `hosts/{hostId}.sendingDomain`, the site's selection. */\n selectedDomain: string | null | undefined\n /** `hosts/{hostId}.sendingLocalPart`, defaulted when unset. */\n selectedLocalPart?: string | null\n platformFrom?: string | null\n /**\n * The site, so an unselected host can be assigned a pool member.\n *\n * Optional because two of the three callers resolve an identity for a host\n * they are already holding and one — the console's identity view — is asking\n * about a specific site by id. A caller that omits it gets no shared\n * identity, which is the honest answer: the pool assignment is per site, and\n * \"some pool member\" is not a thing to send a receipt from.\n */\n hostId?: string | null\n /**\n * A pool member this host is pinned to, overriding the hash.\n *\n * `hosts/{hostId}.sendingPoolMember`. The QUARANTINE lever: a site whose\n * transactional mail is generating complaints can be moved onto a member set\n * aside for it, without disturbing any of the other sites that hash to the\n * member it was on. Ignored unless it names a real member of the current\n * pool, so a stale or hand-edited value degrades to the ordinary assignment\n * rather than to an address nothing signs for.\n */\n poolMember?: string | null\n /** See `SendingIdentityPurpose`. Defaults to transactional. */\n purpose?: SendingIdentityPurpose\n}): Promise<SendingIdentityVerdict> {\n const platformFrom =\n options?.platformFrom ?? process.env.USAGE_EMAIL_FROM ?? null\n const domain = normalizeSendingDomain(options?.selectedDomain ?? '')\n const purpose = options?.purpose\n const sharedFrom = hostSharedFrom(options?.hostId, options?.poolMember)\n\n if (!domain || !options?.orgId) {\n return resolveSendingIdentity({\n selection: null,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n }\n\n const record = readSendingDomainRecord(await domainRef(options.orgId, domain).get())\n\n /*\n * Whose domain this is, decided from the NAME rather than from the record.\n *\n * The record is what a released domain no longer has, and a site pointed at\n * a subdomain of our own apex is still pointed at a subdomain of our own\n * apex whether or not one exists for it. Deriving this from the name keeps\n * the two possible failures apart: a missing record for OUR name is our\n * provisioning that did not finish, a missing record for THEIRS is a domain\n * they asked to send as and nothing has verified.\n */\n const platformIssued = isPlatformSendingDomain(domain)\n\n /*\n * A selection naming a CUSTOMER'S domain with no record refuses rather than\n * falling back. The record can be gone because it was released, or because\n * the selection was written against a different org — both mean the site is\n * configured to send as a domain nothing has verified, and neither is a\n * reason to send as somebody else.\n *\n * `platformIssued` rides on the selection either way, and the resolver reads\n * it only on the unverified branch: a platform subdomain that has not\n * finished provisioning drops to the pool for transactional mail instead,\n * because nothing about it is a merchant instruction to contradict.\n */\n const selection: SendingDomainSelection = record\n ? {\n domain: record.domain,\n status: record.status,\n localPart: normalizeLocalPart(options.selectedLocalPart ?? '') || 'hello',\n missing: record.lastMissing ?? [],\n platformIssued,\n }\n : { domain, status: 'failed', localPart: '', missing: [], platformIssued }\n\n /*\n * `sharedFrom` is passed even here, where a selection exists and will decide\n * the outcome. It is inert by construction — the selection branch of\n * `resolveSendingIdentity` never reads it — and passing it unconditionally\n * keeps the call shape identical between the two arms, so a future edit\n * cannot produce a path that had a pool available and did not offer it.\n *\n * It must NOT become a fallback for an unverified selection, and it is the\n * resolver's structure rather than this call site that guarantees that.\n */\n return resolveSendingIdentity({\n selection,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n}\n\n/**\n * The pool address for one host: the pinned member if it names a real one, the\n * hash assignment otherwise.\n *\n * The override is validated against the LIVE pool rather than trusted, because\n * a stale value is the expected case — an operator shrinks the pool, or a\n * quarantine was lifted by deleting the member rather than the field — and an\n * address on a domain that no longer exists is a send that fails at the\n * provider instead of one that quietly goes back to normal.\n */\nfunction hostSharedFrom(\n hostId: string | null | undefined,\n poolMember: string | null | undefined,\n): string {\n const id = String(hostId ?? '').trim()\n if (!id) return ''\n\n const pinned = normalizeSendingDomain(String(poolMember ?? ''))\n if (pinned && isSharedSendingDomain(pinned)) {\n const shared = sharedTenantSendingFrom(id)\n const at = shared.lastIndexOf('@')\n return at > 0 ? `${shared.slice(0, at)}@${pinned}` : ''\n }\n\n return sharedTenantSendingFrom(id)\n}\n\n/**\n * The identity one site sends on, from a `hostId` alone.\n *\n * The door every tenant sender uses. {@link resolveHostSendingIdentity} needs\n * the org id and the host's two selection fields, and a survey of the tenant\n * send sites found that all of them hold a `hostId` while only three hold an\n * org id and half never read the host document at all. Asking nineteen call\n * sites to each assemble the same three values is the shape that produces a\n * twentieth which does not — and the cost of forgetting here is a message\n * leaving on the platform's own domain.\n *\n * ## The cache is per-call-graph, not per-process\n *\n * Passed a `cache`, repeated resolutions for one host cost one pair of reads.\n * A sweep that mails two hundred abandoned carts across a dozen sites, or a\n * webhook that sends a receipt and a seller notice for the same order, holds\n * one map for the run and pays for each site once.\n *\n * Deliberately NOT a module-level cache. Verification status is exactly the\n * thing that changes underneath a long-lived process — the re-check sweep\n * un-verifies a domain whose records have gone — and a stale entry would keep\n * a site sending on a domain that no longer authenticates. A caller that\n * declares a cache has also declared how long it may be trusted.\n */\nexport async function hostSendingIdentity(\n hostId: string | null | undefined,\n cache?: Map<string, SendingIdentityVerdict>,\n): Promise<SendingIdentityVerdict> {\n const id = String(hostId ?? '').trim()\n if (!id) {\n /*\n * No host is not \"the platform is speaking\", and it is not \"any pool\n * member will do\" either. It is a caller that does not know which site it\n * is sending for, and the honest answer to that is a refusal.\n *\n * No `sharedFrom` is passed, which is what makes that structural rather\n * than a rule: the pool is assigned FROM the host id, so a caller without\n * one has nothing to be assigned.\n */\n return resolveSendingIdentity({ selection: null, audience: 'tenant' })\n }\n\n const hit = cache?.get(id)\n if (hit) return hit\n\n const snapshot = await firestore()\n .collection('hosts')\n .doc(id)\n .get()\n .catch(() => null)\n const owner = await getOrgForHost(id).catch(() => null)\n\n const verdict = await resolveHostSendingIdentity({\n orgId: owner?.orgId ?? null,\n selectedDomain: snapshot?.get('sendingDomain') ?? '',\n selectedLocalPart: snapshot?.get('sendingLocalPart') ?? '',\n hostId: id,\n poolMember: snapshot?.get('sendingPoolMember') ?? '',\n })\n\n cache?.set(id, verdict)\n return verdict\n}\n\n/** The record keys a surface highlights as outstanding. */\nexport function outstandingSendingRecords(\n record: SendingDomainRecord,\n): SendingDnsRecord[] {\n const missing = new Set(record?.lastMissing ?? [])\n if (!missing.size) return []\n return sendingDnsRecords(record).filter((entry) =>\n missing.has(sendingRecordKey(entry)),\n )\n}\n"],"names":["assessDmarc","assessSendingRecords","isPlatformSendingDomain","isSharedSendingDomain","normalizeLocalPart","normalizeSendingDomain","resolveSendingIdentity","safeProviderDetail","SENDING_SUBDOMAIN","sendingDnsRecords","sendingTrackingHost","sendingDomainRequiredRecords","sendingRecordKey","sharedTenantSendingFrom","validateSendingDomain","firebaseAdmin","lookupCaa","lookupMx","lookupTxt","getOrgForHost","firestore","app","SENDING_DOMAINS_COLLECTION","domainRef","orgId","domain","collection","doc","sendingDkimSelector","safe","String","toLowerCase","replace","slice","readSendingDomainRecord","snapshot","data","exists","id","status","dkimSelector","dkimPublicKey","returnPathHost","providerDomainId","trackingTarget","createdAtMs","Number","verifiedAtMs","lastCheckedAtMs","lastIssueError","lastIssueAtMs","lastMissing","Array","isArray","requestSendingDomain","options","error","record","ref","existing","get","Date","now","set","merge","recordIssuedSendingDomain","key","trim","held","selector","issued","some","entry","purpose","FieldValue","delete","recordSendingDomainIssueFailure","detail","getSendingDomain","rawDomain","records","dmarc","listSendingDomains","docs","map","filter","Boolean","readDmarcPolicy","lookup","answered","holdTrackedSendingDomain","windowMs","catch","until","nowMs","trackingRetentionUntilMs","readTrackingCaaNeed","authority","ca","labels","split","index","length","name","join","issuers","test","includes","probeSendingRecords","sendHost","dkimHost","spf","dkim","mx","Promise","all","spfTxt","dkimTxt","conclusive","verifySendingDomain","missing","inconclusive","verdict","verified","releaseSendingDomain","undefined","resolveHostSendingIdentity","platformFrom","process","env","USAGE_EMAIL_FROM","selectedDomain","sharedFrom","hostSharedFrom","hostId","poolMember","selection","audience","platformIssued","localPart","selectedLocalPart","pinned","shared","at","lastIndexOf","hostSendingIdentity","cache","hit","owner","outstandingSendingRecords","Set","size","has"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GAED,SACEA,WAAW,EACXC,oBAAoB,EACpBC,uBAAuB,EACvBC,qBAAqB,EACrBC,kBAAkB,EAClBC,sBAAsB,EACtBC,sBAAsB,EACtBC,kBAAkB,EAClBC,iBAAiB,EACjBC,iBAAiB,EACjBC,mBAAmB,EACnBC,4BAA4B,EAC5BC,gBAAgB,EAChBC,uBAAuB,EACvBC,qBAAqB,QAQhB,2BAA0B;AACjC,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,SAAS,EAAEC,QAAQ,EAAEC,SAAS,QAAQ,iBAAa;AAC5D,SAASC,aAAa,QAAQ,qBAAiB;AAE/C,MAAMC,YAAY,IAAML,cAAcM,GAAG,GAAGD,SAAS;AAErD,wCAAwC,GACxC,OAAO,MAAME,6BAA6B,iBAAgB;AAE1D,MAAMC,YAAY,CAACC,OAAeC,SAChCL,YACGM,UAAU,CAAC,QACXC,GAAG,CAACH,OACJE,UAAU,CAACJ,4BACXK,GAAG,CAACF;AAET;;;;;;;CAOC,GACD,OAAO,SAASG,oBAAoBJ,KAAa;IAC/C,MAAMK,OAAOC,OAAON,gBAAAA,QAAS,IAC1BO,WAAW,GACXC,OAAO,CAAC,cAAc,IACtBC,KAAK,CAAC,GAAG;IACZ,OAAO,CAAC,KAAK,EAAEJ,OAAO,CAAC,CAAC,EAAEA,MAAM,GAAG,IAAI;AACzC;AAUA;;;;;;;CAOC,GACD,OAAO,SAASK,wBACdC,QAA4C;QAG/BA,gBAEIC,cACPA,cACaA,oBACNA,qBACCA,sBACEA,wBACFA,sBAIAA;IAblB,IAAI,EAACD,4BAAAA,SAAUE,MAAM,GAAE,OAAO;IAC9B,MAAMD,QAAOD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;IACjC,OAAO;QACLV,QAAQK,QAAOM,eAAAA,KAAKX,MAAM,YAAXW,eAAeD,SAASG,EAAE;QACzCC,MAAM,GAAEH,eAAAA,KAAKG,MAAM,YAAXH,eAAe;QACvBI,cAAcV,QAAOM,qBAAAA,KAAKI,YAAY,YAAjBJ,qBAAqB;QAC1CK,aAAa,GAAEL,sBAAAA,KAAKK,aAAa,YAAlBL,sBAAsB;QACrCM,cAAc,GAAEN,uBAAAA,KAAKM,cAAc,YAAnBN,uBAAuB;QACvCO,gBAAgB,GAAEP,yBAAAA,KAAKO,gBAAgB,YAArBP,yBAAyB;QAC3CQ,cAAc,GAAER,uBAAAA,KAAKQ,cAAc,YAAnBR,uBAAuB;QACvCS,aAAaC,OAAOV,KAAKS,WAAW,KAAK;QACzCE,cAAcD,OAAOV,KAAKW,YAAY,KAAK;QAC3CC,iBAAiBF,OAAOV,KAAKY,eAAe,KAAK;QACjDC,cAAc,GAAEb,uBAAAA,KAAKa,cAAc,YAAnBb,uBAAuB;QACvCc,eAAeJ,OAAOV,KAAKc,aAAa,KAAK;QAC7CC,aAAaC,MAAMC,OAAO,CAACjB,KAAKe,WAAW,IAAIf,KAAKe,WAAW,GAAG;IACpE;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeG,qBAAqBC,OAG1C;IACC,MAAM,EAAE9B,MAAM,EAAE+B,KAAK,EAAE,GAAG1C,sBAAsByC,2BAAAA,QAAS9B,MAAM;IAC/D,IAAI,CAACA,QAAQ,OAAO;QAAEgC,QAAQ;QAAMD;QAAOjB,QAAQ;IAAI;IACvD,IAAI,EAACgB,2BAAAA,QAAS/B,KAAK,GAAE;QACnB,OAAO;YAAEiC,QAAQ;YAAMD,OAAO;YAAwBjB,QAAQ;QAAI;IACpE;IAEA,MAAMmB,MAAMnC,UAAUgC,QAAQ/B,KAAK,EAAEC;IACrC,MAAMkC,WAAW,MAAMD,IAAIE,GAAG;IAC9B,IAAID,SAAStB,MAAM,EAAE;QACnB,OAAO;YAAEoB,QAAQvB,wBAAwByB;YAAWH,OAAO;YAAMjB,QAAQ;QAAI;IAC/E;IAEA,MAAMkB,SAA8B;QAClChC;QACAc,QAAQ;QACRC,cAAcZ,oBAAoB2B,QAAQ/B,KAAK;QAC/CiB,eAAe;QACfC,gBAAgB;QAChBG,aAAagB,KAAKC,GAAG;IACvB;IACA,MAAMJ,IAAIK,GAAG,CAACN,QAAQ;QAAEO,OAAO;IAAK;IACpC,OAAO;QAAEP;QAAQD,OAAO;QAAMjB,QAAQ;IAAI;AAC5C;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,eAAe0B,0BAA0BV,OAc/C;4BAgEgBA;IA/Df,MAAM9B,SAASpB,uBAAuBkD,2BAAAA,QAAS9B,MAAM;IACrD,MAAMyC,MAAMpC,eAAOyB,2BAAAA,QAASd,aAAa,mBAAI,IAAI0B,IAAI;IACrD,IAAI,CAAC1C,QAAQ;QACX,OAAO;YAAEgC,QAAQ;YAAMD,OAAO;YAAkBjB,QAAQ;QAAI;IAC9D;IAEA,MAAMmB,MAAMnC,UAAUgC,QAAQ/B,KAAK,EAAEC;IACrC,MAAMU,WAAW,MAAMuB,IAAIE,GAAG;IAC9B,IAAI,CAACzB,SAASE,MAAM,EAAE;QACpB,OAAO;YAAEoB,QAAQ;YAAMD,OAAO;YAA2BjB,QAAQ;QAAI;IACvE;IAEA,MAAMoB,WAAWzB,wBAAwBC;IACzC,MAAMiC,OAAOtC,gBAAO6B,4BAAAA,SAAUlB,aAAa,oBAAI,IAAI0B,IAAI;IACvD,IAAIC,QAAQA,SAASF,KAAK;QACxB,OAAO;YACLT,QAAQE;YACRH,OACE,uEACA;YACFjB,QAAQ;QACV;IACF;IAEA,MAAM8B,WACJvC,gBAAOyB,2BAAAA,QAASf,YAAY,oBAAI,IAAI2B,IAAI,OAAMR,4BAAAA,SAAUnB,YAAY,KAAI;IAC1E,MAAME,iBAAiBa,QAAQb,cAAc,KAAIiB,4BAAAA,SAAUjB,cAAc;IAEzE;;;;;;GAMC,GACD,MAAM4B,SAAS3D,6BAA6B;QAC1Cc;QACAe,cAAc6B;QACd5B,eAAeyB;QACfxB;IACF;IACA,IAAI,CAAC4B,OAAOC,IAAI,CAAC,CAACC,QAAUA,MAAMC,OAAO,KAAK,SAAS;QACrD,OAAO;YACLhB,QAAQE;YACRH,OAAO;YACPjB,QAAQ;QACV;IACF;IAEA,MAAMmB,IAAIK,GAAG,CACX;QACExB,QAAQ;QACRE,eAAeyB;OACXG,WAAW;QAAE7B,cAAc6B;IAAS,IAAI,CAAC,GACzCd,QAAQb,cAAc,GACtB;QAAEA,gBAAgBa,QAAQb,cAAc;IAAC,IACzC,CAAC,GACDa,QAAQZ,gBAAgB,GACxB;QAAEA,kBAAkBb,OAAOyB,QAAQZ,gBAAgB;IAAE,IACrD,CAAC,GAIDb,QAAOyB,0BAAAA,QAAQX,cAAc,YAAtBW,0BAA0B,IAAIY,IAAI,KACzC;QAAEvB,gBAAgBd,OAAOyB,QAAQX,cAAc,EAAEuB,IAAI;IAAG,IACxD,CAAC;QACL,kEAAkE;QAClElB,gBAAgBlC,cAAcK,SAAS,CAACsD,UAAU,CAACC,MAAM;QAE3D;QAAEX,OAAO;IAAK;IAEhB,OAAO;QAAEP,QAAQvB,wBAAwB,MAAMwB,IAAIE,GAAG;QAAKJ,OAAO;QAAMjB,QAAQ;IAAI;AACtF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeqC,gCAAgCrB,OAIrD;IACC,MAAM9B,SAASpB,uBAAuBkD,2BAAAA,QAAS9B,MAAM;IACrD,IAAI,EAAC8B,2BAAAA,QAAS/B,KAAK,KAAI,CAACC,QAAQ;IAChC,MAAMiC,MAAMnC,UAAUgC,QAAQ/B,KAAK,EAAEC;IACrC,IAAI,CAAC,AAAC,CAAA,MAAMiC,IAAIE,GAAG,EAAC,EAAGvB,MAAM,EAAE;IAC/B,MAAMqB,IAAIK,GAAG,CACX;QACEd,gBAAgB1C,mBAAmBgD,QAAQsB,MAAM,KAAK;QACtD3B,eAAeW,KAAKC,GAAG;IACzB,GACA;QAAEE,OAAO;IAAK;AAElB;AAUA,yEAAyE,GACzE,OAAO,eAAec,iBACpBtD,KAAa,EACbuD,SAAiB;IAEjB,MAAMtD,SAASpB,uBAAuB0E;IACtC,IAAI,CAACvD,SAAS,CAACC,QAAQ,OAAO;IAC9B,MAAMgC,SAASvB,wBAAwB,MAAMX,UAAUC,OAAOC,QAAQmC,GAAG;IACzE,IAAI,CAACH,QAAQ,OAAO;IACpB,OAAO;QAAEA;QAAQuB,SAASvE,kBAAkBgD;QAASwB,OAAO;IAAK;AACnE;AAEA,OAAO,eAAeC,mBACpB1D,KAAa;IAEb,IAAI,CAACA,OAAO,OAAO,EAAE;IACrB,MAAMW,WAAW,MAAMf,YACpBM,UAAU,CAAC,QACXC,GAAG,CAACH,OACJE,UAAU,CAACJ,4BACXsC,GAAG;IACN,OAAOzB,SAASgD,IAAI,CAACC,GAAG,CAAClD,yBAAyBmD,MAAM,CAACC;AAC3D;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeC,gBACpBR,SAAiB;IAEjB,MAAMtD,SAASpB,uBAAuB0E;IACtC,IAAI,CAACtD,QAAQ,OAAO;IACpB,MAAM+D,SAAS,MAAMtE,UAAU,CAAC,OAAO,EAAEO,QAAQ;IACjD,4EAA4E;IAC5E,0DAA0D;IAC1D,IAAI,CAAC+D,OAAOC,QAAQ,EAAE,OAAO;IAC7B,OAAOzF,YAAYwF,OAAOR,OAAO;AACnC;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeU,yBAAyBnC,OAK9C;;IACC,MAAM/B,QAAQM,eAAOyB,2BAAAA,QAAS/B,KAAK,mBAAI,IAAI2C,IAAI;IAC/C,MAAM1C,SAASpB,uBAAuBkD,2BAAAA,QAAS9B,MAAM;IACrD,IAAI,CAACD,SAAS,CAACC,UAAU8B,QAAQoC,QAAQ,IAAI,GAAG,OAAO;IAEvD,MAAMjC,MAAMnC,UAAUC,OAAOC;IAC7B,MAAMU,WAAW,MAAMuB,IAAIE,GAAG,GAAGgC,KAAK,CAAC,IAAM;IAC7C,IAAI,EAACzD,4BAAAA,SAAUE,MAAM,GAAE,OAAO;IAE9B,MAAM+B,OAAOtB,OAAOX,SAASyB,GAAG,CAAC,gCAAgC;IACjE,IAAIQ,OAAO,GAAG,OAAOA;IAErB,MAAMyB,QAAQtC,QAAQuC,KAAK,GAAGvC,QAAQoC,QAAQ;IAC9C,MAAMjC,IACHK,GAAG,CAAC;QAAEgC,0BAA0BF;IAAM,GAAG;QAAE7B,OAAO;IAAK,EACxD,qEAAqE;IACrE,oEAAoE;IACpE,+CAA+C;KAC9C4B,KAAK,CAAC,IAAM;IACf,OAAOC;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,eAAeG,oBACpBjB,SAAiB,EACjBkB,SAAiB;IAEjB,MAAMxE,SAASpB,uBAAuB0E;IACtC,MAAMmB,KAAKpE,OAAOmE,oBAAAA,YAAa,IAAI9B,IAAI,GAAGpC,WAAW;IACrD,IAAI,CAACN,UAAU,CAACyE,IAAI,OAAO;IAE3B;;;;GAIC,GACD,MAAMC,SAASzF,oBAAoBe,QAAQ2E,KAAK,CAAC;IACjD,IAAK,IAAIC,QAAQ,GAAGA,QAAQF,OAAOG,MAAM,GAAG,GAAGD,SAAS,EAAG;QACzD,MAAME,OAAOJ,OAAOlE,KAAK,CAACoE,OAAOG,IAAI,CAAC;QACtC,MAAMhB,SAAS,MAAMxE,UAAUuF;QAC/B,IAAI,CAACf,OAAOC,QAAQ,EAAE,OAAO;QAC7B,IAAI,CAACD,OAAOR,OAAO,CAACsB,MAAM,EAAE;QAC5B,+DAA+D;QAC/D,MAAMG,UAAUjB,OAAOR,OAAO,CAC3BK,MAAM,CAAC,CAACb,QAAU,WAAWkC,IAAI,CAAClC,QAClCY,GAAG,CAAC,CAACZ,QAAUA,MAAMzC,WAAW;QACnC,OAAO0E,QAAQlC,IAAI,CAAC,CAACC,QAAUA,MAAMmC,QAAQ,CAAC,CAAC,CAAC,EAAET,GAAG,CAAC,CAAC,KACnD,cACA;IACN;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeU,oBACpBnD,MAA2B;;IAE3B,MAAMhC,SAASpB,+BAAuBoD,0BAAAA,OAAQhC,MAAM,mBAAI;IACxD,MAAMoF,WAAW,GAAGrG,kBAAkB,CAAC,EAAEiB,QAAQ;IACjD,MAAMqF,WAAW,GAAGrD,OAAOjB,YAAY,CAAC,YAAY,EAAEf,QAAQ;IAC9D,MAAM,CAACsF,KAAKC,MAAMC,GAAG,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACxCjG,UAAU2F;QACV3F,UAAU4F;QACV7F,SAAS4F;KACV;IAED,OAAO5G,qBAAqBwD,QAAQ;QAClC2D,QAAQL,IAAI/B,OAAO;QACnBqC,SAASL,KAAKhC,OAAO;QACrBiC,IAAIA,GAAGjC,OAAO;QACd,yEAAyE;QACzE,uEAAuE;QACvE,uBAAuB;QACvBsC,YAAYP,IAAItB,QAAQ,IAAIuB,KAAKvB,QAAQ,IAAIwB,GAAGxB,QAAQ;IAC1D;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAe8B,oBACpB/F,KAAa,EACbuD,SAAiB;IAOjB,MAAMtD,SAASpB,uBAAuB0E;IACtC,IAAI,CAACvD,SAAS,CAACC,QAAQ;QACrB,OAAO;YAAEgC,QAAQ;YAAM+D,SAAS,EAAE;YAAEC,cAAc;YAAOjE,OAAO;QAAiB;IACnF;IAEA,MAAME,MAAMnC,UAAUC,OAAOC;IAC7B,MAAMgC,SAASvB,wBAAwB,MAAMwB,IAAIE,GAAG;IACpD,IAAI,CAACH,QAAQ;QACX,OAAO;YACLA,QAAQ;YACR+D,SAAS,EAAE;YACXC,cAAc;YACdjE,OAAO;QACT;IACF;IAEA,MAAMkE,UAAU,MAAMd,oBAAoBnD;IAE1C,MAAMK,MAAMD,KAAKC,GAAG;IACpB,IAAI4D,QAAQnF,MAAM,KAAK,gBAAgB;QACrC,MAAMmB,IAAIK,GAAG,CAAC;YAAEf,iBAAiBc;QAAI,GAAG;YAAEE,OAAO;QAAK;QACtD,OAAO;YAAEP;YAAQ+D,SAAS,EAAE;YAAEC,cAAc;YAAMjE,OAAO;QAAK;IAChE;IAEA,MAAMmE,WAAWD,QAAQnF,MAAM,KAAK;IACpC,MAAMmB,IAAIK,GAAG,CACX;QACExB,QAAQoF,WAAW,aAAa;QAChC3E,iBAAiBc;OACb6D,WACA;QACE5E,cAAce;QACdX,aAAapC,cAAcK,SAAS,CAACsD,UAAU,CAACC,MAAM;IACxD,IACA;QAAExB,aAAauE,QAAQF,OAAO;IAAC,IAErC;QAAExD,OAAO;IAAK;IAGhB,OAAO;QACLP,QAAQvB,wBAAwB,MAAMwB,IAAIE,GAAG;QAC7C4D,SAASE,QAAQF,OAAO;QACxBC,cAAc;QACdjE,OAAO;IACT;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeoE,qBACpBpG,KAAa,EACbuD,SAAiB;IAEjB,MAAMtD,SAASpB,uBAAuB0E;IACtC,IAAI,CAACvD,SAAS,CAACC,QAAQ;IACvB,MAAMF,UAAUC,OAAOC,QACpBkD,MAAM,GACNiB,KAAK,CAAC,IAAMiC;AACjB;AAEA;;0CAE0C,GAE1C;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,eAAeC,2BAA2BvE,OA8BhD;QAEGA,oBA6CkCA,4BACrBE;IA/Cf,MAAMsE,gBACJxE,gBAAAA,2BAAAA,QAASwE,YAAY,oBAAIC,QAAQC,GAAG,CAACC,gBAAgB,YAArD3E,OAAyD;IAC3D,MAAM9B,SAASpB,gCAAuBkD,2BAAAA,QAAS4E,cAAc,oBAAI;IACjE,MAAM1D,UAAUlB,2BAAAA,QAASkB,OAAO;IAChC,MAAM2D,aAAaC,eAAe9E,2BAAAA,QAAS+E,MAAM,EAAE/E,2BAAAA,QAASgF,UAAU;IAEtE,IAAI,CAAC9G,UAAU,EAAC8B,2BAAAA,QAAS/B,KAAK,GAAE;QAC9B,OAAOlB,uBAAuB;YAC5BkI,WAAW;YACXT;YACAK;YACA3D;YACAgE,UAAU;QACZ;IACF;IAEA,MAAMhF,SAASvB,wBAAwB,MAAMX,UAAUgC,QAAQ/B,KAAK,EAAEC,QAAQmC,GAAG;IAEjF;;;;;;;;;GASC,GACD,MAAM8E,iBAAiBxI,wBAAwBuB;IAE/C;;;;;;;;;;;GAWC,GACD,MAAM+G,YAAoC/E,SACtC;QACEhC,QAAQgC,OAAOhC,MAAM;QACrBc,QAAQkB,OAAOlB,MAAM;QACrBoG,WAAWvI,oBAAmBmD,6BAAAA,QAAQqF,iBAAiB,YAAzBrF,6BAA6B,OAAO;QAClEiE,OAAO,GAAE/D,sBAAAA,OAAON,WAAW,YAAlBM,sBAAsB,EAAE;QACjCiF;IACF,IACA;QAAEjH;QAAQc,QAAQ;QAAUoG,WAAW;QAAInB,SAAS,EAAE;QAAEkB;IAAe;IAE3E;;;;;;;;;GASC,GACD,OAAOpI,uBAAuB;QAC5BkI;QACAT;QACAK;QACA3D;QACAgE,UAAU;IACZ;AACF;AAEA;;;;;;;;;CASC,GACD,SAASJ,eACPC,MAAiC,EACjCC,UAAqC;IAErC,MAAMjG,KAAKR,OAAOwG,iBAAAA,SAAU,IAAInE,IAAI;IACpC,IAAI,CAAC7B,IAAI,OAAO;IAEhB,MAAMuG,SAASxI,uBAAuByB,OAAOyG,qBAAAA,aAAc;IAC3D,IAAIM,UAAU1I,sBAAsB0I,SAAS;QAC3C,MAAMC,SAASjI,wBAAwByB;QACvC,MAAMyG,KAAKD,OAAOE,WAAW,CAAC;QAC9B,OAAOD,KAAK,IAAI,GAAGD,OAAO7G,KAAK,CAAC,GAAG8G,IAAI,CAAC,EAAEF,QAAQ,GAAG;IACvD;IAEA,OAAOhI,wBAAwByB;AACjC;AAEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,eAAe2G,oBACpBX,MAAiC,EACjCY,KAA2C;;IAE3C,MAAM5G,KAAKR,OAAOwG,iBAAAA,SAAU,IAAInE,IAAI;IACpC,IAAI,CAAC7B,IAAI;QACP;;;;;;;;KAQC,GACD,OAAOhC,uBAAuB;YAAEkI,WAAW;YAAMC,UAAU;QAAS;IACtE;IAEA,MAAMU,MAAMD,yBAAAA,MAAOtF,GAAG,CAACtB;IACvB,IAAI6G,KAAK,OAAOA;IAEhB,MAAMhH,WAAW,MAAMf,YACpBM,UAAU,CAAC,SACXC,GAAG,CAACW,IACJsB,GAAG,GACHgC,KAAK,CAAC,IAAM;IACf,MAAMwD,QAAQ,MAAMjI,cAAcmB,IAAIsD,KAAK,CAAC,IAAM;IAElD,MAAM8B,UAAU,MAAMI,2BAA2B;QAC/CtG,KAAK,UAAE4H,yBAAAA,MAAO5H,KAAK,mBAAI;QACvB2G,cAAc,WAAEhG,4BAAAA,SAAUyB,GAAG,CAAC,oCAAoB;QAClDgF,iBAAiB,WAAEzG,4BAAAA,SAAUyB,GAAG,CAAC,uCAAuB;QACxD0E,QAAQhG;QACRiG,UAAU,WAAEpG,4BAAAA,SAAUyB,GAAG,CAAC,wCAAwB;IACpD;IAEAsF,yBAAAA,MAAOnF,GAAG,CAACzB,IAAIoF;IACf,OAAOA;AACT;AAEA,yDAAyD,GACzD,OAAO,SAAS2B,0BACd5F,MAA2B;;IAE3B,MAAM+D,UAAU,IAAI8B,YAAI7F,0BAAAA,OAAQN,WAAW,mBAAI,EAAE;IACjD,IAAI,CAACqE,QAAQ+B,IAAI,EAAE,OAAO,EAAE;IAC5B,OAAO9I,kBAAkBgD,QAAQ4B,MAAM,CAAC,CAACb,QACvCgD,QAAQgC,GAAG,CAAC5I,iBAAiB4D;AAEjC"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sending-domains.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 * CUSTOM SENDING DOMAINS — durable half.\n *\n * The record, the DNS lookups and the status transitions. The decisions all\n * live in `@aglyn/shared-util-email`'s `sending-domain.ts`, which is pure;\n * this module is the I/O around them, and it holds no policy of its own.\n *\n * ## Per-org record, per-host selection\n *\n * The record is at `orgs/{orgId}/sendingDomains/{domain}`, alongside\n * `orgs/{orgId}/ssoDomains/{domain}` and for the same reason: proving control\n * of a zone is a property of the ORG that proved it, and an agency running\n * four sites on `client.com` should publish the DKIM record once rather than\n * four times.\n *\n * Which identity a given site SENDS on is a separate per-host field,\n * `hosts/{hostId}.sendingDomain`. That split is what answers the two halves of\n * the question at once: per-org verification keeps the DNS chore down, and a\n * per-host selection is what an agency's client actually wants, because the\n * `From:` their recipients see belongs to the site, not to the agency.\n *\n * ## Reads are cheap on purpose\n *\n * `resolveHostSendingIdentity` is on the path of a campaign send, so it reads\n * the host document the caller already has and at most ONE org subcollection\n * document. It never scans, never lists, and never touches DNS — the standing\n * rule against unrequested reads on a hot path applies here more than most,\n * because a campaign resolves an identity once for thousands of messages.\n *\n * ## Why the provider call is not in here\n *\n * Issuing a DKIM key means creating a domain at the mail provider, which needs\n * a credential that can create things — a different one from the send-only\n * `RESEND_API_KEY`, which is exactly why `email-health.ts` can use the domains\n * endpoint as a read-only credential probe.\n *\n * That credential belongs to the console alone, so the driver that reads it\n * lives in the console app — `apps/console/utils/server/` — and not here.\n * **This library is imported by the tenant runtime**, which\n * serves untrusted published sites; a module it can import is a module whose\n * environment read is one bug away from being reachable from a site request.\n * A file in `apps/console` is not importable from the tenant app at all —\n * there is no path mapping to it and nx's module boundaries forbid app→app —\n * so the isolation is structural rather than a convention.\n *\n * What stays here is the seam: {@link recordIssuedSendingDomain} takes what a\n * provider returned, and {@link recordSendingDomainIssueFailure} takes what it\n * refused. `requestSendingDomain` still stops at `requested`, and a domain\n * stopped there refuses sends, which is the correct behavior for a domain that\n * has no signing key.\n */\n\nimport {\n assessDmarc,\n assessSendingRecords,\n isPlatformSendingDomain,\n isSharedSendingDomain,\n normalizeLocalPart,\n normalizeSendingDomain,\n resolveSendingIdentity,\n safeProviderDetail,\n SENDING_SUBDOMAIN,\n sendingDnsRecords,\n sendingTrackingHost,\n sendingDomainRequiredRecords,\n sendingRecordKey,\n sharedTenantSendingFrom,\n validateSendingDomain,\n type DmarcAssessment,\n type SendingDnsRecord,\n type SendingDomainRecord,\n type SendingDomainSelection,\n type SendingIdentityPurpose,\n type SendingIdentityVerdict,\n type SendingVerification,\n} from '@aglyn/shared-util-email'\nimport { lockdownMode } from '@aglyn/aglyn/server'\nimport firebaseAdmin from './firebase-admin'\nimport { lookupCaa, lookupMx, lookupTxt } from './dns-probe'\nimport { getOrgForHost } from './organizations'\nimport { getSiteLockdown } from './tenant-write-lockdown'\n// Also INSTALLS the outbound phishing screen's review store on `sendEmail`\n// (AGL-3356): every sender whose identity comes from `hostSendingIdentity`\n// has therefore installed the gate the workspace stamped below is read by.\nimport { sendingWorkspaceFor } from './outbound-send-review'\nimport { flagLookalikeDomain } from './hosted-page-review'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Subcollection under the owning org. */\nexport const SENDING_DOMAINS_COLLECTION = 'sendingDomains'\n\nconst domainRef = (orgId: string, domain: string) =>\n firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .doc(domain)\n\n/**\n * The DKIM selector issued to one org for one domain.\n *\n * Includes the org id so two orgs verifying the same name occupy different\n * record names. Without that they would share `resend._domainkey.<domain>`,\n * and whichever verified second would overwrite the first — or, worse, inherit\n * the first's verification without ever publishing anything.\n */\nexport function sendingDkimSelector(orgId: string): string {\n const safe = String(orgId ?? '')\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '')\n .slice(0, 24)\n return `aglyn${safe ? `-${safe}` : ''}`\n}\n\n/** Both keys always present, one always null — `strictNullChecks` is off. */\nexport interface SendingDomainResult {\n record: SendingDomainRecord | null\n error: string | null\n /** The HTTP status a route should answer with. */\n status: number\n}\n\n/**\n * One stored document as a record.\n *\n * Exported so the re-check sweep, which reaches these documents through a\n * collection-group query rather than by org and name, shapes them the same way\n * every other reader here does — a second reader with its own defaults is how\n * two callers come to disagree about what an absent field means.\n */\nexport function readSendingDomainRecord(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): SendingDomainRecord | null {\n if (!snapshot?.exists) return null\n const data = snapshot.data() ?? {}\n return {\n domain: String(data.domain ?? snapshot.id),\n status: data.status ?? 'requested',\n dkimSelector: String(data.dkimSelector ?? ''),\n dkimPublicKey: data.dkimPublicKey ?? null,\n returnPathHost: data.returnPathHost ?? null,\n providerDomainId: data.providerDomainId ?? null,\n trackingTarget: data.trackingTarget ?? null,\n createdAtMs: Number(data.createdAtMs) || null,\n verifiedAtMs: Number(data.verifiedAtMs) || null,\n lastCheckedAtMs: Number(data.lastCheckedAtMs) || null,\n lastIssueError: data.lastIssueError ?? null,\n lastIssueAtMs: Number(data.lastIssueAtMs) || null,\n lastMissing: Array.isArray(data.lastMissing) ? data.lastMissing : null,\n }\n}\n\n/**\n * Start (or re-read) a claim on a sending domain.\n *\n * Idempotent, and deliberately non-destructive on a re-request: an existing\n * record keeps its selector and its key, because reissuing either would\n * invalidate a record the customer may already have published and turn a\n * working setup into a mysterious failure. This mirrors `issueDomainClaim`.\n */\nexport async function requestSendingDomain(options: {\n orgId: string\n domain: string\n}): Promise<SendingDomainResult> {\n const { domain, error } = validateSendingDomain(options?.domain)\n if (!domain) return { record: null, error, status: 400 }\n if (!options?.orgId) {\n return { record: null, error: 'Missing organization', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const existing = await ref.get()\n if (existing.exists) {\n return { record: readSendingDomainRecord(existing), error: null, status: 200 }\n }\n\n const record: SendingDomainRecord = {\n domain,\n status: 'requested',\n dkimSelector: sendingDkimSelector(options.orgId),\n dkimPublicKey: null,\n returnPathHost: null,\n createdAtMs: Date.now(),\n }\n await ref.set(record, { merge: true })\n // A domain that wears a brand (`paypa1.com`) goes to staff in the abuse\n // queue the moment it is claimed — flagged, not refused (AGL-3362). Mail\n // from it is held at the send seam whatever staff decide. Never throws.\n await flagLookalikeDomain({ kind: 'sending', hostId: null, orgId: options.orgId, domain })\n return { record, error: null, status: 201 }\n}\n\n/**\n * Record the key the mail provider issued, moving the domain to\n * `records-issued` so the customer has something to publish.\n *\n * Separated from {@link requestSendingDomain} because it needs a credential\n * that may not exist. Nothing here calls the provider: the caller supplies\n * what it was given, so a deployment whose key cannot create domains still has\n * a working path — an operator can complete this step by hand — and this\n * module never grows a dependency on a credential it cannot assume. The\n * console holds the credential and the driver that uses it; this library,\n * which the tenant runtime also imports, holds neither.\n *\n * ## `records-issued` is a promise that records exist\n *\n * The write is refused unless the resulting record actually yields a DKIM\n * record with a value. The alternative is the failure this whole feature is\n * arranged against: a status saying the customer has records to publish, next\n * to a records table with an empty DKIM row, which cannot ever verify and\n * reads to the customer as our bug.\n *\n * ## An issued key is never overwritten\n *\n * A second call carrying a DIFFERENT key is refused rather than applied. The\n * customer may already have published the first one, and replacing it turns a\n * finished setup into a domain that silently stops signing. Re-recording the\n * SAME key is a no-op and succeeds, so a retried request is safe.\n *\n * The SELECTOR, by contrast, comes from the provider when it supplies one:\n * the record a customer publishes has to be the name the provider will sign\n * under, and `sendingDkimSelector` only proposes it.\n */\nexport async function recordIssuedSendingDomain(options: {\n orgId: string\n domain: string\n dkimPublicKey: string\n /** The selector the provider issued, when it chose its own. */\n dkimSelector?: string\n returnPathHost?: string\n providerDomainId?: string\n /**\n * The provider's tracking host, when it issued one — see\n * `SendingDomainRecord.trackingTarget`. Absent leaves the domain sending\n * and unmeasured rather than unsent.\n */\n trackingTarget?: string | null\n}): Promise<SendingDomainResult> {\n const domain = normalizeSendingDomain(options?.domain)\n const key = String(options?.dkimPublicKey ?? '').trim()\n if (!domain) {\n return { record: null, error: 'Missing domain', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { record: null, error: 'No claim on that domain', status: 404 }\n }\n\n const existing = readSendingDomainRecord(snapshot)\n const held = String(existing?.dkimPublicKey ?? '').trim()\n if (held && held !== key) {\n return {\n record: existing,\n error:\n 'That domain already has an issued signing key. Release the domain ' +\n 'and add it again to start over.',\n status: 409,\n }\n }\n\n const selector =\n String(options?.dkimSelector ?? '').trim() || existing?.dkimSelector || ''\n const returnPathHost = options.returnPathHost || existing?.returnPathHost\n\n /*\n * The only thing standing between a caller and `records-issued`, and it is\n * asked of the SAME function that prints the records for the customer and\n * that the verifier compares against — not a truthiness check on the key,\n * which would be this write's private opinion of what \"publishable\" means\n * and could agree with nothing else.\n */\n const issued = sendingDomainRequiredRecords({\n domain,\n dkimSelector: selector,\n dkimPublicKey: key,\n returnPathHost,\n })\n if (!issued.some((entry) => entry.purpose === 'dkim')) {\n return {\n record: existing,\n error: 'Refusing to issue records for a domain with no publishable DKIM record',\n status: 400,\n }\n }\n\n await ref.set(\n {\n status: 'records-issued',\n dkimPublicKey: key,\n ...(selector ? { dkimSelector: selector } : {}),\n ...(options.returnPathHost\n ? { returnPathHost: options.returnPathHost }\n : {}),\n ...(options.providerDomainId\n ? { providerDomainId: String(options.providerDomainId) }\n : {}),\n // Only when the provider issued one. Writing an empty value would put a\n // tracking CNAME with no target in front of a customer, which is the\n // blank-record shape `sendingDnsRecords` refuses everywhere else.\n ...(String(options.trackingTarget ?? '').trim()\n ? { trackingTarget: String(options.trackingTarget).trim() }\n : {}),\n // A previous failure is not part of the record once it succeeded.\n lastIssueError: firebaseAdmin.firestore.FieldValue.delete(),\n },\n { merge: true },\n )\n return { record: readSendingDomainRecord(await ref.get()), error: null, status: 200 }\n}\n\n/**\n * Record that the provider did not issue anything, WITHOUT moving the status.\n *\n * The point of a separate function is that there is no path from a provider\n * failure to `records-issued`. A `4xx` or `5xx` means no key exists, so the\n * domain stays `requested` — where it refuses sends, which is correct for a\n * domain that cannot sign — and carries a reason an admin can act on instead\n * of appearing to have finished.\n *\n * `detail` is a short code the caller built from a fixed vocabulary, never a\n * provider's response body. {@link safeProviderDetail} is the second line:\n * an `Authorization` header echoed back by a provider must not become a\n * Firestore document.\n */\nexport async function recordSendingDomainIssueFailure(options: {\n orgId: string\n domain: string\n detail: string\n}): Promise<void> {\n const domain = normalizeSendingDomain(options?.domain)\n if (!options?.orgId || !domain) return\n const ref = domainRef(options.orgId, domain)\n if (!(await ref.get()).exists) return\n await ref.set(\n {\n lastIssueError: safeProviderDetail(options.detail) || 'unknown',\n lastIssueAtMs: Date.now(),\n },\n { merge: true },\n )\n}\n\nexport interface SendingDomainView {\n record: SendingDomainRecord\n /** Exactly what the customer must publish. */\n records: SendingDnsRecord[]\n /** Their DMARC policy, read and never written. */\n dmarc: DmarcAssessment | null\n}\n\n/** One domain plus the records it needs, for a surface that shows both. */\nexport async function getSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<SendingDomainView | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return null\n const record = readSendingDomainRecord(await domainRef(orgId, domain).get())\n if (!record) return null\n return { record, records: sendingDnsRecords(record), dmarc: null }\n}\n\nexport async function listSendingDomains(\n orgId: string,\n): Promise<SendingDomainRecord[]> {\n if (!orgId) return []\n const snapshot = await firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .get()\n return snapshot.docs.map(readSendingDomainRecord).filter(Boolean)\n}\n\n/**\n * Read the customer's DMARC policy.\n *\n * Advisory in both directions: it never blocks verification and it is never\n * written. A domain under `p=reject` with our DKIM unpublished hard-fails\n * every message, and a customer deserves to be told that before they wonder\n * why their campaign vanished.\n */\nexport async function readDmarcPolicy(\n rawDomain: string,\n): Promise<DmarcAssessment | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!domain) return null\n const lookup = await lookupTxt(`_dmarc.${domain}`)\n // An unreachable lookup is not \"they have no policy\" — saying so would tell\n // a customer under p=reject that they have no protection.\n if (!lookup.answered) return null\n return assessDmarc(lookup.records)\n}\n\n/**\n * Start (or read) the hold that keeps a tracked domain's links alive.\n *\n * Stamped on FIRST sighting rather than computed from an orphan timestamp,\n * because there is no reliable one: a label reassignment leaves no record of\n * when it happened, and the reaper re-infers the orphan on every pass. The\n * stamp is the record of when the hold began, so a domain cannot be held\n * forever by a sweep that keeps starting the clock over.\n *\n * @returns the instant the domain may be released, or `null` when there is\n * nothing to stamp it on — a domain with no org record cannot be\n * held, and refusing to release it on the strength of a hold nobody\n * can see would strand a provider slot permanently.\n */\nexport async function holdTrackedSendingDomain(options: {\n orgId: string | null\n domain: string\n nowMs: number\n windowMs: number\n}): Promise<number | null> {\n const orgId = String(options?.orgId ?? '').trim()\n const domain = normalizeSendingDomain(options?.domain)\n if (!orgId || !domain || options.windowMs <= 0) return null\n\n const ref = domainRef(orgId, domain)\n const snapshot = await ref.get().catch(() => null)\n if (!snapshot?.exists) return null\n\n const held = Number(snapshot.get('trackingRetentionUntilMs')) || 0\n if (held > 0) return held\n\n const until = options.nowMs + options.windowMs\n await ref\n .set({ trackingRetentionUntilMs: until }, { merge: true })\n // A stamp we could not write is a hold we cannot prove later, so the\n // caller is told there is none rather than being asked to wait on a\n // deadline that will restart on the next pass.\n .catch(() => null)\n return until\n}\n\n/**\n * Whether this domain publishes a CAA policy that would REFUSE the tracking\n * host's certificate.\n *\n * The question decides whether a customer is shown a CAA record at all, and\n * asking it is the difference between guidance and a footgun. CAA restricts\n * which authorities may issue, and the lookup stops at the first name in the\n * tree publishing any record — so:\n *\n * - A domain publishing NOTHING needs nothing. Any authority may already\n * issue, and handing over a CAA record to paste would be the change that\n * STARTS restricting them, breaking whatever else renews on that name.\n * - A domain that publishes some, none of which name our authority, has to\n * add one ALONGSIDE what it has.\n * - A domain that already names our authority is done.\n *\n * Walks up from the tracking host exactly as a certificate authority does,\n * and stops where a real lookup stops: at the first name with any record.\n *\n * @returns `null` when DNS could not be reached, which is not \"they publish\n * none\" — telling a customer to add a CAA on the strength of an\n * unanswered lookup is how a working zone gets narrowed by mistake.\n */\nexport async function readTrackingCaaNeed(\n rawDomain: string,\n authority: string,\n): Promise<'not-needed' | 'must-add' | 'satisfied' | null> {\n const domain = normalizeSendingDomain(rawDomain)\n const ca = String(authority ?? '').trim().toLowerCase()\n if (!domain || !ca) return null\n\n /*\n * From the tracking host upward. Every label is asked because a CAA on a\n * closer name overrides a broader one — a customer who already scoped a\n * policy to `links.` must not be told to widen their root.\n */\n const labels = sendingTrackingHost(domain).split('.')\n for (let index = 0; index < labels.length - 1; index += 1) {\n const name = labels.slice(index).join('.')\n const lookup = await lookupCaa(name)\n if (!lookup.answered) return null\n if (!lookup.records.length) continue\n // The first name with any record is where issuance is decided.\n const issuers = lookup.records\n .filter((entry) => / issue \"/.test(entry))\n .map((entry) => entry.toLowerCase())\n return issuers.some((entry) => entry.includes(`\"${ca}\"`))\n ? 'satisfied'\n : 'must-add'\n }\n return 'not-needed'\n}\n\n/**\n * Read the live DNS for one record and say what it establishes. Writes\n * nothing.\n *\n * Split out so the two callers that need this answer ask the SAME question of\n * the SAME resolvers: {@link verifySendingDomain}, which acts on it\n * immediately, and the unattended re-check sweep, which counts conclusive\n * failures before it acts. A sweep that assessed the records its own way would\n * be a second opinion on \"are these published\", and the customer would meet\n * whichever one happened to run last.\n *\n * The three lookups run together because they are independent and the caller\n * is waiting on the slowest either way.\n */\nexport async function probeSendingRecords(\n record: SendingDomainRecord,\n): Promise<SendingVerification> {\n const domain = normalizeSendingDomain(record?.domain ?? '')\n const sendHost = `${SENDING_SUBDOMAIN}.${domain}`\n const dkimHost = `${record.dkimSelector}._domainkey.${domain}`\n const [spf, dkim, mx] = await Promise.all([\n lookupTxt(sendHost),\n lookupTxt(dkimHost),\n lookupMx(sendHost),\n ])\n\n return assessSendingRecords(record, {\n spfTxt: spf.records,\n dkimTxt: dkim.records,\n mx: mx.records,\n // All three, not any: a record we could not read is not a record that is\n // absent, and one unreadable lookup is enough to make the whole answer\n // evidence of nothing.\n conclusive: spf.answered && dkim.answered && mx.answered,\n })\n}\n\n/**\n * Check the live DNS and move the record to `verified` or `failed`.\n *\n * Never throws: a sweep across every org must not stop at the first bad zone.\n *\n * An INCONCLUSIVE result writes nothing but the check time. A resolver outage\n * must not un-verify a working domain — which would silently stop that\n * tenant's mail — and must not fail a customer who is midway through\n * publishing either. This is the `hold` arm the SSO drift sweep uses, and the\n * reason `assessSendingRecords` has three outcomes rather than two.\n *\n * Acts on ONE conclusive answer, and that is right for this caller: an admin\n * pressed Verify and is watching the result, so the check is attended and its\n * outcome is immediately visible and immediately retryable. The unattended\n * sweep over already-verified domains is the caller that cannot say that, and\n * `sending-domain-recheck.ts` is where the extra evidence it needs is\n * gathered.\n */\nexport async function verifySendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<{\n record: SendingDomainRecord | null\n missing: string[]\n inconclusive: boolean\n error: string | null\n}> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) {\n return { record: null, missing: [], inconclusive: false, error: 'Invalid domain' }\n }\n\n const ref = domainRef(orgId, domain)\n const record = readSendingDomainRecord(await ref.get())\n if (!record) {\n return {\n record: null,\n missing: [],\n inconclusive: false,\n error: 'No claim on that domain — add it first.',\n }\n }\n\n const verdict = await probeSendingRecords(record)\n\n const now = Date.now()\n if (verdict.status === 'inconclusive') {\n await ref.set({ lastCheckedAtMs: now }, { merge: true })\n return { record, missing: [], inconclusive: true, error: null }\n }\n\n const verified = verdict.status === 'verified'\n await ref.set(\n {\n status: verified ? 'verified' : 'failed',\n lastCheckedAtMs: now,\n ...(verified\n ? {\n verifiedAtMs: now,\n lastMissing: firebaseAdmin.firestore.FieldValue.delete(),\n }\n : { lastMissing: verdict.missing }),\n },\n { merge: true },\n )\n\n // Again on verification, the moment it can actually send (AGL-3362): a\n // claim added before this check existed is caught here. One row per\n // domain, so this counts rather than re-alerts.\n if (verified) {\n await flagLookalikeDomain({ kind: 'sending', hostId: null, orgId, domain })\n }\n\n return {\n record: readSendingDomainRecord(await ref.get()),\n missing: verdict.missing,\n inconclusive: false,\n error: null,\n }\n}\n\n/**\n * Drop a claim.\n *\n * The host selections pointing at it are NOT rewritten to the platform\n * identity. A site that was sending as its own domain and now has no verified\n * one must refuse, not quietly revert — silently moving a tenant's mail back\n * onto the shared domain is the exact fallback this feature exists to prevent,\n * and doing it during a delete would be no better than doing it during a send.\n */\nexport async function releaseSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<void> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return\n await domainRef(orgId, domain)\n .delete()\n .catch(() => undefined)\n}\n\n/*==========================================\n The read on the send path\n==========================================*/\n\n/**\n * The identity one host sends on, ready to hand to `sendEmail`.\n *\n * Two document reads at most, and none at all for a host that has no\n * selection. The host document is passed in rather than re-fetched because\n * every caller already holds it.\n *\n * ## This function cannot return an `aglyn.com` address, and that is the point\n *\n * Every caller here is resolving mail FOR A SITE — that is what \"host\" in the\n * name means — so every verdict it produces is tenant mail by construction.\n * `audience: 'tenant'` is therefore passed unconditionally and is not a\n * parameter: a caller that could choose would eventually choose wrong, and the\n * wrong choice puts a merchant's list quality onto the domain the platform's\n * own invoices and password resets leave on.\n *\n * A site that has selected NOTHING does not reach `USAGE_EMAIL_FROM` either.\n * All of its mail goes on the shared pool member `hostSharedFrom` assigns it,\n * marketing included. What keeps the pool usable is measurement rather than\n * prohibition: a pooled campaign is graded against the stricter reputation\n * thresholds, so the site earning complaints is the one that stops.\n */\nexport async function resolveHostSendingIdentity(options: {\n orgId: string | null | undefined\n /** `hosts/{hostId}.sendingDomain`, the site's selection. */\n selectedDomain: string | null | undefined\n /** `hosts/{hostId}.sendingLocalPart`, defaulted when unset. */\n selectedLocalPart?: string | null\n platformFrom?: string | null\n /**\n * The site, so an unselected host can be assigned a pool member.\n *\n * Optional because two of the three callers resolve an identity for a host\n * they are already holding and one — the console's identity view — is asking\n * about a specific site by id. A caller that omits it gets no shared\n * identity, which is the honest answer: the pool assignment is per site, and\n * \"some pool member\" is not a thing to send a receipt from.\n */\n hostId?: string | null\n /**\n * A pool member this host is pinned to, overriding the hash.\n *\n * `hosts/{hostId}.sendingPoolMember`. The QUARANTINE lever: a site whose\n * transactional mail is generating complaints can be moved onto a member set\n * aside for it, without disturbing any of the other sites that hash to the\n * member it was on. Ignored unless it names a real member of the current\n * pool, so a stale or hand-edited value degrades to the ordinary assignment\n * rather than to an address nothing signs for.\n */\n poolMember?: string | null\n /** See `SendingIdentityPurpose`. Defaults to transactional. */\n purpose?: SendingIdentityPurpose\n}): Promise<SendingIdentityVerdict> {\n const platformFrom =\n options?.platformFrom ?? process.env.USAGE_EMAIL_FROM ?? null\n const domain = normalizeSendingDomain(options?.selectedDomain ?? '')\n const purpose = options?.purpose\n const sharedFrom = hostSharedFrom(options?.hostId, options?.poolMember)\n\n if (!domain || !options?.orgId) {\n return resolveSendingIdentity({\n selection: null,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n }\n\n const record = readSendingDomainRecord(await domainRef(options.orgId, domain).get())\n\n /*\n * Whose domain this is, decided from the NAME rather than from the record.\n *\n * The record is what a released domain no longer has, and a site pointed at\n * a subdomain of our own apex is still pointed at a subdomain of our own\n * apex whether or not one exists for it. Deriving this from the name keeps\n * the two possible failures apart: a missing record for OUR name is our\n * provisioning that did not finish, a missing record for THEIRS is a domain\n * they asked to send as and nothing has verified.\n */\n const platformIssued = isPlatformSendingDomain(domain)\n\n /*\n * A selection naming a CUSTOMER'S domain with no record refuses rather than\n * falling back. The record can be gone because it was released, or because\n * the selection was written against a different org — both mean the site is\n * configured to send as a domain nothing has verified, and neither is a\n * reason to send as somebody else.\n *\n * `platformIssued` rides on the selection either way, and the resolver reads\n * it only on the unverified branch: a platform subdomain that has not\n * finished provisioning drops to the pool for transactional mail instead,\n * because nothing about it is a merchant instruction to contradict.\n */\n const selection: SendingDomainSelection = record\n ? {\n domain: record.domain,\n status: record.status,\n localPart: normalizeLocalPart(options.selectedLocalPart ?? '') || 'hello',\n missing: record.lastMissing ?? [],\n platformIssued,\n }\n : { domain, status: 'failed', localPart: '', missing: [], platformIssued }\n\n /*\n * `sharedFrom` is passed even here, where a selection exists and will decide\n * the outcome. It is inert by construction — the selection branch of\n * `resolveSendingIdentity` never reads it — and passing it unconditionally\n * keeps the call shape identical between the two arms, so a future edit\n * cannot produce a path that had a pool available and did not offer it.\n *\n * It must NOT become a fallback for an unverified selection, and it is the\n * resolver's structure rather than this call site that guarantees that.\n */\n return resolveSendingIdentity({\n selection,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n}\n\n/**\n * The pool address for one host: the pinned member if it names a real one, the\n * hash assignment otherwise.\n *\n * The override is validated against the LIVE pool rather than trusted, because\n * a stale value is the expected case — an operator shrinks the pool, or a\n * quarantine was lifted by deleting the member rather than the field — and an\n * address on a domain that no longer exists is a send that fails at the\n * provider instead of one that quietly goes back to normal.\n */\nfunction hostSharedFrom(\n hostId: string | null | undefined,\n poolMember: string | null | undefined,\n): string {\n const id = String(hostId ?? '').trim()\n if (!id) return ''\n\n const pinned = normalizeSendingDomain(String(poolMember ?? ''))\n if (pinned && isSharedSendingDomain(pinned)) {\n const shared = sharedTenantSendingFrom(id)\n const at = shared.lastIndexOf('@')\n return at > 0 ? `${shared.slice(0, at)}@${pinned}` : ''\n }\n\n return sharedTenantSendingFrom(id)\n}\n\n/**\n * The identity one site sends on, from a `hostId` alone.\n *\n * The door every tenant sender uses. {@link resolveHostSendingIdentity} needs\n * the org id and the host's two selection fields, and a survey of the tenant\n * send sites found that all of them hold a `hostId` while only three hold an\n * org id and half never read the host document at all. Asking nineteen call\n * sites to each assemble the same three values is the shape that produces a\n * twentieth which does not — and the cost of forgetting here is a message\n * leaving on the platform's own domain.\n *\n * ## The cache is per-call-graph, not per-process\n *\n * Passed a `cache`, repeated resolutions for one host cost one pair of reads.\n * A sweep that mails two hundred abandoned carts across a dozen sites, or a\n * webhook that sends a receipt and a seller notice for the same order, holds\n * one map for the run and pays for each site once.\n *\n * Deliberately NOT a module-level cache. Verification status is exactly the\n * thing that changes underneath a long-lived process — the re-check sweep\n * un-verifies a domain whose records have gone — and a stale entry would keep\n * a site sending on a domain that no longer authenticates. A caller that\n * declares a cache has also declared how long it may be trusted.\n */\nexport async function hostSendingIdentity(\n hostId: string | null | undefined,\n cache?: Map<string, SendingIdentityVerdict>,\n): Promise<SendingIdentityVerdict> {\n const id = String(hostId ?? '').trim()\n if (!id) {\n /*\n * No host is not \"the platform is speaking\", and it is not \"any pool\n * member will do\" either. It is a caller that does not know which site it\n * is sending for, and the honest answer to that is a refusal.\n *\n * No `sharedFrom` is passed, which is what makes that structural rather\n * than a rule: the pool is assigned FROM the host id, so a caller without\n * one has nothing to be assigned.\n */\n return resolveSendingIdentity({ selection: null, audience: 'tenant' })\n }\n\n const hit = cache?.get(id)\n if (hit) return hit\n\n const snapshot = await firestore()\n .collection('hosts')\n .doc(id)\n .get()\n .catch(() => null)\n const owner = await getOrgForHost(id).catch(() => null)\n\n /*\n * A SUSPENDED SITE SENDS NOTHING (AGL-3356).\n *\n * This is the door every tenant sender but the campaign core uses, so it\n * is where one check covers them all — workflow and automation steps, the\n * CRM's one-off mail, inbox replies, member posts, receipts, reminders.\n * Refusing here rather than at each caller is the same argument this\n * function's own docblock makes about identities: nineteen call sites\n * asked to remember produce a twentieth that does not, and the incident\n * behind this was exactly that — a locked workspace whose workflow could\n * still mail for it.\n *\n * FULL locks only. A read-only lock is a maintenance window whose point\n * is that the site keeps serving and earning, and a receipt for an order\n * taken before it began must still arrive. Staff suspensions for abuse,\n * fraud and billing are full locks, which is the case this closes.\n *\n * The documents read above are handed to the verdict, so the check costs\n * the TTL-cached platform read and nothing else. It inherits the\n * verdict's fail-open posture and its takedown ratchet.\n */\n const lock = await getSiteLockdown(id, Date.now(), {\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n host: snapshot?.exists\n ? ((snapshot.data() as Record<string, unknown>) ?? null)\n : null,\n })\n if (lock && lockdownMode(lock) === 'full') {\n const suspended = suspendedSendingIdentity(lock.scope)\n cache?.set(id, suspended)\n return suspended\n }\n\n const resolved = await resolveHostSendingIdentity({\n orgId: owner?.orgId ?? null,\n selectedDomain: snapshot?.get('sendingDomain') ?? '',\n selectedLocalPart: snapshot?.get('sendingLocalPart') ?? '',\n hostId: id,\n poolMember: snapshot?.get('sendingPoolMember') ?? '',\n })\n\n /*\n * THE WORKSPACE, STAMPED FOR THE PHISHING SCREEN (AGL-3356).\n *\n * `sendEmail` screens every tenant message whose identity carries this,\n * so stamping it here is what puts CRM mail, inbox replies, newsletters,\n * member posts, sweeps, flow steps and receipts behind one screen without\n * any of them passing anything. Built from the two documents read above:\n * the screen costs no read of its own.\n */\n const verdict: SendingIdentityVerdict = {\n ...resolved,\n workspace: sendingWorkspaceFor({\n hostId: id,\n orgId: owner?.orgId ?? null,\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n host: snapshot?.exists\n ? ((snapshot.data() as Record<string, unknown>) ?? null)\n : null,\n }),\n }\n\n cache?.set(id, verdict)\n return verdict\n}\n\n/**\n * The verdict a suspended site's mail gets: a refusal, whatever identity the\n * site would otherwise have sent on. `sendEmail` reads it and answers\n * `suspended` without calling the provider.\n */\nexport function suspendedSendingIdentity(\n scope: string | null | undefined,\n): SendingIdentityVerdict {\n const what =\n scope === 'platform'\n ? 'Sending is paused platform-wide'\n : scope === 'org'\n ? 'This workspace is suspended'\n : 'This site is suspended'\n const message = `${what}, so no email is sent on its behalf until the suspension is lifted.`\n return {\n from: null,\n source: null,\n domain: null,\n summary: message,\n refusal: {\n code: 'workspace-suspended',\n domain: null,\n message,\n missing: [],\n },\n }\n}\n\n/** The record keys a surface highlights as outstanding. */\nexport function outstandingSendingRecords(\n record: SendingDomainRecord,\n): SendingDnsRecord[] {\n const missing = new Set(record?.lastMissing ?? [])\n if (!missing.size) return []\n return sendingDnsRecords(record).filter((entry) =>\n missing.has(sendingRecordKey(entry)),\n )\n}\n"],"names":["assessDmarc","assessSendingRecords","isPlatformSendingDomain","isSharedSendingDomain","normalizeLocalPart","normalizeSendingDomain","resolveSendingIdentity","safeProviderDetail","SENDING_SUBDOMAIN","sendingDnsRecords","sendingTrackingHost","sendingDomainRequiredRecords","sendingRecordKey","sharedTenantSendingFrom","validateSendingDomain","lockdownMode","firebaseAdmin","lookupCaa","lookupMx","lookupTxt","getOrgForHost","getSiteLockdown","sendingWorkspaceFor","flagLookalikeDomain","firestore","app","SENDING_DOMAINS_COLLECTION","domainRef","orgId","domain","collection","doc","sendingDkimSelector","safe","String","toLowerCase","replace","slice","readSendingDomainRecord","snapshot","data","exists","id","status","dkimSelector","dkimPublicKey","returnPathHost","providerDomainId","trackingTarget","createdAtMs","Number","verifiedAtMs","lastCheckedAtMs","lastIssueError","lastIssueAtMs","lastMissing","Array","isArray","requestSendingDomain","options","error","record","ref","existing","get","Date","now","set","merge","kind","hostId","recordIssuedSendingDomain","key","trim","held","selector","issued","some","entry","purpose","FieldValue","delete","recordSendingDomainIssueFailure","detail","getSendingDomain","rawDomain","records","dmarc","listSendingDomains","docs","map","filter","Boolean","readDmarcPolicy","lookup","answered","holdTrackedSendingDomain","windowMs","catch","until","nowMs","trackingRetentionUntilMs","readTrackingCaaNeed","authority","ca","labels","split","index","length","name","join","issuers","test","includes","probeSendingRecords","sendHost","dkimHost","spf","dkim","mx","Promise","all","spfTxt","dkimTxt","conclusive","verifySendingDomain","missing","inconclusive","verdict","verified","releaseSendingDomain","undefined","resolveHostSendingIdentity","platformFrom","process","env","USAGE_EMAIL_FROM","selectedDomain","sharedFrom","hostSharedFrom","poolMember","selection","audience","platformIssued","localPart","selectedLocalPart","pinned","shared","at","lastIndexOf","hostSendingIdentity","cache","hit","owner","lock","org","host","suspended","suspendedSendingIdentity","scope","resolved","workspace","what","message","from","source","summary","refusal","code","outstandingSendingRecords","Set","size","has"],"mappings":"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"}
@@ -31,7 +31,8 @@ import { type SendEmailResult } from '@aglyn/shared-util-email';
31
31
  * Platform email metering is loaded lazily and only after a send, so that
32
32
  * importing this module — which every unit test of the meter does — never
33
33
  * pulls the Firestore-backed meter, and with it the admin app, into a test
34
- * that only wanted to count credits.
34
+ * that only wanted to count credits. The system-email renderer, which reads
35
+ * its template through the same admin app, is loaded the same way.
35
36
  */
36
37
  export declare function sendStaffAlertEmail(input: {
37
38
  subject: string;
@@ -1,3 +1,4 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -30,7 +31,8 @@
30
31
  * Platform email metering is loaded lazily and only after a send, so that
31
32
  * importing this module — which every unit test of the meter does — never
32
33
  * pulls the Firestore-backed meter, and with it the admin app, into a test
33
- * that only wanted to count credits.
34
+ * that only wanted to count credits. The system-email renderer, which reads
35
+ * its template through the same admin app, is loaded the same way.
34
36
  */ export async function sendStaffAlertEmail(input) {
35
37
  var _process_env_STAFF_ALERT_EMAIL;
36
38
  const to = String((_process_env_STAFF_ALERT_EMAIL = process.env.STAFF_ALERT_EMAIL) != null ? _process_env_STAFF_ALERT_EMAIL : '').trim();
@@ -39,12 +41,21 @@
39
41
  reason: 'unconfigured'
40
42
  };
41
43
  try {
42
- const result = await sendEmail({
43
- to,
44
+ // In the platform's header and footer, as the `staff-alert` system email
45
+ // (AGL-3367). Loaded lazily for the reason the meter below is.
46
+ const { renderSystemEmailContent, systemEmailBrand } = await import("./render-system-email.js");
47
+ const content = await renderSystemEmailContent('staff-alert', {
48
+ 'alert.subject': input.subject,
49
+ 'alert.body': input.text
50
+ }, systemEmailBrand(null), {
44
51
  subject: input.subject,
45
- text: input.text,
46
- context: input.context
52
+ text: input.text
47
53
  });
54
+ const result = await sendEmail(_extends({
55
+ to
56
+ }, content, {
57
+ context: input.context
58
+ }));
48
59
  if (result.sent) {
49
60
  // Platform-scoped (AGL-1438): our own alert is our own cost.
50
61
  const { meterPlatformEmail } = await import("./email-metering.js");
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * A staff alert by email, from LIBRARY code (AGL-2925).\n *\n * `STAFF_ALERT_EMAIL` is the one inbox every subsystem's alert lands in —\n * the console's `emailStaffAlert` in `apps/console/app/api/_lib/\n * usage-alert-email.ts` sends the cron's alerts there. A library cannot\n * call that, because a library cannot import from an app. So a library's\n * own alert — the AI meter's platform-wide free-spend ceiling is one —\n * sends through this, to the same address, with the same posture: unset is\n * the ordinary answer outside production and reports `unconfigured`, and\n * nothing here throws, because the mail is a courtesy and the refusal it\n * announces is the control.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits.\n */\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n const to = String(process.env.STAFF_ALERT_EMAIL ?? '').trim()\n if (!to.includes('@')) return { sent: false, reason: 'unconfigured' }\n try {\n const result = await sendEmail({\n to,\n subject: input.subject,\n text: input.text,\n context: input.context,\n })\n if (result.sent) {\n // Platform-scoped (AGL-1438): our own alert is our own cost.\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail().catch(() => undefined)\n }\n return result\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","sendStaffAlertEmail","input","process","to","String","env","STAFF_ALERT_EMAIL","trim","includes","sent","reason","result","subject","text","context","meterPlatformEmail","catch","undefined","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,SAAS,QAA8B,2BAA0B;AAE1E;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeC,oBAAoBC,KAKzC;QACmBC;IAAlB,MAAMC,KAAKC,QAAOF,iCAAAA,QAAQG,GAAG,CAACC,iBAAiB,YAA7BJ,iCAAiC,IAAIK,IAAI;IAC3D,IAAI,CAACJ,GAAGK,QAAQ,CAAC,MAAM,OAAO;QAAEC,MAAM;QAAOC,QAAQ;IAAe;IACpE,IAAI;QACF,MAAMC,SAAS,MAAMZ,UAAU;YAC7BI;YACAS,SAASX,MAAMW,OAAO;YACtBC,MAAMZ,MAAMY,IAAI;YAChBC,SAASb,MAAMa,OAAO;QACxB;QACA,IAAIH,OAAOF,IAAI,EAAE;YACf,6DAA6D;YAC7D,MAAM,EAAEM,kBAAkB,EAAE,GAAG,MAAM,MAAM,CAAC;YAC5C,MAAMA,qBAAqBC,KAAK,CAAC,IAAMC;QACzC;QACA,OAAON;IACT,EAAE,OAAOO,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCA;QACjD,OAAO;YAAET,MAAM;YAAOC,QAAQ;QAAU;IAC1C;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * A staff alert by email, from LIBRARY code (AGL-2925).\n *\n * `STAFF_ALERT_EMAIL` is the one inbox every subsystem's alert lands in —\n * the console's `emailStaffAlert` in `apps/console/app/api/_lib/\n * usage-alert-email.ts` sends the cron's alerts there. A library cannot\n * call that, because a library cannot import from an app. So a library's\n * own alert — the AI meter's platform-wide free-spend ceiling is one —\n * sends through this, to the same address, with the same posture: unset is\n * the ordinary answer outside production and reports `unconfigured`, and\n * nothing here throws, because the mail is a courtesy and the refusal it\n * announces is the control.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer, which reads\n * its template through the same admin app, is loaded the same way.\n */\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n const to = String(process.env.STAFF_ALERT_EMAIL ?? '').trim()\n if (!to.includes('@')) return { sent: false, reason: 'unconfigured' }\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter below is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n const result = await sendEmail({ to, ...content, context: input.context })\n if (result.sent) {\n // Platform-scoped (AGL-1438): our own alert is our own cost.\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail().catch(() => undefined)\n }\n return result\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","sendStaffAlertEmail","input","process","to","String","env","STAFF_ALERT_EMAIL","trim","includes","sent","reason","renderSystemEmailContent","systemEmailBrand","content","subject","text","result","context","meterPlatformEmail","catch","undefined","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,SAAS,QAA8B,2BAA0B;AAE1E;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,eAAeC,oBAAoBC,KAKzC;QACmBC;IAAlB,MAAMC,KAAKC,QAAOF,iCAAAA,QAAQG,GAAG,CAACC,iBAAiB,YAA7BJ,iCAAiC,IAAIK,IAAI;IAC3D,IAAI,CAACJ,GAAGK,QAAQ,CAAC,MAAM,OAAO;QAAEC,MAAM;QAAOC,QAAQ;IAAe;IACpE,IAAI;QACF,yEAAyE;QACzE,+DAA+D;QAC/D,MAAM,EAAEC,wBAAwB,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CACjE;QAEF,MAAMC,UAAU,MAAMF,yBACpB,eACA;YAAE,iBAAiBV,MAAMa,OAAO;YAAE,cAAcb,MAAMc,IAAI;QAAC,GAC3DH,iBAAiB,OACjB;YAAEE,SAASb,MAAMa,OAAO;YAAEC,MAAMd,MAAMc,IAAI;QAAC;QAE7C,MAAMC,SAAS,MAAMjB,UAAU;YAAEI;WAAOU;YAASI,SAAShB,MAAMgB,OAAO;;QACvE,IAAID,OAAOP,IAAI,EAAE;YACf,6DAA6D;YAC7D,MAAM,EAAES,kBAAkB,EAAE,GAAG,MAAM,MAAM,CAAC;YAC5C,MAAMA,qBAAqBC,KAAK,CAAC,IAAMC;QACzC;QACA,OAAOJ;IACT,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCA;QACjD,OAAO;YAAEZ,MAAM;YAAOC,QAAQ;QAAU;IAC1C;AACF"}
@@ -0,0 +1,35 @@
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
+ /** What the card is being collected for. */
18
+ export type CardAuthenticationUse = 'one-time' | 'off-session';
19
+ /** The value Stripe's `request_three_d_secure` takes. */
20
+ export type RequestThreeDSecure = 'automatic' | 'any';
21
+ /** The param name, identical on PaymentIntents, SetupIntents and Checkout Sessions. */
22
+ export declare const REQUEST_THREE_D_SECURE_PARAM = "payment_method_options[card][request_three_d_secure]";
23
+ /** `automatic` for a one-time payment, `any` for a card kept for later. */
24
+ export declare function requestThreeDSecureFor(use: CardAuthenticationUse): RequestThreeDSecure;
25
+ /**
26
+ * The form-encoded params to spread into a Stripe request body, beside the
27
+ * request's own fields in the `URLSearchParams` it builds.
28
+ */
29
+ export declare function cardAuthenticationParams(use: CardAuthenticationUse): Record<string, string>;
30
+ /**
31
+ * The same params for a Checkout Session, chosen by its `mode`: a
32
+ * `subscription` or `setup` session keeps the card for off-session charges,
33
+ * a `payment` session does not.
34
+ */
35
+ export declare function checkoutSessionCardAuthenticationParams(mode: 'payment' | 'subscription' | 'setup'): Record<string, string>;