@aglyn/shared-util-email 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +104 -0
- package/package.json +37 -0
- package/src/index.d.ts +37 -0
- package/src/index.js +46 -0
- package/src/index.js.map +1 -0
- package/src/lib/email-delivery-events.d.ts +198 -0
- package/src/lib/email-delivery-events.js +310 -0
- package/src/lib/email-delivery-events.js.map +1 -0
- package/src/lib/email-health.d.ts +154 -0
- package/src/lib/email-health.js +264 -0
- package/src/lib/email-health.js.map +1 -0
- package/src/lib/email-media-src.d.ts +33 -0
- package/src/lib/email-media-src.js +113 -0
- package/src/lib/email-media-src.js.map +1 -0
- package/src/lib/email-merge.d.ts +31 -0
- package/src/lib/email-merge.js +36 -0
- package/src/lib/email-merge.js.map +1 -0
- package/src/lib/email-render.d.ts +114 -0
- package/src/lib/email-render.js +293 -0
- package/src/lib/email-render.js.map +1 -0
- package/src/lib/email-revenue-window.d.ts +55 -0
- package/src/lib/email-revenue-window.js +58 -0
- package/src/lib/email-revenue-window.js.map +1 -0
- package/src/lib/host-email-render.d.ts +99 -0
- package/src/lib/host-email-render.js +126 -0
- package/src/lib/host-email-render.js.map +1 -0
- package/src/lib/host-sender.d.ts +86 -0
- package/src/lib/host-sender.js +131 -0
- package/src/lib/host-sender.js.map +1 -0
- package/src/lib/marketing-send.d.ts +466 -0
- package/src/lib/marketing-send.js +459 -0
- package/src/lib/marketing-send.js.map +1 -0
- package/src/lib/platform-sending-domain.d.ts +362 -0
- package/src/lib/platform-sending-domain.js +697 -0
- package/src/lib/platform-sending-domain.js.map +1 -0
- package/src/lib/received-email.d.ts +86 -0
- package/src/lib/received-email.js +124 -0
- package/src/lib/received-email.js.map +1 -0
- package/src/lib/send-ceilings.d.ts +394 -0
- package/src/lib/send-ceilings.js +341 -0
- package/src/lib/send-ceilings.js.map +1 -0
- package/src/lib/send-email.d.ts +385 -0
- package/src/lib/send-email.js +586 -0
- package/src/lib/send-email.js.map +1 -0
- package/src/lib/send-rate.d.ts +298 -0
- package/src/lib/send-rate.js +310 -0
- package/src/lib/send-rate.js.map +1 -0
- package/src/lib/sender-reputation.d.ts +357 -0
- package/src/lib/sender-reputation.js +392 -0
- package/src/lib/sender-reputation.js.map +1 -0
- package/src/lib/sending-domain.d.ts +744 -0
- package/src/lib/sending-domain.js +798 -0
- package/src/lib/sending-domain.js.map +1 -0
- package/src/lib/sending-mailbox.d.ts +132 -0
- package/src/lib/sending-mailbox.js +192 -0
- package/src/lib/sending-mailbox.js.map +1 -0
- package/src/lib/stored-email-nodes.d.ts +38 -0
- package/src/lib/stored-email-nodes.js +78 -0
- package/src/lib/stored-email-nodes.js.map +1 -0
- package/src/lib/svix-signature.d.ts +38 -0
- package/src/lib/svix-signature.js +55 -0
- package/src/lib/svix-signature.js.map +1 -0
- package/src/lib/system-email-catalog.d.ts +170 -0
- package/src/lib/system-email-catalog.js +828 -0
- package/src/lib/system-email-catalog.js.map +1 -0
- package/src/lib/tenant-email-catalog.d.ts +78 -0
- package/src/lib/tenant-email-catalog.js +452 -0
- package/src/lib/tenant-email-catalog.js.map +1 -0
- package/src/lib/text-email-html.d.ts +32 -0
- package/src/lib/text-email-html.js +119 -0
- package/src/lib/text-email-html.js.map +1 -0
|
@@ -0,0 +1,466 @@
|
|
|
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
|
+
/** What a marketing send declares about itself. */
|
|
18
|
+
export interface MarketingSendContext {
|
|
19
|
+
/** The site whose audience this is. Both suppression lists key on it. */
|
|
20
|
+
hostId: string;
|
|
21
|
+
/**
|
|
22
|
+
* The site's public origin, for the unsubscribe link.
|
|
23
|
+
*
|
|
24
|
+
* The CALLER resolves it — through `hostPublicOrigin`, from the host
|
|
25
|
+
* document — because the shared subdomain policy lives in the framework
|
|
26
|
+
* library and neither this module nor the gate may import it. Empty when a
|
|
27
|
+
* host has no custom domain and no subdomain, which is a site nobody can
|
|
28
|
+
* reach; the send then carries no unsubscribe URL and says so in the logs
|
|
29
|
+
* rather than minting a link that resolves to nothing.
|
|
30
|
+
*/
|
|
31
|
+
siteBase: string;
|
|
32
|
+
/**
|
|
33
|
+
* An unsubscribe URL the caller has already minted, and the campaign it
|
|
34
|
+
* belongs to.
|
|
35
|
+
*
|
|
36
|
+
* The campaign sender builds its own, because the same URL has to reach the
|
|
37
|
+
* designed template as an `{{unsubscribeUrl}}` merge value long before the
|
|
38
|
+
* message is handed over. Supplied, it is used verbatim and nothing is
|
|
39
|
+
* minted — one link per message, whoever made it.
|
|
40
|
+
*/
|
|
41
|
+
unsubscribeUrl?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The `List-Unsubscribe` URL, when the caller minted its own pair.
|
|
44
|
+
*
|
|
45
|
+
* Separate from {@link unsubscribeUrl} because the two are read by
|
|
46
|
+
* different readers. This one is POSTed by a mailbox provider with nobody
|
|
47
|
+
* present, so it must name the route that writes on POST; the other is
|
|
48
|
+
* clicked by a person, so it names the preference page. Absent, the header
|
|
49
|
+
* falls back to `unsubscribeUrl` — which is right for a caller that minted
|
|
50
|
+
* only one link, and wrong only for one that minted a page and did not say
|
|
51
|
+
* so.
|
|
52
|
+
*/
|
|
53
|
+
oneClickUrl?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Whether a frequency cap may refuse this message.
|
|
56
|
+
*
|
|
57
|
+
* `false` for a campaign. A campaign is a merchant's deliberate, reviewed
|
|
58
|
+
* act with a recipient count on screen before they press Send; a cap that
|
|
59
|
+
* silently removed people from it would make that number a lie, and a
|
|
60
|
+
* one-shot send has nowhere to hold the remainder for later. So a campaign
|
|
61
|
+
* COUNTS toward what a recipient has received — it is most of the load a
|
|
62
|
+
* person feels — and yields the refusal to the automated paths, which fire
|
|
63
|
+
* with no human present and are the ones that stack.
|
|
64
|
+
*
|
|
65
|
+
* Defaults to true: an unmarked caller is a machine.
|
|
66
|
+
*/
|
|
67
|
+
capped?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* The STREAM this message belongs to, so a recipient who left that stream
|
|
70
|
+
* is not mailed it.
|
|
71
|
+
*
|
|
72
|
+
* ## Absent refuses nobody, and that is the whole safety property
|
|
73
|
+
*
|
|
74
|
+
* A topic opt-out governs marketing STREAMS — "Promotions and offers",
|
|
75
|
+
* "Newsletter". It is not a thing anybody can untick off a receipt, a
|
|
76
|
+
* password reset or a booking confirmation, and a gate that refused those
|
|
77
|
+
* on a topic preference would be the failure mode this control is most
|
|
78
|
+
* capable of causing. Two guards make that unreachable rather than
|
|
79
|
+
* unlikely:
|
|
80
|
+
*
|
|
81
|
+
* 1. transactional mail declares no `marketing` context at all, so it
|
|
82
|
+
* never reaches the gate; and
|
|
83
|
+
* 2. a marketing caller that names no stream gets no topic refusal here.
|
|
84
|
+
* `filterTopicSendable` already reads an empty topic that way — there
|
|
85
|
+
* is no stream to have left — and this preserves it rather than
|
|
86
|
+
* defaulting to one.
|
|
87
|
+
*
|
|
88
|
+
* So the check binds exactly the senders that declare what they are, which
|
|
89
|
+
* is the same polarity `isMarketingMessage` chose: enumerate what is
|
|
90
|
+
* restricted, and a forgotten caller sends rather than silently drops.
|
|
91
|
+
*
|
|
92
|
+
* ## Why the gate asks it at all, when the consent split does not
|
|
93
|
+
*
|
|
94
|
+
* The two used to travel together in `email-flow-gate.ts`, and they are not
|
|
95
|
+
* the same kind of fact. The consent split is the ORG's policy over its own
|
|
96
|
+
* audience, which is why it stays a caller-side question. A topic opt-out
|
|
97
|
+
* is the RECIPIENT talking to the platform, recorded by the same preference
|
|
98
|
+
* page that records their cadence and reached by the same
|
|
99
|
+
* `List-Unsubscribe` link — so it belongs with the suppression and cadence
|
|
100
|
+
* checks, at the one chokepoint every marketing message crosses.
|
|
101
|
+
*/
|
|
102
|
+
topicId?: string;
|
|
103
|
+
}
|
|
104
|
+
/** What the gate is asked, once per marketing message. */
|
|
105
|
+
export interface MarketingSendGateRequest {
|
|
106
|
+
hostId: string;
|
|
107
|
+
siteBase: string;
|
|
108
|
+
/** The single recipient. A marketing message addresses exactly one person. */
|
|
109
|
+
email: string;
|
|
110
|
+
/** The caller's `context` label, for the log line on a refusal. */
|
|
111
|
+
context?: string;
|
|
112
|
+
/**
|
|
113
|
+
* The stream this message belongs to, or absent for a message that belongs
|
|
114
|
+
* to none. See {@link MarketingSendContext.topicId} — absent refuses
|
|
115
|
+
* nobody.
|
|
116
|
+
*/
|
|
117
|
+
topicId?: string;
|
|
118
|
+
/** Whether a frequency cap may refuse — see {@link MarketingSendContext}. */
|
|
119
|
+
capped: boolean;
|
|
120
|
+
}
|
|
121
|
+
/** Why a marketing message was not sent, or `null` when it may go. */
|
|
122
|
+
export type MarketingSendRefusal =
|
|
123
|
+
/** On a suppression list: unsubscribed, hard-bounced, or a complaint. */
|
|
124
|
+
'suppressed'
|
|
125
|
+
/**
|
|
126
|
+
* They have left the STREAM this message belongs to.
|
|
127
|
+
*
|
|
128
|
+
* Kept apart from `suppressed` even though both are terminal for this
|
|
129
|
+
* message, because they describe different people: somebody on a
|
|
130
|
+
* suppression list has left the site, and somebody here has left one of its
|
|
131
|
+
* streams and still wants the others. A merchant reading why a send shrank
|
|
132
|
+
* has a different thing to do about each, and only one of them is a list
|
|
133
|
+
* that has to be rebuilt.
|
|
134
|
+
*
|
|
135
|
+
* NOT retryable — the condition clears when the person re-subscribes, not
|
|
136
|
+
* when time passes.
|
|
137
|
+
*/
|
|
138
|
+
| 'topic-unsubscribed'
|
|
139
|
+
/** This person has already received their ceiling from this site. */
|
|
140
|
+
| 'frequency-capped'
|
|
141
|
+
/**
|
|
142
|
+
* The RECIPIENT asked for less than this, and it has not been long enough.
|
|
143
|
+
*
|
|
144
|
+
* Kept apart from `frequency-capped` even though both are refusals about
|
|
145
|
+
* pace, because they are refusals on behalf of different people: the
|
|
146
|
+
* ceiling protects a shared sending domain from a merchant, and this
|
|
147
|
+
* carries out a request the person on the other end made on the preference
|
|
148
|
+
* page. Reporting them as one would make "why did this not send" answerable
|
|
149
|
+
* only by guessing, and it is the merchant-facing half of a promise the
|
|
150
|
+
* product made to a recipient.
|
|
151
|
+
*/
|
|
152
|
+
| 'cadence-limited'
|
|
153
|
+
/**
|
|
154
|
+
* This site has been mailing this person for longer than the sunset window
|
|
155
|
+
* and nothing in that window says they are still listening.
|
|
156
|
+
*
|
|
157
|
+
* NOT retryable by a sweep — the condition does not clear on a schedule,
|
|
158
|
+
* it clears when the person engages. See {@link marketingSunsetVerdict}.
|
|
159
|
+
*/
|
|
160
|
+
| 'unengaged';
|
|
161
|
+
export interface MarketingSendGateVerdict {
|
|
162
|
+
allowed: boolean;
|
|
163
|
+
/** Set when `allowed` is false. */
|
|
164
|
+
refusal?: MarketingSendRefusal;
|
|
165
|
+
/** Human-readable, for the log and the `detail` on the result. */
|
|
166
|
+
detail?: string;
|
|
167
|
+
/**
|
|
168
|
+
* The signed opt-out URL a PERSON clicks, when the gate could mint one.
|
|
169
|
+
* Points at the preference page, where the stream this message belongs to
|
|
170
|
+
* is one of the things they can stop instead of all of it. Absent leaves
|
|
171
|
+
* the message without unsubscribe headers, which is a misconfiguration to
|
|
172
|
+
* fix and not a reason to refuse mail.
|
|
173
|
+
*/
|
|
174
|
+
unsubscribeUrl?: string;
|
|
175
|
+
/**
|
|
176
|
+
* The same signature over the one-click route, for `List-Unsubscribe`.
|
|
177
|
+
*
|
|
178
|
+
* A mailbox provider POSTs that header with no human present and expects
|
|
179
|
+
* the act to have happened when it reads the 200, so it must never name a
|
|
180
|
+
* page of checkboxes somebody has to submit. Absent, the header falls back
|
|
181
|
+
* to {@link unsubscribeUrl}.
|
|
182
|
+
*/
|
|
183
|
+
oneClickUrl?: string;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Whether a message is MARKETING, derived from what it already carries.
|
|
187
|
+
*
|
|
188
|
+
* ## Nothing new to remember, on purpose
|
|
189
|
+
*
|
|
190
|
+
* The obvious design is a `kind: 'marketing' | 'transactional'` option on
|
|
191
|
+
* `sendEmail`. It is rejected for the reason the `from` override was deleted
|
|
192
|
+
* and the `context` tag was derived rather than threaded: twenty call sites
|
|
193
|
+
* cannot each be relied on to set a field, and the twenty-first is the one that
|
|
194
|
+
* does not. The consequence of forgetting here is a merchant's campaign leaving
|
|
195
|
+
* on the pooled identity that carries every other site's password resets — a
|
|
196
|
+
* failure nobody sees until the pool stops delivering.
|
|
197
|
+
*
|
|
198
|
+
* So the answer is read off two things a marketing send is ALREADY obliged to
|
|
199
|
+
* carry, neither of which is optional and neither of which was added for this:
|
|
200
|
+
*
|
|
201
|
+
* 1. **`marketing`** — the context object. A message that declares it gets the
|
|
202
|
+
* RFC 8058 header pair, the suppression check and the frequency cap, so a
|
|
203
|
+
* marketing sender cannot omit it and still be correct; it would be shipping
|
|
204
|
+
* mail with no unsubscribe link. Four of the five marketing senders in the
|
|
205
|
+
* tree take this arm.
|
|
206
|
+
* 2. **`priority === 'campaign'`** — which `resolveSendPriority` derives from
|
|
207
|
+
* `context: 'campaign'`. The campaign sender mints its own unsubscribe
|
|
208
|
+
* headers upstream and so passes no `marketing` context, but it is the one
|
|
209
|
+
* sender that cannot avoid this label: the hourly governor is allowed to
|
|
210
|
+
* refuse a campaign, and a campaign that hid from the priority would be
|
|
211
|
+
* hiding from that too.
|
|
212
|
+
*
|
|
213
|
+
* A sender would have to defeat BOTH — no unsubscribe context and no campaign
|
|
214
|
+
* priority — to reach the pool with promotional mail, and a message in that
|
|
215
|
+
* state is already broken in ways its author would notice.
|
|
216
|
+
*
|
|
217
|
+
* ## Polarity
|
|
218
|
+
*
|
|
219
|
+
* A message matching neither is transactional, which is the permissive answer,
|
|
220
|
+
* and that is deliberate — the same choice `resolveSendPriority` makes for the
|
|
221
|
+
* same reason. Enumerating what is RESTRICTED means a forgotten caller sends a
|
|
222
|
+
* receipt that goes out. Enumerating what is PERMITTED means a forgotten caller
|
|
223
|
+
* drops one, and a dropped password reset is the failure you learn about from a
|
|
224
|
+
* support ticket.
|
|
225
|
+
*/
|
|
226
|
+
export declare function isMarketingMessage(options: {
|
|
227
|
+
marketing?: unknown;
|
|
228
|
+
priority?: string | null;
|
|
229
|
+
context?: string | null;
|
|
230
|
+
}): boolean;
|
|
231
|
+
export type MarketingSendGate = (request: MarketingSendGateRequest) => Promise<MarketingSendGateVerdict>;
|
|
232
|
+
/** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */
|
|
233
|
+
export declare function setMarketingSendGate(gate: MarketingSendGate | null): void;
|
|
234
|
+
/** The installed gate, or null when nothing has been installed. */
|
|
235
|
+
export declare function getMarketingSendGate(): MarketingSendGate | null;
|
|
236
|
+
/** Test seam: forget any installed gate. */
|
|
237
|
+
export declare function resetMarketingSendGateForTests(): void;
|
|
238
|
+
/**
|
|
239
|
+
* The rolling window a frequency cap counts over. One day, because that is
|
|
240
|
+
* the unit a recipient experiences ("this shop mailed me four times today")
|
|
241
|
+
* and the unit every published vendor cap is expressed in.
|
|
242
|
+
*/
|
|
243
|
+
export declare const MARKETING_FREQUENCY_WINDOW_MS = 86400000;
|
|
244
|
+
/**
|
|
245
|
+
* How many marketing messages one person may receive from one site inside the
|
|
246
|
+
* window, by default.
|
|
247
|
+
*
|
|
248
|
+
* **A runaway guard, not a marketing policy.** It is deliberately above what
|
|
249
|
+
* an ordinary merchant produces: the worst legitimate day is a campaign plus
|
|
250
|
+
* a cart reminder plus a restock alert plus a member post, which is four. Set
|
|
251
|
+
* lower and the first thing it would refuse is a real message somebody meant
|
|
252
|
+
* to send; set higher and it stops describing a ceiling at all. What it does
|
|
253
|
+
* remove is the unbounded case — a member post that mails 200 subscribers per
|
|
254
|
+
* click with no limit on clicks, and a workflow whose email step fires on
|
|
255
|
+
* every anonymous form submission.
|
|
256
|
+
*
|
|
257
|
+
* The same number for every plan. This is a deliverability control on a
|
|
258
|
+
* shared sending domain, so it protects every tenant from every other tenant
|
|
259
|
+
* and cannot be something one plan buys its way past.
|
|
260
|
+
*/
|
|
261
|
+
export declare const MARKETING_FREQUENCY_DEFAULT_PER_WINDOW = 5;
|
|
262
|
+
/** Floor and ceiling on a configured cap — a typo guard, not a policy. */
|
|
263
|
+
export declare const MARKETING_FREQUENCY_MIN_PER_WINDOW = 1;
|
|
264
|
+
export declare const MARKETING_FREQUENCY_MAX_PER_WINDOW = 1000;
|
|
265
|
+
/**
|
|
266
|
+
* The live cap.
|
|
267
|
+
*
|
|
268
|
+
* Read from the environment so a self-host operator can set their own —
|
|
269
|
+
* `docs/design` states that every dependency is configurable — and read per
|
|
270
|
+
* call rather than captured at module load, matching `getEmailConfig`: these
|
|
271
|
+
* run in serverless handlers where the module may be evaluated during a build.
|
|
272
|
+
*
|
|
273
|
+
* An unparseable or out-of-range value falls back to the default rather than
|
|
274
|
+
* throwing or disabling the cap. A control that a typo can switch off is not
|
|
275
|
+
* a control.
|
|
276
|
+
*/
|
|
277
|
+
export declare function marketingFrequencyCap(): number;
|
|
278
|
+
/**
|
|
279
|
+
* Whether one more message fits, given what this person has already received.
|
|
280
|
+
*
|
|
281
|
+
* Pure, so the decision is testable without a Firestore harness — the same
|
|
282
|
+
* split `emailSendRateVerdict` makes for the platform hour.
|
|
283
|
+
*
|
|
284
|
+
* @param recentSendsAtMs every marketing send to this person from this site,
|
|
285
|
+
* newest or oldest first, in any order.
|
|
286
|
+
* @returns the verdict and the trimmed window, so the caller writes back only
|
|
287
|
+
* what still counts instead of growing the record forever.
|
|
288
|
+
*/
|
|
289
|
+
export declare function marketingFrequencyVerdict(recentSendsAtMs: readonly number[], nowMs: number, cap?: number): {
|
|
290
|
+
allowed: boolean;
|
|
291
|
+
used: number;
|
|
292
|
+
cap: number;
|
|
293
|
+
inWindow: number[];
|
|
294
|
+
};
|
|
295
|
+
/** Floor and ceiling on a configured sunset window — a typo guard. */
|
|
296
|
+
export declare const MARKETING_SUNSET_MIN_DAYS = 30;
|
|
297
|
+
export declare const MARKETING_SUNSET_MAX_DAYS = 3650;
|
|
298
|
+
/**
|
|
299
|
+
* The window, in days, or 0 for OFF.
|
|
300
|
+
*
|
|
301
|
+
* **Off unless an operator turns it on**, and that default is the honest one:
|
|
302
|
+
* no compared vendor automates this, so a platform that silently stopped
|
|
303
|
+
* mailing a merchant's quiet subscribers would be doing something none of
|
|
304
|
+
* their previous tools did and none of their recipients asked for. The value
|
|
305
|
+
* is a number of days rather than a boolean because the only interesting
|
|
306
|
+
* question about a sunset is where it starts.
|
|
307
|
+
*
|
|
308
|
+
* Read per call rather than captured at module load, matching the frequency
|
|
309
|
+
* cap beside it: these run in serverless handlers where the module may be
|
|
310
|
+
* evaluated during a build.
|
|
311
|
+
*
|
|
312
|
+
* An unparseable or out-of-range value reads as OFF rather than falling back
|
|
313
|
+
* to a default. This is the opposite of {@link marketingFrequencyCap}'s
|
|
314
|
+
* handling and deliberately so — a typo there weakens a guard that is on by
|
|
315
|
+
* default, and a typo here would ENABLE a refusal nobody asked for.
|
|
316
|
+
*/
|
|
317
|
+
export declare function marketingSunsetDays(): number;
|
|
318
|
+
/** What the sunset needs to know about one person. */
|
|
319
|
+
export interface MarketingSunsetFacts {
|
|
320
|
+
/**
|
|
321
|
+
* When this site first sent this person marketing mail, or null when we
|
|
322
|
+
* have no record. Null never refuses.
|
|
323
|
+
*/
|
|
324
|
+
firstSentAtMs: number | null;
|
|
325
|
+
/** When they last opened or clicked any of our mail, or null for never. */
|
|
326
|
+
lastEngagedAtMs: number | null;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Whether this person has gone quiet for longer than the window.
|
|
330
|
+
*
|
|
331
|
+
* Pure, so the decision is testable without a Firestore harness — the same
|
|
332
|
+
* split {@link marketingFrequencyVerdict} makes.
|
|
333
|
+
*
|
|
334
|
+
* @param days 0 disables the sunset entirely and this always allows.
|
|
335
|
+
*/
|
|
336
|
+
export declare function marketingSunsetVerdict(facts: MarketingSunsetFacts, nowMs: number, days?: number): {
|
|
337
|
+
allowed: boolean;
|
|
338
|
+
days: number;
|
|
339
|
+
quietForDays: number | null;
|
|
340
|
+
};
|
|
341
|
+
/**
|
|
342
|
+
* HOW OFTEN THE RECIPIENT ASKED TO HEAR FROM THIS SITE.
|
|
343
|
+
*
|
|
344
|
+
* `docs/specs/email-competitive-gaps.md` G10 shipped its cap half and left
|
|
345
|
+
* this one: unsubscribe was all-or-nothing plus, since topics, per-stream —
|
|
346
|
+
* and a recipient who wanted the same mail LESS OFTEN still had only two
|
|
347
|
+
* levers, one of which is the spam button. On a shared sending domain under
|
|
348
|
+
* `p=reject` that button is charged to every other tenant, which is what
|
|
349
|
+
* makes "monthly" a platform control wearing a courtesy's clothes.
|
|
350
|
+
*
|
|
351
|
+
* ## A minimum interval, not a second rolling window
|
|
352
|
+
*
|
|
353
|
+
* {@link marketingFrequencyVerdict} counts messages inside a day because the
|
|
354
|
+
* thing it guards against is a burst. This guards against a DRIP, and the
|
|
355
|
+
* question a drip asks is "how long since the last one" — one stored instant,
|
|
356
|
+
* not a window that would have to be kept for a month to answer a monthly
|
|
357
|
+
* choice. Two instruments, because they are two different questions.
|
|
358
|
+
*
|
|
359
|
+
* ## New values, so the vocabulary is chosen rather than inherited
|
|
360
|
+
*
|
|
361
|
+
* `'all'` is the default and the absence: a record with no cadence, and every
|
|
362
|
+
* record written before this existed, means the person has expressed no
|
|
363
|
+
* preference — which is not the same as having asked for everything, but is
|
|
364
|
+
* the only reading that does not silently withhold mail from people who never
|
|
365
|
+
* chose.
|
|
366
|
+
*/
|
|
367
|
+
export type MarketingCadence = 'all' | 'daily' | 'weekly' | 'monthly';
|
|
368
|
+
/** The default: no expressed preference, so only the platform ceiling binds. */
|
|
369
|
+
export declare const DEFAULT_MARKETING_CADENCE: MarketingCadence;
|
|
370
|
+
/**
|
|
371
|
+
* The minimum gap each choice asks for, in millis.
|
|
372
|
+
*
|
|
373
|
+
* Calendar-naive on purpose. "At most one a week" is a promise about pace,
|
|
374
|
+
* and honoring it as seven days from the last message is both what the words
|
|
375
|
+
* say and what a recipient can check; anchoring it to a calendar week would
|
|
376
|
+
* let two messages land on a Sunday and a Monday and still be "one a week".
|
|
377
|
+
*/
|
|
378
|
+
export declare const MARKETING_CADENCE_INTERVAL_MS: Record<MarketingCadence, number>;
|
|
379
|
+
/** How each choice reads, wherever one is named to a person or an operator. */
|
|
380
|
+
export declare const MARKETING_CADENCE_LABELS: Record<MarketingCadence, string>;
|
|
381
|
+
/**
|
|
382
|
+
* Coerces a stored or submitted value to a cadence.
|
|
383
|
+
*
|
|
384
|
+
* Everything unrecognized becomes {@link DEFAULT_MARKETING_CADENCE}. The
|
|
385
|
+
* direction matters and is the opposite of the consent policy's: a malformed
|
|
386
|
+
* consent value must not become a way to switch enforcement off, because its
|
|
387
|
+
* failure mode is mail to somebody who declined. A malformed cadence falling
|
|
388
|
+
* to `'monthly'` would withhold mail from everybody whose record got
|
|
389
|
+
* corrupted, and nobody asked for that either — so an unreadable preference
|
|
390
|
+
* reads as no preference, and the person keeps whatever the ceiling allows.
|
|
391
|
+
*/
|
|
392
|
+
export declare function normalizeMarketingCadence(value: unknown): MarketingCadence;
|
|
393
|
+
/**
|
|
394
|
+
* Whether enough time has passed for one more message at this pace.
|
|
395
|
+
*
|
|
396
|
+
* Pure, so the rule can be asserted without a Firestore harness — the same
|
|
397
|
+
* split {@link marketingFrequencyVerdict} makes.
|
|
398
|
+
*
|
|
399
|
+
* @param lastSentAtMs when this site last sent this person marketing mail, or
|
|
400
|
+
* `null` for somebody it has never mailed. Never mailed always allows:
|
|
401
|
+
* a cadence is a gap between messages and there is no first gap.
|
|
402
|
+
* @returns the verdict and, on a refusal, the instant the next message may go
|
|
403
|
+
* — so a caller that defers has something to defer UNTIL rather than
|
|
404
|
+
* a retry loop that discovers the answer by asking again.
|
|
405
|
+
*/
|
|
406
|
+
export declare function marketingCadenceVerdict(cadence: MarketingCadence, lastSentAtMs: number | null | undefined, nowMs: number): {
|
|
407
|
+
allowed: boolean;
|
|
408
|
+
cadence: MarketingCadence;
|
|
409
|
+
nextAllowedAtMs: number;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* The RFC 8058 header pair.
|
|
413
|
+
*
|
|
414
|
+
* Both or neither: `List-Unsubscribe` alone does not satisfy the bulk-sender
|
|
415
|
+
* rules, and `List-Unsubscribe-Post` without a URL to post to advertises a
|
|
416
|
+
* verb nothing serves.
|
|
417
|
+
*/
|
|
418
|
+
export declare function unsubscribeHeaders(unsubscribeUrl: string): Record<string, string>;
|
|
419
|
+
/**
|
|
420
|
+
* What the visible opt-out is CALLED, in every part of every message.
|
|
421
|
+
*
|
|
422
|
+
* The link opens the preference page, where leaving one stream is a choice
|
|
423
|
+
* alongside leaving all of them — and a footer that says only "Unsubscribe"
|
|
424
|
+
* is the only place a recipient would have learned that, so it never gets
|
|
425
|
+
* said. The word "unsubscribe" stays in the line because that is what a
|
|
426
|
+
* recipient scans a footer for.
|
|
427
|
+
*
|
|
428
|
+
* Named once and shared with `renderRecipientEmail`, which writes this same
|
|
429
|
+
* line into a campaign's text part: two spellings of one sentence is how the
|
|
430
|
+
* idempotency checks below come to append a second footer to a message that
|
|
431
|
+
* already had one.
|
|
432
|
+
*/
|
|
433
|
+
export declare const UNSUBSCRIBE_FOOTER_LABEL = "Choose which emails you get, or unsubscribe";
|
|
434
|
+
/**
|
|
435
|
+
* The visible opt-out, appended to the plain-text part.
|
|
436
|
+
*
|
|
437
|
+
* The headers are for the mailbox provider; this is for the person. CAN-SPAM
|
|
438
|
+
* asks for a mechanism the recipient can see and use, and a header is neither
|
|
439
|
+
* — most clients render no control for it at all unless the sender's domain
|
|
440
|
+
* reputation is high enough for the provider to offer one.
|
|
441
|
+
*
|
|
442
|
+
* Idempotent by URL: a body that already carries the link is returned
|
|
443
|
+
* untouched, so a sender that writes its own footer does not get two.
|
|
444
|
+
*/
|
|
445
|
+
export declare function appendUnsubscribeText(text: string, unsubscribeUrl: string): string;
|
|
446
|
+
/**
|
|
447
|
+
* The same visible opt-out for the HTML part.
|
|
448
|
+
*
|
|
449
|
+
* Appended to whatever the sender produced rather than woven into it, because
|
|
450
|
+
* the HTML may be a merchant-designed template this module knows nothing
|
|
451
|
+
* about. A designed template that already renders `{{unsubscribeUrl}}`
|
|
452
|
+
* carries the URL, so the check below leaves it alone and the merchant's own
|
|
453
|
+
* placement wins.
|
|
454
|
+
*
|
|
455
|
+
* ⚠️ That check has to look for the ESCAPED URL as well. A signed opt-out
|
|
456
|
+
* link carries `&` between its query parameters, and a renderer putting it
|
|
457
|
+
* into an `href` escapes it — so `renderEmailHtml` emits `…&sig=…` and a
|
|
458
|
+
* plain `includes` of the unescaped URL matches nothing. Every designed
|
|
459
|
+
* template in the product goes through that renderer, which means the
|
|
460
|
+
* merchants who DID place the token were the ones getting two footers.
|
|
461
|
+
*
|
|
462
|
+
* Styles are inline and literal because this is email HTML: mail clients
|
|
463
|
+
* strip `<style>` blocks and support no CSS variables, so a theme token
|
|
464
|
+
* cannot reach the wire.
|
|
465
|
+
*/
|
|
466
|
+
export declare function appendUnsubscribeHtml(html: string, unsubscribeUrl: string): string;
|