@aglyn/shared-util-email 1.0.0-beta.200 → 1.0.0-beta.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/index.d.ts +6 -0
- package/src/index.js +16 -0
- package/src/index.js.map +1 -1
- package/src/lib/email-deliverability.d.ts +200 -0
- package/src/lib/email-deliverability.js +461 -0
- package/src/lib/email-deliverability.js.map +1 -0
- package/src/lib/email-delivery-events.d.ts +16 -39
- package/src/lib/email-delivery-events.js +28 -12
- package/src/lib/email-delivery-events.js.map +1 -1
- package/src/lib/email-linkify.d.ts +24 -0
- package/src/lib/email-linkify.js +73 -0
- package/src/lib/email-linkify.js.map +1 -0
- package/src/lib/email-render.js +6 -1
- package/src/lib/email-render.js.map +1 -1
- package/src/lib/hosted-page-screen.d.ts +60 -0
- package/src/lib/hosted-page-screen.js +314 -0
- package/src/lib/hosted-page-screen.js.map +1 -0
- package/src/lib/mail-bounce.d.ts +42 -0
- package/src/lib/mail-bounce.js +108 -0
- package/src/lib/mail-bounce.js.map +1 -0
- package/src/lib/mail-gateway.d.ts +161 -0
- package/src/lib/mail-gateway.js +330 -0
- package/src/lib/mail-gateway.js.map +1 -0
- package/src/lib/outbound-phishing-screen.d.ts +248 -0
- package/src/lib/outbound-phishing-screen.js +769 -0
- package/src/lib/outbound-phishing-screen.js.map +1 -0
- package/src/lib/outbound-screen-gate.d.ts +95 -0
- package/src/lib/outbound-screen-gate.js +109 -0
- package/src/lib/outbound-screen-gate.js.map +1 -0
- package/src/lib/send-email.d.ts +79 -1
- package/src/lib/send-email.js +153 -10
- package/src/lib/send-email.js.map +1 -1
- package/src/lib/sending-domain.d.ts +22 -1
- package/src/lib/sending-domain.js +0 -95
- package/src/lib/sending-domain.js.map +1 -1
- package/src/lib/system-email-catalog.js +400 -0
- package/src/lib/system-email-catalog.js.map +1 -1
- package/src/lib/text-email-html.d.ts +38 -0
- package/src/lib/text-email-html.js +3 -44
- package/src/lib/text-email-html.js.map +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -51,15 +52,8 @@
|
|
|
51
52
|
* must be CALLED with a key to do anything — it holds no module state and is
|
|
52
53
|
* unreachable from a client component, unlike an admin-SDK import, which
|
|
53
54
|
* executes on load.
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
* Chosen to be the intersection every ESP can report rather than the union of
|
|
58
|
-
* what any one of them does: a vendor with no equivalent for a state simply
|
|
59
|
-
* never produces it, and a vendor with a richer taxonomy folds into the
|
|
60
|
-
* nearest of these rather than widening the type. `delayed` is retryable and
|
|
61
|
-
* `failed` is not, which is the distinction a staffer actually needs.
|
|
62
|
-
*/ /** Lifecycle states ordered worst-last, for {@link worstDeliveryStatus}. */ const STATUS_SEVERITY = {
|
|
55
|
+
*/ import { readBounceText } from "./mail-bounce.js";
|
|
56
|
+
/** Lifecycle states ordered worst-last, for {@link worstDeliveryStatus}. */ const STATUS_SEVERITY = {
|
|
63
57
|
sent: 1,
|
|
64
58
|
delivered: 2,
|
|
65
59
|
opened: 3,
|
|
@@ -69,6 +63,12 @@
|
|
|
69
63
|
bounced: 7,
|
|
70
64
|
failed: 8
|
|
71
65
|
};
|
|
66
|
+
/** The bare address of a `From:` value (`"Acme" <hi@acme.com>` → `hi@acme.com`), lowercased. */ export function bareSenderAddress(value) {
|
|
67
|
+
const raw = String(value != null ? value : '').trim();
|
|
68
|
+
const angle = raw.match(/<([^>]+)>/);
|
|
69
|
+
const address = (angle ? angle[1] : raw).trim().toLowerCase();
|
|
70
|
+
return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(address) ? address : null;
|
|
71
|
+
}
|
|
72
72
|
/** The later of two statuses on the lifecycle, worst winning a tie. */ export function worstDeliveryStatus(current, next) {
|
|
73
73
|
if (!current) return next;
|
|
74
74
|
return STATUS_SEVERITY[next] >= STATUS_SEVERITY[current] ? next : current;
|
|
@@ -128,7 +128,7 @@ function normalizeBounceType(value) {
|
|
|
128
128
|
* @param receivedAtMs Fallback timestamp for a payload that carries none.
|
|
129
129
|
*/ export function normalizeResendDeliveryEvents(payload, receivedAtMs) {
|
|
130
130
|
var _event_type, _event_data, _ref, _data_email_id, _ref1, _ref2, _ref3, _eventTimeMs, _data_subject;
|
|
131
|
-
var _data_click, _data_open;
|
|
131
|
+
var _data_click, _data_open, _data_bounce, _data_bounce1, _data_bounce2, _data_bounce3, _data_bounce4;
|
|
132
132
|
const event = payload != null ? payload : {};
|
|
133
133
|
const type = RESEND_EVENT_TYPES[String((_event_type = event.type) != null ? _event_type : '')];
|
|
134
134
|
if (!type) return [];
|
|
@@ -144,10 +144,20 @@ function normalizeBounceType(value) {
|
|
|
144
144
|
const at = (_ref1 = (_ref2 = (_ref3 = (_eventTimeMs = eventTimeMs((_data_click = data.click) == null ? void 0 : _data_click.timestamp)) != null ? _eventTimeMs : eventTimeMs((_data_open = data.open) == null ? void 0 : _data_open.timestamp)) != null ? _ref3 : eventTimeMs(event.created_at)) != null ? _ref2 : eventTimeMs(data.created_at)) != null ? _ref1 : receivedAtMs;
|
|
145
145
|
const subject = String((_data_subject = data.subject) != null ? _data_subject : '').trim() || null;
|
|
146
146
|
const tags = normalizeEventTags(data.tags);
|
|
147
|
+
const from = bareSenderAddress(data.from);
|
|
148
|
+
// The provider folds a DSN's fields into one sentence; the structured
|
|
149
|
+
// ones are read first where a payload carries them.
|
|
150
|
+
const bounceText = type === 'bounced' ? readBounceText([
|
|
151
|
+
(_data_bounce = data.bounce) == null ? void 0 : _data_bounce.diagnosticCode,
|
|
152
|
+
(_data_bounce1 = data.bounce) == null ? void 0 : _data_bounce1.diagnostic_code,
|
|
153
|
+
(_data_bounce2 = data.bounce) == null ? void 0 : _data_bounce2.remoteMta,
|
|
154
|
+
(_data_bounce3 = data.bounce) == null ? void 0 : _data_bounce3.remote_mta,
|
|
155
|
+
(_data_bounce4 = data.bounce) == null ? void 0 : _data_bounce4.message
|
|
156
|
+
].filter((part)=>typeof part === 'string' && part.trim()).join(' ')) : null;
|
|
147
157
|
return recipients.map((to)=>{
|
|
148
158
|
var _ref, _ref1, _ref2;
|
|
149
159
|
var _data_click, _data_bounce, _data_bounce1, _data_failed;
|
|
150
|
-
return {
|
|
160
|
+
return _extends({
|
|
151
161
|
type,
|
|
152
162
|
at,
|
|
153
163
|
provider: 'resend',
|
|
@@ -159,7 +169,13 @@ function normalizeBounceType(value) {
|
|
|
159
169
|
link: type === 'clicked' ? String((_ref = (_data_click = data.click) == null ? void 0 : _data_click.link) != null ? _ref : '') || null : null,
|
|
160
170
|
bounceType: type === 'bounced' ? normalizeBounceType((_data_bounce = data.bounce) == null ? void 0 : _data_bounce.type) : null,
|
|
161
171
|
detail: String((_ref1 = (_ref2 = (_data_bounce1 = data.bounce) == null ? void 0 : _data_bounce1.message) != null ? _ref2 : (_data_failed = data.failed) == null ? void 0 : _data_failed.reason) != null ? _ref1 : '').trim() || null
|
|
162
|
-
}
|
|
172
|
+
}, from ? {
|
|
173
|
+
from
|
|
174
|
+
} : {}, (bounceText == null ? void 0 : bounceText.status) ? {
|
|
175
|
+
bounceStatus: bounceText.status
|
|
176
|
+
} : {}, (bounceText == null ? void 0 : bounceText.remoteMta) ? {
|
|
177
|
+
remoteMta: bounceText.remoteMta
|
|
178
|
+
} : {});
|
|
163
179
|
});
|
|
164
180
|
}
|
|
165
181
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-delivery-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT HAPPENED TO A MESSAGE, IN OUR OWN VOCABULARY.\n *\n * ## The seam\n *\n * A staff answer to \"did they get the invite, and did they open it?\" must not\n * be a question about Resend. Two things follow from that, and this module is\n * both of them:\n *\n * 1. **The stored shape is ours.** Nothing downstream — the delivery log, the\n * staff card, a future export — reads a provider's field names or its event\n * strings. Swapping the sender changes exactly one function in this file\n * and nothing else in the tree.\n * 2. **The history is ours.** The log is written into our own Firestore and\n * read from there, never from the provider on render. A provider's list\n * endpoint is a different shape per vendor, has its own retention window,\n * and disappears entirely with the account; a record we keep survives the\n * migration that the seam exists to make possible.\n *\n * That is a rule about the READ PATH, not a rule against ever reading the\n * provider. The event feed only knows mail sent after it was connected, so\n * a log fed by events alone is empty for all existing history — which is\n * precisely the mail a support question is about. The second half of this\n * module (see THE READ SIDE OF THE SEAM below) imports that history\n * through the same neutral vocabulary, once, into the same store.\n *\n * ## Pure on purpose\n *\n * No Firestore and no admin SDK. `system-email-catalog` is imported by console\n * CLIENT components through this library's barrel, so anything reachable from\n * it that touched `firebase-admin` would drag the admin SDK into a browser\n * bundle. Normalisation is a pure function of a payload; the writing lives in\n * `@aglyn/tenant-data-admin/server/email-delivery-log`.\n *\n * The one `fetch` is `resendDeliveryHistorySource`, which is a function that\n * must be CALLED with a key to do anything — it holds no module state and is\n * unreachable from a client component, unlike an admin-SDK import, which\n * executes on load.\n */\n\n/**\n * The lifecycle of one message, in the order it normally happens.\n *\n * Chosen to be the intersection every ESP can report rather than the union of\n * what any one of them does: a vendor with no equivalent for a state simply\n * never produces it, and a vendor with a richer taxonomy folds into the\n * nearest of these rather than widening the type. `delayed` is retryable and\n * `failed` is not, which is the distinction a staffer actually needs.\n */\nexport type EmailDeliveryEventType =\n | 'sent'\n | 'delivered'\n | 'delayed'\n | 'opened'\n | 'clicked'\n | 'bounced'\n | 'complained'\n | 'failed'\n\n/** Lifecycle states ordered worst-last, for {@link worstDeliveryStatus}. */\nconst STATUS_SEVERITY: Record<EmailDeliveryEventType, number> = {\n sent: 1,\n delivered: 2,\n opened: 3,\n clicked: 4,\n delayed: 5,\n complained: 6,\n bounced: 7,\n failed: 8,\n}\n\n/**\n * One normalized delivery event.\n *\n * `at` is epoch milliseconds rather than a Firestore timestamp so this type\n * stays usable in a browser, in a test, and in whatever writes it next.\n */\nexport interface EmailDeliveryEvent {\n type: EmailDeliveryEventType\n /** When the PROVIDER says it happened, falling back to receipt time. */\n at: number\n /** Slug of the sending provider, e.g. `'resend'`. */\n provider: string\n /** The provider's id for the message. Our per-message document key. */\n providerMessageId: string\n /** Recipient, lowercased. One address per record even on a multi-recipient send. */\n to: string\n subject: string | null\n /**\n * The sender label `sendEmail` stamps on every message (`'invite'`,\n * `'password-reset'`, `'campaign'`, …). This is what makes the staff view\n * legible: without it a row says only that *an* email was sent.\n */\n context: string | null\n /** Everything else the send was tagged with, e.g. `hostId`, `campaignId`. */\n tags: Record<string, string>\n /** `clicked` only: the destination the recipient followed. */\n link: string | null\n /**\n * `bounced` only: whether the mailbox is gone (`permanent`) or the failure\n * was temporary. Lowercased, because providers disagree on capitalisation\n * and a staff filter must not depend on which one is in use.\n */\n bounceType: 'permanent' | 'transient' | 'undetermined' | null\n /** Provider-supplied explanation, for the states that carry one. */\n detail: string | null\n}\n\n/** The later of two statuses on the lifecycle, worst winning a tie. */\nexport function worstDeliveryStatus(\n current: EmailDeliveryEventType | null | undefined,\n next: EmailDeliveryEventType,\n): EmailDeliveryEventType {\n if (!current) return next\n return STATUS_SEVERITY[next] >= STATUS_SEVERITY[current] ? next : current\n}\n\n/** Tags arrive as an array of `{name, value}` or a plain map — accept both. */\nexport function normalizeEventTags(raw: unknown): Record<string, string> {\n if (Array.isArray(raw)) {\n const map: Record<string, string> = {}\n for (const tag of raw) {\n if (tag?.name) map[String(tag.name)] = String(tag.value ?? '')\n }\n return map\n }\n if (raw && typeof raw === 'object') {\n return Object.fromEntries(\n Object.entries(raw as Record<string, unknown>).map(([key, value]) => [\n key,\n String(value ?? ''),\n ]),\n )\n }\n return {}\n}\n\n/** Epoch ms from an ISO string or a number, or `null` when unreadable. */\nfunction eventTimeMs(value: unknown): number | null {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n const parsed = Date.parse(String(value ?? ''))\n return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction normalizeBounceType(\n value: unknown,\n): EmailDeliveryEvent['bounceType'] {\n const lowered = String(value ?? '')\n .trim()\n .toLowerCase()\n if (lowered === 'permanent') return 'permanent'\n if (lowered === 'transient') return 'transient'\n return lowered ? 'undetermined' : null\n}\n\n/** The Resend event names we understand, mapped onto ours. */\nconst RESEND_EVENT_TYPES: Record<string, EmailDeliveryEventType> = {\n 'email.sent': 'sent',\n 'email.delivered': 'delivered',\n 'email.delivery_delayed': 'delayed',\n 'email.opened': 'opened',\n 'email.clicked': 'clicked',\n 'email.bounced': 'bounced',\n 'email.complained': 'complained',\n 'email.failed': 'failed',\n}\n\n/**\n * Turns one Resend webhook payload into zero or more of our events — **the\n * only function in the tree that knows Resend's wire format.**\n *\n * One event per recipient, not per message: a send addressed to three people\n * produces one webhook, and a staff view keyed on a person has to be able to\n * find it under each of them.\n *\n * Returns an empty array for anything unrecognised — a contact or domain\n * event, an inbound `email.received`, a type added after this was written.\n * Silence rather than a throw, because a webhook handler that 500s on an\n * unfamiliar event teaches the provider to retry it forever.\n *\n * @param payload The parsed webhook body.\n * @param receivedAtMs Fallback timestamp for a payload that carries none.\n */\nexport function normalizeResendDeliveryEvents(\n payload: unknown,\n receivedAtMs: number,\n): EmailDeliveryEvent[] {\n const event = (payload ?? {}) as Record<string, any>\n const type = RESEND_EVENT_TYPES[String(event.type ?? '')]\n if (!type) return []\n\n const data = (event.data ?? {}) as Record<string, any>\n const providerMessageId = String(data.email_id ?? data.id ?? '').trim()\n if (!providerMessageId) return []\n\n const recipients = (Array.isArray(data.to) ? data.to : [data.to])\n .map((address: unknown) => String(address ?? '').trim().toLowerCase())\n .filter((address: string) => address.includes('@'))\n if (!recipients.length) return []\n\n // The per-state timestamp when the provider gives one, because an open\n // three days after the send is the whole point of recording an open.\n const at =\n eventTimeMs(data.click?.timestamp) ??\n eventTimeMs(data.open?.timestamp) ??\n eventTimeMs(event.created_at) ??\n eventTimeMs(data.created_at) ??\n receivedAtMs\n\n const subject = String(data.subject ?? '').trim() || null\n const tags = normalizeEventTags(data.tags)\n\n return recipients.map((to: string) => ({\n type,\n at,\n provider: 'resend',\n providerMessageId,\n to,\n subject,\n context: tags['context'] || null,\n tags,\n link: type === 'clicked' ? String(data.click?.link ?? '') || null : null,\n bounceType: type === 'bounced' ? normalizeBounceType(data.bounce?.type) : null,\n detail:\n String(data.bounce?.message ?? data.failed?.reason ?? '').trim() || null,\n }))\n}\n\n/*==========================================\n * THE READ SIDE OF THE SEAM.\n *\n * The event feed above only ever knows about mail sent AFTER it was\n * connected. That is correct for the steady state and useless for the\n * question the staff card exists to answer, which is asked about mail that\n * has already gone out — so a delivery log fed only by events is empty\n * exactly when somebody needs it.\n *\n * A provider also holds the history, and reading it is not lock-in as long as\n * it happens through an interface. {@link EmailDeliverySnapshot} is that\n * interface: one message as the provider currently sees it, in our\n * vocabulary. `normalizeResendSentEmails` is the Resend implementation and\n * the second (and last) function in the tree that knows Resend's wire format.\n *\n * WHAT A SNAPSHOT DELIBERATELY DOES NOT CARRY\n *\n * Open and click COUNTS. Resend's list endpoint reports a single\n * `last_event` per message and no engagement detail, so a snapshot can say\n * \"this was opened at least once\" and can never say \"three times\". The\n * writer therefore treats a snapshot as a floor, never as truth that\n * overwrites what the event feed recorded — see `recordEmailDeliverySnapshot`.\n *=========================================*/\n\n/** One message as the provider currently reports it, in our vocabulary. */\nexport interface EmailDeliverySnapshot {\n provider: string\n providerMessageId: string\n to: string\n subject: string | null\n /** Epoch ms the provider says the message was created. */\n sentAt: number\n /** Furthest state the provider reports. Never richer than the event feed. */\n status: EmailDeliveryEventType\n}\n\n/**\n * A provider's `last_event` string, mapped onto our lifecycle.\n *\n * Deliberately the bare state names rather than the `email.*` event names:\n * the list endpoint reports `\"delivered\"`, the webhook reports\n * `\"email.delivered\"`, and they are two different vocabularies for one\n * concept. Both are accepted here so a provider that unifies them later\n * needs no change.\n */\nconst RESEND_LAST_EVENTS: Record<string, EmailDeliveryEventType> = {\n sent: 'sent',\n delivered: 'delivered',\n delivery_delayed: 'delayed',\n opened: 'opened',\n clicked: 'clicked',\n bounced: 'bounced',\n complained: 'complained',\n failed: 'failed',\n canceled: 'failed',\n queued: 'sent',\n scheduled: 'sent',\n}\n\n/**\n * Turns one entry from Resend's `GET /emails` list into zero or more\n * snapshots — one per recipient, for the same reason the event adapter fans\n * out: the staff view is keyed on a person.\n *\n * An unrecognised `last_event` falls back to `sent` rather than being\n * dropped. The message demonstrably exists and was addressed to somebody, and\n * \"we sent this and cannot characterise what happened next\" is a far more\n * useful row than no row — which is the state that sent a staffer to the\n * vendor dashboard in the first place.\n */\nexport function normalizeResendSentEmails(\n raw: unknown,\n): EmailDeliverySnapshot[] {\n const record = (raw ?? {}) as Record<string, any>\n const providerMessageId = String(record.id ?? '').trim()\n if (!providerMessageId) return []\n\n const recipients = (Array.isArray(record.to) ? record.to : [record.to])\n .map((address: unknown) => String(address ?? '').trim().toLowerCase())\n .filter((address: string) => address.includes('@'))\n if (!recipients.length) return []\n\n const parsed = Date.parse(String(record.created_at ?? ''))\n const sentAt = Number.isFinite(parsed) ? parsed : 0\n // A snapshot with no timestamp cannot be ordered, and the log's read drops\n // any document missing its sort key — so it is refused rather than written\n // somewhere nothing will look for it.\n if (!sentAt) return []\n\n const status =\n RESEND_LAST_EVENTS[\n String(record.last_event ?? '')\n .trim()\n .toLowerCase()\n ] ?? 'sent'\n\n return recipients.map((to: string) => ({\n provider: 'resend',\n providerMessageId,\n to,\n subject: String(record.subject ?? '').trim() || null,\n sentAt,\n status,\n }))\n}\n\n/** One page of provider history, in our vocabulary. */\nexport interface EmailDeliveryHistoryPage {\n snapshots: EmailDeliverySnapshot[]\n /** Cursor for the next page, or null at the end. */\n nextCursor: string | null\n}\n\n/**\n * Reads one page of already-sent mail from a provider.\n *\n * The shape a second provider would implement. Cursor-based rather than\n * offset- or date-based because that is the lowest common denominator, and\n * NOT filtered by recipient: Resend's list endpoint has no recipient\n * parameter, so filtering is the caller's job and the import is a sweep\n * rather than a per-person lookup. That is the right shape regardless — a\n * staff page must not fan out to a third party on render.\n */\nexport type EmailDeliveryHistorySource = (options: {\n cursor?: string | null\n limit?: number\n}) => Promise<EmailDeliveryHistoryPage>\n\n/** Resend's list endpoint. Paginates with `after=<id>`; caps at 100. */\nexport const RESEND_EMAILS_ENDPOINT = 'https://api.resend.com/emails'\n\n/**\n * {@link EmailDeliveryHistorySource} for Resend.\n *\n * Needs a FULL-ACCESS key: a sending-scoped key answers every read on this\n * endpoint with `401 restricted_api_key`, which is the correct posture for\n * the key that sends mail and the reason this takes its own.\n */\nexport function resendDeliveryHistorySource(\n apiKey: string,\n): EmailDeliveryHistorySource {\n return async ({ cursor, limit } = {}) => {\n const params = new URLSearchParams({\n limit: String(Math.min(Math.max(1, limit ?? 100), 100)),\n })\n if (cursor) params.set('after', cursor)\n const response = await fetch(`${RESEND_EMAILS_ENDPOINT}?${params}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(\n `email history read failed: HTTP ${response.status} ${detail.slice(0, 200)}`,\n )\n }\n const body = (await response.json()) as {\n data?: unknown[]\n has_more?: boolean\n }\n const entries = Array.isArray(body?.data) ? body.data : []\n const snapshots = entries.flatMap((entry) => normalizeResendSentEmails(entry))\n // The cursor is the LAST RAW entry's id, not the last snapshot's: a page\n // whose final entry fanned out to zero snapshots (no recipient, no\n // timestamp) would otherwise rewind the cursor to an earlier message and\n // loop over the same page forever.\n const lastId = String(\n (entries[entries.length - 1] as { id?: unknown })?.id ?? '',\n ).trim()\n return {\n snapshots,\n nextCursor: body?.has_more && lastId ? lastId : null,\n }\n }\n}\n\n/*==========================================\n * ONE MESSAGE, RENDERED.\n *\n * The log records what HAPPENED to a message; it does not keep the message.\n * Storing every body would put an unbounded copy of every email we have ever\n * sent — including reset links and receipts — into our own database, to\n * duplicate something the provider already holds.\n *\n * So a body is fetched when a staffer explicitly opens one row. That is a\n * deliberate single lookup, not the per-render fan-out the read path refuses:\n * one message, on one click, by id.\n *=========================================*/\n\n/** One message's content and envelope, in our vocabulary. */\nexport interface EmailDeliveryMessage {\n provider: string\n providerMessageId: string\n to: string[]\n cc: string[]\n bcc: string[]\n from: string | null\n replyTo: string[] | null\n subject: string | null\n /** The HTML part, or null when the message was sent as text only. */\n html: string | null\n /** The plain-text part, or null. */\n text: string | null\n sentAt: number | null\n status: EmailDeliveryEventType | null\n}\n\n/** A single message by id. The shape a second provider would implement. */\nexport type EmailDeliveryMessageSource = (\n providerMessageId: string,\n) => Promise<EmailDeliveryMessage | null>\n\nfunction addressList(raw: unknown): string[] {\n return (Array.isArray(raw) ? raw : raw == null ? [] : [raw])\n .map((address) => String(address ?? '').trim())\n .filter(Boolean)\n}\n\n/** Resend's `GET /emails/:id` payload, in our vocabulary. */\nexport function normalizeResendMessage(raw: unknown): EmailDeliveryMessage | null {\n const record = (raw ?? {}) as Record<string, any>\n const providerMessageId = String(record.id ?? '').trim()\n if (!providerMessageId) return null\n const parsed = Date.parse(String(record.created_at ?? ''))\n return {\n provider: 'resend',\n providerMessageId,\n to: addressList(record.to),\n cc: addressList(record.cc),\n bcc: addressList(record.bcc),\n from: String(record.from ?? '').trim() || null,\n replyTo: addressList(record.reply_to).length\n ? addressList(record.reply_to)\n : null,\n subject: String(record.subject ?? '').trim() || null,\n // Empty string is NOT null here, and the difference is the point: a\n // message that went out text-only really does have an empty HTML part,\n // and a reader has to be able to tell that from \"we could not fetch it\".\n html: typeof record.html === 'string' ? record.html : null,\n text: typeof record.text === 'string' ? record.text : null,\n sentAt: Number.isFinite(parsed) ? parsed : null,\n status:\n RESEND_LAST_EVENTS[\n String(record.last_event ?? '')\n .trim()\n .toLowerCase()\n ] ?? null,\n }\n}\n\n/** Resend's single-message endpoint. Needs the same full-access key. */\nexport function resendDeliveryMessageSource(\n apiKey: string,\n): EmailDeliveryMessageSource {\n return async (providerMessageId: string) => {\n const response = await fetch(\n `${RESEND_EMAILS_ENDPOINT}/${encodeURIComponent(providerMessageId)}`,\n { headers: { Authorization: `Bearer ${apiKey}` } },\n )\n // A message the provider has aged out is a 404, and that is an ANSWER —\n // \"we know this was sent and the body is gone\" — not a failure to report\n // as an error the staffer must act on.\n if (response.status === 404) return null\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(\n `message read failed: HTTP ${response.status} ${detail.slice(0, 200)}`,\n )\n }\n return normalizeResendMessage(await response.json())\n }\n}\n"],"names":["STATUS_SEVERITY","sent","delivered","opened","clicked","delayed","complained","bounced","failed","worstDeliveryStatus","current","next","normalizeEventTags","raw","Array","isArray","map","tag","name","String","value","Object","fromEntries","entries","key","eventTimeMs","Number","isFinite","parsed","Date","parse","normalizeBounceType","lowered","trim","toLowerCase","RESEND_EVENT_TYPES","normalizeResendDeliveryEvents","payload","receivedAtMs","event","data","type","providerMessageId","email_id","id","recipients","to","address","filter","includes","length","at","click","timestamp","open","created_at","subject","tags","provider","context","link","bounceType","bounce","detail","message","reason","RESEND_LAST_EVENTS","delivery_delayed","canceled","queued","scheduled","normalizeResendSentEmails","record","sentAt","status","last_event","RESEND_EMAILS_ENDPOINT","resendDeliveryHistorySource","apiKey","cursor","limit","params","URLSearchParams","Math","min","max","set","response","fetch","headers","Authorization","ok","text","catch","Error","slice","body","json","snapshots","flatMap","entry","lastId","nextCursor","has_more","addressList","Boolean","normalizeResendMessage","cc","bcc","from","replyTo","reply_to","html","resendDeliveryMessageSource","encodeURIComponent"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GAED;;;;;;;;CAQC,GAWD,0EAA0E,GAC1E,MAAMA,kBAA0D;IAC9DC,MAAM;IACNC,WAAW;IACXC,QAAQ;IACRC,SAAS;IACTC,SAAS;IACTC,YAAY;IACZC,SAAS;IACTC,QAAQ;AACV;AAuCA,qEAAqE,GACrE,OAAO,SAASC,oBACdC,OAAkD,EAClDC,IAA4B;IAE5B,IAAI,CAACD,SAAS,OAAOC;IACrB,OAAOX,eAAe,CAACW,KAAK,IAAIX,eAAe,CAACU,QAAQ,GAAGC,OAAOD;AACpE;AAEA,6EAA6E,GAC7E,OAAO,SAASE,mBAAmBC,GAAY;IAC7C,IAAIC,MAAMC,OAAO,CAACF,MAAM;QACtB,MAAMG,MAA8B,CAAC;QACrC,KAAK,MAAMC,OAAOJ,IAAK;gBACyBI;YAA9C,IAAIA,uBAAAA,IAAKC,IAAI,EAAEF,GAAG,CAACG,OAAOF,IAAIC,IAAI,EAAE,GAAGC,QAAOF,aAAAA,IAAIG,KAAK,YAATH,aAAa;QAC7D;QACA,OAAOD;IACT;IACA,IAAIH,OAAO,OAAOA,QAAQ,UAAU;QAClC,OAAOQ,OAAOC,WAAW,CACvBD,OAAOE,OAAO,CAACV,KAAgCG,GAAG,CAAC,CAAC,CAACQ,KAAKJ,MAAM,GAAK;gBACnEI;gBACAL,OAAOC,gBAAAA,QAAS;aACjB;IAEL;IACA,OAAO,CAAC;AACV;AAEA,wEAAwE,GACxE,SAASK,YAAYL,KAAc;IACjC,IAAI,OAAOA,UAAU,YAAYM,OAAOC,QAAQ,CAACP,QAAQ,OAAOA;IAChE,MAAMQ,SAASC,KAAKC,KAAK,CAACX,OAAOC,gBAAAA,QAAS;IAC1C,OAAOM,OAAOC,QAAQ,CAACC,UAAUA,SAAS;AAC5C;AAEA,SAASG,oBACPX,KAAc;IAEd,MAAMY,UAAUb,OAAOC,gBAAAA,QAAS,IAC7Ba,IAAI,GACJC,WAAW;IACd,IAAIF,YAAY,aAAa,OAAO;IACpC,IAAIA,YAAY,aAAa,OAAO;IACpC,OAAOA,UAAU,iBAAiB;AACpC;AAEA,4DAA4D,GAC5D,MAAMG,qBAA6D;IACjE,cAAc;IACd,mBAAmB;IACnB,0BAA0B;IAC1B,gBAAgB;IAChB,iBAAiB;IACjB,iBAAiB;IACjB,oBAAoB;IACpB,gBAAgB;AAClB;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,8BACdC,OAAgB,EAChBC,YAAoB;QAGmBC,aAGzBA,aACmBC,MAAAA,gBAW/Bf,OAAAA,OAAAA,OAAAA,cAMqBe;QANTA,aACAA;IAjBd,MAAMD,QAASF,kBAAAA,UAAW,CAAC;IAC3B,MAAMI,OAAON,kBAAkB,CAAChB,QAAOoB,cAAAA,MAAME,IAAI,YAAVF,cAAc,IAAI;IACzD,IAAI,CAACE,MAAM,OAAO,EAAE;IAEpB,MAAMD,QAAQD,cAAAA,MAAMC,IAAI,YAAVD,cAAc,CAAC;IAC7B,MAAMG,oBAAoBvB,QAAOqB,QAAAA,iBAAAA,KAAKG,QAAQ,YAAbH,iBAAiBA,KAAKI,EAAE,YAAxBJ,OAA4B,IAAIP,IAAI;IACrE,IAAI,CAACS,mBAAmB,OAAO,EAAE;IAEjC,MAAMG,aAAa,AAAC/B,CAAAA,MAAMC,OAAO,CAACyB,KAAKM,EAAE,IAAIN,KAAKM,EAAE,GAAG;QAACN,KAAKM,EAAE;KAAC,AAAD,EAC5D9B,GAAG,CAAC,CAAC+B,UAAqB5B,OAAO4B,kBAAAA,UAAW,IAAId,IAAI,GAAGC,WAAW,IAClEc,MAAM,CAAC,CAACD,UAAoBA,QAAQE,QAAQ,CAAC;IAChD,IAAI,CAACJ,WAAWK,MAAM,EAAE,OAAO,EAAE;IAEjC,uEAAuE;IACvE,qEAAqE;IACrE,MAAMC,MACJ1B,SAAAA,SAAAA,SAAAA,eAAAA,aAAYe,cAAAA,KAAKY,KAAK,qBAAVZ,YAAYa,SAAS,aAAjC5B,eACAA,aAAYe,aAAAA,KAAKc,IAAI,qBAATd,WAAWa,SAAS,aADhC5B,QAEAA,YAAYc,MAAMgB,UAAU,aAF5B9B,QAGAA,YAAYe,KAAKe,UAAU,aAH3B9B,QAIAa;IAEF,MAAMkB,UAAUrC,QAAOqB,gBAAAA,KAAKgB,OAAO,YAAZhB,gBAAgB,IAAIP,IAAI,MAAM;IACrD,MAAMwB,OAAO7C,mBAAmB4B,KAAKiB,IAAI;IAEzC,OAAOZ,WAAW7B,GAAG,CAAC,CAAC8B;kBAYZN;YAHyBA,aACmBA,cAE5CA,eAAwBA;eAZI;YACrCC;YACAU;YACAO,UAAU;YACVhB;YACAI;YACAU;YACAG,SAASF,IAAI,CAAC,UAAU,IAAI;YAC5BA;YACAG,MAAMnB,SAAS,YAAYtB,gBAAOqB,cAAAA,KAAKY,KAAK,qBAAVZ,YAAYoB,IAAI,mBAAI,OAAO,OAAO;YACpEC,YAAYpB,SAAS,YAAYV,qBAAoBS,eAAAA,KAAKsB,MAAM,qBAAXtB,aAAaC,IAAI,IAAI;YAC1EsB,QACE5C,QAAOqB,kBAAAA,gBAAAA,KAAKsB,MAAM,qBAAXtB,cAAawB,OAAO,qBAAIxB,eAAAA,KAAKhC,MAAM,qBAAXgC,aAAayB,MAAM,YAA3CzB,QAA+C,IAAIP,IAAI,MAAM;QACxE;;AACF;AAsCA;;;;;;;;CAQC,GACD,MAAMiC,qBAA6D;IACjEjE,MAAM;IACNC,WAAW;IACXiE,kBAAkB;IAClBhE,QAAQ;IACRC,SAAS;IACTG,SAAS;IACTD,YAAY;IACZE,QAAQ;IACR4D,UAAU;IACVC,QAAQ;IACRC,WAAW;AACb;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASC,0BACd1D,GAAY;QAGqB2D,YAQAA,oBAQ/BN,6CACSM;IAlBX,MAAMA,SAAU3D,cAAAA,MAAO,CAAC;IACxB,MAAM6B,oBAAoBvB,QAAOqD,aAAAA,OAAO5B,EAAE,YAAT4B,aAAa,IAAIvC,IAAI;IACtD,IAAI,CAACS,mBAAmB,OAAO,EAAE;IAEjC,MAAMG,aAAa,AAAC/B,CAAAA,MAAMC,OAAO,CAACyD,OAAO1B,EAAE,IAAI0B,OAAO1B,EAAE,GAAG;QAAC0B,OAAO1B,EAAE;KAAC,AAAD,EAClE9B,GAAG,CAAC,CAAC+B,UAAqB5B,OAAO4B,kBAAAA,UAAW,IAAId,IAAI,GAAGC,WAAW,IAClEc,MAAM,CAAC,CAACD,UAAoBA,QAAQE,QAAQ,CAAC;IAChD,IAAI,CAACJ,WAAWK,MAAM,EAAE,OAAO,EAAE;IAEjC,MAAMtB,SAASC,KAAKC,KAAK,CAACX,QAAOqD,qBAAAA,OAAOjB,UAAU,YAAjBiB,qBAAqB;IACtD,MAAMC,SAAS/C,OAAOC,QAAQ,CAACC,UAAUA,SAAS;IAClD,2EAA2E;IAC3E,2EAA2E;IAC3E,sCAAsC;IACtC,IAAI,CAAC6C,QAAQ,OAAO,EAAE;IAEtB,MAAMC,UACJR,8CAAAA,kBAAkB,CAChB/C,QAAOqD,qBAAAA,OAAOG,UAAU,YAAjBH,qBAAqB,IACzBvC,IAAI,GACJC,WAAW,GACf,YAJDgC,8CAIK;IAEP,OAAOrB,WAAW7B,GAAG,CAAC,CAAC8B;YAIL0B;eAJqB;YACrCd,UAAU;YACVhB;YACAI;YACAU,SAASrC,QAAOqD,kBAAAA,OAAOhB,OAAO,YAAdgB,kBAAkB,IAAIvC,IAAI,MAAM;YAChDwC;YACAC;QACF;;AACF;AAwBA,sEAAsE,GACtE,OAAO,MAAME,yBAAyB,gCAA+B;AAErE;;;;;;CAMC,GACD,OAAO,SAASC,4BACdC,MAAc;IAEd,OAAO,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAE,GAAG,CAAC,CAAC;;YAyB/BzD;QAxBH,MAAM0D,SAAS,IAAIC,gBAAgB;YACjCF,OAAO7D,OAAOgE,KAAKC,GAAG,CAACD,KAAKE,GAAG,CAAC,GAAGL,gBAAAA,QAAS,MAAM;QACpD;QACA,IAAID,QAAQE,OAAOK,GAAG,CAAC,SAASP;QAChC,MAAMQ,WAAW,MAAMC,MAAM,GAAGZ,uBAAuB,CAAC,EAAEK,QAAQ,EAAE;YAClEQ,SAAS;gBAAEC,eAAe,CAAC,OAAO,EAAEZ,QAAQ;YAAC;QAC/C;QACA,IAAI,CAACS,SAASI,EAAE,EAAE;YAChB,MAAM5B,SAAS,MAAMwB,SAASK,IAAI,GAAGC,KAAK,CAAC,IAAM;YACjD,MAAM,IAAIC,MACR,CAAC,gCAAgC,EAAEP,SAASb,MAAM,CAAC,CAAC,EAAEX,OAAOgC,KAAK,CAAC,GAAG,MAAM;QAEhF;QACA,MAAMC,OAAQ,MAAMT,SAASU,IAAI;QAIjC,MAAM1E,UAAUT,MAAMC,OAAO,CAACiF,wBAAAA,KAAMxD,IAAI,IAAIwD,KAAKxD,IAAI,GAAG,EAAE;QAC1D,MAAM0D,YAAY3E,QAAQ4E,OAAO,CAAC,CAACC,QAAU7B,0BAA0B6B;QACvE,yEAAyE;QACzE,mEAAmE;QACnE,yEAAyE;QACzE,mCAAmC;QACnC,MAAMC,SAASlF,gBACZI,YAAAA,OAAO,CAACA,QAAQ2B,MAAM,GAAG,EAAE,qBAA5B,AAAC3B,UAAkDqB,EAAE,mBAAI,IACzDX,IAAI;QACN,OAAO;YACLiE;YACAI,YAAYN,CAAAA,wBAAAA,KAAMO,QAAQ,KAAIF,SAASA,SAAS;QAClD;IACF;AACF;AAsCA,SAASG,YAAY3F,GAAY;IAC/B,OAAO,AAACC,CAAAA,MAAMC,OAAO,CAACF,OAAOA,MAAMA,OAAO,OAAO,EAAE,GAAG;QAACA;KAAI,AAAD,EACvDG,GAAG,CAAC,CAAC+B,UAAY5B,OAAO4B,kBAAAA,UAAW,IAAId,IAAI,IAC3Ce,MAAM,CAACyD;AACZ;AAEA,2DAA2D,GAC3D,OAAO,SAASC,uBAAuB7F,GAAY;QAEhB2D,YAEAA,oBAOlBA,cAIGA,iBAQdN,6CACSM;IAvBb,MAAMA,SAAU3D,cAAAA,MAAO,CAAC;IACxB,MAAM6B,oBAAoBvB,QAAOqD,aAAAA,OAAO5B,EAAE,YAAT4B,aAAa,IAAIvC,IAAI;IACtD,IAAI,CAACS,mBAAmB,OAAO;IAC/B,MAAMd,SAASC,KAAKC,KAAK,CAACX,QAAOqD,qBAAAA,OAAOjB,UAAU,YAAjBiB,qBAAqB;IACtD,OAAO;QACLd,UAAU;QACVhB;QACAI,IAAI0D,YAAYhC,OAAO1B,EAAE;QACzB6D,IAAIH,YAAYhC,OAAOmC,EAAE;QACzBC,KAAKJ,YAAYhC,OAAOoC,GAAG;QAC3BC,MAAM1F,QAAOqD,eAAAA,OAAOqC,IAAI,YAAXrC,eAAe,IAAIvC,IAAI,MAAM;QAC1C6E,SAASN,YAAYhC,OAAOuC,QAAQ,EAAE7D,MAAM,GACxCsD,YAAYhC,OAAOuC,QAAQ,IAC3B;QACJvD,SAASrC,QAAOqD,kBAAAA,OAAOhB,OAAO,YAAdgB,kBAAkB,IAAIvC,IAAI,MAAM;QAChD,oEAAoE;QACpE,uEAAuE;QACvE,yEAAyE;QACzE+E,MAAM,OAAOxC,OAAOwC,IAAI,KAAK,WAAWxC,OAAOwC,IAAI,GAAG;QACtDpB,MAAM,OAAOpB,OAAOoB,IAAI,KAAK,WAAWpB,OAAOoB,IAAI,GAAG;QACtDnB,QAAQ/C,OAAOC,QAAQ,CAACC,UAAUA,SAAS;QAC3C8C,MAAM,GACJR,8CAAAA,kBAAkB,CAChB/C,QAAOqD,qBAAAA,OAAOG,UAAU,YAAjBH,qBAAqB,IACzBvC,IAAI,GACJC,WAAW,GACf,YAJDgC,8CAIK;IACT;AACF;AAEA,sEAAsE,GACtE,OAAO,SAAS+C,4BACdnC,MAAc;IAEd,OAAO,OAAOpC;QACZ,MAAM6C,WAAW,MAAMC,MACrB,GAAGZ,uBAAuB,CAAC,EAAEsC,mBAAmBxE,oBAAoB,EACpE;YAAE+C,SAAS;gBAAEC,eAAe,CAAC,OAAO,EAAEZ,QAAQ;YAAC;QAAE;QAEnD,wEAAwE;QACxE,yEAAyE;QACzE,uCAAuC;QACvC,IAAIS,SAASb,MAAM,KAAK,KAAK,OAAO;QACpC,IAAI,CAACa,SAASI,EAAE,EAAE;YAChB,MAAM5B,SAAS,MAAMwB,SAASK,IAAI,GAAGC,KAAK,CAAC,IAAM;YACjD,MAAM,IAAIC,MACR,CAAC,0BAA0B,EAAEP,SAASb,MAAM,CAAC,CAAC,EAAEX,OAAOgC,KAAK,CAAC,GAAG,MAAM;QAE1E;QACA,OAAOW,uBAAuB,MAAMnB,SAASU,IAAI;IACnD;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-delivery-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT HAPPENED TO A MESSAGE, IN OUR OWN VOCABULARY.\n *\n * ## The seam\n *\n * A staff answer to \"did they get the invite, and did they open it?\" must not\n * be a question about Resend. Two things follow from that, and this module is\n * both of them:\n *\n * 1. **The stored shape is ours.** Nothing downstream — the delivery log, the\n * staff card, a future export — reads a provider's field names or its event\n * strings. Swapping the sender changes exactly one function in this file\n * and nothing else in the tree.\n * 2. **The history is ours.** The log is written into our own Firestore and\n * read from there, never from the provider on render. A provider's list\n * endpoint is a different shape per vendor, has its own retention window,\n * and disappears entirely with the account; a record we keep survives the\n * migration that the seam exists to make possible.\n *\n * That is a rule about the READ PATH, not a rule against ever reading the\n * provider. The event feed only knows mail sent after it was connected, so\n * a log fed by events alone is empty for all existing history — which is\n * precisely the mail a support question is about. The second half of this\n * module (see THE READ SIDE OF THE SEAM below) imports that history\n * through the same neutral vocabulary, once, into the same store.\n *\n * ## Pure on purpose\n *\n * No Firestore and no admin SDK. `system-email-catalog` is imported by console\n * CLIENT components through this library's barrel, so anything reachable from\n * it that touched `firebase-admin` would drag the admin SDK into a browser\n * bundle. Normalisation is a pure function of a payload; the writing lives in\n * `@aglyn/tenant-data-admin/server/email-delivery-log`.\n *\n * The one `fetch` is `resendDeliveryHistorySource`, which is a function that\n * must be CALLED with a key to do anything — it holds no module state and is\n * unreachable from a client component, unlike an admin-SDK import, which\n * executes on load.\n */\n\nimport { readBounceText } from './mail-bounce'\n\n/**\n * The lifecycle of one message, in the order it normally happens.\n *\n * Chosen to be the intersection every ESP can report rather than the union of\n * what any one of them does: a vendor with no equivalent for a state simply\n * never produces it, and a vendor with a richer taxonomy folds into the\n * nearest of these rather than widening the type. `delayed` is retryable and\n * `failed` is not, which is the distinction a staffer actually needs.\n */\nexport type EmailDeliveryEventType =\n | 'sent'\n | 'delivered'\n | 'delayed'\n | 'opened'\n | 'clicked'\n | 'bounced'\n | 'complained'\n | 'failed'\n\n/** Lifecycle states ordered worst-last, for {@link worstDeliveryStatus}. */\nconst STATUS_SEVERITY: Record<EmailDeliveryEventType, number> = {\n sent: 1,\n delivered: 2,\n opened: 3,\n clicked: 4,\n delayed: 5,\n complained: 6,\n bounced: 7,\n failed: 8,\n}\n\n/**\n * One normalized delivery event.\n *\n * `at` is epoch milliseconds rather than a Firestore timestamp so this type\n * stays usable in a browser, in a test, and in whatever writes it next.\n */\nexport interface EmailDeliveryEvent {\n type: EmailDeliveryEventType\n /** When the PROVIDER says it happened, falling back to receipt time. */\n at: number\n /** Slug of the sending provider, e.g. `'resend'`. */\n provider: string\n /** The provider's id for the message. Our per-message document key. */\n providerMessageId: string\n /** Recipient, lowercased. One address per record even on a multi-recipient send. */\n to: string\n subject: string | null\n /**\n * The sender label `sendEmail` stamps on every message (`'invite'`,\n * `'password-reset'`, `'campaign'`, …). This is what makes the staff view\n * legible: without it a row says only that *an* email was sent.\n */\n context: string | null\n /** Everything else the send was tagged with, e.g. `hostId`, `campaignId`. */\n tags: Record<string, string>\n /** `clicked` only: the destination the recipient followed. */\n link: string | null\n /**\n * `bounced` only: whether the mailbox is gone (`permanent`) or the failure\n * was temporary. Lowercased, because providers disagree on capitalisation\n * and a staff filter must not depend on which one is in use.\n */\n bounceType: 'permanent' | 'transient' | 'undetermined' | null\n /** Provider-supplied explanation, for the states that carry one. */\n detail: string | null\n /**\n * The bare sender address, lowercased: the domain it names is the sending\n * domain a gateway's verdict is filed against (AGL-3328). Absent from\n * events normalized before it existed, and `null` when the provider sent\n * none.\n */\n from?: string | null\n /**\n * `bounced` only: the enhanced status code (`5.7.1`) and the receiving\n * server the provider's bounce message names, read by `readBounceText` —\n * what a Gmail DSN hands over as `Status` and `Remote-MTA` (AGL-3328).\n */\n bounceStatus?: string | null\n remoteMta?: string | null\n}\n\n/** The bare address of a `From:` value (`\"Acme\" <hi@acme.com>` → `hi@acme.com`), lowercased. */\nexport function bareSenderAddress(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n const angle = raw.match(/<([^>]+)>/)\n const address = (angle ? angle[1] : raw).trim().toLowerCase()\n return /^[^\\s@<>]+@[^\\s@<>]+\\.[^\\s@<>]+$/.test(address) ? address : null\n}\n\n/** The later of two statuses on the lifecycle, worst winning a tie. */\nexport function worstDeliveryStatus(\n current: EmailDeliveryEventType | null | undefined,\n next: EmailDeliveryEventType,\n): EmailDeliveryEventType {\n if (!current) return next\n return STATUS_SEVERITY[next] >= STATUS_SEVERITY[current] ? next : current\n}\n\n/** Tags arrive as an array of `{name, value}` or a plain map — accept both. */\nexport function normalizeEventTags(raw: unknown): Record<string, string> {\n if (Array.isArray(raw)) {\n const map: Record<string, string> = {}\n for (const tag of raw) {\n if (tag?.name) map[String(tag.name)] = String(tag.value ?? '')\n }\n return map\n }\n if (raw && typeof raw === 'object') {\n return Object.fromEntries(\n Object.entries(raw as Record<string, unknown>).map(([key, value]) => [\n key,\n String(value ?? ''),\n ]),\n )\n }\n return {}\n}\n\n/** Epoch ms from an ISO string or a number, or `null` when unreadable. */\nfunction eventTimeMs(value: unknown): number | null {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n const parsed = Date.parse(String(value ?? ''))\n return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction normalizeBounceType(\n value: unknown,\n): EmailDeliveryEvent['bounceType'] {\n const lowered = String(value ?? '')\n .trim()\n .toLowerCase()\n if (lowered === 'permanent') return 'permanent'\n if (lowered === 'transient') return 'transient'\n return lowered ? 'undetermined' : null\n}\n\n/** The Resend event names we understand, mapped onto ours. */\nconst RESEND_EVENT_TYPES: Record<string, EmailDeliveryEventType> = {\n 'email.sent': 'sent',\n 'email.delivered': 'delivered',\n 'email.delivery_delayed': 'delayed',\n 'email.opened': 'opened',\n 'email.clicked': 'clicked',\n 'email.bounced': 'bounced',\n 'email.complained': 'complained',\n 'email.failed': 'failed',\n}\n\n/**\n * Turns one Resend webhook payload into zero or more of our events — **the\n * only function in the tree that knows Resend's wire format.**\n *\n * One event per recipient, not per message: a send addressed to three people\n * produces one webhook, and a staff view keyed on a person has to be able to\n * find it under each of them.\n *\n * Returns an empty array for anything unrecognised — a contact or domain\n * event, an inbound `email.received`, a type added after this was written.\n * Silence rather than a throw, because a webhook handler that 500s on an\n * unfamiliar event teaches the provider to retry it forever.\n *\n * @param payload The parsed webhook body.\n * @param receivedAtMs Fallback timestamp for a payload that carries none.\n */\nexport function normalizeResendDeliveryEvents(\n payload: unknown,\n receivedAtMs: number,\n): EmailDeliveryEvent[] {\n const event = (payload ?? {}) as Record<string, any>\n const type = RESEND_EVENT_TYPES[String(event.type ?? '')]\n if (!type) return []\n\n const data = (event.data ?? {}) as Record<string, any>\n const providerMessageId = String(data.email_id ?? data.id ?? '').trim()\n if (!providerMessageId) return []\n\n const recipients = (Array.isArray(data.to) ? data.to : [data.to])\n .map((address: unknown) => String(address ?? '').trim().toLowerCase())\n .filter((address: string) => address.includes('@'))\n if (!recipients.length) return []\n\n // The per-state timestamp when the provider gives one, because an open\n // three days after the send is the whole point of recording an open.\n const at =\n eventTimeMs(data.click?.timestamp) ??\n eventTimeMs(data.open?.timestamp) ??\n eventTimeMs(event.created_at) ??\n eventTimeMs(data.created_at) ??\n receivedAtMs\n\n const subject = String(data.subject ?? '').trim() || null\n const tags = normalizeEventTags(data.tags)\n const from = bareSenderAddress(data.from)\n // The provider folds a DSN's fields into one sentence; the structured\n // ones are read first where a payload carries them.\n const bounceText =\n type === 'bounced'\n ? readBounceText(\n [data.bounce?.diagnosticCode, data.bounce?.diagnostic_code, data.bounce?.remoteMta, data.bounce?.remote_mta, data.bounce?.message]\n .filter((part: unknown) => typeof part === 'string' && part.trim())\n .join(' '),\n )\n : null\n\n return recipients.map((to: string) => ({\n type,\n at,\n provider: 'resend',\n providerMessageId,\n to,\n subject,\n context: tags['context'] || null,\n tags,\n link: type === 'clicked' ? String(data.click?.link ?? '') || null : null,\n bounceType: type === 'bounced' ? normalizeBounceType(data.bounce?.type) : null,\n detail:\n String(data.bounce?.message ?? data.failed?.reason ?? '').trim() || null,\n ...(from ? { from } : {}),\n ...(bounceText?.status ? { bounceStatus: bounceText.status } : {}),\n ...(bounceText?.remoteMta ? { remoteMta: bounceText.remoteMta } : {}),\n }))\n}\n\n/*==========================================\n * THE READ SIDE OF THE SEAM.\n *\n * The event feed above only ever knows about mail sent AFTER it was\n * connected. That is correct for the steady state and useless for the\n * question the staff card exists to answer, which is asked about mail that\n * has already gone out — so a delivery log fed only by events is empty\n * exactly when somebody needs it.\n *\n * A provider also holds the history, and reading it is not lock-in as long as\n * it happens through an interface. {@link EmailDeliverySnapshot} is that\n * interface: one message as the provider currently sees it, in our\n * vocabulary. `normalizeResendSentEmails` is the Resend implementation and\n * the second (and last) function in the tree that knows Resend's wire format.\n *\n * WHAT A SNAPSHOT DELIBERATELY DOES NOT CARRY\n *\n * Open and click COUNTS. Resend's list endpoint reports a single\n * `last_event` per message and no engagement detail, so a snapshot can say\n * \"this was opened at least once\" and can never say \"three times\". The\n * writer therefore treats a snapshot as a floor, never as truth that\n * overwrites what the event feed recorded — see `recordEmailDeliverySnapshot`.\n *=========================================*/\n\n/** One message as the provider currently reports it, in our vocabulary. */\nexport interface EmailDeliverySnapshot {\n provider: string\n providerMessageId: string\n to: string\n subject: string | null\n /** Epoch ms the provider says the message was created. */\n sentAt: number\n /** Furthest state the provider reports. Never richer than the event feed. */\n status: EmailDeliveryEventType\n}\n\n/**\n * A provider's `last_event` string, mapped onto our lifecycle.\n *\n * Deliberately the bare state names rather than the `email.*` event names:\n * the list endpoint reports `\"delivered\"`, the webhook reports\n * `\"email.delivered\"`, and they are two different vocabularies for one\n * concept. Both are accepted here so a provider that unifies them later\n * needs no change.\n */\nconst RESEND_LAST_EVENTS: Record<string, EmailDeliveryEventType> = {\n sent: 'sent',\n delivered: 'delivered',\n delivery_delayed: 'delayed',\n opened: 'opened',\n clicked: 'clicked',\n bounced: 'bounced',\n complained: 'complained',\n failed: 'failed',\n canceled: 'failed',\n queued: 'sent',\n scheduled: 'sent',\n}\n\n/**\n * Turns one entry from Resend's `GET /emails` list into zero or more\n * snapshots — one per recipient, for the same reason the event adapter fans\n * out: the staff view is keyed on a person.\n *\n * An unrecognised `last_event` falls back to `sent` rather than being\n * dropped. The message demonstrably exists and was addressed to somebody, and\n * \"we sent this and cannot characterise what happened next\" is a far more\n * useful row than no row — which is the state that sent a staffer to the\n * vendor dashboard in the first place.\n */\nexport function normalizeResendSentEmails(\n raw: unknown,\n): EmailDeliverySnapshot[] {\n const record = (raw ?? {}) as Record<string, any>\n const providerMessageId = String(record.id ?? '').trim()\n if (!providerMessageId) return []\n\n const recipients = (Array.isArray(record.to) ? record.to : [record.to])\n .map((address: unknown) => String(address ?? '').trim().toLowerCase())\n .filter((address: string) => address.includes('@'))\n if (!recipients.length) return []\n\n const parsed = Date.parse(String(record.created_at ?? ''))\n const sentAt = Number.isFinite(parsed) ? parsed : 0\n // A snapshot with no timestamp cannot be ordered, and the log's read drops\n // any document missing its sort key — so it is refused rather than written\n // somewhere nothing will look for it.\n if (!sentAt) return []\n\n const status =\n RESEND_LAST_EVENTS[\n String(record.last_event ?? '')\n .trim()\n .toLowerCase()\n ] ?? 'sent'\n\n return recipients.map((to: string) => ({\n provider: 'resend',\n providerMessageId,\n to,\n subject: String(record.subject ?? '').trim() || null,\n sentAt,\n status,\n }))\n}\n\n/** One page of provider history, in our vocabulary. */\nexport interface EmailDeliveryHistoryPage {\n snapshots: EmailDeliverySnapshot[]\n /** Cursor for the next page, or null at the end. */\n nextCursor: string | null\n}\n\n/**\n * Reads one page of already-sent mail from a provider.\n *\n * The shape a second provider would implement. Cursor-based rather than\n * offset- or date-based because that is the lowest common denominator, and\n * NOT filtered by recipient: Resend's list endpoint has no recipient\n * parameter, so filtering is the caller's job and the import is a sweep\n * rather than a per-person lookup. That is the right shape regardless — a\n * staff page must not fan out to a third party on render.\n */\nexport type EmailDeliveryHistorySource = (options: {\n cursor?: string | null\n limit?: number\n}) => Promise<EmailDeliveryHistoryPage>\n\n/** Resend's list endpoint. Paginates with `after=<id>`; caps at 100. */\nexport const RESEND_EMAILS_ENDPOINT = 'https://api.resend.com/emails'\n\n/**\n * {@link EmailDeliveryHistorySource} for Resend.\n *\n * Needs a FULL-ACCESS key: a sending-scoped key answers every read on this\n * endpoint with `401 restricted_api_key`, which is the correct posture for\n * the key that sends mail and the reason this takes its own.\n */\nexport function resendDeliveryHistorySource(\n apiKey: string,\n): EmailDeliveryHistorySource {\n return async ({ cursor, limit } = {}) => {\n const params = new URLSearchParams({\n limit: String(Math.min(Math.max(1, limit ?? 100), 100)),\n })\n if (cursor) params.set('after', cursor)\n const response = await fetch(`${RESEND_EMAILS_ENDPOINT}?${params}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(\n `email history read failed: HTTP ${response.status} ${detail.slice(0, 200)}`,\n )\n }\n const body = (await response.json()) as {\n data?: unknown[]\n has_more?: boolean\n }\n const entries = Array.isArray(body?.data) ? body.data : []\n const snapshots = entries.flatMap((entry) => normalizeResendSentEmails(entry))\n // The cursor is the LAST RAW entry's id, not the last snapshot's: a page\n // whose final entry fanned out to zero snapshots (no recipient, no\n // timestamp) would otherwise rewind the cursor to an earlier message and\n // loop over the same page forever.\n const lastId = String(\n (entries[entries.length - 1] as { id?: unknown })?.id ?? '',\n ).trim()\n return {\n snapshots,\n nextCursor: body?.has_more && lastId ? lastId : null,\n }\n }\n}\n\n/*==========================================\n * ONE MESSAGE, RENDERED.\n *\n * The log records what HAPPENED to a message; it does not keep the message.\n * Storing every body would put an unbounded copy of every email we have ever\n * sent — including reset links and receipts — into our own database, to\n * duplicate something the provider already holds.\n *\n * So a body is fetched when a staffer explicitly opens one row. That is a\n * deliberate single lookup, not the per-render fan-out the read path refuses:\n * one message, on one click, by id.\n *=========================================*/\n\n/** One message's content and envelope, in our vocabulary. */\nexport interface EmailDeliveryMessage {\n provider: string\n providerMessageId: string\n to: string[]\n cc: string[]\n bcc: string[]\n from: string | null\n replyTo: string[] | null\n subject: string | null\n /** The HTML part, or null when the message was sent as text only. */\n html: string | null\n /** The plain-text part, or null. */\n text: string | null\n sentAt: number | null\n status: EmailDeliveryEventType | null\n}\n\n/** A single message by id. The shape a second provider would implement. */\nexport type EmailDeliveryMessageSource = (\n providerMessageId: string,\n) => Promise<EmailDeliveryMessage | null>\n\nfunction addressList(raw: unknown): string[] {\n return (Array.isArray(raw) ? raw : raw == null ? [] : [raw])\n .map((address) => String(address ?? '').trim())\n .filter(Boolean)\n}\n\n/** Resend's `GET /emails/:id` payload, in our vocabulary. */\nexport function normalizeResendMessage(raw: unknown): EmailDeliveryMessage | null {\n const record = (raw ?? {}) as Record<string, any>\n const providerMessageId = String(record.id ?? '').trim()\n if (!providerMessageId) return null\n const parsed = Date.parse(String(record.created_at ?? ''))\n return {\n provider: 'resend',\n providerMessageId,\n to: addressList(record.to),\n cc: addressList(record.cc),\n bcc: addressList(record.bcc),\n from: String(record.from ?? '').trim() || null,\n replyTo: addressList(record.reply_to).length\n ? addressList(record.reply_to)\n : null,\n subject: String(record.subject ?? '').trim() || null,\n // Empty string is NOT null here, and the difference is the point: a\n // message that went out text-only really does have an empty HTML part,\n // and a reader has to be able to tell that from \"we could not fetch it\".\n html: typeof record.html === 'string' ? record.html : null,\n text: typeof record.text === 'string' ? record.text : null,\n sentAt: Number.isFinite(parsed) ? parsed : null,\n status:\n RESEND_LAST_EVENTS[\n String(record.last_event ?? '')\n .trim()\n .toLowerCase()\n ] ?? null,\n }\n}\n\n/** Resend's single-message endpoint. Needs the same full-access key. */\nexport function resendDeliveryMessageSource(\n apiKey: string,\n): EmailDeliveryMessageSource {\n return async (providerMessageId: string) => {\n const response = await fetch(\n `${RESEND_EMAILS_ENDPOINT}/${encodeURIComponent(providerMessageId)}`,\n { headers: { Authorization: `Bearer ${apiKey}` } },\n )\n // A message the provider has aged out is a 404, and that is an ANSWER —\n // \"we know this was sent and the body is gone\" — not a failure to report\n // as an error the staffer must act on.\n if (response.status === 404) return null\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(\n `message read failed: HTTP ${response.status} ${detail.slice(0, 200)}`,\n )\n }\n return normalizeResendMessage(await response.json())\n }\n}\n"],"names":["readBounceText","STATUS_SEVERITY","sent","delivered","opened","clicked","delayed","complained","bounced","failed","bareSenderAddress","value","raw","String","trim","angle","match","address","toLowerCase","test","worstDeliveryStatus","current","next","normalizeEventTags","Array","isArray","map","tag","name","Object","fromEntries","entries","key","eventTimeMs","Number","isFinite","parsed","Date","parse","normalizeBounceType","lowered","RESEND_EVENT_TYPES","normalizeResendDeliveryEvents","payload","receivedAtMs","event","data","type","providerMessageId","email_id","id","recipients","to","filter","includes","length","at","click","timestamp","open","created_at","subject","tags","from","bounceText","bounce","diagnosticCode","diagnostic_code","remoteMta","remote_mta","message","part","join","provider","context","link","bounceType","detail","reason","status","bounceStatus","RESEND_LAST_EVENTS","delivery_delayed","canceled","queued","scheduled","normalizeResendSentEmails","record","sentAt","last_event","RESEND_EMAILS_ENDPOINT","resendDeliveryHistorySource","apiKey","cursor","limit","params","URLSearchParams","Math","min","max","set","response","fetch","headers","Authorization","ok","text","catch","Error","slice","body","json","snapshots","flatMap","entry","lastId","nextCursor","has_more","addressList","Boolean","normalizeResendMessage","cc","bcc","replyTo","reply_to","html","resendDeliveryMessageSource","encodeURIComponent"],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
* Links the bare URLs in one escaped line.
|
|
19
|
+
*
|
|
20
|
+
* Balanced closing parens and brackets are kept, because a URL can genuinely
|
|
21
|
+
* end in one and a wrapping "(see https://…/a_(b))" is the rarer case.
|
|
22
|
+
* Anything the count says is unbalanced belongs to the prose.
|
|
23
|
+
*/
|
|
24
|
+
export declare function linkifyEscapedText(escaped: string): string;
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
* Bare URLs in escaped text, made into links (AGL-3367).
|
|
18
|
+
*
|
|
19
|
+
* Shared by the two places a sender's plain words become HTML: the fallback
|
|
20
|
+
* HTML part `sendEmail` synthesizes for a text-only message, and the
|
|
21
|
+
* renderer's `emailText` block. The second is where a platform email's
|
|
22
|
+
* composed body lands once it wears the system chrome, so without this a
|
|
23
|
+
* digest that used to arrive with clickable links in its fallback part would
|
|
24
|
+
* arrive branded and dead.
|
|
25
|
+
*/ /**
|
|
26
|
+
* Trailing characters stripped from a matched URL.
|
|
27
|
+
*
|
|
28
|
+
* A sentence that ends "…see {@link https://example.com/billing}." puts the
|
|
29
|
+
* period inside the match, because a period is a legal URL character and the
|
|
30
|
+
* regex cannot tell prose from path. Closing brackets are handled separately
|
|
31
|
+
* below, since a URL may legitimately end in one.
|
|
32
|
+
*
|
|
33
|
+
* `;` is deliberately absent: by the time this runs the text is already
|
|
34
|
+
* escaped, so a query string reads `?a=1&b=2` and trimming `;` would cut
|
|
35
|
+
* an entity in half and corrupt the link.
|
|
36
|
+
*/ const URL_TRAILING_PUNCTUATION = /[.,!?'"]+$/;
|
|
37
|
+
/**
|
|
38
|
+
* Bare absolute URLs in already-escaped text.
|
|
39
|
+
*
|
|
40
|
+
* Matched AFTER escaping, not before, so the href and the visible label are
|
|
41
|
+
* the same string and neither can reintroduce markup: `&` inside a query
|
|
42
|
+
* string is `&` by then, which is what an href attribute is supposed to
|
|
43
|
+
* carry and parses back to `&` in the client.
|
|
44
|
+
*
|
|
45
|
+
* `http`/`https` only. Every URL our system copy emits is absolute — the
|
|
46
|
+
* senders share one `consoleOrigin()` precisely because a mail client has no
|
|
47
|
+
* page to resolve a relative path against — and matching bare `www.` or
|
|
48
|
+
* addresses would turn ordinary prose into links nobody wrote.
|
|
49
|
+
*/ const BARE_URL = /https?:\/\/[^\s<>"]+/g;
|
|
50
|
+
/**
|
|
51
|
+
* Links the bare URLs in one escaped line.
|
|
52
|
+
*
|
|
53
|
+
* Balanced closing parens and brackets are kept, because a URL can genuinely
|
|
54
|
+
* end in one and a wrapping "(see https://…/a_(b))" is the rarer case.
|
|
55
|
+
* Anything the count says is unbalanced belongs to the prose.
|
|
56
|
+
*/ export function linkifyEscapedText(escaped) {
|
|
57
|
+
return escaped.replace(BARE_URL, (match)=>{
|
|
58
|
+
let url = match.replace(URL_TRAILING_PUNCTUATION, '');
|
|
59
|
+
// `)` and `]` alike: a token's value dropped into "[{{brand.supportUrl}}]"
|
|
60
|
+
// closes on a bracket the URL never opened.
|
|
61
|
+
for(;;){
|
|
62
|
+
const close = url.slice(-1);
|
|
63
|
+
const open = close === ')' ? '(' : close === ']' ? '[' : '';
|
|
64
|
+
if (!open || url.split(close).length <= url.split(open).length) break;
|
|
65
|
+
url = url.slice(0, -1);
|
|
66
|
+
}
|
|
67
|
+
if (!url) return match;
|
|
68
|
+
const trailer = match.slice(url.length);
|
|
69
|
+
return `<a href="${url}" target="_blank" ` + `style="color:#1a73e8;text-decoration:underline;word-break:break-word;">` + `${url}</a>${trailer}`;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
//# sourceMappingURL=email-linkify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-linkify.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 * Bare URLs in escaped text, made into links (AGL-3367).\n *\n * Shared by the two places a sender's plain words become HTML: the fallback\n * HTML part `sendEmail` synthesizes for a text-only message, and the\n * renderer's `emailText` block. The second is where a platform email's\n * composed body lands once it wears the system chrome, so without this a\n * digest that used to arrive with clickable links in its fallback part would\n * arrive branded and dead.\n */\n\n/**\n * Trailing characters stripped from a matched URL.\n *\n * A sentence that ends \"…see {@link https://example.com/billing}.\" puts the\n * period inside the match, because a period is a legal URL character and the\n * regex cannot tell prose from path. Closing brackets are handled separately\n * below, since a URL may legitimately end in one.\n *\n * `;` is deliberately absent: by the time this runs the text is already\n * escaped, so a query string reads `?a=1&b=2` and trimming `;` would cut\n * an entity in half and corrupt the link.\n */\nconst URL_TRAILING_PUNCTUATION = /[.,!?'\"]+$/\n\n/**\n * Bare absolute URLs in already-escaped text.\n *\n * Matched AFTER escaping, not before, so the href and the visible label are\n * the same string and neither can reintroduce markup: `&` inside a query\n * string is `&` by then, which is what an href attribute is supposed to\n * carry and parses back to `&` in the client.\n *\n * `http`/`https` only. Every URL our system copy emits is absolute — the\n * senders share one `consoleOrigin()` precisely because a mail client has no\n * page to resolve a relative path against — and matching bare `www.` or\n * addresses would turn ordinary prose into links nobody wrote.\n */\nconst BARE_URL = /https?:\\/\\/[^\\s<>\"]+/g\n\n/**\n * Links the bare URLs in one escaped line.\n *\n * Balanced closing parens and brackets are kept, because a URL can genuinely\n * end in one and a wrapping \"(see https://…/a_(b))\" is the rarer case.\n * Anything the count says is unbalanced belongs to the prose.\n */\nexport function linkifyEscapedText(escaped: string): string {\n return escaped.replace(BARE_URL, (match) => {\n let url = match.replace(URL_TRAILING_PUNCTUATION, '')\n // `)` and `]` alike: a token's value dropped into \"[{{brand.supportUrl}}]\"\n // closes on a bracket the URL never opened.\n for (;;) {\n const close = url.slice(-1)\n const open = close === ')' ? '(' : close === ']' ? '[' : ''\n if (!open || url.split(close).length <= url.split(open).length) break\n url = url.slice(0, -1)\n }\n if (!url) return match\n const trailer = match.slice(url.length)\n return (\n `<a href=\"${url}\" target=\"_blank\" ` +\n `style=\"color:#1a73e8;text-decoration:underline;word-break:break-word;\">` +\n `${url}</a>${trailer}`\n )\n })\n}\n\n"],"names":["URL_TRAILING_PUNCTUATION","BARE_URL","linkifyEscapedText","escaped","replace","match","url","close","slice","open","split","length","trailer"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GAED;;;;;;;;;;;CAWC,GACD,MAAMA,2BAA2B;AAEjC;;;;;;;;;;;;CAYC,GACD,MAAMC,WAAW;AAEjB;;;;;;CAMC,GACD,OAAO,SAASC,mBAAmBC,OAAe;IAChD,OAAOA,QAAQC,OAAO,CAACH,UAAU,CAACI;QAChC,IAAIC,MAAMD,MAAMD,OAAO,CAACJ,0BAA0B;QAClD,2EAA2E;QAC3E,4CAA4C;QAC5C,OAAS;YACP,MAAMO,QAAQD,IAAIE,KAAK,CAAC,CAAC;YACzB,MAAMC,OAAOF,UAAU,MAAM,MAAMA,UAAU,MAAM,MAAM;YACzD,IAAI,CAACE,QAAQH,IAAII,KAAK,CAACH,OAAOI,MAAM,IAAIL,IAAII,KAAK,CAACD,MAAME,MAAM,EAAE;YAChEL,MAAMA,IAAIE,KAAK,CAAC,GAAG,CAAC;QACtB;QACA,IAAI,CAACF,KAAK,OAAOD;QACjB,MAAMO,UAAUP,MAAMG,KAAK,CAACF,IAAIK,MAAM;QACtC,OACE,CAAC,SAAS,EAAEL,IAAI,kBAAkB,CAAC,GACnC,CAAC,uEAAuE,CAAC,GACzE,GAAGA,IAAI,IAAI,EAAEM,SAAS;IAE1B;AACF"}
|
package/src/lib/email-render.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* these documents renders outside one — a "view this email in your browser"
|
|
43
43
|
* page is the ordinary way that happens.
|
|
44
44
|
*/ import { hasSafeLinkScheme, hasSafeMediaScheme } from "@aglyn/shared-util-http/safe-url-scheme";
|
|
45
|
+
import { linkifyEscapedText } from "./email-linkify.js";
|
|
45
46
|
import { resolveEmailMediaSrc } from "./email-media-src.js";
|
|
46
47
|
const FONT = 'Helvetica, Arial, sans-serif';
|
|
47
48
|
/**
|
|
@@ -191,11 +192,15 @@ export function renderEmailHtml(options) {
|
|
|
191
192
|
{
|
|
192
193
|
var _TEXT_STYLES_props_variant;
|
|
193
194
|
const text = sub(props.children);
|
|
195
|
+
// A block whose copy is only a token the send left empty (a
|
|
196
|
+
// notification with no detail line, AGL-3367) draws nothing, rather
|
|
197
|
+
// than an empty row and a blank paragraph in the text part.
|
|
198
|
+
if (!text.trim()) return '';
|
|
194
199
|
textParts.push(text);
|
|
195
200
|
const style = (_TEXT_STYLES_props_variant = TEXT_STYLES[props.variant]) != null ? _TEXT_STYLES_props_variant : TEXT_STYLES['body'];
|
|
196
201
|
const color = props.color || '#1a1a1a';
|
|
197
202
|
const align = props.align || 'left';
|
|
198
|
-
return row(`<div style="font-family:${FONT};${style};color:${color};text-align:${align};">` + escapeEmailHtml(text).replace(/\n/g, '<br />') + `</div>`, 'padding:4px 0;');
|
|
203
|
+
return row(`<div style="font-family:${FONT};${style};color:${color};text-align:${align};">` + linkifyEscapedText(escapeEmailHtml(text)).replace(/\n/g, '<br />') + `</div>`, 'padding:4px 0;');
|
|
199
204
|
}
|
|
200
205
|
case 'emailRichtext':
|
|
201
206
|
case 'emailHtml':
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-render.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 * Email render pipeline (AGL-348): converts a designed email screen's\n * node map into email-client-compatible HTML (600px table layout,\n * inlined styles, bulletproof buttons) plus a plain-text alternative.\n * Pure — no I/O; callers resolve products/merge data first.\n *\n * Merge tokens ({{contact.firstName}}, {{unsubscribeUrl}}, …) substitute\n * from the provided map; unknown tokens are left in place so a missing\n * field is visible in test sends instead of silently blank.\n *\n * ## The safety contract, in one place\n *\n * Everything this emits is author-controlled, and it is emitted into mail\n * that has left the building. Three rules, all applied here rather than\n * left to a caller:\n *\n * 1. Block HTML goes through {@link EmailRenderOptions.sanitize}, which is\n * REQUIRED. There is no identity default to fall back into.\n * 2. Every URL is scheme-checked against `@aglyn/shared-util-http` before it\n * is escaped into an attribute — escaping stops an attribute breakout and\n * says nothing at all about `javascript:`.\n * 3. Merge substitution into a URL cannot draw from the `contact.` namespace,\n * so no template can route a recipient's own address into a query string.\n *\n * Mail clients strip most of this themselves, so none of the three is the\n * last line of defense in an inbox. They are the only line the moment one of\n * these documents renders outside one — a \"view this email in your browser\"\n * page is the ordinary way that happens.\n */\n\nimport {\n hasSafeLinkScheme,\n hasSafeMediaScheme,\n} from '@aglyn/shared-util-http/safe-url-scheme'\n\nimport { resolveEmailMediaSrc } from './email-media-src'\n\nexport interface EmailRenderProduct {\n name: string\n priceLabel?: string\n imageUrl?: string\n url?: string\n}\n\nexport interface EmailRenderNode {\n componentId?: string\n props?: Record<string, any>\n nodes?: string[]\n}\n\nexport interface EmailRenderOptions {\n /** Flat node map (screen version `nodes`). */\n nodes: Record<string, EmailRenderNode | undefined>\n /** Root node id (defaults to 'root'). */\n rootId?: string\n subject?: string\n /** Hidden preview line shown next to the subject in inboxes. */\n preheader?: string\n /** Merge values keyed by token body, e.g. 'contact.firstName'. */\n merge?: Record<string, string>\n /** Product data by id for emailProduct blocks. */\n products?: Record<string, EmailRenderProduct | undefined>\n /**\n * The HTML policy applied to every `emailRichtext` / `emailHtml` block.\n *\n * REQUIRED, and required is the point. A policy a caller may omit is a\n * policy that gets omitted, and an omitted one here is not a rendering\n * bug: the console renders these same nodes through `sanitizeCustomHtml`\n * before an author ever sees them, so an unpoliced mail render means two\n * renderers of one document disagreeing about safety, with the unsafe one\n * being the one that sends.\n *\n * This module cannot pick the policy for itself — it is `scope:shared` and\n * the sanitizer is aglyn-scoped, which nx forbids it to import — so the\n * choice belongs to the caller, and the type is what makes the caller make\n * it. Every send path passes `sanitizeAuthorHtml`, which is exactly what\n * `sanitizeCustomHtml` delegates to, so the mailed copy and the previewed\n * copy are the same function of the same string.\n */\n sanitize: (html: string) => string\n /**\n * Absolute origin serving this email's media, e.g. `https://acme.com`\n * (AGL-1224).\n *\n * An image an author picked with \"Browse media\" is stored as a\n * `media:{scope}/{mediaId}` reference, which resolves to the SITE-RELATIVE\n * CDN path `/api/media/cdn/…`. A browser has a page to resolve that\n * against; an inbox has nothing. Without this, every picked image is a\n * broken-image box in the delivered mail.\n *\n * It is an input rather than a lookup because this module is pure, and\n * because the CDN route is mounted in BOTH the console and the tenant app\n * — which origin is correct depends on whose email this is.\n */\n mediaOrigin?: string\n /**\n * Host doc id of the site sending, so an `org:`-scoped reference is\n * host-qualified. The CDN is unauthenticated and decides from the URL\n * alone, so an org asset restricted to particular sites is only served\n * through the qualified form.\n */\n mediaHostId?: string\n /**\n * Absolute URL of the sender's EMAIL logo, prepended as a centred row at\n * the top of the 600px table (AGL-2139).\n *\n * `emailLogoUrl` was a first-class field of `OrgBrandingProfile`, resolved,\n * collected in the branding editor, https-validated and persisted — and read\n * at ZERO render sites. An agency admin on the tier that costs the most\n * filled it in, the form saved, the value round-tripped, and it appeared in\n * no email ever. This is the consumer that makes it a live capability\n * rather than a stored string.\n *\n * Absent or blank emits NOTHING — not an empty row, not a spacer. An email\n * with a gap where a logo should be reads as broken; one without a logo\n * reads as plain, which is the correct appearance for an org that has not\n * set one.\n *\n * Ignored when {@link EmailRenderOptions.chrome} carries a header: that\n * header is the logo row, and a caller that has one passes the org's logo\n * through it, so a message never opens with two.\n */\n brandLogoUrl?: string\n /**\n * The sender's header and footer, drawn around the body (AGL-3322). See\n * {@link EmailChrome}. Absent draws neither.\n */\n chrome?: EmailChrome\n}\n\n/**\n * The row above the body that says who sent the mail.\n *\n * `logoAlt` is required because it does two jobs. It is the logo's `alt`,\n * which is what most inboxes show, since they block images by default. And\n * it is the header itself when there is no logo to draw — none was given, or\n * the one given cannot be fetched from an inbox — so the sender is named in\n * bold text rather than by a broken-image box.\n */\nexport interface EmailChromeHeader {\n /** The logo. Resolved and scheme-checked like every other image here. */\n logoUrl?: string\n /** The sender's name: the logo's alt text, or the header when no logo draws. */\n logoAlt: string\n /** Where the logo or name links. A refused scheme drops the link, not the header. */\n href?: string\n}\n\n/**\n * The rows under the body, in the order they draw. Each is optional, and a\n * footer with none of them draws nothing.\n */\nexport interface EmailChromeFooter {\n /** Why the recipient is getting this mail. */\n reason?: string\n /** Where to get help. Drawn as a link; dropped whole when the href is refused. */\n support?: { label: string; href: string }\n /** Who sent it, and from where: a copyright and postal line. */\n legal?: string\n}\n\n/**\n * A sender's header and footer, drawn by the renderer rather than placed by a\n * designer (AGL-3322).\n *\n * For mail whose body carries no header or footer of its own: a system\n * email's built-in copy, or a design sent under a brand whose own blocks it\n * may not carry. The caller decides WHICH brand; this only draws.\n *\n * Every string goes through the same merge substitution as the body, so\n * `{{org.name}}` in a footer line reads as the organization it names, and is\n * escaped like body text. Every URL goes through the same scheme checks as a\n * body link or image. The footer lines are appended to the plain-text part\n * too, the support link as its bare address, because a reader of that part is\n * owed the same reason and the same way to get help.\n */\nexport interface EmailChrome {\n header?: EmailChromeHeader\n footer?: EmailChromeFooter\n}\n\nexport interface RenderedEmail {\n html: string\n text: string\n}\n\nconst FONT = 'Helvetica, Arial, sans-serif'\n\n/**\n * The colours the chrome is drawn in (AGL-3322), named once.\n *\n * Literal because this is email HTML: mail clients strip `<style>` and read no\n * CSS variables, so no theme token reaches the wire. They are the values\n * already in the mail, not new ones — the ink and card of a default body\n * block, and the band and caption grey of the email footer block the\n * platform's own mail wears — so the chrome and the body it frames agree.\n */\nconst CHROME_PALETTE = {\n /** The sender's name in the header: body text's ink. */\n ink: '#1a1a1a',\n /** The header band: a default section's card. */\n card: '#ffffff',\n /** The footer band. */\n band: '#F8F9FA',\n /** Footer lines and the support link. */\n caption: '#757575',\n} as const\n\nexport function escapeEmailHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n}\n\n/** Substitutes {{token}} occurrences; unknown tokens stay visible. */\nexport function substituteMergeTokens(\n value: string,\n merge: Record<string, string> | undefined,\n): string {\n return value.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (match, token: string) => {\n const replacement = merge?.[token]\n return replacement !== undefined ? replacement : match\n })\n}\n\nconst TEXT_STYLES: Record<string, string> = {\n heading: `font-size:28px;font-weight:700;line-height:1.25`,\n subheading: `font-size:20px;font-weight:600;line-height:1.3`,\n body: `font-size:15px;font-weight:400;line-height:1.55`,\n caption: `font-size:12px;font-weight:400;line-height:1.4`,\n}\n\n/** One full-width table row wrapping arbitrary cell HTML. */\nconst row = (cellHtml: string, cellStyle = ''): string =>\n `<tr><td style=\"${cellStyle}\">${cellHtml}</td></tr>`\n\n/**\n * The id the besigner roots every stored node map at — `CANVAS_ROOT_ELEMENT_ID`\n * in `@aglyn/aglyn`. `renderEmailHtml` still defaults `rootId` to `'root'` for\n * ad-hoc callers, but anything rendering a real besigner document MUST pass\n * this: rendering a besigner map as `'root'` finds no root and emits nothing\n * (AGL-765). Kept here so server code need not import the heavy `@aglyn/aglyn`\n * barrel; a drift guard in the console specs asserts the two stay equal.\n */\nexport const EMAIL_NODE_ROOT_ID = '_@_'\n\nexport function renderEmailHtml(options: EmailRenderOptions): RenderedEmail {\n const {\n nodes,\n rootId = 'root',\n subject = '',\n preheader = '',\n merge,\n products,\n sanitize,\n mediaOrigin,\n mediaHostId,\n brandLogoUrl,\n chrome,\n } = options\n\n const textParts: string[] = []\n const sub = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), merge)\n\n /**\n * The merge map as it applies inside a URL: everything except the\n * `contact.` namespace.\n *\n * That namespace is the recipient — `contact.email`, `contact.name`,\n * `contact.firstName` are what campaign-send puts in the map. Substituting\n * them into an `href` or a `src` is how a template like\n * `https://example.test/?e={{contact.email}}` turns every send into the\n * recipient's own address on somebody's query string, and putting personal\n * data in a URL is a line this codebase does not cross. The refusal has to\n * live here because it is the SUBSTITUTION that creates the value: no\n * scheme check and no sanitizer can see the address, since the template\n * the author wrote and the editor reviewed contains only the token.\n *\n * Nothing else narrows: `{{unsubscribeUrl}}` and `{{site.url}}` ARE URLs\n * and are the reason a token appears in this position at all.\n *\n * A refused token is left standing rather than blanked, which is this\n * module's existing convention for a token it will not fill — a test send\n * shows `?e={{contact.email}}` in the link, which names the problem to the\n * author instead of silently shipping a subtly different URL.\n */\n const urlMerge = merge\n ? Object.fromEntries(\n Object.entries(merge).filter(\n ([token]) => !token.startsWith('contact.'),\n ),\n )\n : undefined\n\n /** {@link sub} for a value that lands in a URL — see {@link urlMerge}. */\n const subUrl = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), urlMerge)\n\n /**\n * A link target safe to emit, or undefined.\n *\n * Escaping the value into the attribute stops a breakout and says nothing\n * about the scheme, so `escapeEmailHtml` on its own passes a stored\n * `javascript:` or `data:` href through verbatim. The check runs on the\n * SUBSTITUTED string, because a merge value is part of the final URL and a\n * template that reads clean can resolve to one that does not.\n */\n const linkHref = (value: unknown): string | undefined => {\n const href = subUrl(value)\n if (!href) return undefined\n return hasSafeLinkScheme(href) ? href : undefined\n }\n\n const origin = mediaOrigin?.replace(/\\/+$/, '')\n\n /**\n * A stored image value turned into something an inbox can actually fetch,\n * or undefined (AGL-1224).\n *\n * `resolveEmailMediaSrc` handles the three stored generations — a `media:`\n * reference, the AGL-175 relative CDN path, and a plain absolute URL an\n * author typed — but the first two come back site-relative, which is the\n * whole bug: only a browser has a page to resolve them against.\n *\n * With no origin to absolutize against, the image is DROPPED rather than\n * emitted relative. Both are a missing picture; a dropped one leaves a gap,\n * while `src=\"/api/media/cdn/…\"` renders as a broken-image box, which reads\n * to a recipient as a broken email rather than a plain one.\n *\n * ⚠️ This comment used to claim \"neither send path relies on this — both\n * supply an origin\", and treated reaching the drop as hypothetical. It was\n * wrong, and it was wrong in the direction that hides the bug: campaign-send\n * passed no `mediaOrigin`/`mediaHostId` at all, so every author-picked image\n * was silently dropped from every marketing campaign until AGL-1394. The\n * quiet failure IS still the right one for a caller that genuinely has no\n * origin — but \"no caller reaches this\" is a claim about the whole repo, and\n * it decays the moment someone adds a third send path. Verify it before\n * relying on it; do not restore a count here.\n *\n * A protocol-relative `//host/x.png` is passed through untouched: it is\n * already absolute enough to name a host, and prefixing an origin would\n * corrupt it.\n *\n * The scheme is checked on the way out. `resolveEmailMediaSrc` recognizes\n * the two stored reference forms and passes anything else through as an\n * author-typed URL, so it is this check and nothing else that stands\n * between what an author types and the `src` attribute. A refusal drops\n * the image on the same reasoning as everything else here that cannot be\n * made fetchable: a gap reads as a plain email, and a `src` we would not\n * stand behind is not made safer by shipping it.\n */\n const imageSrc = (value: unknown): string | undefined => {\n const resolved = resolveEmailMediaSrc(subUrl(value), mediaHostId)\n if (!resolved || !hasSafeMediaScheme(resolved)) return undefined\n if (!resolved.startsWith('/') || resolved.startsWith('//')) return resolved\n return origin ? `${origin}${resolved}` : undefined\n }\n\n const renderChildren = (ids: string[] | undefined): string =>\n (ids ?? [])\n .map((id) => renderNode(id))\n .filter(Boolean)\n .join('')\n\n const renderNode = (id: string): string => {\n const node = nodes[id]\n if (!node) return ''\n const props = node.props ?? {}\n switch (node.componentId) {\n case 'emailSection': {\n const background = props.backgroundColor || '#ffffff'\n const padding = Number.isFinite(Number(props.padding))\n ? Number(props.padding)\n : 24\n const align = props.align || 'left'\n // A ROW, like every other block, with the band's table inside its\n // cell. Whatever encloses a section is a table — the 600px column, or\n // another section's inner table — and a `<table>` placed straight into\n // a table's rows is not a child an HTML parser keeps there: it closes\n // the enclosing table and lands after it. Emitted bare, a section would\n // draw outside the column, as wide as the viewport rather than the\n // 600px the editor shows, and take every row after it out too.\n return row(\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `style=\"background-color:${background};\">` +\n row(\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">` +\n renderChildren(node.nodes) +\n `</table>`,\n `padding:${padding}px;text-align:${align};`,\n ) +\n `</table>`,\n )\n }\n case 'emailText': {\n const text = sub(props.children)\n textParts.push(text)\n const style = TEXT_STYLES[props.variant as string] ?? TEXT_STYLES['body']\n const color = props.color || '#1a1a1a'\n const align = props.align || 'left'\n return row(\n `<div style=\"font-family:${FONT};${style};color:${color};text-align:${align};\">` +\n escapeEmailHtml(text).replace(/\\n/g, '<br />') +\n `</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailRichtext':\n case 'emailHtml': {\n const html = sanitize(sub(props.html))\n if (!html.trim()) return ''\n textParts.push(html.replace(/<[^>]+>/g, ' ').trim())\n return row(\n `<div style=\"font-family:${FONT};font-size:15px;line-height:1.55;\">${html}</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailImage': {\n const src = imageSrc(props.src)\n if (!src) return ''\n const width = Number(props.width) > 0 ? Number(props.width) : 600\n const alt = escapeEmailHtml(String(props.alt ?? ''))\n const align = props.align || 'center'\n const img =\n `<img src=\"${escapeEmailHtml(src)}\" alt=\"${alt}\" width=\"${Math.min(width, 600)}\" ` +\n `style=\"display:inline-block;max-width:100%;height:auto;border:0;\" />`\n // A refused href drops the WRAPPER, not the picture: the image is the\n // content and the link is decoration on it.\n const href = linkHref(props.href)\n return row(\n href\n ? `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\">${img}</a>`\n : img,\n `padding:8px 0;text-align:${align};`,\n )\n }\n case 'emailButton': {\n const label = sub(props.children ?? 'Call to action')\n // A refused href falls back to the placeholder the empty case already\n // uses, so the button keeps its place in the layout and goes nowhere.\n // Dropping the row instead would take the author's copy with it, and\n // an inert button reads as an unfinished email rather than a broken\n // one. The plain-text alternative gets the same value the anchor\n // does — a text/plain body is inert, but it is still delivered mail\n // and a refused URL does not belong in it either.\n const href = linkHref(props.href ?? '#') ?? '#'\n const background = props.backgroundColor || '#1a73e8'\n const color = props.color || '#ffffff'\n const align = props.align || 'center'\n textParts.push(`${label}: ${href}`)\n // Bulletproof-ish button: padded anchor, table-aligned.\n return row(\n `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\" ` +\n `style=\"display:inline-block;padding:12px 28px;border-radius:6px;` +\n `background-color:${background};color:${color};font-family:${FONT};` +\n `font-size:15px;font-weight:600;text-decoration:none;\">` +\n escapeEmailHtml(label) +\n `</a>`,\n `padding:12px 0;text-align:${align};`,\n )\n }\n case 'emailDivider': {\n const color = props.color || '#e0e0e0'\n return row(\n `<div style=\"border-top:1px solid ${color};font-size:0;line-height:0;\"> </div>`,\n 'padding:12px 0;',\n )\n }\n case 'emailSpacer': {\n const height = Number(props.height) > 0 ? Number(props.height) : 24\n return row(\n `<div style=\"height:${height}px;font-size:0;line-height:0;\"> </div>`,\n )\n }\n case 'emailProduct': {\n const product = props.productId\n ? products?.[String(props.productId)]\n : undefined\n if (!product) return ''\n const label = sub(props.buttonLabel ?? 'Shop now')\n // Catalog data rather than author markup, and the caller absolutizes\n // it before it arrives — but \"it came from a trusted table\" is a\n // claim about every writer of that table, so the same rule applies\n // here as to an author-typed href. Refused means no button and no\n // line in the text part; the product card itself still renders.\n const productUrl =\n product.url && hasSafeLinkScheme(product.url)\n ? product.url\n : undefined\n textParts.push(\n `${product.name}${product.priceLabel ? ` — ${product.priceLabel}` : ''}` +\n (productUrl ? `: ${productUrl}` : ''),\n )\n // Same treatment as emailImage: a catalog image can be a picked\n // asset, so it carries the same reference/relative forms (AGL-1224).\n const productImage = imageSrc(product.imageUrl)\n const image = productImage\n ? `<img src=\"${escapeEmailHtml(productImage)}\" alt=\"${escapeEmailHtml(product.name)}\" width=\"280\" style=\"max-width:100%;height:auto;border:0;border-radius:6px;\" /><br />`\n : ''\n const button = productUrl\n ? `<a href=\"${escapeEmailHtml(productUrl)}\" target=\"_blank\" style=\"display:inline-block;margin-top:8px;padding:10px 24px;border-radius:6px;background-color:#1a73e8;color:#ffffff;font-family:${FONT};font-size:14px;font-weight:600;text-decoration:none;\">${escapeEmailHtml(label)}</a>`\n : ''\n return row(\n `<div style=\"border:1px solid #e0e0e0;border-radius:8px;padding:16px;text-align:center;font-family:${FONT};\">` +\n image +\n `<div style=\"font-size:16px;font-weight:600;margin-top:8px;\">${escapeEmailHtml(product.name)}</div>` +\n (product.priceLabel\n ? `<div style=\"font-size:14px;color:#555555;margin-top:2px;\">${escapeEmailHtml(product.priceLabel)}</div>`\n : '') +\n button +\n `</div>`,\n 'padding:8px 0;',\n )\n }\n default: {\n // Unknown/web components: render their children so mixed documents\n // degrade gracefully instead of dropping content.\n return renderChildren(node.nodes)\n }\n }\n }\n\n const body = renderNode(rootId) || renderChildren(nodes[rootId]?.nodes)\n const preheaderHtml = preheader\n ? `<div style=\"display:none;max-height:0;overflow:hidden;mso-hide:all;\">` +\n escapeEmailHtml(sub(preheader)) +\n `</div>`\n : ''\n\n // INSIDE the 600px table, above the designed body, so it inherits the\n // column's width on a phone instead of being centred against the viewport.\n // `alt` is the product name where one is known, because a logo blocked by\n // the client (which is the default in most inboxes) must still say who sent\n // the mail. Height is capped rather than set, so a wordmark and a square\n // mark both land at a sane size without the sender supplying dimensions.\n //\n // The src goes through `imageSrc` like every other image in the message\n // (AGL-2230), and NOT into the tag raw. `emailLogoUrl` is collected by\n // `MediaUrlField`, whose \"Browse\" button writes the picked asset's\n // site-relative `cdnPath` — and the org DAM's other generation stores a\n // `media:{scope}/{mediaId}` reference. Neither is a URL an inbox can\n // resolve: there is no page to resolve it against, so a raw pass-through\n // put `src=\"media:h1/med9\"` at the top of every transactional email a\n // white-label org sent. `imageSrc` absolutizes both forms against\n // `mediaOrigin`, passes a real https URL through untouched, and returns\n // undefined when it cannot produce something fetchable — in which case the\n // row is dropped, on the same reasoning as AGL-1224: a gap reads as a plain\n // email, a broken-image box reads as a broken one.\n const brandLogo = imageSrc(String(brandLogoUrl ?? '').trim()) ?? ''\n // The product name from the merge map, because most inboxes block images by\n // default — a logo with no `alt` is a blank box where the sender's identity\n // should be. Falls back to nothing rather than to a literal, which would\n // reintroduce the hard-coded brand this whole change removes.\n const brandLogoAlt = merge?.['brand.productName'] ?? ''\n const brandLogoHtml = brandLogo\n ? row(\n `<img src=\"${escapeEmailHtml(brandLogo)}\" alt=\"${escapeEmailHtml(brandLogoAlt)}\" ` +\n `style=\"max-height:48px;max-width:200px;display:block;margin:0 auto;border:0;\" />`,\n 'padding:24px 24px 0;text-align:center;',\n )\n : ''\n\n /**\n * The chrome header row, or '' when it has nothing to draw.\n *\n * Same placement and the same `imageSrc` as the logo row above, which it\n * replaces. Height is capped at 40px rather than set, so a wordmark and a\n * square mark both land at a sane size; the `height` attribute is for the\n * desktop clients that ignore CSS `max-height` and would otherwise draw the\n * file at its own size. A logo that cannot be fetched from an inbox falls\n * back to the name in bold, never to an `<img>` that draws a broken box.\n * White, so the header and a default body read as one card.\n */\n const renderChromeHeader = (header: EmailChromeHeader): string => {\n const logo = imageSrc(String(header.logoUrl ?? '').trim())\n const name = sub(header.logoAlt).trim()\n const href = linkHref(header.href)\n const link = (inner: string, style: string): string =>\n href\n ? `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\" style=\"${style}\">${inner}</a>`\n : inner\n let mark: string\n if (logo) {\n mark = link(\n `<img src=\"${escapeEmailHtml(logo)}\" alt=\"${escapeEmailHtml(name)}\" height=\"40\" ` +\n `style=\"display:block;margin:0 auto;border:0;height:auto;width:auto;max-height:40px;max-width:200px;\" />`,\n 'text-decoration:none;',\n )\n } else if (name) {\n mark =\n `<div style=\"font-family:${FONT};font-size:20px;font-weight:700;line-height:1.3;color:${CHROME_PALETTE.ink};text-align:center;\">` +\n link(escapeEmailHtml(name), `color:${CHROME_PALETTE.ink};text-decoration:none;`) +\n `</div>`\n } else {\n return ''\n }\n return row(\n mark,\n `padding:24px 24px 0;text-align:center;background-color:${CHROME_PALETTE.card};`,\n )\n }\n\n const headerHtml = chrome?.header\n ? renderChromeHeader(chrome.header)\n : brandLogoHtml\n\n /**\n * The chrome footer: rows under the body in the look of the email footer\n * block the platform's own mail wears (a `#F8F9FA` section, padded 24,\n * centered captions), and the same lines appended to the plain-text part.\n *\n * Rendered AFTER the body on purpose: the body's blocks push their text as\n * they draw, and the footer's lines belong after all of it. A line that\n * resolves to nothing is left out rather than drawn as an empty row.\n */\n const renderChromeFooter = (footer: EmailChromeFooter): string => {\n const htmlLines: string[] = []\n const textLines: string[] = []\n const reason = sub(footer.reason).trim()\n if (reason) {\n htmlLines.push(escapeEmailHtml(reason))\n textLines.push(reason)\n }\n const supportLabel = sub(footer.support?.label).trim()\n const supportHref = linkHref(footer.support?.href)\n if (supportLabel && supportHref) {\n htmlLines.push(\n `<a href=\"${escapeEmailHtml(supportHref)}\" target=\"_blank\" ` +\n `style=\"color:${CHROME_PALETTE.caption};text-decoration:underline;\">${escapeEmailHtml(supportLabel)}</a>`,\n )\n // A plain-text reader gets the address itself, bare: a mailto's\n // mailbox, or the URL. \"Get help: https://…\" names where it goes; a\n // label that already IS the mailbox is not repeated.\n const bare = /^mailto:/i.test(supportHref)\n ? supportHref.slice('mailto:'.length).split('?')[0]\n : supportHref\n textLines.push(supportLabel === bare ? bare : `${supportLabel}: ${bare}`)\n }\n const legal = sub(footer.legal).trim()\n if (legal) {\n htmlLines.push(escapeEmailHtml(legal))\n textLines.push(legal)\n }\n if (!htmlLines.length) return ''\n textParts.push(textLines.join('\\n'))\n return row(\n htmlLines\n .map(\n (line, index) =>\n `<div style=\"font-family:${FONT};font-size:12px;font-weight:400;line-height:1.4;color:${CHROME_PALETTE.caption};` +\n `${index ? 'margin-top:8px;' : ''}\">${line.replace(/\\n/g, '<br />')}</div>`,\n )\n .join(''),\n `padding:24px;text-align:center;background-color:${CHROME_PALETTE.band};`,\n )\n }\n\n const footerHtml = chrome?.footer ? renderChromeFooter(chrome.footer) : ''\n\n const html =\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\" />` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />` +\n `<title>${escapeEmailHtml(sub(subject))}</title></head>` +\n `<body style=\"margin:0;padding:0;background-color:#f4f4f4;\">` +\n preheaderHtml +\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background-color:#f4f4f4;\">` +\n row(\n `<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" align=\"center\" style=\"max-width:600px;width:100%;margin:0 auto;\">` +\n headerHtml +\n body +\n footerHtml +\n `</table>`,\n 'padding:24px 8px;',\n ) +\n `</table></body></html>`\n\n return { html, text: textParts.join('\\n\\n') }\n}\n"],"names":["hasSafeLinkScheme","hasSafeMediaScheme","resolveEmailMediaSrc","FONT","CHROME_PALETTE","ink","card","band","caption","escapeEmailHtml","value","replace","substituteMergeTokens","merge","match","token","replacement","undefined","TEXT_STYLES","heading","subheading","body","row","cellHtml","cellStyle","EMAIL_NODE_ROOT_ID","renderEmailHtml","options","imageSrc","nodes","rootId","subject","preheader","products","sanitize","mediaOrigin","mediaHostId","brandLogoUrl","chrome","textParts","sub","String","urlMerge","Object","fromEntries","entries","filter","startsWith","subUrl","linkHref","href","origin","resolved","renderChildren","ids","map","id","renderNode","Boolean","join","node","props","componentId","background","backgroundColor","padding","Number","isFinite","align","text","children","push","style","variant","color","html","trim","src","width","alt","img","Math","min","label","height","product","productId","buttonLabel","productUrl","url","name","priceLabel","productImage","imageUrl","image","button","preheaderHtml","brandLogo","brandLogoAlt","brandLogoHtml","renderChromeHeader","header","logo","logoUrl","logoAlt","link","inner","mark","headerHtml","renderChromeFooter","footer","htmlLines","textLines","reason","supportLabel","support","supportHref","bare","test","slice","length","split","legal","line","index","footerHtml"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GAED,SACEA,iBAAiB,EACjBC,kBAAkB,QACb,0CAAyC;AAEhD,SAASC,oBAAoB,QAAQ,uBAAmB;AAuJxD,MAAMC,OAAO;AAEb;;;;;;;;CAQC,GACD,MAAMC,iBAAiB;IACrB,sDAAsD,GACtDC,KAAK;IACL,+CAA+C,GAC/CC,MAAM;IACN,qBAAqB,GACrBC,MAAM;IACN,uCAAuC,GACvCC,SAAS;AACX;AAEA,OAAO,SAASC,gBAAgBC,KAAa;IAC3C,OAAOA,MACJC,OAAO,CAAC,MAAM,SACdA,OAAO,CAAC,MAAM,QACdA,OAAO,CAAC,MAAM,QACdA,OAAO,CAAC,MAAM;AACnB;AAEA,oEAAoE,GACpE,OAAO,SAASC,sBACdF,KAAa,EACbG,KAAyC;IAEzC,OAAOH,MAAMC,OAAO,CAAC,2BAA2B,CAACG,OAAOC;QACtD,MAAMC,cAAcH,yBAAAA,KAAO,CAACE,MAAM;QAClC,OAAOC,gBAAgBC,YAAYD,cAAcF;IACnD;AACF;AAEA,MAAMI,cAAsC;IAC1CC,SAAS,CAAC,+CAA+C,CAAC;IAC1DC,YAAY,CAAC,8CAA8C,CAAC;IAC5DC,MAAM,CAAC,+CAA+C,CAAC;IACvDb,SAAS,CAAC,8CAA8C,CAAC;AAC3D;AAEA,2DAA2D,GAC3D,MAAMc,MAAM,CAACC,UAAkBC,YAAY,EAAE,GAC3C,CAAC,eAAe,EAAEA,UAAU,EAAE,EAAED,SAAS,UAAU,CAAC;AAEtD;;;;;;;CAOC,GACD,OAAO,MAAME,qBAAqB,MAAK;AAEvC,OAAO,SAASC,gBAAgBC,OAA2B;QA+SvCC;QA1BgCC;IApRlD,MAAM,EACJA,KAAK,EACLC,SAAS,MAAM,EACfC,UAAU,EAAE,EACZC,YAAY,EAAE,EACdnB,KAAK,EACLoB,QAAQ,EACRC,QAAQ,EACRC,WAAW,EACXC,WAAW,EACXC,YAAY,EACZC,MAAM,EACP,GAAGX;IAEJ,MAAMY,YAAsB,EAAE;IAC9B,MAAMC,MAAM,CAAC9B,QACXE,sBAAsB6B,OAAO/B,gBAAAA,QAAS,KAAKG;IAE7C;;;;;;;;;;;;;;;;;;;;;GAqBC,GACD,MAAM6B,WAAW7B,QACb8B,OAAOC,WAAW,CAChBD,OAAOE,OAAO,CAAChC,OAAOiC,MAAM,CAC1B,CAAC,CAAC/B,MAAM,GAAK,CAACA,MAAMgC,UAAU,CAAC,gBAGnC9B;IAEJ,wEAAwE,GACxE,MAAM+B,SAAS,CAACtC,QACdE,sBAAsB6B,OAAO/B,gBAAAA,QAAS,KAAKgC;IAE7C;;;;;;;;GAQC,GACD,MAAMO,WAAW,CAACvC;QAChB,MAAMwC,OAAOF,OAAOtC;QACpB,IAAI,CAACwC,MAAM,OAAOjC;QAClB,OAAOjB,kBAAkBkD,QAAQA,OAAOjC;IAC1C;IAEA,MAAMkC,SAAShB,+BAAAA,YAAaxB,OAAO,CAAC,QAAQ;IAE5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCC,GACD,MAAMiB,WAAW,CAAClB;QAChB,MAAM0C,WAAWlD,qBAAqB8C,OAAOtC,QAAQ0B;QACrD,IAAI,CAACgB,YAAY,CAACnD,mBAAmBmD,WAAW,OAAOnC;QACvD,IAAI,CAACmC,SAASL,UAAU,CAAC,QAAQK,SAASL,UAAU,CAAC,OAAO,OAAOK;QACnE,OAAOD,SAAS,GAAGA,SAASC,UAAU,GAAGnC;IAC3C;IAEA,MAAMoC,iBAAiB,CAACC,MACtB,CAACA,cAAAA,MAAO,EAAE,EACPC,GAAG,CAAC,CAACC,KAAOC,WAAWD,KACvBV,MAAM,CAACY,SACPC,IAAI,CAAC;IAEV,MAAMF,aAAa,CAACD;YAGJI;QAFd,MAAMA,OAAO/B,KAAK,CAAC2B,GAAG;QACtB,IAAI,CAACI,MAAM,OAAO;QAClB,MAAMC,SAAQD,cAAAA,KAAKC,KAAK,YAAVD,cAAc,CAAC;QAC7B,OAAQA,KAAKE,WAAW;YACtB,KAAK;gBAAgB;oBACnB,MAAMC,aAAaF,MAAMG,eAAe,IAAI;oBAC5C,MAAMC,UAAUC,OAAOC,QAAQ,CAACD,OAAOL,MAAMI,OAAO,KAChDC,OAAOL,MAAMI,OAAO,IACpB;oBACJ,MAAMG,QAAQP,MAAMO,KAAK,IAAI;oBAC7B,kEAAkE;oBAClE,sEAAsE;oBACtE,uEAAuE;oBACvE,sEAAsE;oBACtE,wEAAwE;oBACxE,mEAAmE;oBACnE,+DAA+D;oBAC/D,OAAO9C,IACL,CAAC,wEAAwE,CAAC,GACxE,CAAC,wBAAwB,EAAEyC,WAAW,GAAG,CAAC,GAC1CzC,IACE,CAAC,wEAAwE,CAAC,GACxE+B,eAAeO,KAAK/B,KAAK,IACzB,CAAC,QAAQ,CAAC,EACZ,CAAC,QAAQ,EAAEoC,QAAQ,cAAc,EAAEG,MAAM,CAAC,CAAC,IAE7C,CAAC,QAAQ,CAAC;gBAEhB;YACA,KAAK;gBAAa;wBAGFlD;oBAFd,MAAMmD,OAAO7B,IAAIqB,MAAMS,QAAQ;oBAC/B/B,UAAUgC,IAAI,CAACF;oBACf,MAAMG,SAAQtD,6BAAAA,WAAW,CAAC2C,MAAMY,OAAO,CAAW,YAApCvD,6BAAwCA,WAAW,CAAC,OAAO;oBACzE,MAAMwD,QAAQb,MAAMa,KAAK,IAAI;oBAC7B,MAAMN,QAAQP,MAAMO,KAAK,IAAI;oBAC7B,OAAO9C,IACL,CAAC,wBAAwB,EAAEnB,KAAK,CAAC,EAAEqE,MAAM,OAAO,EAAEE,MAAM,YAAY,EAAEN,MAAM,GAAG,CAAC,GAC9E3D,gBAAgB4D,MAAM1D,OAAO,CAAC,OAAO,YACrC,CAAC,MAAM,CAAC,EACV;gBAEJ;YACA,KAAK;YACL,KAAK;gBAAa;oBAChB,MAAMgE,OAAOzC,SAASM,IAAIqB,MAAMc,IAAI;oBACpC,IAAI,CAACA,KAAKC,IAAI,IAAI,OAAO;oBACzBrC,UAAUgC,IAAI,CAACI,KAAKhE,OAAO,CAAC,YAAY,KAAKiE,IAAI;oBACjD,OAAOtD,IACL,CAAC,wBAAwB,EAAEnB,KAAK,mCAAmC,EAAEwE,KAAK,MAAM,CAAC,EACjF;gBAEJ;YACA,KAAK;gBAAc;wBAIkBd;oBAHnC,MAAMgB,MAAMjD,SAASiC,MAAMgB,GAAG;oBAC9B,IAAI,CAACA,KAAK,OAAO;oBACjB,MAAMC,QAAQZ,OAAOL,MAAMiB,KAAK,IAAI,IAAIZ,OAAOL,MAAMiB,KAAK,IAAI;oBAC9D,MAAMC,MAAMtE,gBAAgBgC,QAAOoB,aAAAA,MAAMkB,GAAG,YAATlB,aAAa;oBAChD,MAAMO,QAAQP,MAAMO,KAAK,IAAI;oBAC7B,MAAMY,MACJ,CAAC,UAAU,EAAEvE,gBAAgBoE,KAAK,OAAO,EAAEE,IAAI,SAAS,EAAEE,KAAKC,GAAG,CAACJ,OAAO,KAAK,EAAE,CAAC,GAClF,CAAC,oEAAoE,CAAC;oBACxE,sEAAsE;oBACtE,4CAA4C;oBAC5C,MAAM5B,OAAOD,SAASY,MAAMX,IAAI;oBAChC,OAAO5B,IACL4B,OACI,CAAC,SAAS,EAAEzC,gBAAgByC,MAAM,kBAAkB,EAAE8B,IAAI,IAAI,CAAC,GAC/DA,KACJ,CAAC,yBAAyB,EAAEZ,MAAM,CAAC,CAAC;gBAExC;YACA,KAAK;gBAAe;wBACAP,iBAQLZ,WAASY;oBARtB,MAAMsB,QAAQ3C,KAAIqB,kBAAAA,MAAMS,QAAQ,YAAdT,kBAAkB;oBACpC,sEAAsE;oBACtE,sEAAsE;oBACtE,qEAAqE;oBACrE,oEAAoE;oBACpE,iEAAiE;oBACjE,oEAAoE;oBACpE,kDAAkD;oBAClD,MAAMX,QAAOD,YAAAA,UAASY,cAAAA,MAAMX,IAAI,YAAVW,cAAc,gBAAvBZ,YAA+B;oBAC5C,MAAMc,aAAaF,MAAMG,eAAe,IAAI;oBAC5C,MAAMU,QAAQb,MAAMa,KAAK,IAAI;oBAC7B,MAAMN,QAAQP,MAAMO,KAAK,IAAI;oBAC7B7B,UAAUgC,IAAI,CAAC,GAAGY,MAAM,EAAE,EAAEjC,MAAM;oBAClC,wDAAwD;oBACxD,OAAO5B,IACL,CAAC,SAAS,EAAEb,gBAAgByC,MAAM,kBAAkB,CAAC,GACnD,CAAC,gEAAgE,CAAC,GAClE,CAAC,iBAAiB,EAAEa,WAAW,OAAO,EAAEW,MAAM,aAAa,EAAEvE,KAAK,CAAC,CAAC,GACpE,CAAC,sDAAsD,CAAC,GACxDM,gBAAgB0E,SAChB,CAAC,IAAI,CAAC,EACR,CAAC,0BAA0B,EAAEf,MAAM,CAAC,CAAC;gBAEzC;YACA,KAAK;gBAAgB;oBACnB,MAAMM,QAAQb,MAAMa,KAAK,IAAI;oBAC7B,OAAOpD,IACL,CAAC,iCAAiC,EAAEoD,MAAM,yCAAyC,CAAC,EACpF;gBAEJ;YACA,KAAK;gBAAe;oBAClB,MAAMU,SAASlB,OAAOL,MAAMuB,MAAM,IAAI,IAAIlB,OAAOL,MAAMuB,MAAM,IAAI;oBACjE,OAAO9D,IACL,CAAC,mBAAmB,EAAE8D,OAAO,2CAA2C,CAAC;gBAE7E;YACA,KAAK;gBAAgB;wBAKDvB;oBAJlB,MAAMwB,UAAUxB,MAAMyB,SAAS,GAC3BrD,4BAAAA,QAAU,CAACQ,OAAOoB,MAAMyB,SAAS,EAAE,GACnCrE;oBACJ,IAAI,CAACoE,SAAS,OAAO;oBACrB,MAAMF,QAAQ3C,KAAIqB,qBAAAA,MAAM0B,WAAW,YAAjB1B,qBAAqB;oBACvC,qEAAqE;oBACrE,iEAAiE;oBACjE,mEAAmE;oBACnE,kEAAkE;oBAClE,gEAAgE;oBAChE,MAAM2B,aACJH,QAAQI,GAAG,IAAIzF,kBAAkBqF,QAAQI,GAAG,IACxCJ,QAAQI,GAAG,GACXxE;oBACNsB,UAAUgC,IAAI,CACZ,GAAGc,QAAQK,IAAI,GAAGL,QAAQM,UAAU,GAAG,CAAC,GAAG,EAAEN,QAAQM,UAAU,EAAE,GAAG,IAAI,GACrEH,CAAAA,aAAa,CAAC,EAAE,EAAEA,YAAY,GAAG,EAAC;oBAEvC,gEAAgE;oBAChE,qEAAqE;oBACrE,MAAMI,eAAehE,SAASyD,QAAQQ,QAAQ;oBAC9C,MAAMC,QAAQF,eACV,CAAC,UAAU,EAAEnF,gBAAgBmF,cAAc,OAAO,EAAEnF,gBAAgB4E,QAAQK,IAAI,EAAE,qFAAqF,CAAC,GACxK;oBACJ,MAAMK,SAASP,aACX,CAAC,SAAS,EAAE/E,gBAAgB+E,YAAY,oJAAoJ,EAAErF,KAAK,uDAAuD,EAAEM,gBAAgB0E,OAAO,IAAI,CAAC,GACxR;oBACJ,OAAO7D,IACL,CAAC,kGAAkG,EAAEnB,KAAK,GAAG,CAAC,GAC5G2F,QACA,CAAC,4DAA4D,EAAErF,gBAAgB4E,QAAQK,IAAI,EAAE,MAAM,CAAC,GACnGL,CAAAA,QAAQM,UAAU,GACf,CAAC,0DAA0D,EAAElF,gBAAgB4E,QAAQM,UAAU,EAAE,MAAM,CAAC,GACxG,EAAC,IACLI,SACA,CAAC,MAAM,CAAC,EACV;gBAEJ;YACA;gBAAS;oBACP,mEAAmE;oBACnE,kDAAkD;oBAClD,OAAO1C,eAAeO,KAAK/B,KAAK;gBAClC;QACF;IACF;IAEA,MAAMR,OAAOoC,WAAW3B,WAAWuB,gBAAexB,gBAAAA,KAAK,CAACC,OAAO,qBAAbD,cAAeA,KAAK;IACtE,MAAMmE,gBAAgBhE,YAClB,CAAC,qEAAqE,CAAC,GACvEvB,gBAAgB+B,IAAIR,cACpB,CAAC,MAAM,CAAC,GACR;IAEJ,sEAAsE;IACtE,2EAA2E;IAC3E,0EAA0E;IAC1E,4EAA4E;IAC5E,yEAAyE;IACzE,yEAAyE;IACzE,EAAE;IACF,wEAAwE;IACxE,uEAAuE;IACvE,mEAAmE;IACnE,wEAAwE;IACxE,qEAAqE;IACrE,yEAAyE;IACzE,sEAAsE;IACtE,kEAAkE;IAClE,wEAAwE;IACxE,2EAA2E;IAC3E,4EAA4E;IAC5E,mDAAmD;IACnD,MAAMiE,aAAYrE,YAAAA,SAASa,OAAOJ,uBAAAA,eAAgB,IAAIuC,IAAI,eAAxChD,YAA+C;IACjE,4EAA4E;IAC5E,4EAA4E;IAC5E,yEAAyE;IACzE,8DAA8D;IAC9D,MAAMsE,uBAAerF,yBAAAA,KAAO,CAAC,oBAAoB,mBAAI;IACrD,MAAMsF,gBAAgBF,YAClB3E,IACE,CAAC,UAAU,EAAEb,gBAAgBwF,WAAW,OAAO,EAAExF,gBAAgByF,cAAc,EAAE,CAAC,GAChF,CAAC,gFAAgF,CAAC,EACpF,4CAEF;IAEJ;;;;;;;;;;GAUC,GACD,MAAME,qBAAqB,CAACC;YACGA;QAA7B,MAAMC,OAAO1E,SAASa,QAAO4D,kBAAAA,OAAOE,OAAO,YAAdF,kBAAkB,IAAIzB,IAAI;QACvD,MAAMc,OAAOlD,IAAI6D,OAAOG,OAAO,EAAE5B,IAAI;QACrC,MAAM1B,OAAOD,SAASoD,OAAOnD,IAAI;QACjC,MAAMuD,OAAO,CAACC,OAAelC,QAC3BtB,OACI,CAAC,SAAS,EAAEzC,gBAAgByC,MAAM,yBAAyB,EAAEsB,MAAM,EAAE,EAAEkC,MAAM,IAAI,CAAC,GAClFA;QACN,IAAIC;QACJ,IAAIL,MAAM;YACRK,OAAOF,KACL,CAAC,UAAU,EAAEhG,gBAAgB6F,MAAM,OAAO,EAAE7F,gBAAgBiF,MAAM,cAAc,CAAC,GAC/E,CAAC,uGAAuG,CAAC,EAC3G;QAEJ,OAAO,IAAIA,MAAM;YACfiB,OACE,CAAC,wBAAwB,EAAExG,KAAK,sDAAsD,EAAEC,eAAeC,GAAG,CAAC,qBAAqB,CAAC,GACjIoG,KAAKhG,gBAAgBiF,OAAO,CAAC,MAAM,EAAEtF,eAAeC,GAAG,CAAC,sBAAsB,CAAC,IAC/E,CAAC,MAAM,CAAC;QACZ,OAAO;YACL,OAAO;QACT;QACA,OAAOiB,IACLqF,MACA,CAAC,uDAAuD,EAAEvG,eAAeE,IAAI,CAAC,CAAC,CAAC;IAEpF;IAEA,MAAMsG,aAAatE,CAAAA,0BAAAA,OAAQ+D,MAAM,IAC7BD,mBAAmB9D,OAAO+D,MAAM,IAChCF;IAEJ;;;;;;;;GAQC,GACD,MAAMU,qBAAqB,CAACC;YAQDA,iBACIA;QAR7B,MAAMC,YAAsB,EAAE;QAC9B,MAAMC,YAAsB,EAAE;QAC9B,MAAMC,SAASzE,IAAIsE,OAAOG,MAAM,EAAErC,IAAI;QACtC,IAAIqC,QAAQ;YACVF,UAAUxC,IAAI,CAAC9D,gBAAgBwG;YAC/BD,UAAUzC,IAAI,CAAC0C;QACjB;QACA,MAAMC,eAAe1E,KAAIsE,kBAAAA,OAAOK,OAAO,qBAAdL,gBAAgB3B,KAAK,EAAEP,IAAI;QACpD,MAAMwC,cAAcnE,UAAS6D,mBAAAA,OAAOK,OAAO,qBAAdL,iBAAgB5D,IAAI;QACjD,IAAIgE,gBAAgBE,aAAa;YAC/BL,UAAUxC,IAAI,CACZ,CAAC,SAAS,EAAE9D,gBAAgB2G,aAAa,kBAAkB,CAAC,GAC1D,CAAC,aAAa,EAAEhH,eAAeI,OAAO,CAAC,6BAA6B,EAAEC,gBAAgByG,cAAc,IAAI,CAAC;YAE7G,gEAAgE;YAChE,oEAAoE;YACpE,qDAAqD;YACrD,MAAMG,OAAO,YAAYC,IAAI,CAACF,eAC1BA,YAAYG,KAAK,CAAC,UAAUC,MAAM,EAAEC,KAAK,CAAC,IAAI,CAAC,EAAE,GACjDL;YACJJ,UAAUzC,IAAI,CAAC2C,iBAAiBG,OAAOA,OAAO,GAAGH,aAAa,EAAE,EAAEG,MAAM;QAC1E;QACA,MAAMK,QAAQlF,IAAIsE,OAAOY,KAAK,EAAE9C,IAAI;QACpC,IAAI8C,OAAO;YACTX,UAAUxC,IAAI,CAAC9D,gBAAgBiH;YAC/BV,UAAUzC,IAAI,CAACmD;QACjB;QACA,IAAI,CAACX,UAAUS,MAAM,EAAE,OAAO;QAC9BjF,UAAUgC,IAAI,CAACyC,UAAUrD,IAAI,CAAC;QAC9B,OAAOrC,IACLyF,UACGxD,GAAG,CACF,CAACoE,MAAMC,QACL,CAAC,wBAAwB,EAAEzH,KAAK,sDAAsD,EAAEC,eAAeI,OAAO,CAAC,CAAC,CAAC,GACjH,GAAGoH,QAAQ,oBAAoB,GAAG,EAAE,EAAED,KAAKhH,OAAO,CAAC,OAAO,UAAU,MAAM,CAAC,EAE9EgD,IAAI,CAAC,KACR,CAAC,gDAAgD,EAAEvD,eAAeG,IAAI,CAAC,CAAC,CAAC;IAE7E;IAEA,MAAMsH,aAAavF,CAAAA,0BAAAA,OAAQwE,MAAM,IAAGD,mBAAmBvE,OAAOwE,MAAM,IAAI;IAExE,MAAMnC,OACJ,CAAC,mDAAmD,CAAC,GACrD,CAAC,sEAAsE,CAAC,GACxE,CAAC,OAAO,EAAElE,gBAAgB+B,IAAIT,UAAU,eAAe,CAAC,GACxD,CAAC,2DAA2D,CAAC,GAC7DiE,gBACA,CAAC,0GAA0G,CAAC,GAC5G1E,IACE,CAAC,wIAAwI,CAAC,GACxIsF,aACAvF,OACAwG,aACA,CAAC,QAAQ,CAAC,EACZ,uBAEF,CAAC,sBAAsB,CAAC;IAE1B,OAAO;QAAElD;QAAMN,MAAM9B,UAAUoB,IAAI,CAAC;IAAQ;AAC9C"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-render.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 * Email render pipeline (AGL-348): converts a designed email screen's\n * node map into email-client-compatible HTML (600px table layout,\n * inlined styles, bulletproof buttons) plus a plain-text alternative.\n * Pure — no I/O; callers resolve products/merge data first.\n *\n * Merge tokens ({{contact.firstName}}, {{unsubscribeUrl}}, …) substitute\n * from the provided map; unknown tokens are left in place so a missing\n * field is visible in test sends instead of silently blank.\n *\n * ## The safety contract, in one place\n *\n * Everything this emits is author-controlled, and it is emitted into mail\n * that has left the building. Three rules, all applied here rather than\n * left to a caller:\n *\n * 1. Block HTML goes through {@link EmailRenderOptions.sanitize}, which is\n * REQUIRED. There is no identity default to fall back into.\n * 2. Every URL is scheme-checked against `@aglyn/shared-util-http` before it\n * is escaped into an attribute — escaping stops an attribute breakout and\n * says nothing at all about `javascript:`.\n * 3. Merge substitution into a URL cannot draw from the `contact.` namespace,\n * so no template can route a recipient's own address into a query string.\n *\n * Mail clients strip most of this themselves, so none of the three is the\n * last line of defense in an inbox. They are the only line the moment one of\n * these documents renders outside one — a \"view this email in your browser\"\n * page is the ordinary way that happens.\n */\n\nimport {\n hasSafeLinkScheme,\n hasSafeMediaScheme,\n} from '@aglyn/shared-util-http/safe-url-scheme'\n\nimport { linkifyEscapedText } from './email-linkify'\nimport { resolveEmailMediaSrc } from './email-media-src'\n\nexport interface EmailRenderProduct {\n name: string\n priceLabel?: string\n imageUrl?: string\n url?: string\n}\n\nexport interface EmailRenderNode {\n componentId?: string\n props?: Record<string, any>\n nodes?: string[]\n}\n\nexport interface EmailRenderOptions {\n /** Flat node map (screen version `nodes`). */\n nodes: Record<string, EmailRenderNode | undefined>\n /** Root node id (defaults to 'root'). */\n rootId?: string\n subject?: string\n /** Hidden preview line shown next to the subject in inboxes. */\n preheader?: string\n /** Merge values keyed by token body, e.g. 'contact.firstName'. */\n merge?: Record<string, string>\n /** Product data by id for emailProduct blocks. */\n products?: Record<string, EmailRenderProduct | undefined>\n /**\n * The HTML policy applied to every `emailRichtext` / `emailHtml` block.\n *\n * REQUIRED, and required is the point. A policy a caller may omit is a\n * policy that gets omitted, and an omitted one here is not a rendering\n * bug: the console renders these same nodes through `sanitizeCustomHtml`\n * before an author ever sees them, so an unpoliced mail render means two\n * renderers of one document disagreeing about safety, with the unsafe one\n * being the one that sends.\n *\n * This module cannot pick the policy for itself — it is `scope:shared` and\n * the sanitizer is aglyn-scoped, which nx forbids it to import — so the\n * choice belongs to the caller, and the type is what makes the caller make\n * it. Every send path passes `sanitizeAuthorHtml`, which is exactly what\n * `sanitizeCustomHtml` delegates to, so the mailed copy and the previewed\n * copy are the same function of the same string.\n */\n sanitize: (html: string) => string\n /**\n * Absolute origin serving this email's media, e.g. `https://acme.com`\n * (AGL-1224).\n *\n * An image an author picked with \"Browse media\" is stored as a\n * `media:{scope}/{mediaId}` reference, which resolves to the SITE-RELATIVE\n * CDN path `/api/media/cdn/…`. A browser has a page to resolve that\n * against; an inbox has nothing. Without this, every picked image is a\n * broken-image box in the delivered mail.\n *\n * It is an input rather than a lookup because this module is pure, and\n * because the CDN route is mounted in BOTH the console and the tenant app\n * — which origin is correct depends on whose email this is.\n */\n mediaOrigin?: string\n /**\n * Host doc id of the site sending, so an `org:`-scoped reference is\n * host-qualified. The CDN is unauthenticated and decides from the URL\n * alone, so an org asset restricted to particular sites is only served\n * through the qualified form.\n */\n mediaHostId?: string\n /**\n * Absolute URL of the sender's EMAIL logo, prepended as a centred row at\n * the top of the 600px table (AGL-2139).\n *\n * `emailLogoUrl` was a first-class field of `OrgBrandingProfile`, resolved,\n * collected in the branding editor, https-validated and persisted — and read\n * at ZERO render sites. An agency admin on the tier that costs the most\n * filled it in, the form saved, the value round-tripped, and it appeared in\n * no email ever. This is the consumer that makes it a live capability\n * rather than a stored string.\n *\n * Absent or blank emits NOTHING — not an empty row, not a spacer. An email\n * with a gap where a logo should be reads as broken; one without a logo\n * reads as plain, which is the correct appearance for an org that has not\n * set one.\n *\n * Ignored when {@link EmailRenderOptions.chrome} carries a header: that\n * header is the logo row, and a caller that has one passes the org's logo\n * through it, so a message never opens with two.\n */\n brandLogoUrl?: string\n /**\n * The sender's header and footer, drawn around the body (AGL-3322). See\n * {@link EmailChrome}. Absent draws neither.\n */\n chrome?: EmailChrome\n}\n\n/**\n * The row above the body that says who sent the mail.\n *\n * `logoAlt` is required because it does two jobs. It is the logo's `alt`,\n * which is what most inboxes show, since they block images by default. And\n * it is the header itself when there is no logo to draw — none was given, or\n * the one given cannot be fetched from an inbox — so the sender is named in\n * bold text rather than by a broken-image box.\n */\nexport interface EmailChromeHeader {\n /** The logo. Resolved and scheme-checked like every other image here. */\n logoUrl?: string\n /** The sender's name: the logo's alt text, or the header when no logo draws. */\n logoAlt: string\n /** Where the logo or name links. A refused scheme drops the link, not the header. */\n href?: string\n}\n\n/**\n * The rows under the body, in the order they draw. Each is optional, and a\n * footer with none of them draws nothing.\n */\nexport interface EmailChromeFooter {\n /** Why the recipient is getting this mail. */\n reason?: string\n /** Where to get help. Drawn as a link; dropped whole when the href is refused. */\n support?: { label: string; href: string }\n /** Who sent it, and from where: a copyright and postal line. */\n legal?: string\n}\n\n/**\n * A sender's header and footer, drawn by the renderer rather than placed by a\n * designer (AGL-3322).\n *\n * For mail whose body carries no header or footer of its own: a system\n * email's built-in copy, or a design sent under a brand whose own blocks it\n * may not carry. The caller decides WHICH brand; this only draws.\n *\n * Every string goes through the same merge substitution as the body, so\n * `{{org.name}}` in a footer line reads as the organization it names, and is\n * escaped like body text. Every URL goes through the same scheme checks as a\n * body link or image. The footer lines are appended to the plain-text part\n * too, the support link as its bare address, because a reader of that part is\n * owed the same reason and the same way to get help.\n */\nexport interface EmailChrome {\n header?: EmailChromeHeader\n footer?: EmailChromeFooter\n}\n\nexport interface RenderedEmail {\n html: string\n text: string\n}\n\nconst FONT = 'Helvetica, Arial, sans-serif'\n\n/**\n * The colours the chrome is drawn in (AGL-3322), named once.\n *\n * Literal because this is email HTML: mail clients strip `<style>` and read no\n * CSS variables, so no theme token reaches the wire. They are the values\n * already in the mail, not new ones — the ink and card of a default body\n * block, and the band and caption grey of the email footer block the\n * platform's own mail wears — so the chrome and the body it frames agree.\n */\nconst CHROME_PALETTE = {\n /** The sender's name in the header: body text's ink. */\n ink: '#1a1a1a',\n /** The header band: a default section's card. */\n card: '#ffffff',\n /** The footer band. */\n band: '#F8F9FA',\n /** Footer lines and the support link. */\n caption: '#757575',\n} as const\n\nexport function escapeEmailHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n}\n\n/** Substitutes {{token}} occurrences; unknown tokens stay visible. */\nexport function substituteMergeTokens(\n value: string,\n merge: Record<string, string> | undefined,\n): string {\n return value.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (match, token: string) => {\n const replacement = merge?.[token]\n return replacement !== undefined ? replacement : match\n })\n}\n\nconst TEXT_STYLES: Record<string, string> = {\n heading: `font-size:28px;font-weight:700;line-height:1.25`,\n subheading: `font-size:20px;font-weight:600;line-height:1.3`,\n body: `font-size:15px;font-weight:400;line-height:1.55`,\n caption: `font-size:12px;font-weight:400;line-height:1.4`,\n}\n\n/** One full-width table row wrapping arbitrary cell HTML. */\nconst row = (cellHtml: string, cellStyle = ''): string =>\n `<tr><td style=\"${cellStyle}\">${cellHtml}</td></tr>`\n\n/**\n * The id the besigner roots every stored node map at — `CANVAS_ROOT_ELEMENT_ID`\n * in `@aglyn/aglyn`. `renderEmailHtml` still defaults `rootId` to `'root'` for\n * ad-hoc callers, but anything rendering a real besigner document MUST pass\n * this: rendering a besigner map as `'root'` finds no root and emits nothing\n * (AGL-765). Kept here so server code need not import the heavy `@aglyn/aglyn`\n * barrel; a drift guard in the console specs asserts the two stay equal.\n */\nexport const EMAIL_NODE_ROOT_ID = '_@_'\n\nexport function renderEmailHtml(options: EmailRenderOptions): RenderedEmail {\n const {\n nodes,\n rootId = 'root',\n subject = '',\n preheader = '',\n merge,\n products,\n sanitize,\n mediaOrigin,\n mediaHostId,\n brandLogoUrl,\n chrome,\n } = options\n\n const textParts: string[] = []\n const sub = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), merge)\n\n /**\n * The merge map as it applies inside a URL: everything except the\n * `contact.` namespace.\n *\n * That namespace is the recipient — `contact.email`, `contact.name`,\n * `contact.firstName` are what campaign-send puts in the map. Substituting\n * them into an `href` or a `src` is how a template like\n * `https://example.test/?e={{contact.email}}` turns every send into the\n * recipient's own address on somebody's query string, and putting personal\n * data in a URL is a line this codebase does not cross. The refusal has to\n * live here because it is the SUBSTITUTION that creates the value: no\n * scheme check and no sanitizer can see the address, since the template\n * the author wrote and the editor reviewed contains only the token.\n *\n * Nothing else narrows: `{{unsubscribeUrl}}` and `{{site.url}}` ARE URLs\n * and are the reason a token appears in this position at all.\n *\n * A refused token is left standing rather than blanked, which is this\n * module's existing convention for a token it will not fill — a test send\n * shows `?e={{contact.email}}` in the link, which names the problem to the\n * author instead of silently shipping a subtly different URL.\n */\n const urlMerge = merge\n ? Object.fromEntries(\n Object.entries(merge).filter(\n ([token]) => !token.startsWith('contact.'),\n ),\n )\n : undefined\n\n /** {@link sub} for a value that lands in a URL — see {@link urlMerge}. */\n const subUrl = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), urlMerge)\n\n /**\n * A link target safe to emit, or undefined.\n *\n * Escaping the value into the attribute stops a breakout and says nothing\n * about the scheme, so `escapeEmailHtml` on its own passes a stored\n * `javascript:` or `data:` href through verbatim. The check runs on the\n * SUBSTITUTED string, because a merge value is part of the final URL and a\n * template that reads clean can resolve to one that does not.\n */\n const linkHref = (value: unknown): string | undefined => {\n const href = subUrl(value)\n if (!href) return undefined\n return hasSafeLinkScheme(href) ? href : undefined\n }\n\n const origin = mediaOrigin?.replace(/\\/+$/, '')\n\n /**\n * A stored image value turned into something an inbox can actually fetch,\n * or undefined (AGL-1224).\n *\n * `resolveEmailMediaSrc` handles the three stored generations — a `media:`\n * reference, the AGL-175 relative CDN path, and a plain absolute URL an\n * author typed — but the first two come back site-relative, which is the\n * whole bug: only a browser has a page to resolve them against.\n *\n * With no origin to absolutize against, the image is DROPPED rather than\n * emitted relative. Both are a missing picture; a dropped one leaves a gap,\n * while `src=\"/api/media/cdn/…\"` renders as a broken-image box, which reads\n * to a recipient as a broken email rather than a plain one.\n *\n * ⚠️ This comment used to claim \"neither send path relies on this — both\n * supply an origin\", and treated reaching the drop as hypothetical. It was\n * wrong, and it was wrong in the direction that hides the bug: campaign-send\n * passed no `mediaOrigin`/`mediaHostId` at all, so every author-picked image\n * was silently dropped from every marketing campaign until AGL-1394. The\n * quiet failure IS still the right one for a caller that genuinely has no\n * origin — but \"no caller reaches this\" is a claim about the whole repo, and\n * it decays the moment someone adds a third send path. Verify it before\n * relying on it; do not restore a count here.\n *\n * A protocol-relative `//host/x.png` is passed through untouched: it is\n * already absolute enough to name a host, and prefixing an origin would\n * corrupt it.\n *\n * The scheme is checked on the way out. `resolveEmailMediaSrc` recognizes\n * the two stored reference forms and passes anything else through as an\n * author-typed URL, so it is this check and nothing else that stands\n * between what an author types and the `src` attribute. A refusal drops\n * the image on the same reasoning as everything else here that cannot be\n * made fetchable: a gap reads as a plain email, and a `src` we would not\n * stand behind is not made safer by shipping it.\n */\n const imageSrc = (value: unknown): string | undefined => {\n const resolved = resolveEmailMediaSrc(subUrl(value), mediaHostId)\n if (!resolved || !hasSafeMediaScheme(resolved)) return undefined\n if (!resolved.startsWith('/') || resolved.startsWith('//')) return resolved\n return origin ? `${origin}${resolved}` : undefined\n }\n\n const renderChildren = (ids: string[] | undefined): string =>\n (ids ?? [])\n .map((id) => renderNode(id))\n .filter(Boolean)\n .join('')\n\n const renderNode = (id: string): string => {\n const node = nodes[id]\n if (!node) return ''\n const props = node.props ?? {}\n switch (node.componentId) {\n case 'emailSection': {\n const background = props.backgroundColor || '#ffffff'\n const padding = Number.isFinite(Number(props.padding))\n ? Number(props.padding)\n : 24\n const align = props.align || 'left'\n // A ROW, like every other block, with the band's table inside its\n // cell. Whatever encloses a section is a table — the 600px column, or\n // another section's inner table — and a `<table>` placed straight into\n // a table's rows is not a child an HTML parser keeps there: it closes\n // the enclosing table and lands after it. Emitted bare, a section would\n // draw outside the column, as wide as the viewport rather than the\n // 600px the editor shows, and take every row after it out too.\n return row(\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `style=\"background-color:${background};\">` +\n row(\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">` +\n renderChildren(node.nodes) +\n `</table>`,\n `padding:${padding}px;text-align:${align};`,\n ) +\n `</table>`,\n )\n }\n case 'emailText': {\n const text = sub(props.children)\n // A block whose copy is only a token the send left empty (a\n // notification with no detail line, AGL-3367) draws nothing, rather\n // than an empty row and a blank paragraph in the text part.\n if (!text.trim()) return ''\n textParts.push(text)\n const style = TEXT_STYLES[props.variant as string] ?? TEXT_STYLES['body']\n const color = props.color || '#1a1a1a'\n const align = props.align || 'left'\n return row(\n `<div style=\"font-family:${FONT};${style};color:${color};text-align:${align};\">` +\n linkifyEscapedText(escapeEmailHtml(text)).replace(/\\n/g, '<br />') +\n `</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailRichtext':\n case 'emailHtml': {\n const html = sanitize(sub(props.html))\n if (!html.trim()) return ''\n textParts.push(html.replace(/<[^>]+>/g, ' ').trim())\n return row(\n `<div style=\"font-family:${FONT};font-size:15px;line-height:1.55;\">${html}</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailImage': {\n const src = imageSrc(props.src)\n if (!src) return ''\n const width = Number(props.width) > 0 ? Number(props.width) : 600\n const alt = escapeEmailHtml(String(props.alt ?? ''))\n const align = props.align || 'center'\n const img =\n `<img src=\"${escapeEmailHtml(src)}\" alt=\"${alt}\" width=\"${Math.min(width, 600)}\" ` +\n `style=\"display:inline-block;max-width:100%;height:auto;border:0;\" />`\n // A refused href drops the WRAPPER, not the picture: the image is the\n // content and the link is decoration on it.\n const href = linkHref(props.href)\n return row(\n href\n ? `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\">${img}</a>`\n : img,\n `padding:8px 0;text-align:${align};`,\n )\n }\n case 'emailButton': {\n const label = sub(props.children ?? 'Call to action')\n // A refused href falls back to the placeholder the empty case already\n // uses, so the button keeps its place in the layout and goes nowhere.\n // Dropping the row instead would take the author's copy with it, and\n // an inert button reads as an unfinished email rather than a broken\n // one. The plain-text alternative gets the same value the anchor\n // does — a text/plain body is inert, but it is still delivered mail\n // and a refused URL does not belong in it either.\n const href = linkHref(props.href ?? '#') ?? '#'\n const background = props.backgroundColor || '#1a73e8'\n const color = props.color || '#ffffff'\n const align = props.align || 'center'\n textParts.push(`${label}: ${href}`)\n // Bulletproof-ish button: padded anchor, table-aligned.\n return row(\n `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\" ` +\n `style=\"display:inline-block;padding:12px 28px;border-radius:6px;` +\n `background-color:${background};color:${color};font-family:${FONT};` +\n `font-size:15px;font-weight:600;text-decoration:none;\">` +\n escapeEmailHtml(label) +\n `</a>`,\n `padding:12px 0;text-align:${align};`,\n )\n }\n case 'emailDivider': {\n const color = props.color || '#e0e0e0'\n return row(\n `<div style=\"border-top:1px solid ${color};font-size:0;line-height:0;\"> </div>`,\n 'padding:12px 0;',\n )\n }\n case 'emailSpacer': {\n const height = Number(props.height) > 0 ? Number(props.height) : 24\n return row(\n `<div style=\"height:${height}px;font-size:0;line-height:0;\"> </div>`,\n )\n }\n case 'emailProduct': {\n const product = props.productId\n ? products?.[String(props.productId)]\n : undefined\n if (!product) return ''\n const label = sub(props.buttonLabel ?? 'Shop now')\n // Catalog data rather than author markup, and the caller absolutizes\n // it before it arrives — but \"it came from a trusted table\" is a\n // claim about every writer of that table, so the same rule applies\n // here as to an author-typed href. Refused means no button and no\n // line in the text part; the product card itself still renders.\n const productUrl =\n product.url && hasSafeLinkScheme(product.url)\n ? product.url\n : undefined\n textParts.push(\n `${product.name}${product.priceLabel ? ` — ${product.priceLabel}` : ''}` +\n (productUrl ? `: ${productUrl}` : ''),\n )\n // Same treatment as emailImage: a catalog image can be a picked\n // asset, so it carries the same reference/relative forms (AGL-1224).\n const productImage = imageSrc(product.imageUrl)\n const image = productImage\n ? `<img src=\"${escapeEmailHtml(productImage)}\" alt=\"${escapeEmailHtml(product.name)}\" width=\"280\" style=\"max-width:100%;height:auto;border:0;border-radius:6px;\" /><br />`\n : ''\n const button = productUrl\n ? `<a href=\"${escapeEmailHtml(productUrl)}\" target=\"_blank\" style=\"display:inline-block;margin-top:8px;padding:10px 24px;border-radius:6px;background-color:#1a73e8;color:#ffffff;font-family:${FONT};font-size:14px;font-weight:600;text-decoration:none;\">${escapeEmailHtml(label)}</a>`\n : ''\n return row(\n `<div style=\"border:1px solid #e0e0e0;border-radius:8px;padding:16px;text-align:center;font-family:${FONT};\">` +\n image +\n `<div style=\"font-size:16px;font-weight:600;margin-top:8px;\">${escapeEmailHtml(product.name)}</div>` +\n (product.priceLabel\n ? `<div style=\"font-size:14px;color:#555555;margin-top:2px;\">${escapeEmailHtml(product.priceLabel)}</div>`\n : '') +\n button +\n `</div>`,\n 'padding:8px 0;',\n )\n }\n default: {\n // Unknown/web components: render their children so mixed documents\n // degrade gracefully instead of dropping content.\n return renderChildren(node.nodes)\n }\n }\n }\n\n const body = renderNode(rootId) || renderChildren(nodes[rootId]?.nodes)\n const preheaderHtml = preheader\n ? `<div style=\"display:none;max-height:0;overflow:hidden;mso-hide:all;\">` +\n escapeEmailHtml(sub(preheader)) +\n `</div>`\n : ''\n\n // INSIDE the 600px table, above the designed body, so it inherits the\n // column's width on a phone instead of being centred against the viewport.\n // `alt` is the product name where one is known, because a logo blocked by\n // the client (which is the default in most inboxes) must still say who sent\n // the mail. Height is capped rather than set, so a wordmark and a square\n // mark both land at a sane size without the sender supplying dimensions.\n //\n // The src goes through `imageSrc` like every other image in the message\n // (AGL-2230), and NOT into the tag raw. `emailLogoUrl` is collected by\n // `MediaUrlField`, whose \"Browse\" button writes the picked asset's\n // site-relative `cdnPath` — and the org DAM's other generation stores a\n // `media:{scope}/{mediaId}` reference. Neither is a URL an inbox can\n // resolve: there is no page to resolve it against, so a raw pass-through\n // put `src=\"media:h1/med9\"` at the top of every transactional email a\n // white-label org sent. `imageSrc` absolutizes both forms against\n // `mediaOrigin`, passes a real https URL through untouched, and returns\n // undefined when it cannot produce something fetchable — in which case the\n // row is dropped, on the same reasoning as AGL-1224: a gap reads as a plain\n // email, a broken-image box reads as a broken one.\n const brandLogo = imageSrc(String(brandLogoUrl ?? '').trim()) ?? ''\n // The product name from the merge map, because most inboxes block images by\n // default — a logo with no `alt` is a blank box where the sender's identity\n // should be. Falls back to nothing rather than to a literal, which would\n // reintroduce the hard-coded brand this whole change removes.\n const brandLogoAlt = merge?.['brand.productName'] ?? ''\n const brandLogoHtml = brandLogo\n ? row(\n `<img src=\"${escapeEmailHtml(brandLogo)}\" alt=\"${escapeEmailHtml(brandLogoAlt)}\" ` +\n `style=\"max-height:48px;max-width:200px;display:block;margin:0 auto;border:0;\" />`,\n 'padding:24px 24px 0;text-align:center;',\n )\n : ''\n\n /**\n * The chrome header row, or '' when it has nothing to draw.\n *\n * Same placement and the same `imageSrc` as the logo row above, which it\n * replaces. Height is capped at 40px rather than set, so a wordmark and a\n * square mark both land at a sane size; the `height` attribute is for the\n * desktop clients that ignore CSS `max-height` and would otherwise draw the\n * file at its own size. A logo that cannot be fetched from an inbox falls\n * back to the name in bold, never to an `<img>` that draws a broken box.\n * White, so the header and a default body read as one card.\n */\n const renderChromeHeader = (header: EmailChromeHeader): string => {\n const logo = imageSrc(String(header.logoUrl ?? '').trim())\n const name = sub(header.logoAlt).trim()\n const href = linkHref(header.href)\n const link = (inner: string, style: string): string =>\n href\n ? `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\" style=\"${style}\">${inner}</a>`\n : inner\n let mark: string\n if (logo) {\n mark = link(\n `<img src=\"${escapeEmailHtml(logo)}\" alt=\"${escapeEmailHtml(name)}\" height=\"40\" ` +\n `style=\"display:block;margin:0 auto;border:0;height:auto;width:auto;max-height:40px;max-width:200px;\" />`,\n 'text-decoration:none;',\n )\n } else if (name) {\n mark =\n `<div style=\"font-family:${FONT};font-size:20px;font-weight:700;line-height:1.3;color:${CHROME_PALETTE.ink};text-align:center;\">` +\n link(escapeEmailHtml(name), `color:${CHROME_PALETTE.ink};text-decoration:none;`) +\n `</div>`\n } else {\n return ''\n }\n return row(\n mark,\n `padding:24px 24px 0;text-align:center;background-color:${CHROME_PALETTE.card};`,\n )\n }\n\n const headerHtml = chrome?.header\n ? renderChromeHeader(chrome.header)\n : brandLogoHtml\n\n /**\n * The chrome footer: rows under the body in the look of the email footer\n * block the platform's own mail wears (a `#F8F9FA` section, padded 24,\n * centered captions), and the same lines appended to the plain-text part.\n *\n * Rendered AFTER the body on purpose: the body's blocks push their text as\n * they draw, and the footer's lines belong after all of it. A line that\n * resolves to nothing is left out rather than drawn as an empty row.\n */\n const renderChromeFooter = (footer: EmailChromeFooter): string => {\n const htmlLines: string[] = []\n const textLines: string[] = []\n const reason = sub(footer.reason).trim()\n if (reason) {\n htmlLines.push(escapeEmailHtml(reason))\n textLines.push(reason)\n }\n const supportLabel = sub(footer.support?.label).trim()\n const supportHref = linkHref(footer.support?.href)\n if (supportLabel && supportHref) {\n htmlLines.push(\n `<a href=\"${escapeEmailHtml(supportHref)}\" target=\"_blank\" ` +\n `style=\"color:${CHROME_PALETTE.caption};text-decoration:underline;\">${escapeEmailHtml(supportLabel)}</a>`,\n )\n // A plain-text reader gets the address itself, bare: a mailto's\n // mailbox, or the URL. \"Get help: https://…\" names where it goes; a\n // label that already IS the mailbox is not repeated.\n const bare = /^mailto:/i.test(supportHref)\n ? supportHref.slice('mailto:'.length).split('?')[0]\n : supportHref\n textLines.push(supportLabel === bare ? bare : `${supportLabel}: ${bare}`)\n }\n const legal = sub(footer.legal).trim()\n if (legal) {\n htmlLines.push(escapeEmailHtml(legal))\n textLines.push(legal)\n }\n if (!htmlLines.length) return ''\n textParts.push(textLines.join('\\n'))\n return row(\n htmlLines\n .map(\n (line, index) =>\n `<div style=\"font-family:${FONT};font-size:12px;font-weight:400;line-height:1.4;color:${CHROME_PALETTE.caption};` +\n `${index ? 'margin-top:8px;' : ''}\">${line.replace(/\\n/g, '<br />')}</div>`,\n )\n .join(''),\n `padding:24px;text-align:center;background-color:${CHROME_PALETTE.band};`,\n )\n }\n\n const footerHtml = chrome?.footer ? renderChromeFooter(chrome.footer) : ''\n\n const html =\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\" />` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />` +\n `<title>${escapeEmailHtml(sub(subject))}</title></head>` +\n `<body style=\"margin:0;padding:0;background-color:#f4f4f4;\">` +\n preheaderHtml +\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background-color:#f4f4f4;\">` +\n row(\n `<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" align=\"center\" style=\"max-width:600px;width:100%;margin:0 auto;\">` +\n headerHtml +\n body +\n footerHtml +\n `</table>`,\n 'padding:24px 8px;',\n ) +\n `</table></body></html>`\n\n return { html, text: textParts.join('\\n\\n') }\n}\n"],"names":["hasSafeLinkScheme","hasSafeMediaScheme","linkifyEscapedText","resolveEmailMediaSrc","FONT","CHROME_PALETTE","ink","card","band","caption","escapeEmailHtml","value","replace","substituteMergeTokens","merge","match","token","replacement","undefined","TEXT_STYLES","heading","subheading","body","row","cellHtml","cellStyle","EMAIL_NODE_ROOT_ID","renderEmailHtml","options","imageSrc","nodes","rootId","subject","preheader","products","sanitize","mediaOrigin","mediaHostId","brandLogoUrl","chrome","textParts","sub","String","urlMerge","Object","fromEntries","entries","filter","startsWith","subUrl","linkHref","href","origin","resolved","renderChildren","ids","map","id","renderNode","Boolean","join","node","props","componentId","background","backgroundColor","padding","Number","isFinite","align","text","children","trim","push","style","variant","color","html","src","width","alt","img","Math","min","label","height","product","productId","buttonLabel","productUrl","url","name","priceLabel","productImage","imageUrl","image","button","preheaderHtml","brandLogo","brandLogoAlt","brandLogoHtml","renderChromeHeader","header","logo","logoUrl","logoAlt","link","inner","mark","headerHtml","renderChromeFooter","footer","htmlLines","textLines","reason","supportLabel","support","supportHref","bare","test","slice","length","split","legal","line","index","footerHtml"],"mappings":"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"}
|