@aglyn/shared-util-email 1.0.0-beta.218 → 1.0.0-beta.219

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 (42) hide show
  1. package/README.md +58 -19
  2. package/package.json +3 -3
  3. package/src/index.d.ts +2 -0
  4. package/src/index.js +9 -1
  5. package/src/index.js.map +1 -1
  6. package/src/lib/email-delivery-events.d.ts +41 -48
  7. package/src/lib/email-delivery-events.js +18 -247
  8. package/src/lib/email-delivery-events.js.map +1 -1
  9. package/src/lib/email-health.d.ts +30 -43
  10. package/src/lib/email-health.js +45 -141
  11. package/src/lib/email-health.js.map +1 -1
  12. package/src/lib/mail-provider-resend.d.ts +194 -0
  13. package/src/lib/mail-provider-resend.js +649 -0
  14. package/src/lib/mail-provider-resend.js.map +1 -0
  15. package/src/lib/mail-provider-webhook.d.ts +80 -0
  16. package/src/lib/mail-provider-webhook.js +156 -0
  17. package/src/lib/mail-provider-webhook.js.map +1 -0
  18. package/src/lib/mail-provider.d.ts +148 -0
  19. package/src/lib/mail-provider.js +84 -0
  20. package/src/lib/mail-provider.js.map +1 -0
  21. package/src/lib/mail-providers.d.ts +99 -0
  22. package/src/lib/mail-providers.js +182 -0
  23. package/src/lib/mail-providers.js.map +1 -0
  24. package/src/lib/received-email.d.ts +7 -44
  25. package/src/lib/received-email.js +8 -104
  26. package/src/lib/received-email.js.map +1 -1
  27. package/src/lib/send-ceilings.d.ts +0 -57
  28. package/src/lib/send-ceilings.js +0 -47
  29. package/src/lib/send-ceilings.js.map +1 -1
  30. package/src/lib/send-email.d.ts +29 -58
  31. package/src/lib/send-email.js +98 -131
  32. package/src/lib/send-email.js.map +1 -1
  33. package/src/lib/send-rate.js +4 -3
  34. package/src/lib/send-rate.js.map +1 -1
  35. package/src/lib/system-email-catalog.js +3 -3
  36. package/src/lib/system-email-catalog.js.map +1 -1
  37. package/src/lib/tenant-email-catalog.d.ts +7 -0
  38. package/src/lib/tenant-email-catalog.js +10 -591
  39. package/src/lib/tenant-email-catalog.js.map +1 -1
  40. package/src/lib/tenant-emails.generated.d.ts +11 -0
  41. package/src/lib/tenant-emails.generated.js +594 -0
  42. package/src/lib/tenant-emails.generated.js.map +1 -0
package/README.md CHANGED
@@ -6,34 +6,71 @@
6
6
 
7
7
  npm install @aglyn/shared-util-email@beta
8
8
 
