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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/card-payment-velocity.d.ts +85 -0
  3. package/src/lib/server/card-payment-velocity.js +202 -0
  4. package/src/lib/server/card-payment-velocity.js.map +1 -0
  5. package/src/lib/server/configured-url-fetch.d.ts +42 -0
  6. package/src/lib/server/configured-url-fetch.js +118 -0
  7. package/src/lib/server/configured-url-fetch.js.map +1 -0
  8. package/src/lib/server/hosted-page-review.d.ts +22 -2
  9. package/src/lib/server/hosted-page-review.js +43 -7
  10. package/src/lib/server/hosted-page-review.js.map +1 -1
  11. package/src/lib/server/member-email-aliases.js +18 -7
  12. package/src/lib/server/member-email-aliases.js.map +1 -1
  13. package/src/lib/server/notifications.d.ts +8 -0
  14. package/src/lib/server/notifications.js +92 -17
  15. package/src/lib/server/notifications.js.map +1 -1
  16. package/src/lib/server/org-member-notice.js +20 -5
  17. package/src/lib/server/org-member-notice.js.map +1 -1
  18. package/src/lib/server/organizations.d.ts +10 -1
  19. package/src/lib/server/organizations.js +12 -4
  20. package/src/lib/server/organizations.js.map +1 -1
  21. package/src/lib/server/outbound-send-review.d.ts +4 -2
  22. package/src/lib/server/outbound-send-review.js +1 -1
  23. package/src/lib/server/outbound-send-review.js.map +1 -1
  24. package/src/lib/server/render-system-email.d.ts +176 -0
  25. package/src/lib/server/render-system-email.js +315 -0
  26. package/src/lib/server/render-system-email.js.map +1 -0
  27. package/src/lib/server/staff-alert-email.d.ts +2 -1
  28. package/src/lib/server/staff-alert-email.js +16 -5
  29. package/src/lib/server/staff-alert-email.js.map +1 -1
  30. package/src/tenancy.d.ts +2 -0
  31. package/src/tenancy.js +2 -0
  32. package/src/tenancy.js.map +1 -1
@@ -26,9 +26,11 @@ export { OUTBOUND_REVIEW_YOUNG_DAYS };
26
26
  export declare const HELD_SEND_AT_MS = 253402300799000;
27
27
  /**
28
28
  * Which surface held it: a campaign, an automation step, any other tenant
29
- * message at the send seam (`message`), or a site publish (`page`).
29
+ * message at the send seam (`message`), a site publish (`page`), or a
30
+ * submission to a public catalog a plugin keeps — a marketplace listing, its
31
+ * publisher profile (`listing`, AGL-3365).
30
32
  */
31
- export type HeldOutboundSendKind = 'campaign' | 'action' | 'workflow' | 'orgAutomation' | 'message' | 'page';
33
+ export type HeldOutboundSendKind = 'campaign' | 'action' | 'workflow' | 'orgAutomation' | 'message' | 'page' | 'listing';
32
34
  /** The decision state a held send carries on its review row. */
33
35
  export type HeldOutboundSendState = 'held' | 'released' | 'rejected';
34
36
  /** What the review row records about the message it holds. */
@@ -175,7 +175,7 @@ import { notifyStaff } from "./notifications.js";
175
175
  details: [
176
176
  filing.headline,
177
177
  ...describePhishingScreenSignals(filing.heldSend.signals),
178
- filing.heldSend.kind === 'page' ? 'Dismiss to release this publish; mark it actioned to reject it.' : 'Dismiss to release this send; mark it actioned to reject it.'
178
+ filing.heldSend.kind === 'page' ? 'Dismiss to release this publish; mark it actioned to reject it.' : filing.heldSend.kind === 'listing' ? 'Dismiss to release this submission (the publisher submits it again and it ' + 'goes through); mark it actioned to reject it.' : 'Dismiss to release this send; mark it actioned to reject it.'
179
179
  ].join('\n').slice(0, 5000),
180
180
  reporterEmail: null,
