@aglyn/shared-util-email 1.0.0-beta.218 → 1.0.0-beta.219

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 (42) hide show
  1. package/README.md +58 -19
  2. package/package.json +3 -3
  3. package/src/index.d.ts +2 -0
  4. package/src/index.js +9 -1
  5. package/src/index.js.map +1 -1
  6. package/src/lib/email-delivery-events.d.ts +41 -48
  7. package/src/lib/email-delivery-events.js +18 -247
  8. package/src/lib/email-delivery-events.js.map +1 -1
  9. package/src/lib/email-health.d.ts +30 -43
  10. package/src/lib/email-health.js +45 -141
  11. package/src/lib/email-health.js.map +1 -1
  12. package/src/lib/mail-provider-resend.d.ts +194 -0
  13. package/src/lib/mail-provider-resend.js +649 -0
  14. package/src/lib/mail-provider-resend.js.map +1 -0
  15. package/src/lib/mail-provider-webhook.d.ts +80 -0
  16. package/src/lib/mail-provider-webhook.js +156 -0
  17. package/src/lib/mail-provider-webhook.js.map +1 -0
  18. package/src/lib/mail-provider.d.ts +148 -0
  19. package/src/lib/mail-provider.js +84 -0
  20. package/src/lib/mail-provider.js.map +1 -0
  21. package/src/lib/mail-providers.d.ts +99 -0
  22. package/src/lib/mail-providers.js +182 -0
  23. package/src/lib/mail-providers.js.map +1 -0
  24. package/src/lib/received-email.d.ts +7 -44
  25. package/src/lib/received-email.js +8 -104
  26. package/src/lib/received-email.js.map +1 -1
  27. package/src/lib/send-ceilings.d.ts +0 -57
  28. package/src/lib/send-ceilings.js +0 -47
  29. package/src/lib/send-ceilings.js.map +1 -1
  30. package/src/lib/send-email.d.ts +29 -58
  31. package/src/lib/send-email.js +98 -131
  32. package/src/lib/send-email.js.map +1 -1
  33. package/src/lib/send-rate.js +4 -3
  34. package/src/lib/send-rate.js.map +1 -1
  35. package/src/lib/system-email-catalog.js +3 -3
  36. package/src/lib/system-email-catalog.js.map +1 -1
  37. package/src/lib/tenant-email-catalog.d.ts +7 -0
  38. package/src/lib/tenant-email-catalog.js +10 -591
  39. package/src/lib/tenant-email-catalog.js.map +1 -1
  40. package/src/lib/tenant-emails.generated.d.ts +11 -0
  41. package/src/lib/tenant-emails.generated.js +594 -0
  42. package/src/lib/tenant-emails.generated.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-delivery-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT HAPPENED TO A MESSAGE, IN OUR OWN VOCABULARY.\n *\n * ## The seam\n *\n * A staff answer to \"did they get the invite, and did they open it?\" must not\n * be a question about Resend. Two things follow from that, and this module is\n * both of them:\n *\n * 1. **The stored shape is ours.** Nothing downstream — the delivery log, the\n * staff card, a future export — reads a provider's field names or its event\n * strings. Swapping the sender changes exactly one function in this file\n * and nothing else in the tree.\n * 2. **The history is ours.** The log is written into our own Firestore and\n * read from there, never from the provider on render. A provider's list\n * endpoint is a different shape per vendor, has its own retention window,\n * and disappears entirely with the account; a record we keep survives the\n * migration that the seam exists to make possible.\n *\n * That is a rule about the READ PATH, not a rule against ever reading the\n * provider. The event feed only knows mail sent after it was connected, so\n * a log fed by events alone is empty for all existing history — which is\n * precisely the mail a support question is about. The second half of this\n * module (see THE READ SIDE OF THE SEAM below) imports that history\n * through the same neutral vocabulary, once, into the same store.\n *\n * ## Pure on purpose\n *\n * No Firestore and no admin SDK. `system-email-catalog` is imported by console\n * CLIENT components through this library's barrel, so anything reachable from\n * it that touched `firebase-admin` would drag the admin SDK into a browser\n * bundle. Normalisation is a pure function of a payload; the writing lives in\n * `@aglyn/tenant-data-admin/server/email-delivery-log`.\n *\n * The one `fetch` is `resendDeliveryHistorySource`, which is a function that\n * must be CALLED with a key to do anything — it holds no module state and is\n * unreachable from a client component, unlike an admin-SDK import, which\n * executes on load.\n */\n\nimport { readBounceText } from './mail-bounce'\n\n/**\n * The lifecycle of one message, in the order it normally happens.\n *\n * Chosen to be the intersection every ESP can report rather than the union of\n * what any one of them does: a vendor with no equivalent for a state simply\n * never produces it, and a vendor with a richer taxonomy folds into the\n * nearest of these rather than widening the type. `delayed` is retryable and\n * `failed` is not, which is the distinction a staffer actually needs.\n */\nexport type EmailDeliveryEventType =\n | 'sent'\n | 'delivered'\n | 'delayed'\n | 'opened'\n | 'clicked'\n | 'bounced'\n | 'complained'\n | 'failed'\n\n/** Lifecycle states ordered worst-last, for {@link worstDeliveryStatus}. */\nconst STATUS_SEVERITY: Record<EmailDeliveryEventType, number> = {\n sent: 1,\n delivered: 2,\n opened: 3,\n clicked: 4,\n delayed: 5,\n complained: 6,\n bounced: 7,\n failed: 8,\n}\n\n/**\n * One normalized delivery event.\n *\n * `at` is epoch milliseconds rather than a Firestore timestamp so this type\n * stays usable in a browser, in a test, and in whatever writes it next.\n */\nexport interface EmailDeliveryEvent {\n type: EmailDeliveryEventType\n /** When the PROVIDER says it happened, falling back to receipt time. */\n at: number\n /** Slug of the sending provider, e.g. `'resend'`. */\n provider: string\n /** The provider's id for the message. Our per-message document key. */\n providerMessageId: string\n /** Recipient, lowercased. One address per record even on a multi-recipient send. */\n to: string\n subject: string | null\n /**\n * The sender label `sendEmail` stamps on every message (`'invite'`,\n * `'password-reset'`, `'campaign'`, …). This is what makes the staff view\n * legible: without it a row says only that *an* email was sent.\n */\n context: string | null\n /** Everything else the send was tagged with, e.g. `hostId`, `campaignId`. */\n tags: Record<string, string>\n /** `clicked` only: the destination the recipient followed. */\n link: string | null\n /**\n * `bounced` only: whether the mailbox is gone (`permanent`) or the failure\n * was temporary. Lowercased, because providers disagree on capitalisation\n * and a staff filter must not depend on which one is in use.\n */\n bounceType: 'permanent' | 'transient' | 'undetermined' | null\n /** Provider-supplied explanation, for the states that carry one. */\n detail: string | null\n /**\n * The bare sender address, lowercased: the domain it names is the sending\n * domain a gateway's verdict is filed against (AGL-3328). Absent from\n * events normalized before it existed, and `null` when the provider sent\n * none.\n */\n from?: string | null\n /**\n * `bounced` only: the enhanced status code (`5.7.1`) and the receiving\n * server the provider's bounce message names, read by `readBounceText` —\n * what a Gmail DSN hands over as `Status` and `Remote-MTA` (AGL-3328).\n */\n bounceStatus?: string | null\n remoteMta?: string | null\n}\n\n/** The bare address of a `From:` value (`\"Acme\" <hi@acme.com>` → `hi@acme.com`), lowercased. */\nexport function bareSenderAddress(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n const angle = raw.match(/<([^>]+)>/)\n const address = (angle ? angle[1] : raw).trim().toLowerCase()\n return /^[^\\s@<>]+@[^\\s@<>]+\\.[^\\s@<>]+$/.test(address) ? address : null\n}\n\n/** The later of two statuses on the lifecycle, worst winning a tie. */\nexport function worstDeliveryStatus(\n current: EmailDeliveryEventType | null | undefined,\n next: EmailDeliveryEventType,\n): EmailDeliveryEventType {\n if (!current) return next\n return STATUS_SEVERITY[next] >= STATUS_SEVERITY[current] ? next : current\n}\n\n/** Tags arrive as an array of `{name, value}` or a plain map — accept both. */\nexport function normalizeEventTags(raw: unknown): Record<string, string> {\n if (Array.isArray(raw)) {\n const map: Record<string, string> = {}\n for (const tag of raw) {\n if (tag?.name) map[String(tag.name)] = String(tag.value ?? '')\n }\n return map\n }\n if (raw && typeof raw === 'object') {\n return Object.fromEntries(\n Object.entries(raw as Record<string, unknown>).map(([key, value]) => [\n key,\n String(value ?? ''),\n ]),\n )\n }\n return {}\n}\n\n/** Epoch ms from an ISO string or a number, or `null` when unreadable. */\nfunction eventTimeMs(value: unknown): number | null {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n const parsed = Date.parse(String(value ?? ''))\n return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction normalizeBounceType(\n value: unknown,\n): EmailDeliveryEvent['bounceType'] {\n const lowered = String(value ?? '')\n .trim()\n .toLowerCase()\n if (lowered === 'permanent') return 'permanent'\n if (lowered === 'transient') return 'transient'\n return lowered ? 'undetermined' : null\n}\n\n/** The Resend event names we understand, mapped onto ours. */\nconst RESEND_EVENT_TYPES: Record<string, EmailDeliveryEventType> = {\n 'email.sent': 'sent',\n 'email.delivered': 'delivered',\n 'email.delivery_delayed': 'delayed',\n 'email.opened': 'opened',\n 'email.clicked': 'clicked',\n 'email.bounced': 'bounced',\n 'email.complained': 'complained',\n 'email.failed': 'failed',\n}\n\n/**\n * Turns one Resend webhook payload into zero or more of our events — **the\n * only function in the tree that knows Resend's wire format.**\n *\n * One event per recipient, not per message: a send addressed to three people\n * produces one webhook, and a staff view keyed on a person has to be able to\n * find it under each of them.\n *\n * Returns an empty array for anything unrecognised — a contact or domain\n * event, an inbound `email.received`, a type added after this was written.\n * Silence rather than a throw, because a webhook handler that 500s on an\n * unfamiliar event teaches the provider to retry it forever.\n *\n * @param payload The parsed webhook body.\n * @param receivedAtMs Fallback timestamp for a payload that carries none.\n */\nexport function normalizeResendDeliveryEvents(\n payload: unknown,\n receivedAtMs: number,\n): EmailDeliveryEvent[] {\n const event = (payload ?? {}) as Record<string, any>\n const type = RESEND_EVENT_TYPES[String(event.type ?? '')]\n if (!type) return []\n\n const data = (event.data ?? {}) as Record<string, any>\n const providerMessageId = String(data.email_id ?? data.id ?? '').trim()\n if (!providerMessageId) return []\n\n const recipients = (Array.isArray(data.to) ? data.to : [data.to])\n .map((address: unknown) => String(address ?? '').trim().toLowerCase())\n .filter((address: string) => address.includes('@'))\n if (!recipients.length) return []\n\n // The per-state timestamp when the provider gives one, because an open\n // three days after the send is the whole point of recording an open.\n const at =\n eventTimeMs(data.click?.timestamp) ??\n eventTimeMs(data.open?.timestamp) ??\n eventTimeMs(event.created_at) ??\n eventTimeMs(data.created_at) ??\n receivedAtMs\n\n const subject = String(data.subject ?? '').trim() || null\n const tags = normalizeEventTags(data.tags)\n const from = bareSenderAddress(data.from)\n // The provider folds a DSN's fields into one sentence; the structured\n // ones are read first where a payload carries them.\n const bounceText =\n type === 'bounced'\n ? readBounceText(\n [data.bounce?.diagnosticCode, data.bounce?.diagnostic_code, data.bounce?.remoteMta, data.bounce?.remote_mta, data.bounce?.message]\n .filter((part: unknown) => typeof part === 'string' && part.trim())\n .join(' '),\n )\n : null\n\n return recipients.map((to: string) => ({\n type,\n at,\n provider: 'resend',\n providerMessageId,\n to,\n subject,\n context: tags['context'] || null,\n tags,\n link: type === 'clicked' ? String(data.click?.link ?? '') || null : null,\n bounceType: type === 'bounced' ? normalizeBounceType(data.bounce?.type) : null,\n detail:\n String(data.bounce?.message ?? data.failed?.reason ?? '').trim() || null,\n ...(from ? { from } : {}),\n ...(bounceText?.status ? { bounceStatus: bounceText.status } : {}),\n ...(bounceText?.remoteMta ? { remoteMta: bounceText.remoteMta } : {}),\n }))\n}\n\n/*==========================================\n * THE READ SIDE OF THE SEAM.\n *\n * The event feed above only ever knows about mail sent AFTER it was\n * connected. That is correct for the steady state and useless for the\n * question the staff card exists to answer, which is asked about mail that\n * has already gone out — so a delivery log fed only by events is empty\n * exactly when somebody needs it.\n *\n * A provider also holds the history, and reading it is not lock-in as long as\n * it happens through an interface. {@link EmailDeliverySnapshot} is that\n * interface: one message as the provider currently sees it, in our\n * vocabulary. `normalizeResendSentEmails` is the Resend implementation and\n * the second (and last) function in the tree that knows Resend's wire format.\n *\n * WHAT A SNAPSHOT DELIBERATELY DOES NOT CARRY\n *\n * Open and click COUNTS. Resend's list endpoint reports a single\n * `last_event` per message and no engagement detail, so a snapshot can say\n * \"this was opened at least once\" and can never say \"three times\". The\n * writer therefore treats a snapshot as a floor, never as truth that\n * overwrites what the event feed recorded — see `recordEmailDeliverySnapshot`.\n *=========================================*/\n\n/** One message as the provider currently reports it, in our vocabulary. */\nexport interface EmailDeliverySnapshot {\n provider: string\n providerMessageId: string\n to: string\n subject: string | null\n /** Epoch ms the provider says the message was created. */\n sentAt: number\n /** Furthest state the provider reports. Never richer than the event feed. */\n status: EmailDeliveryEventType\n}\n\n/**\n * A provider's `last_event` string, mapped onto our lifecycle.\n *\n * Deliberately the bare state names rather than the `email.*` event names:\n * the list endpoint reports `\"delivered\"`, the webhook reports\n * `\"email.delivered\"`, and they are two different vocabularies for one\n * concept. Both are accepted here so a provider that unifies them later\n * needs no change.\n */\nconst RESEND_LAST_EVENTS: Record<string, EmailDeliveryEventType> = {\n sent: 'sent',\n delivered: 'delivered',\n delivery_delayed: 'delayed',\n opened: 'opened',\n clicked: 'clicked',\n bounced: 'bounced',\n complained: 'complained',\n failed: 'failed',\n canceled: 'failed',\n queued: 'sent',\n scheduled: 'sent',\n}\n\n/**\n * Turns one entry from Resend's `GET /emails` list into zero or more\n * snapshots — one per recipient, for the same reason the event adapter fans\n * out: the staff view is keyed on a person.\n *\n * An unrecognised `last_event` falls back to `sent` rather than being\n * dropped. The message demonstrably exists and was addressed to somebody, and\n * \"we sent this and cannot characterise what happened next\" is a far more\n * useful row than no row — which is the state that sent a staffer to the\n * vendor dashboard in the first place.\n */\nexport function normalizeResendSentEmails(\n raw: unknown,\n): EmailDeliverySnapshot[] {\n const record = (raw ?? {}) as Record<string, any>\n const providerMessageId = String(record.id ?? '').trim()\n if (!providerMessageId) return []\n\n const recipients = (Array.isArray(record.to) ? record.to : [record.to])\n .map((address: unknown) => String(address ?? '').trim().toLowerCase())\n .filter((address: string) => address.includes('@'))\n if (!recipients.length) return []\n\n const parsed = Date.parse(String(record.created_at ?? ''))\n const sentAt = Number.isFinite(parsed) ? parsed : 0\n // A snapshot with no timestamp cannot be ordered, and the log's read drops\n // any document missing its sort key — so it is refused rather than written\n // somewhere nothing will look for it.\n if (!sentAt) return []\n\n const status =\n RESEND_LAST_EVENTS[\n String(record.last_event ?? '')\n .trim()\n .toLowerCase()\n ] ?? 'sent'\n\n return recipients.map((to: string) => ({\n provider: 'resend',\n providerMessageId,\n to,\n subject: String(record.subject ?? '').trim() || null,\n sentAt,\n status,\n }))\n}\n\n/** One page of provider history, in our vocabulary. */\nexport interface EmailDeliveryHistoryPage {\n snapshots: EmailDeliverySnapshot[]\n /** Cursor for the next page, or null at the end. */\n nextCursor: string | null\n}\n\n/**\n * Reads one page of already-sent mail from a provider.\n *\n * The shape a second provider would implement. Cursor-based rather than\n * offset- or date-based because that is the lowest common denominator, and\n * NOT filtered by recipient: Resend's list endpoint has no recipient\n * parameter, so filtering is the caller's job and the import is a sweep\n * rather than a per-person lookup. That is the right shape regardless — a\n * staff page must not fan out to a third party on render.\n */\nexport type EmailDeliveryHistorySource = (options: {\n cursor?: string | null\n limit?: number\n}) => Promise<EmailDeliveryHistoryPage>\n\n/** Resend's list endpoint. Paginates with `after=<id>`; caps at 100. */\nexport const RESEND_EMAILS_ENDPOINT = 'https://api.resend.com/emails'\n\n/**\n * {@link EmailDeliveryHistorySource} for Resend.\n *\n * Needs a FULL-ACCESS key: a sending-scoped key answers every read on this\n * endpoint with `401 restricted_api_key`, which is the correct posture for\n * the key that sends mail and the reason this takes its own.\n */\nexport function resendDeliveryHistorySource(\n apiKey: string,\n): EmailDeliveryHistorySource {\n return async ({ cursor, limit } = {}) => {\n const params = new URLSearchParams({\n limit: String(Math.min(Math.max(1, limit ?? 100), 100)),\n })\n if (cursor) params.set('after', cursor)\n const response = await fetch(`${RESEND_EMAILS_ENDPOINT}?${params}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(\n `email history read failed: HTTP ${response.status} ${detail.slice(0, 200)}`,\n )\n }\n const body = (await response.json()) as {\n data?: unknown[]\n has_more?: boolean\n }\n const entries = Array.isArray(body?.data) ? body.data : []\n const snapshots = entries.flatMap((entry) => normalizeResendSentEmails(entry))\n // The cursor is the LAST RAW entry's id, not the last snapshot's: a page\n // whose final entry fanned out to zero snapshots (no recipient, no\n // timestamp) would otherwise rewind the cursor to an earlier message and\n // loop over the same page forever.\n const lastId = String(\n (entries[entries.length - 1] as { id?: unknown })?.id ?? '',\n ).trim()\n return {\n snapshots,\n nextCursor: body?.has_more && lastId ? lastId : null,\n }\n }\n}\n\n/*==========================================\n * ONE MESSAGE, RENDERED.\n *\n * The log records what HAPPENED to a message; it does not keep the message.\n * Storing every body would put an unbounded copy of every email we have ever\n * sent — including reset links and receipts — into our own database, to\n * duplicate something the provider already holds.\n *\n * So a body is fetched when a staffer explicitly opens one row. That is a\n * deliberate single lookup, not the per-render fan-out the read path refuses:\n * one message, on one click, by id.\n *=========================================*/\n\n/** One message's content and envelope, in our vocabulary. */\nexport interface EmailDeliveryMessage {\n provider: string\n providerMessageId: string\n to: string[]\n cc: string[]\n bcc: string[]\n from: string | null\n replyTo: string[] | null\n subject: string | null\n /** The HTML part, or null when the message was sent as text only. */\n html: string | null\n /** The plain-text part, or null. */\n text: string | null\n sentAt: number | null\n status: EmailDeliveryEventType | null\n}\n\n/** A single message by id. The shape a second provider would implement. */\nexport type EmailDeliveryMessageSource = (\n providerMessageId: string,\n) => Promise<EmailDeliveryMessage | null>\n\nfunction addressList(raw: unknown): string[] {\n return (Array.isArray(raw) ? raw : raw == null ? [] : [raw])\n .map((address) => String(address ?? '').trim())\n .filter(Boolean)\n}\n\n/** Resend's `GET /emails/:id` payload, in our vocabulary. */\nexport function normalizeResendMessage(raw: unknown): EmailDeliveryMessage | null {\n const record = (raw ?? {}) as Record<string, any>\n const providerMessageId = String(record.id ?? '').trim()\n if (!providerMessageId) return null\n const parsed = Date.parse(String(record.created_at ?? ''))\n return {\n provider: 'resend',\n providerMessageId,\n to: addressList(record.to),\n cc: addressList(record.cc),\n bcc: addressList(record.bcc),\n from: String(record.from ?? '').trim() || null,\n replyTo: addressList(record.reply_to).length\n ? addressList(record.reply_to)\n : null,\n subject: String(record.subject ?? '').trim() || null,\n // Empty string is NOT null here, and the difference is the point: a\n // message that went out text-only really does have an empty HTML part,\n // and a reader has to be able to tell that from \"we could not fetch it\".\n html: typeof record.html === 'string' ? record.html : null,\n text: typeof record.text === 'string' ? record.text : null,\n sentAt: Number.isFinite(parsed) ? parsed : null,\n status:\n RESEND_LAST_EVENTS[\n String(record.last_event ?? '')\n .trim()\n .toLowerCase()\n ] ?? null,\n }\n}\n\n/** Resend's single-message endpoint. Needs the same full-access key. */\nexport function resendDeliveryMessageSource(\n apiKey: string,\n): EmailDeliveryMessageSource {\n return async (providerMessageId: string) => {\n const response = await fetch(\n `${RESEND_EMAILS_ENDPOINT}/${encodeURIComponent(providerMessageId)}`,\n { headers: { Authorization: `Bearer ${apiKey}` } },\n )\n // A message the provider has aged out is a 404, and that is an ANSWER —\n // \"we know this was sent and the body is gone\" — not a failure to report\n // as an error the staffer must act on.\n if (response.status === 404) return null\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(\n `message read failed: HTTP ${response.status} ${detail.slice(0, 200)}`,\n )\n }\n return normalizeResendMessage(await response.json())\n }\n}\n"],"names":["readBounceText","STATUS_SEVERITY","sent","delivered","opened","clicked","delayed","complained","bounced","failed","bareSenderAddress","value","raw","String","trim","angle","match","address","toLowerCase","test","worstDeliveryStatus","current","next","normalizeEventTags","Array","isArray","map","tag","name","Object","fromEntries","entries","key","eventTimeMs","Number","isFinite","parsed","Date","parse","normalizeBounceType","lowered","RESEND_EVENT_TYPES","normalizeResendDeliveryEvents","payload","receivedAtMs","event","data","type","providerMessageId","email_id","id","recipients","to","filter","includes","length","at","click","timestamp","open","created_at","subject","tags","from","bounceText","bounce","diagnosticCode","diagnostic_code","remoteMta","remote_mta","message","part","join","provider","context","link","bounceType","detail","reason","status","bounceStatus","RESEND_LAST_EVENTS","delivery_delayed","canceled","queued","scheduled","normalizeResendSentEmails","record","sentAt","last_event","RESEND_EMAILS_ENDPOINT","resendDeliveryHistorySource","apiKey","cursor","limit","params","URLSearchParams","Math","min","max","set","response","fetch","headers","Authorization","ok","text","catch","Error","slice","body","json","snapshots","flatMap","entry","lastId","nextCursor","has_more","addressList","Boolean","normalizeResendMessage","cc","bcc","replyTo","reply_to","html","resendDeliveryMessageSource","encodeURIComponent"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-delivery-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT HAPPENED TO A MESSAGE, IN OUR OWN VOCABULARY.\n *\n * ## The seam\n *\n * A staff answer to \"did they get the invite, and did they open it?\" must not\n * be a question about one sending vendor. Two things follow from that, and\n * this module is both of them:\n *\n * 1. **The stored shape is ours.** Nothing downstream — the delivery log, the\n * staff card, a future export — reads a provider's field names or its event\n * strings. A provider turns its own wire format into these types (its\n * `deliveryEvents` and `reads`, see `mail-provider.ts`), and swapping the\n * sender changes that provider and nothing else in the tree.\n * 2. **The history is ours.** The log is written into our own Firestore and\n * read from there, never from the provider on render. A provider's list\n * endpoint is a different shape per vendor, has its own retention window,\n * and disappears entirely with the account; a record we keep survives the\n * migration that the seam exists to make possible.\n *\n * That is a rule about the READ PATH, not a rule against ever reading the\n * provider. The event feed only knows mail sent after it was connected, so\n * a log fed by events alone is empty for all existing history — which is\n * precisely the mail a support question is about. The second half of this\n * module (see THE READ SIDE OF THE SEAM below) is the vocabulary that\n * history is imported through, once, into the same store.\n *\n * ## Pure on purpose\n *\n * No Firestore and no admin SDK. `system-email-catalog` is imported by console\n * CLIENT components through this library's barrel, so anything reachable from\n * it that touched `firebase-admin` would drag the admin SDK into a browser\n * bundle. Normalisation is a pure function of a payload; the writing lives in\n * `@aglyn/tenant-data-admin/server/email-delivery-log`, and the reading\n * of a provider's payloads in that provider.\n */\n\n/**\n * The lifecycle of one message, in the order it normally happens.\n *\n * Chosen to be the intersection every ESP can report rather than the union of\n * what any one of them does: a vendor with no equivalent for a state simply\n * never produces it, and a vendor with a richer taxonomy folds into the\n * nearest of these rather than widening the type. `delayed` is retryable and\n * `failed` is not, which is the distinction a staffer actually needs.\n */\nexport type EmailDeliveryEventType =\n | 'sent'\n | 'delivered'\n | 'delayed'\n | 'opened'\n | 'clicked'\n | 'bounced'\n | 'complained'\n | 'failed'\n\n/** Lifecycle states ordered worst-last, for {@link worstDeliveryStatus}. */\nconst STATUS_SEVERITY: Record<EmailDeliveryEventType, number> = {\n sent: 1,\n delivered: 2,\n opened: 3,\n clicked: 4,\n delayed: 5,\n complained: 6,\n bounced: 7,\n failed: 8,\n}\n\n/**\n * One normalized delivery event.\n *\n * `at` is epoch milliseconds rather than a Firestore timestamp so this type\n * stays usable in a browser, in a test, and in whatever writes it next.\n */\nexport interface EmailDeliveryEvent {\n type: EmailDeliveryEventType\n /** When the PROVIDER says it happened, falling back to receipt time. */\n at: number\n /** Slug of the sending provider — the `id` of the provider that carried it. */\n provider: string\n /** The provider's id for the message. Our per-message document key. */\n providerMessageId: string\n /** Recipient, lowercased. One address per record even on a multi-recipient send. */\n to: string\n subject: string | null\n /**\n * The sender label `sendEmail` stamps on every message (`'invite'`,\n * `'password-reset'`, `'campaign'`, …). This is what makes the staff view\n * legible: without it a row says only that *an* email was sent.\n */\n context: string | null\n /** Everything else the send was tagged with, e.g. `hostId`, `campaignId`. */\n tags: Record<string, string>\n /** `clicked` only: the destination the recipient followed. */\n link: string | null\n /**\n * `bounced` only: whether the mailbox is gone (`permanent`) or the failure\n * was temporary. Lowercased, because providers disagree on capitalisation\n * and a staff filter must not depend on which one is in use.\n */\n bounceType: 'permanent' | 'transient' | 'undetermined' | null\n /** Provider-supplied explanation, for the states that carry one. */\n detail: string | null\n /**\n * The bare sender address, lowercased: the domain it names is the sending\n * domain a gateway's verdict is filed against (AGL-3328). Absent from\n * events normalized before it existed, and `null` when the provider sent\n * none.\n */\n from?: string | null\n /**\n * `bounced` only: the enhanced status code (`5.7.1`) and the receiving\n * server the provider's bounce message names, read by `readBounceText` —\n * what a Gmail DSN hands over as `Status` and `Remote-MTA` (AGL-3328).\n */\n bounceStatus?: string | null\n remoteMta?: string | null\n}\n\n/** The bare address of a `From:` value (`\"Acme\" <hi@acme.com>` → `hi@acme.com`), lowercased. */\nexport function bareSenderAddress(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n const angle = raw.match(/<([^>]+)>/)\n const address = (angle ? angle[1] : raw).trim().toLowerCase()\n return /^[^\\s@<>]+@[^\\s@<>]+\\.[^\\s@<>]+$/.test(address) ? address : null\n}\n\n/** The later of two statuses on the lifecycle, worst winning a tie. */\nexport function worstDeliveryStatus(\n current: EmailDeliveryEventType | null | undefined,\n next: EmailDeliveryEventType,\n): EmailDeliveryEventType {\n if (!current) return next\n return STATUS_SEVERITY[next] >= STATUS_SEVERITY[current] ? next : current\n}\n\n/** Tags arrive as an array of `{name, value}` or a plain map — accept both. */\nexport function normalizeEventTags(raw: unknown): Record<string, string> {\n if (Array.isArray(raw)) {\n const map: Record<string, string> = {}\n for (const tag of raw) {\n if (tag?.name) map[String(tag.name)] = String(tag.value ?? '')\n }\n return map\n }\n if (raw && typeof raw === 'object') {\n return Object.fromEntries(\n Object.entries(raw as Record<string, unknown>).map(([key, value]) => [\n key,\n String(value ?? ''),\n ]),\n )\n }\n return {}\n}\n\n/*==========================================\n * THE READ SIDE OF THE SEAM.\n *\n * The event feed above only ever knows about mail sent AFTER it was\n * connected. That is correct for the steady state and useless for the\n * question the staff card exists to answer, which is asked about mail that\n * has already gone out — so a delivery log fed only by events is empty\n * exactly when somebody needs it.\n *\n * A provider also holds the history, and reading it is not lock-in as long as\n * it happens through an interface. {@link EmailDeliverySnapshot} is that\n * interface: one message as the provider currently sees it, in our\n * vocabulary, read through the provider's `reads.history`.\n *\n * WHAT A SNAPSHOT DELIBERATELY DOES NOT CARRY\n *\n * Open and click COUNTS. A provider's list typically reports one latest\n * state per message and no engagement detail, so a snapshot can say \"this\n * was opened at least once\" and can never say \"three times\". The writer\n * therefore treats a snapshot as a floor, never as truth that overwrites\n * what the event feed recorded — see `recordEmailDeliverySnapshot`.\n *=========================================*/\n\n/** One message as the provider currently reports it, in our vocabulary. */\nexport interface EmailDeliverySnapshot {\n provider: string\n providerMessageId: string\n to: string\n subject: string | null\n /** Epoch ms the provider says the message was created. */\n sentAt: number\n /** Furthest state the provider reports. Never richer than the event feed. */\n status: EmailDeliveryEventType\n}\n\n/** One page of provider history, in our vocabulary. */\nexport interface EmailDeliveryHistoryPage {\n snapshots: EmailDeliverySnapshot[]\n /** Cursor for the next page, or null at the end. */\n nextCursor: string | null\n}\n\n/**\n * Reads one page of already-sent mail from a provider.\n *\n * The shape a second provider would implement. Cursor-based rather than\n * offset- or date-based because that is the lowest common denominator, and\n * NOT filtered by recipient: a provider's list endpoint need not take one,\n * so filtering is the caller's job and the import is a sweep rather than a\n * per-person lookup. That is the right shape regardless — a staff page must\n * not fan out to a third party on render.\n */\nexport type EmailDeliveryHistorySource = (options: {\n cursor?: string | null\n limit?: number\n}) => Promise<EmailDeliveryHistoryPage>\n\n/*==========================================\n * ONE MESSAGE, RENDERED.\n *\n * The log records what HAPPENED to a message; it does not keep the message.\n * Storing every body would put an unbounded copy of every email we have ever\n * sent — including reset links and receipts — into our own database, to\n * duplicate something the provider already holds.\n *\n * So a body is fetched when a staffer explicitly opens one row. That is a\n * deliberate single lookup, not the per-render fan-out the read path refuses:\n * one message, on one click, by id.\n *=========================================*/\n\n/** One message's content and envelope, in our vocabulary. */\nexport interface EmailDeliveryMessage {\n provider: string\n providerMessageId: string\n to: string[]\n cc: string[]\n bcc: string[]\n from: string | null\n replyTo: string[] | null\n subject: string | null\n /** The HTML part, or null when the message was sent as text only. */\n html: string | null\n /** The plain-text part, or null. */\n text: string | null\n sentAt: number | null\n status: EmailDeliveryEventType | null\n}\n\n/** A single message by id. The shape a second provider would implement. */\nexport type EmailDeliveryMessageSource = (\n providerMessageId: string,\n) => Promise<EmailDeliveryMessage | null>\n"],"names":["STATUS_SEVERITY","sent","delivered","opened","clicked","delayed","complained","bounced","failed","bareSenderAddress","value","raw","String","trim","angle","match","address","toLowerCase","test","worstDeliveryStatus","current","next","normalizeEventTags","Array","isArray","map","tag","name","Object","fromEntries","entries","key"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GAED;;;;;;;;CAQC,GAWD,0EAA0E,GAC1E,MAAMA,kBAA0D;IAC9DC,MAAM;IACNC,WAAW;IACXC,QAAQ;IACRC,SAAS;IACTC,SAAS;IACTC,YAAY;IACZC,SAAS;IACTC,QAAQ;AACV;AAqDA,8FAA8F,GAC9F,OAAO,SAASC,kBAAkBC,KAAc;IAC9C,MAAMC,MAAMC,OAAOF,gBAAAA,QAAS,IAAIG,IAAI;IACpC,MAAMC,QAAQH,IAAII,KAAK,CAAC;IACxB,MAAMC,UAAU,AAACF,CAAAA,QAAQA,KAAK,CAAC,EAAE,GAAGH,GAAE,EAAGE,IAAI,GAAGI,WAAW;IAC3D,OAAO,mCAAmCC,IAAI,CAACF,WAAWA,UAAU;AACtE;AAEA,qEAAqE,GACrE,OAAO,SAASG,oBACdC,OAAkD,EAClDC,IAA4B;IAE5B,IAAI,CAACD,SAAS,OAAOC;IACrB,OAAOrB,eAAe,CAACqB,KAAK,IAAIrB,eAAe,CAACoB,QAAQ,GAAGC,OAAOD;AACpE;AAEA,6EAA6E,GAC7E,OAAO,SAASE,mBAAmBX,GAAY;IAC7C,IAAIY,MAAMC,OAAO,CAACb,MAAM;QACtB,MAAMc,MAA8B,CAAC;QACrC,KAAK,MAAMC,OAAOf,IAAK;gBACyBe;YAA9C,IAAIA,uBAAAA,IAAKC,IAAI,EAAEF,GAAG,CAACb,OAAOc,IAAIC,IAAI,EAAE,GAAGf,QAAOc,aAAAA,IAAIhB,KAAK,YAATgB,aAAa;QAC7D;QACA,OAAOD;IACT;IACA,IAAId,OAAO,OAAOA,QAAQ,UAAU;QAClC,OAAOiB,OAAOC,WAAW,CACvBD,OAAOE,OAAO,CAACnB,KAAgCc,GAAG,CAAC,CAAC,CAACM,KAAKrB,MAAM,GAAK;gBACnEqB;gBACAnB,OAAOF,gBAAAA,QAAS;aACjB;IAEL;IACA,OAAO,CAAC;AACV"}