9
- One place that sends outbound **application** email, via
10
- [Resend](https://resend.com).
9
+ One place that sends outbound **application** email, through a mail provider
10
+ the deployment chooses: [Resend](https://resend.com) by default, or the
11
+ operator's own sender behind a webhook.
11
12
 
12
- Before this library the same ~30 lines of `fetch('https://api.resend.com/emails')`
13
- were copy-pasted across 10 files, each reading the env vars itself and each
14
- handling failure a little differently. Consolidating them means the provider,
15
- the sender identity, and the failure semantics change in one place.
13
+ Before this library the same ~30 lines of a vendor `fetch` were copy-pasted
14
+ across 10 files, each reading the env vars itself and each handling failure a
15
+ little differently. Consolidating them means the provider, the sender
16
+ identity, and the failure semantics change in one place.
16
17
 
17
18
  ## What is and is not in scope
18
19
 
19
20
  | Mail | Sent by | Here? |
20
21
  | --- | --- | --- |
21
- | Invites, receipts, usage summaries, campaigns, staff alerts | Resend | ✅ |
22
+ | Invites, receipts, usage summaries, campaigns, staff alerts | the mail provider | ✅ |
22
23
  | Verification, password reset | Firebase Auth | ❌ |
23
24
  | Inbound mail to `@aglyn.com` mailboxes | Google Workspace | ❌ |
24
25
 
25
26
  ## Configuration
26
27
 
27
- Two env vars, read at call time:
28
+ The sender, and the settings of the provider that carries the mail, read at
29
+ call time:
28
30
 
29
31
  ```
30
- RESEND_API_KEY=re_xxxxxxxx
31
32
  USAGE_EMAIL_FROM="Aglyn <noreply@aglyn.com>"
33
+ RESEND_API_KEY=re_xxxxxxxx # the default provider
34
+ # or, for your own sender:
35
+ AGLYN_MAIL_WEBHOOK_URL=https://relay.internal/send
36
+ AGLYN_MAIL_WEBHOOK_TOKEN=…
32
37
  ```
33
38
 
34
- **Both** are required. With either missing, `sendEmail()` warns and returns
35
- `{ sent: false, reason: 'unconfigured' }` — it does not throw, so local and
36
- preview environments run fine without a Resend account.
39
+ Both the sender and the provider's settings are required. With either
40
+ missing, `sendEmail()` warns and returns `{ sent: false, reason:
41
+ 'unconfigured' }` — it does not throw, so local and preview environments run
42
+ fine without a mail provider.
43
+
44
+ ## The mail provider
45
+
46
+ `sendEmail()` decides whether, as whom and to whom a message leaves —
47
+ suppression, the send-rate governor, the phishing screen, the sending
48
+ identity, the unsubscribe footer. A `MailProvider` (`mail-provider.ts`) only
49
+ carries the finished message, and answers the platform's other questions
50
+ about mail in the platform's own vocabulary: whether its credential works,
51
+ what a delivery webhook said, what a received message contained, what the
52
+ account already sent.
53
+
54
+ `mail-providers.ts` picks the deployment's provider:
55
+
56
+ - `AGLYN_MAIL_PROVIDER` names one — `resend`, `webhook`, or an id a plugin or
57
+ fork registered with `registerMailProvider()`.
58
+ - Unset, the first built-in with its settings wins: `resend` when
59
+ `RESEND_API_KEY` is set, `webhook` when only `AGLYN_MAIL_WEBHOOK_URL` is,
60
+ otherwise `resend`, unconfigured.
61
+ - A name that is neither built in nor registered is **refused**: every send
62
+ is skipped with an error naming it, and nothing falls back to another
63
+ vendor.
64
+
65
+ The built-ins are compiled into this library rather than registered by a
66
+ plugin because this is the transactional rail: it must send from every server
67
+ process with or without a plugin loaded, and a registration that did not
68
+ happen would look exactly like mail that is switched off.
69
+
70
+ The rest of the platform asks through `normalizeDeliveryEvents()`,
71
+ `readInboundMailEvent()` and `mailProviderReads()`, never through a provider
72
+ by name. `mail-provider-resend.ts` is the only module that knows Resend's
73
+ wire format, and `mail-provider-webhook.ts` documents the webhook's.
37
74
 
38
75
  Setup and DNS are documented in [`docs/EMAIL_SETUP.md`](../../../../docs/EMAIL_SETUP.md).
39
76
 
@@ -66,7 +103,8 @@ correct, since the point of `USAGE_EMAIL_FROM` is one verified identity).
66
103
 
67
104
  ### The transport boundary
68
105
 
69
- `postResendEmail()` is the only function that POSTs to Resend's send endpoint,
106
+ `postResendEmail()` (in `mail-provider-resend.ts`) is the only function that
107
+ POSTs to Resend's send endpoint,
70
108
  and it **throws** on a payload with no `to` rather than putting it on the wire.
71
109
  Such a payload cannot become a message; Resend answers `422
72
110
  missing_required_field`, which costs an API call and then sits in the vendor
@@ -112,12 +150,13 @@ import { isEmailConfigured, describeEmailConfig, checkEmailCredentials }
112
150
  from '@aglyn/shared-util-email'
113
151
  ```
114
152
 
115
- - `isEmailConfigured()` — both vars present. Use it to answer `501` from a
116
- route instead of pretending to have sent.
117
- - `describeEmailConfig()` — env presence plus the sender and its domain, with
118
- the API key never included.
119
- - `checkEmailCredentials()` — asks Resend whether the key is accepted
120
- **without sending anything**, by `GET`ting the domains collection and
153
+ - `isEmailConfigured()` — the provider has its settings and a sender is set.
154
+ Use it to answer `501` from a route instead of pretending to have sent.
155
+ - `describeEmailConfig()` — which provider, which of its settings are
156
+ missing, plus the sender and its domain, with no credential ever included.
157
+ - `checkEmailCredentials()` — asks the provider whether its credential is
158
+ accepted **without sending anything**. A provider with no probe answers
159
+ `unknown`. Resend's probe `GET`s the domains collection and
121
160
  reading the error NAME rather than the status. A `2xx`, or a
122
161
  `restricted_api_key`/`invalid_permission` rejection (what a sending-scoped
123
162
  key gets, and it can only be reached once Resend has authenticated the key),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/shared-util-email",
3
- "version": "1.0.0-beta.218",
3
+ "version": "1.0.0-beta.219",
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.218",
29
- "@aglyn/shared-util-http": "1.0.0-beta.218",
28
+ "@aglyn/shared-data-enums": "1.0.0-beta.219",
29
+ "@aglyn/shared-util-http": "1.0.0-beta.219",
30
30
  "@msgpack/msgpack": "^3.1.3",
31
31
  "@swc/helpers": "0.5.23"
32
32
  },
package/src/index.d.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  export * from './lib/send-email';
18
+ export * from './lib/mail-provider';
19
+ export * from './lib/mail-providers';
18
20
  export * from './lib/send-rate';
19
21
  export * from './lib/sender-reputation';
20
22
  export * from './lib/send-ceilings';
package/src/index.js CHANGED
@@ -14,6 +14,13 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ export * from "./lib/send-email.js";
17
+ // Who carries the mail (AGL-3080): the provider contract, and the selection
18
+ // of the deployment's provider from the built-ins and any a plugin
19
+ // registered. The providers themselves are imported by their own paths —
20
+ // `@aglyn/shared-util-email/mail-provider-resend`, `…/mail-provider-webhook`
21
+ // — because nothing outside the seam should name one.
22
+ export * from "./lib/mail-provider.js";
23
+ export * from "./lib/mail-providers.js";
17
24
  export * from "./lib/send-rate.js";
18
25
  export * from "./lib/sender-reputation.js";
19
26
  export * from "./lib/send-ceilings.js";
@@ -32,7 +39,8 @@ export * from "./lib/marketing-send.js";
32
39
  // The List-Unsubscribe setting sequences and campaigns share (AGL-3307).
33
40
  export * from "./lib/list-unsubscribe.js";
34
41
  export * from "./lib/email-delivery-events.js";
35
- // The received-mail reader the CRM's capture route files from (AGL-2657).
42
+ // Received mail in the platform's vocabulary, which the CRM's capture route
43
+ // files from (AGL-2657); the provider reads it.
36
44
  // Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:
37
45
  // it holds a `crypto` HMAC, and this barrel is reached from the browser
38
46
  // through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its
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/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// The deliverability engine every send path asks (AGL-3328): the gateway an\n// MX names and its ledger, the bounce reader, and the address verdicts. Pure\n// — the DNS lookup and the store are injected by `@aglyn/tenant-data-admin`,\n// so nothing here reaches `node:dns` from the browser.\nexport * from './lib/mail-gateway'\nexport * from './lib/mail-bounce'\nexport * from './lib/email-deliverability'\n// The sender's side of the same engine (AGL-3328): SPF, DKIM and DMARC for\n// the domain mail leaves from, and whether they align. Pure as well; the\n// lookups are `@aglyn/tenant-data-admin`'s.\nexport * from './lib/sender-readiness'\n// The outbound phishing screen a young workspace's campaigns and automated\n// email pass (AGL-3356). Pure; the hold it leads to is `tenant-data-admin`'s.\nexport * from './lib/outbound-phishing-screen'\n// The send seam's gate for that screen — the store a hold writes is injected\n// by `@aglyn/tenant-data-admin`, like the governor's.\nexport * from './lib/outbound-screen-gate'\n// The same screen read over a published page (AGL-3356): lookalike links and\n// embeds, author-defined credential fields, a brand's call to action.\nexport * from './lib/hosted-page-screen'\n// The site's theme palette as plain data (AGL-3370), so a palette token an\n// author picked resolves in mail. Built without MUI; `shared-ui-theme`'s\n// parity spec holds it equal to the theme.\nexport * from './lib/email-palette'\nexport * from './lib/email-design-chrome'\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;AACjC,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,uDAAuD;AACvD,cAAc,wBAAoB;AAClC,cAAc,uBAAmB;AACjC,cAAc,gCAA4B;AAC1C,2EAA2E;AAC3E,yEAAyE;AACzE,4CAA4C;AAC5C,cAAc,4BAAwB;AACtC,2EAA2E;AAC3E,8EAA8E;AAC9E,cAAc,oCAAgC;AAC9C,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,gCAA4B;AAC1C,6EAA6E;AAC7E,sEAAsE;AACtE,cAAc,8BAA0B;AACxC,2EAA2E;AAC3E,yEAAyE;AACzE,2CAA2C;AAC3C,cAAc,yBAAqB;AACnC,cAAc,+BAA2B"}
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'\n// Who carries the mail (AGL-3080): the provider contract, and the selection\n// of the deployment's provider from the built-ins and any a plugin\n// registered. The providers themselves are imported by their own paths —\n// `@aglyn/shared-util-email/mail-provider-resend`, `…/mail-provider-webhook`\n// — because nothing outside the seam should name one.\nexport * from './lib/mail-provider'\nexport * from './lib/mail-providers'\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// Received mail in the platform's vocabulary, which the CRM's capture route\n// files from (AGL-2657); the provider reads it.\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// The deliverability engine every send path asks (AGL-3328): the gateway an\n// MX names and its ledger, the bounce reader, and the address verdicts. Pure\n// — the DNS lookup and the store are injected by `@aglyn/tenant-data-admin`,\n// so nothing here reaches `node:dns` from the browser.\nexport * from './lib/mail-gateway'\nexport * from './lib/mail-bounce'\nexport * from './lib/email-deliverability'\n// The sender's side of the same engine (AGL-3328): SPF, DKIM and DMARC for\n// the domain mail leaves from, and whether they align. Pure as well; the\n// lookups are `@aglyn/tenant-data-admin`'s.\nexport * from './lib/sender-readiness'\n// The outbound phishing screen a young workspace's campaigns and automated\n// email pass (AGL-3356). Pure; the hold it leads to is `tenant-data-admin`'s.\nexport * from './lib/outbound-phishing-screen'\n// The send seam's gate for that screen — the store a hold writes is injected\n// by `@aglyn/tenant-data-admin`, like the governor's.\nexport * from './lib/outbound-screen-gate'\n// The same screen read over a published page (AGL-3356): lookalike links and\n// embeds, author-defined credential fields, a brand's call to action.\nexport * from './lib/hosted-page-screen'\n// The site's theme palette as plain data (AGL-3370), so a palette token an\n// author picked resolves in mail. Built without MUI; `shared-ui-theme`'s\n// parity spec holds it equal to the theme.\nexport * from './lib/email-palette'\nexport * from './lib/email-design-chrome'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sBAAkB;AAChC,4EAA4E;AAC5E,mEAAmE;AACnE,yEAAyE;AACzE,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,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,4EAA4E;AAC5E,gDAAgD;AAChD,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;AACjC,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,uDAAuD;AACvD,cAAc,wBAAoB;AAClC,cAAc,uBAAmB;AACjC,cAAc,gCAA4B;AAC1C,2EAA2E;AAC3E,yEAAyE;AACzE,4CAA4C;AAC5C,cAAc,4BAAwB;AACtC,2EAA2E;AAC3E,8EAA8E;AAC9E,cAAc,oCAAgC;AAC9C,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,gCAA4B;AAC1C,6EAA6E;AAC7E,sEAAsE;AACtE,cAAc,8BAA0B;AACxC,2EAA2E;AAC3E,yEAAyE;AACzE,2CAA2C;AAC3C,cAAc,yBAAqB;AACnC,cAAc,+BAA2B"}
@@ -14,6 +14,42 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ /**
18
+ * WHAT HAPPENED TO A MESSAGE, IN OUR OWN VOCABULARY.
19
+ *
20
+ * ## The seam
21
+ *
22
+ * A staff answer to "did they get the invite, and did they open it?" must not
23
+ * be a question about one sending vendor. Two things follow from that, and
24
+ * this module is both of them:
25
+ *
26
+ * 1. **The stored shape is ours.** Nothing downstream — the delivery log, the
27
+ * staff card, a future export — reads a provider's field names or its event
28
+ * strings. A provider turns its own wire format into these types (its
29
+ * `deliveryEvents` and `reads`, see `mail-provider.ts`), and swapping the
30
+ * sender changes that provider and nothing else in the tree.
31
+ * 2. **The history is ours.** The log is written into our own Firestore and
32
+ * read from there, never from the provider on render. A provider's list
33
+ * endpoint is a different shape per vendor, has its own retention window,
34
+ * and disappears entirely with the account; a record we keep survives the
35
+ * migration that the seam exists to make possible.
36
+ *
37
+ * That is a rule about the READ PATH, not a rule against ever reading the
38
+ * provider. The event feed only knows mail sent after it was connected, so
39
+ * a log fed by events alone is empty for all existing history — which is
40
+ * precisely the mail a support question is about. The second half of this
41
+ * module (see THE READ SIDE OF THE SEAM below) is the vocabulary that
42
+ * history is imported through, once, into the same store.
43
+ *
44
+ * ## Pure on purpose
45
+ *
46
+ * No Firestore and no admin SDK. `system-email-catalog` is imported by console
47
+ * CLIENT components through this library's barrel, so anything reachable from
48
+ * it that touched `firebase-admin` would drag the admin SDK into a browser
49
+ * bundle. Normalisation is a pure function of a payload; the writing lives in
50
+ * `@aglyn/tenant-data-admin/server/email-delivery-log`, and the reading
51
+ * of a provider's payloads in that provider.
52
+ */
17
53
  /**
18
54
  * The lifecycle of one message, in the order it normally happens.
19
55
  *
@@ -34,7 +70,7 @@ export interface EmailDeliveryEvent {
34
70
  type: EmailDeliveryEventType;
35
71
  /** When the PROVIDER says it happened, falling back to receipt time. */
36
72
  at: number;
37
- /** Slug of the sending provider, e.g. `'resend'`. */
73
+ /** Slug of the sending provider — the `id` of the provider that carried it. */
38
74
  provider: string;
39
75
  /** The provider's id for the message. Our per-message document key. */
40
76
  providerMessageId: string;
@@ -80,23 +116,6 @@ export declare function bareSenderAddress(value: unknown): string | null;
80
116
  export declare function worstDeliveryStatus(current: EmailDeliveryEventType | null | undefined, next: EmailDeliveryEventType): EmailDeliveryEventType;
81
117
  /** Tags arrive as an array of `{name, value}` or a plain map — accept both. */
82
118
  export declare function normalizeEventTags(raw: unknown): Record<string, string>;
83
- /**
84
- * Turns one Resend webhook payload into zero or more of our events — **the
85
- * only function in the tree that knows Resend's wire format.**
86
- *
87
- * One event per recipient, not per message: a send addressed to three people
88
- * produces one webhook, and a staff view keyed on a person has to be able to
89
- * find it under each of them.
90
- *
91
- * Returns an empty array for anything unrecognised — a contact or domain
92
- * event, an inbound `email.received`, a type added after this was written.
93
- * Silence rather than a throw, because a webhook handler that 500s on an
94
- * unfamiliar event teaches the provider to retry it forever.
95
- *
96
- * @param payload The parsed webhook body.
97
- * @param receivedAtMs Fallback timestamp for a payload that carries none.
98
- */
99
- export declare function normalizeResendDeliveryEvents(payload: unknown, receivedAtMs: number): EmailDeliveryEvent[];
100
119
  /** One message as the provider currently reports it, in our vocabulary. */
101
120
  export interface EmailDeliverySnapshot {
102
121
  provider: string;
@@ -108,18 +127,6 @@ export interface EmailDeliverySnapshot {
108
127
  /** Furthest state the provider reports. Never richer than the event feed. */
109
128
  status: EmailDeliveryEventType;
110
129
  }
111
- /**
112
- * Turns one entry from Resend's `GET /emails` list into zero or more
113
- * snapshots — one per recipient, for the same reason the event adapter fans
114
- * out: the staff view is keyed on a person.
115
- *
116
- * An unrecognised `last_event` falls back to `sent` rather than being
117
- * dropped. The message demonstrably exists and was addressed to somebody, and
118
- * "we sent this and cannot characterise what happened next" is a far more
119
- * useful row than no row — which is the state that sent a staffer to the
120
- * vendor dashboard in the first place.
121
- */
122
- export declare function normalizeResendSentEmails(raw: unknown): EmailDeliverySnapshot[];
123
130
  /** One page of provider history, in our vocabulary. */
124
131
  export interface EmailDeliveryHistoryPage {
125
132
  snapshots: EmailDeliverySnapshot[];
@@ -131,25 +138,15 @@ export interface EmailDeliveryHistoryPage {
131
138
  *
132
139
  * The shape a second provider would implement. Cursor-based rather than
133
140
  * offset- or date-based because that is the lowest common denominator, and
134
- * NOT filtered by recipient: Resend's list endpoint has no recipient
135
- * parameter, so filtering is the caller's job and the import is a sweep
136
- * rather than a per-person lookup. That is the right shape regardless — a
137
- * staff page must not fan out to a third party on render.
141
+ * NOT filtered by recipient: a provider's list endpoint need not take one,
142
+ * so filtering is the caller's job and the import is a sweep rather than a
143
+ * per-person lookup. That is the right shape regardless — a staff page must
144
+ * not fan out to a third party on render.
138
145
  */
139
146
  export type EmailDeliveryHistorySource = (options: {
140
147
  cursor?: string | null;
141
148
  limit?: number;
142
149
  }) => Promise<EmailDeliveryHistoryPage>;
143
- /** Resend's list endpoint. Paginates with `after=<id>`; caps at 100. */
144
- export declare const RESEND_EMAILS_ENDPOINT = "https://api.resend.com/emails";
145
- /**
146
- * {@link EmailDeliveryHistorySource} for Resend.
147
- *
148
- * Needs a FULL-ACCESS key: a sending-scoped key answers every read on this
149
- * endpoint with `401 restricted_api_key`, which is the correct posture for
150
- * the key that sends mail and the reason this takes its own.
151
- */
152
- export declare function resendDeliveryHistorySource(apiKey: string): EmailDeliveryHistorySource;
153
150
  /** One message's content and envelope, in our vocabulary. */
154
151
  export interface EmailDeliveryMessage {
155
152
  provider: string;
@@ -169,7 +166,3 @@ export interface EmailDeliveryMessage {
169
166
  }
170
167
  /** A single message by id. The shape a second provider would implement. */
171
168
  export type EmailDeliveryMessageSource = (providerMessageId: string) => Promise<EmailDeliveryMessage | null>;
172
- /** Resend's `GET /emails/:id` payload, in our vocabulary. */
173
- export declare function normalizeResendMessage(raw: unknown): EmailDeliveryMessage | null;
174
- /** Resend's single-message endpoint. Needs the same full-access key. */
175
- export declare function resendDeliveryMessageSource(apiKey: string): EmailDeliveryMessageSource;
@@ -1,4 +1,3 @@
1
- import { _ as _extends } from "@swc/helpers/_/_extends";
2
1
  /**
3
2
  * @license
4
3
  * Copyright 2026 Aglyn LLC
@@ -20,13 +19,14 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
20
19
  * ## The seam
21
20
  *
22
21
  * A staff answer to "did they get the invite, and did they open it?" must not
23
- * be a question about Resend. Two things follow from that, and this module is
24
- * both of them:
22
+ * be a question about one sending vendor. Two things follow from that, and
23
+ * this module is both of them:
25
24
  *
26
25
  * 1. **The stored shape is ours.** Nothing downstream — the delivery log, the
27
26
  * staff card, a future export — reads a provider's field names or its event
28
- * strings. Swapping the sender changes exactly one function in this file
29
- * and nothing else in the tree.
27
+ * strings. A provider turns its own wire format into these types (its
28
+ * `deliveryEvents` and `reads`, see `mail-provider.ts`), and swapping the
29
+ * sender changes that provider and nothing else in the tree.
30
30
  * 2. **The history is ours.** The log is written into our own Firestore and
31
31
  * read from there, never from the provider on render. A provider's list
32
32
  * endpoint is a different shape per vendor, has its own retention window,
@@ -37,8 +37,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
37
37
  * provider. The event feed only knows mail sent after it was connected, so
38
38
  * a log fed by events alone is empty for all existing history — which is
39
39
  * precisely the mail a support question is about. The second half of this
40
- * module (see THE READ SIDE OF THE SEAM below) imports that history
41
- * through the same neutral vocabulary, once, into the same store.
40
+ * module (see THE READ SIDE OF THE SEAM below) is the vocabulary that
41
+ * history is imported through, once, into the same store.
42
42
  *
43
43
  * ## Pure on purpose
44
44
  *
@@ -46,14 +46,17 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
46
46
  * CLIENT components through this library's barrel, so anything reachable from
47
47
  * it that touched `firebase-admin` would drag the admin SDK into a browser
48
48
  * bundle. Normalisation is a pure function of a payload; the writing lives in
49
- * `@aglyn/tenant-data-admin/server/email-delivery-log`.
50
- *
51
- * The one `fetch` is `resendDeliveryHistorySource`, which is a function that
52
- * must be CALLED with a key to do anything — it holds no module state and is
53
- * unreachable from a client component, unlike an admin-SDK import, which
54
- * executes on load.
55
- */ import { readBounceText } from "./mail-bounce.js";
56
- /** Lifecycle states ordered worst-last, for {@link worstDeliveryStatus}. */ const STATUS_SEVERITY = {
49
+ * `@aglyn/tenant-data-admin/server/email-delivery-log`, and the reading
50
+ * of a provider's payloads in that provider.
51
+ */ /**
52
+ * The lifecycle of one message, in the order it normally happens.
53
+ *
54
+ * Chosen to be the intersection every ESP can report rather than the union of
55
+ * what any one of them does: a vendor with no equivalent for a state simply
56
+ * never produces it, and a vendor with a richer taxonomy folds into the
57
+ * nearest of these rather than widening the type. `delayed` is retryable and
58
+ * `failed` is not, which is the distinction a staffer actually needs.
59
+ */ /** Lifecycle states ordered worst-last, for {@link worstDeliveryStatus}. */ const STATUS_SEVERITY = {
57
60
  sent: 1,
58
61
  delivered: 2,
59
62
  opened: 3,
@@ -90,237 +93,5 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
90
93
  }
91
94
  return {};
92
95
  }
93
- /** Epoch ms from an ISO string or a number, or `null` when unreadable. */ function eventTimeMs(value) {
94
- if (typeof value === 'number' && Number.isFinite(value)) return value;
95
- const parsed = Date.parse(String(value != null ? value : ''));
96
- return Number.isFinite(parsed) ? parsed : null;
97
- }
98
- function normalizeBounceType(value) {
99
- const lowered = String(value != null ? value : '').trim().toLowerCase();
100
- if (lowered === 'permanent') return 'permanent';
101
- if (lowered === 'transient') return 'transient';
102
- return lowered ? 'undetermined' : null;
103
- }
104
- /** The Resend event names we understand, mapped onto ours. */ const RESEND_EVENT_TYPES = {
105
- 'email.sent': 'sent',
106
- 'email.delivered': 'delivered',
107
- 'email.delivery_delayed': 'delayed',
108
- 'email.opened': 'opened',
109
- 'email.clicked': 'clicked',
110
- 'email.bounced': 'bounced',
111
- 'email.complained': 'complained',
112
- 'email.failed': 'failed'
113
- };
114
- /**
115
- * Turns one Resend webhook payload into zero or more of our events — **the
116
- * only function in the tree that knows Resend's wire format.**
117
- *
118
- * One event per recipient, not per message: a send addressed to three people
119
- * produces one webhook, and a staff view keyed on a person has to be able to
120
- * find it under each of them.
121
- *
122
- * Returns an empty array for anything unrecognised — a contact or domain
123
- * event, an inbound `email.received`, a type added after this was written.
124
- * Silence rather than a throw, because a webhook handler that 500s on an
125
- * unfamiliar event teaches the provider to retry it forever.
126
- *
127
- * @param payload The parsed webhook body.
128
- * @param receivedAtMs Fallback timestamp for a payload that carries none.
129
- */ export function normalizeResendDeliveryEvents(payload, receivedAtMs) {
130
- var _event_type, _event_data, _ref, _data_email_id, _ref1, _ref2, _ref3, _eventTimeMs, _data_subject;
131
- var _data_click, _data_open, _data_bounce, _data_bounce1, _data_bounce2, _data_bounce3, _data_bounce4;
132
- const event = payload != null ? payload : {};
133
- const type = RESEND_EVENT_TYPES[String((_event_type = event.type) != null ? _event_type : '')];
134
- if (!type) return [];
135
- const data = (_event_data = event.data) != null ? _event_data : {};
136
- const providerMessageId = String((_ref = (_data_email_id = data.email_id) != null ? _data_email_id : data.id) != null ? _ref : '').trim();
137
- if (!providerMessageId) return [];
138
- const recipients = (Array.isArray(data.to) ? data.to : [
139
- data.to
140
- ]).map((address)=>String(address != null ? address : '').trim().toLowerCase()).filter((address)=>address.includes('@'));
141
- if (!recipients.length) return [];
142
- // The per-state timestamp when the provider gives one, because an open
143
- // three days after the send is the whole point of recording an open.
144
- const at = (_ref1 = (_ref2 = (_ref3 = (_eventTimeMs = eventTimeMs((_data_click = data.click) == null ? void 0 : _data_click.timestamp)) != null ? _eventTimeMs : eventTimeMs((_data_open = data.open) == null ? void 0 : _data_open.timestamp)) != null ? _ref3 : eventTimeMs(event.created_at)) != null ? _ref2 : eventTimeMs(data.created_at)) != null ? _ref1 : receivedAtMs;
145
- const subject = String((_data_subject = data.subject) != null ? _data_subject : '').trim() || null;
146
- const tags = normalizeEventTags(data.tags);
147
- const from = bareSenderAddress(data.from);
148
- // The provider folds a DSN's fields into one sentence; the structured
149
- // ones are read first where a payload carries them.
150
- const bounceText = type === 'bounced' ? readBounceText([
151
- (_data_bounce = data.bounce) == null ? void 0 : _data_bounce.diagnosticCode,
152
- (_data_bounce1 = data.bounce) == null ? void 0 : _data_bounce1.diagnostic_code,
153
- (_data_bounce2 = data.bounce) == null ? void 0 : _data_bounce2.remoteMta,
154
- (_data_bounce3 = data.bounce) == null ? void 0 : _data_bounce3.remote_mta,
155
- (_data_bounce4 = data.bounce) == null ? void 0 : _data_bounce4.message
156
- ].filter((part)=>typeof part === 'string' && part.trim()).join(' ')) : null;
157
- return recipients.map((to)=>{
158
- var _ref, _ref1, _ref2;
159
- var _data_click, _data_bounce, _data_bounce1, _data_failed;
160
- return _extends({
161
- type,
162
- at,
163
- provider: 'resend',
164
- providerMessageId,
165
- to,
166
- subject,
167
- context: tags['context'] || null,
168
- tags,
169
- link: type === 'clicked' ? String((_ref = (_data_click = data.click) == null ? void 0 : _data_click.link) != null ? _ref : '') || null : null,
170
- bounceType: type === 'bounced' ? normalizeBounceType((_data_bounce = data.bounce) == null ? void 0 : _data_bounce.type) : null,
171
- detail: String((_ref1 = (_ref2 = (_data_bounce1 = data.bounce) == null ? void 0 : _data_bounce1.message) != null ? _ref2 : (_data_failed = data.failed) == null ? void 0 : _data_failed.reason) != null ? _ref1 : '').trim() || null
172
- }, from ? {
173
- from
174
- } : {}, (bounceText == null ? void 0 : bounceText.status) ? {
175
- bounceStatus: bounceText.status
176
- } : {}, (bounceText == null ? void 0 : bounceText.remoteMta) ? {
177
- remoteMta: bounceText.remoteMta
178
- } : {});
179
- });
180
- }
181
- /**
182
- * A provider's `last_event` string, mapped onto our lifecycle.
183
- *
184
- * Deliberately the bare state names rather than the `email.*` event names:
185
- * the list endpoint reports `"delivered"`, the webhook reports
186
- * `"email.delivered"`, and they are two different vocabularies for one
187
- * concept. Both are accepted here so a provider that unifies them later
188
- * needs no change.
189
- */ const RESEND_LAST_EVENTS = {
190
- sent: 'sent',
191
- delivered: 'delivered',
192
- delivery_delayed: 'delayed',
193
- opened: 'opened',
194
- clicked: 'clicked',
195
- bounced: 'bounced',
196
- complained: 'complained',
197
- failed: 'failed',
198
- canceled: 'failed',
199
- queued: 'sent',
200
- scheduled: 'sent'
201
- };
202
- /**
203
- * Turns one entry from Resend's `GET /emails` list into zero or more
204
- * snapshots — one per recipient, for the same reason the event adapter fans
205
- * out: the staff view is keyed on a person.
206
- *
207
- * An unrecognised `last_event` falls back to `sent` rather than being
208
- * dropped. The message demonstrably exists and was addressed to somebody, and
209
- * "we sent this and cannot characterise what happened next" is a far more
210
- * useful row than no row — which is the state that sent a staffer to the
211
- * vendor dashboard in the first place.
212
- */ export function normalizeResendSentEmails(raw) {
213
- var _record_id, _record_created_at, _RESEND_LAST_EVENTS_String_trim_toLowerCase, _record_last_event;
214
- const record = raw != null ? raw : {};
215
- const providerMessageId = String((_record_id = record.id) != null ? _record_id : '').trim();
216
- if (!providerMessageId) return [];
217
- const recipients = (Array.isArray(record.to) ? record.to : [
218
- record.to
219
- ]).map((address)=>String(address != null ? address : '').trim().toLowerCase()).filter((address)=>address.includes('@'));
220
- if (!recipients.length) return [];
221
- const parsed = Date.parse(String((_record_created_at = record.created_at) != null ? _record_created_at : ''));
222
- const sentAt = Number.isFinite(parsed) ? parsed : 0;
223
- // A snapshot with no timestamp cannot be ordered, and the log's read drops
224
- // any document missing its sort key — so it is refused rather than written
225
- // somewhere nothing will look for it.
226
- if (!sentAt) return [];
227
- const status = (_RESEND_LAST_EVENTS_String_trim_toLowerCase = RESEND_LAST_EVENTS[String((_record_last_event = record.last_event) != null ? _record_last_event : '').trim().toLowerCase()]) != null ? _RESEND_LAST_EVENTS_String_trim_toLowerCase : 'sent';
228
- return recipients.map((to)=>{
229
- var _record_subject;
230
- return {
231
- provider: 'resend',
232
- providerMessageId,
233
- to,
234
- subject: String((_record_subject = record.subject) != null ? _record_subject : '').trim() || null,
235
- sentAt,
236
- status
237
- };
238
- });
239
- }
240
- /** Resend's list endpoint. Paginates with `after=<id>`; caps at 100. */ export const RESEND_EMAILS_ENDPOINT = 'https://api.resend.com/emails';
241
- /**
242
- * {@link EmailDeliveryHistorySource} for Resend.
243
- *
244
- * Needs a FULL-ACCESS key: a sending-scoped key answers every read on this
245
- * endpoint with `401 restricted_api_key`, which is the correct posture for
246
- * the key that sends mail and the reason this takes its own.
247
- */ export function resendDeliveryHistorySource(apiKey) {
248
- return async ({ cursor, limit } = {})=>{
249
- var _ref;
250
- var _entries_;
251
- const params = new URLSearchParams({
252
- limit: String(Math.min(Math.max(1, limit != null ? limit : 100), 100))
253
- });
254
- if (cursor) params.set('after', cursor);
255
- const response = await fetch(`${RESEND_EMAILS_ENDPOINT}?${params}`, {
256
- headers: {
257
- Authorization: `Bearer ${apiKey}`
258
- }
259
- });
260
- if (!response.ok) {
261
- const detail = await response.text().catch(()=>'');
262
- throw new Error(`email history read failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
263
- }
264
- const body = await response.json();
265
- const entries = Array.isArray(body == null ? void 0 : body.data) ? body.data : [];
266
- const snapshots = entries.flatMap((entry)=>normalizeResendSentEmails(entry));
267
- // The cursor is the LAST RAW entry's id, not the last snapshot's: a page
268
- // whose final entry fanned out to zero snapshots (no recipient, no
269
- // timestamp) would otherwise rewind the cursor to an earlier message and
270
- // loop over the same page forever.
271
- const lastId = String((_ref = (_entries_ = entries[entries.length - 1]) == null ? void 0 : _entries_.id) != null ? _ref : '').trim();
272
- return {
273
- snapshots,
274
- nextCursor: (body == null ? void 0 : body.has_more) && lastId ? lastId : null
275
- };
276
- };
277
- }
278
- function addressList(raw) {
279
- return (Array.isArray(raw) ? raw : raw == null ? [] : [
280
- raw
281
- ]).map((address)=>String(address != null ? address : '').trim()).filter(Boolean);
282
- }
283
- /** Resend's `GET /emails/:id` payload, in our vocabulary. */ export function normalizeResendMessage(raw) {
284
- var _record_id, _record_created_at, _record_from, _record_subject, _RESEND_LAST_EVENTS_String_trim_toLowerCase, _record_last_event;
285
- const record = raw != null ? raw : {};
286
- const providerMessageId = String((_record_id = record.id) != null ? _record_id : '').trim();
287
- if (!providerMessageId) return null;
288
- const parsed = Date.parse(String((_record_created_at = record.created_at) != null ? _record_created_at : ''));
289
- return {
290
- provider: 'resend',
291
- providerMessageId,
292
- to: addressList(record.to),
293
- cc: addressList(record.cc),
294
- bcc: addressList(record.bcc),
295
- from: String((_record_from = record.from) != null ? _record_from : '').trim() || null,
296
- replyTo: addressList(record.reply_to).length ? addressList(record.reply_to) : null,
297
- subject: String((_record_subject = record.subject) != null ? _record_subject : '').trim() || null,
298
- // Empty string is NOT null here, and the difference is the point: a
299
- // message that went out text-only really does have an empty HTML part,
300
- // and a reader has to be able to tell that from "we could not fetch it".
301
- html: typeof record.html === 'string' ? record.html : null,
302
- text: typeof record.text === 'string' ? record.text : null,
303
- sentAt: Number.isFinite(parsed) ? parsed : null,
304
- status: (_RESEND_LAST_EVENTS_String_trim_toLowerCase = RESEND_LAST_EVENTS[String((_record_last_event = record.last_event) != null ? _record_last_event : '').trim().toLowerCase()]) != null ? _RESEND_LAST_EVENTS_String_trim_toLowerCase : null
305
- };
306
- }
307
- /** Resend's single-message endpoint. Needs the same full-access key. */ export function resendDeliveryMessageSource(apiKey) {
308
- return async (providerMessageId)=>{
309
- const response = await fetch(`${RESEND_EMAILS_ENDPOINT}/${encodeURIComponent(providerMessageId)}`, {
310
- headers: {
311
- Authorization: `Bearer ${apiKey}`
312
- }
313
- });
314
- // A message the provider has aged out is a 404, and that is an ANSWER —
315
- // "we know this was sent and the body is gone" — not a failure to report
316
- // as an error the staffer must act on.
317
- if (response.status === 404) return null;
318
- if (!response.ok) {
319
- const detail = await response.text().catch(()=>'');
320
- throw new Error(`message read failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
321
- }
322
- return normalizeResendMessage(await response.json());
323
- };
324
- }
325
96
 
326
97
  //# sourceMappingURL=email-delivery-events.js.map