181
181
  reporterName: null,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/outbound-send-review.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 * HELD FOR REVIEW: outbound mail from a young workspace (AGL-3356).\n *\n * The phishing screen (`screenOutboundEmail`, shared-util-email) decides\n * whether a message carries a phishing signal, and `signalsThatHold` beside\n * it decides which signals hold for whom. This module is what happens next,\n * for every surface that screens — the send seam every tenant message\n * crosses ({@link installOutboundScreenGate}), the campaign core and the\n * automation step before they commit, and a site publish\n * (`hosted-page-review.ts`) — so none restates it:\n *\n * - WHO IS SCREENED. Every workspace, in two tiers. A lookalike link holds\n * whatever the workspace's age. The soft rules (a brand in the sender\n * name, the three-part lure) hold only for a workspace younger than\n * {@link OUTBOUND_REVIEW_YOUNG_DAYS}: the incident's workspace was days\n * old, and an established customer's clumsy copy never waits on them. An\n * org whose creation date cannot be read is an existing customer (the same\n * reading `orgAgeDays` gives the sending ramp) and is not young.\n * - WHERE A HOLD GOES. Into the abuse queue at `/admin/abuse-reports` — the\n * staff surface that already exists to answer \"is this phishing\", with its\n * audit trail, its urgent count and its staff notification — as a\n * `phishing` row whose `heldSend` names the message. Not a new queue: a\n * second place to look is a place nobody looks.\n * - HOW STAFF DECIDE. By the row's existing status. `dismissed` (a false\n * positive) RELEASES the send; `actioned` REJECTS it. The admin route\n * calls {@link decideHeldOutboundSend} in the same act as the status\n * write, so the decision and its audit row cannot come apart.\n * - WHAT A RELEASE COVERS. Exactly the content that was held: the review\n * row's id is derived from the message's source and a hash of its\n * content, so an edited message is screened afresh rather than riding a\n * release granted to different words.\n *\n * A hold is never a drop. A held campaign is parked as a scheduled send that\n * a release puts back on the clock; a held automation step fails that one\n * run with a reason the merchant can read, or — after a wait — stays queued\n * until the decision.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n type PhishingScreenInput,\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n type OutboundScreenGateRequest,\n type OutboundScreenGateVerdict,\n type SendingWorkspace,\n setOutboundScreenGate,\n} from '@aglyn/shared-util-email/outbound-screen-gate'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { orgAgeDays } from './org-age'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyStaff } from './notifications'\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport { OUTBOUND_REVIEW_YOUNG_DAYS }\n\n/**\n * Where a held campaign is parked: a send time no processor run reaches.\n * A release replaces it with \"now\"; nothing else ever does, so a hold that\n * nobody decides stays a hold rather than lapsing into a send.\n */\nexport const HELD_SEND_AT_MS = 253402300799000\n\n/**\n * Which surface held it: a campaign, an automation step, any other tenant\n * message at the send seam (`message`), or a site publish (`page`).\n */\nexport type HeldOutboundSendKind =\n | 'campaign'\n | 'action'\n | 'workflow'\n | 'orgAutomation'\n | 'message'\n | 'page'\n\n/** The decision state a held send carries on its review row. */\nexport type HeldOutboundSendState = 'held' | 'released' | 'rejected'\n\n/** What the review row records about the message it holds. */\nexport interface HeldOutboundSend {\n kind: HeldOutboundSendKind\n /** The document the message came from — a send, an action, a workflow. */\n path: string\n hostId: string\n orgId: string | null\n contentHash: string\n subject: string\n fromName: string | null\n signals: PhishingScreenSignal[]\n state: HeldOutboundSendState\n heldAtMs: number\n /** Workspace age in days when it was held, for the reviewer. */\n ageDays: number | null\n}\n\n/**\n * Is this workspace young enough to screen?\n *\n * Age alone, deliberately. Payment history was the other candidate and the\n * incident is the argument against it: the actor PAID, on a card that never\n * met 3-D Secure, within days of signing up. A paid invoice proves a card\n * worked, not that its holder is who they say.\n */\nexport function isYoungWorkspace(\n org: { createdAt?: unknown } | null | undefined,\n nowMs: number = Date.now(),\n): boolean {\n return isYoungWorkspaceAge(orgAgeDays(org?.createdAt, nowMs))\n}\n\n/**\n * The workspace a host's mail belongs to, as the send seam's screen reads it\n * — stamped on the sending identity by `hostSendingIdentity` from the host\n * and org documents it has already read.\n */\nexport function sendingWorkspaceFor(input: {\n hostId: string\n orgId: string | null\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n nowMs?: number\n}): SendingWorkspace {\n const identity = workspaceScreenIdentity({ org: input.org, host: input.host })\n return {\n hostId: input.hostId,\n orgId: input.orgId,\n ageDays: orgAgeDays(\n (input.org as { createdAt?: unknown } | null | undefined)?.createdAt,\n input.nowMs ?? Date.now(),\n ),\n ownNames: (identity.ownNames ?? []).filter((name): name is string => Boolean(name)),\n ownDomains: (identity.ownDomains ?? []).filter((domain): domain is string => Boolean(domain)),\n }\n}\n\n/**\n * The workspace's own names and hosts, as the screen reads them: a brand in\n * its own name is its own, and a link to its own site is not \"elsewhere\".\n */\nexport function workspaceScreenIdentity(input: {\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n}): Pick<PhishingScreenInput, 'ownNames' | 'ownDomains'> {\n const org = input.org ?? {}\n const host = input.host ?? {}\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const subdomain = text(host['subdomain'])\n return {\n ownNames: [text(org['name']), text(host['name']), subdomain],\n ownDomains: [\n subdomain ? `${subdomain}.${TENANT_APEX}` : '',\n text(host['cname']),\n text(host['sendingDomain']),\n ],\n }\n}\n\n/**\n * JSON with every object's keys sorted, so the same design serializes to the\n * same string however it was read. Firestore hands back a map's keys in an\n * order that differs by reader, and a release keyed on a hash of an unsorted\n * serialization would not match the very content it released.\n */\nexport function canonicalJson(value: unknown): string {\n return JSON.stringify(value, (_key, entry) =>\n entry && typeof entry === 'object' && !Array.isArray(entry)\n ? Object.fromEntries(\n Object.keys(entry as Record<string, unknown>)\n .sort()\n .map((key) => [key, (entry as Record<string, unknown>)[key]]),\n )\n : entry,\n )\n}\n\n/** A stable hash of what a message says, so a release covers these words only. */\nexport function outboundContentHash(parts: readonly unknown[]): string {\n return createHash('sha256').update(canonicalJson(parts)).digest('hex')\n}\n\n/**\n * The review row's id: one row per (source, content). Hex, so the admin\n * route's report-id pattern addresses it like any intake row.\n */\nexport function heldOutboundReviewId(path: string, contentHash: string): string {\n return createHash('sha256')\n .update(`outbound-hold:${path}:${contentHash}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, in the intake's `AR-` style. */\nexport function heldOutboundReference(reviewId: string): string {\n return `HS-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\nexport interface OutboundScreenRequest {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n orgId: string | null\n org: Record<string, unknown> | null\n host: Record<string, unknown> | null\n subject: string\n fromName?: string | null\n /**\n * The address it leaves from (AGL-3362). Read by the lookalike rule only,\n * and kept out of the content hash so a release granted before this was\n * read still names the same row.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n bodies: readonly (string | null | undefined)[]\n nowMs?: number\n}\n\n/** What a screen answers for one piece of content. */\nexport type OutboundScreenOutcome =\n | {\n outcome: 'send'\n /** The review row staff released this content under, when there is one. */\n releasedReviewId?: string\n }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string; contentHash: string }\n\n/** What a surface files when its screen holds, for {@link fileOutboundHold}. */\nexport interface OutboundHoldFiling {\n reviewId: string\n heldSend: HeldOutboundSend\n /** Extra fields a surface records beside `heldSend` (a page's version). */\n extra?: Record<string, unknown>\n /** The first line of the row's details, naming what was held. */\n headline: string\n /** The staff alert's title and body, the first time. */\n alertTitle: string\n alertBody: string\n /** The URL the row reports: the flagged host, or the held page. */\n url: string | null\n reportedHostname: string | null\n}\n\n/**\n * Write (or count) the review row for a hold, and tell staff the first time.\n * Returns the row's decision state — a row staff already decided answers\n * with that decision and is not written.\n *\n * One writer for every surface, so a campaign, a seam message and a page\n * land in the queue in the one shape the admin route and page already read.\n */\nexport async function fileOutboundHold(\n filing: OutboundHoldFiling,\n): Promise<HeldOutboundSendState> {\n const firestore = firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(filing.reviewId)\n const reference = heldOutboundReference(filing.reviewId)\n let first = false\n let state = 'held' as HeldOutboundSendState\n await firestore.runTransaction(async (transaction) => {\n const existing = await transaction.get(ref)\n first = !existing.exists\n const stored = existing.exists\n ? (existing.get('heldSend') as Partial<HeldOutboundSend> | undefined)\n : undefined\n state = stored?.state === 'released' || stored?.state === 'rejected'\n ? stored.state\n : 'held'\n if (state !== 'held') return\n transaction.set(\n ref,\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n // Who filed it: the screen, not a person. The queue reads it to\n // say so, and a reporter-contact field stays honestly empty.\n source: 'outbound-screen',\n // The flagged host as TEXT — the page never renders a reported URL\n // as a link, and this one may be a phishing kit's.\n url: filing.url,\n reportedHostname: filing.reportedHostname,\n hostId: filing.heldSend.hostId,\n orgId: filing.heldSend.orgId,\n details: [\n filing.headline,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n filing.heldSend.kind === 'page'\n ? 'Dismiss to release this publish; mark it actioned to reject it.'\n : 'Dismiss to release this send; mark it actioned to reject it.',\n ]\n .join('\\n')\n .slice(0, 5000),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n // Every message the same content held counts here, so a workflow\n // firing on each new contact reads as the volume it is.\n reportCount: FieldValue.increment(1),\n heldSend: first\n ? filing.heldSend\n : { ...filing.heldSend, heldAtMs: stored?.heldAtMs ?? filing.heldSend.heldAtMs },\n ...(filing.extra ?? {}),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n })\n if (first && state === 'held') {\n await notifyStaff({\n type: 'system.abuseReportUrgent',\n title: filing.alertTitle,\n body: `${filing.alertBody} Reference ${reference}.`,\n link: '/admin/abuse-reports',\n })\n }\n return state\n}\n\n/** The first signal that names a host, for the row's reported URL. */\nexport function flaggedHostOf(signals: readonly PhishingScreenSignal[]): string | null {\n const flagged = signals.find(\n (signal): signal is Extract<PhishingScreenSignal, { host: string }> => 'host' in signal,\n )\n return flagged?.host ?? null\n}\n\n/**\n * The screen's answer for one message, and what to do with it.\n *\n * - `send` — clean under the tiers, or held earlier and RELEASED by staff\n * for exactly this content (then `releasedReviewId` names the row, and the\n * caller hands it to `sendEmail` so the seam's screen honors it too).\n * - `held` — hold it. The review row is written (or its count bumped) and\n * staff are told the first time.\n * - `rejected` — staff rejected exactly this content. Never sent.\n *\n * Every workspace is screened; `signalsThatHold` applies the tiers, so an\n * established workspace is held only by a lookalike link.\n *\n * Fails OPEN on a store error, the posture of every other control on the\n * send path: an outage on the review queue must not become an outage on\n * every workspace's mail. The screen itself cannot fail.\n */\nexport async function screenOutboundSend(\n request: OutboundScreenRequest,\n): Promise<OutboundScreenOutcome> {\n const nowMs = request.nowMs ?? Date.now()\n const ageDays = orgAgeDays(\n (request.org as { createdAt?: unknown } | null)?.createdAt,\n nowMs,\n )\n const verdict = screenOutboundEmail({\n subject: request.subject,\n fromName: request.fromName ?? null,\n fromAddress: request.fromAddress ?? null,\n replyTo: request.replyTo ?? null,\n preheader: request.preheader ?? null,\n bodies: request.bodies,\n ...workspaceScreenIdentity({ org: request.org, host: request.host }),\n })\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'send' }\n\n const contentHash = outboundContentHash([\n request.subject,\n request.fromName ?? '',\n request.replyTo ?? '',\n request.preheader ?? '',\n ...request.bodies.map((body) => body ?? ''),\n ])\n const reviewId = heldOutboundReviewId(request.path, contentHash)\n const reference = heldOutboundReference(reviewId)\n try {\n const heldSend: HeldOutboundSend = {\n kind: request.kind,\n path: request.path,\n hostId: request.hostId,\n orgId: request.orgId,\n contentHash,\n subject: request.subject.slice(0, 300),\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n }\n const flagged = flaggedHostOf(signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend,\n headline:\n `Held ${request.kind === 'campaign' ? 'campaign' : `${request.kind} email step`} ` +\n `\"${heldSend.subject}\" from a workspace ${ageDays ?? '?'} day(s) old.`,\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody:\n `A ${request.kind === 'campaign' ? 'campaign' : 'automated email'} ` +\n `was held: \"${request.subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n if (state === 'released') return { outcome: 'send', releasedReviewId: reviewId }\n return { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference, contentHash }\n } catch (error) {\n console.error('[outbound-review] hold could not be recorded — allowing', error)\n return { outcome: 'send' }\n }\n}\n\n/**\n * How long this process trusts what it last learned about a seam row: that\n * it is held (so the next message carrying the same signals skips the\n * write), or how staff decided it. Short, because a decision must reach a\n * sender within a minute; long enough that a newsletter fanned out to a\n * thousand people costs one transaction rather than a thousand on one\n * document.\n */\nconst SEAM_MEMO_TTL_MS = 60_000\nconst seamMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n\n/** Test seam: forget what this process learned about seam rows. */\nexport function resetOutboundSeamMemoForTests(): void {\n seamMemo.clear()\n}\n\n/**\n * The seam row's id for a site and the signals that held. Keyed on the\n * SIGNALS rather than the words: a message at the seam is already\n * personalized per recipient, so its words differ for every person while\n * what makes it phishing — the lookalike host, the brand and the lure — does\n * not. A release therefore covers this site's use of exactly these signals,\n * and a message carrying any other signal is screened afresh.\n */\nexport function seamReviewId(hostId: string, signals: readonly PhishingScreenSignal[]): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/outbound`, outboundContentHash([key]))\n}\n\n/** Is this row a staff RELEASE for this site? */\nasync function isReleaseForHost(reviewId: string, hostId: string): Promise<boolean> {\n const memoKey = `release:${hostId}:${reviewId}`\n const memo = seamMemo.get(memoKey)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) return memo.state === 'released'\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n const held = snapshot.get('heldSend') as Partial<HeldOutboundSend> | undefined\n const released = held?.state === 'released' && held?.hostId === hostId\n seamMemo.set(memoKey, { state: released ? 'released' : 'held', atMs: Date.now() })\n return released\n}\n\n/**\n * The send seam's gate: what `sendEmail` asks when a tenant message carries\n * a signal that holds (see `outbound-screen-gate.ts`). Installed at module\n * load by {@link installOutboundScreenGate}.\n *\n * A seam hold REFUSES that one message (`held-for-review`) — the sender's own\n * failure handling reports it, the way it reports a suppression — and files\n * one row per site and signal set. Once staff release it, the site's next\n * message carrying those signals sends; reject it, and none does.\n */\nexport async function screenSeamMessage(\n request: OutboundScreenGateRequest,\n): Promise<OutboundScreenGateVerdict> {\n const { workspace } = request\n if (\n request.releasedReviewId &&\n (await isReleaseForHost(request.releasedReviewId, workspace.hostId))\n ) {\n return { outcome: 'send' }\n }\n const reviewId = seamReviewId(workspace.hostId, request.signals)\n const reference = heldOutboundReference(reviewId)\n const memo = seamMemo.get(reviewId)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'send' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reference }\n }\n const nowMs = Date.now()\n const subject = String(request.subject ?? '').slice(0, 300)\n const context = request.context ? String(request.context).slice(0, 80) : null\n const flagged = flaggedHostOf(request.signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'message',\n path: `hosts/${workspace.hostId}`,\n hostId: workspace.hostId,\n orgId: workspace.orgId,\n contentHash: outboundContentHash([request.signals]),\n subject,\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals: request.signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays: workspace.ageDays,\n },\n extra: { heldContext: context },\n headline:\n `Held ${context ? `\"${context}\" ` : ''}email \"${subject}\" from a workspace ` +\n `${workspace.ageDays ?? '?'} day(s) old. Every later message from this site ` +\n 'carrying the same signals waits on this decision.',\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody: `An email from a site was held: \"${subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n seamMemo.set(reviewId, { state, atMs: Date.now() })\n return state === 'released'\n ? { outcome: 'send' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reference }\n}\n\n/**\n * Puts the review store on `sendEmail`'s phishing screen.\n *\n * **Called at module load**, from the bottom of this file, for the reason\n * `installEmailSendGovernor` is — and this module is also imported by\n * `sending-domains.ts`, so any sender that resolved its identity through\n * `hostSendingIdentity` (the only thing that stamps a workspace on it, and\n * so the only thing the seam screens) has installed it.\n */\nexport function installOutboundScreenGate(): void {\n if (typeof setOutboundScreenGate !== 'function') return\n setOutboundScreenGate((request) => screenSeamMessage(request))\n}\n\n/**\n * Apply a staff decision to a held send (the admin abuse route, in the same\n * act as the row's status change).\n *\n * The row's `heldSend.state` is written first: it is what the screen reads,\n * so an automation step released here sends on its next run whatever\n * happens to the rest. A CAMPAIGN is also put back on the clock (released)\n * or canceled (rejected) — but only while it is still the parked send this\n * row held: a send that has since been edited, sent or canceled by the\n * merchant is left alone, and the answer says so.\n */\nexport async function decideHeldOutboundSend(input: {\n reviewId: string\n decision: 'release' | 'reject'\n actorUid: string\n actorEmail: string | null\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}): Promise<'released' | 'rejected' | 'not-held' | 'campaign-moved'> {\n const nowMs = input.nowMs ?? Date.now()\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(input.reviewId)\n const snapshot = await ref.get()\n const heldSend = snapshot.get('heldSend') as HeldOutboundSend | undefined\n if (!heldSend?.path || !heldSend.contentHash) return 'not-held'\n const state: HeldOutboundSendState =\n input.decision === 'release' ? 'released' : 'rejected'\n const decided = {\n state,\n decidedAtMs: nowMs,\n decidedByUid: input.actorUid,\n decidedByEmail: input.actorEmail,\n }\n await ref.set(\n { heldSend: { ...heldSend, ...decided } },\n { merge: true },\n )\n if (heldSend.kind !== 'campaign') return state\n\n const sendRef = firestore.doc(heldSend.path)\n const moved = await firestore.runTransaction(async (transaction) => {\n const send = await transaction.get(sendRef)\n const review = send.get('staffReview') as\n | { reviewId?: string; state?: string }\n | undefined\n const status = String(send.get('status') ?? '')\n if (\n !send.exists ||\n review?.reviewId !== input.reviewId ||\n (status !== 'scheduled' && status !== 'draft')\n ) {\n return true\n }\n transaction.set(\n sendRef,\n input.decision === 'release'\n ? {\n status: 'scheduled',\n // Due now: the next processor run sends it, and the screen\n // finds this row released for exactly this content.\n sendAtMs: nowMs,\n staffReview: { ...review, ...decided },\n }\n : {\n status: 'canceled',\n sendAtMs: FieldValue.delete(),\n staffReview: { ...review, ...decided },\n },\n { merge: true },\n )\n return false\n })\n return moved ? 'campaign-moved' : state\n}\n\ninstallOutboundScreenGate()\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","TENANT_APEX","describePhishingScreenSignals","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","screenOutboundEmail","signalsThatHold","setOutboundScreenGate","FieldValue","orgAgeDays","firebaseAdmin","notifyStaff","HELD_SEND_AT_MS","isYoungWorkspace","org","nowMs","Date","now","createdAt","sendingWorkspaceFor","input","identity","workspaceScreenIdentity","host","hostId","orgId","ageDays","ownNames","filter","name","Boolean","ownDomains","domain","text","value","subdomain","canonicalJson","JSON","stringify","_key","entry","Array","isArray","Object","fromEntries","keys","sort","map","key","outboundContentHash","parts","update","digest","heldOutboundReviewId","path","contentHash","slice","heldOutboundReference","reviewId","toUpperCase","fileOutboundHold","filing","firestore","app","ref","collection","doc","reference","first","state","runTransaction","transaction","existing","get","exists","stored","undefined","set","category","severity","source","url","reportedHostname","heldSend","details","headline","signals","kind","join","reporterEmail","reporterName","dmca","reportCount","increment","heldAtMs","extra","updatedAt","serverTimestamp","status","merge","type","title","alertTitle","body","alertBody","link","flaggedHostOf","flagged","find","signal","screenOutboundSend","request","verdict","subject","fromName","fromAddress","replyTo","preheader","bodies","length","outcome","String","releasedReviewId","error","console","SEAM_MEMO_TTL_MS","seamMemo","Map","resetOutboundSeamMemoForTests","clear","seamReviewId","isReleaseForHost","memoKey","memo","atMs","snapshot","held","released","screenSeamMessage","workspace","context","heldContext","installOutboundScreenGate","decideHeldOutboundSend","decision","decided","decidedAtMs","decidedByUid","actorUid","decidedByEmail","actorEmail","sendRef","moved","send","review","sendAtMs","staffReview","delete"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/outbound-send-review.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 * HELD FOR REVIEW: outbound mail from a young workspace (AGL-3356).\n *\n * The phishing screen (`screenOutboundEmail`, shared-util-email) decides\n * whether a message carries a phishing signal, and `signalsThatHold` beside\n * it decides which signals hold for whom. This module is what happens next,\n * for every surface that screens — the send seam every tenant message\n * crosses ({@link installOutboundScreenGate}), the campaign core and the\n * automation step before they commit, and a site publish\n * (`hosted-page-review.ts`) — so none restates it:\n *\n * - WHO IS SCREENED. Every workspace, in two tiers. A lookalike link holds\n * whatever the workspace's age. The soft rules (a brand in the sender\n * name, the three-part lure) hold only for a workspace younger than\n * {@link OUTBOUND_REVIEW_YOUNG_DAYS}: the incident's workspace was days\n * old, and an established customer's clumsy copy never waits on them. An\n * org whose creation date cannot be read is an existing customer (the same\n * reading `orgAgeDays` gives the sending ramp) and is not young.\n * - WHERE A HOLD GOES. Into the abuse queue at `/admin/abuse-reports` — the\n * staff surface that already exists to answer \"is this phishing\", with its\n * audit trail, its urgent count and its staff notification — as a\n * `phishing` row whose `heldSend` names the message. Not a new queue: a\n * second place to look is a place nobody looks.\n * - HOW STAFF DECIDE. By the row's existing status. `dismissed` (a false\n * positive) RELEASES the send; `actioned` REJECTS it. The admin route\n * calls {@link decideHeldOutboundSend} in the same act as the status\n * write, so the decision and its audit row cannot come apart.\n * - WHAT A RELEASE COVERS. Exactly the content that was held: the review\n * row's id is derived from the message's source and a hash of its\n * content, so an edited message is screened afresh rather than riding a\n * release granted to different words.\n *\n * A hold is never a drop. A held campaign is parked as a scheduled send that\n * a release puts back on the clock; a held automation step fails that one\n * run with a reason the merchant can read, or — after a wait — stays queued\n * until the decision.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n type PhishingScreenInput,\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n type OutboundScreenGateRequest,\n type OutboundScreenGateVerdict,\n type SendingWorkspace,\n setOutboundScreenGate,\n} from '@aglyn/shared-util-email/outbound-screen-gate'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { orgAgeDays } from './org-age'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyStaff } from './notifications'\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport { OUTBOUND_REVIEW_YOUNG_DAYS }\n\n/**\n * Where a held campaign is parked: a send time no processor run reaches.\n * A release replaces it with \"now\"; nothing else ever does, so a hold that\n * nobody decides stays a hold rather than lapsing into a send.\n */\nexport const HELD_SEND_AT_MS = 253402300799000\n\n/**\n * Which surface held it: a campaign, an automation step, any other tenant\n * message at the send seam (`message`), a site publish (`page`), or a\n * submission to a public catalog a plugin keeps — a marketplace listing, its\n * publisher profile (`listing`, AGL-3365).\n */\nexport type HeldOutboundSendKind =\n | 'campaign'\n | 'action'\n | 'workflow'\n | 'orgAutomation'\n | 'message'\n | 'page'\n | 'listing'\n\n/** The decision state a held send carries on its review row. */\nexport type HeldOutboundSendState = 'held' | 'released' | 'rejected'\n\n/** What the review row records about the message it holds. */\nexport interface HeldOutboundSend {\n kind: HeldOutboundSendKind\n /** The document the message came from — a send, an action, a workflow. */\n path: string\n hostId: string\n orgId: string | null\n contentHash: string\n subject: string\n fromName: string | null\n signals: PhishingScreenSignal[]\n state: HeldOutboundSendState\n heldAtMs: number\n /** Workspace age in days when it was held, for the reviewer. */\n ageDays: number | null\n}\n\n/**\n * Is this workspace young enough to screen?\n *\n * Age alone, deliberately. Payment history was the other candidate and the\n * incident is the argument against it: the actor PAID, on a card that never\n * met 3-D Secure, within days of signing up. A paid invoice proves a card\n * worked, not that its holder is who they say.\n */\nexport function isYoungWorkspace(\n org: { createdAt?: unknown } | null | undefined,\n nowMs: number = Date.now(),\n): boolean {\n return isYoungWorkspaceAge(orgAgeDays(org?.createdAt, nowMs))\n}\n\n/**\n * The workspace a host's mail belongs to, as the send seam's screen reads it\n * — stamped on the sending identity by `hostSendingIdentity` from the host\n * and org documents it has already read.\n */\nexport function sendingWorkspaceFor(input: {\n hostId: string\n orgId: string | null\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n nowMs?: number\n}): SendingWorkspace {\n const identity = workspaceScreenIdentity({ org: input.org, host: input.host })\n return {\n hostId: input.hostId,\n orgId: input.orgId,\n ageDays: orgAgeDays(\n (input.org as { createdAt?: unknown } | null | undefined)?.createdAt,\n input.nowMs ?? Date.now(),\n ),\n ownNames: (identity.ownNames ?? []).filter((name): name is string => Boolean(name)),\n ownDomains: (identity.ownDomains ?? []).filter((domain): domain is string => Boolean(domain)),\n }\n}\n\n/**\n * The workspace's own names and hosts, as the screen reads them: a brand in\n * its own name is its own, and a link to its own site is not \"elsewhere\".\n */\nexport function workspaceScreenIdentity(input: {\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n}): Pick<PhishingScreenInput, 'ownNames' | 'ownDomains'> {\n const org = input.org ?? {}\n const host = input.host ?? {}\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const subdomain = text(host['subdomain'])\n return {\n ownNames: [text(org['name']), text(host['name']), subdomain],\n ownDomains: [\n subdomain ? `${subdomain}.${TENANT_APEX}` : '',\n text(host['cname']),\n text(host['sendingDomain']),\n ],\n }\n}\n\n/**\n * JSON with every object's keys sorted, so the same design serializes to the\n * same string however it was read. Firestore hands back a map's keys in an\n * order that differs by reader, and a release keyed on a hash of an unsorted\n * serialization would not match the very content it released.\n */\nexport function canonicalJson(value: unknown): string {\n return JSON.stringify(value, (_key, entry) =>\n entry && typeof entry === 'object' && !Array.isArray(entry)\n ? Object.fromEntries(\n Object.keys(entry as Record<string, unknown>)\n .sort()\n .map((key) => [key, (entry as Record<string, unknown>)[key]]),\n )\n : entry,\n )\n}\n\n/** A stable hash of what a message says, so a release covers these words only. */\nexport function outboundContentHash(parts: readonly unknown[]): string {\n return createHash('sha256').update(canonicalJson(parts)).digest('hex')\n}\n\n/**\n * The review row's id: one row per (source, content). Hex, so the admin\n * route's report-id pattern addresses it like any intake row.\n */\nexport function heldOutboundReviewId(path: string, contentHash: string): string {\n return createHash('sha256')\n .update(`outbound-hold:${path}:${contentHash}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, in the intake's `AR-` style. */\nexport function heldOutboundReference(reviewId: string): string {\n return `HS-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\nexport interface OutboundScreenRequest {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n orgId: string | null\n org: Record<string, unknown> | null\n host: Record<string, unknown> | null\n subject: string\n fromName?: string | null\n /**\n * The address it leaves from (AGL-3362). Read by the lookalike rule only,\n * and kept out of the content hash so a release granted before this was\n * read still names the same row.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n bodies: readonly (string | null | undefined)[]\n nowMs?: number\n}\n\n/** What a screen answers for one piece of content. */\nexport type OutboundScreenOutcome =\n | {\n outcome: 'send'\n /** The review row staff released this content under, when there is one. */\n releasedReviewId?: string\n }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string; contentHash: string }\n\n/** What a surface files when its screen holds, for {@link fileOutboundHold}. */\nexport interface OutboundHoldFiling {\n reviewId: string\n heldSend: HeldOutboundSend\n /** Extra fields a surface records beside `heldSend` (a page's version). */\n extra?: Record<string, unknown>\n /** The first line of the row's details, naming what was held. */\n headline: string\n /** The staff alert's title and body, the first time. */\n alertTitle: string\n alertBody: string\n /** The URL the row reports: the flagged host, or the held page. */\n url: string | null\n reportedHostname: string | null\n}\n\n/**\n * Write (or count) the review row for a hold, and tell staff the first time.\n * Returns the row's decision state — a row staff already decided answers\n * with that decision and is not written.\n *\n * One writer for every surface, so a campaign, a seam message and a page\n * land in the queue in the one shape the admin route and page already read.\n */\nexport async function fileOutboundHold(\n filing: OutboundHoldFiling,\n): Promise<HeldOutboundSendState> {\n const firestore = firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(filing.reviewId)\n const reference = heldOutboundReference(filing.reviewId)\n let first = false\n let state = 'held' as HeldOutboundSendState\n await firestore.runTransaction(async (transaction) => {\n const existing = await transaction.get(ref)\n first = !existing.exists\n const stored = existing.exists\n ? (existing.get('heldSend') as Partial<HeldOutboundSend> | undefined)\n : undefined\n state = stored?.state === 'released' || stored?.state === 'rejected'\n ? stored.state\n : 'held'\n if (state !== 'held') return\n transaction.set(\n ref,\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n // Who filed it: the screen, not a person. The queue reads it to\n // say so, and a reporter-contact field stays honestly empty.\n source: 'outbound-screen',\n // The flagged host as TEXT — the page never renders a reported URL\n // as a link, and this one may be a phishing kit's.\n url: filing.url,\n reportedHostname: filing.reportedHostname,\n hostId: filing.heldSend.hostId,\n orgId: filing.heldSend.orgId,\n details: [\n filing.headline,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n filing.heldSend.kind === 'page'\n ? 'Dismiss to release this publish; mark it actioned to reject it.'\n : filing.heldSend.kind === 'listing'\n ? 'Dismiss to release this submission (the publisher submits it again and it ' +\n 'goes through); mark it actioned to reject it.'\n : 'Dismiss to release this send; mark it actioned to reject it.',\n ]\n .join('\\n')\n .slice(0, 5000),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n // Every message the same content held counts here, so a workflow\n // firing on each new contact reads as the volume it is.\n reportCount: FieldValue.increment(1),\n heldSend: first\n ? filing.heldSend\n : { ...filing.heldSend, heldAtMs: stored?.heldAtMs ?? filing.heldSend.heldAtMs },\n ...(filing.extra ?? {}),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n })\n if (first && state === 'held') {\n await notifyStaff({\n type: 'system.abuseReportUrgent',\n title: filing.alertTitle,\n body: `${filing.alertBody} Reference ${reference}.`,\n link: '/admin/abuse-reports',\n })\n }\n return state\n}\n\n/** The first signal that names a host, for the row's reported URL. */\nexport function flaggedHostOf(signals: readonly PhishingScreenSignal[]): string | null {\n const flagged = signals.find(\n (signal): signal is Extract<PhishingScreenSignal, { host: string }> => 'host' in signal,\n )\n return flagged?.host ?? null\n}\n\n/**\n * The screen's answer for one message, and what to do with it.\n *\n * - `send` — clean under the tiers, or held earlier and RELEASED by staff\n * for exactly this content (then `releasedReviewId` names the row, and the\n * caller hands it to `sendEmail` so the seam's screen honors it too).\n * - `held` — hold it. The review row is written (or its count bumped) and\n * staff are told the first time.\n * - `rejected` — staff rejected exactly this content. Never sent.\n *\n * Every workspace is screened; `signalsThatHold` applies the tiers, so an\n * established workspace is held only by a lookalike link.\n *\n * Fails OPEN on a store error, the posture of every other control on the\n * send path: an outage on the review queue must not become an outage on\n * every workspace's mail. The screen itself cannot fail.\n */\nexport async function screenOutboundSend(\n request: OutboundScreenRequest,\n): Promise<OutboundScreenOutcome> {\n const nowMs = request.nowMs ?? Date.now()\n const ageDays = orgAgeDays(\n (request.org as { createdAt?: unknown } | null)?.createdAt,\n nowMs,\n )\n const verdict = screenOutboundEmail({\n subject: request.subject,\n fromName: request.fromName ?? null,\n fromAddress: request.fromAddress ?? null,\n replyTo: request.replyTo ?? null,\n preheader: request.preheader ?? null,\n bodies: request.bodies,\n ...workspaceScreenIdentity({ org: request.org, host: request.host }),\n })\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'send' }\n\n const contentHash = outboundContentHash([\n request.subject,\n request.fromName ?? '',\n request.replyTo ?? '',\n request.preheader ?? '',\n ...request.bodies.map((body) => body ?? ''),\n ])\n const reviewId = heldOutboundReviewId(request.path, contentHash)\n const reference = heldOutboundReference(reviewId)\n try {\n const heldSend: HeldOutboundSend = {\n kind: request.kind,\n path: request.path,\n hostId: request.hostId,\n orgId: request.orgId,\n contentHash,\n subject: request.subject.slice(0, 300),\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n }\n const flagged = flaggedHostOf(signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend,\n headline:\n `Held ${request.kind === 'campaign' ? 'campaign' : `${request.kind} email step`} ` +\n `\"${heldSend.subject}\" from a workspace ${ageDays ?? '?'} day(s) old.`,\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody:\n `A ${request.kind === 'campaign' ? 'campaign' : 'automated email'} ` +\n `was held: \"${request.subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n if (state === 'released') return { outcome: 'send', releasedReviewId: reviewId }\n return { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference, contentHash }\n } catch (error) {\n console.error('[outbound-review] hold could not be recorded — allowing', error)\n return { outcome: 'send' }\n }\n}\n\n/**\n * How long this process trusts what it last learned about a seam row: that\n * it is held (so the next message carrying the same signals skips the\n * write), or how staff decided it. Short, because a decision must reach a\n * sender within a minute; long enough that a newsletter fanned out to a\n * thousand people costs one transaction rather than a thousand on one\n * document.\n */\nconst SEAM_MEMO_TTL_MS = 60_000\nconst seamMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n\n/** Test seam: forget what this process learned about seam rows. */\nexport function resetOutboundSeamMemoForTests(): void {\n seamMemo.clear()\n}\n\n/**\n * The seam row's id for a site and the signals that held. Keyed on the\n * SIGNALS rather than the words: a message at the seam is already\n * personalized per recipient, so its words differ for every person while\n * what makes it phishing — the lookalike host, the brand and the lure — does\n * not. A release therefore covers this site's use of exactly these signals,\n * and a message carrying any other signal is screened afresh.\n */\nexport function seamReviewId(hostId: string, signals: readonly PhishingScreenSignal[]): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/outbound`, outboundContentHash([key]))\n}\n\n/** Is this row a staff RELEASE for this site? */\nasync function isReleaseForHost(reviewId: string, hostId: string): Promise<boolean> {\n const memoKey = `release:${hostId}:${reviewId}`\n const memo = seamMemo.get(memoKey)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) return memo.state === 'released'\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n const held = snapshot.get('heldSend') as Partial<HeldOutboundSend> | undefined\n const released = held?.state === 'released' && held?.hostId === hostId\n seamMemo.set(memoKey, { state: released ? 'released' : 'held', atMs: Date.now() })\n return released\n}\n\n/**\n * The send seam's gate: what `sendEmail` asks when a tenant message carries\n * a signal that holds (see `outbound-screen-gate.ts`). Installed at module\n * load by {@link installOutboundScreenGate}.\n *\n * A seam hold REFUSES that one message (`held-for-review`) — the sender's own\n * failure handling reports it, the way it reports a suppression — and files\n * one row per site and signal set. Once staff release it, the site's next\n * message carrying those signals sends; reject it, and none does.\n */\nexport async function screenSeamMessage(\n request: OutboundScreenGateRequest,\n): Promise<OutboundScreenGateVerdict> {\n const { workspace } = request\n if (\n request.releasedReviewId &&\n (await isReleaseForHost(request.releasedReviewId, workspace.hostId))\n ) {\n return { outcome: 'send' }\n }\n const reviewId = seamReviewId(workspace.hostId, request.signals)\n const reference = heldOutboundReference(reviewId)\n const memo = seamMemo.get(reviewId)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'send' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reference }\n }\n const nowMs = Date.now()\n const subject = String(request.subject ?? '').slice(0, 300)\n const context = request.context ? String(request.context).slice(0, 80) : null\n const flagged = flaggedHostOf(request.signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'message',\n path: `hosts/${workspace.hostId}`,\n hostId: workspace.hostId,\n orgId: workspace.orgId,\n contentHash: outboundContentHash([request.signals]),\n subject,\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals: request.signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays: workspace.ageDays,\n },\n extra: { heldContext: context },\n headline:\n `Held ${context ? `\"${context}\" ` : ''}email \"${subject}\" from a workspace ` +\n `${workspace.ageDays ?? '?'} day(s) old. Every later message from this site ` +\n 'carrying the same signals waits on this decision.',\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody: `An email from a site was held: \"${subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n seamMemo.set(reviewId, { state, atMs: Date.now() })\n return state === 'released'\n ? { outcome: 'send' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reference }\n}\n\n/**\n * Puts the review store on `sendEmail`'s phishing screen.\n *\n * **Called at module load**, from the bottom of this file, for the reason\n * `installEmailSendGovernor` is — and this module is also imported by\n * `sending-domains.ts`, so any sender that resolved its identity through\n * `hostSendingIdentity` (the only thing that stamps a workspace on it, and\n * so the only thing the seam screens) has installed it.\n */\nexport function installOutboundScreenGate(): void {\n if (typeof setOutboundScreenGate !== 'function') return\n setOutboundScreenGate((request) => screenSeamMessage(request))\n}\n\n/**\n * Apply a staff decision to a held send (the admin abuse route, in the same\n * act as the row's status change).\n *\n * The row's `heldSend.state` is written first: it is what the screen reads,\n * so an automation step released here sends on its next run whatever\n * happens to the rest. A CAMPAIGN is also put back on the clock (released)\n * or canceled (rejected) — but only while it is still the parked send this\n * row held: a send that has since been edited, sent or canceled by the\n * merchant is left alone, and the answer says so.\n */\nexport async function decideHeldOutboundSend(input: {\n reviewId: string\n decision: 'release' | 'reject'\n actorUid: string\n actorEmail: string | null\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}): Promise<'released' | 'rejected' | 'not-held' | 'campaign-moved'> {\n const nowMs = input.nowMs ?? Date.now()\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(input.reviewId)\n const snapshot = await ref.get()\n const heldSend = snapshot.get('heldSend') as HeldOutboundSend | undefined\n if (!heldSend?.path || !heldSend.contentHash) return 'not-held'\n const state: HeldOutboundSendState =\n input.decision === 'release' ? 'released' : 'rejected'\n const decided = {\n state,\n decidedAtMs: nowMs,\n decidedByUid: input.actorUid,\n decidedByEmail: input.actorEmail,\n }\n await ref.set(\n { heldSend: { ...heldSend, ...decided } },\n { merge: true },\n )\n if (heldSend.kind !== 'campaign') return state\n\n const sendRef = firestore.doc(heldSend.path)\n const moved = await firestore.runTransaction(async (transaction) => {\n const send = await transaction.get(sendRef)\n const review = send.get('staffReview') as\n | { reviewId?: string; state?: string }\n | undefined\n const status = String(send.get('status') ?? '')\n if (\n !send.exists ||\n review?.reviewId !== input.reviewId ||\n (status !== 'scheduled' && status !== 'draft')\n ) {\n return true\n }\n transaction.set(\n sendRef,\n input.decision === 'release'\n ? {\n status: 'scheduled',\n // Due now: the next processor run sends it, and the screen\n // finds this row released for exactly this content.\n sendAtMs: nowMs,\n staffReview: { ...review, ...decided },\n }\n : {\n status: 'canceled',\n sendAtMs: FieldValue.delete(),\n staffReview: { ...review, ...decided },\n },\n { merge: true },\n )\n return false\n })\n return moved ? 'campaign-moved' : state\n}\n\ninstallOutboundScreenGate()\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","TENANT_APEX","describePhishingScreenSignals","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","screenOutboundEmail","signalsThatHold","setOutboundScreenGate","FieldValue","orgAgeDays","firebaseAdmin","notifyStaff","HELD_SEND_AT_MS","isYoungWorkspace","org","nowMs","Date","now","createdAt","sendingWorkspaceFor","input","identity","workspaceScreenIdentity","host","hostId","orgId","ageDays","ownNames","filter","name","Boolean","ownDomains","domain","text","value","subdomain","canonicalJson","JSON","stringify","_key","entry","Array","isArray","Object","fromEntries","keys","sort","map","key","outboundContentHash","parts","update","digest","heldOutboundReviewId","path","contentHash","slice","heldOutboundReference","reviewId","toUpperCase","fileOutboundHold","filing","firestore","app","ref","collection","doc","reference","first","state","runTransaction","transaction","existing","get","exists","stored","undefined","set","category","severity","source","url","reportedHostname","heldSend","details","headline","signals","kind","join","reporterEmail","reporterName","dmca","reportCount","increment","heldAtMs","extra","updatedAt","serverTimestamp","status","merge","type","title","alertTitle","body","alertBody","link","flaggedHostOf","flagged","find","signal","screenOutboundSend","request","verdict","subject","fromName","fromAddress","replyTo","preheader","bodies","length","outcome","String","releasedReviewId","error","console","SEAM_MEMO_TTL_MS","seamMemo","Map","resetOutboundSeamMemoForTests","clear","seamReviewId","isReleaseForHost","memoKey","memo","atMs","snapshot","held","released","screenSeamMessage","workspace","context","heldContext","installOutboundScreenGate","decideHeldOutboundSend","decision","decided","decidedAtMs","decidedByUid","actorUid","decidedByEmail","actorEmail","sendRef","moved","send","review","sendAtMs","staffReview","delete"],"mappings":"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"}
@@ -0,0 +1,176 @@
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 SystemEmailTemplateDefinition } from '@aglyn/shared-util-email';
18
+ import { type ReusableComponentTree } from '@aglyn/aglyn/server';
19
+ import { isPlatformBrandedSend, type SystemEmailBrandOptions } from '@aglyn/aglyn/app-utils/system-email-chrome';
20
+ export { isPlatformBrandedSend };
21
+ export type { SystemEmailBrandOptions };
22
+ /**
23
+ * Which copy a system email carries: a design staff published, or the
24
+ * catalog's built-in copy, which is what every key without a usable design
25
+ * sends (AGL-3322).
26
+ */
27
+ export type SystemEmailSource = 'designed' | 'default';
28
+ export interface RenderedSystemEmail {
29
+ subject: string;
30
+ html: string;
31
+ text: string;
32
+ source: SystemEmailSource;
33
+ }
34
+ /**
35
+ * A system email loaded once, ready to render for any number of recipients
36
+ * without touching Firestore again (AGL-768): the published staff design, or
37
+ * the catalog's built-in copy when there is none.
38
+ */
39
+ export interface LoadedSystemEmail {
40
+ definition: SystemEmailTemplateDefinition;
41
+ /** The design as stored, each email block still a placement; or the built-in copy's node map. */
42
+ nodes: Record<string, unknown>;
43
+ subjectTemplate: string;
44
+ preheaderTemplate: string;
45
+ source: SystemEmailSource;
46
+ /**
47
+ * The published definitions of the email blocks `nodes` places, keyed by
48
+ * id, read from the platform marketing site ONCE with the template
49
+ * (AGL-3318), so a batch composes every recipient's copy without another
50
+ * read. Absent when the design places none, when the deployment names no
51
+ * marketing site, and when the read failed; each placement then renders as
52
+ * nothing.
53
+ */
54
+ components?: Record<string, ReusableComponentTree>;
55
+ }
56
+ /**
57
+ * Loads what a key sends — ONE Firestore read — ready to render per
58
+ * recipient: the published staff design, or the catalog's built-in copy
59
+ * (AGL-3322) when there is none to use (no document, no published version,
60
+ * an empty node map, or a read failure).
61
+ *
62
+ * `null` only for a key that is unknown or not Resend-delivered, which has
63
+ * nothing here to render. Split out from {@link renderSystemEmail} so a batch
64
+ * send resolves the template once and renders it per recipient, rather than
65
+ * one read each (AGL-768): a Firestore read per invite is fine, one per
66
+ * recipient in a usage-email batch is not.
67
+ *
68
+ * Reads through the Admin SDK, which bypasses the staff-only rules on the
69
+ * collection — the send path runs as the server, not as a signed-in user.
70
+ *
71
+ * A design that places email blocks costs one more read per block, from the
72
+ * platform marketing site, made here with the template so a batch pays it
73
+ * once too (AGL-3318). Whether a recipient's copy expands them is decided per
74
+ * send, by {@link renderLoadedSystemEmail}.
75
+ */
76
+ export declare function loadSystemEmail(templateKey: string): Promise<LoadedSystemEmail | null>;
77
+ /**
78
+ * Renders a loaded email for one recipient's merge values (AGL-768). No
79
+ * Firestore access — call {@link loadSystemEmail} once, then this per
80
+ * recipient. `null` only if the renderer produced no document at all.
81
+ *
82
+ * Which header and footer it wears is decided here, per send, because one
83
+ * batch-loaded design goes to recipients of different brands:
84
+ *
85
+ * - A send under the platform's own brand of a design whose email blocks the
86
+ * load read draws those blocks (AGL-3318), and nothing else: they ARE its
87
+ * header and footer, and a second one around them would draw both.
88
+ * - Every other send is drawn inside {@link buildSystemEmailChrome}'s chrome
89
+ * (AGL-3322): the built-in copy, which places no blocks; a design sent to a
90
+ * white-label org's people, whose blocks stay unexpanded and draw nothing,
91
+ * since they are the platform's; and a design on a deployment that names no
92
+ * marketing site, or whose blocks could not be read. The chrome is in the
93
+ * brand of the send — the org's email logo rides in its header, so a
94
+ * white-label mail carries exactly one logo.
95
+ */
96
+ export declare function renderLoadedSystemEmail(loaded: LoadedSystemEmail, merge?: Record<string, string>, options?: SystemEmailBrandOptions): RenderedSystemEmail | null;
97
+ /**
98
+ * Renders the system email a key sends to one recipient (AGL-750): the
99
+ * published staff design, or the catalog's built-in copy (AGL-3322), in the
100
+ * header and footer of the brand it is sent under.
101
+ *
102
+ * **`null` only for a key that is unknown or not Resend-delivered**, which
103
+ * has nothing here to render. No template document, no published version, an
104
+ * empty node map and a Firestore failure all render the built-in copy — the
105
+ * same copy the editor seeds a first design with — so a missing, half-saved
106
+ * or broken template can never mean a customer receives no email, or a bare
107
+ * one. Every caller still keeps its own hard-coded copy behind this, as the
108
+ * last resort for a render that produced nothing.
109
+ *
110
+ * A single-recipient convenience over {@link loadSystemEmail} +
111
+ * {@link renderLoadedSystemEmail}; a batch should call those two directly so
112
+ * it reads the template once.
113
+ */
114
+ export declare function renderSystemEmail(templateKey: string, merge?: Record<string, string>, options?: SystemEmailBrandOptions): Promise<RenderedSystemEmail | null>;
115
+ /**
116
+ * The email a template sends right now, for the staff test-send endpoint
117
+ * (AGL-766): exactly what a real send of it renders — the published design,
118
+ * or the built-in copy, with the same header and footer — so a test shows
119
+ * what recipients receive. `null` for an unknown or non-Resend key.
120
+ */
121
+ export declare function renderEffectiveSystemEmail(templateKey: string, merge?: Record<string, string>, options?: SystemEmailBrandOptions): Promise<RenderedSystemEmail | null>;
122
+ /**
123
+ * The brand one system email is sent under (AGL-3367): the merge tokens, the
124
+ * chrome options and the sender name, from one place.
125
+ *
126
+ * `null` is the platform's own brand, and it is the answer for everything
127
+ * the platform says on its own behalf: a staff alert, a legal receipt, an
128
+ * enforcement notice. A workspace-context email passes the workspace's org
129
+ * document, and `resolveBrandingProfile` decides from there: a white-label
130
+ * org gets its own name, logo, home and support in the header and footer,
131
+ * and every other org resolves to the platform profile, so the same call is
132
+ * right for both.
133
+ */
134
+ export interface SystemEmailBrand {
135
+ merge: Record<string, string>;
136
+ options: SystemEmailBrandOptions;
137
+ /** The display name to send under; absent sends under the platform's. */
138
+ fromName?: string;
139
+ /** The org's own name, for the `{{org.name}}` its copy may use. */
140
+ orgName?: string;
141
+ }
142
+ /** See {@link SystemEmailBrand}. */
143
+ export declare function systemEmailBrand(org: Readonly<Record<string, unknown>> | null | undefined): SystemEmailBrand;
144
+ /**
145
+ * {@link systemEmailBrand} for an org read by id, for a sender that holds
146
+ * only the id. A failed or missing read is the platform's brand: the email
147
+ * still goes, and a white-label org's people see the platform's chrome on
148
+ * one message rather than no message.
149
+ */
150
+ export declare function orgSystemEmailBrand(orgId: string | null | undefined): Promise<SystemEmailBrand>;
151
+ /** What a send takes from a rendered system email. */
152
+ export interface SystemEmailContent {
153
+ subject: string;
154
+ text: string;
155
+ html?: string;
156
+ }
157
+ /**
158
+ * The subject, text and HTML to send, with the caller's own copy as the
159
+ * last resort for a render that produced nothing: the shape every sender of
160
+ * a system email repeats, written once.
161
+ */
162
+ export declare function systemEmailContent(rendered: RenderedSystemEmail | null, fallback: {
163
+ subject: string;
164
+ text: string;
165
+ }): SystemEmailContent;
166
+ /**
167
+ * Renders a key for one recipient in a brand and returns what to send
168
+ * (AGL-3367): {@link renderSystemEmail} and {@link systemEmailContent} in
169
+ * one call, for the senders with one message to build. Never throws; a
170
+ * render that fails sends the fallback copy, which is what it sent before.
171
+ */
172
+ export declare function renderSystemEmailContent(templateKey: string, merge: Record<string, string>, brand: SystemEmailBrand, fallback: {
173
+ subject: string;
174
+ text: string;
175
+ }): Promise<SystemEmailContent>;
176
+ export default renderSystemEmail;