@aglyn/shared-util-email 1.0.0-beta.199 → 1.0.0-beta.201
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/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 +220 -0
- package/src/lib/outbound-phishing-screen.js +719 -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
|
@@ -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":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-delivery-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT HAPPENED TO A MESSAGE, IN OUR OWN VOCABULARY.\n *\n * ## The seam\n *\n * A staff answer to \"did they get the invite, and did they open it?\" must not\n * be a question about 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,60 @@
|
|
|
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
|
+
import { type PhishingScreenSignal } from './outbound-phishing-screen';
|
|
18
|
+
export interface HostedPageScreenInput {
|
|
19
|
+
/**
|
|
20
|
+
* The page as it will be served — the composed tree, or any object that
|
|
21
|
+
* holds nodes (`{ componentId, props }`) at any depth. Walked, never
|
|
22
|
+
* trusted to have one shape: a screen, a layout's chrome, a grafted
|
|
23
|
+
* component and a form all arrive together.
|
|
24
|
+
*/
|
|
25
|
+
nodes: unknown;
|
|
26
|
+
/** The workspace's own names, as `screenOutboundEmail` reads them. */
|
|
27
|
+
ownNames?: readonly (string | null | undefined)[];
|
|
28
|
+
/** The workspace's own hosts — a link to one is never "elsewhere". */
|
|
29
|
+
ownDomains?: readonly (string | null | undefined)[];
|
|
30
|
+
}
|
|
31
|
+
export interface HostedPageScreenVerdict {
|
|
32
|
+
signals: PhishingScreenSignal[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The credential an input asks for, read from the two attributes that decide
|
|
36
|
+
* it in the browser — its `type` and its `autocomplete` token — or null.
|
|
37
|
+
* For code that builds inputs rather than storing them as page props: the
|
|
38
|
+
* marketplace verifier reads a plugin bundle's `{ type: 'password' }` and
|
|
39
|
+
* `setAttribute('autocomplete', 'cc-number')` with this (AGL-3362).
|
|
40
|
+
*/
|
|
41
|
+
export declare function credentialForInputAttribute(attribute: string, value: string): 'password' | 'card' | 'otp' | null;
|
|
42
|
+
/** The credential a piece of raw HTML asks for in an input of its own, or null. */
|
|
43
|
+
export declare function credentialInHtml(html: string): 'password' | 'card' | 'otp' | null;
|
|
44
|
+
/**
|
|
45
|
+
* Does a submitted field's NAME ask for a credential — a password, a card
|
|
46
|
+
* number or security code, a one-time code? The same wording the page
|
|
47
|
+
* screen holds a field for, read over a name as a form posts it
|
|
48
|
+
* (`card_number`, `cardNumber`, `one-time-code`).
|
|
49
|
+
*
|
|
50
|
+
* For the one path that does not pass a publish: a public form endpoint
|
|
51
|
+
* takes whatever field names a request carries, so a page the screen never
|
|
52
|
+
* saw (third-party code, a hand-built request) could still post a password
|
|
53
|
+
* to it. The endpoint drops such a field rather than store it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isCredentialFieldName(name: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Screen one page as it will be served. The caller applies the tiers with
|
|
58
|
+
* `signalsThatHold` and decides what a hold does.
|
|
59
|
+
*/
|
|
60
|
+
export declare function screenHostedPage(input: HostedPageScreenInput): HostedPageScreenVerdict;
|
|
@@ -0,0 +1,314 @@
|
|
|
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
|
+
* THE HOSTED PAGE SCREEN (AGL-3362).
|
|
18
|
+
*
|
|
19
|
+
* The outbound phishing screen, read over a PUBLISHED PAGE instead of an
|
|
20
|
+
* email: the same brand list, the same lookalike logic, the same tiers
|
|
21
|
+
* (`signalsThatHold`). A fraud actor who could not mail a lure could still
|
|
22
|
+
* host one, and the incident's sibling site published a page at the
|
|
23
|
+
* document-share path `/reviewfile`.
|
|
24
|
+
*
|
|
25
|
+
* Three shapes, one per requirement:
|
|
26
|
+
*
|
|
27
|
+
* 1. **A lookalike link or embed** (`lookalike-link`, STRONG) — any `href`,
|
|
28
|
+
* `src`, form action or URL in the page's authored content whose host
|
|
29
|
+
* wears a brand without being the brand's domain.
|
|
30
|
+
* 2. **A credential field** (`credential-field`, STRONG) — a field the
|
|
31
|
+
* merchant AUTHORED that asks for a password, a card number or a
|
|
32
|
+
* one-time code: its type, its autocomplete token, or its label,
|
|
33
|
+
* placeholder or name. The platform's own sign-in, account and checkout
|
|
34
|
+
* elements render their inputs in code, not in stored props, so they are
|
|
35
|
+
* never read as a field here — which is exactly the sanctioned/unsanctioned
|
|
36
|
+
* line: what the platform collects through its own components is
|
|
37
|
+
* Aglyn's to protect, and what a page's author collects in a form of
|
|
38
|
+
* their own is not something a site may ask a stranger for.
|
|
39
|
+
* 3. **A brand's call to action** (`brand-action-page`, SOFT) — one element
|
|
40
|
+
* whose text names a brand that is not the workspace's AND asks the reader
|
|
41
|
+
* to sign in, verify, confirm or open a document, on a page that links
|
|
42
|
+
* somewhere that is neither the workspace's, nor a brand's own, nor a
|
|
43
|
+
* common social or maps link. The same element, not the same page: a
|
|
44
|
+
* hotel that says "find us on Booking.com" in one place and "sign in" in
|
|
45
|
+
* another is two ordinary sentences.
|
|
46
|
+
*
|
|
47
|
+
* Pure: the caller hands over the composed node tree and the workspace's own
|
|
48
|
+
* names and hosts, and decides what a hold does.
|
|
49
|
+
*=========================================*/ import { isWorkspaceOwnBrand, linkHostsIn, lookalikeBrandForHost, PHISHING_LURE_PATTERNS, PHISHING_SCREEN_BRANDS, registrableDomain, isAnyOfficialBrandDomain, isCommonLinkDomain, squashScreenText } from "./outbound-phishing-screen.js";
|
|
50
|
+
/** The most a page's text is read to, so a huge page costs a bounded scan. */ const MAX_PAGE_TEXT = 2000000;
|
|
51
|
+
/** The most nodes the walk visits. */ const MAX_NODES = 20000;
|
|
52
|
+
/** Props that describe a field the author defined, rather than content. */ const FIELD_PROP_KEYS = [
|
|
53
|
+
'fieldType',
|
|
54
|
+
'fieldName',
|
|
55
|
+
'inputType',
|
|
56
|
+
'autoComplete',
|
|
57
|
+
'autocomplete'
|
|
58
|
+
];
|
|
59
|
+
/** Autocomplete tokens (WHATWG) that name a credential. */ const CREDENTIAL_AUTOCOMPLETE = {
|
|
60
|
+
'current-password': 'password',
|
|
61
|
+
'new-password': 'password',
|
|
62
|
+
'cc-number': 'card',
|
|
63
|
+
'cc-csc': 'card',
|
|
64
|
+
'cc-exp': 'card',
|
|
65
|
+
'one-time-code': 'otp'
|
|
66
|
+
};
|
|
67
|
+
/** Field wording that asks for a credential, by kind. */ const CREDENTIAL_LABELS = [
|
|
68
|
+
{
|
|
69
|
+
field: 'password',
|
|
70
|
+
pattern: /\bpass\s?(?:word|code|phrase)s?\b|\bpasswd\b|\bpin\s*(?:code|number)?\s*$/i
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
field: 'card',
|
|
74
|
+
pattern: /\b(?:credit|debit)\s*card\b|\bcard\s*(?:number|no\.?|#)|\bcardnumber\b|\bcvv2?\b|\bcvc\b|\bcard\s*security\s*code\b|\bcard\s*expir/i
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
field: 'otp',
|
|
78
|
+
pattern: /\bone[-\s]?time\s*(?:pass\s?code|password|code|pin)\b|\botp\b|\bverification\s*code\b|\b2fa\b|\btwo[-\s]?factor\b|\bauthenticat(?:ion|or)\s*code\b|\bsms\s*code\b/i
|
|
79
|
+
}
|
|
80
|
+
];
|
|
81
|
+
/** Raw HTML an author placed that holds a credential input. */ const HTML_CREDENTIAL_INPUTS = [
|
|
82
|
+
{
|
|
83
|
+
field: 'password',
|
|
84
|
+
pattern: /<input\b[^>]*\btype\s*=\s*["']?password/i
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
field: 'password',
|
|
88
|
+
pattern: /<input\b[^>]*\b(?:name|autocomplete)\s*=\s*["']?(?:pass(?:word|wd)?|current-password|new-password)\b/i
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
field: 'card',
|
|
92
|
+
pattern: /<input\b[^>]*\b(?:name|autocomplete)\s*=\s*["']?(?:card_?number|cc-number|cc-csc|cvv|cvc)\b/i
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
field: 'otp',
|
|
96
|
+
pattern: /<input\b[^>]*\b(?:name|autocomplete)\s*=\s*["']?(?:otp|one-time-code|verification_?code)\b/i
|
|
97
|
+
}
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* What an element asks its reader to DO with an account or a document —
|
|
101
|
+
* counted only beside a brand in the SAME element. The email lure phrasing
|
|
102
|
+
* counts too.
|
|
103
|
+
*/ const PAGE_ACTION_PATTERNS = [
|
|
104
|
+
/\b(?:sign|log)[\s-]?in\b/i,
|
|
105
|
+
/\bverify\b/i,
|
|
106
|
+
/\bconfirm\s+(?:your|the)\b/i,
|
|
107
|
+
/\bunlock\b/i,
|
|
108
|
+
/\benter\s+your\b/i,
|
|
109
|
+
/\b(?:view|review|open|download)\s+(?:the\s+|your\s+)?(?:shared\s+)?(?:file|document|invoice|statement)s?\b/i
|
|
110
|
+
];
|
|
111
|
+
/** Every node in the tree, breadth-first in document order, bounded. */ function walkNodes(root) {
|
|
112
|
+
const found = [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const stack = [
|
|
115
|
+
root
|
|
116
|
+
];
|
|
117
|
+
for(let head = 0; head < stack.length && found.length < MAX_NODES; head += 1){
|
|
118
|
+
const value = stack[head];
|
|
119
|
+
if (!value || typeof value !== 'object' || seen.has(value)) continue;
|
|
120
|
+
seen.add(value);
|
|
121
|
+
const record = value;
|
|
122
|
+
if (typeof record['componentId'] === 'string') {
|
|
123
|
+
const props = record['props'];
|
|
124
|
+
found.push({
|
|
125
|
+
componentId: record['componentId'],
|
|
126
|
+
props: props && typeof props === 'object' ? props : {}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
for (const child of Array.isArray(value) ? value : Object.values(record)){
|
|
130
|
+
if (child && typeof child === 'object') stack.push(child);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
/** Every string a node's props hold, one level of arrays and objects deep. */ function propStrings(props, skip = new Set()) {
|
|
136
|
+
const out = [];
|
|
137
|
+
const visit = (value, depth)=>{
|
|
138
|
+
if (typeof value === 'string') out.push(value);
|
|
139
|
+
else if (depth < 3 && value && typeof value === 'object') {
|
|
140
|
+
for (const entry of Array.isArray(value) ? value : Object.values(value)){
|
|
141
|
+
visit(entry, depth + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
for (const [key, value] of Object.entries(props)){
|
|
146
|
+
if (key === 'sx' || skip.has(key)) continue;
|
|
147
|
+
visit(value, 0);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** Does this node describe a field its author defined? */ function isAuthoredField(node) {
|
|
152
|
+
if (/field|input/i.test(node.componentId)) return true;
|
|
153
|
+
return FIELD_PROP_KEYS.some((key)=>node.props[key] !== undefined);
|
|
154
|
+
}
|
|
155
|
+
/** The credential a field asks for, or null. */ function credentialOf(node) {
|
|
156
|
+
const text = (key)=>typeof node.props[key] === 'string' ? node.props[key] : '';
|
|
157
|
+
const type = [
|
|
158
|
+
text('fieldType'),
|
|
159
|
+
text('type'),
|
|
160
|
+
text('inputType')
|
|
161
|
+
].join(' ').toLowerCase();
|
|
162
|
+
const label = text('label') || text('placeholder') || text('fieldName') || text('name');
|
|
163
|
+
if (/\bpassword\b/.test(type)) return {
|
|
164
|
+
field: 'password',
|
|
165
|
+
label: label || 'password'
|
|
166
|
+
};
|
|
167
|
+
const autocomplete = (text('autoComplete') || text('autocomplete')).toLowerCase().trim();
|
|
168
|
+
const byToken = CREDENTIAL_AUTOCOMPLETE[autocomplete];
|
|
169
|
+
if (byToken) return {
|
|
170
|
+
field: byToken,
|
|
171
|
+
label: label || autocomplete
|
|
172
|
+
};
|
|
173
|
+
const wording = [
|
|
174
|
+
text('label'),
|
|
175
|
+
text('placeholder'),
|
|
176
|
+
text('fieldName'),
|
|
177
|
+
text('name'),
|
|
178
|
+
text('helperText')
|
|
179
|
+
].filter(Boolean).join(' ');
|
|
180
|
+
for (const { field, pattern } of CREDENTIAL_LABELS){
|
|
181
|
+
if (pattern.test(wording)) return {
|
|
182
|
+
field,
|
|
183
|
+
label: (label || wording).slice(0, 80)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The credential an input asks for, read from the two attributes that decide
|
|
190
|
+
* it in the browser — its `type` and its `autocomplete` token — or null.
|
|
191
|
+
* For code that builds inputs rather than storing them as page props: the
|
|
192
|
+
* marketplace verifier reads a plugin bundle's `{ type: 'password' }` and
|
|
193
|
+
* `setAttribute('autocomplete', 'cc-number')` with this (AGL-3362).
|
|
194
|
+
*/ export function credentialForInputAttribute(attribute, value) {
|
|
195
|
+
var _CREDENTIAL_AUTOCOMPLETE_token;
|
|
196
|
+
const name = String(attribute != null ? attribute : '').toLowerCase();
|
|
197
|
+
const token = String(value != null ? value : '').trim().toLowerCase();
|
|
198
|
+
if ((name === 'type' || name === 'inputtype') && token === 'password') return 'password';
|
|
199
|
+
if (name === 'autocomplete') return (_CREDENTIAL_AUTOCOMPLETE_token = CREDENTIAL_AUTOCOMPLETE[token]) != null ? _CREDENTIAL_AUTOCOMPLETE_token : null;
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
/** The credential a piece of raw HTML asks for in an input of its own, or null. */ export function credentialInHtml(html) {
|
|
203
|
+
var _ref;
|
|
204
|
+
var _HTML_CREDENTIAL_INPUTS_find;
|
|
205
|
+
const text = String(html != null ? html : '');
|
|
206
|
+
if (!text.includes('<')) return null;
|
|
207
|
+
return (_ref = (_HTML_CREDENTIAL_INPUTS_find = HTML_CREDENTIAL_INPUTS.find(({ pattern })=>pattern.test(text))) == null ? void 0 : _HTML_CREDENTIAL_INPUTS_find.field) != null ? _ref : null;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Does a submitted field's NAME ask for a credential — a password, a card
|
|
211
|
+
* number or security code, a one-time code? The same wording the page
|
|
212
|
+
* screen holds a field for, read over a name as a form posts it
|
|
213
|
+
* (`card_number`, `cardNumber`, `one-time-code`).
|
|
214
|
+
*
|
|
215
|
+
* For the one path that does not pass a publish: a public form endpoint
|
|
216
|
+
* takes whatever field names a request carries, so a page the screen never
|
|
217
|
+
* saw (third-party code, a hand-built request) could still post a password
|
|
218
|
+
* to it. The endpoint drops such a field rather than store it.
|
|
219
|
+
*/ export function isCredentialFieldName(name) {
|
|
220
|
+
const words = String(name != null ? name : '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_\-.[\]]+/g, ' ').trim();
|
|
221
|
+
if (!words) return false;
|
|
222
|
+
if (CREDENTIAL_AUTOCOMPLETE[words.toLowerCase().replace(/\s+/g, '-')]) return true;
|
|
223
|
+
if (/^(?:pass|pwd|ccnum|cc\s?num|cc\s?number|cvv2?|cvc|csc)$/i.test(words)) return true;
|
|
224
|
+
return CREDENTIAL_LABELS.some(({ pattern })=>pattern.test(words));
|
|
225
|
+
}
|
|
226
|
+
/** URLs a page carries in a form Links-by-shape would miss: `//host/…`. */ function protocolRelativeUrls(strings) {
|
|
227
|
+
return strings.filter((value)=>/^\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+/i.test(value.trim())).map((value)=>`https:${value.trim()}`).join('\n');
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Screen one page as it will be served. The caller applies the tiers with
|
|
231
|
+
* `signalsThatHold` and decides what a hold does.
|
|
232
|
+
*/ export function screenHostedPage(input) {
|
|
233
|
+
var _input_ownNames, _input_ownDomains;
|
|
234
|
+
const signals = [];
|
|
235
|
+
const nodes = walkNodes(input.nodes);
|
|
236
|
+
const ownNames = squashScreenText(((_input_ownNames = input.ownNames) != null ? _input_ownNames : []).filter(Boolean).join(' '));
|
|
237
|
+
const ownHosts = ((_input_ownDomains = input.ownDomains) != null ? _input_ownDomains : []).map((domain)=>String(domain != null ? domain : '').trim().toLowerCase().replace(/\.+$/, '')).filter((domain)=>domain.includes('.'));
|
|
238
|
+
const ownRegistrables = new Set(ownHosts.map(registrableDomain));
|
|
239
|
+
const isOwnHost = (host)=>ownHosts.some((own)=>host === own || host.endsWith(`.${own}`));
|
|
240
|
+
const nodeStrings = nodes.map((node)=>propStrings(node.props));
|
|
241
|
+
let pageText = nodeStrings.map((strings)=>strings.join('\n')).join('\n');
|
|
242
|
+
pageText = `${pageText}\n${protocolRelativeUrls(nodeStrings.flat())}`.slice(0, MAX_PAGE_TEXT);
|
|
243
|
+
// 1. A lookalike host anywhere the page links, embeds or posts to.
|
|
244
|
+
const linkHosts = linkHostsIn(pageText);
|
|
245
|
+
for (const host of linkHosts){
|
|
246
|
+
const brand = lookalikeBrandForHost(host);
|
|
247
|
+
if (brand) signals.push({
|
|
248
|
+
code: 'lookalike-link',
|
|
249
|
+
brand: brand.id,
|
|
250
|
+
host
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
// 2. A credential field the page's author defined.
|
|
254
|
+
const credentialSeen = new Set();
|
|
255
|
+
const noteCredential = (field, label)=>{
|
|
256
|
+
if (credentialSeen.has(field)) return;
|
|
257
|
+
credentialSeen.add(field);
|
|
258
|
+
signals.push({
|
|
259
|
+
code: 'credential-field',
|
|
260
|
+
field,
|
|
261
|
+
label: label.slice(0, 80)
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
nodes.forEach((node, index)=>{
|
|
265
|
+
if (isAuthoredField(node)) {
|
|
266
|
+
const credential = credentialOf(node);
|
|
267
|
+
if (credential) noteCredential(credential.field, credential.label);
|
|
268
|
+
}
|
|
269
|
+
for (const html of nodeStrings[index]){
|
|
270
|
+
if (!html.includes('<')) continue;
|
|
271
|
+
for (const { field, pattern } of HTML_CREDENTIAL_INPUTS){
|
|
272
|
+
if (pattern.test(html)) noteCredential(field, 'an input in the page’s own HTML');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
// 3. A brand's call to action, in one element, on a page that links away.
|
|
277
|
+
const foreign = linkHosts.find((host)=>{
|
|
278
|
+
if (isOwnHost(host)) return false;
|
|
279
|
+
const registrable = registrableDomain(host);
|
|
280
|
+
return !isAnyOfficialBrandDomain(registrable) && !isCommonLinkDomain(registrable);
|
|
281
|
+
});
|
|
282
|
+
if (foreign) {
|
|
283
|
+
const actions = [
|
|
284
|
+
...PAGE_ACTION_PATTERNS,
|
|
285
|
+
...PHISHING_LURE_PATTERNS
|
|
286
|
+
];
|
|
287
|
+
const named = new Set();
|
|
288
|
+
for (const strings of nodeStrings){
|
|
289
|
+
const text = strings.join(' ');
|
|
290
|
+
if (!text) continue;
|
|
291
|
+
const action = actions.map((pattern)=>{
|
|
292
|
+
var _text_match;
|
|
293
|
+
return (_text_match = text.match(pattern)) == null ? void 0 : _text_match[0];
|
|
294
|
+
}).find(Boolean);
|
|
295
|
+
if (!action) continue;
|
|
296
|
+
for (const brand of PHISHING_SCREEN_BRANDS){
|
|
297
|
+
if (named.has(brand.id)) continue;
|
|
298
|
+
if (brand.mention.test(text) && !isWorkspaceOwnBrand(brand, ownNames, ownRegistrables)) {
|
|
299
|
+
named.add(brand.id);
|
|
300
|
+
signals.push({
|
|
301
|
+
code: 'brand-action-page',
|
|
302
|
+
brand: brand.id,
|
|
303
|
+
action: action.slice(0, 80)
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
signals
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
//# sourceMappingURL=hosted-page-screen.js.map
|