@@ -14,20 +14,20 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- /**
18
- * Where the credential probe asks its question.
19
- *
20
- * Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send
21
- * attempt however empty its body is: it consumes an API call, and Resend
22
- * records it in the account's logs as a `422` on `POST /emails` with no
23
- * recipient, no subject and nothing identifying the caller — a line an
24
- * operator reading that dashboard has to treat as failed mail. A domain read
25
- * cannot create a message and cannot be mistaken for one.
26
- */
27
- export declare const RESEND_DOMAINS_ENDPOINT = "https://api.resend.com/domains";
17
+ import type { MailProvider } from './mail-provider';
28
18
  export interface EmailConfigReport {
29
- /** Both env vars present — mail will at least be attempted. */
19
+ /** The provider has its settings and a sender is set — mail will at least be attempted. */
30
20
  configured: boolean;
21
+ /** The id of the provider mail is handed to — see `mail-providers.ts`. */
22
+ provider: string;
23
+ /** Settings that provider still needs before it can send, as an operator names them. */
24
+ missingSettings: string[];
25
+ /**
26
+ * Set when the operator chose a provider this process does not have: why
27
+ * no mail is sent. Not the same as a missing key — see `mailProviderProblem`.
28
+ */
29
+ providerProblem: string | null;
30
+ /** The provider has what it needs to send. */
31
31
  hasApiKey: boolean;
32
32
  hasFrom: boolean;
33
33
  /**
@@ -35,7 +35,7 @@ export interface EmailConfigReport {
35
35
  * it appears in the headers of every message we send.
36
36
  */
37
37
  from: string | null;
38
- /** Domain part of the sender, which is what must be verified in Resend. */
38
+ /** Domain part of the sender, which is what must be verified with the provider. */
39
39
  fromDomain: string | null;
40
40
  }
