@aglyn/shared-util-email 1.0.0-beta.181 → 1.0.0-beta.183
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/README.md +29 -0
- package/package.json +3 -3
- package/src/index.d.ts +2 -0
- package/src/index.js +3 -0
- package/src/index.js.map +1 -1
- package/src/lib/host-email-render.d.ts +37 -5
- package/src/lib/host-email-render.js +16 -3
- package/src/lib/host-email-render.js.map +1 -1
- package/src/lib/list-unsubscribe.d.ts +193 -0
- package/src/lib/list-unsubscribe.js +221 -0
- package/src/lib/list-unsubscribe.js.map +1 -0
- package/src/lib/marketing-send.js +4 -4
- package/src/lib/marketing-send.js.map +1 -1
- package/src/lib/sending-domain.d.ts +57 -0
- package/src/lib/sending-domain.js +19 -3
- package/src/lib/sending-domain.js.map +1 -1
- package/src/lib/tracking-host.d.ts +197 -0
- package/src/lib/tracking-host.js +186 -0
- package/src/lib/tracking-host.js.map +1 -0
package/README.md
CHANGED
|
@@ -76,6 +76,35 @@ this point, so ordinary senders never meet the guard — it is there because
|
|
|
76
76
|
`RESEND_SEND_ENDPOINT` is exported and a module that fetches it directly
|
|
77
77
|
bypasses every check `sendEmail()` owns.
|
|
78
78
|
|
|
79
|
+
### The List-Unsubscribe setting
|
|
80
|
+
|
|
81
|
+
`list-unsubscribe.ts` is the one place the `List-Unsubscribe` /
|
|
82
|
+
`List-Unsubscribe-Post` (RFC 8058) pair is modeled, for every sender that makes
|
|
83
|
+
it a setting — an outreach sequence (default off) and a marketing campaign
|
|
84
|
+
(default on):
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import {
|
|
88
|
+
decideListUnsubscribe,
|
|
89
|
+
listUnsubscribeHeaders,
|
|
90
|
+
readListUnsubscribeSetting,
|
|
91
|
+
} from '@aglyn/shared-util-email/list-unsubscribe'
|
|
92
|
+
|
|
93
|
+
const requested = readListUnsubscribeSetting(stored.listUnsubscribe, true)
|
|
94
|
+
const decision = decideListUnsubscribe({
|
|
95
|
+
requested,
|
|
96
|
+
recentVolume, // the organization's campaign messages in the last day
|
|
97
|
+
sendVolume, // everyone this email will address
|
|
98
|
+
})
|
|
99
|
+
const headers = decision.on ? listUnsubscribeHeaders({ url: oneClickUrl }) : {}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`decideListUnsubscribe` is the bulk-sender guard: with the setting off, a send
|
|
103
|
+
that takes the organization to `EMAIL_LIST_UNSUBSCRIBE_BULK_THRESHOLD` messages
|
|
104
|
+
(default 5,000, Gmail's and Yahoo's line) carries the pair anyway, and the
|
|
105
|
+
decision says it was forced and why. The headers are plain headers, so any
|
|
106
|
+
provider behind `sendEmail` delivers them.
|
|
107
|
+
|
|
79
108
|
## Checking configuration
|
|
80
109
|
|
|
81
110
|
```typescript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-util-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.183",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
28
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.183",
|
|
29
|
+
"@aglyn/shared-util-http": "1.0.0-beta.183",
|
|
30
30
|
"@msgpack/msgpack": "^3.1.3",
|
|
31
31
|
"@swc/helpers": "0.5.23"
|
|
32
32
|
},
|
package/src/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export * from './lib/send-rate';
|
|
|
19
19
|
export * from './lib/sender-reputation';
|
|
20
20
|
export * from './lib/send-ceilings';
|
|
21
21
|
export * from './lib/sending-domain';
|
|
22
|
+
export * from './lib/tracking-host';
|
|
22
23
|
export * from './lib/sending-mailbox';
|
|
23
24
|
export * from './lib/host-sender';
|
|
24
25
|
export * from './lib/platform-sending-domain';
|
|
@@ -29,6 +30,7 @@ export * from './lib/host-email-render';
|
|
|
29
30
|
export * from './lib/email-render';
|
|
30
31
|
export * from './lib/text-email-html';
|
|
31
32
|
export * from './lib/marketing-send';
|
|
33
|
+
export * from './lib/list-unsubscribe';
|
|
32
34
|
export * from './lib/email-delivery-events';
|
|
33
35
|
export * from './lib/received-email';
|
|
34
36
|
export * from './lib/email-revenue-window';
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export * from "./lib/send-rate.js";
|
|
|
18
18
|
export * from "./lib/sender-reputation.js";
|
|
19
19
|
export * from "./lib/send-ceilings.js";
|
|
20
20
|
export * from "./lib/sending-domain.js";
|
|
21
|
+
export * from "./lib/tracking-host.js";
|
|
21
22
|
export * from "./lib/sending-mailbox.js";
|
|
22
23
|
export * from "./lib/host-sender.js";
|
|
23
24
|
export * from "./lib/platform-sending-domain.js";
|
|
@@ -28,6 +29,8 @@ export * from "./lib/host-email-render.js";
|
|
|
28
29
|
export * from "./lib/email-render.js";
|
|
29
30
|
export * from "./lib/text-email-html.js";
|
|
30
31
|
export * from "./lib/marketing-send.js";
|
|
32
|
+
// The List-Unsubscribe setting sequences and campaigns share (AGL-3307).
|
|
33
|
+
export * from "./lib/list-unsubscribe.js";
|
|
31
34
|
export * from "./lib/email-delivery-events.js";
|
|
32
35
|
// The received-mail reader the CRM's capture route files from (AGL-2657).
|
|
33
36
|
// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:
|
package/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/shared/util/email/src/index.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\nexport * from './lib/send-email'\nexport * from './lib/send-rate'\nexport * from './lib/sender-reputation'\nexport * from './lib/send-ceilings'\nexport * from './lib/sending-domain'\nexport * from './lib/sending-mailbox'\nexport * from './lib/host-sender'\nexport * from './lib/platform-sending-domain'\nexport * from './lib/email-health'\nexport * from './lib/system-email-catalog'\nexport * from './lib/tenant-email-catalog'\nexport * from './lib/host-email-render'\nexport * from './lib/email-render'\nexport * from './lib/text-email-html'\nexport * from './lib/marketing-send'\nexport * from './lib/email-delivery-events'\n// The received-mail reader the CRM's capture route files from (AGL-2657).\n// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:\n// it holds a `crypto` HMAC, and this barrel is reached from the browser\n// through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its\n// own path — `@aglyn/shared-util-email/svix-signature`.\nexport * from './lib/received-email'\nexport * from './lib/email-revenue-window'\n// Exported so the console drift guard can pin it against @aglyn/aglyn.\nexport * from './lib/email-media-src'\n// Same arrangement, same reason (AGL-1223): a copy of `decodeStoredNodes`\n// this scope:shared lib may not import, pinned by `email-nodes-drift.spec.ts`.\nexport * from './lib/stored-email-nodes'\nexport * from './lib/email-merge'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sBAAkB;AAChC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB;AACvC,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,2BAAuB;AACrC,cAAc,uBAAmB;AACjC,cAAc,mCAA+B;AAC7C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,gCAA4B;AAC1C,cAAc,6BAAyB;AACvC,cAAc,wBAAoB;AAClC,cAAc,2BAAuB;AACrC,cAAc,0BAAsB;AACpC,cAAc,iCAA6B;AAC3C,0EAA0E;AAC1E,4EAA4E;AAC5E,wEAAwE;AACxE,6EAA6E;AAC7E,wDAAwD;AACxD,cAAc,0BAAsB;AACpC,cAAc,gCAA4B;AAC1C,uEAAuE;AACvE,cAAc,2BAAuB;AACrC,0EAA0E;AAC1E,+EAA+E;AAC/E,cAAc,8BAA0B;AACxC,cAAc,uBAAmB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/shared/util/email/src/index.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\nexport * from './lib/send-email'\nexport * from './lib/send-rate'\nexport * from './lib/sender-reputation'\nexport * from './lib/send-ceilings'\nexport * from './lib/sending-domain'\nexport * from './lib/tracking-host'\nexport * from './lib/sending-mailbox'\nexport * from './lib/host-sender'\nexport * from './lib/platform-sending-domain'\nexport * from './lib/email-health'\nexport * from './lib/system-email-catalog'\nexport * from './lib/tenant-email-catalog'\nexport * from './lib/host-email-render'\nexport * from './lib/email-render'\nexport * from './lib/text-email-html'\nexport * from './lib/marketing-send'\n// The List-Unsubscribe setting sequences and campaigns share (AGL-3307).\nexport * from './lib/list-unsubscribe'\nexport * from './lib/email-delivery-events'\n// The received-mail reader the CRM's capture route files from (AGL-2657).\n// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:\n// it holds a `crypto` HMAC, and this barrel is reached from the browser\n// through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its\n// own path — `@aglyn/shared-util-email/svix-signature`.\nexport * from './lib/received-email'\nexport * from './lib/email-revenue-window'\n// Exported so the console drift guard can pin it against @aglyn/aglyn.\nexport * from './lib/email-media-src'\n// Same arrangement, same reason (AGL-1223): a copy of `decodeStoredNodes`\n// this scope:shared lib may not import, pinned by `email-nodes-drift.spec.ts`.\nexport * from './lib/stored-email-nodes'\nexport * from './lib/email-merge'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sBAAkB;AAChC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB;AACvC,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,yBAAqB;AACnC,cAAc,2BAAuB;AACrC,cAAc,uBAAmB;AACjC,cAAc,mCAA+B;AAC7C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,gCAA4B;AAC1C,cAAc,6BAAyB;AACvC,cAAc,wBAAoB;AAClC,cAAc,2BAAuB;AACrC,cAAc,0BAAsB;AACpC,yEAAyE;AACzE,cAAc,4BAAwB;AACtC,cAAc,iCAA6B;AAC3C,0EAA0E;AAC1E,4EAA4E;AAC5E,wEAAwE;AACxE,6EAA6E;AAC7E,wDAAwD;AACxD,cAAc,0BAAsB;AACpC,cAAc,gCAA4B;AAC1C,uEAAuE;AACvE,cAAc,2BAAuB;AACrC,0EAA0E;AAC1E,+EAA+E;AAC/E,cAAc,8BAA0B;AACxC,cAAc,uBAAmB"}
|
|
@@ -41,6 +41,36 @@ export interface RenderedHostEmail {
|
|
|
41
41
|
html: string;
|
|
42
42
|
text: string;
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Expands the reusable blocks a site's email places into the blocks they stand
|
|
46
|
+
* for (AGL-3287): a shared header or footer is a `reusableInstance` node naming
|
|
47
|
+
* one of the site's components, and `renderEmailHtml` draws an unknown node as
|
|
48
|
+
* nothing at all. Given the decoded node map and the handle and site it was
|
|
49
|
+
* read with; resolves to the map to render.
|
|
50
|
+
*
|
|
51
|
+
* REQUIRED, for the reason `sanitize` is (see
|
|
52
|
+
* {@link EmailRenderOptions.sanitize}): this module is `scope:shared` and the
|
|
53
|
+
* composition — the component graft, the document reads — is the core's, which
|
|
54
|
+
* nx forbids it to import. A composer a caller may omit is one a new send path
|
|
55
|
+
* omits, and its customers then receive every designed email with the header
|
|
56
|
+
* and footer missing while the canvas shows them in place. So the type makes
|
|
57
|
+
* each caller hand one in; every sender passes the core's
|
|
58
|
+
* `composeHostComponentNodes`.
|
|
59
|
+
*
|
|
60
|
+
* Only style overrides are lost on the way through, and not by the composer:
|
|
61
|
+
* a placement's `styleOverrides` land in `sx`, which the mail renderer never
|
|
62
|
+
* reads. Property values and attribute overrides reach the blocks.
|
|
63
|
+
*/
|
|
64
|
+
export type HostEmailComposer = (nodes: Record<string, unknown>, context: {
|
|
65
|
+
firestore: AdminFirestoreLike;
|
|
66
|
+
hostId: string;
|
|
67
|
+
}) => Promise<Record<string, unknown>>;
|
|
68
|
+
export interface LoadHostEmailOptions {
|
|
69
|
+
/** The site's origin when the caller already knows it; saves the host read. */
|
|
70
|
+
origin?: string;
|
|
71
|
+
/** See {@link HostEmailComposer} — required, deliberately. */
|
|
72
|
+
compose: HostEmailComposer;
|
|
73
|
+
}
|
|
44
74
|
/**
|
|
45
75
|
* A site-owner-designed template loaded once, ready to render for any number
|
|
46
76
|
* of recipients without another Firestore read (AGL-770) — the booking
|
|
@@ -75,10 +105,13 @@ export interface LoadedHostEmail {
|
|
|
75
105
|
* published still costs a single read — and it is skipped entirely when the
|
|
76
106
|
* caller already knows the origin and passes it. Still once per batch, not
|
|
77
107
|
* once per recipient, which is the property AGL-770 cared about.
|
|
108
|
+
*
|
|
109
|
+
* The components the template places are read and grafted HERE, through
|
|
110
|
+
* `options.compose`, for the same reason: once per load, so a batch renders
|
|
111
|
+
* every recipient's copy from one composed map. A template that places none
|
|
112
|
+
* costs no extra read.
|
|
78
113
|
*/
|
|
79
|
-
export declare function loadHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, options
|
|
80
|
-
origin?: string;
|
|
81
|
-
}): Promise<LoadedHostEmail | null>;
|
|
114
|
+
export declare function loadHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, options: LoadHostEmailOptions): Promise<LoadedHostEmail | null>;
|
|
82
115
|
/**
|
|
83
116
|
* Renders a pre-loaded template for one recipient's merge values (AGL-770).
|
|
84
117
|
* No Firestore access; returns `null` if the node map renders empty so the
|
|
@@ -91,8 +124,7 @@ export declare function renderLoadedHostEmail(loaded: LoadedHostEmail, merge: Re
|
|
|
91
124
|
* convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a
|
|
92
125
|
* batch should call those two so it reads the template once.
|
|
93
126
|
*/
|
|
94
|
-
export declare function renderHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, merge: Record<string, string>, options: {
|
|
95
|
-
origin?: string;
|
|
127
|
+
export declare function renderHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, merge: Record<string, string>, options: LoadHostEmailOptions & {
|
|
96
128
|
/** See {@link EmailRenderOptions.sanitize} — required, deliberately. */
|
|
97
129
|
sanitize: EmailRenderOptions['sanitize'];
|
|
98
130
|
}): Promise<RenderedHostEmail | null>;
|
|
@@ -36,7 +36,12 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
|
|
|
36
36
|
* published still costs a single read — and it is skipped entirely when the
|
|
37
37
|
* caller already knows the origin and passes it. Still once per batch, not
|
|
38
38
|
* once per recipient, which is the property AGL-770 cared about.
|
|
39
|
-
|
|
39
|
+
*
|
|
40
|
+
* The components the template places are read and grafted HERE, through
|
|
41
|
+
* `options.compose`, for the same reason: once per load, so a batch renders
|
|
42
|
+
* every recipient's copy from one composed map. A template that places none
|
|
43
|
+
* costs no extra read.
|
|
44
|
+
*/ export async function loadHostEmail(firestore, hostId, templateKey, options) {
|
|
40
45
|
const entry = getTenantEmail(templateKey);
|
|
41
46
|
if (!entry || !isTenantEmailEditable(entry)) return null;
|
|
42
47
|
if (!hostId) return null;
|
|
@@ -58,8 +63,16 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
|
|
|
58
63
|
// a `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test
|
|
59
64
|
// passes and the caller renders an empty email instead of falling back to
|
|
60
65
|
// its built-in copy (AGL-1223).
|
|
61
|
-
const
|
|
62
|
-
if (!
|
|
66
|
+
const stored = decodeEmailNodes(versionSnapshot.get('nodes'));
|
|
67
|
+
if (!stored || !Object.keys(stored).length) return null;
|
|
68
|
+
// Reusable blocks (AGL-3287), BEFORE anything reads the tree: a header or
|
|
69
|
+
// footer the site owner placed is a component reference until this runs.
|
|
70
|
+
// A failed component read throws into the catch below and falls back to
|
|
71
|
+
// the built-in copy — never a send with half its design missing.
|
|
72
|
+
const nodes = await options.compose(stored, {
|
|
73
|
+
firestore,
|
|
74
|
+
hostId
|
|
75
|
+
});
|
|
63
76
|
let origin = options.origin;
|
|
64
77
|
if (!origin) {
|
|
65
78
|
const hostSnapshot = await hostRef.get();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/host-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\nimport { hostEmailOrigin } from './email-media-src'\nimport { decodeEmailNodes } from './stored-email-nodes'\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailRenderOptions,\n} from './email-render'\nimport {\n getTenantEmail,\n isTenantEmailEditable,\n TENANT_EMAIL_COLLECTION,\n type TenantEmailEntry,\n} from './tenant-email-catalog'\n\n/**\n * The slice of the Admin Firestore chain this resolver uses. Structural on\n * purpose (AGL-770): the plugins that call this already hold an Admin\n * Firestore instance, so passing it in keeps `@aglyn/shared-util-email` free\n * of a firebase-admin dependency and importable from every send site.\n */\ninterface AdminDocSnapshotLike {\n exists: boolean\n get(field: string): unknown\n}\ninterface AdminDocRefLike {\n get(): Promise<AdminDocSnapshotLike>\n collection(path: string): AdminCollectionRefLike\n}\ninterface AdminCollectionRefLike {\n doc(id: string): AdminDocRefLike\n}\nexport interface AdminFirestoreLike {\n collection(path: string): AdminCollectionRefLike\n}\n\nexport interface RenderedHostEmail {\n subject: string\n html: string\n text: string\n}\n\n/**\n * A site-owner-designed template loaded once, ready to render for any number\n * of recipients without another Firestore read (AGL-770) — the booking\n * reminder job renders one per booking.\n */\nexport interface LoadedHostEmail {\n entry: TenantEmailEntry\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n /** Host doc id, so an org-scoped media reference can be host-qualified. */\n hostId: string\n /**\n * The site's absolute origin, for absolutizing picked images (AGL-1224).\n * Undefined only for a host with neither a custom domain nor a subdomain,\n * in which case those images are dropped rather than sent broken.\n */\n origin?: string\n}\n\n/** Blanks any `{{token}}` the caller did not supply, so a customer never\n * sees a raw tag. Mirrors the platform system-email resolver. */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Loads a site's published email template, or `null` when there is nothing\n * usable (no document, no published version, an empty node map, an unknown or\n * non-designable key). The host-scoped mirror of the platform\n * `loadSystemEmail`: `hosts/{hostId}/emailTemplates/{key}`.\n *\n * The caller passes its own Admin Firestore, so this reads through the Admin\n * SDK — the send path runs as the server, not a signed-in user.\n *\n * TWO reads, not one (AGL-1224): the host document is read for the site's\n * origin, which picked images need to be fetchable from an inbox. It is read\n * LAST, only once there is something to render, so a site with nothing\n * published still costs a single read — and it is skipped entirely when the\n * caller already knows the origin and passes it. Still once per batch, not\n * once per recipient, which is the property AGL-770 cared about.\n */\nexport async function loadHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmail | null> {\n const entry = getTenantEmail(templateKey)\n if (!entry || !isTenantEmailEditable(entry)) return null\n if (!hostId) return null\n\n try {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const templateRef = hostRef\n .collection(TENANT_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return null\n\n const versionId = templateSnapshot.get('versionId')\n if (!versionId) return null\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms. The email besigner compresses like every other\n // besigner document (AGL-1151), and every version written before it did\n // is still a plain map — a plain map comes back unchanged, so a single\n // call serves both and keeps serving both.\n //\n // Reading the field raw is the failure the guard below cannot catch: over\n // a `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and the caller renders an empty email instead of falling back to\n // its built-in copy (AGL-1223).\n const nodes = decodeEmailNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) return null\n\n let origin = options.origin\n if (!origin) {\n const hostSnapshot = await hostRef.get()\n origin = hostEmailOrigin({\n cname: hostSnapshot.get('cname') as string | undefined,\n subdomain: hostSnapshot.get('subdomain') as string | undefined,\n })\n }\n\n return {\n entry,\n nodes,\n hostId,\n origin,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n entry.defaultSubject ||\n '',\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n }\n } catch (error) {\n // Never let a template problem block the send — the caller falls back to\n // its built-in copy and the customer still gets their email.\n console.error(`host email template ${templateKey} failed to load`, error)\n return null\n }\n}\n\n/**\n * Renders a pre-loaded template for one recipient's merge values (AGL-770).\n * No Firestore access; returns `null` if the node map renders empty so the\n * caller still falls back to its built-in copy.\n */\nexport function renderLoadedHostEmail(\n loaded: LoadedHostEmail,\n merge: Record<string, string> = {},\n sanitize: EmailRenderOptions['sanitize'],\n): RenderedHostEmail | null {\n const rendered = renderEmailHtml({\n nodes: loaded.nodes as never,\n // A site owner designs these in the besigner, so a template's richtext is\n // author markup exactly as a campaign's is. The policy has to arrive from\n // above for the reason this lib documents everywhere: `scope:shared`\n // cannot import the aglyn-scoped sanitizer.\n sanitize,\n // Besigner maps are rooted at '_@_' (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merge),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merge),\n merge,\n // A picked image is stored as a reference; the site's own origin is what\n // makes it fetchable from a recipient's inbox (AGL-1224).\n mediaOrigin: loaded.origin,\n mediaHostId: loaded.hostId,\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merge),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n }\n}\n\n/**\n * Renders a site's designed email, or `null` when nothing usable is published\n * so the send site keeps its built-in copy (AGL-770). Single-recipient\n * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a\n * batch should call those two so it reads the template once.\n */\nexport async function renderHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: {\n origin?: string\n /** See {@link EmailRenderOptions.sanitize} — required, deliberately. */\n sanitize: EmailRenderOptions['sanitize']\n },\n): Promise<RenderedHostEmail | null> {\n const loaded = await loadHostEmail(firestore, hostId, templateKey, options)\n return loaded ? renderLoadedHostEmail(loaded, merge, options.sanitize) : null\n}\n"],"names":["hostEmailOrigin","decodeEmailNodes","EMAIL_NODE_ROOT_ID","renderEmailHtml","substituteMergeTokens","getTenantEmail","isTenantEmailEditable","TENANT_EMAIL_COLLECTION","blankUnresolvedTokens","value","replace","loadHostEmail","firestore","hostId","templateKey","options","entry","templateSnapshot","hostRef","collection","doc","templateRef","get","exists","versionId","versionSnapshot","String","nodes","Object","keys","length","origin","hostSnapshot","cname","subdomain","subjectTemplate","defaultSubject","preheaderTemplate","error","console","renderLoadedHostEmail","loaded","merge","sanitize","rendered","rootId","subject","preheader","mediaOrigin","mediaHostId","html","text","renderHostEmail"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/host-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\nimport { hostEmailOrigin } from './email-media-src'\nimport { decodeEmailNodes } from './stored-email-nodes'\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailRenderOptions,\n} from './email-render'\nimport {\n getTenantEmail,\n isTenantEmailEditable,\n TENANT_EMAIL_COLLECTION,\n type TenantEmailEntry,\n} from './tenant-email-catalog'\n\n/**\n * The slice of the Admin Firestore chain this resolver uses. Structural on\n * purpose (AGL-770): the plugins that call this already hold an Admin\n * Firestore instance, so passing it in keeps `@aglyn/shared-util-email` free\n * of a firebase-admin dependency and importable from every send site.\n */\ninterface AdminDocSnapshotLike {\n exists: boolean\n get(field: string): unknown\n}\ninterface AdminDocRefLike {\n get(): Promise<AdminDocSnapshotLike>\n collection(path: string): AdminCollectionRefLike\n}\ninterface AdminCollectionRefLike {\n doc(id: string): AdminDocRefLike\n}\nexport interface AdminFirestoreLike {\n collection(path: string): AdminCollectionRefLike\n}\n\nexport interface RenderedHostEmail {\n subject: string\n html: string\n text: string\n}\n\n/**\n * Expands the reusable blocks a site's email places into the blocks they stand\n * for (AGL-3287): a shared header or footer is a `reusableInstance` node naming\n * one of the site's components, and `renderEmailHtml` draws an unknown node as\n * nothing at all. Given the decoded node map and the handle and site it was\n * read with; resolves to the map to render.\n *\n * REQUIRED, for the reason `sanitize` is (see\n * {@link EmailRenderOptions.sanitize}): this module is `scope:shared` and the\n * composition — the component graft, the document reads — is the core's, which\n * nx forbids it to import. A composer a caller may omit is one a new send path\n * omits, and its customers then receive every designed email with the header\n * and footer missing while the canvas shows them in place. So the type makes\n * each caller hand one in; every sender passes the core's\n * `composeHostComponentNodes`.\n *\n * Only style overrides are lost on the way through, and not by the composer:\n * a placement's `styleOverrides` land in `sx`, which the mail renderer never\n * reads. Property values and attribute overrides reach the blocks.\n */\nexport type HostEmailComposer = (\n nodes: Record<string, unknown>,\n context: { firestore: AdminFirestoreLike; hostId: string },\n) => Promise<Record<string, unknown>>\n\nexport interface LoadHostEmailOptions {\n /** The site's origin when the caller already knows it; saves the host read. */\n origin?: string\n /** See {@link HostEmailComposer} — required, deliberately. */\n compose: HostEmailComposer\n}\n\n/**\n * A site-owner-designed template loaded once, ready to render for any number\n * of recipients without another Firestore read (AGL-770) — the booking\n * reminder job renders one per booking.\n */\nexport interface LoadedHostEmail {\n entry: TenantEmailEntry\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n /** Host doc id, so an org-scoped media reference can be host-qualified. */\n hostId: string\n /**\n * The site's absolute origin, for absolutizing picked images (AGL-1224).\n * Undefined only for a host with neither a custom domain nor a subdomain,\n * in which case those images are dropped rather than sent broken.\n */\n origin?: string\n}\n\n/** Blanks any `{{token}}` the caller did not supply, so a customer never\n * sees a raw tag. Mirrors the platform system-email resolver. */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Loads a site's published email template, or `null` when there is nothing\n * usable (no document, no published version, an empty node map, an unknown or\n * non-designable key). The host-scoped mirror of the platform\n * `loadSystemEmail`: `hosts/{hostId}/emailTemplates/{key}`.\n *\n * The caller passes its own Admin Firestore, so this reads through the Admin\n * SDK — the send path runs as the server, not a signed-in user.\n *\n * TWO reads, not one (AGL-1224): the host document is read for the site's\n * origin, which picked images need to be fetchable from an inbox. It is read\n * LAST, only once there is something to render, so a site with nothing\n * published still costs a single read — and it is skipped entirely when the\n * caller already knows the origin and passes it. Still once per batch, not\n * once per recipient, which is the property AGL-770 cared about.\n *\n * The components the template places are read and grafted HERE, through\n * `options.compose`, for the same reason: once per load, so a batch renders\n * every recipient's copy from one composed map. A template that places none\n * costs no extra read.\n */\nexport async function loadHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n options: LoadHostEmailOptions,\n): Promise<LoadedHostEmail | null> {\n const entry = getTenantEmail(templateKey)\n if (!entry || !isTenantEmailEditable(entry)) return null\n if (!hostId) return null\n\n try {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const templateRef = hostRef\n .collection(TENANT_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return null\n\n const versionId = templateSnapshot.get('versionId')\n if (!versionId) return null\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms. The email besigner compresses like every other\n // besigner document (AGL-1151), and every version written before it did\n // is still a plain map — a plain map comes back unchanged, so a single\n // call serves both and keeps serving both.\n //\n // Reading the field raw is the failure the guard below cannot catch: over\n // a `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and the caller renders an empty email instead of falling back to\n // its built-in copy (AGL-1223).\n const stored = decodeEmailNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!stored || !Object.keys(stored).length) return null\n // Reusable blocks (AGL-3287), BEFORE anything reads the tree: a header or\n // footer the site owner placed is a component reference until this runs.\n // A failed component read throws into the catch below and falls back to\n // the built-in copy — never a send with half its design missing.\n const nodes = await options.compose(stored, { firestore, hostId })\n\n let origin = options.origin\n if (!origin) {\n const hostSnapshot = await hostRef.get()\n origin = hostEmailOrigin({\n cname: hostSnapshot.get('cname') as string | undefined,\n subdomain: hostSnapshot.get('subdomain') as string | undefined,\n })\n }\n\n return {\n entry,\n nodes,\n hostId,\n origin,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n entry.defaultSubject ||\n '',\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n }\n } catch (error) {\n // Never let a template problem block the send — the caller falls back to\n // its built-in copy and the customer still gets their email.\n console.error(`host email template ${templateKey} failed to load`, error)\n return null\n }\n}\n\n/**\n * Renders a pre-loaded template for one recipient's merge values (AGL-770).\n * No Firestore access; returns `null` if the node map renders empty so the\n * caller still falls back to its built-in copy.\n */\nexport function renderLoadedHostEmail(\n loaded: LoadedHostEmail,\n merge: Record<string, string> = {},\n sanitize: EmailRenderOptions['sanitize'],\n): RenderedHostEmail | null {\n const rendered = renderEmailHtml({\n nodes: loaded.nodes as never,\n // A site owner designs these in the besigner, so a template's richtext is\n // author markup exactly as a campaign's is. The policy has to arrive from\n // above for the reason this lib documents everywhere: `scope:shared`\n // cannot import the aglyn-scoped sanitizer.\n sanitize,\n // Besigner maps are rooted at '_@_' (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merge),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merge),\n merge,\n // A picked image is stored as a reference; the site's own origin is what\n // makes it fetchable from a recipient's inbox (AGL-1224).\n mediaOrigin: loaded.origin,\n mediaHostId: loaded.hostId,\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merge),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n }\n}\n\n/**\n * Renders a site's designed email, or `null` when nothing usable is published\n * so the send site keeps its built-in copy (AGL-770). Single-recipient\n * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a\n * batch should call those two so it reads the template once.\n */\nexport async function renderHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: LoadHostEmailOptions & {\n /** See {@link EmailRenderOptions.sanitize} — required, deliberately. */\n sanitize: EmailRenderOptions['sanitize']\n },\n): Promise<RenderedHostEmail | null> {\n const loaded = await loadHostEmail(firestore, hostId, templateKey, options)\n return loaded ? renderLoadedHostEmail(loaded, merge, options.sanitize) : null\n}\n"],"names":["hostEmailOrigin","decodeEmailNodes","EMAIL_NODE_ROOT_ID","renderEmailHtml","substituteMergeTokens","getTenantEmail","isTenantEmailEditable","TENANT_EMAIL_COLLECTION","blankUnresolvedTokens","value","replace","loadHostEmail","firestore","hostId","templateKey","options","entry","templateSnapshot","hostRef","collection","doc","templateRef","get","exists","versionId","versionSnapshot","String","stored","Object","keys","length","nodes","compose","origin","hostSnapshot","cname","subdomain","subjectTemplate","defaultSubject","preheaderTemplate","error","console","renderLoadedHostEmail","loaded","merge","sanitize","rendered","rootId","subject","preheader","mediaOrigin","mediaHostId","html","text","renderHostEmail"],"mappings":"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"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* THE MAIL-CLIENT UNSUBSCRIBE BUTTON, AS A SETTING (AGL-3296, AGL-3307).
|
|
19
|
+
*
|
|
20
|
+
* `List-Unsubscribe` plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
|
|
21
|
+
* (RFC 8058) is what makes Gmail, Yahoo, Apple Mail and Outlook draw their own
|
|
22
|
+
* Unsubscribe button above a message. It is a per-sender SETTING in two
|
|
23
|
+
* places, with opposite defaults:
|
|
24
|
+
*
|
|
25
|
+
* | Sender | Default | Why |
|
|
26
|
+
* | --- | --- | --- |
|
|
27
|
+
* | An outreach sequence | off | one-to-one mail; the header makes it read as a mailing list |
|
|
28
|
+
* | A marketing campaign | on | bulk mail; Gmail and Yahoo require one-click from bulk senders |
|
|
29
|
+
*
|
|
30
|
+
* Everything the two share lives here, so neither keeps a copy: how a stored
|
|
31
|
+
* value is read ({@link readListUnsubscribeSetting}), how the header pair is
|
|
32
|
+
* written ({@link listUnsubscribeHeaders}), how a send decides whether it can
|
|
33
|
+
* carry the pair it promised ({@link resolveListUnsubscribe}), and — for bulk
|
|
34
|
+
* mail — the guard that turns the header back on when a send makes the
|
|
35
|
+
* organization a bulk sender ({@link decideListUnsubscribe}).
|
|
36
|
+
*
|
|
37
|
+
* ## What the setting never touches
|
|
38
|
+
*
|
|
39
|
+
* The visible way out. A campaign's footer link and a sequence's "reply 'no'"
|
|
40
|
+
* line are in the body whatever this says, because they are what CAN-SPAM
|
|
41
|
+
* and the recipient rely on; the header is a convenience a mail client may or
|
|
42
|
+
* may not draw. The routes that answer a one-click POST keep answering every
|
|
43
|
+
* link already sent, whatever the setting says now.
|
|
44
|
+
*
|
|
45
|
+
* ## Provider-neutral
|
|
46
|
+
*
|
|
47
|
+
* Nothing here knows who delivers the mail. The pair is two ordinary headers,
|
|
48
|
+
* handed to whatever transport the deployment runs — `sendEmail`'s provider
|
|
49
|
+
* seam for campaigns, the mailbox's own API for a sequence — so a self-hosted
|
|
50
|
+
* deployment on any provider gets the same behavior.
|
|
51
|
+
*/
|
|
52
|
+
/** The one value RFC 8058 defines for `List-Unsubscribe-Post`. */
|
|
53
|
+
export declare const LIST_UNSUBSCRIBE_ONE_CLICK = "List-Unsubscribe=One-Click";
|
|
54
|
+
/**
|
|
55
|
+
* A stored `listUnsubscribe` setting, read with the sender's own default.
|
|
56
|
+
*
|
|
57
|
+
* Only a real boolean is a choice. Anything else — absent on every record
|
|
58
|
+
* written before the setting existed, `null`, a string — reads as the
|
|
59
|
+
* default, which is `false` for a sequence and `true` for a campaign. So a
|
|
60
|
+
* stored sequence never grows a header it did not ask for, and a stored
|
|
61
|
+
* campaign never loses one.
|
|
62
|
+
*/
|
|
63
|
+
export declare function readListUnsubscribeSetting(raw: unknown, defaultOn: boolean): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The `List-Unsubscribe` header pair for one message, or `{}`.
|
|
66
|
+
*
|
|
67
|
+
* `url` is the signed HTTPS link a mailbox provider POSTs with nobody present,
|
|
68
|
+
* and `mailto` an address that unsubscribes whoever writes to it. Either can
|
|
69
|
+
* stand alone in `List-Unsubscribe`; `List-Unsubscribe-Post` is written only
|
|
70
|
+
* beside a URL, because advertising one-click with nothing to post to
|
|
71
|
+
* promises a verb nothing serves.
|
|
72
|
+
*/
|
|
73
|
+
export declare function listUnsubscribeHeaders(input: {
|
|
74
|
+
url?: string | null;
|
|
75
|
+
mailto?: string | null;
|
|
76
|
+
}): Record<string, string>;
|
|
77
|
+
/** The pair one send carries, or why it carries none. */
|
|
78
|
+
export type ListUnsubscribeResolution = {
|
|
79
|
+
status: 'off';
|
|
80
|
+
} | {
|
|
81
|
+
status: 'unavailable';
|
|
82
|
+
} | {
|
|
83
|
+
status: 'ready';
|
|
84
|
+
url: string;
|
|
85
|
+
mailto: string | null;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Decides what one send's `List-Unsubscribe` header says.
|
|
89
|
+
*
|
|
90
|
+
* `off` unless `enabled` is exactly `true`. On, the URL is minted — and the
|
|
91
|
+
* mailto too, when the sender has one (`mintMailto` present) — and the result
|
|
92
|
+
* is `unavailable` when any part cannot be: no signing secret, no HTTPS
|
|
93
|
+
* origin, no mailbox address. What a caller does with `unavailable` is its
|
|
94
|
+
* own policy; a sequence holds the send rather than leave without the way out
|
|
95
|
+
* it promised.
|
|
96
|
+
*/
|
|
97
|
+
export declare function resolveListUnsubscribe(input: {
|
|
98
|
+
enabled: boolean | null | undefined;
|
|
99
|
+
mintUrl: () => string | null | undefined;
|
|
100
|
+
mintMailto?: () => string | null | undefined;
|
|
101
|
+
}): ListUnsubscribeResolution;
|
|
102
|
+
/**
|
|
103
|
+
* Messages in {@link LIST_UNSUBSCRIBE_BULK_WINDOW_MS} at which an organization
|
|
104
|
+
* is a bulk sender and a campaign's header is turned back on.
|
|
105
|
+
*
|
|
106
|
+
* 5,000 is the figure Gmail and Yahoo publish for their bulk-sender rules, and
|
|
107
|
+
* one-click unsubscribe is one of those rules: a bulk sender without it is
|
|
108
|
+
* rejected or foldered as spam. Their count is per mailbox provider, which a
|
|
109
|
+
* send cannot see (a Google Workspace address is on a company's own domain),
|
|
110
|
+
* and a count to any one provider can never exceed the organization's total —
|
|
111
|
+
* so the total is the count, and it is never smaller than the provider's.
|
|
112
|
+
*/
|
|
113
|
+
export declare const LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT = 5000;
|
|
114
|
+
/** The environment variable a deployment overrides the threshold with. */
|
|
115
|
+
export declare const LIST_UNSUBSCRIBE_BULK_THRESHOLD_ENV = "EMAIL_LIST_UNSUBSCRIBE_BULK_THRESHOLD";
|
|
116
|
+
/** The window the threshold counts over. */
|
|
117
|
+
export declare const LIST_UNSUBSCRIBE_BULK_WINDOW_MS = 86400000;
|
|
118
|
+
/**
|
|
119
|
+
* The deployment's bulk threshold.
|
|
120
|
+
*
|
|
121
|
+
* Unset, blank or unreadable resolves to the published default, never to
|
|
122
|
+
* something smaller or larger by accident: a typo must not silently move a
|
|
123
|
+
* deliverability control. A positive whole number is taken as written — a
|
|
124
|
+
* self-hoster whose provider or audience draws the line elsewhere sets it
|
|
125
|
+
* lower or higher. There is no value that disables the guard.
|
|
126
|
+
*
|
|
127
|
+
* Read per call rather than captured at module load, because these run in
|
|
128
|
+
* serverless handlers whose module may be evaluated during a build, before
|
|
129
|
+
* the runtime env exists.
|
|
130
|
+
*/
|
|
131
|
+
export declare function listUnsubscribeBulkThreshold(raw?: unknown): number;
|
|
132
|
+
/** Why a send carries the header its setting turned off. */
|
|
133
|
+
export type ListUnsubscribeForcedReason =
|
|
134
|
+
/** The send takes the organization to the bulk threshold. */
|
|
135
|
+
'bulk-volume'
|
|
136
|
+
/**
|
|
137
|
+
* The send leaves on the shared pooled identity, whose reputation is every
|
|
138
|
+
* site's on it, and which refuses marketing mail without a way out.
|
|
139
|
+
*/
|
|
140
|
+
| 'pooled-identity'
|
|
141
|
+
/**
|
|
142
|
+
* The organization's recent volume could not be read, so whether the send
|
|
143
|
+
* reaches the threshold is unknown — and the header is the safe answer.
|
|
144
|
+
*/
|
|
145
|
+
| 'volume-unknown';
|
|
146
|
+
/** What one send does with the header, and why. */
|
|
147
|
+
export interface ListUnsubscribeDecision {
|
|
148
|
+
/** What the sender's setting asked for. */
|
|
149
|
+
requested: boolean;
|
|
150
|
+
/** Whether the send carries the header. */
|
|
151
|
+
on: boolean;
|
|
152
|
+
/** True when the setting said off and the send carries it anyway. */
|
|
153
|
+
forced: boolean;
|
|
154
|
+
/** Why it was forced; null when it was not. */
|
|
155
|
+
reason: ListUnsubscribeForcedReason | null;
|
|
156
|
+
/**
|
|
157
|
+
* The organization's volume the decision was made on: what it had sent in
|
|
158
|
+
* the window plus what this send will address.
|
|
159
|
+
*/
|
|
160
|
+
volume: number;
|
|
161
|
+
/** The threshold it was compared with. */
|
|
162
|
+
threshold: number;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Whether one bulk send carries the header, honoring the sender's setting
|
|
166
|
+
* unless the send would make the organization a bulk sender.
|
|
167
|
+
*
|
|
168
|
+
* `recentVolume` is what the organization has already sent in the window and
|
|
169
|
+
* `sendVolume` what this send will address in total, so the question asked is
|
|
170
|
+
* the one the rule asks — does the organization reach the threshold — and a
|
|
171
|
+
* send that starts under it and would finish over it is caught at its start
|
|
172
|
+
* rather than halfway through. Reaching the threshold counts, because the
|
|
173
|
+
* rule's line is "5,000 or more".
|
|
174
|
+
*
|
|
175
|
+
* `forcedBefore` keeps a decision made for an earlier part of the same send:
|
|
176
|
+
* a campaign delivered over several batches does not drop the header halfway
|
|
177
|
+
* through because a day rolled over.
|
|
178
|
+
*/
|
|
179
|
+
export declare function decideListUnsubscribe(input: {
|
|
180
|
+
requested: boolean;
|
|
181
|
+
recentVolume: number;
|
|
182
|
+
sendVolume: number;
|
|
183
|
+
threshold?: number;
|
|
184
|
+
pooled?: boolean;
|
|
185
|
+
forcedBefore?: ListUnsubscribeForcedReason | null;
|
|
186
|
+
}): ListUnsubscribeDecision;
|
|
187
|
+
/**
|
|
188
|
+
* One sentence for a forced decision, for the page that shows it.
|
|
189
|
+
*
|
|
190
|
+
* Written here rather than in a component so the send record, the log line
|
|
191
|
+
* and the campaign page say the same thing.
|
|
192
|
+
*/
|
|
193
|
+
export declare function listUnsubscribeForcedDetail(decision: Pick<ListUnsubscribeDecision, 'reason' | 'volume' | 'threshold'>): string;
|