@aglyn/shared-util-email 1.0.0-beta.181 → 1.0.0-beta.182

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 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.181",
3
+ "version": "1.0.0-beta.182",
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.181",
29
- "@aglyn/shared-util-http": "1.0.0-beta.181",
28
+ "@aglyn/shared-data-enums": "1.0.0-beta.182",
29
+ "@aglyn/shared-util-http": "1.0.0-beta.182",
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"}
@@ -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;
@@ -0,0 +1,221 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * 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
+ */ /** The one value RFC 8058 defines for `List-Unsubscribe-Post`. */ export const LIST_UNSUBSCRIBE_ONE_CLICK = 'List-Unsubscribe=One-Click';
52
+ /*==========================================
53
+ * THE SETTING.
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
+ */ export function readListUnsubscribeSetting(raw, defaultOn) {
63
+ return typeof raw === 'boolean' ? raw : defaultOn;
64
+ }
65
+ /*==========================================
66
+ * THE HEADERS.
67
+ *=========================================*/ /**
68
+ * The `List-Unsubscribe` header pair for one message, or `{}`.
69
+ *
70
+ * `url` is the signed HTTPS link a mailbox provider POSTs with nobody present,
71
+ * and `mailto` an address that unsubscribes whoever writes to it. Either can
72
+ * stand alone in `List-Unsubscribe`; `List-Unsubscribe-Post` is written only
73
+ * beside a URL, because advertising one-click with nothing to post to
74
+ * promises a verb nothing serves.
75
+ */ export function listUnsubscribeHeaders(input) {
76
+ var _input_url, _input_mailto;
77
+ const url = String((_input_url = input.url) != null ? _input_url : '').trim();
78
+ const mailto = String((_input_mailto = input.mailto) != null ? _input_mailto : '').trim();
79
+ const uris = [
80
+ url,
81
+ mailto
82
+ ].filter(Boolean);
83
+ if (!uris.length) return {};
84
+ return _extends({
85
+ 'List-Unsubscribe': uris.map((uri)=>`<${uri}>`).join(', ')
86
+ }, url ? {
87
+ 'List-Unsubscribe-Post': LIST_UNSUBSCRIBE_ONE_CLICK
88
+ } : {});
89
+ }
90
+ /**
91
+ * Decides what one send's `List-Unsubscribe` header says.
92
+ *
93
+ * `off` unless `enabled` is exactly `true`. On, the URL is minted — and the
94
+ * mailto too, when the sender has one (`mintMailto` present) — and the result
95
+ * is `unavailable` when any part cannot be: no signing secret, no HTTPS
96
+ * origin, no mailbox address. What a caller does with `unavailable` is its
97
+ * own policy; a sequence holds the send rather than leave without the way out
98
+ * it promised.
99
+ */ export function resolveListUnsubscribe(input) {
100
+ if (input.enabled !== true) return {
101
+ status: 'off'
102
+ };
103
+ const url = input.mintUrl() || '';
104
+ if (!url) return {
105
+ status: 'unavailable'
106
+ };
107
+ if (!input.mintMailto) return {
108
+ status: 'ready',
109
+ url,
110
+ mailto: null
111
+ };
112
+ const mailto = input.mintMailto() || '';
113
+ return mailto ? {
114
+ status: 'ready',
115
+ url,
116
+ mailto
117
+ } : {
118
+ status: 'unavailable'
119
+ };
120
+ }
121
+ /*==========================================
122
+ * THE BULK-SENDER GUARD (AGL-3307).
123
+ *=========================================*/ /**
124
+ * Messages in {@link LIST_UNSUBSCRIBE_BULK_WINDOW_MS} at which an organization
125
+ * is a bulk sender and a campaign's header is turned back on.
126
+ *
127
+ * 5,000 is the figure Gmail and Yahoo publish for their bulk-sender rules, and
128
+ * one-click unsubscribe is one of those rules: a bulk sender without it is
129
+ * rejected or foldered as spam. Their count is per mailbox provider, which a
130
+ * send cannot see (a Google Workspace address is on a company's own domain),
131
+ * and a count to any one provider can never exceed the organization's total —
132
+ * so the total is the count, and it is never smaller than the provider's.
133
+ */ export const LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT = 5000;
134
+ /** The environment variable a deployment overrides the threshold with. */ export const LIST_UNSUBSCRIBE_BULK_THRESHOLD_ENV = 'EMAIL_LIST_UNSUBSCRIBE_BULK_THRESHOLD';
135
+ /** The window the threshold counts over. */ export const LIST_UNSUBSCRIBE_BULK_WINDOW_MS = 86400000;
136
+ /**
137
+ * The deployment's bulk threshold.
138
+ *
139
+ * Unset, blank or unreadable resolves to the published default, never to
140
+ * something smaller or larger by accident: a typo must not silently move a
141
+ * deliverability control. A positive whole number is taken as written — a
142
+ * self-hoster whose provider or audience draws the line elsewhere sets it
143
+ * lower or higher. There is no value that disables the guard.
144
+ *
145
+ * Read per call rather than captured at module load, because these run in
146
+ * serverless handlers whose module may be evaluated during a build, before
147
+ * the runtime env exists.
148
+ */ export function listUnsubscribeBulkThreshold(raw = (()=>{
149
+ var _process_env;
150
+ return typeof process === 'undefined' ? undefined : (_process_env = process.env) == null ? void 0 : _process_env[LIST_UNSUBSCRIBE_BULK_THRESHOLD_ENV];
151
+ })()) {
152
+ if (raw === null || raw === undefined || String(raw).trim() === '') {
153
+ return LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT;
154
+ }
155
+ const value = Number(raw);
156
+ if (!Number.isFinite(value) || value < 1) {
157
+ return LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT;
158
+ }
159
+ return Math.floor(value);
160
+ }
161
+ /**
162
+ * Whether one bulk send carries the header, honoring the sender's setting
163
+ * unless the send would make the organization a bulk sender.
164
+ *
165
+ * `recentVolume` is what the organization has already sent in the window and
166
+ * `sendVolume` what this send will address in total, so the question asked is
167
+ * the one the rule asks — does the organization reach the threshold — and a
168
+ * send that starts under it and would finish over it is caught at its start
169
+ * rather than halfway through. Reaching the threshold counts, because the
170
+ * rule's line is "5,000 or more".
171
+ *
172
+ * `forcedBefore` keeps a decision made for an earlier part of the same send:
173
+ * a campaign delivered over several batches does not drop the header halfway
174
+ * through because a day rolled over.
175
+ */ export function decideListUnsubscribe(input) {
176
+ var _input_forcedBefore;
177
+ const count = (raw)=>Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
178
+ const threshold = listUnsubscribeBulkThreshold(input.threshold);
179
+ const volume = count(input.recentVolume) + count(input.sendVolume);
180
+ const base = {
181
+ requested: input.requested,
182
+ volume,
183
+ threshold
184
+ };
185
+ if (input.requested) {
186
+ return _extends({}, base, {
187
+ on: true,
188
+ forced: false,
189
+ reason: null
190
+ });
191
+ }
192
+ const reason = input.pooled ? 'pooled-identity' : volume >= threshold ? 'bulk-volume' : (_input_forcedBefore = input.forcedBefore) != null ? _input_forcedBefore : null;
193
+ return reason ? _extends({}, base, {
194
+ on: true,
195
+ forced: true,
196
+ reason
197
+ }) : _extends({}, base, {
198
+ on: false,
199
+ forced: false,
200
+ reason: null
201
+ });
202
+ }
203
+ /**
204
+ * One sentence for a forced decision, for the page that shows it.
205
+ *
206
+ * Written here rather than in a component so the send record, the log line
207
+ * and the campaign page say the same thing.
208
+ */ export function listUnsubscribeForcedDetail(decision) {
209
+ if (decision.reason === 'pooled-identity') {
210
+ return 'Turned back on because this email left on the shared sending ' + 'address, where every campaign carries it.';
211
+ }
212
+ if (decision.reason === 'volume-unknown') {
213
+ return 'Turned back on because your organization’s sending volume could not ' + 'be read when this email went out, so it could not be shown to be ' + 'under the bulk-sender threshold.';
214
+ }
215
+ if (decision.reason === 'bulk-volume') {
216
+ return `Turned back on because this email took your organization to ` + `${decision.volume.toLocaleString('en-US')} campaign emails in 24 ` + `hours, at or over the ${decision.threshold.toLocaleString('en-US')} ` + 'at which Gmail and Yahoo require a one-click unsubscribe.';
217
+ }
218
+ return '';
219
+ }
220
+
221
+ //# sourceMappingURL=list-unsubscribe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/list-unsubscribe.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 * THE MAIL-CLIENT UNSUBSCRIBE BUTTON, AS A SETTING (AGL-3296, AGL-3307).\n *\n * `List-Unsubscribe` plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click`\n * (RFC 8058) is what makes Gmail, Yahoo, Apple Mail and Outlook draw their own\n * Unsubscribe button above a message. It is a per-sender SETTING in two\n * places, with opposite defaults:\n *\n * | Sender | Default | Why |\n * | --- | --- | --- |\n * | An outreach sequence | off | one-to-one mail; the header makes it read as a mailing list |\n * | A marketing campaign | on | bulk mail; Gmail and Yahoo require one-click from bulk senders |\n *\n * Everything the two share lives here, so neither keeps a copy: how a stored\n * value is read ({@link readListUnsubscribeSetting}), how the header pair is\n * written ({@link listUnsubscribeHeaders}), how a send decides whether it can\n * carry the pair it promised ({@link resolveListUnsubscribe}), and — for bulk\n * mail — the guard that turns the header back on when a send makes the\n * organization a bulk sender ({@link decideListUnsubscribe}).\n *\n * ## What the setting never touches\n *\n * The visible way out. A campaign's footer link and a sequence's \"reply 'no'\"\n * line are in the body whatever this says, because they are what CAN-SPAM\n * and the recipient rely on; the header is a convenience a mail client may or\n * may not draw. The routes that answer a one-click POST keep answering every\n * link already sent, whatever the setting says now.\n *\n * ## Provider-neutral\n *\n * Nothing here knows who delivers the mail. The pair is two ordinary headers,\n * handed to whatever transport the deployment runs — `sendEmail`'s provider\n * seam for campaigns, the mailbox's own API for a sequence — so a self-hosted\n * deployment on any provider gets the same behavior.\n */\n\n/** The one value RFC 8058 defines for `List-Unsubscribe-Post`. */\nexport const LIST_UNSUBSCRIBE_ONE_CLICK = 'List-Unsubscribe=One-Click'\n\n/*==========================================\n * THE SETTING.\n *=========================================*/\n\n/**\n * A stored `listUnsubscribe` setting, read with the sender's own default.\n *\n * Only a real boolean is a choice. Anything else — absent on every record\n * written before the setting existed, `null`, a string — reads as the\n * default, which is `false` for a sequence and `true` for a campaign. So a\n * stored sequence never grows a header it did not ask for, and a stored\n * campaign never loses one.\n */\nexport function readListUnsubscribeSetting(\n raw: unknown,\n defaultOn: boolean,\n): boolean {\n return typeof raw === 'boolean' ? raw : defaultOn\n}\n\n/*==========================================\n * THE HEADERS.\n *=========================================*/\n\n/**\n * The `List-Unsubscribe` header pair for one message, or `{}`.\n *\n * `url` is the signed HTTPS link a mailbox provider POSTs with nobody present,\n * and `mailto` an address that unsubscribes whoever writes to it. Either can\n * stand alone in `List-Unsubscribe`; `List-Unsubscribe-Post` is written only\n * beside a URL, because advertising one-click with nothing to post to\n * promises a verb nothing serves.\n */\nexport function listUnsubscribeHeaders(input: {\n url?: string | null\n mailto?: string | null\n}): Record<string, string> {\n const url = String(input.url ?? '').trim()\n const mailto = String(input.mailto ?? '').trim()\n const uris = [url, mailto].filter(Boolean)\n if (!uris.length) return {}\n return {\n 'List-Unsubscribe': uris.map((uri) => `<${uri}>`).join(', '),\n ...(url ? { 'List-Unsubscribe-Post': LIST_UNSUBSCRIBE_ONE_CLICK } : {}),\n }\n}\n\n/*==========================================\n * ONE SEND'S PAIR.\n *=========================================*/\n\n/** The pair one send carries, or why it carries none. */\nexport type ListUnsubscribeResolution =\n | { status: 'off' }\n | { status: 'unavailable' }\n | { status: 'ready'; url: string; mailto: string | null }\n\n/**\n * Decides what one send's `List-Unsubscribe` header says.\n *\n * `off` unless `enabled` is exactly `true`. On, the URL is minted — and the\n * mailto too, when the sender has one (`mintMailto` present) — and the result\n * is `unavailable` when any part cannot be: no signing secret, no HTTPS\n * origin, no mailbox address. What a caller does with `unavailable` is its\n * own policy; a sequence holds the send rather than leave without the way out\n * it promised.\n */\nexport function resolveListUnsubscribe(input: {\n enabled: boolean | null | undefined\n mintUrl: () => string | null | undefined\n mintMailto?: () => string | null | undefined\n}): ListUnsubscribeResolution {\n if (input.enabled !== true) return { status: 'off' }\n const url = input.mintUrl() || ''\n if (!url) return { status: 'unavailable' }\n if (!input.mintMailto) return { status: 'ready', url, mailto: null }\n const mailto = input.mintMailto() || ''\n return mailto ? { status: 'ready', url, mailto } : { status: 'unavailable' }\n}\n\n/*==========================================\n * THE BULK-SENDER GUARD (AGL-3307).\n *=========================================*/\n\n/**\n * Messages in {@link LIST_UNSUBSCRIBE_BULK_WINDOW_MS} at which an organization\n * is a bulk sender and a campaign's header is turned back on.\n *\n * 5,000 is the figure Gmail and Yahoo publish for their bulk-sender rules, and\n * one-click unsubscribe is one of those rules: a bulk sender without it is\n * rejected or foldered as spam. Their count is per mailbox provider, which a\n * send cannot see (a Google Workspace address is on a company's own domain),\n * and a count to any one provider can never exceed the organization's total —\n * so the total is the count, and it is never smaller than the provider's.\n */\nexport const LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT = 5_000\n\n/** The environment variable a deployment overrides the threshold with. */\nexport const LIST_UNSUBSCRIBE_BULK_THRESHOLD_ENV =\n 'EMAIL_LIST_UNSUBSCRIBE_BULK_THRESHOLD'\n\n/** The window the threshold counts over. */\nexport const LIST_UNSUBSCRIBE_BULK_WINDOW_MS = 86_400_000\n\n/**\n * The deployment's bulk threshold.\n *\n * Unset, blank or unreadable resolves to the published default, never to\n * something smaller or larger by accident: a typo must not silently move a\n * deliverability control. A positive whole number is taken as written — a\n * self-hoster whose provider or audience draws the line elsewhere sets it\n * lower or higher. There is no value that disables the guard.\n *\n * Read per call rather than captured at module load, because these run in\n * serverless handlers whose module may be evaluated during a build, before\n * the runtime env exists.\n */\nexport function listUnsubscribeBulkThreshold(\n raw: unknown = typeof process === 'undefined'\n ? undefined\n : process.env?.[LIST_UNSUBSCRIBE_BULK_THRESHOLD_ENV],\n): number {\n if (raw === null || raw === undefined || String(raw).trim() === '') {\n return LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT\n }\n const value = Number(raw)\n if (!Number.isFinite(value) || value < 1) {\n return LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT\n }\n return Math.floor(value)\n}\n\n/** Why a send carries the header its setting turned off. */\nexport type ListUnsubscribeForcedReason =\n /** The send takes the organization to the bulk threshold. */\n | 'bulk-volume'\n /**\n * The send leaves on the shared pooled identity, whose reputation is every\n * site's on it, and which refuses marketing mail without a way out.\n */\n | 'pooled-identity'\n /**\n * The organization's recent volume could not be read, so whether the send\n * reaches the threshold is unknown — and the header is the safe answer.\n */\n | 'volume-unknown'\n\n/** What one send does with the header, and why. */\nexport interface ListUnsubscribeDecision {\n /** What the sender's setting asked for. */\n requested: boolean\n /** Whether the send carries the header. */\n on: boolean\n /** True when the setting said off and the send carries it anyway. */\n forced: boolean\n /** Why it was forced; null when it was not. */\n reason: ListUnsubscribeForcedReason | null\n /**\n * The organization's volume the decision was made on: what it had sent in\n * the window plus what this send will address.\n */\n volume: number\n /** The threshold it was compared with. */\n threshold: number\n}\n\n/**\n * Whether one bulk send carries the header, honoring the sender's setting\n * unless the send would make the organization a bulk sender.\n *\n * `recentVolume` is what the organization has already sent in the window and\n * `sendVolume` what this send will address in total, so the question asked is\n * the one the rule asks — does the organization reach the threshold — and a\n * send that starts under it and would finish over it is caught at its start\n * rather than halfway through. Reaching the threshold counts, because the\n * rule's line is \"5,000 or more\".\n *\n * `forcedBefore` keeps a decision made for an earlier part of the same send:\n * a campaign delivered over several batches does not drop the header halfway\n * through because a day rolled over.\n */\nexport function decideListUnsubscribe(input: {\n requested: boolean\n recentVolume: number\n sendVolume: number\n threshold?: number\n pooled?: boolean\n forcedBefore?: ListUnsubscribeForcedReason | null\n}): ListUnsubscribeDecision {\n const count = (raw: number) =>\n Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0\n const threshold = listUnsubscribeBulkThreshold(input.threshold)\n const volume = count(input.recentVolume) + count(input.sendVolume)\n const base = { requested: input.requested, volume, threshold }\n if (input.requested) {\n return { ...base, on: true, forced: false, reason: null }\n }\n const reason: ListUnsubscribeForcedReason | null = input.pooled\n ? 'pooled-identity'\n : volume >= threshold\n ? 'bulk-volume'\n : (input.forcedBefore ?? null)\n return reason\n ? { ...base, on: true, forced: true, reason }\n : { ...base, on: false, forced: false, reason: null }\n}\n\n/**\n * One sentence for a forced decision, for the page that shows it.\n *\n * Written here rather than in a component so the send record, the log line\n * and the campaign page say the same thing.\n */\nexport function listUnsubscribeForcedDetail(\n decision: Pick<ListUnsubscribeDecision, 'reason' | 'volume' | 'threshold'>,\n): string {\n if (decision.reason === 'pooled-identity') {\n return (\n 'Turned back on because this email left on the shared sending ' +\n 'address, where every campaign carries it.'\n )\n }\n if (decision.reason === 'volume-unknown') {\n return (\n 'Turned back on because your organization’s sending volume could not ' +\n 'be read when this email went out, so it could not be shown to be ' +\n 'under the bulk-sender threshold.'\n )\n }\n if (decision.reason === 'bulk-volume') {\n return (\n `Turned back on because this email took your organization to ` +\n `${decision.volume.toLocaleString('en-US')} campaign emails in 24 ` +\n `hours, at or over the ${decision.threshold.toLocaleString('en-US')} ` +\n 'at which Gmail and Yahoo require a one-click unsubscribe.'\n )\n }\n return ''\n}\n"],"names":["LIST_UNSUBSCRIBE_ONE_CLICK","readListUnsubscribeSetting","raw","defaultOn","listUnsubscribeHeaders","input","url","String","trim","mailto","uris","filter","Boolean","length","map","uri","join","resolveListUnsubscribe","enabled","status","mintUrl","mintMailto","LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT","LIST_UNSUBSCRIBE_BULK_THRESHOLD_ENV","LIST_UNSUBSCRIBE_BULK_WINDOW_MS","listUnsubscribeBulkThreshold","process","undefined","env","value","Number","isFinite","Math","floor","decideListUnsubscribe","count","threshold","volume","recentVolume","sendVolume","base","requested","on","forced","reason","pooled","forcedBefore","listUnsubscribeForcedDetail","decision","toLocaleString"],"mappings":"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"}
@@ -70,6 +70,7 @@
70
70
  * jest.
71
71
  */ import { escapeEmailHtml } from "./email-render.js";
72
72
  import { resolveSendPriority } from "./send-rate.js";
73
+ import { listUnsubscribeHeaders } from "./list-unsubscribe.js";
73
74
  /**
74
75
  * Whether a message is MARKETING, derived from what it already carries.
75
76
  *
@@ -395,10 +396,9 @@ export const MARKETING_SUNSET_MAX_DAYS = 3650;
395
396
  * verb nothing serves.
396
397
  */ export function unsubscribeHeaders(unsubscribeUrl) {
397
398
  if (!unsubscribeUrl) return {};
398
- return {
399
- 'List-Unsubscribe': `<${unsubscribeUrl}>`,
400
- 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
401
- };
399
+ return listUnsubscribeHeaders({
400
+ url: unsubscribeUrl
401
+ });
402
402
  }
403
403
  /**
404
404
  * What the visible opt-out is CALLED, in every part of every message.