@aglyn/tenant-data-admin 1.0.0-beta.202 → 1.0.0-beta.204
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 +7 -7
- package/src/lib/server/capture-email-check.d.ts +109 -0
- package/src/lib/server/capture-email-check.js +362 -0
- package/src/lib/server/capture-email-check.js.map +1 -0
- package/src/lib/server/card-payment-velocity.d.ts +11 -16
- package/src/lib/server/card-payment-velocity.js +24 -28
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +19 -3
- package/src/lib/server/email-deliverability.js +34 -16
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/held-page-subject.d.ts +93 -0
- package/src/lib/server/held-page-subject.js +206 -0
- package/src/lib/server/held-page-subject.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +42 -1
- package/src/lib/server/host-email-tokens.js +100 -12
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +21 -5
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +16 -5
- package/src/lib/server/hosted-page-review.js +242 -61
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/list-members.js +16 -0
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +22 -0
- package/src/lib/server/outbound-send-review.js +74 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +20 -12
- package/src/lib/server/payment-fraud-signal.js +41 -11
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +37 -13
- package/src/lib/server/payment-risk-record.js +23 -8
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +3 -0
- package/src/lib/server/render-system-email.js +39 -11
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +327 -0
- package/src/lib/server/risk-notice.js +1240 -0
- package/src/lib/server/risk-notice.js.map +1 -0
- package/src/lib/server/sender-readiness.d.ts +36 -0
- package/src/lib/server/sender-readiness.js +99 -0
- package/src/lib/server/sender-readiness.js.map +1 -0
- package/src/lib/server/stripe-transfer-reversal.d.ts +63 -0
- package/src/lib/server/stripe-transfer-reversal.js +177 -0
- package/src/lib/server/stripe-transfer-reversal.js.map +1 -0
- package/src/lib/server/test-firestore.js +1 -0
- package/src/lib/server/test-firestore.js.map +1 -1
- package/src/lib/server/upsert-contact.js +19 -3
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +3 -0
- package/src/tenancy.js.map +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /*==========================================
|
|
18
|
+
* WHICH PAGE WAS HELD, AND WHERE IT LIVES (AGL-3374).
|
|
19
|
+
*
|
|
20
|
+
* `describeHeldPage` turns the screen id the page review holds into a
|
|
21
|
+
* {@link HeldPageSubject} (`@aglyn/shared-util-email/held-page`): the page's
|
|
22
|
+
* kind, name and route, the entry it was showing, the layout or component
|
|
23
|
+
* the flagged content came from, and what visitors see meanwhile.
|
|
24
|
+
*
|
|
25
|
+
* Run once per review row, never per render: the page review calls it only
|
|
26
|
+
* when it files a row that does not describe its page yet, so the reads it
|
|
27
|
+
* may make — the screen document, the collection that designates a
|
|
28
|
+
* template, the site's usage corpus when a layout or component is the
|
|
29
|
+
* source — are paid once for a page that was flagged, and never for a clean
|
|
30
|
+
* one.
|
|
31
|
+
*
|
|
32
|
+
* The composer knows most of this already and hands it over as a
|
|
33
|
+
* {@link HostedPageContext}; the reads here are the fallback for a caller
|
|
34
|
+
* that does not, and for a page kind the caller cannot see (a layout's
|
|
35
|
+
* content is only a layout's to the one who composed it).
|
|
36
|
+
*=========================================*/ import { TENANT_APEX } from "@aglyn/aglyn/app-utils/host-naming";
|
|
37
|
+
import { screenHostedPage } from "@aglyn/shared-util-email/hosted-page-screen";
|
|
38
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
39
|
+
import { readUsageSources, screenIdsUsingComponentDeep, screenIdsUsingLayoutDeep } from "./live-page-usage.js";
|
|
40
|
+
import { canonicalJson } from "./outbound-send-review.js";
|
|
41
|
+
/** The site's public origin, or null when it has no address yet. */ export function hostOrigin(host) {
|
|
42
|
+
const subdomain = typeof (host == null ? void 0 : host['subdomain']) === 'string' ? host['subdomain'] : '';
|
|
43
|
+
const cname = typeof (host == null ? void 0 : host['cname']) === 'string' ? host['cname'] : '';
|
|
44
|
+
if (cname) return `https://${cname}`;
|
|
45
|
+
if (subdomain) return `https://${subdomain}.${TENANT_APEX}`;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A screen's route from the host routing map: `/about`, `/` for the index.
|
|
50
|
+
* Null when the map does not route it — a template, an error screen, an
|
|
51
|
+
* unpublished page. Never the site root for a page the map does not name:
|
|
52
|
+
* that was the row that called a video template "https://aglyn.com/".
|
|
53
|
+
*/ export function routedScreenPath(host, screenId) {
|
|
54
|
+
var _ref;
|
|
55
|
+
const screens = (_ref = host == null ? void 0 : host['screens']) != null ? _ref : {};
|
|
56
|
+
const raw = screens[screenId];
|
|
57
|
+
if (typeof raw !== 'string') return null;
|
|
58
|
+
const clean = raw.replace(/^\/+|\/+$/g, '');
|
|
59
|
+
return clean === 'index' || clean === '' ? '/' : `/${clean}`;
|
|
60
|
+
}
|
|
61
|
+
function text(value) {
|
|
62
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
63
|
+
}
|
|
64
|
+
function hostRef(hostId) {
|
|
65
|
+
return firebaseAdmin.app().firestore().collection('hosts').doc(hostId);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The collection route a template screen renders, by the three fields a
|
|
69
|
+
* collection designates one with (the runtime's own resolution: list through
|
|
70
|
+
* `listScreenId`, entry through `entryScreenId` then the legacy
|
|
71
|
+
* `templateScreenId`). Null when no collection names it.
|
|
72
|
+
*/ async function collectionTemplateOf(hostId, screenId) {
|
|
73
|
+
const collections = hostRef(hostId).collection('collections');
|
|
74
|
+
for (const [field, role] of [
|
|
75
|
+
[
|
|
76
|
+
'entryScreenId',
|
|
77
|
+
'entry'
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
'templateScreenId',
|
|
81
|
+
'entry'
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
'listScreenId',
|
|
85
|
+
'list'
|
|
86
|
+
]
|
|
87
|
+
]){
|
|
88
|
+
const found = await collections.where(field, '==', screenId).limit(1).get();
|
|
89
|
+
const doc = found.docs[0];
|
|
90
|
+
const slug = text(doc == null ? void 0 : doc.get('slug'));
|
|
91
|
+
if (doc && slug) return {
|
|
92
|
+
role,
|
|
93
|
+
slug,
|
|
94
|
+
name: text(doc.get('displayName'))
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
function signalKeys(signals) {
|
|
100
|
+
return new Set(signals.map((signal)=>canonicalJson(signal)));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Which part of the page carries the flagged content. The page's own nodes
|
|
104
|
+
* first — when they carry any of it, the page is the source and nothing else
|
|
105
|
+
* is named; otherwise the first layout, then the first component, that does.
|
|
106
|
+
*/ export function attributeHeldSignals(parts, signals, identity) {
|
|
107
|
+
var _ref, _parts_find;
|
|
108
|
+
const held = signalKeys(signals);
|
|
109
|
+
const carries = (part)=>screenHostedPage(_extends({
|
|
110
|
+
nodes: part.nodes
|
|
111
|
+
}, identity)).signals.some((signal)=>held.has(canonicalJson(signal)));
|
|
112
|
+
const own = parts.filter((part)=>part.type === 'screen');
|
|
113
|
+
if (own.some(carries)) return null;
|
|
114
|
+
return (_ref = (_parts_find = parts.find((part)=>part.type === 'layout' && carries(part))) != null ? _parts_find : parts.find((part)=>part.type === 'component' && carries(part))) != null ? _ref : null;
|
|
115
|
+
}
|
|
116
|
+
/** How many live pages render a layout or component, or null past the corpus bound. */ async function pagesUsing(hostId, part) {
|
|
117
|
+
try {
|
|
118
|
+
const { candidates, truncated } = await readUsageSources(hostRef(hostId), 500);
|
|
119
|
+
if (truncated) return null;
|
|
120
|
+
const ids = part.type === 'layout' ? screenIdsUsingLayoutDeep(part.id, candidates.screens, candidates.layouts) : screenIdsUsingComponentDeep(part.id, candidates);
|
|
121
|
+
return ids.length;
|
|
122
|
+
} catch (unused) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function sourceOf(hostId, part) {
|
|
127
|
+
let name = text(part.name);
|
|
128
|
+
if (!name) {
|
|
129
|
+
var _text;
|
|
130
|
+
const doc = await hostRef(hostId).collection(part.type === 'layout' ? 'layouts' : 'components').doc(part.id).get().catch(()=>null);
|
|
131
|
+
name = (_text = text(doc == null ? void 0 : doc.get('displayName'))) != null ? _text : text(doc == null ? void 0 : doc.get('name'));
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
type: part.type === 'layout' ? 'layout' : 'component',
|
|
135
|
+
id: part.id,
|
|
136
|
+
name,
|
|
137
|
+
pagesUsing: await pagesUsing(hostId, part)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Describe the reviewed page. Best effort throughout: a read that fails
|
|
142
|
+
* leaves its field empty, and the subject still names what it can — it is a
|
|
143
|
+
* label on a decision already made, never a reason to change it.
|
|
144
|
+
*/ export async function describeHeldPage(input) {
|
|
145
|
+
var _input_page, _page_screen, _text;
|
|
146
|
+
const page = (_input_page = input.page) != null ? _input_page : {};
|
|
147
|
+
let screen = (_page_screen = page.screen) != null ? _page_screen : null;
|
|
148
|
+
if (!screen) {
|
|
149
|
+
screen = await hostRef(input.hostId).collection('screens').doc(input.screenId).get().then((doc)=>{
|
|
150
|
+
var _doc_data;
|
|
151
|
+
return doc.exists ? (_doc_data = doc.data()) != null ? _doc_data : null : null;
|
|
152
|
+
}).catch(()=>null);
|
|
153
|
+
}
|
|
154
|
+
const name = (_text = text(screen == null ? void 0 : screen.displayName)) != null ? _text : text(screen == null ? void 0 : screen.name);
|
|
155
|
+
const origin = hostOrigin(input.host);
|
|
156
|
+
const routed = routedScreenPath(input.host, input.screenId);
|
|
157
|
+
let kind = 'screen';
|
|
158
|
+
let route = routed;
|
|
159
|
+
let collectionName = null;
|
|
160
|
+
let entryPath = null;
|
|
161
|
+
let fallback = 'not-found';
|
|
162
|
+
if (page.template) {
|
|
163
|
+
var _page_template_fallback;
|
|
164
|
+
kind = page.template.role === 'list' ? 'list-template' : 'entry-template';
|
|
165
|
+
route = page.template.route;
|
|
166
|
+
collectionName = text(page.template.collectionName);
|
|
167
|
+
entryPath = text(page.template.entryPath);
|
|
168
|
+
fallback = (_page_template_fallback = page.template.fallback) != null ? _page_template_fallback : 'not-found';
|
|
169
|
+
} else if (!page.variant && !routed) {
|
|
170
|
+
// Not routed and not handed over: a template the caller did not
|
|
171
|
+
// describe, or an error screen.
|
|
172
|
+
const template = await collectionTemplateOf(input.hostId, input.screenId).catch(()=>null);
|
|
173
|
+
if (template) {
|
|
174
|
+
kind = template.role === 'list' ? 'list-template' : 'entry-template';
|
|
175
|
+
route = template.role === 'list' ? `/${template.slug}` : `/${template.slug}/:slug`;
|
|
176
|
+
collectionName = template.name;
|
|
177
|
+
fallback = 'built-in-design';
|
|
178
|
+
} else if ((screen == null ? void 0 : screen.kind) === 'error') {
|
|
179
|
+
kind = 'error-screen';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (page.variant) kind = 'variant';
|
|
183
|
+
let source = null;
|
|
184
|
+
if (page.parts) {
|
|
185
|
+
const parts = await page.parts().catch(()=>[]);
|
|
186
|
+
const part = attributeHeldSignals(parts, input.signals, input.identity);
|
|
187
|
+
if (part) source = await sourceOf(input.hostId, part);
|
|
188
|
+
}
|
|
189
|
+
const visitorView = input.live ? 'live' : kind === 'variant' ? 'published-version' : input.previousVersionId && input.previousVersionId !== input.versionId ? 'previous-version' : kind === 'list-template' || kind === 'entry-template' ? fallback : 'not-found';
|
|
190
|
+
const addressable = kind === 'screen' || kind === 'list-template' || kind === 'variant';
|
|
191
|
+
return {
|
|
192
|
+
kind,
|
|
193
|
+
screenId: input.screenId,
|
|
194
|
+
versionId: input.versionId,
|
|
195
|
+
name,
|
|
196
|
+
route,
|
|
197
|
+
url: origin && route && addressable ? `${origin}${route === '/' ? '/' : route}` : null,
|
|
198
|
+
entryUrl: origin && entryPath ? `${origin}${entryPath}` : null,
|
|
199
|
+
collectionName,
|
|
200
|
+
variantName: page.variant ? text(page.variant.name) : null,
|
|
201
|
+
source,
|
|
202
|
+
visitorView
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
//# sourceMappingURL=held-page-subject.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/held-page-subject.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 * WHICH PAGE WAS HELD, AND WHERE IT LIVES (AGL-3374).\n *\n * `describeHeldPage` turns the screen id the page review holds into a\n * {@link HeldPageSubject} (`@aglyn/shared-util-email/held-page`): the page's\n * kind, name and route, the entry it was showing, the layout or component\n * the flagged content came from, and what visitors see meanwhile.\n *\n * Run once per review row, never per render: the page review calls it only\n * when it files a row that does not describe its page yet, so the reads it\n * may make — the screen document, the collection that designates a\n * template, the site's usage corpus when a layout or component is the\n * source — are paid once for a page that was flagged, and never for a clean\n * one.\n *\n * The composer knows most of this already and hands it over as a\n * {@link HostedPageContext}; the reads here are the fallback for a caller\n * that does not, and for a page kind the caller cannot see (a layout's\n * content is only a layout's to the one who composed it).\n *=========================================*/\n\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport type {\n HeldPageKind,\n HeldPageSource,\n HeldPageSubject,\n HeldPageVisitorView,\n} from '@aglyn/shared-util-email/held-page'\nimport { screenHostedPage } from '@aglyn/shared-util-email/hosted-page-screen'\nimport type { PhishingScreenSignal } from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport firebaseAdmin from './firebase-admin'\nimport {\n readUsageSources,\n screenIdsUsingComponentDeep,\n screenIdsUsingLayoutDeep,\n} from './live-page-usage'\nimport { canonicalJson } from './outbound-send-review'\n\n/** One document that composes into the page, for attributing a signal. */\nexport interface HostedPagePart {\n type: 'screen' | 'layout' | 'component'\n id: string\n name?: string | null\n nodes: unknown\n}\n\n/** What the composer knows about the page it is reviewing. */\nexport interface HostedPageContext {\n /** The screen document it composed (its name and `kind`). */\n screen?: { displayName?: unknown; name?: unknown; kind?: unknown } | null\n /**\n * A template composed against a routed listing or entry: its route pattern\n * (`/videos`, `/videos/:slug`), what it renders, the address being shown,\n * and what a visitor gets when the template has no clean version.\n */\n template?: {\n role: 'list' | 'entry'\n route: string\n collectionName?: string | null\n entryPath?: string | null\n fallback?: 'built-in-design' | 'not-found'\n } | null\n /** An experiment variant's version of the page. */\n variant?: { experimentId: string; variantId: string; name?: string | null } | null\n /** The page's own nodes and those of each layout and component composed into it. */\n parts?: () => Promise<HostedPagePart[]>\n}\n\n/** The site's public origin, or null when it has no address yet. */\nexport function hostOrigin(host: Record<string, unknown> | null | undefined): string | null {\n const subdomain = typeof host?.['subdomain'] === 'string' ? (host['subdomain'] as string) : ''\n const cname = typeof host?.['cname'] === 'string' ? (host['cname'] as string) : ''\n if (cname) return `https://${cname}`\n if (subdomain) return `https://${subdomain}.${TENANT_APEX}`\n return null\n}\n\n/**\n * A screen's route from the host routing map: `/about`, `/` for the index.\n * Null when the map does not route it — a template, an error screen, an\n * unpublished page. Never the site root for a page the map does not name:\n * that was the row that called a video template \"https://aglyn.com/\".\n */\nexport function routedScreenPath(\n host: Record<string, unknown> | null | undefined,\n screenId: string,\n): string | null {\n const screens = (host?.['screens'] ?? {}) as Record<string, unknown>\n const raw = screens[screenId]\n if (typeof raw !== 'string') return null\n const clean = raw.replace(/^\\/+|\\/+$/g, '')\n return clean === 'index' || clean === '' ? '/' : `/${clean}`\n}\n\nfunction text(value: unknown): string | null {\n return typeof value === 'string' && value.trim() ? value.trim() : null\n}\n\nfunction hostRef(hostId: string) {\n return firebaseAdmin.app().firestore().collection('hosts').doc(hostId)\n}\n\n/**\n * The collection route a template screen renders, by the three fields a\n * collection designates one with (the runtime's own resolution: list through\n * `listScreenId`, entry through `entryScreenId` then the legacy\n * `templateScreenId`). Null when no collection names it.\n */\nasync function collectionTemplateOf(\n hostId: string,\n screenId: string,\n): Promise<{ role: 'list' | 'entry'; slug: string; name: string | null } | null> {\n const collections = hostRef(hostId).collection('collections')\n for (const [field, role] of [\n ['entryScreenId', 'entry'],\n ['templateScreenId', 'entry'],\n ['listScreenId', 'list'],\n ] as const) {\n const found = await collections.where(field, '==', screenId).limit(1).get()\n const doc = found.docs[0]\n const slug = text(doc?.get('slug'))\n if (doc && slug) return { role, slug, name: text(doc.get('displayName')) }\n }\n return null\n}\n\nfunction signalKeys(signals: readonly PhishingScreenSignal[]): Set<string> {\n return new Set(signals.map((signal) => canonicalJson(signal)))\n}\n\n/**\n * Which part of the page carries the flagged content. The page's own nodes\n * first — when they carry any of it, the page is the source and nothing else\n * is named; otherwise the first layout, then the first component, that does.\n */\nexport function attributeHeldSignals(\n parts: readonly HostedPagePart[],\n signals: readonly PhishingScreenSignal[],\n identity: { ownNames?: readonly (string | null | undefined)[]; ownDomains?: readonly (string | null | undefined)[] },\n): HostedPagePart | null {\n const held = signalKeys(signals)\n const carries = (part: HostedPagePart) =>\n screenHostedPage({ nodes: part.nodes, ...identity }).signals.some((signal) =>\n held.has(canonicalJson(signal)),\n )\n const own = parts.filter((part) => part.type === 'screen')\n if (own.some(carries)) return null\n return (\n parts.find((part) => part.type === 'layout' && carries(part)) ??\n parts.find((part) => part.type === 'component' && carries(part)) ??\n null\n )\n}\n\n/** How many live pages render a layout or component, or null past the corpus bound. */\nasync function pagesUsing(hostId: string, part: HostedPagePart): Promise<number | null> {\n try {\n const { candidates, truncated } = await readUsageSources(hostRef(hostId), 500)\n if (truncated) return null\n const ids =\n part.type === 'layout'\n ? screenIdsUsingLayoutDeep(part.id, candidates.screens, candidates.layouts)\n : screenIdsUsingComponentDeep(part.id, candidates)\n return ids.length\n } catch {\n return null\n }\n}\n\nasync function sourceOf(\n hostId: string,\n part: HostedPagePart,\n): Promise<HeldPageSource> {\n let name = text(part.name)\n if (!name) {\n const doc = await hostRef(hostId)\n .collection(part.type === 'layout' ? 'layouts' : 'components')\n .doc(part.id)\n .get()\n .catch(() => null)\n name = text(doc?.get('displayName')) ?? text(doc?.get('name'))\n }\n return {\n type: part.type === 'layout' ? 'layout' : 'component',\n id: part.id,\n name,\n pagesUsing: await pagesUsing(hostId, part),\n }\n}\n\n/**\n * Describe the reviewed page. Best effort throughout: a read that fails\n * leaves its field empty, and the subject still names what it can — it is a\n * label on a decision already made, never a reason to change it.\n */\nexport async function describeHeldPage(input: {\n hostId: string\n screenId: string\n versionId: string\n host: Record<string, unknown> | null\n page?: HostedPageContext | null\n signals: readonly PhishingScreenSignal[]\n identity: { ownNames?: readonly (string | null | undefined)[]; ownDomains?: readonly (string | null | undefined)[] }\n /** The page was already live and keeps serving (the flag path). */\n live: boolean\n /** The last version the page served clean, if any. */\n previousVersionId: string | null\n}): Promise<HeldPageSubject> {\n const page = input.page ?? {}\n let screen = page.screen ?? null\n if (!screen) {\n screen = await hostRef(input.hostId)\n .collection('screens')\n .doc(input.screenId)\n .get()\n .then((doc) => (doc.exists ? (doc.data() ?? null) : null))\n .catch(() => null)\n }\n const name = text(screen?.displayName) ?? text(screen?.name)\n const origin = hostOrigin(input.host)\n const routed = routedScreenPath(input.host, input.screenId)\n\n let kind: HeldPageKind = 'screen'\n let route: string | null = routed\n let collectionName: string | null = null\n let entryPath: string | null = null\n let fallback: 'built-in-design' | 'not-found' = 'not-found'\n\n if (page.template) {\n kind = page.template.role === 'list' ? 'list-template' : 'entry-template'\n route = page.template.route\n collectionName = text(page.template.collectionName)\n entryPath = text(page.template.entryPath)\n fallback = page.template.fallback ?? 'not-found'\n } else if (!page.variant && !routed) {\n // Not routed and not handed over: a template the caller did not\n // describe, or an error screen.\n const template = await collectionTemplateOf(input.hostId, input.screenId).catch(() => null)\n if (template) {\n kind = template.role === 'list' ? 'list-template' : 'entry-template'\n route = template.role === 'list' ? `/${template.slug}` : `/${template.slug}/:slug`\n collectionName = template.name\n fallback = 'built-in-design'\n } else if (screen?.kind === 'error') {\n kind = 'error-screen'\n }\n }\n if (page.variant) kind = 'variant'\n\n let source: HeldPageSource | null = null\n if (page.parts) {\n const parts = await page.parts().catch(() => [] as HostedPagePart[])\n const part = attributeHeldSignals(parts, input.signals, input.identity)\n if (part) source = await sourceOf(input.hostId, part)\n }\n\n const visitorView: HeldPageVisitorView = input.live\n ? 'live'\n : kind === 'variant'\n ? 'published-version'\n : input.previousVersionId && input.previousVersionId !== input.versionId\n ? 'previous-version'\n : kind === 'list-template' || kind === 'entry-template'\n ? fallback\n : 'not-found'\n\n const addressable = kind === 'screen' || kind === 'list-template' || kind === 'variant'\n return {\n kind,\n screenId: input.screenId,\n versionId: input.versionId,\n name,\n route,\n url: origin && route && addressable ? `${origin}${route === '/' ? '/' : route}` : null,\n entryUrl: origin && entryPath ? `${origin}${entryPath}` : null,\n collectionName,\n variantName: page.variant ? text(page.variant.name) : null,\n source,\n visitorView,\n }\n}\n"],"names":["TENANT_APEX","screenHostedPage","firebaseAdmin","readUsageSources","screenIdsUsingComponentDeep","screenIdsUsingLayoutDeep","canonicalJson","hostOrigin","host","subdomain","cname","routedScreenPath","screenId","screens","raw","clean","replace","text","value","trim","hostRef","hostId","app","firestore","collection","doc","collectionTemplateOf","collections","field","role","found","where","limit","get","docs","slug","name","signalKeys","signals","Set","map","signal","attributeHeldSignals","parts","identity","held","carries","part","nodes","some","has","own","filter","type","find","pagesUsing","candidates","truncated","ids","id","layouts","length","sourceOf","catch","describeHeldPage","input","page","screen","then","exists","data","displayName","origin","routed","kind","route","collectionName","entryPath","fallback","template","variant","source","visitorView","live","previousVersionId","versionId","addressable","url","entryUrl","variantName"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type RenderedHostEmail } from '@aglyn/shared-util-email';
|
|
17
|
+
import { type EmailChrome, type EmailTheme, type LoadedHostEmail, type RenderedHostEmail } from '@aglyn/shared-util-email';
|
|
18
18
|
/**
|
|
19
19
|
* `renderHostEmail` with `host.*` tokens resolved (AGL-1022).
|
|
20
20
|
*
|
|
@@ -44,4 +44,45 @@ import { type RenderedHostEmail } from '@aglyn/shared-util-email';
|
|
|
44
44
|
export declare function renderHostEmailWithTokens(firestore: FirebaseFirestore.Firestore, hostId: string, templateKey: string, merge?: Record<string, string>, options?: {
|
|
45
45
|
origin?: string;
|
|
46
46
|
}): Promise<RenderedHostEmail | null>;
|
|
47
|
+
/** A site email loaded once for a batch, with the site's tokens beside it. */
|
|
48
|
+
export interface LoadedHostEmailWithTokens {
|
|
49
|
+
loaded: LoadedHostEmail;
|
|
50
|
+
hostMerge: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in
|
|
54
|
+
* copy in the site's header and footer, read ONCE, with the site's `host.*`
|
|
55
|
+
* tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only
|
|
56
|
+
* for a key with neither, so the batch keeps its own copy.
|
|
57
|
+
*/
|
|
58
|
+
export declare function loadHostEmailWithTokens(firestore: FirebaseFirestore.Firestore, hostId: string, templateKey: string, options?: {
|
|
59
|
+
origin?: string;
|
|
60
|
+
}): Promise<LoadedHostEmailWithTokens | null>;
|
|
61
|
+
/** One recipient's copy of a batch-loaded site email; no reads. */
|
|
62
|
+
export declare function renderLoadedHostEmailWithTokens(bundle: LoadedHostEmailWithTokens, merge?: Record<string, string>): RenderedHostEmail | null;
|
|
63
|
+
/**
|
|
64
|
+
* The site's header and footer for one kind of email, its theme, and the
|
|
65
|
+
* origin and host a stored logo reference resolves against (AGL-3370): for a sender
|
|
66
|
+
* that renders per recipient itself, such as a typed campaign.
|
|
67
|
+
*/
|
|
68
|
+
export declare function siteEmailFrame(firestore: FirebaseFirestore.Firestore, hostId: string, templateKey: string): Promise<{
|
|
69
|
+
chrome: EmailChrome;
|
|
70
|
+
theme: EmailTheme;
|
|
71
|
+
mediaOrigin?: string;
|
|
72
|
+
mediaHostId: string;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* A text-only site email in the site's header and footer (AGL-3370): a
|
|
76
|
+
* workflow's email step, or a message a site sends in words nobody designed.
|
|
77
|
+
* The text arrives resolved; the footer's reason is the kind's, by key.
|
|
78
|
+
*/
|
|
79
|
+
export declare function renderSiteTextEmail(firestore: FirebaseFirestore.Firestore, hostId: string, templateKey: string, message: {
|
|
80
|
+
subject: string;
|
|
81
|
+
text: string;
|
|
82
|
+
preheader?: string;
|
|
83
|
+
}): Promise<{
|
|
84
|
+
subject: string;
|
|
85
|
+
html: string;
|
|
86
|
+
text: string;
|
|
87
|
+
}>;
|
|
47
88
|
export default renderHostEmailWithTokens;
|
|
@@ -17,7 +17,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
17
17
|
*/ import { sanitizeAuthorHtml } from "@aglyn/aglyn/app-utils/author-html";
|
|
18
18
|
import { hostTokenMerge } from "@aglyn/aglyn/app-utils/host-tokens";
|
|
19
19
|
import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
|
|
20
|
-
import {
|
|
20
|
+
import { buildSiteEmailChrome } from "@aglyn/aglyn/app-utils/site-email-chrome";
|
|
21
|
+
import { buildEmailPalette, emailPaletteBaseForHost, getTenantEmail, hostEmailOrigin, loadHostEmail, renderFramedTextEmail, renderHostEmail, renderLoadedHostEmail } from "@aglyn/shared-util-email";
|
|
21
22
|
/**
|
|
22
23
|
* `renderHostEmail` with `host.*` tokens resolved (AGL-1022).
|
|
23
24
|
*
|
|
@@ -44,16 +45,7 @@ import { renderHostEmail } from "@aglyn/shared-util-email";
|
|
|
44
45
|
* Costs one host read per send. Emails are not a hot path, and the read buys
|
|
45
46
|
* the guarantee that a template's branding is right by construction.
|
|
46
47
|
*/ export async function renderHostEmailWithTokens(firestore, hostId, templateKey, merge = {}, options = {}) {
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
const snapshot = await firestore.collection('hosts').doc(hostId).get();
|
|
50
|
-
host = snapshot.exists ? snapshot.data() : null;
|
|
51
|
-
} catch (unused) {
|
|
52
|
-
// A failed read must not stop the send. Every token then resolves to its
|
|
53
|
-
// empty behaviour, which is the same outcome as a site that set nothing —
|
|
54
|
-
// an email with a gap where the address would be, rather than no email.
|
|
55
|
-
host = null;
|
|
56
|
-
}
|
|
48
|
+
const host = await readHostTokenSource(firestore, hostId);
|
|
57
49
|
return renderHostEmail(firestore, hostId, templateKey, // Host tokens first: an explicit caller value WINS. A sender passing
|
|
58
50
|
// `host.businessName` is naming a specific thing on purpose (a white-label
|
|
59
51
|
// send, a preview with sample data), and this is not the place to overrule
|
|
@@ -71,11 +63,107 @@ import { renderHostEmail } from "@aglyn/shared-util-email";
|
|
|
71
63
|
// having to know it exists. Style overrides do not survive into mail —
|
|
72
64
|
// they land in `sx`, which `renderEmailHtml` never reads — but property
|
|
73
65
|
// values and attribute overrides do.
|
|
66
|
+
//
|
|
67
|
+
// And the site's header and footer (AGL-3370), which the built-in copy is
|
|
68
|
+
// drawn inside; a design the owner published goes out as they built it.
|
|
74
69
|
_extends({}, options, {
|
|
75
70
|
sanitize: sanitizeAuthorHtml,
|
|
76
|
-
compose: composeHostComponentNodes
|
|
71
|
+
compose: composeHostComponentNodes,
|
|
72
|
+
chrome: siteChromeFor(host, templateKey),
|
|
73
|
+
theme: siteThemeFor(host)
|
|
77
74
|
}));
|
|
78
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The site's host document as the token and chrome readers see it, or null.
|
|
78
|
+
*
|
|
79
|
+
* A failed read must not stop the send. Every token then resolves to its
|
|
80
|
+
* empty behaviour, which is the same outcome as a site that set nothing —
|
|
81
|
+
* an email with a gap where the address would be, rather than no email.
|
|
82
|
+
*/ async function readHostTokenSource(firestore, hostId) {
|
|
83
|
+
try {
|
|
84
|
+
const snapshot = await firestore.collection('hosts').doc(hostId).get();
|
|
85
|
+
return snapshot.exists ? snapshot.data() : null;
|
|
86
|
+
} catch (unused) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The site's theme as its email draws it (AGL-3370): the palette the
|
|
92
|
+
* published site renders with, light scheme, since an inbox has one, and its
|
|
93
|
+
* corner radius for buttons. A site with no theme of its own resolves the
|
|
94
|
+
* same base the site itself does.
|
|
95
|
+
*/ function siteThemeFor(host) {
|
|
96
|
+
var _ref;
|
|
97
|
+
var _host_theme_shape, _host_theme, _host_theme_colorSchemes, _host_theme1;
|
|
98
|
+
const radius = host == null ? void 0 : (_host_theme = host.theme) == null ? void 0 : (_host_theme_shape = _host_theme.shape) == null ? void 0 : _host_theme_shape.borderRadius;
|
|
99
|
+
return _extends({
|
|
100
|
+
palette: buildEmailPalette({
|
|
101
|
+
base: emailPaletteBaseForHost(host),
|
|
102
|
+
colors: (_ref = host == null ? void 0 : (_host_theme1 = host.theme) == null ? void 0 : (_host_theme_colorSchemes = _host_theme1.colorSchemes) == null ? void 0 : _host_theme_colorSchemes.light) != null ? _ref : null
|
|
103
|
+
})
|
|
104
|
+
}, typeof radius === 'number' ? {
|
|
105
|
+
buttonRadius: radius
|
|
106
|
+
} : {});
|
|
107
|
+
}
|
|
108
|
+
/** The site's chrome for one kind of email, with that kind's footer reason. */ function siteChromeFor(host, templateKey) {
|
|
109
|
+
var _getTenantEmail;
|
|
110
|
+
return buildSiteEmailChrome({
|
|
111
|
+
host,
|
|
112
|
+
reason: (_getTenantEmail = getTenantEmail(templateKey)) == null ? void 0 : _getTenantEmail.footerReason
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in
|
|
117
|
+
* copy in the site's header and footer, read ONCE, with the site's `host.*`
|
|
118
|
+
* tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only
|
|
119
|
+
* for a key with neither, so the batch keeps its own copy.
|
|
120
|
+
*/ export async function loadHostEmailWithTokens(firestore, hostId, templateKey, options = {}) {
|
|
121
|
+
const host = await readHostTokenSource(firestore, hostId);
|
|
122
|
+
const loaded = await loadHostEmail(firestore, hostId, templateKey, _extends({}, options, {
|
|
123
|
+
compose: composeHostComponentNodes,
|
|
124
|
+
chrome: siteChromeFor(host, templateKey),
|
|
125
|
+
theme: siteThemeFor(host)
|
|
126
|
+
}));
|
|
127
|
+
return loaded ? {
|
|
128
|
+
loaded,
|
|
129
|
+
hostMerge: hostTokenMerge(host)
|
|
130
|
+
} : null;
|
|
131
|
+
}
|
|
132
|
+
/** One recipient's copy of a batch-loaded site email; no reads. */ export function renderLoadedHostEmailWithTokens(bundle, merge = {}) {
|
|
133
|
+
return renderLoadedHostEmail(bundle.loaded, _extends({}, bundle.hostMerge, merge), sanitizeAuthorHtml);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The site's header and footer for one kind of email, its theme, and the
|
|
137
|
+
* origin and host a stored logo reference resolves against (AGL-3370): for a sender
|
|
138
|
+
* that renders per recipient itself, such as a typed campaign.
|
|
139
|
+
*/ export async function siteEmailFrame(firestore, hostId, templateKey) {
|
|
140
|
+
const host = await readHostTokenSource(firestore, hostId);
|
|
141
|
+
const origin = host ? hostEmailOrigin({
|
|
142
|
+
cname: host.cname,
|
|
143
|
+
subdomain: host.subdomain
|
|
144
|
+
}) : undefined;
|
|
145
|
+
return _extends({
|
|
146
|
+
chrome: siteChromeFor(host, templateKey),
|
|
147
|
+
theme: siteThemeFor(host)
|
|
148
|
+
}, origin ? {
|
|
149
|
+
mediaOrigin: origin
|
|
150
|
+
} : {}, {
|
|
151
|
+
mediaHostId: hostId
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A text-only site email in the site's header and footer (AGL-3370): a
|
|
156
|
+
* workflow's email step, or a message a site sends in words nobody designed.
|
|
157
|
+
* The text arrives resolved; the footer's reason is the kind's, by key.
|
|
158
|
+
*/ export async function renderSiteTextEmail(firestore, hostId, templateKey, message) {
|
|
159
|
+
const frame = await siteEmailFrame(firestore, hostId, templateKey);
|
|
160
|
+
const rendered = renderFramedTextEmail(_extends({}, message, frame));
|
|
161
|
+
return {
|
|
162
|
+
subject: message.subject,
|
|
163
|
+
html: rendered.html,
|
|
164
|
+
text: rendered.text
|
|
165
|
+
};
|
|
166
|
+
}
|
|
79
167
|
export default renderHostEmailWithTokens;
|
|
80
168
|
|
|
81
169
|
//# sourceMappingURL=host-email-tokens.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport {\n renderHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n let host: HostTokenSource | null\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n host = snapshot.exists ? (snapshot.data() as HostTokenSource) : null\n } catch {\n // A failed read must not stop the send. Every token then resolves to its\n // empty behaviour, which is the same outcome as a site that set nothing —\n // an email with a gap where the address would be, rather than no email.\n host = null\n }\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n },\n )\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","renderHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","snapshot","collection","doc","get","exists","data","sanitize","compose"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,qCAAoC;AACvE,SACEC,cAAc,QAET,qCAAoC;AAC3C,SAASC,yBAAyB,QAAQ,oDAAmD;AAC7F,SACEC,eAAe,QAEV,2BAA0B;AAGjC;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,eAAeC,0BACpBC,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnBC,QAAgC,CAAC,CAAC,EAClCC,UAA+B,CAAC,CAAC;IAEjC,IAAIC;IACJ,IAAI;QACF,MAAMC,WAAW,MAAMN,UAAUO,UAAU,CAAC,SAASC,GAAG,CAACP,QAAQQ,GAAG;QACpEJ,OAAOC,SAASI,MAAM,GAAIJ,SAASK,IAAI,KAAyB;IAClE,EAAE,eAAM;QACN,yEAAyE;QACzE,0EAA0E;QAC1E,wEAAwE;QACxEN,OAAO;IACT;IACA,OAAOP,gBACLE,WACAC,QACAC,aACA,qEAAqE;IACrE,2EAA2E;IAC3E,2EAA2E;IAC3E,MAAM;IACN,aAAKN,eAAeS,OAAUF,QAC9B,yEAAyE;IACzE,wEAAwE;IACxE,wEAAwE;IACxE,sEAAsE;IACtE,wEAAwE;IACxE,iBAAiB;IACjB,EAAE;IACF,yEAAyE;IACzE,yEAAyE;IACzE,0EAA0E;IAC1E,uEAAuE;IACvE,wEAAwE;IACxE,qCAAqC;IACrC,aACKC;QACHQ,UAAUjB;QACVkB,SAAShB;;AAGf;AAEA,eAAeE,0BAAyB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport { buildSiteEmailChrome } from '@aglyn/aglyn/app-utils/site-email-chrome'\nimport {\n buildEmailPalette,\n emailPaletteBaseForHost,\n getTenantEmail,\n hostEmailOrigin,\n loadHostEmail,\n renderFramedTextEmail,\n renderHostEmail,\n renderLoadedHostEmail,\n type EmailChrome,\n type EmailTheme,\n type LoadedHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n const host = await readHostTokenSource(firestore, hostId)\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n //\n // And the site's header and footer (AGL-3370), which the built-in copy is\n // drawn inside; a design the owner published goes out as they built it.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n },\n )\n}\n\n/**\n * The site's host document as the token and chrome readers see it, or null.\n *\n * A failed read must not stop the send. Every token then resolves to its\n * empty behaviour, which is the same outcome as a site that set nothing —\n * an email with a gap where the address would be, rather than no email.\n */\nasync function readHostTokenSource(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<SiteEmailHost | null> {\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n return snapshot.exists ? (snapshot.data() as SiteEmailHost) : null\n } catch {\n return null\n }\n}\n\n/** What a site email reads off the host document: its tokens and its theme. */\ntype SiteEmailHost = HostTokenSource & {\n theme?: {\n colorSchemes?: { light?: Record<string, unknown> | null }\n shape?: { borderRadius?: unknown }\n } | null\n}\n\n/**\n * The site's theme as its email draws it (AGL-3370): the palette the\n * published site renders with, light scheme, since an inbox has one, and its\n * corner radius for buttons. A site with no theme of its own resolves the\n * same base the site itself does.\n */\nfunction siteThemeFor(host: SiteEmailHost | null): EmailTheme {\n const radius = host?.theme?.shape?.borderRadius\n return {\n palette: buildEmailPalette({\n base: emailPaletteBaseForHost(host),\n colors: host?.theme?.colorSchemes?.light ?? null,\n }),\n ...(typeof radius === 'number' ? { buttonRadius: radius } : {}),\n }\n}\n\n/** The site's chrome for one kind of email, with that kind's footer reason. */\nfunction siteChromeFor(\n host: HostTokenSource | null,\n templateKey: string,\n): EmailChrome {\n return buildSiteEmailChrome({\n host,\n reason: getTenantEmail(templateKey)?.footerReason,\n })\n}\n\n/** A site email loaded once for a batch, with the site's tokens beside it. */\nexport interface LoadedHostEmailWithTokens {\n loaded: LoadedHostEmail\n hostMerge: Record<string, string>\n}\n\n/**\n * {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in\n * copy in the site's header and footer, read ONCE, with the site's `host.*`\n * tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only\n * for a key with neither, so the batch keeps its own copy.\n */\nexport async function loadHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmailWithTokens | null> {\n const host = await readHostTokenSource(firestore, hostId)\n const loaded = await loadHostEmail(firestore as never, hostId, templateKey, {\n ...options,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n })\n return loaded ? { loaded, hostMerge: hostTokenMerge(host) } : null\n}\n\n/** One recipient's copy of a batch-loaded site email; no reads. */\nexport function renderLoadedHostEmailWithTokens(\n bundle: LoadedHostEmailWithTokens,\n merge: Record<string, string> = {},\n): RenderedHostEmail | null {\n return renderLoadedHostEmail(\n bundle.loaded,\n { ...bundle.hostMerge, ...merge },\n sanitizeAuthorHtml,\n )\n}\n\n/**\n * The site's header and footer for one kind of email, its theme, and the\n * origin and host a stored logo reference resolves against (AGL-3370): for a sender\n * that renders per recipient itself, such as a typed campaign.\n */\nexport async function siteEmailFrame(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n): Promise<{\n chrome: EmailChrome\n theme: EmailTheme\n mediaOrigin?: string\n mediaHostId: string\n}> {\n const host = await readHostTokenSource(firestore, hostId)\n const origin = host\n ? hostEmailOrigin({ cname: host.cname, subdomain: host.subdomain })\n : undefined\n return {\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n ...(origin ? { mediaOrigin: origin } : {}),\n mediaHostId: hostId,\n }\n}\n\n/**\n * A text-only site email in the site's header and footer (AGL-3370): a\n * workflow's email step, or a message a site sends in words nobody designed.\n * The text arrives resolved; the footer's reason is the kind's, by key.\n */\nexport async function renderSiteTextEmail(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n message: { subject: string; text: string; preheader?: string },\n): Promise<{ subject: string; html: string; text: string }> {\n const frame = await siteEmailFrame(firestore, hostId, templateKey)\n const rendered = renderFramedTextEmail({ ...message, ...frame })\n return { subject: message.subject, html: rendered.html, text: rendered.text }\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","buildSiteEmailChrome","buildEmailPalette","emailPaletteBaseForHost","getTenantEmail","hostEmailOrigin","loadHostEmail","renderFramedTextEmail","renderHostEmail","renderLoadedHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","readHostTokenSource","sanitize","compose","chrome","siteChromeFor","theme","siteThemeFor","snapshot","collection","doc","get","exists","data","radius","shape","borderRadius","palette","base","colors","colorSchemes","light","buttonRadius","reason","footerReason","loadHostEmailWithTokens","loaded","hostMerge","renderLoadedHostEmailWithTokens","bundle","siteEmailFrame","origin","cname","subdomain","undefined","mediaOrigin","mediaHostId","renderSiteTextEmail","message","frame","rendered","subject","html","text"],"mappings":"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"}
|
|
@@ -26,6 +26,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
26
26
|
import { FieldValue } from "firebase-admin/firestore";
|
|
27
27
|
import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
|
|
28
28
|
import { notifyHostManagers } from "./notifications.js";
|
|
29
|
+
import { scheduleCapturedEmailCheck } from "./capture-email-check.js";
|
|
29
30
|
import firebaseAdmin from "./firebase-admin.js";
|
|
30
31
|
import { consentGroupForSite, orgDataCollectionForHost, scopedToHost } from "./organizations.js";
|
|
31
32
|
import { crmLeadListFields, crmReadTokens, crmScopeTokens } from "@aglyn/aglyn/server";
|
|
@@ -261,6 +262,8 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
261
262
|
const { hostRef, hostId, lead } = options;
|
|
262
263
|
const maxPerHost = (_options_ceiling = options.ceiling) != null ? _options_ceiling : LEADS_MAX_PER_HOST;
|
|
263
264
|
try {
|
|
265
|
+
var _ref;
|
|
266
|
+
var _leadsRef_parent;
|
|
264
267
|
const leadsRef = await orgLeadsForHost(hostId);
|
|
265
268
|
const firestore = hostRef.firestore;
|
|
266
269
|
/*
|
|
@@ -298,8 +301,9 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
298
301
|
} : {});
|
|
299
302
|
let created = false;
|
|
300
303
|
let sourceAdded = false;
|
|
304
|
+
let predicted = false;
|
|
301
305
|
const refused = await firestore.runTransaction(async (tx)=>{
|
|
302
|
-
var _existing_data, _existing_data1, _existing_data2;
|
|
306
|
+
var _existing_data, _existing_data1, _existing_data2, _existing_data3;
|
|
303
307
|
// Reset per attempt: a contended transaction re-runs its body, and a
|
|
304
308
|
// flag left standing from an aborted attempt would credit a campaign
|
|
305
309
|
// with a person who turned out to exist already.
|
|
@@ -307,6 +311,8 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
307
311
|
sourceAdded = false;
|
|
308
312
|
// ALL READS BEFORE THE WRITE, which Firestore requires.
|
|
309
313
|
const existing = await tx.get(leadRef);
|
|
314
|
+
// Whether the lead already reads "Would bounce", for the check below.
|
|
315
|
+
predicted = ((_existing_data = existing.data()) != null ? _existing_data : {})['emailStatus'] === 'undeliverable';
|
|
310
316
|
/*
|
|
311
317
|
* ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.
|
|
312
318
|
*
|
|
@@ -372,7 +378,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
372
378
|
* nothing.
|
|
373
379
|
*
|
|
374
380
|
* Resolved above the transaction — see `group`'s declaration.
|
|
375
|
-
*/ const prior = readMarketingBasis((
|
|
381
|
+
*/ const prior = readMarketingBasis((_existing_data1 = existing.data()) != null ? _existing_data1 : null, group);
|
|
376
382
|
const consentAtMs = prior.basis === 'granted' && prior.basisAtMs !== null ? prior.basisAtMs : now;
|
|
377
383
|
created = !existing.exists;
|
|
378
384
|
/*
|
|
@@ -381,7 +387,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
381
387
|
* counts its leads by this (AGL-3330) — the people it filed, which is
|
|
382
388
|
* what a recount of `sources` finds — so a returning visitor's second
|
|
383
389
|
* submission moves no form's lead count.
|
|
384
|
-
*/ const storedSources = ((
|
|
390
|
+
*/ const storedSources = ((_existing_data2 = existing.data()) != null ? _existing_data2 : {})['sources'];
|
|
385
391
|
sourceAdded = created || !(Array.isArray(storedSources) && storedSources.includes(lead.source));
|
|
386
392
|
/*
|
|
387
393
|
* THE FIELDS THE LEADS LIST QUERIES (AGL-3321), from the lead as it
|
|
@@ -389,7 +395,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
389
395
|
* capture brings and the scope it widens. A new lead is written
|
|
390
396
|
* `status: 'new'`, so the Open view's `status in [new, working]`
|
|
391
397
|
* finds a lead nobody has touched — an absent field matches no query.
|
|
392
|
-
*/ const stored = (
|
|
398
|
+
*/ const stored = (_existing_data3 = existing.data()) != null ? _existing_data3 : {};
|
|
393
399
|
const storedScope = Array.isArray(stored['visibleTo']) ? stored['visibleTo'] : [];
|
|
394
400
|
const listFields = crmLeadListFields(_extends({}, stored, {
|
|
395
401
|
email: lead.email
|
|
@@ -457,7 +463,17 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
457
463
|
* Awaited rather than fired off: `addHostLead` already returns only after
|
|
458
464
|
* its own write, and a caller that `void`s it (every one of them) is
|
|
459
465
|
* unaffected. Never throws, so a failure here cannot cost the lead.
|
|
460
|
-
*/
|
|
466
|
+
*/ /*
|
|
467
|
+
* THE ADDRESS'S DOMAIN, checked after the response (AGL-3328): a lead
|
|
468
|
+
* whose domain takes no mail reads "Would bounce". Queued, never
|
|
469
|
+
* awaited — the write above is the capture, and this costs it nothing.
|
|
470
|
+
*/ scheduleCapturedEmailCheck({
|
|
471
|
+
orgId: (_ref = (_leadsRef_parent = leadsRef.parent) == null ? void 0 : _leadsRef_parent.id) != null ? _ref : null,
|
|
472
|
+
hostId,
|
|
473
|
+
email: lead.email,
|
|
474
|
+
predicted
|
|
475
|
+
});
|
|
476
|
+
if (created && options.touch) {
|
|
461
477
|
await attributeCampaignConversion({
|
|
462
478
|
hostId,
|
|
463
479
|
kind: 'lead',
|