@aglyn/aglyn 1.0.0-beta.203 → 1.0.0-beta.205
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 +11 -11
- package/src/lib/app-utils/activity-presenter.d.ts +21 -0
- package/src/lib/app-utils/activity-presenter.js +22 -0
- package/src/lib/app-utils/activity-presenter.js.map +1 -1
- package/src/lib/app-utils/console-routes.d.ts +4 -0
- package/src/lib/app-utils/console-routes.js +4 -0
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/csv-import.d.ts +12 -0
- package/src/lib/app-utils/csv-import.js +13 -3
- package/src/lib/app-utils/csv-import.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
- package/src/lib/app-utils/docs-help.generated.js +2 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +78 -8
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/health-report.js +10 -0
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/notifications.d.ts +13 -1
- package/src/lib/app-utils/notifications.js +20 -1
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/recipient-email-render.d.ts +21 -1
- package/src/lib/app-utils/recipient-email-render.js +34 -5
- package/src/lib/app-utils/recipient-email-render.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +1 -1
- package/src/lib/app-utils/server.js +1 -1
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/app-utils/site-email-chrome.d.ts +52 -0
- package/src/lib/app-utils/site-email-chrome.js +82 -0
- package/src/lib/app-utils/site-email-chrome.js.map +1 -0
- package/src/lib/app-utils/system-email-chrome.d.ts +6 -0
- package/src/lib/app-utils/system-email-chrome.js.map +1 -1
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +11 -0
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-email-state.d.ts +7 -0
- package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
|
@@ -22,7 +22,7 @@ import type { ITimestamp } from '@aglyn/shared-util-timestamp';
|
|
|
22
22
|
* their owner. Types follow the common SaaS taxonomy so the console can
|
|
23
23
|
* icon/group them without a registry.
|
|
24
24
|
*/
|
|
25
|
-
export type AglynNotificationType = 'billing.invoice' | 'billing.paymentFailed' | 'billing.subscriptionCanceled' | 'billing.usage' | 'team.invite' | 'team.roleChanged' | 'team.hostAccessGranted' | 'content.formSubmission' | 'content.booking' | 'content.order' | 'content.lowStock' | 'content.taskAssigned' | 'content.taskReminder' | 'content.contactAssigned' | 'content.leadAssigned' | 'content.crmDailyDigest' | 'content.insightsDigest' | 'marketplace.review' | 'support.ticketOpened' | 'support.ticketReply' | 'system.announcement' | 'system.pluginVerifierRegression' | 'system.signInMethodRemoved' | 'system.scopeDrift' | 'system.ssoDomainUnverified' | 'system.formSubmissionsPaused' | 'system.visitorRecordsPaused' | 'system.abuseReportUrgent' | 'system.dmcaCounterNotice' | 'system.bandwidthCeilingTripped' | 'system.bandwidthCapEngaged' | 'system.billingWebhookHalfApplied' | 'system.disputeUnattributed' | 'staff.userSignedUp' | 'staff.orgCreated' | 'staff.subscriptionStarted' | 'staff.subscriptionCanceled' | 'staff.planChanged' | 'staff.paymentFailed';
|
|
25
|
+
export type AglynNotificationType = 'billing.invoice' | 'billing.paymentFailed' | 'billing.subscriptionCanceled' | 'billing.usage' | 'team.invite' | 'team.roleChanged' | 'team.hostAccessGranted' | 'content.formSubmission' | 'content.booking' | 'content.order' | 'content.lowStock' | 'content.taskAssigned' | 'content.taskReminder' | 'content.contactAssigned' | 'content.leadAssigned' | 'content.crmDailyDigest' | 'content.insightsDigest' | 'marketplace.review' | 'support.ticketOpened' | 'support.ticketReply' | 'system.announcement' | 'system.pluginVerifierRegression' | 'system.signInMethodRemoved' | 'system.scopeDrift' | 'system.ssoDomainUnverified' | 'system.formSubmissionsPaused' | 'system.visitorRecordsPaused' | 'system.abuseReportUrgent' | 'system.riskNotice' | 'system.dmcaCounterNotice' | 'system.bandwidthCeilingTripped' | 'system.bandwidthCapEngaged' | 'system.billingWebhookHalfApplied' | 'system.disputeUnattributed' | 'staff.userSignedUp' | 'staff.orgCreated' | 'staff.subscriptionStarted' | 'staff.subscriptionCanceled' | 'staff.planChanged' | 'staff.paymentFailed';
|
|
26
26
|
export interface AglynNotification {
|
|
27
27
|
$id?: string;
|
|
28
28
|
type: AglynNotificationType;
|
|
@@ -237,6 +237,18 @@ export declare const NOTIFICATION_CHANNEL_DEFAULTS: Record<NotificationCategory,
|
|
|
237
237
|
* notification saying that the digest happened.
|
|
238
238
|
*/
|
|
239
239
|
export declare const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string>;
|
|
240
|
+
/**
|
|
241
|
+
* Staff notifications the OPERATOR must hear about by email, whoever runs the
|
|
242
|
+
* install and whatever anyone switched on (AGL-3375): fraud and risk alerts,
|
|
243
|
+
* a card dispute nobody owns, a DMCA counter-notice whose statutory clock is
|
|
244
|
+
* running, and a billing webhook that applied only half of what it meant to.
|
|
245
|
+
*
|
|
246
|
+
* The console notification is still written for every staff member. What
|
|
247
|
+
* this adds is one email to the operator's alert inbox, because the email
|
|
248
|
+
* channel defaults OFF and a self-hosted or OSS install that nobody tuned
|
|
249
|
+
* would otherwise learn about card testing on its checkout from its bank.
|
|
250
|
+
*/
|
|
251
|
+
export declare const OPERATOR_ALERT_NOTIFICATION_TYPES: ReadonlySet<string>;
|
|
240
252
|
/**
|
|
241
253
|
* Whether a channel is on for one notification, at its own scope.
|
|
242
254
|
*
|
|
@@ -43,6 +43,7 @@ export const NOTIFICATION_TYPE_LABELS = {
|
|
|
43
43
|
'system.formSubmissionsPaused': 'Form submissions paused',
|
|
44
44
|
'system.visitorRecordsPaused': 'Sign-ups or leads paused',
|
|
45
45
|
'system.abuseReportUrgent': 'Urgent abuse report',
|
|
46
|
+
'system.riskNotice': 'Held, flagged or locked on your workspace',
|
|
46
47
|
'system.dmcaCounterNotice': 'DMCA counter-notice',
|
|
47
48
|
'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',
|
|
48
49
|
'system.bandwidthCapEngaged': 'Monthly traffic limit reached',
|
|
@@ -208,7 +209,25 @@ export const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings';
|
|
|
208
209
|
* notification saying that the digest happened.
|
|
209
210
|
*/ export const NOTIFICATION_SELF_SENT_EMAIL_TYPES = new Set([
|
|
210
211
|
'content.crmDailyDigest',
|
|
211
|
-
'content.insightsDigest'
|
|
212
|
+
'content.insightsDigest',
|
|
213
|
+
// Emailed by `notifyRiskEvent` itself, as account mail (AGL-3368).
|
|
214
|
+
'system.riskNotice'
|
|
215
|
+
]);
|
|
216
|
+
/**
|
|
217
|
+
* Staff notifications the OPERATOR must hear about by email, whoever runs the
|
|
218
|
+
* install and whatever anyone switched on (AGL-3375): fraud and risk alerts,
|
|
219
|
+
* a card dispute nobody owns, a DMCA counter-notice whose statutory clock is
|
|
220
|
+
* running, and a billing webhook that applied only half of what it meant to.
|
|
221
|
+
*
|
|
222
|
+
* The console notification is still written for every staff member. What
|
|
223
|
+
* this adds is one email to the operator's alert inbox, because the email
|
|
224
|
+
* channel defaults OFF and a self-hosted or OSS install that nobody tuned
|
|
225
|
+
* would otherwise learn about card testing on its checkout from its bank.
|
|
226
|
+
*/ export const OPERATOR_ALERT_NOTIFICATION_TYPES = new Set([
|
|
227
|
+
'system.abuseReportUrgent',
|
|
228
|
+
'system.disputeUnattributed',
|
|
229
|
+
'system.dmcaCounterNotice',
|
|
230
|
+
'system.billingWebhookHalfApplied'
|
|
212
231
|
]);
|
|
213
232
|
/**
|
|
214
233
|
* Whether a channel is on for one notification, at its own scope.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/notifications.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 type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport { buildRoute, Route } from './console-routes'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by\n // `crmDailyDigestEnabled`: the mute is a fact about the console feed and\n // the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n // And when money moves (AGL-3267). The category was two signup types, so\n // the one thing a platform feed exists to report — revenue starting,\n // changing and stopping — was the one thing it did not.\n //\n // `staff.paymentFailed` is deliberately NOT the customer's\n // `billing.paymentFailed`: different audience, different action. The\n // customer is told to fix their card; staff are told a paying workspace is\n // about to stop paying.\n | 'staff.subscriptionStarted'\n | 'staff.subscriptionCanceled'\n | 'staff.planChanged'\n | 'staff.paymentFailed'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n createdAt?: ITimestamp\n /** When the owner read it; absent or null while unread. */\n readAt?: ITimestamp | null\n /**\n * Whether the owner has read it — `false` on create, `true` beside\n * `readAt` when read (AGL-3321).\n *\n * A boolean rather than `readAt: null`, because the feed filters by it\n * under its newest-first cursor. Equality on a boolean answers BOTH\n * states (`read == false`, `read == true`) beneath `orderBy('createdAt')`\n * with one composite index. A nullable timestamp answers \"unread\" by\n * equality but \"read\" only as `readAt != null`, and Firestore requires an\n * inequality's field to be the FIRST sort, which would order the feed by\n * when things were read instead of when they arrived. Absent only on\n * notifications written before the field existed, which\n * `tools/scripts/backfill-notification-read.mjs` stamps.\n */\n read?: boolean\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n 'staff.subscriptionStarted': 'New subscription',\n 'staff.subscriptionCanceled': 'Subscription canceled',\n 'staff.planChanged': 'Plan changed',\n 'staff.paymentFailed': 'Payment failed (workspace)',\n}\n\n/** Preference buckets (AGL-267): the prefix before the dot. */\nexport type NotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'marketplace'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\nexport const NOTIFICATION_CATEGORY_LABELS: Record<\n NotificationCategory,\n string\n> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n marketplace: 'Marketplace',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\n/**\n * What each category actually covers, in the reader's words (AGL-3251).\n *\n * The settings page listed seven bare labels and two switches, so deciding\n * whether to silence `Product & system` meant guessing what was in it. The\n * type labels beneath each row now name the contents exactly; this is the\n * one-line answer for somebody who does not want to expand anything.\n *\n * Written as what ARRIVES rather than as what the bucket is called: \"someone\n * joins, a role changes\" is checkable against your own feed in a way that\n * \"team and access events\" is not. Exhaustive `Record`, like the channel\n * defaults below — a new category cannot ship without somebody saying in\n * plain words what lands in it.\n */\nexport const NOTIFICATION_CATEGORY_DESCRIPTIONS: Record<\n NotificationCategory,\n string\n> = {\n billing:\n 'Invoices, failed payments, cancellations, and usage that crosses a plan limit.',\n team: 'Somebody joins or leaves, a role changes, or a site is shared with you.',\n content:\n 'Work arriving on your sites: form submissions, bookings, orders, low stock, and the tasks and leads assigned to you.',\n marketplace: 'Decisions on plugin listings you submitted for review.',\n support: 'New support tickets and replies on tickets you are following.',\n system:\n 'Announcements, and the faults the platform finds in your account: sign-in methods removed, traffic limits reached, billing or sharing left in a broken state.',\n staff:\n 'New accounts, new workspaces, and money moving — subscriptions starting, changing, failing and ending — across the whole platform. Only staff receive these.',\n}\n\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return (\n [\n 'billing',\n 'team',\n 'content',\n 'marketplace',\n 'support',\n 'system',\n 'staff',\n ].includes(prefix)\n ? prefix\n : 'system'\n ) as NotificationCategory\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ crmDaily: false }` turns the daily CRM digest off, and an\n * absent key leaves it on. Its own map rather than a key in\n * `notificationPrefs`, because that map is keyed by CATEGORY and read by\n * {@link notificationMuted} — a digest is a schedule a person keeps or\n * drops, not a bucket of types, and one switch governs both the console\n * notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.\n *\n * Exported so the console writes the switch through a name rather than a\n * second copy of the string (AGL-3230). The reader below and the page that\n * flips it were spelling the same literal in two files, which is how a\n * preference comes to be written under one key and read under another — and\n * it kept the plugin's vocabulary in a console page, where\n * `check:plugin-domain-in-core` rightly refuses it.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\nexport function crmDailyDigestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n): boolean {\n return prefs?.[CRM_DAILY_DIGEST_KEY] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * One scope's answer for a single notification TYPE (AGL-3251), tri-state on\n * the same terms as {@link NotificationChannelPrefs}: an absent key falls\n * through to the category.\n *\n * Its own map rather than extra keys in {@link NotificationCategoryPrefs},\n * because that one is an exhaustive record keyed by category and a type key\n * inside it would type-check only by widening the thing that makes a missing\n * category a compile error.\n */\nexport type NotificationTypePrefs = Partial<\n Record<AglynNotificationType, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /**\n * Per-TYPE answers at the account scope (AGL-3251), consulted before\n * {@link NotificationSettings.account}'s category answer and never above a\n * narrower scope — see {@link notificationChannelEnabled}.\n *\n * ACCOUNT ONLY, deliberately. The per-workspace and per-site card is\n * already seven categories by three states by two channels, and a type row\n * for each of the ~thirty types, per workspace AND per site, is a page\n * nobody can read — which is the complaint this whole issue started as.\n * The account scope is where \"stop telling me about THIS\" belongs anyway:\n * it is a statement about the kind of thing, and kinds do not vary by site.\n */\n accountTypes?: NotificationTypePrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n /**\n * Per-TYPE answers at the workspace and site scopes (AGL-3267), beside the\n * category maps above rather than nested inside them.\n *\n * Parallel maps because the category maps are already stored under `orgs`\n * and `hosts` on live user documents: folding both into one\n * `{ categories, types }` object per scope would be a migration of every\n * preference anybody has set, to express something two more keys express\n * without touching a byte of what exists.\n *\n * This supersedes the account-only limit AGL-3251 shipped under. That was\n * the right call for a card nobody had used yet and the wrong one once it\n * existed: \"quiet this one type down on this one busy site\" is the question\n * the scope card is FOR, and answering it only for whole categories made\n * the fine grain stop exactly where the noise is worst.\n */\n orgTypes?: Record<string, NotificationTypePrefs>\n hostTypes?: Record<string, NotificationTypePrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, everywhere. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a\n * compile error here until somebody decides what it does by default, which is\n * the one question a new category must not be able to ship without answering.\n */\nexport const NOTIFICATION_CHANNEL_DEFAULTS: Record<\n NotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n marketplace: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console ON, because the complaint this answers is that staff never heard\n // about a sign-up at all; email off, like everything else, because that is\n // what the channel defaults to and a sign-up is not urgent enough to be the\n // exception that starts filling inboxes by default.\n staff: { console: true, email: false },\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n ])\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n /*\n * ⛔ A STAFF NOTIFICATION HAS NO SCOPE TO BE NARROWED BY (AGL-3267).\n *\n * It is about the platform, and the workspace one MENTIONS is its subject,\n * not its audience: the staff member reading \"Acme Co subscribed\" is\n * almost never a member of Acme Co, and if they happen to be, their\n * preferences for their own membership have nothing to do with it.\n *\n * This was already true by accident — no staff emitter passes `orgId` or\n * `hostId`, so the scope layers never matched — which made every\n * \"Platform growth\" row in the per-workspace card a control that could be\n * set and could never do anything. Stating it here rather than only hiding\n * those rows is the difference between the model being right and the UI\n * covering for a model that is wrong: a `staff.*` type that ever does\n * start carrying an `orgId`, for its link or its subject line, must not\n * silently become mutable per workspace.\n */\n const staffWide = STAFF_NOTIFICATION_CATEGORIES.has(category)\n /*\n * Narrowest scope first, and within each scope the TYPE before its\n * CATEGORY (AGL-3251, widened to every scope by AGL-3267).\n *\n * Both halves matter and they answer different questions. Scope order is\n * \"where is this from\" — a site's answer beats its workspace's, which beats\n * the account's, because quietening one busy site is the thing the scope\n * card exists for. Type-before-category is \"what kind of thing is this\" —\n * somebody who switched `Payment failed` off said something about that\n * type, and it must beat their own broader `Billing` answer at the SAME\n * scope without leaking upward past a narrower one.\n */\n const layers: Array<NotificationChannelPrefs | undefined> = staffWide\n ? [\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n : [\n scope?.hostId\n ? settings?.hostTypes?.[scope.hostId]?.[type as AglynNotificationType]\n : undefined,\n scope?.hostId ? settings?.hosts?.[scope.hostId]?.[category] : undefined,\n scope?.orgId\n ? settings?.orgTypes?.[scope.orgId]?.[type as AglynNotificationType]\n : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId]?.[category] : undefined,\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n for (const layer of layers) {\n const answer = layer?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return NOTIFICATION_CHANNEL_DEFAULTS[category][channel]\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\n/**\n * What the account scope says about one TYPE, with no inheritance applied\n * (AGL-3251) — `undefined` means the row follows its category.\n *\n * The page needs the unresolved answer to draw the difference between \"on\n * because Billing is on\" and \"on because you said so\": only the second can be\n * reset, and a switch that cannot show which one it is leaves a person unable\n * to get back to following the category.\n */\nexport function notificationAccountTypePref(\n settings: NotificationSettings | null | undefined,\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n return settings?.accountTypes?.[type as AglynNotificationType]?.[channel]\n}\n\n/**\n * Every type in a category, in the order the labels declare them\n * (AGL-3251) — what the settings page expands a category row into.\n *\n * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second\n * map, so a type added there appears under its category without anybody\n * remembering to list it twice.\n */\nexport function notificationTypesInCategory(\n category: NotificationCategory,\n): AglynNotificationType[] {\n return (\n Object.keys(NOTIFICATION_TYPE_LABELS) as AglynNotificationType[]\n ).filter((type) => notificationCategory(type) === category)\n}\n\n/**\n * What ONE scope says about ONE type, with no inheritance applied\n * (AGL-3267) — `undefined` means the row follows its category at that scope.\n *\n * The scope-aware sibling of {@link notificationAccountTypePref}, which stays\n * as the account-only shorthand its callers already use.\n */\nexport function notificationScopeTypePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.accountTypes\n : scope.kind === 'org'\n ? settings?.orgTypes?.[scope.id]\n : settings?.hostTypes?.[scope.id]\n return layer?.[type as AglynNotificationType]?.[channel]\n}\n\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n /*\n * BOTH maps for a scope, categories and types (AGL-3267).\n *\n * Reading only the category map would make a scope whose ONLY answer is a\n * per-type one invisible here — and this list is the single place a person\n * can find an override they set months ago. Silencing one type on one busy\n * site and then being unable to find where you did it is precisely the\n * failure this function exists to prevent.\n */\n const answered = (prefs: Record<string, NotificationChannelPrefs> | undefined) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some((value) => typeof value === 'boolean'),\n )\n const named = (\n categories: Record<string, NotificationCategoryPrefs> | undefined,\n types: Record<string, NotificationTypePrefs> | undefined,\n ) =>\n [\n ...new Set([\n ...Object.keys(categories ?? {}),\n ...Object.keys(types ?? {}),\n ]),\n ]\n .filter(\n (id) =>\n answered(categories?.[id] as Record<string, NotificationChannelPrefs>) ||\n answered(types?.[id] as Record<string, NotificationChannelPrefs>),\n )\n .sort()\n return {\n orgIds: named(settings?.orgs, settings?.orgTypes),\n hostIds: named(settings?.hosts, settings?.hostTypes),\n }\n}\n\n/**\n * Rewrites a stored notification link onto the current URL scheme (AGL-644).\n *\n * A notification's `link` is frozen at write time — `notifyUsers` persists\n * whatever string the emitter passed and never normalizes it. So every\n * notification written before the org-slug migration (AGL-621) and the\n * subdomain migration (AGL-622) still points at a route that no longer\n * exists, and fixing the emitters alone would leave that whole backlog dead.\n * Normalizing when the link is FOLLOWED repairs old and new alike, and keeps\n * working for emitters that haven't been migrated yet.\n *\n * Rewrites, in order:\n * - `/{hostDocId}` or `/{hostDocId}/rest` → `/{orgSlug}/hosts/{subdomain}/rest`\n * - `/org` or `/org/rest` → `/{orgSlug}/rest`\n * - `/hosts` (exactly) → `/{orgSlug}/hosts`\n *\n * Anything already canonical, user-scoped (`/manage/...`), staff (`/admin/...`)\n * or absolute is returned untouched. Every rewrite is gated on having the\n * context it needs, so an unresolvable link degrades to its stored value\n * rather than to a wrong destination.\n *\n * The host rewrite is keyed on the notification's own `hostId` rather than\n * guessing from the path shape, so it can never mistake a real first segment\n * for a doc id.\n *\n * The email copy of a notification goes through it too (AGL-3367): a button\n * in an inbox is a followed link with no console around it to repair it.\n */\nexport function normalizeNotificationLink(\n link: string | undefined | null,\n context: {\n orgSlug?: string | null\n /** The notification's `hostId` (a Firestore doc id). */\n hostId?: string | null\n /** That host's subdomain, which the current routes are keyed by. */\n hostSubdomain?: string | null\n },\n): string | undefined {\n if (!link) return undefined\n // Absolute URLs (staff broadcasts can carry them) are not ours to rewrite.\n if (!link.startsWith('/')) return link\n\n const { orgSlug, hostId, hostSubdomain } = context\n\n if (orgSlug && hostId && hostSubdomain) {\n const prefix = `/${hostId}`\n if (link === prefix || link.startsWith(`${prefix}/`)) {\n return `${buildRoute(Route.HOST_DASHBOARD, {\n orgSlug,\n host: hostSubdomain,\n })}${link.slice(prefix.length)}`\n }\n }\n\n if (orgSlug) {\n if (link === '/org' || link.startsWith('/org/')) {\n return `${buildRoute(Route.ORG_HOME, { orgSlug })}${link.slice(\n '/org'.length,\n )}`\n }\n // Only the bare list — `/hosts/{docId}` would still need a subdomain, and\n // guessing one is worse than leaving the link alone.\n if (link === '/hosts') return buildRoute(Route.HOST_LIST, { orgSlug })\n }\n\n return link\n}\n"],"names":["buildRoute","Route","NOTIFICATION_TYPE_LABELS","NOTIFICATION_CATEGORY_LABELS","billing","team","content","marketplace","support","system","staff","STAFF_NOTIFICATION_CATEGORIES","Set","NOTIFICATION_CATEGORY_DESCRIPTIONS","notificationCategory","type","prefix","String","split","includes","notificationMuted","prefs","DIGEST_PREFS_FIELD","CRM_DAILY_DIGEST_KEY","crmDailyDigestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","value","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","NOTIFICATION_CHANNEL_DEFAULTS","console","email","NOTIFICATION_SELF_SENT_EMAIL_TYPES","notificationChannelEnabled","settings","channel","scope","legacyPrefs","category","staffWide","has","layers","accountTypes","account","hostId","hostTypes","undefined","hosts","orgTypes","orgs","layer","answer","notificationAccountTypePref","notificationTypesInCategory","Object","keys","filter","notificationScopeTypePref","kind","id","notificationScopePref","notificationOverriddenScopes","answered","values","some","channels","named","categories","types","sort","orgIds","hostIds","normalizeNotificationLink","link","context","startsWith","orgSlug","hostSubdomain","HOST_DASHBOARD","host","slice","length","ORG_HOME","HOST_LIST"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/notifications.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 type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport { buildRoute, Route } from './console-routes'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by\n // `crmDailyDigestEnabled`: the mute is a fact about the console feed and\n // the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // Something on the recipient's OWN workspace was held, flagged, locked or\n // paused (AGL-3368): an email or page held for review, a flagged domain,\n // a fraud signal on a payment, a lock and its lift. Written by\n // `notifyRiskEvent` from the risk notice catalog, which says what happened,\n // what it means and how to request a review. `system.` for the AGL-1088\n // reason: it is the one message saying the owner's work has STOPPED, and a\n // category muted to quieten routine chatter must not swallow it. It sends\n // its own email, transactional and preference-free, so it is also in\n // NOTIFICATION_SELF_SENT_EMAIL_TYPES.\n | 'system.riskNotice'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n // And when money moves (AGL-3267). The category was two signup types, so\n // the one thing a platform feed exists to report — revenue starting,\n // changing and stopping — was the one thing it did not.\n //\n // `staff.paymentFailed` is deliberately NOT the customer's\n // `billing.paymentFailed`: different audience, different action. The\n // customer is told to fix their card; staff are told a paying workspace is\n // about to stop paying.\n | 'staff.subscriptionStarted'\n | 'staff.subscriptionCanceled'\n | 'staff.planChanged'\n | 'staff.paymentFailed'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n createdAt?: ITimestamp\n /** When the owner read it; absent or null while unread. */\n readAt?: ITimestamp | null\n /**\n * Whether the owner has read it — `false` on create, `true` beside\n * `readAt` when read (AGL-3321).\n *\n * A boolean rather than `readAt: null`, because the feed filters by it\n * under its newest-first cursor. Equality on a boolean answers BOTH\n * states (`read == false`, `read == true`) beneath `orderBy('createdAt')`\n * with one composite index. A nullable timestamp answers \"unread\" by\n * equality but \"read\" only as `readAt != null`, and Firestore requires an\n * inequality's field to be the FIRST sort, which would order the feed by\n * when things were read instead of when they arrived. Absent only on\n * notifications written before the field existed, which\n * `tools/scripts/backfill-notification-read.mjs` stamps.\n */\n read?: boolean\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.riskNotice': 'Held, flagged or locked on your workspace',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n 'staff.subscriptionStarted': 'New subscription',\n 'staff.subscriptionCanceled': 'Subscription canceled',\n 'staff.planChanged': 'Plan changed',\n 'staff.paymentFailed': 'Payment failed (workspace)',\n}\n\n/** Preference buckets (AGL-267): the prefix before the dot. */\nexport type NotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'marketplace'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\nexport const NOTIFICATION_CATEGORY_LABELS: Record<\n NotificationCategory,\n string\n> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n marketplace: 'Marketplace',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\n/**\n * What each category actually covers, in the reader's words (AGL-3251).\n *\n * The settings page listed seven bare labels and two switches, so deciding\n * whether to silence `Product & system` meant guessing what was in it. The\n * type labels beneath each row now name the contents exactly; this is the\n * one-line answer for somebody who does not want to expand anything.\n *\n * Written as what ARRIVES rather than as what the bucket is called: \"someone\n * joins, a role changes\" is checkable against your own feed in a way that\n * \"team and access events\" is not. Exhaustive `Record`, like the channel\n * defaults below — a new category cannot ship without somebody saying in\n * plain words what lands in it.\n */\nexport const NOTIFICATION_CATEGORY_DESCRIPTIONS: Record<\n NotificationCategory,\n string\n> = {\n billing:\n 'Invoices, failed payments, cancellations, and usage that crosses a plan limit.',\n team: 'Somebody joins or leaves, a role changes, or a site is shared with you.',\n content:\n 'Work arriving on your sites: form submissions, bookings, orders, low stock, and the tasks and leads assigned to you.',\n marketplace: 'Decisions on plugin listings you submitted for review.',\n support: 'New support tickets and replies on tickets you are following.',\n system:\n 'Announcements, and the faults the platform finds in your account: sign-in methods removed, traffic limits reached, billing or sharing left in a broken state.',\n staff:\n 'New accounts, new workspaces, and money moving — subscriptions starting, changing, failing and ending — across the whole platform. Only staff receive these.',\n}\n\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return (\n [\n 'billing',\n 'team',\n 'content',\n 'marketplace',\n 'support',\n 'system',\n 'staff',\n ].includes(prefix)\n ? prefix\n : 'system'\n ) as NotificationCategory\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ crmDaily: false }` turns the daily CRM digest off, and an\n * absent key leaves it on. Its own map rather than a key in\n * `notificationPrefs`, because that map is keyed by CATEGORY and read by\n * {@link notificationMuted} — a digest is a schedule a person keeps or\n * drops, not a bucket of types, and one switch governs both the console\n * notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.\n *\n * Exported so the console writes the switch through a name rather than a\n * second copy of the string (AGL-3230). The reader below and the page that\n * flips it were spelling the same literal in two files, which is how a\n * preference comes to be written under one key and read under another — and\n * it kept the plugin's vocabulary in a console page, where\n * `check:plugin-domain-in-core` rightly refuses it.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\nexport function crmDailyDigestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n): boolean {\n return prefs?.[CRM_DAILY_DIGEST_KEY] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * One scope's answer for a single notification TYPE (AGL-3251), tri-state on\n * the same terms as {@link NotificationChannelPrefs}: an absent key falls\n * through to the category.\n *\n * Its own map rather than extra keys in {@link NotificationCategoryPrefs},\n * because that one is an exhaustive record keyed by category and a type key\n * inside it would type-check only by widening the thing that makes a missing\n * category a compile error.\n */\nexport type NotificationTypePrefs = Partial<\n Record<AglynNotificationType, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /**\n * Per-TYPE answers at the account scope (AGL-3251), consulted before\n * {@link NotificationSettings.account}'s category answer and never above a\n * narrower scope — see {@link notificationChannelEnabled}.\n *\n * ACCOUNT ONLY, deliberately. The per-workspace and per-site card is\n * already seven categories by three states by two channels, and a type row\n * for each of the ~thirty types, per workspace AND per site, is a page\n * nobody can read — which is the complaint this whole issue started as.\n * The account scope is where \"stop telling me about THIS\" belongs anyway:\n * it is a statement about the kind of thing, and kinds do not vary by site.\n */\n accountTypes?: NotificationTypePrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n /**\n * Per-TYPE answers at the workspace and site scopes (AGL-3267), beside the\n * category maps above rather than nested inside them.\n *\n * Parallel maps because the category maps are already stored under `orgs`\n * and `hosts` on live user documents: folding both into one\n * `{ categories, types }` object per scope would be a migration of every\n * preference anybody has set, to express something two more keys express\n * without touching a byte of what exists.\n *\n * This supersedes the account-only limit AGL-3251 shipped under. That was\n * the right call for a card nobody had used yet and the wrong one once it\n * existed: \"quiet this one type down on this one busy site\" is the question\n * the scope card is FOR, and answering it only for whole categories made\n * the fine grain stop exactly where the noise is worst.\n */\n orgTypes?: Record<string, NotificationTypePrefs>\n hostTypes?: Record<string, NotificationTypePrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, everywhere. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a\n * compile error here until somebody decides what it does by default, which is\n * the one question a new category must not be able to ship without answering.\n */\nexport const NOTIFICATION_CHANNEL_DEFAULTS: Record<\n NotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n marketplace: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console ON, because the complaint this answers is that staff never heard\n // about a sign-up at all; email off, like everything else, because that is\n // what the channel defaults to and a sign-up is not urgent enough to be the\n // exception that starts filling inboxes by default.\n staff: { console: true, email: false },\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n // Emailed by `notifyRiskEvent` itself, as account mail (AGL-3368).\n 'system.riskNotice',\n ])\n\n/**\n * Staff notifications the OPERATOR must hear about by email, whoever runs the\n * install and whatever anyone switched on (AGL-3375): fraud and risk alerts,\n * a card dispute nobody owns, a DMCA counter-notice whose statutory clock is\n * running, and a billing webhook that applied only half of what it meant to.\n *\n * The console notification is still written for every staff member. What\n * this adds is one email to the operator's alert inbox, because the email\n * channel defaults OFF and a self-hosted or OSS install that nobody tuned\n * would otherwise learn about card testing on its checkout from its bank.\n */\nexport const OPERATOR_ALERT_NOTIFICATION_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'system.abuseReportUrgent',\n 'system.disputeUnattributed',\n 'system.dmcaCounterNotice',\n 'system.billingWebhookHalfApplied',\n ])\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n /*\n * ⛔ A STAFF NOTIFICATION HAS NO SCOPE TO BE NARROWED BY (AGL-3267).\n *\n * It is about the platform, and the workspace one MENTIONS is its subject,\n * not its audience: the staff member reading \"Acme Co subscribed\" is\n * almost never a member of Acme Co, and if they happen to be, their\n * preferences for their own membership have nothing to do with it.\n *\n * This was already true by accident — no staff emitter passes `orgId` or\n * `hostId`, so the scope layers never matched — which made every\n * \"Platform growth\" row in the per-workspace card a control that could be\n * set and could never do anything. Stating it here rather than only hiding\n * those rows is the difference between the model being right and the UI\n * covering for a model that is wrong: a `staff.*` type that ever does\n * start carrying an `orgId`, for its link or its subject line, must not\n * silently become mutable per workspace.\n */\n const staffWide = STAFF_NOTIFICATION_CATEGORIES.has(category)\n /*\n * Narrowest scope first, and within each scope the TYPE before its\n * CATEGORY (AGL-3251, widened to every scope by AGL-3267).\n *\n * Both halves matter and they answer different questions. Scope order is\n * \"where is this from\" — a site's answer beats its workspace's, which beats\n * the account's, because quietening one busy site is the thing the scope\n * card exists for. Type-before-category is \"what kind of thing is this\" —\n * somebody who switched `Payment failed` off said something about that\n * type, and it must beat their own broader `Billing` answer at the SAME\n * scope without leaking upward past a narrower one.\n */\n const layers: Array<NotificationChannelPrefs | undefined> = staffWide\n ? [\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n : [\n scope?.hostId\n ? settings?.hostTypes?.[scope.hostId]?.[type as AglynNotificationType]\n : undefined,\n scope?.hostId ? settings?.hosts?.[scope.hostId]?.[category] : undefined,\n scope?.orgId\n ? settings?.orgTypes?.[scope.orgId]?.[type as AglynNotificationType]\n : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId]?.[category] : undefined,\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n for (const layer of layers) {\n const answer = layer?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return NOTIFICATION_CHANNEL_DEFAULTS[category][channel]\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\n/**\n * What the account scope says about one TYPE, with no inheritance applied\n * (AGL-3251) — `undefined` means the row follows its category.\n *\n * The page needs the unresolved answer to draw the difference between \"on\n * because Billing is on\" and \"on because you said so\": only the second can be\n * reset, and a switch that cannot show which one it is leaves a person unable\n * to get back to following the category.\n */\nexport function notificationAccountTypePref(\n settings: NotificationSettings | null | undefined,\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n return settings?.accountTypes?.[type as AglynNotificationType]?.[channel]\n}\n\n/**\n * Every type in a category, in the order the labels declare them\n * (AGL-3251) — what the settings page expands a category row into.\n *\n * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second\n * map, so a type added there appears under its category without anybody\n * remembering to list it twice.\n */\nexport function notificationTypesInCategory(\n category: NotificationCategory,\n): AglynNotificationType[] {\n return (\n Object.keys(NOTIFICATION_TYPE_LABELS) as AglynNotificationType[]\n ).filter((type) => notificationCategory(type) === category)\n}\n\n/**\n * What ONE scope says about ONE type, with no inheritance applied\n * (AGL-3267) — `undefined` means the row follows its category at that scope.\n *\n * The scope-aware sibling of {@link notificationAccountTypePref}, which stays\n * as the account-only shorthand its callers already use.\n */\nexport function notificationScopeTypePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.accountTypes\n : scope.kind === 'org'\n ? settings?.orgTypes?.[scope.id]\n : settings?.hostTypes?.[scope.id]\n return layer?.[type as AglynNotificationType]?.[channel]\n}\n\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n /*\n * BOTH maps for a scope, categories and types (AGL-3267).\n *\n * Reading only the category map would make a scope whose ONLY answer is a\n * per-type one invisible here — and this list is the single place a person\n * can find an override they set months ago. Silencing one type on one busy\n * site and then being unable to find where you did it is precisely the\n * failure this function exists to prevent.\n */\n const answered = (prefs: Record<string, NotificationChannelPrefs> | undefined) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some((value) => typeof value === 'boolean'),\n )\n const named = (\n categories: Record<string, NotificationCategoryPrefs> | undefined,\n types: Record<string, NotificationTypePrefs> | undefined,\n ) =>\n [\n ...new Set([\n ...Object.keys(categories ?? {}),\n ...Object.keys(types ?? {}),\n ]),\n ]\n .filter(\n (id) =>\n answered(categories?.[id] as Record<string, NotificationChannelPrefs>) ||\n answered(types?.[id] as Record<string, NotificationChannelPrefs>),\n )\n .sort()\n return {\n orgIds: named(settings?.orgs, settings?.orgTypes),\n hostIds: named(settings?.hosts, settings?.hostTypes),\n }\n}\n\n/**\n * Rewrites a stored notification link onto the current URL scheme (AGL-644).\n *\n * A notification's `link` is frozen at write time — `notifyUsers` persists\n * whatever string the emitter passed and never normalizes it. So every\n * notification written before the org-slug migration (AGL-621) and the\n * subdomain migration (AGL-622) still points at a route that no longer\n * exists, and fixing the emitters alone would leave that whole backlog dead.\n * Normalizing when the link is FOLLOWED repairs old and new alike, and keeps\n * working for emitters that haven't been migrated yet.\n *\n * Rewrites, in order:\n * - `/{hostDocId}` or `/{hostDocId}/rest` → `/{orgSlug}/hosts/{subdomain}/rest`\n * - `/org` or `/org/rest` → `/{orgSlug}/rest`\n * - `/hosts` (exactly) → `/{orgSlug}/hosts`\n *\n * Anything already canonical, user-scoped (`/manage/...`), staff (`/admin/...`)\n * or absolute is returned untouched. Every rewrite is gated on having the\n * context it needs, so an unresolvable link degrades to its stored value\n * rather than to a wrong destination.\n *\n * The host rewrite is keyed on the notification's own `hostId` rather than\n * guessing from the path shape, so it can never mistake a real first segment\n * for a doc id.\n *\n * The email copy of a notification goes through it too (AGL-3367): a button\n * in an inbox is a followed link with no console around it to repair it.\n */\nexport function normalizeNotificationLink(\n link: string | undefined | null,\n context: {\n orgSlug?: string | null\n /** The notification's `hostId` (a Firestore doc id). */\n hostId?: string | null\n /** That host's subdomain, which the current routes are keyed by. */\n hostSubdomain?: string | null\n },\n): string | undefined {\n if (!link) return undefined\n // Absolute URLs (staff broadcasts can carry them) are not ours to rewrite.\n if (!link.startsWith('/')) return link\n\n const { orgSlug, hostId, hostSubdomain } = context\n\n if (orgSlug && hostId && hostSubdomain) {\n const prefix = `/${hostId}`\n if (link === prefix || link.startsWith(`${prefix}/`)) {\n return `${buildRoute(Route.HOST_DASHBOARD, {\n orgSlug,\n host: hostSubdomain,\n })}${link.slice(prefix.length)}`\n }\n }\n\n if (orgSlug) {\n if (link === '/org' || link.startsWith('/org/')) {\n return `${buildRoute(Route.ORG_HOME, { orgSlug })}${link.slice(\n '/org'.length,\n )}`\n }\n // Only the bare list — `/hosts/{docId}` would still need a subdomain, and\n // guessing one is worse than leaving the link alone.\n if (link === '/hosts') return buildRoute(Route.HOST_LIST, { orgSlug })\n }\n\n return link\n}\n"],"names":["buildRoute","Route","NOTIFICATION_TYPE_LABELS","NOTIFICATION_CATEGORY_LABELS","billing","team","content","marketplace","support","system","staff","STAFF_NOTIFICATION_CATEGORIES","Set","NOTIFICATION_CATEGORY_DESCRIPTIONS","notificationCategory","type","prefix","String","split","includes","notificationMuted","prefs","DIGEST_PREFS_FIELD","CRM_DAILY_DIGEST_KEY","crmDailyDigestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","value","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","NOTIFICATION_CHANNEL_DEFAULTS","console","email","NOTIFICATION_SELF_SENT_EMAIL_TYPES","OPERATOR_ALERT_NOTIFICATION_TYPES","notificationChannelEnabled","settings","channel","scope","legacyPrefs","category","staffWide","has","layers","accountTypes","account","hostId","hostTypes","undefined","hosts","orgTypes","orgs","layer","answer","notificationAccountTypePref","notificationTypesInCategory","Object","keys","filter","notificationScopeTypePref","kind","id","notificationScopePref","notificationOverriddenScopes","answered","values","some","channels","named","categories","types","sort","orgIds","hostIds","normalizeNotificationLink","link","context","startsWith","orgSlug","hostSubdomain","HOST_DASHBOARD","host","slice","length","ORG_HOME","HOST_LIST"],"mappings":"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"}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* are already-loaded data — a node map, resolved products, a recipient — and
|
|
42
42
|
* the loading stays with the caller that has the credentials for it.
|
|
43
43
|
*/
|
|
44
|
-
import { type EmailRenderProduct } from '@aglyn/shared-util-email/email-render';
|
|
44
|
+
import { type EmailChrome, type EmailRenderProduct, type EmailTheme } from '@aglyn/shared-util-email/email-render';
|
|
45
45
|
import { type MergeTagRecipient } from '@aglyn/shared-util-email/email-merge';
|
|
46
46
|
/**
|
|
47
47
|
* A designed email, loaded. `nodes` is the DECODED besigner map: the stored
|
|
@@ -55,6 +55,12 @@ export interface DesignedEmailTemplate {
|
|
|
55
55
|
preheader?: string;
|
|
56
56
|
/** The template's own subject, used when the campaign names none. */
|
|
57
57
|
subject?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The design as saved, its reusable blocks still placements, when `nodes`
|
|
60
|
+
* has them grafted in: an unmarked placed block keeps the site's chrome off
|
|
61
|
+
* the design (AGL-3372). Absent reads `nodes` itself.
|
|
62
|
+
*/
|
|
63
|
+
storedNodes?: Record<string, unknown>;
|
|
58
64
|
}
|
|
59
65
|
/**
|
|
60
66
|
* WHERE THE MESSAGE COMES FROM — one source, named.
|
|
@@ -133,6 +139,20 @@ export interface RecipientEmailRenderInput {
|
|
|
133
139
|
hostId?: string;
|
|
134
140
|
/** The recipient's signed opt-out link, appended to the text part. */
|
|
135
141
|
unsubscribeUrl?: string;
|
|
142
|
+
/**
|
|
143
|
+
* The site's header, footer and theme (AGL-3370). The header and footer
|
|
144
|
+
* are drawn around a typed message, and around a design less any band the
|
|
145
|
+
* design draws itself (AGL-3372). The theme applies to both: a design's
|
|
146
|
+
* picked colors are palette tokens only the site's theme resolves. Absent
|
|
147
|
+
* sends the typed message in the unbranded card and drops a design's
|
|
148
|
+
* tokens, as the composer's own checks do.
|
|
149
|
+
*/
|
|
150
|
+
frame?: {
|
|
151
|
+
chrome: EmailChrome;
|
|
152
|
+
theme?: EmailTheme;
|
|
153
|
+
mediaOrigin?: string;
|
|
154
|
+
mediaHostId?: string;
|
|
155
|
+
};
|
|
136
156
|
}
|
|
137
157
|
export interface RenderedRecipientEmail {
|
|
138
158
|
/** The subject with this recipient's merge values resolved. */
|
|
@@ -45,7 +45,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
45
45
|
import { EMAIL_NODE_ROOT_ID, renderEmailHtml } from "@aglyn/shared-util-email/email-render";
|
|
46
46
|
import { resolveMergeTags } from "@aglyn/shared-util-email/email-merge";
|
|
47
47
|
import { appendUnsubscribeHtml, UNSUBSCRIBE_FOOTER_LABEL } from "@aglyn/shared-util-email/marketing-send";
|
|
48
|
-
import { renderTextEmailHtml } from "@aglyn/shared-util-email/text-email-html";
|
|
48
|
+
import { renderFramedTextEmail, renderTextEmailHtml } from "@aglyn/shared-util-email/text-email-html";
|
|
49
|
+
import { chromeForDesign } from "@aglyn/shared-util-email/email-design-chrome";
|
|
49
50
|
/*
|
|
50
51
|
* The LEAF app-util, not `@aglyn/aglyn/server`: this module is pure and is
|
|
51
52
|
* imported by client components, so a server entry point here would pull the
|
|
@@ -88,7 +89,7 @@ export function emailPlainTextState(record, /** The design's version as it stand
|
|
|
88
89
|
* subject, so a plain-text campaign is byte-identical either way.
|
|
89
90
|
*/ export function renderRecipientEmail(input) {
|
|
90
91
|
var _recipient_name, _name_split_, _content_template_products;
|
|
91
|
-
var _input_preheader, _content_plainText;
|
|
92
|
+
var _input_preheader, _input_frame, _content_plainText;
|
|
92
93
|
const { content, recipient, siteBase = '', hostId, unsubscribeUrl } = input;
|
|
93
94
|
const template = content.mode === 'design' ? content.template : null;
|
|
94
95
|
const subject = resolveMergeTags(input.subject || (template == null ? void 0 : template.subject) || '', recipient);
|
|
@@ -100,6 +101,22 @@ export function emailPlainTextState(record, /** The design's version as it stand
|
|
|
100
101
|
* write into their own footers from the same constant.
|
|
101
102
|
*/ const unsubscribeLine = unsubscribeUrl ? `\n\n—\n${UNSUBSCRIBE_FOOTER_LABEL}: ${unsubscribeUrl}` : '';
|
|
102
103
|
if (content.mode === 'text') {
|
|
104
|
+
if (input.frame) {
|
|
105
|
+
const messageText = resolveMergeTags(content.body, recipient);
|
|
106
|
+
// The same message in the site's header and footer; the opt-out still
|
|
107
|
+
// follows everything, as it does on a design.
|
|
108
|
+
const framed = renderFramedTextEmail(_extends({
|
|
109
|
+
text: messageText,
|
|
110
|
+
subject,
|
|
111
|
+
preheader
|
|
112
|
+
}, input.frame));
|
|
113
|
+
return {
|
|
114
|
+
subject,
|
|
115
|
+
html: unsubscribeUrl ? appendUnsubscribeHtml(framed.html, unsubscribeUrl) : framed.html,
|
|
116
|
+
text: `${framed.text}${unsubscribeLine}`,
|
|
117
|
+
messageText
|
|
118
|
+
};
|
|
119
|
+
}
|
|
103
120
|
const messageText = resolveMergeTags(content.body, recipient);
|
|
104
121
|
const text = `${messageText}${unsubscribeLine}`;
|
|
105
122
|
return {
|
|
@@ -128,7 +145,7 @@ export function emailPlainTextState(record, /** The design's version as it stand
|
|
|
128
145
|
messageText
|
|
129
146
|
};
|
|
130
147
|
}
|
|
131
|
-
const rendered = renderEmailHtml({
|
|
148
|
+
const rendered = renderEmailHtml(_extends({
|
|
132
149
|
nodes: content.template.nodes,
|
|
133
150
|
// Besigner maps are rooted at `_@_`, not the renderer's default `root`:
|
|
134
151
|
// rendering one as `root` finds no root and emits an empty 600px shell.
|
|
@@ -140,7 +157,19 @@ export function emailPlainTextState(record, /** The design's version as it stand
|
|
|
140
157
|
// site-relative; an inbox has no page to resolve it against, so without an
|
|
141
158
|
// origin the renderer drops it.
|
|
142
159
|
mediaOrigin: siteBase || undefined,
|
|
143
|
-
mediaHostId: hostId
|
|
160
|
+
mediaHostId: hostId
|
|
161
|
+
}, (_input_frame = input.frame) == null ? void 0 : _input_frame.theme, (()=>{
|
|
162
|
+
var _content_template_storedNodes;
|
|
163
|
+
var _input_frame;
|
|
164
|
+
const chrome = chromeForDesign({
|
|
165
|
+
stored: (_content_template_storedNodes = content.template.storedNodes) != null ? _content_template_storedNodes : content.template.nodes,
|
|
166
|
+
composed: content.template.nodes,
|
|
167
|
+
chrome: (_input_frame = input.frame) == null ? void 0 : _input_frame.chrome
|
|
168
|
+
});
|
|
169
|
+
return chrome ? {
|
|
170
|
+
chrome
|
|
171
|
+
} : {};
|
|
172
|
+
})(), {
|
|
144
173
|
merge: {
|
|
145
174
|
'contact.email': recipient.email,
|
|
146
175
|
'contact.name': name,
|
|
@@ -157,7 +186,7 @@ export function emailPlainTextState(record, /** The design's version as it stand
|
|
|
157
186
|
})
|
|
158
187
|
];
|
|
159
188
|
}))
|
|
160
|
-
});
|
|
189
|
+
}));
|
|
161
190
|
/*
|
|
162
191
|
* THE PLAIN-TEXT PART: the design's own, unless somebody wrote one.
|
|
163
192
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/recipient-email-render.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * ONE MESSAGE FOR ONE RECIPIENT, RENDERED — the step between a composed email\n * and the two parts that reach an inbox.\n *\n * It sits on the platform's mail rail, in the core, because more than one\n * plugin needs the SAME rendering: the plugin that sends a campaign renders\n * each recipient's copy through it, and the plugin that designs an email\n * checks a design through it before anything is sent. Neither may import the\n * other, and a second renderer in either would be a preview of something else.\n * It composes `@aglyn/shared-util-email`'s primitives with the core's HTML\n * sanitizer, which is why it cannot live in `shared`. Imported by its own\n * subpath; it is not in the barrel.\n *\n * It exists as a shared function because a preview that renders the message a\n * second way is a preview of something else. The class of defect it is meant\n * to catch has bitten twice on this send path — a designed campaign mailed\n * with its product blocks missing, and merge tags that resolved to empty\n * strings for an entire member audience — and neither would have been visible\n * in a preview that did not go through the same renderer, the same merge-tag\n * resolution and the same plain-text synthesis as the send.\n *\n * PURE, and free of `firebase-admin`, so the same call answers in three\n * places: the per-recipient loop in `campaign-send.ts`, the composer's\n * `renderPreview` action, and the console's own client components. Its inputs\n * are already-loaded data — a node map, resolved products, a recipient — and\n * the loading stays with the caller that has the credentials for it.\n */\n\n// By leaf, never the library's index: the index reaches the sender and the\n// health check, which this module has no use for.\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n type EmailRenderProduct,\n} from '@aglyn/shared-util-email/email-render'\nimport {\n resolveMergeTags,\n type MergeTagRecipient,\n} from '@aglyn/shared-util-email/email-merge'\nimport {\n appendUnsubscribeHtml,\n UNSUBSCRIBE_FOOTER_LABEL,\n} from '@aglyn/shared-util-email/marketing-send'\nimport { renderTextEmailHtml } from '@aglyn/shared-util-email/text-email-html'\n/*\n * The LEAF app-util, not `@aglyn/aglyn/server`: this module is pure and is\n * imported by client components, so a server entry point here would pull the\n * Admin SDK into a browser bundle. The email blocks the besigner previews\n * these same nodes under call this same function — so the mailed copy and the\n * previewed copy are one policy over one string rather than two kept in step\n * by hand.\n */\nimport { sanitizeAuthorHtml } from './author-html';\n\n/**\n * A designed email, loaded. `nodes` is the DECODED besigner map: the stored\n * form is compressed msgpack from the first designer save onward, and a\n * caller that passes the raw value renders an empty shell.\n */\nexport interface DesignedEmailTemplate {\n nodes: Record<string, unknown>\n products?: Record<string, EmailRenderProduct | undefined>\n /** The template's own preview line, used when the campaign names none. */\n preheader?: string\n /** The template's own subject, used when the campaign names none. */\n subject?: string\n}\n\n/**\n * WHERE THE MESSAGE COMES FROM — one source, named.\n *\n * A union rather than two optional fields, and that is the whole point. The\n * design already supplies BOTH parts an inbox receives: `renderEmailHtml`\n * returns the HTML and a plain-text rendering of the same nodes, links\n * included. So on a designed send a typed BODY — the whole message of a\n * plain-text email — has no part to occupy, and the earlier shape, which\n * accepted both and read the body only when no template was given, computed it\n * and threw it away without saying so.\n *\n * What an author actually wants there is named for its job instead:\n * `plainText`, the text HALF of a designed message, generated from the design\n * by default. That is a different offer from reinterpreting a body, and\n * deliberately — see the field.\n *\n * Two members and not three: a saved TEMPLATE is a besigner email screen, the\n * same document the design picker offers, so \"designed\" and \"from a template\"\n * name one thing.\n *\n * With the two collapsed into a union the discard is not merely refused, it is\n * unsayable: the designed branch has no `body` in scope to drop, and a caller\n * holding both has to decide which it means before it can call at all.\n */\nexport type RecipientEmailContent =\n | {\n /** Written in the composer; the HTML part is synthesized from it. */\n mode: 'text'\n /** The plain-text body, after any A/B variant override. */\n body: string\n }\n | {\n /** Built in the besigner; the HTML part comes from the nodes. */\n mode: 'design'\n template: DesignedEmailTemplate\n /**\n * The author's OWN plain-text part, replacing the one the design\n * generates. Absent — not empty — means generated.\n *\n * Presence is the whole signal, which is why this is a separate\n * optional field rather than a flag beside a string: a flag and a value\n * are two things that can disagree, and the disagreement is invisible.\n *\n * Worth having because the generated text is good but not perfect. A\n * button and a product keep their URLs in it; an inline link inside an\n * `emailRichtext` block does not — the synthesis strips tags, so the\n * href goes with them. An author who cares about what a text-only\n * reader can actually follow needs a way to say so.\n */\n plainText?: string\n }\n\n/** Which of the two ways one email is written. */\nexport type EmailMessageMode = RecipientEmailContent['mode']\n\n/**\n * Which mode a stored email is in, from the one field that decides it.\n *\n * Naming a template IS being designed — there is no separate flag, and adding\n * one would be a second answer to drift from this one. Read by the composer,\n * by the surfaces that describe a message and by the send path, so a record\n * cannot be shown as one thing and mailed as another.\n */\nexport function emailMessageMode(record: {\n templateScreenId?: string | null\n}): EmailMessageMode {\n return record.templateScreenId ? 'design' : 'text'\n}\n\nexport interface RecipientEmailRenderInput {\n /** The campaign's subject, after any A/B variant override. */\n subject: string\n /** The one source this message is written from. */\n content: RecipientEmailContent\n /** The composer's preview line; overrides the template's own. */\n preheader?: string\n /** Who this copy is being personalized for. */\n recipient: MergeTagRecipient\n /** The site's own public origin, absolutizing media and product links. */\n siteBase?: string\n /** Qualifies an org-scoped media asset for the unauthenticated CDN. */\n hostId?: string\n /** The recipient's signed opt-out link, appended to the text part. */\n unsubscribeUrl?: string\n}\n\nexport interface RenderedRecipientEmail {\n /** The subject with this recipient's merge values resolved. */\n subject: string\n /** The HTML part, which every campaign message carries. */\n html: string\n /** The plain-text part, ending in the opt-out line. */\n text: string\n /**\n * The message's own plain text, WITHOUT the opt-out line.\n *\n * What the composer fills an authored plain-text part from. Prefilling from\n * `text` would fold this send's footer into the stored override, and the\n * next send would append a second one — so the two are reported apart\n * rather than the caller trying to trim one off the other.\n */\n messageText: string\n}\n\n/**\n * WHERE A DESIGNED EMAIL'S PLAIN-TEXT PART COMES FROM, and whether it still\n * describes the design.\n *\n * Two facts a composer has to state rather than imply. An authored text part\n * is not overwritten when the design is edited — losing somebody's writing\n * because they touched the canvas afterwards is not a trade worth making —\n * which leaves the other half of the problem: an override written against a\n * design that has since changed says something the HTML no longer says, and\n * nothing about it is visible. A value that cannot be read scores the same as\n * one that is absent, which is the shape of the defect this whole area is\n * being fixed for.\n *\n * So the design's VERSION is recorded beside the override, and the two are\n * compared here. Pure, and read by the composer, so the notice a merchant sees\n * and the state the record is in cannot disagree.\n */\nexport interface EmailPlainTextState {\n /** `authored` when somebody wrote it; `generated` from the design. */\n source: 'generated' | 'authored'\n /**\n * An authored part written against a design that has moved since.\n *\n * False for a generated part, which cannot go stale — it is derived from\n * whatever the design says at send time — and false when either version is\n * unknown, because an unanswerable question must not render as a warning.\n */\n stale: boolean\n}\n\nexport function emailPlainTextState(\n record: {\n plainText?: string | null\n /** The design version the override was written against. */\n plainTextVersionId?: string | null\n },\n /** The design's version as it stands now. */\n currentVersionId?: string | null,\n): EmailPlainTextState {\n if (!record.plainText?.trim()) return { source: 'generated', stale: false }\n const written = record.plainTextVersionId ?? ''\n return {\n source: 'authored',\n stale: Boolean(written && currentVersionId && written !== currentVersionId),\n }\n}\n\n/**\n * Renders one campaign message for one recipient.\n *\n * The HTML part is produced here rather than left to `sendEmail`'s fallback\n * so that a caller can SEE it. `sendEmail` synthesizes an HTML part from the\n * text of any message that arrives without one — a text-only message has no\n * anchors, so its links are inert and click tracking has nothing to rewrite —\n * and this calls the same `renderTextEmailHtml` with the same text and\n * subject, so a plain-text campaign is byte-identical either way.\n */\nexport function renderRecipientEmail(\n input: RecipientEmailRenderInput,\n): RenderedRecipientEmail {\n const { content, recipient, siteBase = '', hostId, unsubscribeUrl } = input\n const template = content.mode === 'design' ? content.template : null\n const subject = resolveMergeTags(\n input.subject || template?.subject || '',\n recipient,\n )\n const preheader = input.preheader?.trim() || template?.preheader || ''\n const name = (recipient.name ?? '').trim()\n /*\n * The plain-text footer, named for what the link actually opens — see\n * {@link UNSUBSCRIBE_FOOTER_LABEL}, which the non-campaign marketing paths\n * write into their own footers from the same constant.\n */\n const unsubscribeLine = unsubscribeUrl\n ? `\\n\\n—\\n${UNSUBSCRIBE_FOOTER_LABEL}: ${unsubscribeUrl}`\n : ''\n\n if (content.mode === 'text') {\n const messageText = resolveMergeTags(content.body, recipient)\n const text = `${messageText}${unsubscribeLine}`\n return {\n subject,\n /*\n * THE HTML FOOTER IS RENDERED, NOT LINKIFIED OUT OF THE TEXT.\n *\n * Synthesizing the HTML from `text` — footer included — used to work,\n * and produced a footer whose visible words were the raw signed URL:\n * `renderTextEmailHtml` turns a bare URL into an anchor labelled with\n * itself, which is right for a link the AUTHOR typed and wrong for the\n * one line this renderer writes. A hundred characters of `hostId`,\n * `sig` and `cid` is not a thing a person reads, and it buries the only\n * sentence in the message that tells them they can leave one stream\n * instead of all of them.\n *\n * So the message is linkified WITHOUT the footer, and the footer is\n * appended by the same helper the designed branch uses — same styling,\n * same words, tracking-rewritten by the provider exactly as before,\n * because rewriting keys on the `href` and not on the label.\n *\n * The TEXT part keeps the bare URL. Markup is invisible there and an\n * address a reader can copy is the only form that works.\n */\n html: unsubscribeUrl\n ? appendUnsubscribeHtml(\n renderTextEmailHtml(messageText, subject, preheader),\n unsubscribeUrl,\n )\n : renderTextEmailHtml(text, subject, preheader),\n text,\n messageText,\n }\n }\n\n const rendered = renderEmailHtml({\n nodes: content.template.nodes as never,\n // Besigner maps are rooted at `_@_`, not the renderer's default `root`:\n // rendering one as `root` finds no root and emits an empty 600px shell.\n rootId: EMAIL_NODE_ROOT_ID,\n subject,\n preheader,\n sanitize: sanitizeAuthorHtml,\n // An author-picked image is stored as a `media:` reference and resolves\n // site-relative; an inbox has no page to resolve it against, so without an\n // origin the renderer drops it.\n mediaOrigin: siteBase || undefined,\n mediaHostId: hostId,\n merge: {\n 'contact.email': recipient.email,\n 'contact.name': name,\n 'contact.firstName': name.split(/\\s+/)[0] ?? '',\n 'site.url': siteBase,\n unsubscribeUrl: unsubscribeUrl ?? '',\n },\n products: Object.fromEntries(\n Object.entries(content.template.products ?? {}).map(([id, product]) => [\n id,\n product && {\n ...product,\n url: product.url?.startsWith('/')\n ? `${siteBase}${product.url}`\n : product.url,\n },\n ]),\n ),\n })\n /*\n * THE PLAIN-TEXT PART: the design's own, unless somebody wrote one.\n *\n * Merge tags are resolved in an authored part exactly as they are in a\n * plain-text campaign's body — a text part that ships `{{firstName|there}}`\n * literally is worse than the generated one it replaced. The design's own\n * text arrives already substituted, through `renderEmailHtml`'s `merge` map.\n *\n * The opt-out line is appended either way, and it is a BARE URL rather than\n * an anchor: markup is invisible in a text part, so a link a text-only\n * reader can copy is the only form that works. It is the one part of the\n * footer an author cannot write away.\n */\n const authored = content.plainText?.trim()\n const messageText = authored\n ? resolveMergeTags(authored, recipient)\n : rendered.text\n return {\n subject,\n /*\n * THE HTML PART GETS THE SAME FOOTER THE TEXT PART GETS.\n *\n * A designed template carries an opt-out only if its author placed a\n * `{{unsubscribeUrl}}` somewhere, and nothing made them: a template whose\n * footer block says only the copyright line renders an HTML part with no\n * way out of the list at all, which is the half of the message almost\n * every recipient reads. The text part had the link and the HTML did not,\n * so the defect was invisible to anyone reading their own test send.\n *\n * `appendUnsubscribeHtml` is idempotent by URL, so an author who DID\n * place the merge token keeps their own placement and gets no second\n * footer — the same rule `sendEmail` applies to every other marketing\n * path, applied here because the campaign sender passes no `marketing`\n * context and so never reaches that append.\n */\n html: unsubscribeUrl\n ? appendUnsubscribeHtml(rendered.html, unsubscribeUrl)\n : rendered.html,\n text: `${messageText}${unsubscribeLine}`,\n messageText,\n }\n}\n\n/**\n * The merge tags the send path resolves, for the composer to offer.\n *\n * Kept beside the renderer because `resolveMergeTags` recognizes exactly\n * these three and substitutes the fallback for anything else: a tag a\n * merchant types from memory that is not on this list does not fail, it\n * silently renders as nothing in mail that has already gone out. A composer\n * that lists them is the difference between a documented feature and a\n * guess.\n */\nexport const EMAIL_MERGE_TAGS: ReadonlyArray<{\n /** The full token, fallback included, as it should be typed. */\n token: string\n /** What it resolves to, per recipient. */\n description: string\n}> = [\n {\n token: '{{firstName|there}}',\n description: 'The recipient’s first name, or “there” when none is stored',\n },\n {\n token: '{{name}}',\n description: 'The recipient’s full name, where the audience stores one',\n },\n { token: '{{email}}', description: 'The address this copy is going to' },\n]\n"],"names":["EMAIL_NODE_ROOT_ID","renderEmailHtml","resolveMergeTags","appendUnsubscribeHtml","UNSUBSCRIBE_FOOTER_LABEL","renderTextEmailHtml","sanitizeAuthorHtml","emailMessageMode","record","templateScreenId","emailPlainTextState","currentVersionId","plainText","trim","source","stale","written","plainTextVersionId","Boolean","renderRecipientEmail","input","recipient","name","content","siteBase","hostId","unsubscribeUrl","template","mode","subject","preheader","unsubscribeLine","messageText","body","text","html","rendered","nodes","rootId","sanitize","mediaOrigin","undefined","mediaHostId","merge","email","split","products","Object","fromEntries","entries","map","id","product","url","startsWith","authored","EMAIL_MERGE_TAGS","token","description"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/recipient-email-render.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * ONE MESSAGE FOR ONE RECIPIENT, RENDERED — the step between a composed email\n * and the two parts that reach an inbox.\n *\n * It sits on the platform's mail rail, in the core, because more than one\n * plugin needs the SAME rendering: the plugin that sends a campaign renders\n * each recipient's copy through it, and the plugin that designs an email\n * checks a design through it before anything is sent. Neither may import the\n * other, and a second renderer in either would be a preview of something else.\n * It composes `@aglyn/shared-util-email`'s primitives with the core's HTML\n * sanitizer, which is why it cannot live in `shared`. Imported by its own\n * subpath; it is not in the barrel.\n *\n * It exists as a shared function because a preview that renders the message a\n * second way is a preview of something else. The class of defect it is meant\n * to catch has bitten twice on this send path — a designed campaign mailed\n * with its product blocks missing, and merge tags that resolved to empty\n * strings for an entire member audience — and neither would have been visible\n * in a preview that did not go through the same renderer, the same merge-tag\n * resolution and the same plain-text synthesis as the send.\n *\n * PURE, and free of `firebase-admin`, so the same call answers in three\n * places: the per-recipient loop in `campaign-send.ts`, the composer's\n * `renderPreview` action, and the console's own client components. Its inputs\n * are already-loaded data — a node map, resolved products, a recipient — and\n * the loading stays with the caller that has the credentials for it.\n */\n\n// By leaf, never the library's index: the index reaches the sender and the\n// health check, which this module has no use for.\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n type EmailChrome,\n type EmailRenderProduct,\n type EmailTheme,\n} from '@aglyn/shared-util-email/email-render'\nimport {\n resolveMergeTags,\n type MergeTagRecipient,\n} from '@aglyn/shared-util-email/email-merge'\nimport {\n appendUnsubscribeHtml,\n UNSUBSCRIBE_FOOTER_LABEL,\n} from '@aglyn/shared-util-email/marketing-send'\nimport {\n renderFramedTextEmail,\n renderTextEmailHtml,\n} from '@aglyn/shared-util-email/text-email-html'\nimport { chromeForDesign } from '@aglyn/shared-util-email/email-design-chrome'\n/*\n * The LEAF app-util, not `@aglyn/aglyn/server`: this module is pure and is\n * imported by client components, so a server entry point here would pull the\n * Admin SDK into a browser bundle. The email blocks the besigner previews\n * these same nodes under call this same function — so the mailed copy and the\n * previewed copy are one policy over one string rather than two kept in step\n * by hand.\n */\nimport { sanitizeAuthorHtml } from './author-html';\n\n/**\n * A designed email, loaded. `nodes` is the DECODED besigner map: the stored\n * form is compressed msgpack from the first designer save onward, and a\n * caller that passes the raw value renders an empty shell.\n */\nexport interface DesignedEmailTemplate {\n nodes: Record<string, unknown>\n products?: Record<string, EmailRenderProduct | undefined>\n /** The template's own preview line, used when the campaign names none. */\n preheader?: string\n /** The template's own subject, used when the campaign names none. */\n subject?: string\n /**\n * The design as saved, its reusable blocks still placements, when `nodes`\n * has them grafted in: an unmarked placed block keeps the site's chrome off\n * the design (AGL-3372). Absent reads `nodes` itself.\n */\n storedNodes?: Record<string, unknown>\n}\n\n/**\n * WHERE THE MESSAGE COMES FROM — one source, named.\n *\n * A union rather than two optional fields, and that is the whole point. The\n * design already supplies BOTH parts an inbox receives: `renderEmailHtml`\n * returns the HTML and a plain-text rendering of the same nodes, links\n * included. So on a designed send a typed BODY — the whole message of a\n * plain-text email — has no part to occupy, and the earlier shape, which\n * accepted both and read the body only when no template was given, computed it\n * and threw it away without saying so.\n *\n * What an author actually wants there is named for its job instead:\n * `plainText`, the text HALF of a designed message, generated from the design\n * by default. That is a different offer from reinterpreting a body, and\n * deliberately — see the field.\n *\n * Two members and not three: a saved TEMPLATE is a besigner email screen, the\n * same document the design picker offers, so \"designed\" and \"from a template\"\n * name one thing.\n *\n * With the two collapsed into a union the discard is not merely refused, it is\n * unsayable: the designed branch has no `body` in scope to drop, and a caller\n * holding both has to decide which it means before it can call at all.\n */\nexport type RecipientEmailContent =\n | {\n /** Written in the composer; the HTML part is synthesized from it. */\n mode: 'text'\n /** The plain-text body, after any A/B variant override. */\n body: string\n }\n | {\n /** Built in the besigner; the HTML part comes from the nodes. */\n mode: 'design'\n template: DesignedEmailTemplate\n /**\n * The author's OWN plain-text part, replacing the one the design\n * generates. Absent — not empty — means generated.\n *\n * Presence is the whole signal, which is why this is a separate\n * optional field rather than a flag beside a string: a flag and a value\n * are two things that can disagree, and the disagreement is invisible.\n *\n * Worth having because the generated text is good but not perfect. A\n * button and a product keep their URLs in it; an inline link inside an\n * `emailRichtext` block does not — the synthesis strips tags, so the\n * href goes with them. An author who cares about what a text-only\n * reader can actually follow needs a way to say so.\n */\n plainText?: string\n }\n\n/** Which of the two ways one email is written. */\nexport type EmailMessageMode = RecipientEmailContent['mode']\n\n/**\n * Which mode a stored email is in, from the one field that decides it.\n *\n * Naming a template IS being designed — there is no separate flag, and adding\n * one would be a second answer to drift from this one. Read by the composer,\n * by the surfaces that describe a message and by the send path, so a record\n * cannot be shown as one thing and mailed as another.\n */\nexport function emailMessageMode(record: {\n templateScreenId?: string | null\n}): EmailMessageMode {\n return record.templateScreenId ? 'design' : 'text'\n}\n\nexport interface RecipientEmailRenderInput {\n /** The campaign's subject, after any A/B variant override. */\n subject: string\n /** The one source this message is written from. */\n content: RecipientEmailContent\n /** The composer's preview line; overrides the template's own. */\n preheader?: string\n /** Who this copy is being personalized for. */\n recipient: MergeTagRecipient\n /** The site's own public origin, absolutizing media and product links. */\n siteBase?: string\n /** Qualifies an org-scoped media asset for the unauthenticated CDN. */\n hostId?: string\n /** The recipient's signed opt-out link, appended to the text part. */\n unsubscribeUrl?: string\n /**\n * The site's header, footer and theme (AGL-3370). The header and footer\n * are drawn around a typed message, and around a design less any band the\n * design draws itself (AGL-3372). The theme applies to both: a design's\n * picked colors are palette tokens only the site's theme resolves. Absent\n * sends the typed message in the unbranded card and drops a design's\n * tokens, as the composer's own checks do.\n */\n frame?: {\n chrome: EmailChrome\n theme?: EmailTheme\n mediaOrigin?: string\n mediaHostId?: string\n }\n}\n\nexport interface RenderedRecipientEmail {\n /** The subject with this recipient's merge values resolved. */\n subject: string\n /** The HTML part, which every campaign message carries. */\n html: string\n /** The plain-text part, ending in the opt-out line. */\n text: string\n /**\n * The message's own plain text, WITHOUT the opt-out line.\n *\n * What the composer fills an authored plain-text part from. Prefilling from\n * `text` would fold this send's footer into the stored override, and the\n * next send would append a second one — so the two are reported apart\n * rather than the caller trying to trim one off the other.\n */\n messageText: string\n}\n\n/**\n * WHERE A DESIGNED EMAIL'S PLAIN-TEXT PART COMES FROM, and whether it still\n * describes the design.\n *\n * Two facts a composer has to state rather than imply. An authored text part\n * is not overwritten when the design is edited — losing somebody's writing\n * because they touched the canvas afterwards is not a trade worth making —\n * which leaves the other half of the problem: an override written against a\n * design that has since changed says something the HTML no longer says, and\n * nothing about it is visible. A value that cannot be read scores the same as\n * one that is absent, which is the shape of the defect this whole area is\n * being fixed for.\n *\n * So the design's VERSION is recorded beside the override, and the two are\n * compared here. Pure, and read by the composer, so the notice a merchant sees\n * and the state the record is in cannot disagree.\n */\nexport interface EmailPlainTextState {\n /** `authored` when somebody wrote it; `generated` from the design. */\n source: 'generated' | 'authored'\n /**\n * An authored part written against a design that has moved since.\n *\n * False for a generated part, which cannot go stale — it is derived from\n * whatever the design says at send time — and false when either version is\n * unknown, because an unanswerable question must not render as a warning.\n */\n stale: boolean\n}\n\nexport function emailPlainTextState(\n record: {\n plainText?: string | null\n /** The design version the override was written against. */\n plainTextVersionId?: string | null\n },\n /** The design's version as it stands now. */\n currentVersionId?: string | null,\n): EmailPlainTextState {\n if (!record.plainText?.trim()) return { source: 'generated', stale: false }\n const written = record.plainTextVersionId ?? ''\n return {\n source: 'authored',\n stale: Boolean(written && currentVersionId && written !== currentVersionId),\n }\n}\n\n/**\n * Renders one campaign message for one recipient.\n *\n * The HTML part is produced here rather than left to `sendEmail`'s fallback\n * so that a caller can SEE it. `sendEmail` synthesizes an HTML part from the\n * text of any message that arrives without one — a text-only message has no\n * anchors, so its links are inert and click tracking has nothing to rewrite —\n * and this calls the same `renderTextEmailHtml` with the same text and\n * subject, so a plain-text campaign is byte-identical either way.\n */\nexport function renderRecipientEmail(\n input: RecipientEmailRenderInput,\n): RenderedRecipientEmail {\n const { content, recipient, siteBase = '', hostId, unsubscribeUrl } = input\n const template = content.mode === 'design' ? content.template : null\n const subject = resolveMergeTags(\n input.subject || template?.subject || '',\n recipient,\n )\n const preheader = input.preheader?.trim() || template?.preheader || ''\n const name = (recipient.name ?? '').trim()\n /*\n * The plain-text footer, named for what the link actually opens — see\n * {@link UNSUBSCRIBE_FOOTER_LABEL}, which the non-campaign marketing paths\n * write into their own footers from the same constant.\n */\n const unsubscribeLine = unsubscribeUrl\n ? `\\n\\n—\\n${UNSUBSCRIBE_FOOTER_LABEL}: ${unsubscribeUrl}`\n : ''\n\n if (content.mode === 'text') {\n if (input.frame) {\n const messageText = resolveMergeTags(content.body, recipient)\n // The same message in the site's header and footer; the opt-out still\n // follows everything, as it does on a design.\n const framed = renderFramedTextEmail({\n text: messageText,\n subject,\n preheader,\n ...input.frame,\n })\n return {\n subject,\n html: unsubscribeUrl\n ? appendUnsubscribeHtml(framed.html, unsubscribeUrl)\n : framed.html,\n text: `${framed.text}${unsubscribeLine}`,\n messageText,\n }\n }\n const messageText = resolveMergeTags(content.body, recipient)\n const text = `${messageText}${unsubscribeLine}`\n return {\n subject,\n /*\n * THE HTML FOOTER IS RENDERED, NOT LINKIFIED OUT OF THE TEXT.\n *\n * Synthesizing the HTML from `text` — footer included — used to work,\n * and produced a footer whose visible words were the raw signed URL:\n * `renderTextEmailHtml` turns a bare URL into an anchor labelled with\n * itself, which is right for a link the AUTHOR typed and wrong for the\n * one line this renderer writes. A hundred characters of `hostId`,\n * `sig` and `cid` is not a thing a person reads, and it buries the only\n * sentence in the message that tells them they can leave one stream\n * instead of all of them.\n *\n * So the message is linkified WITHOUT the footer, and the footer is\n * appended by the same helper the designed branch uses — same styling,\n * same words, tracking-rewritten by the provider exactly as before,\n * because rewriting keys on the `href` and not on the label.\n *\n * The TEXT part keeps the bare URL. Markup is invisible there and an\n * address a reader can copy is the only form that works.\n */\n html: unsubscribeUrl\n ? appendUnsubscribeHtml(\n renderTextEmailHtml(messageText, subject, preheader),\n unsubscribeUrl,\n )\n : renderTextEmailHtml(text, subject, preheader),\n text,\n messageText,\n }\n }\n\n\n const rendered = renderEmailHtml({\n nodes: content.template.nodes as never,\n // Besigner maps are rooted at `_@_`, not the renderer's default `root`:\n // rendering one as `root` finds no root and emits an empty 600px shell.\n rootId: EMAIL_NODE_ROOT_ID,\n subject,\n preheader,\n sanitize: sanitizeAuthorHtml,\n // An author-picked image is stored as a `media:` reference and resolves\n // site-relative; an inbox has no page to resolve it against, so without an\n // origin the renderer drops it.\n mediaOrigin: siteBase || undefined,\n mediaHostId: hostId,\n // The site's theme resolves the colors the merchant picked (AGL-3370),\n // and its chrome frames the design, less the bands it draws (AGL-3372).\n ...input.frame?.theme,\n ...(() => {\n const chrome = chromeForDesign({\n stored: content.template.storedNodes ?? content.template.nodes,\n composed: content.template.nodes,\n chrome: input.frame?.chrome,\n })\n return chrome ? { chrome } : {}\n })(),\n merge: {\n 'contact.email': recipient.email,\n 'contact.name': name,\n 'contact.firstName': name.split(/\\s+/)[0] ?? '',\n 'site.url': siteBase,\n unsubscribeUrl: unsubscribeUrl ?? '',\n },\n products: Object.fromEntries(\n Object.entries(content.template.products ?? {}).map(([id, product]) => [\n id,\n product && {\n ...product,\n url: product.url?.startsWith('/')\n ? `${siteBase}${product.url}`\n : product.url,\n },\n ]),\n ),\n })\n /*\n * THE PLAIN-TEXT PART: the design's own, unless somebody wrote one.\n *\n * Merge tags are resolved in an authored part exactly as they are in a\n * plain-text campaign's body — a text part that ships `{{firstName|there}}`\n * literally is worse than the generated one it replaced. The design's own\n * text arrives already substituted, through `renderEmailHtml`'s `merge` map.\n *\n * The opt-out line is appended either way, and it is a BARE URL rather than\n * an anchor: markup is invisible in a text part, so a link a text-only\n * reader can copy is the only form that works. It is the one part of the\n * footer an author cannot write away.\n */\n const authored = content.plainText?.trim()\n const messageText = authored\n ? resolveMergeTags(authored, recipient)\n : rendered.text\n return {\n subject,\n /*\n * THE HTML PART GETS THE SAME FOOTER THE TEXT PART GETS.\n *\n * A designed template carries an opt-out only if its author placed a\n * `{{unsubscribeUrl}}` somewhere, and nothing made them: a template whose\n * footer block says only the copyright line renders an HTML part with no\n * way out of the list at all, which is the half of the message almost\n * every recipient reads. The text part had the link and the HTML did not,\n * so the defect was invisible to anyone reading their own test send.\n *\n * `appendUnsubscribeHtml` is idempotent by URL, so an author who DID\n * place the merge token keeps their own placement and gets no second\n * footer — the same rule `sendEmail` applies to every other marketing\n * path, applied here because the campaign sender passes no `marketing`\n * context and so never reaches that append.\n */\n html: unsubscribeUrl\n ? appendUnsubscribeHtml(rendered.html, unsubscribeUrl)\n : rendered.html,\n text: `${messageText}${unsubscribeLine}`,\n messageText,\n }\n}\n\n/**\n * The merge tags the send path resolves, for the composer to offer.\n *\n * Kept beside the renderer because `resolveMergeTags` recognizes exactly\n * these three and substitutes the fallback for anything else: a tag a\n * merchant types from memory that is not on this list does not fail, it\n * silently renders as nothing in mail that has already gone out. A composer\n * that lists them is the difference between a documented feature and a\n * guess.\n */\nexport const EMAIL_MERGE_TAGS: ReadonlyArray<{\n /** The full token, fallback included, as it should be typed. */\n token: string\n /** What it resolves to, per recipient. */\n description: string\n}> = [\n {\n token: '{{firstName|there}}',\n description: 'The recipient’s first name, or “there” when none is stored',\n },\n {\n token: '{{name}}',\n description: 'The recipient’s full name, where the audience stores one',\n },\n { token: '{{email}}', description: 'The address this copy is going to' },\n]\n"],"names":["EMAIL_NODE_ROOT_ID","renderEmailHtml","resolveMergeTags","appendUnsubscribeHtml","UNSUBSCRIBE_FOOTER_LABEL","renderFramedTextEmail","renderTextEmailHtml","chromeForDesign","sanitizeAuthorHtml","emailMessageMode","record","templateScreenId","emailPlainTextState","currentVersionId","plainText","trim","source","stale","written","plainTextVersionId","Boolean","renderRecipientEmail","input","recipient","name","content","siteBase","hostId","unsubscribeUrl","template","mode","subject","preheader","unsubscribeLine","frame","messageText","body","framed","text","html","rendered","nodes","rootId","sanitize","mediaOrigin","undefined","mediaHostId","theme","chrome","stored","storedNodes","composed","merge","email","split","products","Object","fromEntries","entries","map","id","product","url","startsWith","authored","EMAIL_MERGE_TAGS","token","description"],"mappings":"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"}
|
|
@@ -59,7 +59,7 @@ export * from './crm-task-reminders';
|
|
|
59
59
|
export * from './crm-inbound';
|
|
60
60
|
export * from './member-email-aliases';
|
|
61
61
|
export * from './crm-next-activity';
|
|
62
|
-
export { csvCell, csvDocument, customImportTarget, customImportTargetKey, } from './csv-import';
|
|
62
|
+
export { csvCell, csvDocument, customImportTarget, customImportTargetKey, importNoMailServerSentence, } from './csv-import';
|
|
63
63
|
export type { ImportChunkResult, ImportDroppedValue, ImportSkippedRow, } from './csv-import';
|
|
64
64
|
export * from './contact-custom-fields';
|
|
65
65
|
export * from './consent-groups';
|
|
@@ -122,7 +122,7 @@ export * from "./crm-next-activity.js";
|
|
|
122
122
|
// mapped column names for a custom field — named rather than starred, because
|
|
123
123
|
// an importer built on this file exports the shared ceilings under its own
|
|
124
124
|
// names.
|
|
125
|
-
export { csvCell, csvDocument, customImportTarget, customImportTargetKey } from "./csv-import.js";
|
|
125
|
+
export { csvCell, csvDocument, customImportTarget, customImportTargetKey, importNoMailServerSentence } from "./csv-import.js";
|
|
126
126
|
// What a VALUE under a custom contact field may be, and the coercion every
|
|
127
127
|
// door applies to one (AGL-2601): a mapped form field, an API `custom` body,
|
|
128
128
|
// and the carry-over that keeps a mapping across a publish. Pure like `crm`.
|