@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.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/package.json +37 -0
  4. package/src/index.d.ts +37 -0
  5. package/src/index.js +46 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/email-delivery-events.d.ts +198 -0
  8. package/src/lib/email-delivery-events.js +310 -0
  9. package/src/lib/email-delivery-events.js.map +1 -0
  10. package/src/lib/email-health.d.ts +154 -0
  11. package/src/lib/email-health.js +264 -0
  12. package/src/lib/email-health.js.map +1 -0
  13. package/src/lib/email-media-src.d.ts +33 -0
  14. package/src/lib/email-media-src.js +113 -0
  15. package/src/lib/email-media-src.js.map +1 -0
  16. package/src/lib/email-merge.d.ts +31 -0
  17. package/src/lib/email-merge.js +36 -0
  18. package/src/lib/email-merge.js.map +1 -0
  19. package/src/lib/email-render.d.ts +114 -0
  20. package/src/lib/email-render.js +293 -0
  21. package/src/lib/email-render.js.map +1 -0
  22. package/src/lib/email-revenue-window.d.ts +55 -0
  23. package/src/lib/email-revenue-window.js +58 -0
  24. package/src/lib/email-revenue-window.js.map +1 -0
  25. package/src/lib/host-email-render.d.ts +99 -0
  26. package/src/lib/host-email-render.js +126 -0
  27. package/src/lib/host-email-render.js.map +1 -0
  28. package/src/lib/host-sender.d.ts +86 -0
  29. package/src/lib/host-sender.js +131 -0
  30. package/src/lib/host-sender.js.map +1 -0
  31. package/src/lib/marketing-send.d.ts +466 -0
  32. package/src/lib/marketing-send.js +459 -0
  33. package/src/lib/marketing-send.js.map +1 -0
  34. package/src/lib/platform-sending-domain.d.ts +362 -0
  35. package/src/lib/platform-sending-domain.js +697 -0
  36. package/src/lib/platform-sending-domain.js.map +1 -0
  37. package/src/lib/received-email.d.ts +86 -0
  38. package/src/lib/received-email.js +124 -0
  39. package/src/lib/received-email.js.map +1 -0
  40. package/src/lib/send-ceilings.d.ts +394 -0
  41. package/src/lib/send-ceilings.js +341 -0
  42. package/src/lib/send-ceilings.js.map +1 -0
  43. package/src/lib/send-email.d.ts +385 -0
  44. package/src/lib/send-email.js +586 -0
  45. package/src/lib/send-email.js.map +1 -0
  46. package/src/lib/send-rate.d.ts +298 -0
  47. package/src/lib/send-rate.js +310 -0
  48. package/src/lib/send-rate.js.map +1 -0
  49. package/src/lib/sender-reputation.d.ts +357 -0
  50. package/src/lib/sender-reputation.js +392 -0
  51. package/src/lib/sender-reputation.js.map +1 -0
  52. package/src/lib/sending-domain.d.ts +744 -0
  53. package/src/lib/sending-domain.js +798 -0
  54. package/src/lib/sending-domain.js.map +1 -0
  55. package/src/lib/sending-mailbox.d.ts +132 -0
  56. package/src/lib/sending-mailbox.js +192 -0
  57. package/src/lib/sending-mailbox.js.map +1 -0
  58. package/src/lib/stored-email-nodes.d.ts +38 -0
  59. package/src/lib/stored-email-nodes.js +78 -0
  60. package/src/lib/stored-email-nodes.js.map +1 -0
  61. package/src/lib/svix-signature.d.ts +38 -0
  62. package/src/lib/svix-signature.js +55 -0
  63. package/src/lib/svix-signature.js.map +1 -0
  64. package/src/lib/system-email-catalog.d.ts +170 -0
  65. package/src/lib/system-email-catalog.js +828 -0
  66. package/src/lib/system-email-catalog.js.map +1 -0
  67. package/src/lib/tenant-email-catalog.d.ts +78 -0
  68. package/src/lib/tenant-email-catalog.js +452 -0
  69. package/src/lib/tenant-email-catalog.js.map +1 -0
  70. package/src/lib/text-email-html.d.ts +32 -0
  71. package/src/lib/text-email-html.js +119 -0
  72. 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 `…&amp;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;