@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 +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/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.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.
|
|
29
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,gEAAgE,GAChE,OAAO,MAAMA,6BAA6B,6BAA4B;AAEtE;;2CAE2C,GAE3C;;;;;;;;CAQC,GACD,OAAO,SAASC,2BACdC,GAAY,EACZC,SAAkB;IAElB,OAAO,OAAOD,QAAQ,YAAYA,MAAMC;AAC1C;AAEA;;2CAE2C,GAE3C;;;;;;;;CAQC,GACD,OAAO,SAASC,uBAAuBC,KAGtC;QACoBA,YACGA;IADtB,MAAMC,MAAMC,QAAOF,aAAAA,MAAMC,GAAG,YAATD,aAAa,IAAIG,IAAI;IACxC,MAAMC,SAASF,QAAOF,gBAAAA,MAAMI,MAAM,YAAZJ,gBAAgB,IAAIG,IAAI;IAC9C,MAAME,OAAO;QAACJ;QAAKG;KAAO,CAACE,MAAM,CAACC;IAClC,IAAI,CAACF,KAAKG,MAAM,EAAE,OAAO,CAAC;IAC1B,OAAO;QACL,oBAAoBH,KAAKI,GAAG,CAAC,CAACC,MAAQ,CAAC,CAAC,EAAEA,IAAI,CAAC,CAAC,EAAEC,IAAI,CAAC;OACnDV,MAAM;QAAE,yBAAyBN;IAA2B,IAAI,CAAC;AAEzE;AAYA;;;;;;;;;CASC,GACD,OAAO,SAASiB,uBAAuBZ,KAItC;IACC,IAAIA,MAAMa,OAAO,KAAK,MAAM,OAAO;QAAEC,QAAQ;IAAM;IACnD,MAAMb,MAAMD,MAAMe,OAAO,MAAM;IAC/B,IAAI,CAACd,KAAK,OAAO;QAAEa,QAAQ;IAAc;IACzC,IAAI,CAACd,MAAMgB,UAAU,EAAE,OAAO;QAAEF,QAAQ;QAASb;QAAKG,QAAQ;IAAK;IACnE,MAAMA,SAASJ,MAAMgB,UAAU,MAAM;IACrC,OAAOZ,SAAS;QAAEU,QAAQ;QAASb;QAAKG;IAAO,IAAI;QAAEU,QAAQ;IAAc;AAC7E;AAEA;;2CAE2C,GAE3C;;;;;;;;;;CAUC,GACD,OAAO,MAAMG,0CAA0C,KAAK;AAE5D,wEAAwE,GACxE,OAAO,MAAMC,sCACX,wCAAuC;AAEzC,0CAA0C,GAC1C,OAAO,MAAMC,kCAAkC,SAAU;AAEzD;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,6BACdvB;QAEIwB;WAFW,OAAOA,YAAY,cAC9BC,aACAD,eAAAA,QAAQE,GAAG,qBAAXF,YAAa,CAACH,oCAAoC;IAAA;IAEtD,IAAIrB,QAAQ,QAAQA,QAAQyB,aAAapB,OAAOL,KAAKM,IAAI,OAAO,IAAI;QAClE,OAAOc;IACT;IACA,MAAMO,QAAQC,OAAO5B;IACrB,IAAI,CAAC4B,OAAOC,QAAQ,CAACF,UAAUA,QAAQ,GAAG;QACxC,OAAOP;IACT;IACA,OAAOU,KAAKC,KAAK,CAACJ;AACpB;AAoCA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASK,sBAAsB7B,KAOrC;QAaQA;IAZP,MAAM8B,QAAQ,CAACjC,MACb4B,OAAOC,QAAQ,CAAC7B,QAAQA,MAAM,IAAI8B,KAAKC,KAAK,CAAC/B,OAAO;IACtD,MAAMkC,YAAYX,6BAA6BpB,MAAM+B,SAAS;IAC9D,MAAMC,SAASF,MAAM9B,MAAMiC,YAAY,IAAIH,MAAM9B,MAAMkC,UAAU;IACjE,MAAMC,OAAO;QAAEC,WAAWpC,MAAMoC,SAAS;QAAEJ;QAAQD;IAAU;IAC7D,IAAI/B,MAAMoC,SAAS,EAAE;QACnB,OAAO,aAAKD;YAAME,IAAI;YAAMC,QAAQ;YAAOC,QAAQ;;IACrD;IACA,MAAMA,SAA6CvC,MAAMwC,MAAM,GAC3D,oBACAR,UAAUD,YACR,iBACC/B,sBAAAA,MAAMyC,YAAY,YAAlBzC,sBAAsB;IAC7B,OAAOuC,SACH,aAAKJ;QAAME,IAAI;QAAMC,QAAQ;QAAMC;SACnC,aAAKJ;QAAME,IAAI;QAAOC,QAAQ;QAAOC,QAAQ;;AACnD;AAEA;;;;;CAKC,GACD,OAAO,SAASG,4BACdC,QAA0E;IAE1E,IAAIA,SAASJ,MAAM,KAAK,mBAAmB;QACzC,OACE,kEACA;IAEJ;IACA,IAAII,SAASJ,MAAM,KAAK,kBAAkB;QACxC,OACE,yEACA,sEACA;IAEJ;IACA,IAAII,SAASJ,MAAM,KAAK,eAAe;QACrC,OACE,CAAC,4DAA4D,CAAC,GAC9D,GAAGI,SAASX,MAAM,CAACY,cAAc,CAAC,SAAS,uBAAuB,CAAC,GACnE,CAAC,sBAAsB,EAAED,SAASZ,SAAS,CAACa,cAAc,CAAC,SAAS,CAAC,CAAC,GACtE;IAEJ;IACA,OAAO;AACT"}
|
|
@@ -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
|
-
|
|
400
|
-
|
|
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.
|