@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,385 @@
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 Resend send endpoint. Every outbound application email in Aglyn goes
19
+ * through here — invites, receipts, usage summaries, campaigns, staff alerts.
20
+ *
21
+ * Auth email (verification, password reset) is Firebase's job and does NOT
22
+ * come through this module.
23
+ */
24
+ import { type EmailSendPriority } from './send-rate';
25
+ import { type SendingIdentityAudience, type SendingIdentityVerdict } from './sending-domain';
26
+ import { type MarketingSendContext } from './marketing-send';
27
+ export declare const RESEND_SEND_ENDPOINT = "https://api.resend.com/emails";
28
+ /** A Resend delivery tag, used for webhook attribution (AGL-268). */
29
+ export interface EmailTag {
30
+ name: string;
31
+ value: string;
32
+ }
33
+ export interface SendEmailOptions {
34
+ /** One or more recipient addresses. */
35
+ to: string | string[];
36
+ subject: string;
37
+ /** Plain-text body. Supply at least one of `text` or `html`. */
38
+ text?: string;
39
+ /**
40
+ * HTML body. Supply at least one of `text` or `html`.
41
+ *
42
+ * Omitted, one is synthesized from `text` so the message always carries an
43
+ * HTML part — a text-only message has no anchors, so its links are inert in
44
+ * the inbox and Resend's click tracking has nothing to rewrite. See
45
+ * `text-email-html.ts`.
46
+ */
47
+ html?: string;
48
+ /** Extra MIME headers, e.g. `List-Unsubscribe`. */
49
+ headers?: Record<string, string>;
50
+ /** Delivery tags for the opens/clicks webhook. */
51
+ tags?: EmailTag[];
52
+ replyTo?: string | string[];
53
+ /**
54
+ * White-label display name for the sender (White-Label Phase 1). Replaces
55
+ * only the display name in front of the verified address — the address
56
+ * itself is never taken from the caller, so this cannot forge a different
57
+ * sender. Callers pass `resolveBrandingProfile(org).fromName` here so an
58
+ * agency's mail reads as their brand instead of "Aglyn".
59
+ */
60
+ fromName?: string;
61
+ /**
62
+ * The server-resolved sending identity for this message, from
63
+ * `resolveSendingIdentity`.
64
+ *
65
+ * Supplied, it decides the address and it may refuse the send outright —
66
+ * `fromName` is subordinate to it, because a verdict is the answer to "may
67
+ * this leave, and as whom" and a display name is not. Omitted, every
68
+ * existing caller keeps the behavior it had: the configured platform
69
+ * identity with an optional display name.
70
+ *
71
+ * Callers resolve it from the ORG DOCUMENT, never from request input. An
72
+ * address assembled from a request body is a `From:` override wearing a new
73
+ * name, and the invariant `applyFromName` exists to hold is that the
74
+ * address cannot move off a verified identity.
75
+ */
76
+ sendingIdentity?: SendingIdentityVerdict | null;
77
+ /**
78
+ * Whose mail this is — see {@link SendingIdentityAudience}.
79
+ *
80
+ * `tenant` says the message belongs to a SITE, and it makes the platform
81
+ * sender unreachable: a tenant message with no resolved identity is refused
82
+ * rather than sent from `aglyn.com`. Pair it with `sendingIdentity` from
83
+ * `hostSendingIdentity(hostId)` and the ordinary path is unchanged; the flag
84
+ * is what decides the behavior when that resolution is missing or refuses.
85
+ *
86
+ * Omitted, a send is platform mail and keeps the configured sender, because
87
+ * that is what the console's own senders are. `email-audience-coverage.spec`
88
+ * sweeps the tenant-owned trees so the omission cannot be an accident there.
89
+ */
90
+ audience?: SendingIdentityAudience;
91
+ /**
92
+ * Short label for logs, e.g. `'invite'` or `'usage-summary'`. Makes a
93
+ * failure in the runtime logs traceable to the feature that caused it.
94
+ *
95
+ * Since AGL-2407 it is also stamped as a Resend `context` TAG on every
96
+ * send — see `contextTag` below.
97
+ */
98
+ context?: string;
99
+ /**
100
+ * What the platform send-rate governor is allowed to do to this message
101
+ * (AGL-2409). Omitted, it is derived from `context`: `'campaign'` is a
102
+ * campaign and everything else is transactional, so no existing caller
103
+ * changes and the default is the one that can never be refused.
104
+ *
105
+ * Set it to `'bulk'` ONLY from a resumable sweep — a cron that leaves its
106
+ * subject unstamped and picks it up on the next run. A refusal for a bulk
107
+ * send means "not this hour", and a caller that cannot come back would turn
108
+ * that into a message nobody ever gets.
109
+ */
110
+ priority?: EmailSendPriority;
111
+ /**
112
+ * Declares this message as MARKETING mail for one site's audience — see
113
+ * `marketing-send.ts` for what that means and why it is one seam.
114
+ *
115
+ * Set it and the message gains, in one place, the three things marketing
116
+ * mail owes: the RFC 8058 unsubscribe header pair plus a visible opt-out
117
+ * link, a check against both suppression lists, and a ceiling on how much
118
+ * one person receives from one site.
119
+ *
120
+ * ONE RECIPIENT. An unsubscribe link is an HMAC over the address it belongs
121
+ * to, and a suppression verdict is about one person — so a marketing send
122
+ * addressed to a list would carry the wrong link for everybody after the
123
+ * first, and would ask the gate about one of them. Callers fan out.
124
+ */
125
+ marketing?: MarketingSendContext;
126
+ }
127
+ /**
128
+ * The `context` tag, attached to every send (AGL-2407).
129
+ *
130
+ * ## Why this is here and not at 37 call sites
131
+ *
132
+ * Until now `tags` were set by exactly one sender, `campaign-send.ts`, which
133
+ * stamps `hostId` and `campaignId` for the opens/clicks webhook. Everything
134
+ * else went out with NO tags at all, so a bounce on an invite, a password
135
+ * reset, a receipt or a usage summary reached the webhook carrying nothing to
136
+ * identify it, and was dropped.
137
+ *
138
+ * The obvious fix — thread an identifier through every call site — asks 37
139
+ * places to remember, which is the shape that produces the 38th that does
140
+ * not. But `context` is ALREADY threaded through 35 of the 37 for logging,
141
+ * and it is exactly the right value: it names the sender. So the tag is
142
+ * derived here, once, and no caller changes.
143
+ *
144
+ * Resend tag values are restricted to ASCII letters, digits, `_` and `-`;
145
+ * anything else is rejected and would fail the whole send. Every `context` in
146
+ * the tree is already a plain slug, but this is mail delivery — a value that
147
+ * makes the send fail is far worse than a value that is sanitised — so the
148
+ * label is normalised rather than trusted, and a context that sanitises to
149
+ * nothing yields no tag rather than an invalid one.
150
+ */
151
+ export declare function contextTag(context: string | undefined): EmailTag[];
152
+ /**
153
+ * Why a send did not happen. `unconfigured` and `no-recipient` mean nothing
154
+ * was attempted; `rejected` and `network` mean Resend was called and failed;
155
+ * `rate-limited` is either, and says so in `status`.
156
+ *
157
+ * `rate-limited` (AGL-2409) and `frequency-capped` are the two a caller may
158
+ * reasonably retry unchanged — see {@link isDeferrableSendResult}, which is
159
+ * where that distinction is made once rather than at each sweep.
160
+ */
161
+ export type SendEmailFailureReason = 'unconfigured' | 'no-recipient'
162
+ /**
163
+ * Resend answered and would not take this message.
164
+ *
165
+ * Per-message and terminal: a malformed payload, an address the provider
166
+ * will not accept, a tag it rejected. A caller that retries it unchanged
167
+ * gets the same answer, so a batch settles the recipient rather than
168
+ * holding a slot open for them.
169
+ *
170
+ * A 429 is deliberately NOT this. See `rate-limited`.
171
+ */
172
+ | 'rejected' | 'network'
173
+ /**
174
+ * A refusal that is about the RATE, not about this message — so the message
175
+ * is intact and a later attempt sends it.
176
+ *
177
+ * Two sources, which is why it is one value. The platform hourly governor
178
+ * refuses before the network and nothing is attempted (AGL-2409). Resend
179
+ * answers `429` on the wire when requests arrive faster than it accepts
180
+ * them, and `status` is 429 in that case.
181
+ *
182
+ * The provider arm reports here rather than as `rejected` because of what
183
+ * the two mean to a batch. A `rejected` recipient is settled and never
184
+ * addressed again, which is right for an address the provider will not take
185
+ * and wrong for every recipient a 429 touches: nothing about them was
186
+ * refused, the request was simply too soon. Classified as `rejected` a
187
+ * single rate-limited burst silently deletes the rest of a campaign's
188
+ * audience while the campaign reports itself complete — and the `sent`
189
+ * figure stays honest throughout, so no rate on the report can show it.
190
+ *
191
+ * The provider's quota errors — `daily_quota_exceeded`,
192
+ * `monthly_quota_exceeded` — are also 429 and also land here. That is the
193
+ * right answer for the same reason: none of them is a statement about the
194
+ * recipient, and all of them clear with time.
195
+ */
196
+ | 'rate-limited'
197
+ /**
198
+ * The org selected a custom sending domain and that domain is not verified.
199
+ *
200
+ * Distinct from `unconfigured` because the two need opposite responses: an
201
+ * unconfigured deployment is the operator's to fix, while this is a
202
+ * customer's DNS that is not finished, and the customer is the only person
203
+ * who can finish it. `detail` carries the sentence naming the domain.
204
+ *
205
+ * This is the LAST line of defence, not the visible one. A caller that
206
+ * reaches it has already skipped the check its route should have made, and
207
+ * a refusal seen only here is a log line — which is the shape of the
208
+ * `USAGE_EMAIL_FROM` outage. `performCampaignSend` refuses first, with a
209
+ * `409`, so a person finds out.
210
+ */
211
+ | 'unverified-domain'
212
+ /**
213
+ * A MARKETING send whose recipient is on a suppression list — they
214
+ * unsubscribed from this site, hard-bounced, or pressed "report spam".
215
+ *
216
+ * Terminal, and the only outcome here a caller must not retry: retrying is
217
+ * the behavior the suppression exists to stop. Distinct from `rejected`
218
+ * because nothing was attempted and nothing failed — this is the control
219
+ * working.
220
+ */
221
+ | 'suppressed'
222
+ /**
223
+ * A MARKETING send refused because this person has already received their
224
+ * ceiling from this site inside the window.
225
+ *
226
+ * Retryable, unlike `suppressed`: the window rolls. A resumable sweep does
227
+ * not need to do anything about it — its next run asks again.
228
+ */
229
+ | 'frequency-capped'
230
+ /**
231
+ * A MARKETING send refused because this site has been mailing this person
232
+ * for longer than the sunset window with nothing to show for it.
233
+ *
234
+ * TERMINAL for a sweep, and it sits with `suppressed` rather than with
235
+ * `frequency-capped` for a reason worth stating: the frequency window
236
+ * clears by the passage of time, so waiting works. A sunset clears when the
237
+ * PERSON engages, which more mail from us cannot cause — so a sweep that
238
+ * treated it as deferrable would re-read the same doomed row on every beat
239
+ * forever. Nothing about the recipient has been reduced; the next message
240
+ * after they open anything goes.
241
+ */
242
+ | 'unengaged';
243
+ export type SendEmailResult = {
244
+ sent: true;
245
+ id: string | null;
246
+ } | {
247
+ sent: false;
248
+ reason: SendEmailFailureReason;
249
+ /** HTTP status, when Resend answered. */
250
+ status?: number;
251
+ /** Resend's error body or the thrown message, trimmed for logs. */
252
+ detail?: string;
253
+ /**
254
+ * `rate-limited` only: the earliest instant a caller may try again —
255
+ * when the platform hourly window rolls, or what the provider's own
256
+ * `retry-after` asked for. A resumable sweep does not need to wait on
257
+ * it; its next scheduled run is the retry.
258
+ */
259
+ retryAtMs?: number;
260
+ };
261
+ /**
262
+ * The retry instant when the platform send-rate governor deferred this
263
+ * message, or `null` for every other outcome (AGL-2409).
264
+ *
265
+ * A FUNCTION rather than `result.reason === 'rate-limited'` at each call site,
266
+ * because `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a
267
+ * boolean-literal discriminant without it: `if (result.sent) … else
268
+ * result.reason` does not compile, in every consumer, for a reason that has
269
+ * nothing to do with this union. One helper is also one place to change if the
270
+ * shape of a deferral ever moves.
271
+ */
272
+ export declare function rateLimitedRetryAtMs(result: SendEmailResult | null | undefined): number | null;
273
+ /**
274
+ * When the provider says a refused request may be repeated.
275
+ *
276
+ * Read from the two headers Resend documents beside a 429, both in whole
277
+ * seconds: `retry-after` first because it is the direct answer to this
278
+ * question, then `ratelimit-reset`, which names when the window rolls. A
279
+ * response carrying neither falls back to one window.
280
+ */
281
+ export declare function providerRetryAtMs(headers: {
282
+ get?: (name: string) => string | null;
283
+ } | null | undefined, nowMs?: number): number;
284
+ /**
285
+ * Why a send did not happen, or `null` when it did.
286
+ *
287
+ * The same accessor `rateLimitedRetryAtMs` is, generalized: `strictNullChecks`
288
+ * is OFF repo-wide, so TypeScript will not narrow the union on `result.sent`
289
+ * and reading `result.reason` at a call site does not compile.
290
+ */
291
+ export declare function sendFailureReason(result: SendEmailResult | null | undefined): SendEmailFailureReason | null;
292
+ /**
293
+ * Whether this outcome is worth coming back for.
294
+ *
295
+ * TRUE only for the two refusals a later attempt can pass: the platform hour
296
+ * rolls, and so does the marketing frequency window. Everything else is
297
+ * either a delivery that happened or a failure a retry repeats — a
298
+ * suppression most of all, since retrying is the exact behavior a suppression
299
+ * exists to stop, and a sunset for the same reason at one remove: it clears
300
+ * when the recipient engages, which no amount of further mail from us brings
301
+ * about.
302
+ *
303
+ * A resumable sweep uses this to decide whether to leave its subject
304
+ * unstamped. Stamping on a deferrable refusal discards a message; NOT
305
+ * stamping on a terminal one re-reads the same doomed row on every beat until
306
+ * it crowds out the work that could succeed. Both are silent, so the
307
+ * distinction lives here instead of at each sweep.
308
+ *
309
+ * A FUNCTION rather than `result.reason === …` at each call site, because
310
+ * `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a
311
+ * boolean-literal discriminant without it — the same reason
312
+ * {@link rateLimitedRetryAtMs} beside it is one.
313
+ */
314
+ export declare function isDeferrableSendResult(result: SendEmailResult | null | undefined): boolean;
315
+ export interface EmailConfig {
316
+ apiKey: string | undefined;
317
+ from: string | undefined;
318
+ }
319
+ /**
320
+ * Reads the email environment.
321
+ *
322
+ * Deliberately read per call rather than captured at module load: these run
323
+ * in serverless handlers where the module may be evaluated during a build,
324
+ * long before the runtime env exists.
325
+ */
326
+ export declare function getEmailConfig(): EmailConfig;
327
+ /**
328
+ * True when both `RESEND_API_KEY` and `USAGE_EMAIL_FROM` are present.
329
+ *
330
+ * Callers that answer an HTTP request (rather than firing best-effort mail)
331
+ * use this to return a 501 with an actionable message instead of pretending
332
+ * to have sent something.
333
+ */
334
+ export declare function isEmailConfigured(): boolean;
335
+ /**
336
+ * Applies a white-label display name to a configured sender while keeping
337
+ * its verified address (White-Label Phase 1). Accepts either a bare address
338
+ * (`noreply@aglyn.com`) or an RFC-5322 `Name <addr>` header and returns
339
+ * `"<fromName>" <addr>`. A blank name, or a value with no extractable
340
+ * address, yields the original `from` untouched — the sender identity is
341
+ * never dropped on the floor.
342
+ */
343
+ export declare function applyFromName(from: string | undefined, fromName: string | undefined): string | undefined;
344
+ /** A Resend send payload in the provider's own wire shape. */
345
+ export interface ResendSendPayload {
346
+ to?: unknown;
347
+ from?: unknown;
348
+ subject?: unknown;
349
+ [field: string]: unknown;
350
+ }
351
+ /**
352
+ * The one place that POSTs to Resend's send endpoint, and the last thing
353
+ * standing between a payload and the network.
354
+ *
355
+ * A payload carrying no recipient cannot become a message. Resend answers it
356
+ * `422 missing_required_field`, which costs an API call and then shows up in
357
+ * the vendor dashboard as a red line indistinguishable from mail that
358
+ * genuinely failed to deliver — carrying no subject, no recipient and nothing
359
+ * naming the code that produced it. Diagnosing that means reading a log
360
+ * outside the deployment and guessing. So the refusal happens here, before
361
+ * the fetch, and names the caller's `context`.
362
+ *
363
+ * It throws rather than returning a `SendEmailResult`: this is a programming
364
+ * error, not a delivery outcome. `sendEmail` filters recipients well before
365
+ * it reaches this call, so nothing on the ordinary path can trip it. The
366
+ * guard exists because `RESEND_SEND_ENDPOINT` is exported and any module can
367
+ * therefore reach the send endpoint on its own, bypassing every check
368
+ * `sendEmail` owns.
369
+ */
370
+ export declare function postResendEmail(apiKey: string, payload: ResendSendPayload, context?: string): Promise<Response>;
371
+ /**
372
+ * Sends one email through Resend.
373
+ *
374
+ * **Never throws and never rejects.** Outbound mail is best-effort across
375
+ * every caller in this codebase — a checkout must not fail because a receipt
376
+ * bounced — so every outcome comes back as a `SendEmailResult` instead. The
377
+ * one thing callers must not do is ignore the result: `sent` is what tells
378
+ * the user whether a message actually went out (AGL-708).
379
+ *
380
+ * When the env vars are missing this warns once per call and returns
381
+ * `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and
382
+ * preview environments keep working without a Resend account.
383
+ */
384
+ export declare function sendEmail(options: SendEmailOptions): Promise<SendEmailResult>;
385
+ export default sendEmail;