@aglyn/shared-util-email 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/package.json +37 -0
- package/src/index.d.ts +37 -0
- package/src/index.js +46 -0
- package/src/index.js.map +1 -0
- package/src/lib/email-delivery-events.d.ts +198 -0
- package/src/lib/email-delivery-events.js +310 -0
- package/src/lib/email-delivery-events.js.map +1 -0
- package/src/lib/email-health.d.ts +154 -0
- package/src/lib/email-health.js +264 -0
- package/src/lib/email-health.js.map +1 -0
- package/src/lib/email-media-src.d.ts +33 -0
- package/src/lib/email-media-src.js +113 -0
- package/src/lib/email-media-src.js.map +1 -0
- package/src/lib/email-merge.d.ts +31 -0
- package/src/lib/email-merge.js +36 -0
- package/src/lib/email-merge.js.map +1 -0
- package/src/lib/email-render.d.ts +114 -0
- package/src/lib/email-render.js +293 -0
- package/src/lib/email-render.js.map +1 -0
- package/src/lib/email-revenue-window.d.ts +55 -0
- package/src/lib/email-revenue-window.js +58 -0
- package/src/lib/email-revenue-window.js.map +1 -0
- package/src/lib/host-email-render.d.ts +99 -0
- package/src/lib/host-email-render.js +126 -0
- package/src/lib/host-email-render.js.map +1 -0
- package/src/lib/host-sender.d.ts +86 -0
- package/src/lib/host-sender.js +131 -0
- package/src/lib/host-sender.js.map +1 -0
- package/src/lib/marketing-send.d.ts +466 -0
- package/src/lib/marketing-send.js +459 -0
- package/src/lib/marketing-send.js.map +1 -0
- package/src/lib/platform-sending-domain.d.ts +362 -0
- package/src/lib/platform-sending-domain.js +697 -0
- package/src/lib/platform-sending-domain.js.map +1 -0
- package/src/lib/received-email.d.ts +86 -0
- package/src/lib/received-email.js +124 -0
- package/src/lib/received-email.js.map +1 -0
- package/src/lib/send-ceilings.d.ts +394 -0
- package/src/lib/send-ceilings.js +341 -0
- package/src/lib/send-ceilings.js.map +1 -0
- package/src/lib/send-email.d.ts +385 -0
- package/src/lib/send-email.js +586 -0
- package/src/lib/send-email.js.map +1 -0
- package/src/lib/send-rate.d.ts +298 -0
- package/src/lib/send-rate.js +310 -0
- package/src/lib/send-rate.js.map +1 -0
- package/src/lib/sender-reputation.d.ts +357 -0
- package/src/lib/sender-reputation.js +392 -0
- package/src/lib/sender-reputation.js.map +1 -0
- package/src/lib/sending-domain.d.ts +744 -0
- package/src/lib/sending-domain.js +798 -0
- package/src/lib/sending-domain.js.map +1 -0
- package/src/lib/sending-mailbox.d.ts +132 -0
- package/src/lib/sending-mailbox.js +192 -0
- package/src/lib/sending-mailbox.js.map +1 -0
- package/src/lib/stored-email-nodes.d.ts +38 -0
- package/src/lib/stored-email-nodes.js +78 -0
- package/src/lib/stored-email-nodes.js.map +1 -0
- package/src/lib/svix-signature.d.ts +38 -0
- package/src/lib/svix-signature.js +55 -0
- package/src/lib/svix-signature.js.map +1 -0
- package/src/lib/system-email-catalog.d.ts +170 -0
- package/src/lib/system-email-catalog.js +828 -0
- package/src/lib/system-email-catalog.js.map +1 -0
- package/src/lib/tenant-email-catalog.d.ts +78 -0
- package/src/lib/tenant-email-catalog.js +452 -0
- package/src/lib/tenant-email-catalog.js.map +1 -0
- package/src/lib/text-email-html.d.ts +32 -0
- package/src/lib/text-email-html.js +119 -0
- package/src/lib/text-email-html.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/sending-domain.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 — policy half.\n *\n * Everything the platform sends leaves from one verified identity\n * (`USAGE_EMAIL_FROM`) on one domain. A tenant's campaign and another tenant's\n * password reset therefore share a DKIM `d=` and a reputation: one merchant's\n * complaint rate is charged against every merchant's authentication mail. A\n * custom sending domain is the control that moves a tenant's reputation onto a\n * domain that tenant owns.\n *\n * ## Pure, and dependency-free, for the same reason `send-rate.ts` is\n *\n * The decision — which identity does this send leave on, and may it leave at\n * all — is a pure function of a stored record and the environment. It is\n * unit-testable with no Firestore harness, no DNS resolver and no route. The\n * durable half (the record, the DNS probe, the verification sweep) lives in\n * `@aglyn/tenant-data-admin`, which is the only layer that may hold the Admin\n * SDK. `@aglyn/shared-util-email` is `scope:shared` and may not import it.\n *\n * ## THE BOUNDARY THAT MATTERS MOST\n *\n * **A CUSTOMER'S OWN sending domain that is not verified refuses the send. It\n * never falls back to any other identity.** {@link resolveSendingIdentity} has\n * no arm that reaches another address from such a domain, and\n * {@link sendingIdentityRefusal} is what a caller must print.\n *\n * Silent fallback would be wrong three ways, and each is independently\n * disqualifying: the customer believes their DNS is finished when it is not;\n * the recipient sees a `From:` they did not expect from a brand they did;\n * and the tenant's reputation risk lands back on the shared domain the custom\n * domain existed to move it off.\n *\n * **WHOSE DOMAIN IT IS carries the whole rule**, and it is the distinction\n * `SendingDomainSelection.platformIssued` records. There are three cases and\n * only the first refuses:\n *\n * - A domain the CUSTOMER owns, unverified. An instruction of theirs that we\n * would be ignoring. Refused.\n * - A platform subdomain we issued, unverified. The merchant asked for a\n * domain; we chose the name, wrote the records and pointed the site at\n * it, so there is nothing of theirs to contradict and everything that can\n * leave it unfinished is ours to fix. Sends on the pool.\n * - Nothing selected. Likewise no statement to contradict, and the site\n * still has receipts to send. Sends on the pool, which is what the console\n * has always told merchants happens.\n *\n * The last two are why the DEDICATED subdomain can be rationed safely: it is\n * an optimization on top of a floor that always holds, so exhausting the\n * provider's domain allowance degrades delivery reputation rather than\n * stopping the mail.\n *\n * ## The shared identity carries marketing too, and is GRADED for it\n *\n * The shared identity pools reputation across every site using it, so one\n * merchant's imported list is charged against every other site's password\n * resets. The answer to that is measurement, not exclusion: refusing marketing\n * outright made campaigns unreachable for every site that had not bought a\n * domain, while doing nothing about the pooled site whose receipts were already\n * bouncing.\n *\n * So a pooled campaign is graded against the STRICTER reputation thresholds —\n * the watch levels in `sender-reputation.ts` rather than the trip levels — and\n * the site earning the complaints is the one that stops. `sendEmail` refuses\n * outright only the case a seven-day window cannot catch in time: bulk mail\n * carrying no unsubscribe link, which is {@link pooledMarketingRefusal}.\n *\n * That is a REPUTATION rule and not a pricing one. Which plans may run a\n * campaign at all is an entitlement decided elsewhere, and nothing here reads\n * a plan.\n *\n * The failure mode this guards against is the house one. `USAGE_EMAIL_FROM`\n * was empty in production for weeks; because mail is best-effort at every\n * call site, every send returned `{sent: false, reason: 'unconfigured'}`,\n * nothing threw, and no surface said anything was wrong. A refusal that is\n * only a log line is that same defect wearing a new reason string — so the\n * refusal carries the domain, the missing records, and a sentence a person can\n * act on, and the campaign route answers it as a `409` rather than a no-op.\n *\n * ## Scope: this module issues records and decides. It does not verify.\n *\n * Reading live DNS belongs to the durable half, which reuses the pinned-\n * resolver probe that already backs SSO domain verification — the one that\n * distinguishes \"the record is absent\" from \"nobody answered\". That third\n * state is why {@link SendingDomainStatus} has no arm meaning \"checked and we\n * are not sure\": an unreachable resolver leaves the stored status alone.\n */\n\n/*==========================================\n The record\n==========================================*/\n\n/**\n * Where a domain is in its verification lifecycle.\n *\n * Persisted, unlike the site-domain machine's live-computed\n * `ProjectDomainState`. A site domain can be re-read from the hosting provider\n * on every request; a sending domain's proof is a DKIM key the mail provider\n * issued once, and the send path must be able to answer \"may this leave?\"\n * without a network call on the critical path of every message.\n */\nexport type SendingDomainStatus =\n /**\n * The customer asked for the domain. No records exist to publish yet,\n * because issuing them needs a provider credential that may be absent.\n */\n | 'requested'\n /** Records are issued and shown. The customer has DNS work to do. */\n | 'records-issued'\n /** A lookup has seen every required record. This is the only sending state. */\n | 'verified'\n /**\n * A lookup got a conclusive answer and the records were wrong or absent.\n * Distinct from `records-issued` so a surface can say \"we looked, and it is\n * not there\" rather than leaving a customer to wonder whether we ever\n * checked. Never reached from an unreachable resolver.\n */\n | 'failed'\n\n/** The lifecycle in order, for a surface that renders progress. */\nexport const SENDING_DOMAIN_STATUSES: readonly SendingDomainStatus[] = [\n 'requested',\n 'records-issued',\n 'verified',\n 'failed',\n]\n\n/**\n * The stored record, one per domain per org.\n *\n * Per-ORG rather than per-host: DNS control is proved once for a name, and an\n * agency running four sites on `client.com` should publish the DKIM record\n * once, not four times. Which identity a given site *uses* is a separate,\n * per-host choice ({@link SendingDomainSelection}) — that split is what lets\n * one verification serve the agency case without making every site repeat the\n * DNS chore.\n */\nexport interface SendingDomainRecord {\n /** Normalized, lowercased, no trailing dot. Also the document id. */\n domain: string\n status: SendingDomainStatus\n /**\n * The DKIM selector this domain signs with. Per-org rather than a shared\n * `resend`, so two orgs verifying the same name cannot collide on one\n * record — and so revoking one org's identity cannot invalidate another's.\n *\n * Requested per-org, but ISSUED by the provider: a provider that signs on a\n * selector of its own choosing overwrites this when the key is recorded,\n * because the record the customer publishes has to be the record the\n * provider will actually sign under. See `sendingDomainProvider`.\n */\n dkimSelector: string\n /** The public key the provider issued, base64, without the `p=` prefix. */\n dkimPublicKey?: string | null\n /** The provider's return-path host for bounce and complaint routing. */\n returnPathHost?: string | null\n /**\n * The provider's own id for the domain object it created.\n *\n * Stored so a re-request can recognize a domain this deployment already\n * created rather than creating a second one, and so an operator can find\n * the object in the provider's dashboard. Never a credential.\n */\n providerDomainId?: string | null\n /**\n * The host the provider redirects tracked link clicks through, as a CNAME\n * target (`links1.resend-dns.com` and the like).\n *\n * From the PROVIDER, like the DKIM key and unlike the SPF include: the\n * target names infrastructure the provider chose and can move, and a value\n * of ours would point a customer's zone at a host we do not operate. Absent\n * until a domain is issued with tracking on, and absent forever for one\n * issued before it was.\n */\n trackingTarget?: string | null\n createdAtMs?: number | null\n verifiedAtMs?: number | null\n lastCheckedAtMs?: number | null\n /**\n * Why the last issuing attempt did not produce a key.\n *\n * A REASON on a record still at `requested`, never a half-written\n * `records-issued`. A provider that answered `4xx` has issued nothing, and\n * a domain whose status says records exist while its DKIM value is empty\n * would print a blank record for the customer to publish — which reads as\n * our bug and cannot ever verify.\n */\n lastIssueError?: string | null\n lastIssueAtMs?: number | null\n /** What the last conclusive lookup saw, for a surface that shows the gap. */\n lastMissing?: string[] | null\n}\n\n/**\n * The one {@link SendingDomainRecord.lastIssueError} value that is not a\n * provider failure.\n *\n * A dedicated platform subdomain draws on an allowance the provider grants the\n * whole account, so a claim made while that allowance is spent is refused\n * before any call is made. It is stored in the same field as a provider\n * refusal because it is the same fact from the record's point of view — no key\n * was issued, and the domain stays `requested` — but it is a DIFFERENT\n * sentence to the person reading it: nothing failed, nobody's DNS is wrong,\n * and pressing the retry does not move it until there is room.\n *\n * Named here rather than at either end because the two ends are in different\n * projects: the console's provisioning sweep writes it and the email plugin's\n * status model reads it, and a string spelled out twice is one a rename\n * silently breaks in the surface rather than in the writer.\n */\nexport const SENDING_DOMAIN_AT_CAPACITY = 'at-capacity'\n\n/**\n * A provider's failure reduced to something safe to store, log and print.\n *\n * Provider error bodies are attacker-adjacent text we did not write, and the\n * one thing that must never appear in a Firestore document, a log line or an\n * admin surface is the credential that made the call. Vendor keys have a\n * recognizable shape — a short prefix, an underscore, a long opaque body\n * (`re_`, `sk_`, `rk_`, `whsec_`) — and an `Authorization` header echoed into\n * an error message carries the whole thing.\n *\n * This is the LAST line rather than the only one: callers build their detail\n * from a fixed vocabulary and never from response prose, so nothing should\n * reach here that needs redacting. A guard that is only ever a no-op in\n * practice is exactly the guard worth having on a secret.\n */\nexport function safeProviderDetail(input: string | null | undefined): string {\n return String(input ?? '')\n .replace(/\\bBearer\\s+\\S+/gi, 'Bearer [redacted]')\n .replace(/\\b[A-Za-z]{2,8}_[A-Za-z0-9]{12,}\\b/g, '[redacted]')\n .trim()\n .slice(0, 120)\n}\n\n/**\n * Which sending identity one site uses, resolved for a send.\n *\n * The `localPart` is stored, never taken from a request: an address assembled\n * from user input is a `From:` override, and the one invariant `applyFromName`\n * was built to hold is that the address cannot move off a verified identity.\n */\nexport interface SendingDomainSelection {\n domain: string\n status: SendingDomainStatus\n /** Mailbox to send as, for example `hello`. */\n localPart: string\n /** Required records not seen by the last conclusive lookup. */\n missing?: string[] | null\n /**\n * Whether this domain is one the PLATFORM issued the site — a subdomain of\n * our own mail apex, whose records we write into our own zone — rather than\n * a name the customer owns and published DNS for.\n *\n * It decides what an UNVERIFIED selection means, which is the one place the\n * two kinds of domain must not be treated alike. A customer's own domain is\n * an instruction about the NAME: they published DNS for it and told us what\n * their recipients would see, so sending as anything else contradicts them.\n * A platform subdomain carries no such instruction even though the merchant\n * asked for one — they asked for a domain of their own, and we picked the\n * name, wrote the records and pointed the site at it. So a subdomain that\n * has not finished provisioning is our unfinished work rather than their\n * unfinished DNS. See {@link resolveSendingIdentity}.\n *\n * Carried on the selection rather than derived here because the apex lives\n * in `platform-sending-domain.ts`, which imports from this module; reading\n * it here would be a cycle. The durable half sets it with\n * `isPlatformSendingDomain`.\n *\n * Absent means \"a domain the customer owns\", which is the direction that\n * refuses. A caller that forgets gets the strict answer.\n */\n platformIssued?: boolean\n}\n\n/*==========================================\n Domain validation\n==========================================*/\n\nconst DOMAIN_PATTERN =\n /^(?!-)[a-z0-9-]{1,63}(?<!-)(\\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/\n\n/**\n * Domains whose mail nobody but their operator may authorize.\n *\n * Publishing our DKIM record in one of these zones is impossible, so a claim\n * on one can never verify — but refusing it up front is a sentence the\n * customer can act on rather than a verification that silently never\n * completes.\n */\nconst PUBLIC_EMAIL_DOMAINS = new Set([\n 'gmail.com',\n 'googlemail.com',\n 'yahoo.com',\n 'ymail.com',\n 'hotmail.com',\n 'outlook.com',\n 'live.com',\n 'msn.com',\n 'aol.com',\n 'icloud.com',\n 'me.com',\n 'mac.com',\n 'proton.me',\n 'protonmail.com',\n 'gmx.com',\n 'zoho.com',\n 'fastmail.com',\n 'hey.com',\n 'qq.com',\n '163.com',\n '126.com',\n])\n\n/**\n * `{ value, error }` rather than a discriminated union, matching\n * `validateSsoDomain`: `strictNullChecks` is off repo-wide, so an\n * `{ ok: true } | { ok: false }` union does not narrow across a library\n * boundary and the caller ends up unable to reach either arm's fields. Both\n * keys always present, exactly one of them null.\n */\nexport interface SendingDomainCheck {\n domain: string | null\n error: string | null\n}\n\nexport function normalizeSendingDomain(input: string): string {\n const raw = String(input ?? '')\n .trim()\n .toLowerCase()\n const at = raw.lastIndexOf('@')\n return (at >= 0 ? raw.slice(at + 1) : raw)\n .replace(/^@+/, '')\n .replace(/\\.$/, '')\n}\n\nexport function validateSendingDomain(input: string): SendingDomainCheck {\n const domain = normalizeSendingDomain(input)\n if (!domain || !DOMAIN_PATTERN.test(domain)) {\n return { domain: null, error: 'Enter a valid domain, for example acme.com' }\n }\n if (PUBLIC_EMAIL_DOMAINS.has(domain)) {\n return {\n domain: null,\n error:\n 'Mailbox providers do not delegate sending for their own domains, so ' +\n 'this domain can never be verified. Use a domain you own.',\n }\n }\n return { domain, error: null }\n}\n\n/**\n * The mailbox part of an address, validated so a stored `localPart` cannot\n * smuggle a second address or a header into the `From:` line.\n */\nexport function normalizeLocalPart(input: string): string {\n const raw = String(input ?? '')\n .trim()\n .toLowerCase()\n return /^[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?$/.test(raw) ? raw : ''\n}\n\n/*==========================================\n The records the customer must publish\n==========================================*/\n\n/**\n * The subdomain the envelope sender and its bounce routing live on.\n *\n * A subdomain rather than the root for two reasons the customer feels: their\n * existing root SPF is untouched, so their Workspace or Microsoft mail keeps\n * authenticating; and this SPF does not spend any of the root record's\n * ten-lookup budget, which is a limit that fails closed and is easy to reach.\n */\nexport const SENDING_SUBDOMAIN = 'send'\n\n/**\n * `||` not `??`, matching the rest of the env reads in this repo: an empty\n * string is a variable somebody set to nothing, not a configured value, and an\n * empty SPF include would print an instruction that authorizes no one.\n *\n * Configurable because a self-host operator may front a different provider —\n * the same reason `AGLYN_DOMAIN_PROVIDER` exists. The defaults describe the\n * provider this deployment actually uses.\n */\nexport function sendingSpfInclude(): string {\n return process.env.AGLYN_EMAIL_SPF_INCLUDE || 'amazonses.com'\n}\n\nexport function sendingReturnPathHost(): string {\n return (\n process.env.AGLYN_EMAIL_RETURN_PATH_HOST ||\n 'feedback-smtp.us-east-1.amazonses.com'\n )\n}\n\n/**\n * The label the CLICK-TRACKING host hangs off the sending domain.\n *\n * An ESP measures clicks by rewriting every `<a href>` to point at this host\n * and redirecting from it, so it needs a name of its own with a TLS\n * certificate. `links` is what the platform's own domains already use, and\n * keeping one label across every domain class means an operator reading a\n * zone sees the same name everywhere.\n */\nexport const SENDING_TRACKING_SUBDOMAIN = 'links'\n\n/**\n * The certificate authority the tracking host's TLS certificate comes from.\n *\n * Only ever consulted to build a CAA record, and only matters for a domain\n * that ALREADY publishes CAA — see the note on the record itself. Configurable\n * for the same reason {@link sendingSpfInclude} is: a self-host operator\n * fronting a different provider fronts a different CA.\n */\nexport function sendingTrackingCertAuthority(): string {\n return process.env.AGLYN_EMAIL_TRACKING_CA || 'amazon.com'\n}\n\n/**\n * How long a tracked domain is held before its provider object is released.\n *\n * Every link in every message a tracked domain has already sent points at\n * that domain's tracking host, and the provider deletes the host with the\n * domain — a tracking subdomain cannot even be removed on its own, precisely\n * because live mail points at it. So a same-day teardown does not merely stop\n * future tracking, it retroactively breaks the links in mail already\n * delivered, for recipients who have done nothing.\n *\n * 30 days is the window most campaign clicks arrive in, and it costs one\n * provider domain slot per released site for that long — the trade is\n * deliberate and the ceiling is documented in `provision-sending-domain.ts`.\n * `0` disables the hold for an operator who would rather have the slot back.\n *\n * ⚠️ It does NOT gate an erasure. A person asking to be erased outranks a\n * link, and the caller passes `immediate` for that path.\n */\nexport function sendingTrackingRetentionDays(): number {\n const raw = Number(process.env.AGLYN_SENDING_TRACKING_RETENTION_DAYS)\n return Number.isFinite(raw) && raw >= 0 ? raw : 30\n}\n\n/** What a record is for, so a surface can group and explain rather than dump. */\nexport type SendingRecordPurpose =\n | 'spf'\n | 'dkim'\n | 'return-path'\n | 'dmarc'\n | 'tracking'\n | 'tracking-caa'\n\n/** One DNS record, as the customer's registrar labels it. */\nexport interface SendingDnsRecord {\n type: 'TXT' | 'MX' | 'CNAME' | 'CAA'\n /** Fully-qualified name the record goes on. */\n name: string\n value: string\n /** `MX` only. */\n priority?: number\n purpose: SendingRecordPurpose\n /**\n * Whether verification waits on it. DMARC never blocks: it is the\n * customer's policy about their own domain and we must not make publishing\n * one a condition of using our product.\n */\n required: boolean\n /** Why this record exists, in a sentence aimed at whoever edits the zone. */\n note: string\n}\n\n/**\n * Every record for a domain, in the order a customer should create them.\n *\n * ONE function, so the records a card prints and the records the verifier\n * accepts cannot drift apart. `tenant-dns.ts` carries the same invariant for\n * site domains and documents what it cost to learn: a wizard printing one\n * target while the route checked another produced a check that could not fail,\n * then a check that could not pass, over three separate issues.\n *\n * The DKIM value is absent until the provider issues a key. That is a real\n * state, not an error — {@link sendingDomainRequiredRecords} treats a record\n * with no value as unpublishable and keeps the domain out of `records-issued`.\n */\nexport function sendingDnsRecords(\n record: Pick<\n SendingDomainRecord,\n | 'domain'\n | 'dkimSelector'\n | 'dkimPublicKey'\n | 'returnPathHost'\n | 'trackingTarget'\n >,\n): SendingDnsRecord[] {\n const domain = normalizeSendingDomain(record?.domain)\n const selector = String(record?.dkimSelector ?? '').trim() || 'aglyn'\n const sendHost = `${SENDING_SUBDOMAIN}.${domain}`\n const returnPath = record?.returnPathHost || sendingReturnPathHost()\n const dkimKey = String(record?.dkimPublicKey ?? '').trim()\n\n return [\n {\n type: 'TXT',\n name: sendHost,\n value: `v=spf1 include:${sendingSpfInclude()} ~all`,\n purpose: 'spf',\n required: true,\n note:\n `Authorizes our infrastructure for mail whose envelope sender is ` +\n `${sendHost}. It sits on this subdomain so your existing root SPF is ` +\n `untouched.`,\n },\n {\n type: 'TXT',\n name: `${selector}._domainkey.${domain}`,\n value: dkimKey ? `p=${dkimKey}` : '',\n purpose: 'dkim',\n required: true,\n note:\n 'The signing key. This is the record DMARC alignment depends on — ' +\n 'without it, mail from your domain fails whatever policy you publish.',\n },\n {\n type: 'MX',\n name: sendHost,\n value: returnPath,\n priority: 10,\n purpose: 'return-path',\n required: true,\n note:\n 'Routes bounces and spam complaints back to us so they can be ' +\n 'suppressed. It is on the send subdomain and does not affect mail ' +\n 'delivered to your normal inboxes.',\n },\n ...trackingRecords(domain, record?.trackingTarget),\n ]\n}\n\n/**\n * The two records that make CLICK TRACKING work, and neither is required.\n *\n * An ESP measures a click by rewriting every `<a href>` in the HTML part to\n * point at a tracking host on the sending domain, then redirecting. No host,\n * no rewriting, and the click rate is structurally 0% — which reads on a\n * dashboard as low engagement rather than as a wiring fault, so it stays\n * unnoticed. That is the same shape as the `\"html\": \"\"` defect this library\n * already carries a fix for, one layer up.\n *\n * ## Why `required: false`\n *\n * Verification is about AUTHENTICATION — can this domain prove it sent the\n * mail. Tracking is measurement. A customer who publishes SPF, DKIM and the\n * return path can send perfectly well and must not be held at `requested`\n * over a record that only decides whether we can count clicks; and making\n * these required would un-verify every domain already verified without them.\n * {@link sendingDomainPublishableRecords} is what puts them in a zone we own.\n *\n * ## ⚠️ The CAA record is the one that can hurt\n *\n * CAA restricts which authorities may issue a certificate for a name, and the\n * lookup stops at the FIRST name in the tree that publishes any. So:\n *\n * - A domain publishing NO CAA today needs nothing. Any authority may\n * already issue, and adding this record would be the change that starts\n * restricting them.\n * - A domain that DOES publish CAA has to ADD this one alongside what it has,\n * never in place of it. Replacing the set is how a zone stops its own web\n * certificates renewing.\n *\n * It is emitted on the sending domain rather than on the registrable root so\n * that a customer following it verbatim scopes the permission to the name we\n * put a tracking host under, instead of widening it across everything they\n * own. Deriving the registrable root would need a public-suffix list this\n * library does not have, and a guess at it prints a record for the wrong name.\n *\n * @returns nothing at all until the provider has issued a tracking target.\n * A CNAME with no value is a record that says nothing while looking\n * published, the same rule the DKIM row follows.\n */\nfunction trackingRecords(\n domain: string,\n trackingTarget: string | null | undefined,\n): SendingDnsRecord[] {\n const target = String(trackingTarget ?? '').trim()\n if (!domain || !target) return []\n const authority = sendingTrackingCertAuthority()\n\n return [\n {\n type: 'CNAME',\n name: `${SENDING_TRACKING_SUBDOMAIN}.${domain}`,\n value: target,\n purpose: 'tracking',\n required: false,\n note:\n 'Counts link clicks. Every link in an email is rewritten to point ' +\n 'here and redirected, so without it clicks cannot be measured at ' +\n 'all — mail still sends, and the click rate reads a permanent 0%.',\n },\n {\n type: 'CAA',\n name: domain,\n value: `0 issue \"${authority}\"`,\n purpose: 'tracking-caa',\n required: false,\n note:\n `Only needed if this domain already publishes CAA records. Add this ` +\n `one ALONGSIDE them — replacing the set would stop your other ` +\n `certificates renewing. If you publish no CAA at all, skip it: any ` +\n `authority may already issue, and ${authority} is the one that ` +\n `issues the certificate for the tracking host above.`,\n },\n ]\n}\n\n/** The subset verification waits on, and only those with a value to publish. */\nexport function sendingDomainRequiredRecords(\n record: Parameters<typeof sendingDnsRecords>[0],\n): SendingDnsRecord[] {\n return sendingDnsRecords(record).filter(\n (entry) => entry.required && Boolean(entry.value),\n )\n}\n\n/**\n * Everything worth WRITING into a zone this platform owns.\n *\n * Deliberately wider than {@link sendingDomainRequiredRecords}, and the gap\n * between them is the whole point. `required` answers \"does verification wait\n * on this\", which tracking must not; this answers \"should we publish it\",\n * which tracking must be — a platform subdomain's zone is ours, so there is\n * nobody to ask and no reason to leave the click rate at zero.\n *\n * Reusing the required set here is what left the platform's own subdomains\n * untracked: one flag was being asked two different questions, and the\n * conservative answer to the first silently decided the second.\n *\n * DMARC is still excluded — it carries `required: false` too, but a policy is\n * the domain owner's to choose and this platform publishes one deliberately\n * elsewhere, not as a side effect of provisioning.\n */\nexport function sendingDomainPublishableRecords(\n record: Parameters<typeof sendingDnsRecords>[0],\n): SendingDnsRecord[] {\n return sendingDnsRecords(record).filter(\n (entry) =>\n Boolean(entry.value) &&\n (entry.required || entry.purpose === 'tracking'),\n )\n}\n\n/**\n * A stable key for one record, used to report which are still missing without\n * putting a full DKIM public key into a status document or a log line.\n */\nexport function sendingRecordKey(entry: SendingDnsRecord): string {\n return `${entry.type}:${entry.name}`\n}\n\n/** `TXT send.acme.com → v=spf1 …`, matching `formatDnsInstruction`. */\nexport function formatSendingRecord(entry: SendingDnsRecord): string {\n const target = entry.priority ? `${entry.priority} ${entry.value}` : entry.value\n return `${String(entry.type).padEnd(5)} ${entry.name} → ${target}`\n}\n\n/*==========================================\n DMARC — read and warn, never write\n==========================================*/\n\n/**\n * A customer's DMARC policy is theirs. We read it because it changes what an\n * unverified domain does to their mail, and we must never ask them to weaken\n * it to accommodate us.\n */\nexport type DmarcPolicy = 'reject' | 'quarantine' | 'none' | 'absent'\n\nexport interface DmarcAssessment {\n policy: DmarcPolicy\n /** The record as published, or null when there is none. */\n record: string | null\n /**\n * What this policy does to mail we cannot authenticate. Phrased as a\n * consequence rather than as the record's contents: a customer deciding\n * whether to finish their DNS needs the outcome, not the syntax.\n */\n consequence: string\n}\n\n/**\n * Read a `_dmarc` TXT answer.\n *\n * Only records that actually begin `v=DMARC1` count. A zone often carries\n * unrelated TXT records at any name, and treating the first string found as a\n * policy would report a verification token as `p=none`.\n */\nexport function assessDmarc(records: readonly string[]): DmarcAssessment {\n const found = (records ?? [])\n .map((entry) => String(entry ?? '').trim())\n .find((entry) => /^v\\s*=\\s*DMARC1\\b/i.test(entry))\n\n if (!found) {\n return {\n policy: 'absent',\n record: null,\n consequence:\n 'Your domain publishes no DMARC policy. Mail we cannot authenticate ' +\n 'will usually still be delivered, and anyone may send mail claiming ' +\n 'to be from your domain.',\n }\n }\n\n const policy = /(^|;)\\s*p\\s*=\\s*(reject|quarantine|none)\\b/i.exec(found)\n const value = (policy?.[2] ?? 'none').toLowerCase() as DmarcPolicy\n\n if (value === 'reject') {\n return {\n policy: 'reject',\n record: found,\n consequence:\n 'Your domain publishes p=reject. Until the DKIM record below is ' +\n 'live, every message we send from this domain is refused outright — ' +\n 'not filed as spam, refused.',\n }\n }\n if (value === 'quarantine') {\n return {\n policy: 'quarantine',\n record: found,\n consequence:\n 'Your domain publishes p=quarantine. Until the DKIM record below is ' +\n 'live, our mail from this domain lands in spam, which reads as low ' +\n 'engagement rather than as a configuration problem.',\n }\n }\n return {\n policy: 'none',\n record: found,\n consequence:\n 'Your domain publishes p=none, which monitors but enforces nothing. ' +\n 'Mail we cannot authenticate is still delivered.',\n }\n}\n\n/**\n * The DMARC record we suggest to a domain that has none, offered and never\n * required.\n *\n * `p=none` deliberately: it starts reporting without changing the delivery of\n * any mail the customer already sends from other systems. Recommending\n * `p=reject` to a domain whose other senders are unknown to us would break\n * their invoicing or their helpdesk, and we would not find out.\n */\nexport function dmarcRecommendation(domain: string): SendingDnsRecord {\n return {\n type: 'TXT',\n name: `_dmarc.${normalizeSendingDomain(domain)}`,\n value: 'v=DMARC1; p=none; rua=mailto:dmarc@' + normalizeSendingDomain(domain),\n purpose: 'dmarc',\n required: false,\n note:\n 'Recommended, not required, and yours to set. This starts DMARC in ' +\n 'report-only mode so you can see who sends as your domain before you ' +\n 'enforce anything. Point rua at a mailbox you read.',\n }\n}\n\n/*==========================================\n Did the customer publish the records?\n==========================================*/\n\n/** What a set of lookups saw. Assembled by the durable half, compared here. */\nexport interface SendingDnsObservation {\n /** TXT at `send.<domain>`. */\n spfTxt: readonly string[]\n /** TXT at `<selector>._domainkey.<domain>`. */\n dkimTxt: readonly string[]\n /** MX at `send.<domain>`. */\n mx: readonly { exchange: string; priority: number }[]\n /**\n * False when ANY of the three lookups failed to get an answer.\n *\n * One unreachable lookup poisons the whole observation rather than being\n * treated as an empty one: a partial read cannot distinguish a customer who\n * published two of three records from a resolver that answered twice.\n */\n conclusive: boolean\n}\n\nexport type SendingVerificationStatus =\n | 'verified'\n /** We got answers, and at least one required record is not there. */\n | 'failed'\n /** Nobody answered. Not evidence in either direction. */\n | 'inconclusive'\n\nexport interface SendingVerification {\n status: SendingVerificationStatus\n /** Keys of the required records not seen. Empty when verified. */\n missing: string[]\n}\n\n/**\n * Compare the records we asked for against the records that are live.\n *\n * Pure, and separated from the lookups for the reason `sso-drift-logic.ts` is\n * separated from `sso-provisioning.ts`: the decision a customer's verification\n * rests on should be reachable from a test without standing up DNS, so the\n * route's spec can fake the I/O and run the REAL comparison.\n *\n * `inconclusive` is the load-bearing arm. A resolver outage must not be read\n * as every customer deleting their records at the same instant, so it produces\n * neither `verified` nor `failed` and the caller leaves the stored status\n * alone. This is the same three-state discipline the SSO drift sweep uses, for\n * the same reason.\n *\n * The SPF comparison is a `startsWith` on `v=spf1` plus a search for the\n * include, not an exact match: a zone may legitimately carry a longer policy\n * with extra mechanisms, and demanding our exact string would fail a\n * configuration that works. The DKIM comparison IS exact on the key, because\n * a key that is nearly right is a key that does not sign.\n */\nexport function assessSendingRecords(\n record: Parameters<typeof sendingDnsRecords>[0],\n observation: SendingDnsObservation,\n): SendingVerification {\n const required = sendingDomainRequiredRecords(record)\n\n /*\n * A domain with no issued DKIM key can never verify, whatever else is live.\n *\n * Checked on the DKIM record specifically rather than on the requirement set\n * being empty: SPF and the return path both have values before a key is\n * issued, so a domain with no signing key at all would otherwise satisfy\n * every requirement in the set and reach the sending state. DKIM is the\n * record that must align for DMARC — a domain that cannot sign is exactly\n * the domain this feature must not let send.\n */\n const hasDkim = required.some((entry) => entry.purpose === 'dkim')\n if (!required.length || !hasDkim) {\n return { status: 'failed', missing: ['dkim-key-not-issued'] }\n }\n if (!observation?.conclusive) return { status: 'inconclusive', missing: [] }\n\n const include = `include:${sendingSpfInclude()}`\n const missing: string[] = []\n\n for (const entry of required) {\n const key = sendingRecordKey(entry)\n if (entry.purpose === 'spf') {\n const found = (observation.spfTxt ?? []).some(\n (txt) =>\n /^v\\s*=\\s*spf1\\b/i.test(String(txt ?? '').trim()) &&\n String(txt).toLowerCase().includes(include),\n )\n if (!found) missing.push(key)\n } else if (entry.purpose === 'dkim') {\n const expected = entry.value.replace(/^p=/, '')\n const found = (observation.dkimTxt ?? []).some((txt) =>\n String(txt ?? '')\n .replace(/\\s+/g, '')\n .includes(expected.replace(/\\s+/g, '')),\n )\n if (!found) missing.push(key)\n } else if (entry.purpose === 'return-path') {\n const found = (observation.mx ?? []).some(\n (mx) => String(mx?.exchange ?? '').toLowerCase() === entry.value.toLowerCase(),\n )\n if (!found) missing.push(key)\n }\n }\n\n return missing.length\n ? { status: 'failed', missing }\n : { status: 'verified', missing: [] }\n}\n\n/*==========================================\n Which identity does a send leave on?\n==========================================*/\n\n/**\n * WHO IS SPEAKING in this message, which is the whole of what decides whether\n * `USAGE_EMAIL_FROM` is reachable.\n *\n * `platform` — Aglyn talking to its own customers. Billing, account notices,\n * console password resets, staff alerts. These belong on `aglyn.com` and are\n * the only mail that does.\n *\n * `tenant` — a site talking to its visitors. Marketing AND transactional: a\n * receipt, a booking reminder, a membership password reset are all the tenant\n * speaking, and all of them carry the tenant's list quality. These leave on\n * the site's own domain or they do not leave at all.\n *\n * The distinction is not promotional-versus-not. A merchant who imports a\n * purchased list and mails it produces complaints; if that merchant's receipts\n * ride the same domain as the platform's invoices, one merchant's import\n * degrades every other merchant's account mail. Splitting by CONTENT would put\n * the receipts on the safe domain and leave the reputation shared anyway,\n * because the complaints follow the domain and not the subject line.\n */\nexport type SendingIdentityAudience = 'platform' | 'tenant'\n\n/**\n * WHAT KIND OF MESSAGE this is, which decides whether the shared tenant\n * identity is reachable.\n *\n * `transactional` — the recipient's own action produced it, or a fact about\n * their order or account did. A receipt, a password reset, a booking\n * confirmation, a supplier notice. **Never blocked**, on any tier, by anything\n * in this module: a merchant who cannot send a receipt does not have a\n * degraded product, they have no product.\n *\n * `marketing` — the merchant chose to send it. A campaign, an abandoned-cart\n * sweep, a restock alert, a flow step. It carries the merchant's list quality,\n * so it may only leave on a domain whose reputation is that merchant's own.\n *\n * Orthogonal to {@link SendingIdentityAudience}, which asks WHO is speaking.\n * Both axes are needed and neither implies the other: platform mail is all\n * transactional today, tenant mail is both, and the answer to \"may this leave\n * on the pooled address\" needs the second axis specifically.\n *\n * Defaults to `transactional` wherever it is unset, and the polarity is\n * deliberate — the same one `resolveSendPriority` chose for the same reason.\n * Enumerating what is RESTRICTED means a caller who forgets sends a receipt\n * that goes; enumerating what is permitted means a caller who forgets drops\n * one. The forgotten-marketing case is caught structurally instead, in\n * `sendEmail`, which derives the answer from fields a marketing send is\n * already required to carry rather than from one more thing to remember.\n */\nexport type SendingIdentityPurpose = 'transactional' | 'marketing'\n\nexport interface SendingIdentityInput {\n /**\n * The site's chosen sending domain, or null when it has not chosen one.\n */\n selection?: SendingDomainSelection | null\n /** `USAGE_EMAIL_FROM`, the platform's own verified identity. */\n platformFrom?: string | null\n /**\n * The shared tenant identity — `sharedTenantSendingFrom()`, an address on\n * the mail apex.\n *\n * Passed in rather than read from the environment here, so this module stays\n * pure and free of a dependency on `platform-sending-domain.ts` (which\n * imports from this file; reading it here would be a cycle). The durable\n * half supplies it, which is also the layer that knows whether this\n * deployment has one.\n */\n sharedFrom?: string | null\n /** See {@link SendingIdentityPurpose}. Defaults to `transactional`. */\n purpose?: SendingIdentityPurpose\n /**\n * Whose mail this is. Defaults to `platform`, which is what every caller\n * that resolves a PLATFORM identity means and never has to say.\n *\n * A default is safe here only because the tenant side never reaches this\n * function directly: `resolveHostSendingIdentity` is the single door for\n * host-scoped mail and it passes `tenant` unconditionally. A tenant caller\n * cannot forget the flag, because it is not the tenant caller that sets it.\n */\n audience?: SendingIdentityAudience\n}\n\nexport type SendingIdentitySource =\n /** A domain this site has verified — its own name, or one inside our apex. */\n | 'custom'\n /**\n * The pooled tenant identity on the mail apex. Every kind of mail from a\n * site that has no domain of its own, campaigns included — graded more\n * tightly, because the member is shared with other sites.\n */\n | 'shared'\n /** `USAGE_EMAIL_FROM`. Aglyn's own mail, never a tenant's. */\n | 'platform'\n\n/**\n * Why a send was refused. Every arm is a state a customer can be walked out\n * of, which is the test for whether a refusal is worth having.\n */\nexport type SendingIdentityRefusalCode =\n /** A domain is selected and its DNS is not finished. */\n | 'domain-unverified'\n /** A domain is selected and a lookup found its records wrong or absent. */\n | 'domain-failed'\n /** No custom domain, and the platform identity is not configured either. */\n | 'platform-unconfigured'\n /**\n * TENANT mail from a site with no sending domain, on a deployment that has\n * no shared identity configured either.\n *\n * An OPERATOR fault, not a customer one — the shared identity is derived\n * from the mail apex and needs no tenant action — so it is the tenant-side\n * twin of `platform-unconfigured` rather than something a merchant can fix.\n * It stays a refusal and not a fallback for the same reason that one does:\n * `aglyn.com` carries the platform's own billing and account mail, and a\n * tenant's list quality must never be charged against it.\n */\n | 'tenant-identity-unprovisioned'\n /**\n * MARKETING mail on the pooled identity, carrying no way out.\n *\n * The pool is shared with every other site that has no domain of its own, so\n * a complaint earned here is charged against their mail as well. Nothing\n * earns complaints faster than bulk mail a recipient cannot stop, which is\n * why this is the one thing the pool will not carry.\n *\n * Deliberately narrow. Marketing on the pool is allowed — a merchant with no\n * domain of their own may run campaigns — and it is bounded by the\n * per-workspace reputation breaker, the new-sender ramp and the suppression\n * and cadence checks. This refuses only the message that has defeated the\n * unsubscribe mechanism those controls assume.\n *\n * On a domain the merchant owns the same message sends: there the complaint\n * lands on their own name, and how they spend it is theirs to decide.\n */\n | 'shared-identity-no-unsubscribe'\n\nexport interface SendingIdentityRefusal {\n code: SendingIdentityRefusalCode\n /** The domain at fault, or null for `platform-unconfigured`. */\n domain: string | null\n /** One sentence naming the cause and the next action. */\n message: string\n /** Record keys the last conclusive lookup did not see. */\n missing: string[]\n}\n\n/**\n * What a merchant is told when bulk mail with no opt-out meets the pool.\n *\n * It names a DEFECT rather than a policy, because that is what it is: every\n * marketing path in the product attaches an unsubscribe link, so a message\n * arriving here without one has lost it somewhere a merchant cannot see. The\n * text therefore points at support and at the workaround that is genuinely\n * theirs to take, instead of asking them to fix something they did not break.\n */\nconst POOLED_MARKETING_NO_UNSUBSCRIBE_MESSAGE =\n 'This marketing message has no unsubscribe link, so it was not sent on the ' +\n 'shared Aglyn address. That address is shared with other sites, and bulk ' +\n 'mail nobody can stop earns complaints that are charged against all of ' +\n 'them. Every campaign and automated email the product sends carries an ' +\n 'unsubscribe link, so this is a fault worth reporting. A sending domain of ' +\n 'this site’s own — added or requested at Emails → Sending — is not subject ' +\n 'to this check.'\n\nexport interface SendingIdentityVerdict {\n /** Null whenever `refusal` is set. */\n from: string | null\n /** Null whenever `refusal` is set. */\n source: SendingIdentitySource | null\n domain: string | null\n /**\n * What a surface prints, in every outcome including refusal. Requirement:\n * the surface must always be able to say which identity is in use, so this\n * is never empty and never needs the caller to compose it.\n */\n summary: string\n /** Null on success. Both keys always present — `strictNullChecks` is off. */\n refusal: SendingIdentityRefusal | null\n}\n\n/**\n * Choose the identity a message leaves on, or refuse.\n *\n * The whole rule, and the reason this function exists rather than an inline\n * `?:` at the send site:\n *\n * 1. Selection, `verified` → that identity.\n * 2. Selection of a PLATFORM-ISSUED subdomain, not verified → treated as no\n * selection, and resolved by arms 4-5.\n * 3. Selection of a CUSTOMER'S OWN domain, not verified → **REFUSED**.\n * 4. No selection, `tenant` audience, `marketing` → **REFUSED**.\n * 5. No selection, `tenant` audience, `transactional` → the shared identity,\n * if this deployment has one; **REFUSED** if it does not.\n * 6. No selection, `platform` audience → the platform identity, named as such.\n *\n * There is no arm that reaches ANY other address from an unverified selection\n * a CUSTOMER made. Somebody who has told us to send as their domain has made a\n * statement about what their recipients will see; quietly sending as somebody\n * else instead is not a degraded version of honoring it. That is arm 3, and\n * neither the shared identity nor the platform one is consulted inside it.\n *\n * Arm 2 is the same sentence read the other way. A platform subdomain is a\n * name WE chose, provisioned and pointed the site at — the merchant asked for\n * a domain, not for that name and not for a DNS state — so there is no\n * statement of theirs to contradict, and everything that can leave one\n * unverified is ours: the provider's domain allowance, a zone write, a sweep\n * that has not run. Refusing there would make the dedicated domain a single\n * point of failure for receipts on exactly the tiers that pay for it.\n *\n * Arms 4 and 5 are the site that has chosen nothing. It is not the same case\n * as arm 3 and must not get the same answer: there is no instruction to\n * contradict, and a site that cannot send a receipt is not a site. So\n * transactional mail goes, on the pooled identity, which is what the console\n * has always disclosed. What does NOT go is marketing, because the pool is\n * only usable while nobody is spending it on a list — and arm 2 lands here\n * too, so a campaign from a site whose subdomain is unfinished still refuses.\n *\n * Arm 6 is the platform's own mail and is unreachable from a tenant audience,\n * which is what keeps a merchant's list quality off `aglyn.com`. The `tenant`\n * checks sit ABOVE the `platformFrom` read rather than inside it, so the\n * platform address is not preferred-but-overridable for a tenant — it is\n * simply not an address this audience can reach.\n *\n * Deciding all of it here rather than at the call sites is what makes it a\n * property: `resolveHostSendingIdentity` passes `tenant` for every host-scoped\n * send, so no individual caller has to remember.\n */\nexport function resolveSendingIdentity(\n input: SendingIdentityInput,\n): SendingIdentityVerdict {\n const selection = input?.selection ?? null\n const platformFrom = String(input?.platformFrom ?? '').trim() || null\n const audience: SendingIdentityAudience =\n input?.audience === 'tenant' ? 'tenant' : 'platform'\n const sharedFrom = String(input?.sharedFrom ?? '')\n .trim()\n .toLowerCase()\n // Defaulted to the arm that can never be blocked. See `SendingIdentityPurpose`.\n const purpose: SendingIdentityPurpose =\n input?.purpose === 'marketing' ? 'marketing' : 'transactional'\n\n if (selection) {\n const domain = normalizeSendingDomain(selection.domain)\n const localPart = normalizeLocalPart(selection.localPart)\n const missing = (selection.missing ?? []).map(String).filter(Boolean)\n\n if (selection.status === 'verified' && domain && localPart) {\n const from = `${localPart}@${domain}`\n return {\n from,\n source: 'custom',\n domain,\n summary: `Sending as ${from} on your verified domain ${domain}.`,\n refusal: null,\n }\n }\n\n /*\n * AN UNFINISHED PLATFORM SUBDOMAIN IS NOT A SELECTION, AND FALLS THROUGH.\n *\n * The dedicated subdomain is an OPTIMIZATION and the pool is the\n * GUARANTEE, which is the property that lets the dedicated tier be\n * rationed at all. Everything that can stop one arriving is ours: the\n * provider's account-wide domain allowance, a zone write that failed, a\n * provisioning sweep that has not run yet, DNS that has not propagated.\n * None of it is anything the merchant can act on, and all of it would\n * otherwise refuse their receipts — leaving a PAYING workspace unable to\n * send while a free one, which is never issued a subdomain and therefore\n * never has a selection, sends on the pool perfectly well.\n *\n * So this drops to the unselected arm below, which is the same code path a\n * site with no domain of its own takes. That arm still refuses MARKETING,\n * and it must: the reason a campaign may not leave on the pool — one\n * merchant's complaint rate charged against every other site's password\n * resets — is unrelated to why this site has no domain yet.\n *\n * A CUSTOMER'S OWN domain is the opposite case and is deliberately not\n * included. Arm 2 below still refuses it outright, because there the\n * merchant published DNS and told us what their recipients would see.\n */\n if (selection.platformIssued && audience === 'tenant') {\n return resolveSendingIdentity({\n selection: null,\n platformFrom,\n sharedFrom,\n purpose,\n audience,\n })\n }\n\n // A selected domain that is verified but has no usable address is a\n // storage fault, not a customer one, and it is still not a reason to send\n // as somebody else.\n const code: SendingIdentityRefusalCode =\n selection.status === 'failed' ? 'domain-failed' : 'domain-unverified'\n\n return {\n from: null,\n source: null,\n domain: domain || null,\n summary: `Blocked: ${domain || 'the selected domain'} is not verified.`,\n refusal: {\n code,\n domain: domain || null,\n missing,\n message:\n code === 'domain-failed'\n ? `We checked the DNS for ${domain} and the required records are ` +\n `not published yet, so this send was refused rather than sent ` +\n `from a different address. Publish the records shown on the ` +\n `sending domain card, then verify.`\n : `${domain || 'The selected sending domain'} has not been ` +\n `verified yet, so this send was refused rather than sent from a ` +\n `different address. Publish the records shown on the sending ` +\n `domain card, then verify.`,\n },\n }\n }\n\n /*\n * TENANT mail with nothing selected. The PLATFORM identity is not consulted\n * at all — not preferred-but-overridable, not a last resort. It is simply\n * not an address this audience can reach, which is why these checks sit\n * above the `platformFrom` read rather than inside it.\n */\n if (audience === 'tenant') {\n /*\n * Re-validated here rather than trusted, for the same reason `localPart` is\n * on the selection branch: this is the one function that decides what goes\n * into a `From:` header, and a malformed address reaching the provider is a\n * failed send whose cause names the wrong layer.\n */\n const sharedAt = sharedFrom.lastIndexOf('@')\n const sharedLocal =\n sharedAt > 0 ? normalizeLocalPart(sharedFrom.slice(0, sharedAt)) : ''\n const sharedDomain =\n sharedAt > 0 ? normalizeSendingDomain(sharedFrom.slice(sharedAt + 1)) : ''\n const shared =\n sharedLocal && sharedDomain ? `${sharedLocal}@${sharedDomain}` : ''\n\n if (shared) {\n return {\n from: shared,\n source: 'shared',\n domain: sharedDomain,\n summary:\n purpose === 'marketing'\n ? `Sending as ${shared} on a shared Aglyn domain. Delivery ` +\n 'reputation there is pooled with the other sites using it, so ' +\n 'campaigns are graded against the stricter complaint and ' +\n 'bounce limits until this site sends from a domain of its own.'\n : `Sending as ${shared} on a shared Aglyn domain. Delivery ` +\n 'reputation there is pooled with the other sites using it.',\n refusal: null,\n }\n }\n\n return {\n from: null,\n source: null,\n domain: null,\n summary: 'Blocked: this deployment has no shared sending identity.',\n refusal: {\n code: 'tenant-identity-unprovisioned',\n domain: null,\n missing: [],\n message:\n 'This site has no sending domain of its own, and this deployment ' +\n 'has no shared sending identity configured for it to fall back to, ' +\n 'so the message was refused rather than sent from the platform’s ' +\n 'own address. Nothing on this site can fix it and nothing about ' +\n 'the site is wrong — the shared identity is an operator setting. ' +\n 'Contact support, and note that no mail leaves this site until it ' +\n 'is set.',\n },\n }\n }\n\n if (!platformFrom) {\n return {\n from: null,\n source: null,\n domain: null,\n summary: 'Blocked: no sending identity is configured.',\n refusal: {\n code: 'platform-unconfigured',\n domain: null,\n missing: [],\n message:\n 'This deployment has no sending identity. Set USAGE_EMAIL_FROM, or ' +\n 'verify a custom sending domain.',\n },\n }\n }\n\n const platformDomain = normalizeSendingDomain(platformFrom)\n return {\n from: platformFrom,\n source: 'platform',\n domain: platformDomain || null,\n summary: `Sending as ${platformFrom} on the shared platform domain.`,\n refusal: null,\n }\n}\n\n/**\n * The refusal on a verdict, or null.\n *\n * A function rather than `verdict.refusal` at each call site for the reason\n * `rateLimitedRetryAtMs` is one: with `strictNullChecks` off, consumers cannot\n * narrow this union reliably across the library boundary, and every call site\n * would re-derive the same defensive read.\n */\nexport function sendingIdentityRefusal(\n verdict: SendingIdentityVerdict | null | undefined,\n): SendingIdentityRefusal | null {\n return verdict?.refusal ?? null\n}\n\n/**\n * The one thing the pool will not carry: MARKETING mail with no way out.\n *\n * ## What this is not\n *\n * It is not a marketing gate. A site with no domain of its own may run\n * campaigns on the pool — that is the point of the pool, and refusing it made\n * the whole marketing feature unreachable for every site that had not bought\n * a domain. What bounds one site's ability to spend the pool's reputation is\n * measurement rather than prohibition: the per-workspace reputation breaker\n * grades seven days of complaints and bounces and stops the workspace that is\n * earning them, the new-sender ramp keeps a first import off the pool, and the\n * suppression, topic, cadence and frequency checks refuse the individual\n * message. Those act on the site responsible, which a blanket refusal never\n * did.\n *\n * ## Why THIS one still refuses\n *\n * Every one of those controls is downstream of the recipient being able to\n * stop the mail. Bulk mail with no unsubscribe mechanism does not accumulate\n * complaints slowly enough for a seven-day window to catch it, and on a shared\n * member the complaints are charged to sites that did nothing. So it is\n * refused outright, at the send, where the message is in hand.\n *\n * It is a DEFECT check rather than a policy: every marketing path in the\n * product attaches a link, so this fires only when one has gone missing —\n * an unset `EMAIL_UNSUBSCRIBE_SECRET`, a host with no public origin, or a new\n * caller that forgot. On the pool that misconfiguration is charged to other\n * people, which is what makes it a refusal here and a logged warning on a\n * domain the merchant owns.\n *\n * Takes the verdict rather than the source string so a caller cannot pass the\n * wrong field, and returns the same refusal shape every other arm produces so\n * `sendEmail` has one thing to print.\n *\n * @param hasUnsubscribe whether the message carries an opt-out — a minted URL\n * or a caller-supplied `List-Unsubscribe` header. Passed in because\n * this module never sees the message.\n */\nexport function pooledMarketingRefusal(\n verdict: SendingIdentityVerdict | null | undefined,\n hasUnsubscribe: boolean,\n): SendingIdentityRefusal | null {\n if (verdict?.source !== 'shared') return null\n if (hasUnsubscribe) return null\n return {\n code: 'shared-identity-no-unsubscribe',\n domain: verdict?.domain ?? null,\n missing: [],\n message: POOLED_MARKETING_NO_UNSUBSCRIBE_MESSAGE,\n }\n}\n"],"names":["SENDING_DOMAIN_STATUSES","SENDING_DOMAIN_AT_CAPACITY","safeProviderDetail","input","String","replace","trim","slice","DOMAIN_PATTERN","PUBLIC_EMAIL_DOMAINS","Set","normalizeSendingDomain","raw","toLowerCase","at","lastIndexOf","validateSendingDomain","domain","test","error","has","normalizeLocalPart","SENDING_SUBDOMAIN","sendingSpfInclude","process","env","AGLYN_EMAIL_SPF_INCLUDE","sendingReturnPathHost","AGLYN_EMAIL_RETURN_PATH_HOST","SENDING_TRACKING_SUBDOMAIN","sendingTrackingCertAuthority","AGLYN_EMAIL_TRACKING_CA","sendingTrackingRetentionDays","Number","AGLYN_SENDING_TRACKING_RETENTION_DAYS","isFinite","sendingDnsRecords","record","selector","dkimSelector","sendHost","returnPath","returnPathHost","dkimKey","dkimPublicKey","type","name","value","purpose","required","note","priority","trackingRecords","trackingTarget","target","authority","sendingDomainRequiredRecords","filter","entry","Boolean","sendingDomainPublishableRecords","sendingRecordKey","formatSendingRecord","padEnd","assessDmarc","records","found","map","find","policy","consequence","exec","dmarcRecommendation","assessSendingRecords","observation","hasDkim","some","length","status","missing","conclusive","include","key","spfTxt","txt","includes","push","expected","dkimTxt","mx","exchange","POOLED_MARKETING_NO_UNSUBSCRIBE_MESSAGE","resolveSendingIdentity","selection","platformFrom","audience","sharedFrom","localPart","from","source","summary","refusal","platformIssued","code","message","sharedAt","sharedLocal","sharedDomain","shared","platformDomain","sendingIdentityRefusal","verdict","pooledMarketingRefusal","hasUnsubscribe"],"mappings":"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"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The mailbox a site sends as before anybody chooses one.
|
|
19
|
+
*
|
|
20
|
+
* Here rather than beside either reader, because it had two definitions: the
|
|
21
|
+
* console identity route and `resolveHostSendingIdentity` each carried their
|
|
22
|
+
* own `'hello'`, and a default that is written down twice is a default that
|
|
23
|
+
* disagrees with itself the first time either moves. The route's copy decides
|
|
24
|
+
* what a merchant is shown; the resolver's decides what is actually sent.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DEFAULT_SENDING_LOCAL_PART = "hello";
|
|
27
|
+
/**
|
|
28
|
+
* Mailbox names a site may not send as.
|
|
29
|
+
*
|
|
30
|
+
* Not a spam-word list — every entry is a mailbox that carries a meaning
|
|
31
|
+
* somebody else relies on:
|
|
32
|
+
*
|
|
33
|
+
* `postmaster` and `abuse` are the role mailboxes RFC 2142 requires a domain
|
|
34
|
+
* to RECEIVE at, and mail receivers treat traffic from them as operational.
|
|
35
|
+
* A campaign leaving as `abuse@` is a merchant asserting a role they cannot
|
|
36
|
+
* serve on a name whose complaints route elsewhere.
|
|
37
|
+
*
|
|
38
|
+
* `bounce`, `bounces` and `dmarc` are the return-path and reporting mailboxes
|
|
39
|
+
* on the sending subdomain. On a domain Aglyn issues, the whole zone is ours
|
|
40
|
+
* and those names are already spoken for; on a customer's own domain they are
|
|
41
|
+
* where their DMARC aggregate reports land, and a merchant sending as one
|
|
42
|
+
* would be reading their own campaign replies out of a reporting feed.
|
|
43
|
+
*
|
|
44
|
+
* A short list on purpose. Every additional refusal is a merchant told no for
|
|
45
|
+
* a reason they have to be talked through, so a name earns its place here by
|
|
46
|
+
* being one that BREAKS something rather than one that reads oddly.
|
|
47
|
+
*/
|
|
48
|
+
export declare const RESERVED_SENDING_LOCAL_PARTS: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* `{ value, error }` rather than a discriminated union, matching
|
|
51
|
+
* {@link SendingDomainCheck}: `strictNullChecks` is off repo-wide, so a
|
|
52
|
+
* `{ ok: true } | { ok: false }` union does not narrow across a library
|
|
53
|
+
* boundary. Both keys always present, exactly one of them null.
|
|
54
|
+
*/
|
|
55
|
+
export interface SendingLocalPartCheck {
|
|
56
|
+
localPart: string | null;
|
|
57
|
+
error: string | null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The mailbox name a site asked to send as, or the sentence refusing it.
|
|
61
|
+
*
|
|
62
|
+
* {@link normalizeLocalPart} is the safety half and is already sufficient as
|
|
63
|
+
* one: its pattern is anchored at both ends of a trimmed string over
|
|
64
|
+
* `[a-z0-9._+-]`, so no value it accepts can carry a CR, an LF, an `@`, a
|
|
65
|
+
* quote or an angle bracket — the characters that turn a `From:` line into
|
|
66
|
+
* two headers — and its 64-character ceiling is the local-part limit RFC 5321
|
|
67
|
+
* sets. Nothing here loosens that; this function is called for what it
|
|
68
|
+
* returns when the pattern says no.
|
|
69
|
+
*
|
|
70
|
+
* Which is the reason it exists. `normalizeLocalPart` answers the empty
|
|
71
|
+
* string for anything malformed, and the console route read that answer
|
|
72
|
+
* through `|| DEFAULT_LOCAL_PART` — so a merchant who typed `sales team!`
|
|
73
|
+
* was told their site now sends as `hello@`, which is a real address they did
|
|
74
|
+
* not choose presented as though they had. A refusal that names the rule is
|
|
75
|
+
* the difference between a mistake somebody can correct and a setting that
|
|
76
|
+
* silently disagrees with the person who set it.
|
|
77
|
+
*/
|
|
78
|
+
export declare function validateSendingLocalPart(input: string | null | undefined): SendingLocalPartCheck;
|
|
79
|
+
/**
|
|
80
|
+
* One line of text, safe to put in a header and short enough to fit one.
|
|
81
|
+
*
|
|
82
|
+
* Whitespace and control characters collapse to single spaces rather than
|
|
83
|
+
* being stripped, because CR and LF inside a header value ARE the injection
|
|
84
|
+
* shape and a name written across two lines is a name with a second header
|
|
85
|
+
* after it. Truncation is last so the ceiling is measured over what survives.
|
|
86
|
+
*/
|
|
87
|
+
export declare function headerSafeText(value: string | null | undefined, max: number): string;
|
|
88
|
+
/** The line length an RFC 5322 display name has to live inside. */
|
|
89
|
+
export declare const SENDING_FROM_NAME_MAX = 78;
|
|
90
|
+
/** The length an address may reach before it stops being deliverable. */
|
|
91
|
+
export declare const SENDING_REPLY_TO_MAX = 254;
|
|
92
|
+
/**
|
|
93
|
+
* The sender a message actually left with, as it is stored on the send.
|
|
94
|
+
*
|
|
95
|
+
* `from` is the envelope and header address — the local part and the domain
|
|
96
|
+
* the resolver chose, never a domain a request named. `fromName` is the
|
|
97
|
+
* display name that was in front of it, RESOLVED: the campaign's own name
|
|
98
|
+
* where the composer set one and the org's branding default otherwise, which
|
|
99
|
+
* is what the recipient saw and is not the same fact as the field the
|
|
100
|
+
* composer stored. `replyTo` is present only when the send set one; a message
|
|
101
|
+
* without it takes replies at `from`.
|
|
102
|
+
*/
|
|
103
|
+
export interface SentAsRecord {
|
|
104
|
+
from: string;
|
|
105
|
+
fromName?: string;
|
|
106
|
+
replyTo?: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The fields a send stamps to record the address it left as, or nothing.
|
|
110
|
+
*
|
|
111
|
+
* Spread into the write that closes a send, beside the audience figures and
|
|
112
|
+
* the list name, and for the same reason those are written rather than
|
|
113
|
+
* re-derived: a site's sending identity is a setting, and a merchant who
|
|
114
|
+
* verifies a new domain in November has not changed what went out in March.
|
|
115
|
+
* A report that resolved the identity at read time would answer "what would
|
|
116
|
+
* this send as today", which is a different question from the one a delivery
|
|
117
|
+
* report is asked.
|
|
118
|
+
*
|
|
119
|
+
* Returns an EMPTY object when there is no address, so a caller spreading it
|
|
120
|
+
* writes nothing at all rather than a record whose `from` is blank. A missing
|
|
121
|
+
* `sentAs` is what a message sent before this shipped looks like, and a
|
|
122
|
+
* surface has to be able to tell that apart from a send that recorded an
|
|
123
|
+
* empty address — see `emailSentAs`, which reports it as unrecorded rather
|
|
124
|
+
* than inventing one.
|
|
125
|
+
*/
|
|
126
|
+
export declare function sentAsStamp(options: {
|
|
127
|
+
from?: string | null;
|
|
128
|
+
fromName?: string | null;
|
|
129
|
+
replyTo?: string | null;
|
|
130
|
+
}): {
|
|
131
|
+
sentAs?: SentAsRecord;
|
|
132
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* THE MAILBOX HALF OF A SENDING IDENTITY — which mailbox a site sends as, and
|
|
19
|
+
* what a sent message records about the address it left on.
|
|
20
|
+
*
|
|
21
|
+
* `sending-domain.ts` decides the DOMAIN, which is never free: DMARC on the
|
|
22
|
+
* mail apex is published with `adkim=s`, so the `From:` domain has to be
|
|
23
|
+
* exactly the domain whose DKIM key signed the message. Nothing a merchant
|
|
24
|
+
* types can move it. What a merchant CAN choose is the part in front of the
|
|
25
|
+
* `@`, and this module is where that choice is validated and where the
|
|
26
|
+
* address a message actually left on is turned into a stored record.
|
|
27
|
+
*
|
|
28
|
+
* ## The mailbox is a SITE setting, not a per-send field
|
|
29
|
+
*
|
|
30
|
+
* The two halves of "who is this from" separate on whether the value has to
|
|
31
|
+
* be a real mailbox. A display name and a `Reply-To:` may vary per campaign —
|
|
32
|
+
* they name a person and a destination, and the composer already carries
|
|
33
|
+
* both. The local part may not, because it addresses the mailbox that a
|
|
34
|
+
* bounce comes back to and that a mail client ignoring `Reply-To:` will
|
|
35
|
+
* answer. A mailbox that exists in one campaign's headers and nowhere else
|
|
36
|
+
* is an address nobody serves.
|
|
37
|
+
*
|
|
38
|
+
* `campaign-send.ts` states the same boundary from the other side: the
|
|
39
|
+
* sending identity named in a send request is read by nothing, because the
|
|
40
|
+
* address is resolved from the host document. Accepting a per-send local part
|
|
41
|
+
* would reintroduce exactly the input path that closure removed.
|
|
42
|
+
*
|
|
43
|
+
* ## Pure, for the reason `sending-domain.ts` is pure
|
|
44
|
+
*
|
|
45
|
+
* Validating a mailbox name and composing the record of a send are decisions
|
|
46
|
+
* about strings. They are unit-testable with no Firestore harness and no
|
|
47
|
+
* route, and they are needed on both sides of the wire — the console route
|
|
48
|
+
* that stores the choice and the surface that renders what a send recorded.
|
|
49
|
+
*/ import { normalizeLocalPart } from "./sending-domain.js";
|
|
50
|
+
/*==========================================
|
|
51
|
+
The mailbox a site sends as
|
|
52
|
+
==========================================*/ /**
|
|
53
|
+
* The mailbox a site sends as before anybody chooses one.
|
|
54
|
+
*
|
|
55
|
+
* Here rather than beside either reader, because it had two definitions: the
|
|
56
|
+
* console identity route and `resolveHostSendingIdentity` each carried their
|
|
57
|
+
* own `'hello'`, and a default that is written down twice is a default that
|
|
58
|
+
* disagrees with itself the first time either moves. The route's copy decides
|
|
59
|
+
* what a merchant is shown; the resolver's decides what is actually sent.
|
|
60
|
+
*/ export const DEFAULT_SENDING_LOCAL_PART = 'hello';
|
|
61
|
+
/**
|
|
62
|
+
* Mailbox names a site may not send as.
|
|
63
|
+
*
|
|
64
|
+
* Not a spam-word list — every entry is a mailbox that carries a meaning
|
|
65
|
+
* somebody else relies on:
|
|
66
|
+
*
|
|
67
|
+
* `postmaster` and `abuse` are the role mailboxes RFC 2142 requires a domain
|
|
68
|
+
* to RECEIVE at, and mail receivers treat traffic from them as operational.
|
|
69
|
+
* A campaign leaving as `abuse@` is a merchant asserting a role they cannot
|
|
70
|
+
* serve on a name whose complaints route elsewhere.
|
|
71
|
+
*
|
|
72
|
+
* `bounce`, `bounces` and `dmarc` are the return-path and reporting mailboxes
|
|
73
|
+
* on the sending subdomain. On a domain Aglyn issues, the whole zone is ours
|
|
74
|
+
* and those names are already spoken for; on a customer's own domain they are
|
|
75
|
+
* where their DMARC aggregate reports land, and a merchant sending as one
|
|
76
|
+
* would be reading their own campaign replies out of a reporting feed.
|
|
77
|
+
*
|
|
78
|
+
* A short list on purpose. Every additional refusal is a merchant told no for
|
|
79
|
+
* a reason they have to be talked through, so a name earns its place here by
|
|
80
|
+
* being one that BREAKS something rather than one that reads oddly.
|
|
81
|
+
*/ export const RESERVED_SENDING_LOCAL_PARTS = [
|
|
82
|
+
'abuse',
|
|
83
|
+
'bounce',
|
|
84
|
+
'bounces',
|
|
85
|
+
'dmarc',
|
|
86
|
+
'postmaster'
|
|
87
|
+
];
|
|
88
|
+
/**
|
|
89
|
+
* The mailbox name a site asked to send as, or the sentence refusing it.
|
|
90
|
+
*
|
|
91
|
+
* {@link normalizeLocalPart} is the safety half and is already sufficient as
|
|
92
|
+
* one: its pattern is anchored at both ends of a trimmed string over
|
|
93
|
+
* `[a-z0-9._+-]`, so no value it accepts can carry a CR, an LF, an `@`, a
|
|
94
|
+
* quote or an angle bracket — the characters that turn a `From:` line into
|
|
95
|
+
* two headers — and its 64-character ceiling is the local-part limit RFC 5321
|
|
96
|
+
* sets. Nothing here loosens that; this function is called for what it
|
|
97
|
+
* returns when the pattern says no.
|
|
98
|
+
*
|
|
99
|
+
* Which is the reason it exists. `normalizeLocalPart` answers the empty
|
|
100
|
+
* string for anything malformed, and the console route read that answer
|
|
101
|
+
* through `|| DEFAULT_LOCAL_PART` — so a merchant who typed `sales team!`
|
|
102
|
+
* was told their site now sends as `hello@`, which is a real address they did
|
|
103
|
+
* not choose presented as though they had. A refusal that names the rule is
|
|
104
|
+
* the difference between a mistake somebody can correct and a setting that
|
|
105
|
+
* silently disagrees with the person who set it.
|
|
106
|
+
*/ export function validateSendingLocalPart(input) {
|
|
107
|
+
const raw = String(input != null ? input : '').trim();
|
|
108
|
+
if (!raw) {
|
|
109
|
+
return {
|
|
110
|
+
localPart: null,
|
|
111
|
+
error: 'Enter a mailbox name — the part of the address before the @.'
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (raw.includes('@')) {
|
|
115
|
+
return {
|
|
116
|
+
localPart: null,
|
|
117
|
+
error: 'Enter only the part before the @. The domain is your site’s ' + 'verified sending domain and cannot be changed here.'
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const localPart = normalizeLocalPart(raw);
|
|
121
|
+
if (!localPart) {
|
|
122
|
+
return {
|
|
123
|
+
localPart: null,
|
|
124
|
+
error: 'A mailbox name uses letters, numbers, dots, dashes, plus signs and ' + 'underscores, starts and ends with a letter or number, and is at ' + 'most 64 characters. For example hello, sales or jamie.'
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (RESERVED_SENDING_LOCAL_PARTS.includes(localPart)) {
|
|
128
|
+
return {
|
|
129
|
+
localPart: null,
|
|
130
|
+
error: `${localPart}@ is reserved. Mail receivers and DMARC reporting treat ` + 'it as an operational mailbox, so sending as it would misroute ' + 'replies and complaints. Choose another name.'
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
localPart,
|
|
135
|
+
error: null
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/*==========================================
|
|
139
|
+
Header-safe text
|
|
140
|
+
==========================================*/ /**
|
|
141
|
+
* One line of text, safe to put in a header and short enough to fit one.
|
|
142
|
+
*
|
|
143
|
+
* Whitespace and control characters collapse to single spaces rather than
|
|
144
|
+
* being stripped, because CR and LF inside a header value ARE the injection
|
|
145
|
+
* shape and a name written across two lines is a name with a second header
|
|
146
|
+
* after it. Truncation is last so the ceiling is measured over what survives.
|
|
147
|
+
*/ export function headerSafeText(value, max) {
|
|
148
|
+
return String(value != null ? value : '')/*
|
|
149
|
+
* `\p{Cc}` names the control block by its Unicode property rather than
|
|
150
|
+
* by a numeric range, which is what keeps the pattern readable as an
|
|
151
|
+
* intention — the control characters are the POINT of this class, not
|
|
152
|
+
* an accident in it — and keeps the file free of the literal escapes
|
|
153
|
+
* that make a header-safety guard hard to review.
|
|
154
|
+
*/ .replace(RegExp("[\\u0000-\\u001F\\u007F-\\u009F\\s]+", "gu"), ' ').trim().slice(0, Math.max(0, max));
|
|
155
|
+
}
|
|
156
|
+
/** The line length an RFC 5322 display name has to live inside. */ export const SENDING_FROM_NAME_MAX = 78;
|
|
157
|
+
/** The length an address may reach before it stops being deliverable. */ export const SENDING_REPLY_TO_MAX = 254;
|
|
158
|
+
/**
|
|
159
|
+
* The fields a send stamps to record the address it left as, or nothing.
|
|
160
|
+
*
|
|
161
|
+
* Spread into the write that closes a send, beside the audience figures and
|
|
162
|
+
* the list name, and for the same reason those are written rather than
|
|
163
|
+
* re-derived: a site's sending identity is a setting, and a merchant who
|
|
164
|
+
* verifies a new domain in November has not changed what went out in March.
|
|
165
|
+
* A report that resolved the identity at read time would answer "what would
|
|
166
|
+
* this send as today", which is a different question from the one a delivery
|
|
167
|
+
* report is asked.
|
|
168
|
+
*
|
|
169
|
+
* Returns an EMPTY object when there is no address, so a caller spreading it
|
|
170
|
+
* writes nothing at all rather than a record whose `from` is blank. A missing
|
|
171
|
+
* `sentAs` is what a message sent before this shipped looks like, and a
|
|
172
|
+
* surface has to be able to tell that apart from a send that recorded an
|
|
173
|
+
* empty address — see `emailSentAs`, which reports it as unrecorded rather
|
|
174
|
+
* than inventing one.
|
|
175
|
+
*/ export function sentAsStamp(options) {
|
|
176
|
+
var _ref;
|
|
177
|
+
const from = String((_ref = options == null ? void 0 : options.from) != null ? _ref : '').trim().toLowerCase();
|
|
178
|
+
if (!from.includes('@')) return {};
|
|
179
|
+
const fromName = headerSafeText(options == null ? void 0 : options.fromName, SENDING_FROM_NAME_MAX);
|
|
180
|
+
const replyTo = headerSafeText(options == null ? void 0 : options.replyTo, SENDING_REPLY_TO_MAX).toLowerCase();
|
|
181
|
+
return {
|
|
182
|
+
sentAs: _extends({
|
|
183
|
+
from
|
|
184
|
+
}, fromName ? {
|
|
185
|
+
fromName
|
|
186
|
+
} : {}, replyTo ? {
|
|
187
|
+
replyTo
|
|
188
|
+
} : {})
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
//# sourceMappingURL=sending-mailbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/sending-mailbox.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 * THE MAILBOX HALF OF A SENDING IDENTITY — which mailbox a site sends as, and\n * what a sent message records about the address it left on.\n *\n * `sending-domain.ts` decides the DOMAIN, which is never free: DMARC on the\n * mail apex is published with `adkim=s`, so the `From:` domain has to be\n * exactly the domain whose DKIM key signed the message. Nothing a merchant\n * types can move it. What a merchant CAN choose is the part in front of the\n * `@`, and this module is where that choice is validated and where the\n * address a message actually left on is turned into a stored record.\n *\n * ## The mailbox is a SITE setting, not a per-send field\n *\n * The two halves of \"who is this from\" separate on whether the value has to\n * be a real mailbox. A display name and a `Reply-To:` may vary per campaign —\n * they name a person and a destination, and the composer already carries\n * both. The local part may not, because it addresses the mailbox that a\n * bounce comes back to and that a mail client ignoring `Reply-To:` will\n * answer. A mailbox that exists in one campaign's headers and nowhere else\n * is an address nobody serves.\n *\n * `campaign-send.ts` states the same boundary from the other side: the\n * sending identity named in a send request is read by nothing, because the\n * address is resolved from the host document. Accepting a per-send local part\n * would reintroduce exactly the input path that closure removed.\n *\n * ## Pure, for the reason `sending-domain.ts` is pure\n *\n * Validating a mailbox name and composing the record of a send are decisions\n * about strings. They are unit-testable with no Firestore harness and no\n * route, and they are needed on both sides of the wire — the console route\n * that stores the choice and the surface that renders what a send recorded.\n */\n\nimport { normalizeLocalPart } from './sending-domain'\n\n/*==========================================\n The mailbox a site sends as\n==========================================*/\n\n/**\n * The mailbox a site sends as before anybody chooses one.\n *\n * Here rather than beside either reader, because it had two definitions: the\n * console identity route and `resolveHostSendingIdentity` each carried their\n * own `'hello'`, and a default that is written down twice is a default that\n * disagrees with itself the first time either moves. The route's copy decides\n * what a merchant is shown; the resolver's decides what is actually sent.\n */\nexport const DEFAULT_SENDING_LOCAL_PART = 'hello'\n\n/**\n * Mailbox names a site may not send as.\n *\n * Not a spam-word list — every entry is a mailbox that carries a meaning\n * somebody else relies on:\n *\n * `postmaster` and `abuse` are the role mailboxes RFC 2142 requires a domain\n * to RECEIVE at, and mail receivers treat traffic from them as operational.\n * A campaign leaving as `abuse@` is a merchant asserting a role they cannot\n * serve on a name whose complaints route elsewhere.\n *\n * `bounce`, `bounces` and `dmarc` are the return-path and reporting mailboxes\n * on the sending subdomain. On a domain Aglyn issues, the whole zone is ours\n * and those names are already spoken for; on a customer's own domain they are\n * where their DMARC aggregate reports land, and a merchant sending as one\n * would be reading their own campaign replies out of a reporting feed.\n *\n * A short list on purpose. Every additional refusal is a merchant told no for\n * a reason they have to be talked through, so a name earns its place here by\n * being one that BREAKS something rather than one that reads oddly.\n */\nexport const RESERVED_SENDING_LOCAL_PARTS: readonly string[] = [\n 'abuse',\n 'bounce',\n 'bounces',\n 'dmarc',\n 'postmaster',\n]\n\n/**\n * `{ value, error }` rather than a discriminated union, matching\n * {@link SendingDomainCheck}: `strictNullChecks` is off repo-wide, so a\n * `{ ok: true } | { ok: false }` union does not narrow across a library\n * boundary. Both keys always present, exactly one of them null.\n */\nexport interface SendingLocalPartCheck {\n localPart: string | null\n error: string | null\n}\n\n/**\n * The mailbox name a site asked to send as, or the sentence refusing it.\n *\n * {@link normalizeLocalPart} is the safety half and is already sufficient as\n * one: its pattern is anchored at both ends of a trimmed string over\n * `[a-z0-9._+-]`, so no value it accepts can carry a CR, an LF, an `@`, a\n * quote or an angle bracket — the characters that turn a `From:` line into\n * two headers — and its 64-character ceiling is the local-part limit RFC 5321\n * sets. Nothing here loosens that; this function is called for what it\n * returns when the pattern says no.\n *\n * Which is the reason it exists. `normalizeLocalPart` answers the empty\n * string for anything malformed, and the console route read that answer\n * through `|| DEFAULT_LOCAL_PART` — so a merchant who typed `sales team!`\n * was told their site now sends as `hello@`, which is a real address they did\n * not choose presented as though they had. A refusal that names the rule is\n * the difference between a mistake somebody can correct and a setting that\n * silently disagrees with the person who set it.\n */\nexport function validateSendingLocalPart(\n input: string | null | undefined,\n): SendingLocalPartCheck {\n const raw = String(input ?? '').trim()\n if (!raw) {\n return {\n localPart: null,\n error: 'Enter a mailbox name — the part of the address before the @.',\n }\n }\n if (raw.includes('@')) {\n return {\n localPart: null,\n error:\n 'Enter only the part before the @. The domain is your site’s ' +\n 'verified sending domain and cannot be changed here.',\n }\n }\n const localPart = normalizeLocalPart(raw)\n if (!localPart) {\n return {\n localPart: null,\n error:\n 'A mailbox name uses letters, numbers, dots, dashes, plus signs and ' +\n 'underscores, starts and ends with a letter or number, and is at ' +\n 'most 64 characters. For example hello, sales or jamie.',\n }\n }\n if (RESERVED_SENDING_LOCAL_PARTS.includes(localPart)) {\n return {\n localPart: null,\n error:\n `${localPart}@ is reserved. Mail receivers and DMARC reporting treat ` +\n 'it as an operational mailbox, so sending as it would misroute ' +\n 'replies and complaints. Choose another name.',\n }\n }\n return { localPart, error: null }\n}\n\n/*==========================================\n Header-safe text\n==========================================*/\n\n/**\n * One line of text, safe to put in a header and short enough to fit one.\n *\n * Whitespace and control characters collapse to single spaces rather than\n * being stripped, because CR and LF inside a header value ARE the injection\n * shape and a name written across two lines is a name with a second header\n * after it. Truncation is last so the ceiling is measured over what survives.\n */\nexport function headerSafeText(\n value: string | null | undefined,\n max: number,\n): string {\n return (\n String(value ?? '')\n /*\n * `\\p{Cc}` names the control block by its Unicode property rather than\n * by a numeric range, which is what keeps the pattern readable as an\n * intention — the control characters are the POINT of this class, not\n * an accident in it — and keeps the file free of the literal escapes\n * that make a header-safety guard hard to review.\n */\n .replace(/[\\p{Cc}\\s]+/gu, ' ')\n .trim()\n .slice(0, Math.max(0, max))\n )\n}\n\n/** The line length an RFC 5322 display name has to live inside. */\nexport const SENDING_FROM_NAME_MAX = 78\n\n/** The length an address may reach before it stops being deliverable. */\nexport const SENDING_REPLY_TO_MAX = 254\n\n/*==========================================\n What a message recorded about its sender\n==========================================*/\n\n/**\n * The sender a message actually left with, as it is stored on the send.\n *\n * `from` is the envelope and header address — the local part and the domain\n * the resolver chose, never a domain a request named. `fromName` is the\n * display name that was in front of it, RESOLVED: the campaign's own name\n * where the composer set one and the org's branding default otherwise, which\n * is what the recipient saw and is not the same fact as the field the\n * composer stored. `replyTo` is present only when the send set one; a message\n * without it takes replies at `from`.\n */\nexport interface SentAsRecord {\n from: string\n fromName?: string\n replyTo?: string\n}\n\n/**\n * The fields a send stamps to record the address it left as, or nothing.\n *\n * Spread into the write that closes a send, beside the audience figures and\n * the list name, and for the same reason those are written rather than\n * re-derived: a site's sending identity is a setting, and a merchant who\n * verifies a new domain in November has not changed what went out in March.\n * A report that resolved the identity at read time would answer \"what would\n * this send as today\", which is a different question from the one a delivery\n * report is asked.\n *\n * Returns an EMPTY object when there is no address, so a caller spreading it\n * writes nothing at all rather than a record whose `from` is blank. A missing\n * `sentAs` is what a message sent before this shipped looks like, and a\n * surface has to be able to tell that apart from a send that recorded an\n * empty address — see `emailSentAs`, which reports it as unrecorded rather\n * than inventing one.\n */\nexport function sentAsStamp(options: {\n from?: string | null\n fromName?: string | null\n replyTo?: string | null\n}): { sentAs?: SentAsRecord } {\n const from = String(options?.from ?? '')\n .trim()\n .toLowerCase()\n if (!from.includes('@')) return {}\n const fromName = headerSafeText(options?.fromName, SENDING_FROM_NAME_MAX)\n const replyTo = headerSafeText(\n options?.replyTo,\n SENDING_REPLY_TO_MAX,\n ).toLowerCase()\n return {\n sentAs: {\n from,\n ...(fromName ? { fromName } : {}),\n ...(replyTo ? { replyTo } : {}),\n },\n }\n}\n"],"names":["normalizeLocalPart","DEFAULT_SENDING_LOCAL_PART","RESERVED_SENDING_LOCAL_PARTS","validateSendingLocalPart","input","raw","String","trim","localPart","error","includes","headerSafeText","value","max","replace","slice","Math","SENDING_FROM_NAME_MAX","SENDING_REPLY_TO_MAX","sentAsStamp","options","from","toLowerCase","fromName","replyTo","sentAs"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GAED,SAASA,kBAAkB,QAAQ,sBAAkB;AAErD;;0CAE0C,GAE1C;;;;;;;;CAQC,GACD,OAAO,MAAMC,6BAA6B,QAAO;AAEjD;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAMC,+BAAkD;IAC7D;IACA;IACA;IACA;IACA;CACD,CAAA;AAaD;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,yBACdC,KAAgC;IAEhC,MAAMC,MAAMC,OAAOF,gBAAAA,QAAS,IAAIG,IAAI;IACpC,IAAI,CAACF,KAAK;QACR,OAAO;YACLG,WAAW;YACXC,OAAO;QACT;IACF;IACA,IAAIJ,IAAIK,QAAQ,CAAC,MAAM;QACrB,OAAO;YACLF,WAAW;YACXC,OACE,iEACA;QACJ;IACF;IACA,MAAMD,YAAYR,mBAAmBK;IACrC,IAAI,CAACG,WAAW;QACd,OAAO;YACLA,WAAW;YACXC,OACE,wEACA,qEACA;QACJ;IACF;IACA,IAAIP,6BAA6BQ,QAAQ,CAACF,YAAY;QACpD,OAAO;YACLA,WAAW;YACXC,OACE,GAAGD,UAAU,wDAAwD,CAAC,GACtE,mEACA;QACJ;IACF;IACA,OAAO;QAAEA;QAAWC,OAAO;IAAK;AAClC;AAEA;;0CAE0C,GAE1C;;;;;;;CAOC,GACD,OAAO,SAASE,eACdC,KAAgC,EAChCC,GAAW;IAEX,OACEP,OAAOM,gBAAAA,QAAS,GACd;;;;;;OAMC,IACAE,OAAO,CAAC,sDAAiB,KACzBP,IAAI,GACJQ,KAAK,CAAC,GAAGC,KAAKH,GAAG,CAAC,GAAGA;AAE5B;AAEA,iEAAiE,GACjE,OAAO,MAAMI,wBAAwB,GAAE;AAEvC,uEAAuE,GACvE,OAAO,MAAMC,uBAAuB,IAAG;AAuBvC;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,YAAYC,OAI3B;;IACC,MAAMC,OAAOf,eAAOc,2BAAAA,QAASC,IAAI,mBAAI,IAClCd,IAAI,GACJe,WAAW;IACd,IAAI,CAACD,KAAKX,QAAQ,CAAC,MAAM,OAAO,CAAC;IACjC,MAAMa,WAAWZ,eAAeS,2BAAAA,QAASG,QAAQ,EAAEN;IACnD,MAAMO,UAAUb,eACdS,2BAAAA,QAASI,OAAO,EAChBN,sBACAI,WAAW;IACb,OAAO;QACLG,QAAQ;YACNJ;WACIE,WAAW;YAAEA;QAAS,IAAI,CAAC,GAC3BC,UAAU;YAAEA;QAAQ,IAAI,CAAC;IAEjC;AACF"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A COPY of `decodeStoredNodes` from `@aglyn/aglyn` (AGL-1223), for the same
|
|
19
|
+
* reason `email-media-src.ts` carries a copy of `resolveMediaSrc`:
|
|
20
|
+
* `shared-util-email` is tagged `scope:shared`, the module-boundary rule makes
|
|
21
|
+
* shared libs leaves, and the arrow points the other way so every send site
|
|
22
|
+
* can pull the email renderer without the framework.
|
|
23
|
+
*
|
|
24
|
+
* `email-nodes-drift.spec.ts` in the console — which may import both — runs
|
|
25
|
+
* the two implementations over one table of inputs, so a divergence fails a
|
|
26
|
+
* build rather than silently changing what a recipient receives.
|
|
27
|
+
*
|
|
28
|
+
* ## Why an email loader needs this at all
|
|
29
|
+
*
|
|
30
|
+
* An email version's `nodes` is stored in the same two live forms every other
|
|
31
|
+
* besigner document uses: a plain Firestore map, and msgpack bytes. Reading
|
|
32
|
+
* the field raw is not a loud failure — a `Buffer` walks, `Object.keys` over
|
|
33
|
+
* one returns BYTE INDICES rather than nothing, so an emptiness guard passes
|
|
34
|
+
* and the send renders an empty email instead of falling back to its built-in
|
|
35
|
+
* copy.
|
|
36
|
+
*/
|
|
37
|
+
export declare function decodeEmailNodes<T = Record<string, unknown>>(raw: unknown): T | null;
|
|
38
|
+
export default decodeEmailNodes;
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
*/ import { decode } from "@msgpack/msgpack";
|
|
17
|
+
/**
|
|
18
|
+
* A COPY of `decodeStoredNodes` from `@aglyn/aglyn` (AGL-1223), for the same
|
|
19
|
+
* reason `email-media-src.ts` carries a copy of `resolveMediaSrc`:
|
|
20
|
+
* `shared-util-email` is tagged `scope:shared`, the module-boundary rule makes
|
|
21
|
+
* shared libs leaves, and the arrow points the other way so every send site
|
|
22
|
+
* can pull the email renderer without the framework.
|
|
23
|
+
*
|
|
24
|
+
* `email-nodes-drift.spec.ts` in the console — which may import both — runs
|
|
25
|
+
* the two implementations over one table of inputs, so a divergence fails a
|
|
26
|
+
* build rather than silently changing what a recipient receives.
|
|
27
|
+
*
|
|
28
|
+
* ## Why an email loader needs this at all
|
|
29
|
+
*
|
|
30
|
+
* An email version's `nodes` is stored in the same two live forms every other
|
|
31
|
+
* besigner document uses: a plain Firestore map, and msgpack bytes. Reading
|
|
32
|
+
* the field raw is not a loud failure — a `Buffer` walks, `Object.keys` over
|
|
33
|
+
* one returns BYTE INDICES rather than nothing, so an emptiness guard passes
|
|
34
|
+
* and the send renders an empty email instead of falling back to its built-in
|
|
35
|
+
* copy.
|
|
36
|
+
*/ export function decodeEmailNodes(raw) {
|
|
37
|
+
var _byteSourceBytes;
|
|
38
|
+
if (raw === null || raw === undefined) return null;
|
|
39
|
+
const bytes = ArrayBuffer.isView(raw) ? // so a small field is typically a view into a shared 8 KB allocation
|
|
40
|
+
// and decoding the whole pool throws on the trailing bytes.
|
|
41
|
+
new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength) : (_byteSourceBytes = byteSourceBytes(raw)) != null ? _byteSourceBytes : bufferEnvelopeBytes(raw);
|
|
42
|
+
if (bytes) {
|
|
43
|
+
try {
|
|
44
|
+
return decode(bytes);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// Undecodable nodes must never read as "this template is empty" — that
|
|
47
|
+
// is indistinguishable from a template nobody has designed yet.
|
|
48
|
+
console.error('could not decode stored email nodes', error);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return raw;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The client SDK's `Bytes`, structurally. Matched on the method rather than
|
|
56
|
+
* the class so no Firestore package is imported here. A node map cannot
|
|
57
|
+
* collide: its values are node OBJECTS, never functions.
|
|
58
|
+
*/ function byteSourceBytes(raw) {
|
|
59
|
+
if (typeof raw !== 'object') return null;
|
|
60
|
+
const source = raw;
|
|
61
|
+
return typeof source.toUint8Array === 'function' ? source.toUint8Array() : null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A JSON-serialized Node `Buffer` — what `JSON.stringify` makes of one
|
|
65
|
+
* (AGL-1391). The test is deliberately exact, because the alternative reading
|
|
66
|
+
* is a node map with nodes called `type` and `data`; that map cannot exist,
|
|
67
|
+
* since `type` would have to hold the literal string `'Buffer'` and `data` an
|
|
68
|
+
* array, in the same document.
|
|
69
|
+
*/ function bufferEnvelopeBytes(raw) {
|
|
70
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
71
|
+
const value = raw;
|
|
72
|
+
if (value.type !== 'Buffer' || !Array.isArray(value.data)) return null;
|
|
73
|
+
if (Object.keys(value).length !== 2) return null;
|
|
74
|
+
return Uint8Array.from(value.data);
|
|
75
|
+
}
|
|
76
|
+
export default decodeEmailNodes;
|
|
77
|
+
|
|
78
|
+
//# sourceMappingURL=stored-email-nodes.js.map
|