41
41
  /**
@@ -48,37 +48,24 @@ export declare function describeEmailConfig(): EmailConfigReport;
48
48
  export type EmailCredentialStatus = 'ok' | 'unconfigured' | 'invalid-key' | 'unknown';
49
49
  export interface EmailCredentialReport {
50
50
  status: EmailCredentialStatus;
51
- /** HTTP status Resend answered the probe with, when it answered. */
51
+ /** HTTP status the provider answered the probe with, when it answered. */
52
52
  probeStatus?: number;
53
53
  detail?: string;
54
54
  }
55
55
  /**
56
- * Checks whether `RESEND_API_KEY` is actually accepted by Resend — without
57
- * sending anything to anybody, and without leaving anything behind that reads
58
- * as failed mail.
59
- *
60
- * How: a `GET` of the domains collection. The question is only ever "does
61
- * Resend recognize this credential", so the probe reads the ERROR NAME rather
62
- * than the status, because `401` and `403` each carry both meanings:
63
- *
64
- * - `2xx` — the key is accepted and has read scope → `ok`
65
- * - `restricted_api_key` / `invalid_permission` — Resend authenticated the
66
- * key and then denied it this read. A sending-scoped key, which is what
67
- * Aglyn provisions, always answers this way, and getting the answer proves
68
- * the credential works → `ok`
69
- * - `missing_api_key` / `validation_error` / `suspended_api_key` — the key
70
- * itself was refused → `invalid-key`
71
- * - anything else → `unknown`
56
+ * Whether the provider accepts this deployment's credential — asked without
57
+ * sending anything to anybody, and without leaving anything behind that
58
+ * reads as failed mail. How it asks is the provider's own (see its
59
+ * `checkCredentials`).
72
60
  *
73
- * An unrecognized rejection is `unknown`, never `invalid-key`: this feeds a
61
+ * An answer this cannot get is `unknown`, never `invalid-key`: this feeds a
74
62
  * staff diagnostics screen whose whole value is that a red line means
75
- * something, and a shape we have not seen before is not evidence that a
76
- * working key is broken.
63
+ * something. A provider with no probe is `unknown` for the same reason.
77
64
  *
78
65
  * It cannot confirm that *domain verification* has completed — only a real
79
66
  * send does that.
80
67
  */
81
- export declare function checkEmailCredentials(): Promise<EmailCredentialReport>;
68
+ export declare function checkEmailCredentials(provider?: MailProvider): Promise<EmailCredentialReport>;
82
69
  /** One pool member, as the provider reports it. */
83
70
  export interface SharedPoolDomainReport {
84
71
  domain: string;
@@ -135,13 +122,13 @@ export interface SharedPoolReport {
135
122
  * site's transactional mail already failing — which is why the caller treats
136
123
  * this as a blocker rather than a note.
137
124
  *
138
- * Read with `RESEND_READ_API_KEY`, deliberately, and never with the sending
139
- * key. A sending-scoped key has no read permission, so asking it about domains
140
- * yields an authorization error that says nothing about the domains — which is
141
- * exactly how a pool the key could not send from reported healthy. Without a
142
- * read key this answers `unreadable`, which is the honest answer and not a
143
- * pass: the caller must not treat "I could not look" as "I looked and it was
144
- * fine".
125
+ * Read through the provider's READS, deliberately, and never with the
126
+ * sending credential. A sending-scoped key has no read permission, so asking
127
+ * it about domains yields an authorization error that says nothing about the
128
+ * domains — which is exactly how a pool the key could not send from reported
129
+ * healthy. A provider that cannot read answers `unreadable`, which is the
130
+ * honest answer and not a pass: the caller must not treat "I could not look"
131
+ * as "I looked and it was fine".
145
132
  *
146
133
  * `not-applicable` covers the self-host shape. The pool is a property of the
147
134
  * Aglyn platform; an operator running their own deployment sends from their
@@ -149,6 +136,6 @@ export interface SharedPoolReport {
149
136
  */
150
137
  export declare function checkSharedSendingPool(options: {
151
138
  pool: string[];
152
- readApiKey?: string;
153
- endpoint?: string;
139
+ /** The provider to ask; the deployment's own by default. */
140
+ provider?: MailProvider;
154
141
  }): Promise<SharedPoolReport>;
@@ -15,42 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { getEmailConfig } from "./send-email.js";
18
- /**
19
- * Where the credential probe asks its question.
20
- *
21
- * Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send
22
- * attempt however empty its body is: it consumes an API call, and Resend
23
- * records it in the account's logs as a `422` on `POST /emails` with no
24
- * recipient, no subject and nothing identifying the caller — a line an
25
- * operator reading that dashboard has to treat as failed mail. A domain read
26
- * cannot create a message and cannot be mistaken for one.
27
- */ export const RESEND_DOMAINS_ENDPOINT = 'https://api.resend.com/domains';
28
- /**
29
- * Resend error names that mean the key itself was not accepted, as opposed to
30
- * a key that authenticated and merely lacks read scope. Matched by NAME, not
31
- * status: `401` and `403` each cover both meanings.
32
- */ const REFUSED_KEY_ERRORS = new Set([
33
- 'missing_api_key',
34
- 'validation_error',
35
- 'suspended_api_key'
36
- ]);
37
- /**
38
- * Resend error names that mean the key authenticated and was then denied this
39
- * particular read. A sending-scoped key — the shape Aglyn provisions — always
40
- * lands here, and reaching this answer at all required Resend to recognize
41
- * the credential, which is exactly what the probe is asking.
42
- */ const AUTHENTICATED_BUT_UNSCOPED_ERRORS = new Set([
43
- 'restricted_api_key',
44
- 'invalid_permission'
45
- ]);
46
- /** The `name` Resend puts on an error body, or `''` for anything else. */ function resendErrorName(body) {
47
- try {
48
- const parsed = JSON.parse(body);
49
- return typeof (parsed == null ? void 0 : parsed.name) === 'string' ? parsed.name : '';
50
- } catch (unused) {
51
- return '';
52
- }
53
- }
18
+ import { mailProvider, mailProviderProblem, mailProviderReads } from "./mail-providers.js";
54
19
  /**
55
20
  * Describes the email configuration without revealing the API key.
56
21
  *
@@ -58,83 +23,50 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
58
23
  * otherwise only answerable by emailing a real person and waiting.
59
24
  */ export function describeEmailConfig() {
60
25
  var _ref, _ref1;
61
- const { apiKey, from } = getEmailConfig();
26
+ const { provider, missingSettings, from } = getEmailConfig();
62
27
  const match = from == null ? void 0 : from.match(/<([^>]+)>/);
63
28
  const address = ((_ref = (_ref1 = match == null ? void 0 : match[1]) != null ? _ref1 : from) != null ? _ref : '').trim();
64
29
  const domain = address.includes('@') ? address.split('@').pop() : null;
65
30
  return {
66
- configured: Boolean(apiKey && from),
67
- hasApiKey: Boolean(apiKey),
31
+ configured: !missingSettings.length && Boolean(from),
32
+ provider,
33
+ missingSettings,
34
+ providerProblem: mailProviderProblem(),
35
+ hasApiKey: !missingSettings.length,
68
36
  hasFrom: Boolean(from),
69
37
  from: from != null ? from : null,
70
38
  fromDomain: domain
71
39
  };
72
40
  }
73
41
  /**
74
- * Checks whether `RESEND_API_KEY` is actually accepted by Resend — without
75
- * sending anything to anybody, and without leaving anything behind that reads
76
- * as failed mail.
77
- *
78
- * How: a `GET` of the domains collection. The question is only ever "does
79
- * Resend recognize this credential", so the probe reads the ERROR NAME rather
80
- * than the status, because `401` and `403` each carry both meanings:
81
- *
82
- * - `2xx` — the key is accepted and has read scope → `ok`
83
- * - `restricted_api_key` / `invalid_permission` — Resend authenticated the
84
- * key and then denied it this read. A sending-scoped key, which is what
85
- * Aglyn provisions, always answers this way, and getting the answer proves
86
- * the credential works → `ok`
87
- * - `missing_api_key` / `validation_error` / `suspended_api_key` — the key
88
- * itself was refused → `invalid-key`
89
- * - anything else → `unknown`
42
+ * Whether the provider accepts this deployment's credential — asked without
43
+ * sending anything to anybody, and without leaving anything behind that
44
+ * reads as failed mail. How it asks is the provider's own (see its
45
+ * `checkCredentials`).
90
46
  *
91
- * An unrecognized rejection is `unknown`, never `invalid-key`: this feeds a
47
+ * An answer this cannot get is `unknown`, never `invalid-key`: this feeds a
92
48
  * staff diagnostics screen whose whole value is that a red line means
93
- * something, and a shape we have not seen before is not evidence that a
94
- * working key is broken.
49
+ * something. A provider with no probe is `unknown` for the same reason.
95
50
  *
96
51
  * It cannot confirm that *domain verification* has completed — only a real
97
52
  * send does that.
98
- */ export async function checkEmailCredentials() {
99
- const { apiKey } = getEmailConfig();
100
- if (!apiKey) return {
53
+ */ export async function checkEmailCredentials(provider = mailProvider()) {
54
+ const problem = mailProviderProblem(provider);
55
+ if (problem) return {
56
+ status: 'unconfigured',
57
+ detail: problem
58
+ };
59
+ if (provider.missingSettings().length) return {
101
60
  status: 'unconfigured'
102
61
  };
103
- try {
104
- const response = await fetch(RESEND_DOMAINS_ENDPOINT, {
105
- method: 'GET',
106
- headers: {
107
- Authorization: `Bearer ${apiKey}`
108
- }
109
- });
110
- const detail = (await response.text().catch(()=>'')).slice(0, 300);
111
- if (response.status >= 200 && response.status < 300) {
112
- return {
113
- status: 'ok',
114
- probeStatus: response.status
115
- };
116
- }
117
- if (response.status === 401 || response.status === 403) {
118
- const name = resendErrorName(detail);
119
- if (AUTHENTICATED_BUT_UNSCOPED_ERRORS.has(name)) {
120
- return {
121
- status: 'ok',
122
- probeStatus: response.status
123
- };
124
- }
125
- if (REFUSED_KEY_ERRORS.has(name)) {
126
- return {
127
- status: 'invalid-key',
128
- probeStatus: response.status,
129
- detail
130
- };
131
- }
132
- }
62
+ if (!provider.checkCredentials) {
133
63
  return {
134
64
  status: 'unknown',
135
- probeStatus: response.status,
136
- detail
65
+ detail: `The "${provider.id}" mail provider has no credential probe; a test ` + 'send is the way to know it delivers.'
137
66
  };
67
+ }
68
+ try {
69
+ return await provider.checkCredentials();
138
70
  } catch (error) {
139
71
  var _ref;
140
72
  return {
@@ -152,19 +84,19 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
152
84
  * site's transactional mail already failing — which is why the caller treats
153
85
  * this as a blocker rather than a note.
154
86
  *
155
- * Read with `RESEND_READ_API_KEY`, deliberately, and never with the sending
156
- * key. A sending-scoped key has no read permission, so asking it about domains
157
- * yields an authorization error that says nothing about the domains — which is
158
- * exactly how a pool the key could not send from reported healthy. Without a
159
- * read key this answers `unreadable`, which is the honest answer and not a
160
- * pass: the caller must not treat "I could not look" as "I looked and it was
161
- * fine".
87
+ * Read through the provider's READS, deliberately, and never with the
88
+ * sending credential. A sending-scoped key has no read permission, so asking
89
+ * it about domains yields an authorization error that says nothing about the
90
+ * domains — which is exactly how a pool the key could not send from reported
91
+ * healthy. A provider that cannot read answers `unreadable`, which is the
92
+ * honest answer and not a pass: the caller must not treat "I could not look"
93
+ * as "I looked and it was fine".
162
94
  *
163
95
  * `not-applicable` covers the self-host shape. The pool is a property of the
164
96
  * Aglyn platform; an operator running their own deployment sends from their
165
97
  * own domain and has no pool to be degraded.
166
98
  */ export async function checkSharedSendingPool(options) {
167
- var _options_readApiKey;
99
+ var _options_provider;
168
100
  const pool = options.pool.filter(Boolean);
169
101
  if (!pool.length) {
170
102
  return {
@@ -174,50 +106,22 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
174
106
  untracked: []
175
107
  };
176
108
  }
177
- const key = String((_options_readApiKey = options.readApiKey) != null ? _options_readApiKey : '').trim();
178
- if (!key) {
109
+ const reads = mailProviderReads((_options_provider = options.provider) != null ? _options_provider : mailProvider());
110
+ const unmet = reads.unmet();
111
+ if (unmet) {
179
112
  return {
180
113
  status: 'unreadable',
181
114
  domains: [],
182
115
  unusable: [],
183
116
  untracked: [],
184
- detail: 'RESEND_READ_API_KEY is not set, so the shared pool cannot be ' + 'inspected. The sending key has no read permission and would report ' + 'an authorization error rather than the state of the domains.'
117
+ detail: `${unmet} The shared pool cannot be inspected without it.`
185
118
  };
186
119
  }
187
120
  try {
188
- var _options_endpoint, _ref;
189
- const response = await fetch((_options_endpoint = options.endpoint) != null ? _options_endpoint : RESEND_DOMAINS_ENDPOINT, {
190
- method: 'GET',
191
- headers: {
192
- Authorization: `Bearer ${key}`
193
- }
194
- });
195
- if (response.status < 200 || response.status >= 300) {
196
- return {
197
- status: 'unreadable',
198
- domains: [],
199
- unusable: [],
200
- untracked: [],
201
- detail: `The provider answered ${response.status} to the domain read.`
202
- };
203
- }
204
- const payload = await response.json().catch(()=>null);
205
- /**
206
- * A tri-state, and the third state matters: a listing that does not carry
207
- * the field must not be reported as the field being false. Off is a fault
208
- * somebody should fix; unknown is a question this probe could not ask.
209
- */ const flag = (value)=>typeof value === 'boolean' ? value : null;
210
- const byName = new Map();
211
- for (const row of (_ref = payload == null ? void 0 : payload.data) != null ? _ref : []){
212
- var _ref1, _ref2;
213
- const name = String((_ref1 = row == null ? void 0 : row.name) != null ? _ref1 : '').toLowerCase();
214
- if (!name) continue;
215
- byName.set(name, {
216
- status: String((_ref2 = row == null ? void 0 : row.status) != null ? _ref2 : 'unknown'),
217
- click: flag(row == null ? void 0 : row.click_tracking),
218
- open: flag(row == null ? void 0 : row.open_tracking)
219
- });
220
- }
121
+ const byName = new Map((await reads.sendingDomains()).map((row)=>[
122
+ row.name.toLowerCase(),
123
+ row
124
+ ]));
221
125
  const domains = pool.map((domain)=>{
222
126
  var _ref, _ref1, _ref2;
223
127
  const row = byName.get(domain.toLowerCase());
@@ -225,8 +129,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
225
129
  domain,
226
130
  status: (_ref = row == null ? void 0 : row.status) != null ? _ref : 'absent',
227
131
  present: row !== undefined,
228
- clickTracking: (_ref1 = row == null ? void 0 : row.click) != null ? _ref1 : null,
229
- openTracking: (_ref2 = row == null ? void 0 : row.open) != null ? _ref2 : null
132
+ clickTracking: (_ref1 = row == null ? void 0 : row.clickTracking) != null ? _ref1 : null,
133
+ openTracking: (_ref2 = row == null ? void 0 : row.openTracking) != null ? _ref2 : null
230
134
  };
231
135
  });
232
136
  const unusable = domains.filter((entry)=>entry.status !== 'verified').map((entry)=>entry.domain);
@@ -250,13 +154,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
250
154
  detail: `Click tracking is off for ${untracked.join(', ')}. Mail from ` + 'these domains delivers normally and reports a click rate of ' + 'exactly 0%, because the provider rewrites links only on a ' + 'domain that has a verified tracking subdomain.'
251
155
  } : {});
252
156
  } catch (error) {
253
- var _ref3;
157
+ var _ref;
254
158
  return {
255
159
  status: 'unreadable',
256
160
  domains: [],
257
161
  unusable: [],
258
162
  untracked: [],
259
- detail: String((_ref3 = error == null ? void 0 : error.message) != null ? _ref3 : error).slice(0, 300)
163
+ detail: String((_ref = error == null ? void 0 : error.message) != null ? _ref : error).slice(0, 300)
260
164
  };
261
165
  }
262
166
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-health.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 { getEmailConfig } from './send-email'\n\n/**\n * Where the credential probe asks its question.\n *\n * Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send\n * attempt however empty its body is: it consumes an API call, and Resend\n * records it in the account's logs as a `422` on `POST /emails` with no\n * recipient, no subject and nothing identifying the caller — a line an\n * operator reading that dashboard has to treat as failed mail. A domain read\n * cannot create a message and cannot be mistaken for one.\n */\nexport const RESEND_DOMAINS_ENDPOINT = 'https://api.resend.com/domains'\n\n/**\n * Resend error names that mean the key itself was not accepted, as opposed to\n * a key that authenticated and merely lacks read scope. Matched by NAME, not\n * status: `401` and `403` each cover both meanings.\n */\nconst REFUSED_KEY_ERRORS = new Set([\n 'missing_api_key',\n 'validation_error',\n 'suspended_api_key',\n])\n\n/**\n * Resend error names that mean the key authenticated and was then denied this\n * particular read. A sending-scoped key — the shape Aglyn provisions — always\n * lands here, and reaching this answer at all required Resend to recognize\n * the credential, which is exactly what the probe is asking.\n */\nconst AUTHENTICATED_BUT_UNSCOPED_ERRORS = new Set([\n 'restricted_api_key',\n 'invalid_permission',\n])\n\n/** The `name` Resend puts on an error body, or `''` for anything else. */\nfunction resendErrorName(body: string): string {\n try {\n const parsed = JSON.parse(body) as { name?: unknown }\n return typeof parsed?.name === 'string' ? parsed.name : ''\n } catch {\n return ''\n }\n}\n\nexport interface EmailConfigReport {\n /** Both env vars present — mail will at least be attempted. */\n configured: boolean\n hasApiKey: boolean\n hasFrom: boolean\n /**\n * The configured sender, e.g. `Aglyn <noreply@aglyn.com>`. Not a secret —\n * it appears in the headers of every message we send.\n */\n from: string | null\n /** Domain part of the sender, which is what must be verified in Resend. */\n fromDomain: string | null\n}\n\n/**\n * Describes the email configuration without revealing the API key.\n *\n * Answers \"is this environment able to send mail?\" — the question that is\n * otherwise only answerable by emailing a real person and waiting.\n */\nexport function describeEmailConfig(): EmailConfigReport {\n const { apiKey, from } = getEmailConfig()\n const match = from?.match(/<([^>]+)>/)\n const address = (match?.[1] ?? from ?? '').trim()\n const domain = address.includes('@') ? address.split('@').pop()! : null\n return {\n configured: Boolean(apiKey && from),\n hasApiKey: Boolean(apiKey),\n hasFrom: Boolean(from),\n from: from ?? null,\n fromDomain: domain,\n }\n}\n\nexport type EmailCredentialStatus =\n | 'ok'\n | 'unconfigured'\n | 'invalid-key'\n | 'unknown'\n\nexport interface EmailCredentialReport {\n status: EmailCredentialStatus\n /** HTTP status Resend answered the probe with, when it answered. */\n probeStatus?: number\n detail?: string\n}\n\n/**\n * Checks whether `RESEND_API_KEY` is actually accepted by Resend — without\n * sending anything to anybody, and without leaving anything behind that reads\n * as failed mail.\n *\n * How: a `GET` of the domains collection. The question is only ever \"does\n * Resend recognize this credential\", so the probe reads the ERROR NAME rather\n * than the status, because `401` and `403` each carry both meanings:\n *\n * - `2xx` — the key is accepted and has read scope → `ok`\n * - `restricted_api_key` / `invalid_permission` — Resend authenticated the\n * key and then denied it this read. A sending-scoped key, which is what\n * Aglyn provisions, always answers this way, and getting the answer proves\n * the credential works → `ok`\n * - `missing_api_key` / `validation_error` / `suspended_api_key` — the key\n * itself was refused → `invalid-key`\n * - anything else → `unknown`\n *\n * An unrecognized rejection is `unknown`, never `invalid-key`: this feeds a\n * staff diagnostics screen whose whole value is that a red line means\n * something, and a shape we have not seen before is not evidence that a\n * working key is broken.\n *\n * It cannot confirm that *domain verification* has completed — only a real\n * send does that.\n */\nexport async function checkEmailCredentials(): Promise<EmailCredentialReport> {\n const { apiKey } = getEmailConfig()\n if (!apiKey) return { status: 'unconfigured' }\n\n try {\n const response = await fetch(RESEND_DOMAINS_ENDPOINT, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n })\n const detail = (await response.text().catch(() => '')).slice(0, 300)\n\n if (response.status >= 200 && response.status < 300) {\n return { status: 'ok', probeStatus: response.status }\n }\n if (response.status === 401 || response.status === 403) {\n const name = resendErrorName(detail)\n if (AUTHENTICATED_BUT_UNSCOPED_ERRORS.has(name)) {\n return { status: 'ok', probeStatus: response.status }\n }\n if (REFUSED_KEY_ERRORS.has(name)) {\n return { status: 'invalid-key', probeStatus: response.status, detail }\n }\n }\n return { status: 'unknown', probeStatus: response.status, detail }\n } catch (error) {\n return {\n status: 'unknown',\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n\n/** One pool member, as the provider reports it. */\nexport interface SharedPoolDomainReport {\n domain: string\n /** `verified` when the provider will accept mail on it. */\n status: string\n /** False when the provider has never heard of it. */\n present: boolean\n /**\n * Whether the provider will rewrite links on this domain to measure clicks.\n *\n * Reported because the symptom of it being off is INVISIBLE. A provider\n * counts a click by rewriting every `<a href>` to a tracking host, so a\n * domain without one produces a click rate of exactly 0% — which reads on a\n * dashboard as an audience that does not click, not as a domain that cannot\n * count. The last time this was wrong nobody found it by looking at the\n * numbers; it was found by reading a delivered message's source.\n *\n * `null` when the provider's listing does not say, so \"it did not tell us\"\n * is never reported as \"it is off\".\n */\n clickTracking: boolean | null\n /** The same, for the open pixel. */\n openTracking: boolean | null\n}\n\nexport type SharedPoolStatus =\n /** No platform pool applies to this deployment. */\n | 'not-applicable'\n /** No read credential, so the pool cannot be inspected. */\n | 'unreadable'\n | 'ok'\n | 'degraded'\n\nexport interface SharedPoolReport {\n status: SharedPoolStatus\n domains: SharedPoolDomainReport[]\n /** Members the provider will not accept mail on right now. */\n unusable: string[]\n /**\n * Members that will carry mail but cannot measure a click on it.\n *\n * Reported APART from {@link unusable} because the two are different\n * severities and conflating them would be wrong in both directions: a\n * member here is delivering perfectly, and a member in `unusable` is not\n * delivering at all. This one does not degrade the pool — see\n * {@link SharedPoolReport.status} — it is a measurement fault, and stopping\n * mail over it would be the control causing the outage.\n */\n untracked: string[]\n detail?: string\n}\n\n/**\n * Whether the shared platform pool can actually carry mail.\n *\n * The pool is the delivery floor: a site with no domain of its own sends its\n * receipts, password resets and booking confirmations from a member of it. So\n * a degraded pool is not a warning about a future problem, it is every such\n * site's transactional mail already failing — which is why the caller treats\n * this as a blocker rather than a note.\n *\n * Read with `RESEND_READ_API_KEY`, deliberately, and never with the sending\n * key. A sending-scoped key has no read permission, so asking it about domains\n * yields an authorization error that says nothing about the domains — which is\n * exactly how a pool the key could not send from reported healthy. Without a\n * read key this answers `unreadable`, which is the honest answer and not a\n * pass: the caller must not treat \"I could not look\" as \"I looked and it was\n * fine\".\n *\n * `not-applicable` covers the self-host shape. The pool is a property of the\n * Aglyn platform; an operator running their own deployment sends from their\n * own domain and has no pool to be degraded.\n */\nexport async function checkSharedSendingPool(options: {\n pool: string[]\n readApiKey?: string\n endpoint?: string\n}): Promise<SharedPoolReport> {\n const pool = options.pool.filter(Boolean)\n if (!pool.length) {\n return {\n status: 'not-applicable',\n domains: [],\n unusable: [],\n untracked: [],\n }\n }\n\n const key = String(options.readApiKey ?? '').trim()\n if (!key) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail:\n 'RESEND_READ_API_KEY is not set, so the shared pool cannot be ' +\n 'inspected. The sending key has no read permission and would report ' +\n 'an authorization error rather than the state of the domains.',\n }\n }\n\n try {\n const response = await fetch(options.endpoint ?? RESEND_DOMAINS_ENDPOINT, {\n method: 'GET',\n headers: { Authorization: `Bearer ${key}` },\n })\n if (response.status < 200 || response.status >= 300) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: `The provider answered ${response.status} to the domain read.`,\n }\n }\n const payload = (await response.json().catch(() => null)) as\n | {\n data?: Array<{\n name?: unknown\n status?: unknown\n click_tracking?: unknown\n open_tracking?: unknown\n }>\n }\n | null\n /**\n * A tri-state, and the third state matters: a listing that does not carry\n * the field must not be reported as the field being false. Off is a fault\n * somebody should fix; unknown is a question this probe could not ask.\n */\n const flag = (value: unknown): boolean | null =>\n typeof value === 'boolean' ? value : null\n const byName = new Map<\n string,\n { status: string; click: boolean | null; open: boolean | null }\n >()\n for (const row of payload?.data ?? []) {\n const name = String(row?.name ?? '').toLowerCase()\n if (!name) continue\n byName.set(name, {\n status: String(row?.status ?? 'unknown'),\n click: flag(row?.click_tracking),\n open: flag(row?.open_tracking),\n })\n }\n const domains = pool.map((domain) => {\n const row = byName.get(domain.toLowerCase())\n return {\n domain,\n status: row?.status ?? 'absent',\n present: row !== undefined,\n clickTracking: row?.click ?? null,\n openTracking: row?.open ?? null,\n }\n })\n const unusable = domains\n .filter((entry) => entry.status !== 'verified')\n .map((entry) => entry.domain)\n /*\n * Only a definite `false` counts. A member the provider did not describe\n * is already reported by `present`, and listing it here as well would\n * send an operator to fix a setting they cannot see.\n */\n const untracked = domains\n .filter((entry) => entry.clickTracking === false)\n .map((entry) => entry.domain)\n return {\n /*\n * `untracked` deliberately does NOT degrade the pool. `degraded` is\n * read as \"transactional mail is failing right now\" and is treated as a\n * blocker; a domain that delivers but does not count clicks is neither.\n * Folding the two together would either raise a false alarm about\n * delivery or teach an operator to ignore the real one.\n */\n status: unusable.length ? 'degraded' : 'ok',\n domains,\n unusable,\n untracked,\n ...(untracked.length\n ? {\n detail:\n `Click tracking is off for ${untracked.join(', ')}. Mail from ` +\n 'these domains delivers normally and reports a click rate of ' +\n 'exactly 0%, because the provider rewrites links only on a ' +\n 'domain that has a verified tracking subdomain.',\n }\n : {}),\n }\n } catch (error) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n"],"names":["getEmailConfig","RESEND_DOMAINS_ENDPOINT","REFUSED_KEY_ERRORS","Set","AUTHENTICATED_BUT_UNSCOPED_ERRORS","resendErrorName","body","parsed","JSON","parse","name","describeEmailConfig","match","apiKey","from","address","trim","domain","includes","split","pop","configured","Boolean","hasApiKey","hasFrom","fromDomain","checkEmailCredentials","status","response","fetch","method","headers","Authorization","detail","text","catch","slice","probeStatus","has","error","String","message","checkSharedSendingPool","options","pool","filter","length","domains","unusable","untracked","key","readApiKey","endpoint","payload","json","flag","value","byName","Map","row","data","toLowerCase","set","click","click_tracking","open","open_tracking","map","get","present","undefined","clickTracking","openTracking","entry","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-health.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 { getEmailConfig } from './send-email'\nimport type { MailProvider } from './mail-provider'\nimport {\n mailProvider,\n mailProviderProblem,\n mailProviderReads,\n} from './mail-providers'\n\nexport interface EmailConfigReport {\n /** The provider has its settings and a sender is set — mail will at least be attempted. */\n configured: boolean\n /** The id of the provider mail is handed to — see `mail-providers.ts`. */\n provider: string\n /** Settings that provider still needs before it can send, as an operator names them. */\n missingSettings: string[]\n /**\n * Set when the operator chose a provider this process does not have: why\n * no mail is sent. Not the same as a missing key — see `mailProviderProblem`.\n */\n providerProblem: string | null\n /** The provider has what it needs to send. */\n hasApiKey: boolean\n hasFrom: boolean\n /**\n * The configured sender, e.g. `Aglyn <noreply@aglyn.com>`. Not a secret —\n * it appears in the headers of every message we send.\n */\n from: string | null\n /** Domain part of the sender, which is what must be verified with the provider. */\n fromDomain: string | null\n}\n\n/**\n * Describes the email configuration without revealing the API key.\n *\n * Answers \"is this environment able to send mail?\" — the question that is\n * otherwise only answerable by emailing a real person and waiting.\n */\nexport function describeEmailConfig(): EmailConfigReport {\n const { provider, missingSettings, from } = getEmailConfig()\n const match = from?.match(/<([^>]+)>/)\n const address = (match?.[1] ?? from ?? '').trim()\n const domain = address.includes('@') ? address.split('@').pop()! : null\n return {\n configured: !missingSettings.length && Boolean(from),\n provider,\n missingSettings,\n providerProblem: mailProviderProblem(),\n hasApiKey: !missingSettings.length,\n hasFrom: Boolean(from),\n from: from ?? null,\n fromDomain: domain,\n }\n}\n\nexport type EmailCredentialStatus =\n | 'ok'\n | 'unconfigured'\n | 'invalid-key'\n | 'unknown'\n\nexport interface EmailCredentialReport {\n status: EmailCredentialStatus\n /** HTTP status the provider answered the probe with, when it answered. */\n probeStatus?: number\n detail?: string\n}\n\n/**\n * Whether the provider accepts this deployment's credential — asked without\n * sending anything to anybody, and without leaving anything behind that\n * reads as failed mail. How it asks is the provider's own (see its\n * `checkCredentials`).\n *\n * An answer this cannot get is `unknown`, never `invalid-key`: this feeds a\n * staff diagnostics screen whose whole value is that a red line means\n * something. A provider with no probe is `unknown` for the same reason.\n *\n * It cannot confirm that *domain verification* has completed — only a real\n * send does that.\n */\nexport async function checkEmailCredentials(\n provider: MailProvider = mailProvider(),\n): Promise<EmailCredentialReport> {\n const problem = mailProviderProblem(provider)\n if (problem) return { status: 'unconfigured', detail: problem }\n if (provider.missingSettings().length) return { status: 'unconfigured' }\n if (!provider.checkCredentials) {\n return {\n status: 'unknown',\n detail:\n `The \"${provider.id}\" mail provider has no credential probe; a test ` +\n 'send is the way to know it delivers.',\n }\n }\n try {\n return await provider.checkCredentials()\n } catch (error) {\n return {\n status: 'unknown',\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n\n/** One pool member, as the provider reports it. */\nexport interface SharedPoolDomainReport {\n domain: string\n /** `verified` when the provider will accept mail on it. */\n status: string\n /** False when the provider has never heard of it. */\n present: boolean\n /**\n * Whether the provider will rewrite links on this domain to measure clicks.\n *\n * Reported because the symptom of it being off is INVISIBLE. A provider\n * counts a click by rewriting every `<a href>` to a tracking host, so a\n * domain without one produces a click rate of exactly 0% — which reads on a\n * dashboard as an audience that does not click, not as a domain that cannot\n * count. The last time this was wrong nobody found it by looking at the\n * numbers; it was found by reading a delivered message's source.\n *\n * `null` when the provider's listing does not say, so \"it did not tell us\"\n * is never reported as \"it is off\".\n */\n clickTracking: boolean | null\n /** The same, for the open pixel. */\n openTracking: boolean | null\n}\n\nexport type SharedPoolStatus =\n /** No platform pool applies to this deployment. */\n | 'not-applicable'\n /** No read credential, so the pool cannot be inspected. */\n | 'unreadable'\n | 'ok'\n | 'degraded'\n\nexport interface SharedPoolReport {\n status: SharedPoolStatus\n domains: SharedPoolDomainReport[]\n /** Members the provider will not accept mail on right now. */\n unusable: string[]\n /**\n * Members that will carry mail but cannot measure a click on it.\n *\n * Reported APART from {@link unusable} because the two are different\n * severities and conflating them would be wrong in both directions: a\n * member here is delivering perfectly, and a member in `unusable` is not\n * delivering at all. This one does not degrade the pool — see\n * {@link SharedPoolReport.status} — it is a measurement fault, and stopping\n * mail over it would be the control causing the outage.\n */\n untracked: string[]\n detail?: string\n}\n\n/**\n * Whether the shared platform pool can actually carry mail.\n *\n * The pool is the delivery floor: a site with no domain of its own sends its\n * receipts, password resets and booking confirmations from a member of it. So\n * a degraded pool is not a warning about a future problem, it is every such\n * site's transactional mail already failing — which is why the caller treats\n * this as a blocker rather than a note.\n *\n * Read through the provider's READS, deliberately, and never with the\n * sending credential. A sending-scoped key has no read permission, so asking\n * it about domains yields an authorization error that says nothing about the\n * domains — which is exactly how a pool the key could not send from reported\n * healthy. A provider that cannot read answers `unreadable`, which is the\n * honest answer and not a pass: the caller must not treat \"I could not look\"\n * as \"I looked and it was fine\".\n *\n * `not-applicable` covers the self-host shape. The pool is a property of the\n * Aglyn platform; an operator running their own deployment sends from their\n * own domain and has no pool to be degraded.\n */\nexport async function checkSharedSendingPool(options: {\n pool: string[]\n /** The provider to ask; the deployment's own by default. */\n provider?: MailProvider\n}): Promise<SharedPoolReport> {\n const pool = options.pool.filter(Boolean)\n if (!pool.length) {\n return {\n status: 'not-applicable',\n domains: [],\n unusable: [],\n untracked: [],\n }\n }\n\n const reads = mailProviderReads(options.provider ?? mailProvider())\n const unmet = reads.unmet()\n if (unmet) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: `${unmet} The shared pool cannot be inspected without it.`,\n }\n }\n\n try {\n const byName = new Map(\n (await reads.sendingDomains()).map((row) => [row.name.toLowerCase(), row]),\n )\n const domains = pool.map((domain) => {\n const row = byName.get(domain.toLowerCase())\n return {\n domain,\n status: row?.status ?? 'absent',\n present: row !== undefined,\n clickTracking: row?.clickTracking ?? null,\n openTracking: row?.openTracking ?? null,\n }\n })\n const unusable = domains\n .filter((entry) => entry.status !== 'verified')\n .map((entry) => entry.domain)\n /*\n * Only a definite `false` counts. A member the provider did not describe\n * is already reported by `present`, and listing it here as well would\n * send an operator to fix a setting they cannot see.\n */\n const untracked = domains\n .filter((entry) => entry.clickTracking === false)\n .map((entry) => entry.domain)\n return {\n /*\n * `untracked` deliberately does NOT degrade the pool. `degraded` is\n * read as \"transactional mail is failing right now\" and is treated as a\n * blocker; a domain that delivers but does not count clicks is neither.\n * Folding the two together would either raise a false alarm about\n * delivery or teach an operator to ignore the real one.\n */\n status: unusable.length ? 'degraded' : 'ok',\n domains,\n unusable,\n untracked,\n ...(untracked.length\n ? {\n detail:\n `Click tracking is off for ${untracked.join(', ')}. Mail from ` +\n 'these domains delivers normally and reports a click rate of ' +\n 'exactly 0%, because the provider rewrites links only on a ' +\n 'domain that has a verified tracking subdomain.',\n }\n : {}),\n }\n } catch (error) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n"],"names":["getEmailConfig","mailProvider","mailProviderProblem","mailProviderReads","describeEmailConfig","match","provider","missingSettings","from","address","trim","domain","includes","split","pop","configured","length","Boolean","providerProblem","hasApiKey","hasFrom","fromDomain","checkEmailCredentials","problem","status","detail","checkCredentials","id","error","String","message","slice","checkSharedSendingPool","options","pool","filter","domains","unusable","untracked","reads","unmet","byName","Map","sendingDomains","map","row","name","toLowerCase","get","present","undefined","clickTracking","openTracking","entry","join"],"mappings":"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"}