sently 0.10.0 → 1.0.1

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 (49) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +50 -2
  3. package/README.md +21 -18
  4. package/SECURITY.md +59 -0
  5. package/dist/chunk-z1589fjk.js.map +1 -1
  6. package/dist/core/push-types.d.ts +1 -1
  7. package/dist/transports/mailpit.d.ts +166 -0
  8. package/dist/transports/mailpit.js +3 -0
  9. package/dist/transports/mailpit.js.map +10 -0
  10. package/dist/transports/webpush.d.ts +5 -1
  11. package/dist/transports/webpush.js +2 -2
  12. package/dist/transports/webpush.js.map +3 -3
  13. package/package.json +15 -2
  14. package/site/content/docs/ai/llms-txt.mdx +47 -2
  15. package/site/content/docs/channels/email.mdx +3 -0
  16. package/site/content/docs/channels/index.mdx +3 -1
  17. package/site/content/docs/channels/push.mdx +4 -0
  18. package/site/content/docs/decorators/fallback.mdx +5 -0
  19. package/site/content/docs/decorators/index.mdx +2 -1
  20. package/site/content/docs/decorators/preview.mdx +52 -9
  21. package/site/content/docs/get-started/entrypoints.mdx +5 -3
  22. package/site/content/docs/get-started/index.mdx +8 -2
  23. package/site/content/docs/get-started/introduction.mdx +7 -3
  24. package/site/content/docs/get-started/meta.json +4 -1
  25. package/site/content/docs/get-started/migrate-nodemailer.mdx +132 -5
  26. package/site/content/docs/get-started/non-goals.mdx +57 -0
  27. package/site/content/docs/get-started/stability.mdx +69 -0
  28. package/site/content/docs/get-started/support-matrix.mdx +62 -0
  29. package/site/content/docs/guides/compare.mdx +87 -0
  30. package/site/content/docs/guides/failover.mdx +121 -0
  31. package/site/content/docs/guides/index.mdx +2 -0
  32. package/site/content/docs/guides/meta.json +2 -0
  33. package/site/content/docs/guides/security.mdx +38 -8
  34. package/site/content/docs/index.mdx +78 -4
  35. package/site/content/docs/meta.json +0 -1
  36. package/site/content/docs/reference/exports.mdx +5 -2
  37. package/site/content/docs/reference/push-options.mdx +14 -0
  38. package/site/content/docs/transports/fcm.mdx +8 -4
  39. package/site/content/docs/transports/index.mdx +9 -3
  40. package/site/content/docs/transports/mailpit.mdx +123 -0
  41. package/site/content/docs/transports/meta.json +1 -0
  42. package/site/content/docs/transports/smtp.mdx +61 -22
  43. package/site/content/docs/transports/webpush.mdx +9 -2
  44. package/site/content/docs/quick-start/email.mdx +0 -40
  45. package/site/content/docs/quick-start/index.mdx +0 -15
  46. package/site/content/docs/quick-start/meta.json +0 -5
  47. package/site/content/docs/quick-start/push.mdx +0 -53
  48. package/site/content/docs/quick-start/sms.mdx +0 -36
  49. package/site/content/docs/quick-start/whatsapp.mdx +0 -38
package/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # sently — agent contract
2
2
 
3
- sently is a runtime-agnostic TypeScript messaging library (email, SMS, WhatsApp, Web Push, FCM) for Node.js, Bun, Deno, and Cloudflare Workers. ESM-only, zero runtime dependencies.
3
+ sently is a runtime-agnostic TypeScript channel-delivery library (email, SMS, WhatsApp, Web Push, FCM) for Node.js, Bun, Deno, and Cloudflare Workers. ESM-only, zero runtime dependencies. One sender shape / error model / retry path as channels grow — not a hosted orchestration platform.
4
4
 
5
5
  ## Sently-first provider model
6
6
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,54 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.0.1] — 2026-08-01
6
+
7
+ ### 🐛 Fixed
8
+
9
+ - **Web Push `subject` validation** — `WebPushTransport` rejects invalid
10
+ VAPID `subject` values at construction (`mailto:` / `https:` only) instead
11
+ of failing later with an opaque push-service 403
12
+
13
+ ## [1.0.0] — 2026-08-01
14
+
15
+ First stable release. Channel contracts and Supported transports are semver-frozen;
16
+ npm publishes ship with provenance attestations and Trusted Publishing (OIDC).
17
+ sently is the channel-delivery layer — preference centers, digests, workflows, and
18
+ in-app inboxes sit on top (custom or Novu / Knock / Courier), not instead of it.
19
+
20
+ ### ✨ Added
21
+
22
+ - **Stability policy** — what is frozen at 1.x vs allowed without a major
23
+ (`PushOptions` union, `providerIndex`, email-only preview / idempotency)
24
+ - **Support matrix** — Supported vs Available transports and runtimes
25
+ - **Non-goals** — library-not-platform boundaries (no preference centers /
26
+ digests / workflow builders inside sently)
27
+ - **Security policy** — root `SECURITY.md` (private reporting, response targets,
28
+ supported versions for patches, Trusted Publisher setup notes)
29
+ - **Compare guide** — positioning vs vendor SDK piles, Nodemailer, and
30
+ Novu / Knock / Courier
31
+ - **Failover guide** — cross-channel retry-then-fallback recipes
32
+ - **Mailpit transport** — `sently/transports/mailpit` for local email capture
33
+ (SMTP defaults `localhost:1025`, REST helpers for list / get / delete)
34
+
35
+ ### ♻️ Changed
36
+
37
+ - **Messaging positioning** — homepage, README, `llms.txt`, and package
38
+ description/keywords lead with multi-channel scope creep (one sender / error /
39
+ retry model); bundle size as supporting evidence for edge / Workers; FCM
40
+ documented as current HTTP API support, not a feature boast
41
+ - **Handbook landing** — install → send loop, channel map, where-next table,
42
+ and library-vs-platform troubleshooting
43
+ - **Docs IA** — remove duplicate Quick start section; Channels is the first-send
44
+ path; cross-links for stability, support matrix, failover, and security
45
+ - **Migrate from Nodemailer** — diff-style transporter and `sendMail` → `send`
46
+ swaps, field map, SMTP → HTTP follow-on, fuller options tables
47
+ - **npm publish** — OIDC Trusted Publishing first, optional `NPM_TOKEN`
48
+ fallback; continues to use `npm publish --provenance` from CI (npm CLI ≥11.5.1
49
+ check in the publish workflow)
50
+ - **Semver** — public channel factories, contracts, hooks, `SentlyError` codes,
51
+ `ChannelSendResult`, and published subpaths follow 1.x stability
52
+
5
53
  ## [0.10.0] — 2026-08-01
6
54
 
7
55
  ### ✨ Added
@@ -14,8 +62,8 @@
14
62
  WhatsApp / push senders wire `onRetry` / `onFallback` like email
15
63
  - **Unifonic SMS** — `sently/transports/unifonic` (`AppSid` + el.cloud REST
16
64
  send); webhook parser at `sently/webhooks/unifonic`
17
- - **FCM push** — `sently/transports/fcm` (HTTP v1 + service-account JWT, zero
18
- Google SDK)
65
+ - **FCM push** — `sently/transports/fcm` (current FCM HTTP API + service-account
66
+ JWT, no Google SDK)
19
67
  - **Non-email webhooks** — `DeliveryEvent` plus parsers for Twilio SMS and
20
68
  WhatsApp Cloud (`sently/webhooks/twilio-sms`, `whatsapp-cloud`);
21
69
  `toDeliveryEvent` maps `EmailEvent` into the shared shape; Twilio and
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
  </p>
24
24
 
25
25
  <p align="center">
26
- <em>Stop wiring vendor SDKs into every channel. One sender shape for email, SMS, WhatsApp, and push swap the transport, keep your call sites. Node, Bun, Deno, Workers.</em>
26
+ <em>Start with email, add SMS and push later — one sender shape, one error model, one retry path. Swap the transport; keep your call sites. Node, Bun, Deno, Workers.</em>
27
27
  </p>
28
28
 
29
29
  <p align="center">
@@ -33,11 +33,6 @@
33
33
  <a href="https://www.npmjs.com/package/sently"><code>sently</code></a>
34
34
  </p>
35
35
 
36
- > [!WARNING]
37
- > **Early development (`v0.x`) — API may change.**
38
- >
39
- > Pin an exact version for production until v1.0.0. See [CHANGELOG](CHANGELOG.md).
40
-
41
36
  ## Install
42
37
 
43
38
  ```bash
@@ -78,18 +73,24 @@ Full walkthrough: [Get started](https://sently.omqkhafi.dev/docs/get-started).
78
73
 
79
74
  ## Why sently?
80
75
 
81
- Nodemailer is Node.js–only and ships the full mail stack on every import (~59 KB gzip for [v9.0.3](https://bundlephobia.com/package/nodemailer@9.0.3)). sently is tree-shakeable, multi-runtime, and multi-channel.
76
+ Teams usually start on one channel (often email), then add SMS and push. Each vendor SDK brings its own auth, retries, and error shapes. sently keeps **one sender shape**, **one `SentlyError` model**, and **one retry/fallback path** as you add channels.
77
+
78
+ **Library, not platform.** sently is the channel-delivery layer. Preference centers, digests, workflow builders, and in-app inboxes — custom logic or tools like [Novu](https://novu.co), [Knock](https://knock.app), or [Courier](https://www.courier.com) — sit **on top of** sently, not instead of it.
79
+
80
+ Compared with email-only stacks (e.g. Nodemailer) and a pile of vendor clients:
81
+
82
+ | | Typical stack | sently |
83
+ | ----------------- | ------------------------------------- | ------------------------------------------- |
84
+ | As channels grow | New SDK per channel | Same sender factories |
85
+ | Failures | Per-vendor exceptions | Stable `SentlyError` codes |
86
+ | Reliability | Ad-hoc per client | `RetryTransport` + `FallbackTransport` |
87
+ | Providers | Vendor clients in app code | Pluggable transports |
88
+ | Runtimes | Often Node only | Node, Bun, Deno, CF Workers |
89
+ | Edge / Workers | Fat SDKs hurt cold start | Tree-shakeable (~6.3 KB HTTP · ~14.9 KB SMTP) |
90
+ | Module format | Often CJS | ESM only |
91
+ | Dependencies | Varies | 0 runtime deps |
82
92
 
83
- | | Nodemailer | sently |
84
- | ----------------- | ----------------------- | ------------------------------------------- |
85
- | Bundle size | ~59 KB gzip always | ~6.3 KB HTTP · ~14.9 KB SMTP |
86
- | Runtimes | Node.js only | Node, Bun, Deno, CF Workers |
87
- | Module format | CommonJS | ESM only |
88
- | Dependencies | 0 | 0 |
89
- | Channels | Email | Email · SMS · WhatsApp · Push |
90
- | HTTP transports | via plugins | built-in subpaths |
91
- | Provider failover | — | `FallbackTransport` + weighted routing |
92
- | TypeScript | `@types/nodemailer` | built-in |
93
+ More detail: [Compare](https://sently.omqkhafi.dev/docs/guides/compare) · [Stability](https://sently.omqkhafi.dev/docs/get-started/stability) · [Support matrix](https://sently.omqkhafi.dev/docs/get-started/support-matrix).
93
94
 
94
95
  ## Entrypoints
95
96
 
@@ -113,10 +114,12 @@ Nodemailer is Node.js–only and ships the full mail stack on every import (~59
113
114
  | Get started | [/docs/get-started](https://sently.omqkhafi.dev/docs/get-started) |
114
115
  | Channels | [/docs/channels](https://sently.omqkhafi.dev/docs/channels) |
115
116
  | Transports | [/docs/transports](https://sently.omqkhafi.dev/docs/transports) |
117
+ | Compare | [/docs/guides/compare](https://sently.omqkhafi.dev/docs/guides/compare) |
116
118
  | Agents index | [/llms.txt](https://sently.omqkhafi.dev/llms.txt) |
117
119
  | Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
120
+ | Security | [`SECURITY.md`](SECURITY.md) |
118
121
  | Agents | [`AGENTS.md`](AGENTS.md) |
119
122
 
120
123
  Local docs: `bun run site:dev`. Verify: `bun run verify`.
121
124
 
122
- Pre-1.0. Published on [npm](https://www.npmjs.com/package/sently) and [JSR](https://jsr.io/@alialnaghmoush/sently). MIT.
125
+ **1.0.0** — semver-stable channel contracts. Published on [npm](https://www.npmjs.com/package/sently) (provenance + Trusted Publishing) and [JSR](https://jsr.io/@alialnaghmoush/sently). MIT.
package/SECURITY.md ADDED
@@ -0,0 +1,59 @@
1
+ # Security Policy
2
+
3
+ sently is a messaging library that holds provider API keys and tokens in the installing application at runtime. Treat compromised publishes and credential leaks as high severity.
4
+
5
+ ## Reporting a vulnerability
6
+
7
+ **Do not open a public GitHub issue for security reports.**
8
+
9
+ Use [GitHub Security Advisories](https://github.com/alialnaghmoush/sently/security/advisories/new) for this repository so the report stays private until a fix is ready.
10
+
11
+ Include:
12
+
13
+ - Affected package version(s)
14
+ - Impact (credential exfiltration, unauthorized send, SSRF, signature bypass, …)
15
+ - Reproduction steps or a minimal proof of concept when possible
16
+
17
+ ## Response targets
18
+
19
+ | Stage | Target |
20
+ | --- | --- |
21
+ | Initial acknowledgement | Within **72 hours** |
22
+ | Triage (severity / severity) | Within **7 days** of acknowledgement |
23
+ | Fix or mitigation guidance | As soon as practical; critical issues are prioritized over feature work |
24
+
25
+ These are goals, not SLAs. Complex issues may take longer; we will keep the reporter updated.
26
+
27
+ ## Supported versions (security patches)
28
+
29
+ After **1.0.0**, security fixes land on the current **1.x** line (latest minor/patch). See the [stability policy](https://sently.omqkhafi.dev/docs/get-started/stability) for what is semver-frozen.
30
+
31
+ | Version | Security patches |
32
+ | --- | --- |
33
+ | `1.x` | Yes |
34
+ | `0.x` | Best effort only (pre-1.0; prefer upgrading to 1.x) |
35
+
36
+ ## Release integrity
37
+
38
+ npm publishes from CI (`.github/workflows/publish.yml`) use:
39
+
40
+ - **`npm publish --provenance`** — attestations linking the tarball to this public repository and the build
41
+ - **Trusted Publishing (OIDC)** — preferred path so a long-lived `NPM_TOKEN` is not required
42
+
43
+ ### One-time setup (package owner)
44
+
45
+ On [npmjs.com/package/sently](https://www.npmjs.com/package/sently) → **Settings** → **Trusted Publisher**:
46
+
47
+ 1. Bind **GitHub Actions**
48
+ 2. Repository: `alialnaghmoush/sently`
49
+ 3. Workflow: `publish.yml` (`.github/workflows/publish.yml`)
50
+ 4. Environment: `production` (must match the workflow `environment`)
51
+ 5. Confirm the npm package repository URL matches `package.json` → `repository`
52
+
53
+ Until one OIDC publish has succeeded, CI may fall back to a repository secret `NPM_TOKEN` / `NODE_AUTH_TOKEN` if present. Remove that secret after OIDC is verified.
54
+
55
+ ## Related hardening in the library
56
+
57
+ - Web Push endpoint allowlisting / SSRF checks
58
+ - Redaction of push endpoints and FCM tokens in hooks
59
+ - Webhook signature verification helpers for Supported providers
@@ -2,7 +2,7 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/core/push-types.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * @module\n * Shared push channel contracts — sibling types to email MailOptions/Transport/Hooks.\n *\n * Sently-first: this contract is the stable push surface. Provider transports\n * implement {@link PushTransport}. Web Push uses a browser subscription; FCM\n * uses a device registration token.\n */\nimport type { VerifyResult } from \"./types.js\";\n\n/** Browser push subscription as returned by the Push API. */\nexport interface PushSubscription {\n /** Push service endpoint URL. */\n endpoint: string;\n /** Client encryption keys for RFC 8291 payload encryption. */\n keys: {\n /** Base64url-encoded P-256 ECDH public key. */\n p256dh: string;\n /** Base64url-encoded authentication secret. */\n auth: string;\n };\n}\n\n/** Shared notification fields for Web Push and FCM. */\ninterface PushNotificationFields {\n /** Notification title. */\n title: string;\n /** Notification body text. */\n body: string;\n /** Arbitrary application data attached to the notification. */\n data?: Record<string, unknown>;\n /** Notification icon URL (Web Push / some FCM platforms). */\n icon?: string;\n /** Time-to-live in seconds for the push message. */\n ttl?: number;\n /** Client-supplied message identifier when known. */\n messageId?: string;\n}\n\n/** Options for sending a Web Push notification (VAPID / browser subscription). */\nexport interface WebPushOptions extends PushNotificationFields {\n /** Target browser push subscription. */\n subscription: PushSubscription;\n}\n\n/**\n * Options for sending via Firebase Cloud Messaging HTTP v1 (device token).\n * Data values are stringified by the FCM transport when needed.\n */\nexport interface FcmPushOptions extends PushNotificationFields {\n /** FCM registration token for the target device. */\n token: string;\n /** Optional image URL for the notification. */\n image?: string;\n}\n\n/** Options for sending a push notification (Web Push or FCM). */\nexport type PushOptions = WebPushOptions | FcmPushOptions;\n\n/** True when options target FCM (device token) rather than Web Push. */\nexport function isFcmPushOptions(options: PushOptions): options is FcmPushOptions {\n return \"token\" in options && typeof (options as FcmPushOptions).token === \"string\";\n}\n\n/** True when options target Web Push (browser subscription). */\nexport function isWebPushOptions(options: PushOptions): options is WebPushOptions {\n return (\n \"subscription\" in options &&\n typeof (options as WebPushOptions).subscription?.endpoint === \"string\"\n );\n}\n\n/** Result returned after a push notification is accepted by the push service. */\nexport interface PushSendResult {\n /** Client-generated or assigned message identifier. */\n messageId: string;\n /** Delivery status string (e.g. `\"accepted\"`). */\n status: string;\n /** Human-readable or raw status text (often the HTTP status). */\n response: string;\n /** Transport or provider identifier (e.g. `\"webpush\"`, `\"fcm\"`). */\n provider?: string;\n /**\n * Zero-based index of the transport that handled the send in a fallback chain.\n * Set by {@link FallbackTransport}.\n */\n providerIndex?: number;\n}\n\n/** Pluggable push delivery backend (Web Push, FCM, …). */\nexport interface PushTransport {\n /**\n * Stable provider identifier for observability (e.g. `\"webpush\"`, `\"fcm\"`).\n * Prefer this over constructor-name inference in hooks.\n */\n readonly provider?: string;\n /** Send a push notification through this transport. */\n send(options: PushOptions): Promise<PushSendResult>;\n /** Test connectivity and credentials without sending. */\n verify?(): Promise<VerifyResult>;\n /** Release resources held by the transport. */\n close?(): Promise<void>;\n}\n\n/**\n * Context passed to push lifecycle hooks.\n * Uses a redacted subscription endpoint or device-token fingerprint (no natural `to`).\n */\nexport interface PushHookContext {\n /** Message-ID when known (from options or send result). */\n messageId?: string;\n /**\n * Redacted push target for observability: `origin/#<short-hash>` (Web Push)\n * or `fcm:#<short-hash>` (FCM device token).\n * Never the full endpoint URL or raw device token.\n */\n endpoint: string;\n /** Transport or provider identifier (e.g. `\"webpush\"`, `\"fcm\"`). */\n provider: string;\n}\n\n/** Optional lifecycle hooks for metrics, tracing, and observability on every push send. */\nexport interface PushHooks {\n /** Fired before the transport sends the notification. */\n onSend?: (ctx: PushHookContext) => void | Promise<void>;\n /**\n * Fired after a successful send.\n * @param durationMs — elapsed milliseconds from send start to success (optional third argument).\n */\n onSuccess?: (\n ctx: PushHookContext,\n result: PushSendResult,\n durationMs?: number,\n ) => void | Promise<void>;\n /**\n * Fired when a send throws (error is re-thrown after the hook runs).\n * @param durationMs — elapsed milliseconds from send start to failure (optional third argument).\n */\n onError?: (ctx: PushHookContext, error: unknown, durationMs?: number) => void | Promise<void>;\n /** Fired before each retry attempt (requires {@link RetryTransport}). */\n onRetry?: (ctx: PushHookContext, attempt: number, error: unknown) => void | Promise<void>;\n /**\n * Fired when {@link FallbackTransport} fails over to the next provider.\n * Requires a fallback (or weighted fallback) transport in the push stack.\n */\n onFallback?: (\n ctx: PushHookContext,\n failedProvider: string,\n nextProvider: string,\n error: unknown,\n ) => void | Promise<void>;\n}\n\n/**\n * A push plugin transforms {@link PushOptions} before the transport sends.\n * Plugins run sequentially. Each receives the output of the previous.\n * Return a new options object — do not mutate the input.\n */\nexport type PushPlugin =\n | ((options: PushOptions) => PushOptions)\n | ((options: PushOptions) => Promise<PushOptions>);\n"
5
+ "/**\n * @module\n * Shared push channel contracts — sibling types to email MailOptions/Transport/Hooks.\n *\n * Sently-first: this contract is the stable push surface. Provider transports\n * implement {@link PushTransport}. Web Push uses a browser subscription; FCM\n * uses a device registration token.\n */\nimport type { VerifyResult } from \"./types.js\";\n\n/** Browser push subscription as returned by the Push API. */\nexport interface PushSubscription {\n /** Push service endpoint URL. */\n endpoint: string;\n /** Client encryption keys for RFC 8291 payload encryption. */\n keys: {\n /** Base64url-encoded P-256 ECDH public key. */\n p256dh: string;\n /** Base64url-encoded authentication secret. */\n auth: string;\n };\n}\n\n/** Shared notification fields for Web Push and FCM. */\ninterface PushNotificationFields {\n /** Notification title. */\n title: string;\n /** Notification body text. */\n body: string;\n /** Arbitrary application data attached to the notification. */\n data?: Record<string, unknown>;\n /** Notification icon URL (Web Push / some FCM platforms). */\n icon?: string;\n /** Time-to-live in seconds for the push message. */\n ttl?: number;\n /** Client-supplied message identifier when known. */\n messageId?: string;\n}\n\n/** Options for sending a Web Push notification (VAPID / browser subscription). */\nexport interface WebPushOptions extends PushNotificationFields {\n /** Target browser push subscription. */\n subscription: PushSubscription;\n}\n\n/**\n * Options for sending via Firebase Cloud Messaging (device token).\n * Data values are stringified by the FCM transport when needed.\n */\nexport interface FcmPushOptions extends PushNotificationFields {\n /** FCM registration token for the target device. */\n token: string;\n /** Optional image URL for the notification. */\n image?: string;\n}\n\n/** Options for sending a push notification (Web Push or FCM). */\nexport type PushOptions = WebPushOptions | FcmPushOptions;\n\n/** True when options target FCM (device token) rather than Web Push. */\nexport function isFcmPushOptions(options: PushOptions): options is FcmPushOptions {\n return \"token\" in options && typeof (options as FcmPushOptions).token === \"string\";\n}\n\n/** True when options target Web Push (browser subscription). */\nexport function isWebPushOptions(options: PushOptions): options is WebPushOptions {\n return (\n \"subscription\" in options &&\n typeof (options as WebPushOptions).subscription?.endpoint === \"string\"\n );\n}\n\n/** Result returned after a push notification is accepted by the push service. */\nexport interface PushSendResult {\n /** Client-generated or assigned message identifier. */\n messageId: string;\n /** Delivery status string (e.g. `\"accepted\"`). */\n status: string;\n /** Human-readable or raw status text (often the HTTP status). */\n response: string;\n /** Transport or provider identifier (e.g. `\"webpush\"`, `\"fcm\"`). */\n provider?: string;\n /**\n * Zero-based index of the transport that handled the send in a fallback chain.\n * Set by {@link FallbackTransport}.\n */\n providerIndex?: number;\n}\n\n/** Pluggable push delivery backend (Web Push, FCM, …). */\nexport interface PushTransport {\n /**\n * Stable provider identifier for observability (e.g. `\"webpush\"`, `\"fcm\"`).\n * Prefer this over constructor-name inference in hooks.\n */\n readonly provider?: string;\n /** Send a push notification through this transport. */\n send(options: PushOptions): Promise<PushSendResult>;\n /** Test connectivity and credentials without sending. */\n verify?(): Promise<VerifyResult>;\n /** Release resources held by the transport. */\n close?(): Promise<void>;\n}\n\n/**\n * Context passed to push lifecycle hooks.\n * Uses a redacted subscription endpoint or device-token fingerprint (no natural `to`).\n */\nexport interface PushHookContext {\n /** Message-ID when known (from options or send result). */\n messageId?: string;\n /**\n * Redacted push target for observability: `origin/#<short-hash>` (Web Push)\n * or `fcm:#<short-hash>` (FCM device token).\n * Never the full endpoint URL or raw device token.\n */\n endpoint: string;\n /** Transport or provider identifier (e.g. `\"webpush\"`, `\"fcm\"`). */\n provider: string;\n}\n\n/** Optional lifecycle hooks for metrics, tracing, and observability on every push send. */\nexport interface PushHooks {\n /** Fired before the transport sends the notification. */\n onSend?: (ctx: PushHookContext) => void | Promise<void>;\n /**\n * Fired after a successful send.\n * @param durationMs — elapsed milliseconds from send start to success (optional third argument).\n */\n onSuccess?: (\n ctx: PushHookContext,\n result: PushSendResult,\n durationMs?: number,\n ) => void | Promise<void>;\n /**\n * Fired when a send throws (error is re-thrown after the hook runs).\n * @param durationMs — elapsed milliseconds from send start to failure (optional third argument).\n */\n onError?: (ctx: PushHookContext, error: unknown, durationMs?: number) => void | Promise<void>;\n /** Fired before each retry attempt (requires {@link RetryTransport}). */\n onRetry?: (ctx: PushHookContext, attempt: number, error: unknown) => void | Promise<void>;\n /**\n * Fired when {@link FallbackTransport} fails over to the next provider.\n * Requires a fallback (or weighted fallback) transport in the push stack.\n */\n onFallback?: (\n ctx: PushHookContext,\n failedProvider: string,\n nextProvider: string,\n error: unknown,\n ) => void | Promise<void>;\n}\n\n/**\n * A push plugin transforms {@link PushOptions} before the transport sends.\n * Plugins run sequentially. Each receives the output of the previous.\n * Return a new options object — do not mutate the input.\n */\nexport type PushPlugin =\n | ((options: PushOptions) => PushOptions)\n | ((options: PushOptions) => Promise<PushOptions>);\n"
6
6
  ],
7
7
  "mappings": "AA4DO,SAAS,CAAgB,CAAC,EAAiD,CAChF,MAAO,UAAW,GAAW,OAAQ,EAA2B,QAAU,SAIrE,SAAS,CAAgB,CAAC,EAAiD,CAChF,MACE,iBAAkB,GAClB,OAAQ,EAA2B,cAAc,WAAa",
8
8
  "debugId": "F4A2DC6CC43BC46D64756E2164756E21",
@@ -40,7 +40,7 @@ export interface WebPushOptions extends PushNotificationFields {
40
40
  subscription: PushSubscription;
41
41
  }
42
42
  /**
43
- * Options for sending via Firebase Cloud Messaging HTTP v1 (device token).
43
+ * Options for sending via Firebase Cloud Messaging (device token).
44
44
  * Data values are stringified by the FCM transport when needed.
45
45
  */
46
46
  export interface FcmPushOptions extends PushNotificationFields {
@@ -0,0 +1,166 @@
1
+ import { SentlyError } from "../core/errors.js";
2
+ import type { MailOptions, SendResult, SMTPAuth, SocketAdapter, TLSOptions, Transport, VerifyResult } from "../core/types.js";
3
+ /** Default SMTP host for a local Mailpit instance. */
4
+ export declare const MAILPIT_DEFAULT_HOST = "localhost";
5
+ /** Default SMTP port for a local Mailpit instance. */
6
+ export declare const MAILPIT_DEFAULT_PORT = 1025;
7
+ /** Default web UI / REST API base URL for a local Mailpit instance. */
8
+ export declare const MAILPIT_DEFAULT_API_URL = "http://localhost:8025";
9
+ /** Mailpit development transport configuration. */
10
+ export interface MailpitConfig {
11
+ /** SMTP hostname. Default: `"localhost"`. */
12
+ host?: string;
13
+ /** SMTP port. Default: `1025`. */
14
+ port?: number;
15
+ /** Use implicit TLS on connect. Default: `false` (Mailpit is plain SMTP). */
16
+ secure?: boolean;
17
+ /**
18
+ * Refuse AUTH over a non-TLS connection.
19
+ * Default: `false` so optional local SMTP auth works without TLS.
20
+ */
21
+ requireTLS?: boolean;
22
+ /** Optional SMTP authentication (Mailpit can accept any credentials). */
23
+ auth?: SMTPAuth;
24
+ /** TLS options when STARTTLS or implicit TLS is enabled. */
25
+ tls?: TLSOptions;
26
+ /** Socket connect timeout in milliseconds. */
27
+ connectionTimeout?: number;
28
+ /** Runtime socket adapter. Auto-detected on first send when omitted. */
29
+ adapter?: SocketAdapter;
30
+ /**
31
+ * Mailpit web UI / REST API base URL (no trailing slash).
32
+ * Default: `"http://localhost:8025"`.
33
+ */
34
+ apiUrl?: string;
35
+ /** Basic auth for the Mailpit UI/API when the instance requires it. */
36
+ apiAuth?: {
37
+ user: string;
38
+ pass: string;
39
+ };
40
+ }
41
+ /** Address object returned by the Mailpit REST API. */
42
+ export interface MailpitAddress {
43
+ /** Display name, may be empty. */
44
+ Name: string;
45
+ /** Email address. */
46
+ Address: string;
47
+ }
48
+ /** Summary row from `GET /api/v1/messages`. */
49
+ export interface MailpitMessageSummary {
50
+ /** Mailpit message id. */
51
+ ID: string;
52
+ /** MIME Message-ID. */
53
+ MessageID: string;
54
+ /** Sender. */
55
+ From: MailpitAddress;
56
+ /** Recipients. */
57
+ To: MailpitAddress[];
58
+ /** Subject line. */
59
+ Subject: string;
60
+ /** ISO created timestamp. */
61
+ Created: string;
62
+ /** Attachment count. */
63
+ Attachments: number;
64
+ /** Whether the message has been read in the UI. */
65
+ Read: boolean;
66
+ /** Short plain-text snippet. */
67
+ Snippet: string;
68
+ }
69
+ /** Response from `GET /api/v1/messages`. */
70
+ export interface MailpitMessageList {
71
+ /** Total messages stored. */
72
+ total: number;
73
+ /** Unread message count. */
74
+ unread: number;
75
+ /** Messages returned in this page. */
76
+ count: number;
77
+ /** Message summaries (newest first). */
78
+ messages: MailpitMessageSummary[];
79
+ }
80
+ /** Full message from `GET /api/v1/message/{id}`. */
81
+ export interface MailpitMessage {
82
+ /** Mailpit message id. */
83
+ ID: string;
84
+ /** MIME Message-ID. */
85
+ MessageID: string;
86
+ /** Sender. */
87
+ From: MailpitAddress;
88
+ /** Recipients. */
89
+ To: MailpitAddress[];
90
+ /** CC recipients. */
91
+ Cc?: MailpitAddress[];
92
+ /** BCC recipients. */
93
+ Bcc?: MailpitAddress[];
94
+ /** Subject line. */
95
+ Subject: string;
96
+ /** Plain-text body. */
97
+ Text: string;
98
+ /** HTML body. */
99
+ HTML: string;
100
+ /** ISO created timestamp. */
101
+ Date: string;
102
+ /** Attachment count. */
103
+ Attachments: number;
104
+ }
105
+ /** Options for {@link MailpitTransport.messages}. */
106
+ export interface MailpitMessagesOptions {
107
+ /** Max messages to return. */
108
+ limit?: number;
109
+ /** Pagination offset. */
110
+ start?: number;
111
+ }
112
+ /** Error thrown when the Mailpit REST API returns a non-success response. */
113
+ export declare class MailpitError extends SentlyError {
114
+ readonly statusCode: number;
115
+ readonly apiError: unknown;
116
+ /** Creates a Mailpit API error with status code (`0` = network/connect failure). */
117
+ constructor(message: string, statusCode: number, apiError: unknown);
118
+ }
119
+ /**
120
+ * Development transport for [Mailpit](https://github.com/axllent/mailpit).
121
+ *
122
+ * Sends via SMTP (defaults: `localhost:1025`) and exposes REST helpers for
123
+ * listing, reading, and deleting captured messages.
124
+ */
125
+ export declare class MailpitTransport implements Transport {
126
+ readonly provider = "mailpit";
127
+ private readonly host;
128
+ private readonly port;
129
+ private readonly secure;
130
+ private readonly requireTLS;
131
+ private readonly auth?;
132
+ private readonly tls?;
133
+ private readonly connectionTimeout?;
134
+ private readonly adapter?;
135
+ private readonly apiUrl;
136
+ private readonly apiAuth?;
137
+ private smtp;
138
+ /** Creates a Mailpit transport with local-dev defaults. */
139
+ constructor(config?: MailpitConfig);
140
+ /** Web UI base URL (same origin as the REST API). */
141
+ get webUrl(): string;
142
+ /** Sends an email to Mailpit over SMTP. */
143
+ send(options: MailOptions): Promise<SendResult>;
144
+ /** Verifies SMTP connectivity to Mailpit. */
145
+ verify(): Promise<VerifyResult>;
146
+ /** Closes the underlying SMTP adapter if connected. */
147
+ close(): Promise<void>;
148
+ /**
149
+ * Lists captured messages via `GET /api/v1/messages`.
150
+ * Newest messages appear first.
151
+ */
152
+ messages(options?: MailpitMessagesOptions): Promise<MailpitMessageList>;
153
+ /** Fetches a full message via `GET /api/v1/message/{id}`. */
154
+ getMessage(id: string): Promise<MailpitMessage>;
155
+ /**
156
+ * Deletes messages by id via `DELETE /api/v1/messages`.
157
+ * Pass an empty array (or call {@link deleteAll}) to clear the inbox.
158
+ */
159
+ deleteMessages(ids: string[]): Promise<void>;
160
+ /** Deletes every captured message. */
161
+ deleteAll(): Promise<void>;
162
+ private getSmtp;
163
+ private apiHeaders;
164
+ private apiGet;
165
+ private apiDelete;
166
+ }
@@ -0,0 +1,3 @@
1
+ import{n as X}from"../chunk-a057jzsy.js";import"../chunk-f5x8vesr.js";import"../chunk-yg3kj723.js";import"../chunk-w4c9kj70.js";import"../chunk-1ke3nmyg.js";import{M as N}from"../chunk-d5aw5vag.js";import"../chunk-8kpgbrba.js";import{da as Q}from"../chunk-hnzmn4s4.js";import{ja as V,ka as W}from"../chunk-ttbwyxmh.js";import"../chunk-th4cwrpb.js";var Y="localhost",Z=1025,$="http://localhost:8025";class K extends V{statusCode;apiError;constructor(w,z,G){super(w,z===0?"CONNECTION_FAILED":W(z),{...z>0?{statusCode:z}:{},provider:"mailpit",cause:G});this.statusCode=z;this.apiError=G;this.name="MailpitError"}}class k{provider="mailpit";host;port;secure;requireTLS;auth;tls;connectionTimeout;adapter;apiUrl;apiAuth;smtp=null;constructor(w={}){if(this.host=w.host??Y,this.port=w.port??Z,this.secure=w.secure??!1,this.requireTLS=w.requireTLS??!1,w.auth!==void 0)this.auth=w.auth;if(w.tls!==void 0)this.tls=w.tls;if(w.connectionTimeout!==void 0)this.connectionTimeout=w.connectionTimeout;if(w.adapter!==void 0)this.adapter=w.adapter;if(this.apiUrl=(w.apiUrl??$).replace(/\/$/,""),w.apiAuth!==void 0)this.apiAuth=w.apiAuth}get webUrl(){return this.apiUrl}async send(w){return(await this.getSmtp()).send(w)}async verify(){return{...await(await this.getSmtp()).verify(),provider:"mailpit"}}async close(){if(this.smtp)await this.smtp.close()}async messages(w={}){let z=new URLSearchParams;if(w.limit!==void 0)z.set("limit",String(w.limit));if(w.start!==void 0)z.set("start",String(w.start));let G=z.toString(),J=G?`/api/v1/messages?${G}`:"/api/v1/messages";return this.apiGet(J)}async getMessage(w){if(!w)throw new K("Message id is required",400,{code:"INVALID_CONFIG"});return this.apiGet(`/api/v1/message/${encodeURIComponent(w)}`)}async deleteMessages(w){await this.apiDelete("/api/v1/messages",{IDs:w})}async deleteAll(){await this.deleteMessages([])}async getSmtp(){if(!this.smtp){let w=this.adapter??await N({secure:this.secure,...this.connectionTimeout!==void 0?{connectionTimeout:this.connectionTimeout}:{},...this.tls!==void 0?{tls:this.tls}:{}});this.smtp=new X({host:this.host,port:this.port,secure:this.secure,requireTLS:this.requireTLS,adapter:w,...this.auth!==void 0?{auth:this.auth}:{},...this.tls!==void 0?{tls:this.tls}:{},...this.connectionTimeout!==void 0?{connectionTimeout:this.connectionTimeout}:{}})}return this.smtp}apiHeaders(){let w={Accept:"application/json","Content-Type":"application/json"};if(this.apiAuth){let z=Q(`${this.apiAuth.user}:${this.apiAuth.pass}`).replace(/\r\n/g,"");w.Authorization=`Basic ${z}`}return w}async apiGet(w){let z;try{z=await fetch(`${this.apiUrl}${w}`,{method:"GET",headers:this.apiHeaders()})}catch(G){throw new K(G instanceof Error?G.message:"Mailpit API request failed",0,G)}if(!z.ok){let G=await z.text().catch(()=>"");throw new K(G||`Mailpit API error (${z.status})`,z.status,G)}return await z.json()}async apiDelete(w,z){let G;try{G=await fetch(`${this.apiUrl}${w}`,{method:"DELETE",headers:this.apiHeaders(),body:JSON.stringify(z)})}catch(J){throw new K(J instanceof Error?J.message:"Mailpit API request failed",0,J)}if(!G.ok){let J=await G.text().catch(()=>"");throw new K(J||`Mailpit API error (${G.status})`,G.status,J)}}}export{k as MailpitTransport,K as MailpitError,Z as MAILPIT_DEFAULT_PORT,Y as MAILPIT_DEFAULT_HOST,$ as MAILPIT_DEFAULT_API_URL};
2
+
3
+ //# debugId=A41842358B8E380464756E2164756E21
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/transports/mailpit.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * @module\n * Mailpit development transport — SMTP to a local Mailpit catcher with\n * REST helpers for inspecting and clearing captured messages.\n *\n * Defaults match a stock Mailpit install: SMTP `localhost:1025`,\n * UI/API `http://localhost:8025`.\n *\n * @example\n * ```ts\n * import { createMailer } from \"sently/mailer\";\n * import { MailpitTransport } from \"sently/transports/mailpit\";\n *\n * const mailpit = new MailpitTransport();\n * const mailer = await createMailer({ transport: mailpit });\n *\n * await mailer.send({\n * from: \"dev@example.com\",\n * to: \"you@example.com\",\n * subject: \"Hello\",\n * text: \"Captured by Mailpit\",\n * });\n *\n * const inbox = await mailpit.messages();\n * console.log(inbox.messages[0]?.Subject);\n * ```\n */\nimport { encodeBase64 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport type {\n MailOptions,\n SendResult,\n SMTPAuth,\n SocketAdapter,\n TLSOptions,\n Transport,\n VerifyResult,\n} from \"../core/types.js\";\nimport { createDefaultAdapter } from \"../detect.js\";\nimport { SMTPTransport } from \"./smtp.js\";\n\n/** Default SMTP host for a local Mailpit instance. */\nexport const MAILPIT_DEFAULT_HOST = \"localhost\";\n\n/** Default SMTP port for a local Mailpit instance. */\nexport const MAILPIT_DEFAULT_PORT = 1025;\n\n/** Default web UI / REST API base URL for a local Mailpit instance. */\nexport const MAILPIT_DEFAULT_API_URL = \"http://localhost:8025\";\n\n/** Mailpit development transport configuration. */\nexport interface MailpitConfig {\n /** SMTP hostname. Default: `\"localhost\"`. */\n host?: string;\n /** SMTP port. Default: `1025`. */\n port?: number;\n /** Use implicit TLS on connect. Default: `false` (Mailpit is plain SMTP). */\n secure?: boolean;\n /**\n * Refuse AUTH over a non-TLS connection.\n * Default: `false` so optional local SMTP auth works without TLS.\n */\n requireTLS?: boolean;\n /** Optional SMTP authentication (Mailpit can accept any credentials). */\n auth?: SMTPAuth;\n /** TLS options when STARTTLS or implicit TLS is enabled. */\n tls?: TLSOptions;\n /** Socket connect timeout in milliseconds. */\n connectionTimeout?: number;\n /** Runtime socket adapter. Auto-detected on first send when omitted. */\n adapter?: SocketAdapter;\n /**\n * Mailpit web UI / REST API base URL (no trailing slash).\n * Default: `\"http://localhost:8025\"`.\n */\n apiUrl?: string;\n /** Basic auth for the Mailpit UI/API when the instance requires it. */\n apiAuth?: { user: string; pass: string };\n}\n\n/** Address object returned by the Mailpit REST API. */\nexport interface MailpitAddress {\n /** Display name, may be empty. */\n Name: string;\n /** Email address. */\n Address: string;\n}\n\n/** Summary row from `GET /api/v1/messages`. */\nexport interface MailpitMessageSummary {\n /** Mailpit message id. */\n ID: string;\n /** MIME Message-ID. */\n MessageID: string;\n /** Sender. */\n From: MailpitAddress;\n /** Recipients. */\n To: MailpitAddress[];\n /** Subject line. */\n Subject: string;\n /** ISO created timestamp. */\n Created: string;\n /** Attachment count. */\n Attachments: number;\n /** Whether the message has been read in the UI. */\n Read: boolean;\n /** Short plain-text snippet. */\n Snippet: string;\n}\n\n/** Response from `GET /api/v1/messages`. */\nexport interface MailpitMessageList {\n /** Total messages stored. */\n total: number;\n /** Unread message count. */\n unread: number;\n /** Messages returned in this page. */\n count: number;\n /** Message summaries (newest first). */\n messages: MailpitMessageSummary[];\n}\n\n/** Full message from `GET /api/v1/message/{id}`. */\nexport interface MailpitMessage {\n /** Mailpit message id. */\n ID: string;\n /** MIME Message-ID. */\n MessageID: string;\n /** Sender. */\n From: MailpitAddress;\n /** Recipients. */\n To: MailpitAddress[];\n /** CC recipients. */\n Cc?: MailpitAddress[];\n /** BCC recipients. */\n Bcc?: MailpitAddress[];\n /** Subject line. */\n Subject: string;\n /** Plain-text body. */\n Text: string;\n /** HTML body. */\n HTML: string;\n /** ISO created timestamp. */\n Date: string;\n /** Attachment count. */\n Attachments: number;\n}\n\n/** Options for {@link MailpitTransport.messages}. */\nexport interface MailpitMessagesOptions {\n /** Max messages to return. */\n limit?: number;\n /** Pagination offset. */\n start?: number;\n}\n\n/** Error thrown when the Mailpit REST API returns a non-success response. */\nexport class MailpitError extends SentlyError {\n /** Creates a Mailpit API error with status code (`0` = network/connect failure). */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, statusCode === 0 ? \"CONNECTION_FAILED\" : httpStatusToSentlyCode(statusCode), {\n ...(statusCode > 0 ? { statusCode } : {}),\n provider: \"mailpit\",\n cause: apiError,\n });\n this.name = \"MailpitError\";\n }\n}\n\n/**\n * Development transport for [Mailpit](https://github.com/axllent/mailpit).\n *\n * Sends via SMTP (defaults: `localhost:1025`) and exposes REST helpers for\n * listing, reading, and deleting captured messages.\n */\nexport class MailpitTransport implements Transport {\n readonly provider = \"mailpit\";\n\n private readonly host: string;\n private readonly port: number;\n private readonly secure: boolean;\n private readonly requireTLS: boolean;\n private readonly auth?: SMTPAuth;\n private readonly tls?: TLSOptions;\n private readonly connectionTimeout?: number;\n private readonly adapter?: SocketAdapter;\n private readonly apiUrl: string;\n private readonly apiAuth?: { user: string; pass: string };\n\n private smtp: SMTPTransport | null = null;\n\n /** Creates a Mailpit transport with local-dev defaults. */\n constructor(config: MailpitConfig = {}) {\n this.host = config.host ?? MAILPIT_DEFAULT_HOST;\n this.port = config.port ?? MAILPIT_DEFAULT_PORT;\n this.secure = config.secure ?? false;\n this.requireTLS = config.requireTLS ?? false;\n if (config.auth !== undefined) {\n this.auth = config.auth;\n }\n if (config.tls !== undefined) {\n this.tls = config.tls;\n }\n if (config.connectionTimeout !== undefined) {\n this.connectionTimeout = config.connectionTimeout;\n }\n if (config.adapter !== undefined) {\n this.adapter = config.adapter;\n }\n this.apiUrl = (config.apiUrl ?? MAILPIT_DEFAULT_API_URL).replace(/\\/$/, \"\");\n if (config.apiAuth !== undefined) {\n this.apiAuth = config.apiAuth;\n }\n }\n\n /** Web UI base URL (same origin as the REST API). */\n get webUrl(): string {\n return this.apiUrl;\n }\n\n /** Sends an email to Mailpit over SMTP. */\n async send(options: MailOptions): Promise<SendResult> {\n return (await this.getSmtp()).send(options);\n }\n\n /** Verifies SMTP connectivity to Mailpit. */\n async verify(): Promise<VerifyResult> {\n const result = await (await this.getSmtp()).verify();\n return {\n ...result,\n provider: \"mailpit\",\n };\n }\n\n /** Closes the underlying SMTP adapter if connected. */\n async close(): Promise<void> {\n if (this.smtp) {\n await this.smtp.close();\n }\n }\n\n /**\n * Lists captured messages via `GET /api/v1/messages`.\n * Newest messages appear first.\n */\n async messages(options: MailpitMessagesOptions = {}): Promise<MailpitMessageList> {\n const params = new URLSearchParams();\n if (options.limit !== undefined) {\n params.set(\"limit\", String(options.limit));\n }\n if (options.start !== undefined) {\n params.set(\"start\", String(options.start));\n }\n const query = params.toString();\n const path = query ? `/api/v1/messages?${query}` : \"/api/v1/messages\";\n return this.apiGet<MailpitMessageList>(path);\n }\n\n /** Fetches a full message via `GET /api/v1/message/{id}`. */\n async getMessage(id: string): Promise<MailpitMessage> {\n if (!id) {\n throw new MailpitError(\"Message id is required\", 400, { code: \"INVALID_CONFIG\" });\n }\n return this.apiGet<MailpitMessage>(`/api/v1/message/${encodeURIComponent(id)}`);\n }\n\n /**\n * Deletes messages by id via `DELETE /api/v1/messages`.\n * Pass an empty array (or call {@link deleteAll}) to clear the inbox.\n */\n async deleteMessages(ids: string[]): Promise<void> {\n await this.apiDelete(\"/api/v1/messages\", { IDs: ids });\n }\n\n /** Deletes every captured message. */\n async deleteAll(): Promise<void> {\n await this.deleteMessages([]);\n }\n\n private async getSmtp(): Promise<SMTPTransport> {\n if (!this.smtp) {\n const adapter =\n this.adapter ??\n (await createDefaultAdapter({\n secure: this.secure,\n ...(this.connectionTimeout !== undefined\n ? { connectionTimeout: this.connectionTimeout }\n : {}),\n ...(this.tls !== undefined ? { tls: this.tls } : {}),\n }));\n\n this.smtp = new SMTPTransport({\n host: this.host,\n port: this.port,\n secure: this.secure,\n requireTLS: this.requireTLS,\n adapter,\n ...(this.auth !== undefined ? { auth: this.auth } : {}),\n ...(this.tls !== undefined ? { tls: this.tls } : {}),\n ...(this.connectionTimeout !== undefined\n ? { connectionTimeout: this.connectionTimeout }\n : {}),\n });\n }\n return this.smtp;\n }\n\n private apiHeaders(): HeadersInit {\n const headers: Record<string, string> = {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n };\n if (this.apiAuth) {\n const token = encodeBase64(`${this.apiAuth.user}:${this.apiAuth.pass}`).replace(/\\r\\n/g, \"\");\n headers.Authorization = `Basic ${token}`;\n }\n return headers;\n }\n\n private async apiGet<T>(path: string): Promise<T> {\n let response: Response;\n try {\n response = await fetch(`${this.apiUrl}${path}`, {\n method: \"GET\",\n headers: this.apiHeaders(),\n });\n } catch (err) {\n throw new MailpitError(\n err instanceof Error ? err.message : \"Mailpit API request failed\",\n 0,\n err,\n );\n }\n\n if (!response.ok) {\n const body = await response.text().catch(() => \"\");\n throw new MailpitError(\n body || `Mailpit API error (${response.status})`,\n response.status,\n body,\n );\n }\n\n return (await response.json()) as T;\n }\n\n private async apiDelete(path: string, body: unknown): Promise<void> {\n let response: Response;\n try {\n response = await fetch(`${this.apiUrl}${path}`, {\n method: \"DELETE\",\n headers: this.apiHeaders(),\n body: JSON.stringify(body),\n });\n } catch (err) {\n throw new MailpitError(\n err instanceof Error ? err.message : \"Mailpit API request failed\",\n 0,\n err,\n );\n }\n\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw new MailpitError(\n text || `Mailpit API error (${response.status})`,\n response.status,\n text,\n );\n }\n }\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "A41842358B8E380464756E2164756E21",
9
+ "names": []
10
+ }
@@ -10,7 +10,11 @@ export interface WebPushConfig {
10
10
  * Treat as a tier-1 secret — inject from env / secrets manager only.
11
11
  */
12
12
  vapidPrivateKey: string;
13
- /** Contact URI for the VAPID `sub` claim (`mailto:` or `https:`). */
13
+ /**
14
+ * Contact URI for the VAPID `sub` claim. Must be a `mailto:` address
15
+ * (e.g. `mailto:you@example.com`) or an `https:` URL
16
+ * (e.g. `https://example.com/contact`). Validated at construction.
17
+ */
14
18
  subject: string;
15
19
  /**
16
20
  * Extra exact hostnames allowed for `subscription.endpoint` beyond the
@@ -1,3 +1,3 @@
1
- import{W as T}from"../chunk-fv0bemgf.js";import{_ as A}from"../chunk-z1589fjk.js";import{ga as N,ha as H,ia as J}from"../chunk-hnzmn4s4.js";import{ja as m,ka as v}from"../chunk-ttbwyxmh.js";import"../chunk-th4cwrpb.js";class V extends m{statusCode;apiError;constructor(q,z,Q){super(q,v(z),{statusCode:z,provider:"webpush",cause:Q});this.statusCode=z;this.apiError=Q;this.name="WebPushError"}}var U=2419200,g=4096,C=4096;function X(q){return q.buffer.slice(q.byteOffset,q.byteOffset+q.byteLength)}function I(...q){let z=q.reduce((G,Z)=>G+Z.length,0),Q=new Uint8Array(z),$=0;for(let G of q)Q.set(G,$),$+=G.length;return Q}function K(q){let z=new Uint8Array(4);return new DataView(z.buffer).setUint32(0,q,!1),z}function B(q){if(q.length!==65||q[0]!==4)throw new V("Invalid P-256 uncompressed public key",400,{length:q.length});return{kty:"EC",crv:"P-256",x:N(q.subarray(1,33)),y:N(q.subarray(33,65))}}function E(q,z){return{...B(q),d:N(z)}}async function _(q,z){let Q=await crypto.subtle.importKey("raw",X(q),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),$=await crypto.subtle.sign("HMAC",Q,X(z));return new Uint8Array($)}async function w(q,z,Q,$){let G=await _(q,z);return(await _(G,I(Q,new Uint8Array([1])))).subarray(0,$)}async function P(q,z){let Q=H(q.keys.p256dh),$=H(q.keys.auth);if(Q.length!==65||Q[0]!==4)throw new V("Invalid subscription p256dh key",400,{length:Q.length});if($.length<16)throw new V("Invalid subscription auth key",400,{length:$.length});let G=await crypto.subtle.generateKey({name:"ECDH",namedCurve:"P-256"},!0,["deriveBits"]),Z=new Uint8Array(await crypto.subtle.exportKey("raw",G.publicKey)),F=await crypto.subtle.importKey("raw",X(Q),{name:"ECDH",namedCurve:"P-256"},!1,[]),M=new Uint8Array(await crypto.subtle.deriveBits({name:"ECDH",public:F},G.privateKey,256)),L=I(J("WebPush: info\x00"),Q,Z),x=await w($,M,L,32),O=crypto.getRandomValues(new Uint8Array(16)),R=J("Content-Encoding: aes128gcm\x00"),j=J("Content-Encoding: nonce\x00"),Y=await w(O,x,R,16),D=await w(O,x,j,12),S=I(z,new Uint8Array([2])),W=await crypto.subtle.importKey("raw",X(Y),"AES-GCM",!1,["encrypt"]),k=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv:X(D)},W,X(S)));return I(O,K(g),new Uint8Array([Z.length]),Z,k)}async function h(q,z,Q,$){let G=H(Q),Z=H($),F=E(G,Z),M=await crypto.subtle.importKey("jwk",F,{name:"ECDSA",namedCurve:"P-256"},!1,["sign"]),L=new URL(q).origin,x={typ:"JWT",alg:"ES256"},O={aud:L,exp:Math.floor(Date.now()/1000)+43200,sub:z},R=N(J(JSON.stringify(x))),j=N(J(JSON.stringify(O))),Y=`${R}.${j}`,D=new Uint8Array(await crypto.subtle.sign({name:"ECDSA",hash:"SHA-256"},M,X(J(Y))));return`${Y}.${N(D)}`}class f{provider="webpush";vapidPublicKey;vapidPrivateKey;subject;allowedEndpointHosts;constructor(q){this.vapidPublicKey=q.vapidPublicKey,this.vapidPrivateKey=q.vapidPrivateKey,this.subject=q.subject,this.allowedEndpointHosts=q.allowedEndpointHosts??[]}async send(q){if(!A(q))throw new V("WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens",400,{hint:"fcm"});try{T(q.subscription.endpoint,this.allowedEndpointHosts)}catch(M){let L;try{L=new URL(q.subscription.endpoint).hostname}catch{L=void 0}throw new V(M instanceof Error?M.message:"Unsafe push subscription endpoint",400,{endpointHost:L})}let z={title:q.title,body:q.body,...q.data!==void 0?{data:q.data}:{},...q.icon!==void 0?{icon:q.icon}:{}},Q=J(JSON.stringify(z)),$=await P(q.subscription,Q);if($.length>C)throw new V(`Encrypted push payload exceeds ${C} bytes (RFC 8291)`,413,{size:$.length});let G=await h(q.subscription.endpoint,this.subject,this.vapidPublicKey,this.vapidPrivateKey),Z=q.ttl??U,F=await fetch(q.subscription.endpoint,{method:"POST",redirect:"manual",headers:{Authorization:`vapid t=${G}, k=${this.vapidPublicKey}`,"Content-Encoding":"aes128gcm",TTL:String(Z),"Content-Type":"application/octet-stream"},body:X($)});if(F.status>=300&&F.status<400)throw new V(`Push service returned redirect ${F.status}; redirects are not followed`,F.status,{location:F.headers.get("location")});if(!F.ok){let M=await F.text().catch(()=>"");throw new V(M||`Web Push request failed with status ${F.status}`,F.status,M||{status:F.status})}return{messageId:q.messageId??crypto.randomUUID(),status:"accepted",response:String(F.status),provider:"webpush"}}async verify(){try{let q=H(this.vapidPublicKey),z=H(this.vapidPrivateKey),Q=q.length===65&&z.length===32&&this.subject.length>0;return{ok:Q,provider:"webpush",message:Q?"VAPID credentials present":"Invalid VAPID key lengths"}}catch(q){return{ok:!1,provider:"webpush",message:q instanceof Error?q.message:"Invalid VAPID credentials"}}}}export{f as WebPushTransport,V as WebPushError};
1
+ import{V as T}from"../chunk-8kpgbrba.js";import{W as S}from"../chunk-fv0bemgf.js";import{_ as W}from"../chunk-z1589fjk.js";import{ga as H,ha as V,ia as L}from"../chunk-hnzmn4s4.js";import{ja as m,ka as A}from"../chunk-ttbwyxmh.js";import"../chunk-th4cwrpb.js";class M extends m{statusCode;apiError;constructor(q,z,Q){super(q,A(z),{statusCode:z,provider:"webpush",cause:Q});this.statusCode=z;this.apiError=Q;this.name="WebPushError"}}var g=2419200,K=4096,_=4096;function B(q){if(q.startsWith("mailto:")){let z=q.slice(7);if(T(z))return}else if(q.startsWith("https:"))try{let z=new URL(q);if(z.protocol==="https:"&&z.hostname.length>0)return}catch{}throw new M(`WebPushConfig.subject must be a mailto: address or https: URL identifying you to push services, got: ${JSON.stringify(q)}`,400,{field:"subject",value:q})}function J(q){return q.buffer.slice(q.byteOffset,q.byteOffset+q.byteLength)}function I(...q){let z=q.reduce((G,Z)=>G+Z.length,0),Q=new Uint8Array(z),$=0;for(let G of q)Q.set(G,$),$+=G.length;return Q}function E(q){let z=new Uint8Array(4);return new DataView(z.buffer).setUint32(0,q,!1),z}function P(q){if(q.length!==65||q[0]!==4)throw new M("Invalid P-256 uncompressed public key",400,{length:q.length});return{kty:"EC",crv:"P-256",x:H(q.subarray(1,33)),y:H(q.subarray(33,65))}}function h(q,z){return{...P(q),d:H(z)}}async function v(q,z){let Q=await crypto.subtle.importKey("raw",J(q),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),$=await crypto.subtle.sign("HMAC",Q,J(z));return new Uint8Array($)}async function C(q,z,Q,$){let G=await v(q,z);return(await v(G,I(Q,new Uint8Array([1])))).subarray(0,$)}async function f(q,z){let Q=V(q.keys.p256dh),$=V(q.keys.auth);if(Q.length!==65||Q[0]!==4)throw new M("Invalid subscription p256dh key",400,{length:Q.length});if($.length<16)throw new M("Invalid subscription auth key",400,{length:$.length});let G=await crypto.subtle.generateKey({name:"ECDH",namedCurve:"P-256"},!0,["deriveBits"]),Z=new Uint8Array(await crypto.subtle.exportKey("raw",G.publicKey)),F=await crypto.subtle.importKey("raw",J(Q),{name:"ECDH",namedCurve:"P-256"},!1,[]),X=new Uint8Array(await crypto.subtle.deriveBits({name:"ECDH",public:F},G.privateKey,256)),N=I(L("WebPush: info\x00"),Q,Z),x=await C($,X,N,32),O=crypto.getRandomValues(new Uint8Array(16)),R=L("Content-Encoding: aes128gcm\x00"),D=L("Content-Encoding: nonce\x00"),Y=await C(O,x,R,16),w=await C(O,x,D,12),k=I(z,new Uint8Array([2])),U=await crypto.subtle.importKey("raw",J(Y),"AES-GCM",!1,["encrypt"]),j=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv:J(w)},U,J(k)));return I(O,E(K),new Uint8Array([Z.length]),Z,j)}async function l(q,z,Q,$){let G=V(Q),Z=V($),F=h(G,Z),X=await crypto.subtle.importKey("jwk",F,{name:"ECDSA",namedCurve:"P-256"},!1,["sign"]),N=new URL(q).origin,x={typ:"JWT",alg:"ES256"},O={aud:N,exp:Math.floor(Date.now()/1000)+43200,sub:z},R=H(L(JSON.stringify(x))),D=H(L(JSON.stringify(O))),Y=`${R}.${D}`,w=new Uint8Array(await crypto.subtle.sign({name:"ECDSA",hash:"SHA-256"},X,J(L(Y))));return`${Y}.${H(w)}`}class y{provider="webpush";vapidPublicKey;vapidPrivateKey;subject;allowedEndpointHosts;constructor(q){B(q.subject),this.vapidPublicKey=q.vapidPublicKey,this.vapidPrivateKey=q.vapidPrivateKey,this.subject=q.subject,this.allowedEndpointHosts=q.allowedEndpointHosts??[]}async send(q){if(!W(q))throw new M("WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens",400,{hint:"fcm"});try{S(q.subscription.endpoint,this.allowedEndpointHosts)}catch(X){let N;try{N=new URL(q.subscription.endpoint).hostname}catch{N=void 0}throw new M(X instanceof Error?X.message:"Unsafe push subscription endpoint",400,{endpointHost:N})}let z={title:q.title,body:q.body,...q.data!==void 0?{data:q.data}:{},...q.icon!==void 0?{icon:q.icon}:{}},Q=L(JSON.stringify(z)),$=await f(q.subscription,Q);if($.length>_)throw new M(`Encrypted push payload exceeds ${_} bytes (RFC 8291)`,413,{size:$.length});let G=await l(q.subscription.endpoint,this.subject,this.vapidPublicKey,this.vapidPrivateKey),Z=q.ttl??g,F=await fetch(q.subscription.endpoint,{method:"POST",redirect:"manual",headers:{Authorization:`vapid t=${G}, k=${this.vapidPublicKey}`,"Content-Encoding":"aes128gcm",TTL:String(Z),"Content-Type":"application/octet-stream"},body:J($)});if(F.status>=300&&F.status<400)throw new M(`Push service returned redirect ${F.status}; redirects are not followed`,F.status,{location:F.headers.get("location")});if(!F.ok){let X=await F.text().catch(()=>"");throw new M(X||`Web Push request failed with status ${F.status}`,F.status,X||{status:F.status})}return{messageId:q.messageId??crypto.randomUUID(),status:"accepted",response:String(F.status),provider:"webpush"}}async verify(){try{let q=V(this.vapidPublicKey),z=V(this.vapidPrivateKey),Q=q.length===65&&z.length===32&&this.subject.length>0;return{ok:Q,provider:"webpush",message:Q?"VAPID credentials present":"Invalid VAPID key lengths"}}catch(q){return{ok:!1,provider:"webpush",message:q instanceof Error?q.message:"Invalid VAPID credentials"}}}}export{y as WebPushTransport,M as WebPushError};
2
2
 
3
- //# debugId=DFA1A46FC61CEB2264756E2164756E21
3
+ //# debugId=4593CBC2A87BBFF364756E2164756E21
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/transports/webpush.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * @module\n * Web Push transport (RFC 8292 VAPID + RFC 8291 aes128gcm).\n *\n * VAPID keys are the common web-push raw format: base64url-encoded\n * uncompressed P-256 public key (65 bytes) and raw private key (32 bytes).\n *\n * Store `vapidPrivateKey` in a secrets manager / environment variable — never\n * hardcode it or commit it to source control.\n *\n * @example\n * ```ts\n * import { createPushSender } from \"sently/push\";\n * import { WebPushTransport } from \"sently/transports/webpush\";\n *\n * const push = createPushSender({\n * transport: new WebPushTransport({\n * vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,\n * vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,\n * subject: \"mailto:you@example.com\",\n * }),\n * });\n * ```\n */\nimport { decodeBase64Url, encodeBase64Url, encodeUtf8 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport { assertSafePushEndpoint } from \"../core/push-endpoint.js\";\nimport type {\n PushOptions,\n PushSendResult,\n PushSubscription,\n PushTransport,\n} from \"../core/push-types.js\";\nimport { isWebPushOptions } from \"../core/push-types.js\";\nimport type { VerifyResult } from \"../core/types.js\";\n\n/** Web Push / VAPID configuration. */\nexport interface WebPushConfig {\n /** Base64url-encoded uncompressed P-256 public key (65 bytes). */\n vapidPublicKey: string;\n /**\n * Base64url-encoded raw P-256 private key (32 bytes).\n * Treat as a tier-1 secret — inject from env / secrets manager only.\n */\n vapidPrivateKey: string;\n /** Contact URI for the VAPID `sub` claim (`mailto:` or `https:`). */\n subject: string;\n /**\n * Extra exact hostnames allowed for `subscription.endpoint` beyond the\n * built-in FCM / Mozilla / Apple / WNS allowlist. Use for private push relays.\n */\n allowedEndpointHosts?: string[];\n}\n\n/** Error thrown when a push service rejects the request. */\nexport class WebPushError extends SentlyError {\n /** Creates a Web Push error with HTTP status and response body. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, httpStatusToSentlyCode(statusCode), {\n statusCode,\n provider: \"webpush\",\n cause: apiError,\n });\n this.name = \"WebPushError\";\n }\n}\n\n/** Default TTL (28 days) in seconds. */\nconst DEFAULT_TTL_SECONDS = 2419200;\n/** RFC 8188 / Web Push record size. */\nconst RECORD_SIZE = 4096;\n/**\n * Maximum encrypted body size push services are required to accept (RFC 8291).\n * Includes salt, rs, keyid, and ciphertext+tag.\n */\nconst MAX_ENCRYPTED_BODY_BYTES = 4096;\n\nfunction toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;\n}\n\nfunction concatBytes(...parts: Uint8Array[]): Uint8Array {\n const total = parts.reduce((sum, part) => sum + part.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nfunction uint32Be(value: number): Uint8Array {\n const out = new Uint8Array(4);\n new DataView(out.buffer).setUint32(0, value, false);\n return out;\n}\n\nfunction ecPublicToJwk(publicKey: Uint8Array): JsonWebKey {\n if (publicKey.length !== 65 || publicKey[0] !== 0x04) {\n throw new WebPushError(\"Invalid P-256 uncompressed public key\", 400, {\n length: publicKey.length,\n });\n }\n return {\n kty: \"EC\",\n crv: \"P-256\",\n x: encodeBase64Url(publicKey.subarray(1, 33)),\n y: encodeBase64Url(publicKey.subarray(33, 65)),\n };\n}\n\nfunction ecKeyPairToJwk(publicKey: Uint8Array, privateKey: Uint8Array): JsonWebKey {\n return {\n ...ecPublicToJwk(publicKey),\n d: encodeBase64Url(privateKey),\n };\n}\n\nasync function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", cryptoKey, toArrayBuffer(data));\n return new Uint8Array(sig);\n}\n\n/** HKDF-Extract + Expand (HMAC-SHA-256) for a single block (L ≤ 32). */\nasync function hkdf(\n salt: Uint8Array,\n ikm: Uint8Array,\n info: Uint8Array,\n length: number,\n): Promise<Uint8Array> {\n const prk = await hmacSha256(salt, ikm);\n const block = await hmacSha256(prk, concatBytes(info, new Uint8Array([0x01])));\n return block.subarray(0, length);\n}\n\n/**\n * Encrypt a payload per RFC 8291 (aes128gcm content coding).\n *\n * Confidence note: Implemented from RFC 8291 / RFC 8188. Tests assert header\n * shapes, framing, and size limits; full known-vector fixtures were not\n * available in-repo — treat as high-scrutiny crypto.\n */\nasync function encryptAes128Gcm(\n subscription: PushSubscription,\n plaintext: Uint8Array,\n): Promise<Uint8Array> {\n const uaPublic = decodeBase64Url(subscription.keys.p256dh);\n const authSecret = decodeBase64Url(subscription.keys.auth);\n\n if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) {\n throw new WebPushError(\"Invalid subscription p256dh key\", 400, {\n length: uaPublic.length,\n });\n }\n if (authSecret.length < 16) {\n throw new WebPushError(\"Invalid subscription auth key\", 400, {\n length: authSecret.length,\n });\n }\n\n const asKeyPair = await crypto.subtle.generateKey({ name: \"ECDH\", namedCurve: \"P-256\" }, true, [\n \"deriveBits\",\n ]);\n const asPublicRaw = new Uint8Array(await crypto.subtle.exportKey(\"raw\", asKeyPair.publicKey));\n\n const uaKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(uaPublic),\n { name: \"ECDH\", namedCurve: \"P-256\" },\n false,\n [],\n );\n\n const ecdhSecret = new Uint8Array(\n await crypto.subtle.deriveBits({ name: \"ECDH\", public: uaKey }, asKeyPair.privateKey, 256),\n );\n\n const keyInfo = concatBytes(encodeUtf8(\"WebPush: info\\0\"), uaPublic, asPublicRaw);\n const ikm = await hkdf(authSecret, ecdhSecret, keyInfo, 32);\n\n const salt = crypto.getRandomValues(new Uint8Array(16));\n const cekInfo = encodeUtf8(\"Content-Encoding: aes128gcm\\0\");\n const nonceInfo = encodeUtf8(\"Content-Encoding: nonce\\0\");\n const cek = await hkdf(salt, ikm, cekInfo, 16);\n const nonce = await hkdf(salt, ikm, nonceInfo, 12);\n\n // RFC 8188 final-record padding delimiter\n const padded = concatBytes(plaintext, new Uint8Array([0x02]));\n\n const aesKey = await crypto.subtle.importKey(\"raw\", toArrayBuffer(cek), \"AES-GCM\", false, [\n \"encrypt\",\n ]);\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv: toArrayBuffer(nonce) },\n aesKey,\n toArrayBuffer(padded),\n ),\n );\n\n // salt || rs || idlen || keyid || ciphertext\n return concatBytes(\n salt,\n uint32Be(RECORD_SIZE),\n new Uint8Array([asPublicRaw.length]),\n asPublicRaw,\n ciphertext,\n );\n}\n\nasync function signVapidJwt(\n endpoint: string,\n subject: string,\n vapidPublicKey: string,\n vapidPrivateKey: string,\n): Promise<string> {\n const publicKey = decodeBase64Url(vapidPublicKey);\n const privateKey = decodeBase64Url(vapidPrivateKey);\n const jwk = ecKeyPairToJwk(publicKey, privateKey);\n\n const key = await crypto.subtle.importKey(\n \"jwk\",\n jwk,\n { name: \"ECDSA\", namedCurve: \"P-256\" },\n false,\n [\"sign\"],\n );\n\n const origin = new URL(endpoint).origin;\n const header = { typ: \"JWT\", alg: \"ES256\" };\n const payload = {\n aud: origin,\n exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,\n sub: subject,\n };\n\n const encodedHeader = encodeBase64Url(encodeUtf8(JSON.stringify(header)));\n const encodedPayload = encodeBase64Url(encodeUtf8(JSON.stringify(payload)));\n const signingInput = `${encodedHeader}.${encodedPayload}`;\n\n const signature = new Uint8Array(\n await crypto.subtle.sign(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n key,\n toArrayBuffer(encodeUtf8(signingInput)),\n ),\n );\n\n return `${signingInput}.${encodeBase64Url(signature)}`;\n}\n\n/**\n * Web Push transport — VAPID auth + RFC 8291 payload encryption.\n *\n * Endpoint URLs are validated against an allowlist before fetch to mitigate SSRF.\n * Redirects are not followed.\n */\nexport class WebPushTransport implements PushTransport {\n readonly provider = \"webpush\";\n\n private readonly vapidPublicKey: string;\n private readonly vapidPrivateKey: string;\n private readonly subject: string;\n private readonly allowedEndpointHosts: readonly string[];\n\n /** Creates a Web Push transport with VAPID credentials. */\n constructor(config: WebPushConfig) {\n this.vapidPublicKey = config.vapidPublicKey;\n this.vapidPrivateKey = config.vapidPrivateKey;\n this.subject = config.subject;\n this.allowedEndpointHosts = config.allowedEndpointHosts ?? [];\n }\n\n /** Encrypts and POSTs a notification to the subscription endpoint. */\n async send(options: PushOptions): Promise<PushSendResult> {\n if (!isWebPushOptions(options)) {\n throw new WebPushError(\n \"WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens\",\n 400,\n { hint: \"fcm\" },\n );\n }\n\n try {\n assertSafePushEndpoint(options.subscription.endpoint, this.allowedEndpointHosts);\n } catch (error) {\n let endpointHost: string | undefined;\n try {\n endpointHost = new URL(options.subscription.endpoint).hostname;\n } catch {\n endpointHost = undefined;\n }\n throw new WebPushError(\n error instanceof Error ? error.message : \"Unsafe push subscription endpoint\",\n 400,\n { endpointHost },\n );\n }\n\n const notification = {\n title: options.title,\n body: options.body,\n ...(options.data !== undefined ? { data: options.data } : {}),\n ...(options.icon !== undefined ? { icon: options.icon } : {}),\n };\n const plaintext = encodeUtf8(JSON.stringify(notification));\n const encrypted = await encryptAes128Gcm(options.subscription, plaintext);\n\n if (encrypted.length > MAX_ENCRYPTED_BODY_BYTES) {\n throw new WebPushError(\n `Encrypted push payload exceeds ${MAX_ENCRYPTED_BODY_BYTES} bytes (RFC 8291)`,\n 413,\n { size: encrypted.length },\n );\n }\n\n const jwt = await signVapidJwt(\n options.subscription.endpoint,\n this.subject,\n this.vapidPublicKey,\n this.vapidPrivateKey,\n );\n\n const ttl = options.ttl ?? DEFAULT_TTL_SECONDS;\n const response = await fetch(options.subscription.endpoint, {\n method: \"POST\",\n redirect: \"manual\",\n headers: {\n Authorization: `vapid t=${jwt}, k=${this.vapidPublicKey}`,\n \"Content-Encoding\": \"aes128gcm\",\n TTL: String(ttl),\n \"Content-Type\": \"application/octet-stream\",\n },\n body: toArrayBuffer(encrypted),\n });\n\n // Opaque redirect responses (3xx) must not be followed — SSRF mitigation.\n if (response.status >= 300 && response.status < 400) {\n throw new WebPushError(\n `Push service returned redirect ${response.status}; redirects are not followed`,\n response.status,\n { location: response.headers.get(\"location\") },\n );\n }\n\n if (!response.ok) {\n const bodyText = await response.text().catch(() => \"\");\n throw new WebPushError(\n bodyText || `Web Push request failed with status ${response.status}`,\n response.status,\n bodyText || { status: response.status },\n );\n }\n\n return {\n messageId: options.messageId ?? crypto.randomUUID(),\n status: \"accepted\",\n response: String(response.status),\n provider: \"webpush\",\n };\n }\n\n /** Lightweight VAPID credential shape check. */\n async verify(): Promise<VerifyResult> {\n try {\n const pub = decodeBase64Url(this.vapidPublicKey);\n const priv = decodeBase64Url(this.vapidPrivateKey);\n const ok = pub.length === 65 && priv.length === 32 && this.subject.length > 0;\n return {\n ok,\n provider: \"webpush\",\n message: ok ? \"VAPID credentials present\" : \"Invalid VAPID key lengths\",\n };\n } catch (error) {\n return {\n ok: false,\n provider: \"webpush\",\n message: error instanceof Error ? error.message : \"Invalid VAPID credentials\",\n };\n }\n }\n}\n"
5
+ "/**\n * @module\n * Web Push transport (RFC 8292 VAPID + RFC 8291 aes128gcm).\n *\n * VAPID keys are the common web-push raw format: base64url-encoded\n * uncompressed P-256 public key (65 bytes) and raw private key (32 bytes).\n *\n * Store `vapidPrivateKey` in a secrets manager / environment variable — never\n * hardcode it or commit it to source control.\n *\n * @example\n * ```ts\n * import { createPushSender } from \"sently/push\";\n * import { WebPushTransport } from \"sently/transports/webpush\";\n *\n * const push = createPushSender({\n * transport: new WebPushTransport({\n * vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,\n * vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,\n * subject: \"mailto:you@example.com\",\n * }),\n * });\n * ```\n */\nimport { isValidEmail } from \"../core/address.js\";\nimport { decodeBase64Url, encodeBase64Url, encodeUtf8 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport { assertSafePushEndpoint } from \"../core/push-endpoint.js\";\nimport type {\n PushOptions,\n PushSendResult,\n PushSubscription,\n PushTransport,\n} from \"../core/push-types.js\";\nimport { isWebPushOptions } from \"../core/push-types.js\";\nimport type { VerifyResult } from \"../core/types.js\";\n\n/** Web Push / VAPID configuration. */\nexport interface WebPushConfig {\n /** Base64url-encoded uncompressed P-256 public key (65 bytes). */\n vapidPublicKey: string;\n /**\n * Base64url-encoded raw P-256 private key (32 bytes).\n * Treat as a tier-1 secret — inject from env / secrets manager only.\n */\n vapidPrivateKey: string;\n /**\n * Contact URI for the VAPID `sub` claim. Must be a `mailto:` address\n * (e.g. `mailto:you@example.com`) or an `https:` URL\n * (e.g. `https://example.com/contact`). Validated at construction.\n */\n subject: string;\n /**\n * Extra exact hostnames allowed for `subscription.endpoint` beyond the\n * built-in FCM / Mozilla / Apple / WNS allowlist. Use for private push relays.\n */\n allowedEndpointHosts?: string[];\n}\n\n/** Error thrown when a push service rejects the request. */\nexport class WebPushError extends SentlyError {\n /** Creates a Web Push error with HTTP status and response body. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, httpStatusToSentlyCode(statusCode), {\n statusCode,\n provider: \"webpush\",\n cause: apiError,\n });\n this.name = \"WebPushError\";\n }\n}\n\n/** Default TTL (28 days) in seconds. */\nconst DEFAULT_TTL_SECONDS = 2419200;\n/** RFC 8188 / Web Push record size. */\nconst RECORD_SIZE = 4096;\n/**\n * Maximum encrypted body size push services are required to accept (RFC 8291).\n * Includes salt, rs, keyid, and ciphertext+tag.\n */\nconst MAX_ENCRYPTED_BODY_BYTES = 4096;\n\n/**\n * RFC 8292 VAPID `sub` must identify the sender as `mailto:` or `https:`.\n * Checked at construction so a bare handle like `@oke.local` fails here,\n * not later as an opaque 403 from a push service.\n */\nfunction assertVapidSubject(subject: string): void {\n if (subject.startsWith(\"mailto:\")) {\n const email = subject.slice(\"mailto:\".length);\n if (isValidEmail(email)) {\n return;\n }\n } else if (subject.startsWith(\"https:\")) {\n try {\n const url = new URL(subject);\n if (url.protocol === \"https:\" && url.hostname.length > 0) {\n return;\n }\n } catch {\n // fall through to the shared error\n }\n }\n\n throw new WebPushError(\n `WebPushConfig.subject must be a mailto: address or https: URL identifying you to push services, got: ${JSON.stringify(subject)}`,\n 400,\n { field: \"subject\", value: subject },\n );\n}\n\nfunction toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;\n}\n\nfunction concatBytes(...parts: Uint8Array[]): Uint8Array {\n const total = parts.reduce((sum, part) => sum + part.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nfunction uint32Be(value: number): Uint8Array {\n const out = new Uint8Array(4);\n new DataView(out.buffer).setUint32(0, value, false);\n return out;\n}\n\nfunction ecPublicToJwk(publicKey: Uint8Array): JsonWebKey {\n if (publicKey.length !== 65 || publicKey[0] !== 0x04) {\n throw new WebPushError(\"Invalid P-256 uncompressed public key\", 400, {\n length: publicKey.length,\n });\n }\n return {\n kty: \"EC\",\n crv: \"P-256\",\n x: encodeBase64Url(publicKey.subarray(1, 33)),\n y: encodeBase64Url(publicKey.subarray(33, 65)),\n };\n}\n\nfunction ecKeyPairToJwk(publicKey: Uint8Array, privateKey: Uint8Array): JsonWebKey {\n return {\n ...ecPublicToJwk(publicKey),\n d: encodeBase64Url(privateKey),\n };\n}\n\nasync function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", cryptoKey, toArrayBuffer(data));\n return new Uint8Array(sig);\n}\n\n/** HKDF-Extract + Expand (HMAC-SHA-256) for a single block (L ≤ 32). */\nasync function hkdf(\n salt: Uint8Array,\n ikm: Uint8Array,\n info: Uint8Array,\n length: number,\n): Promise<Uint8Array> {\n const prk = await hmacSha256(salt, ikm);\n const block = await hmacSha256(prk, concatBytes(info, new Uint8Array([0x01])));\n return block.subarray(0, length);\n}\n\n/**\n * Encrypt a payload per RFC 8291 (aes128gcm content coding).\n *\n * Confidence note: Implemented from RFC 8291 / RFC 8188. Tests assert header\n * shapes, framing, and size limits; full known-vector fixtures were not\n * available in-repo — treat as high-scrutiny crypto.\n */\nasync function encryptAes128Gcm(\n subscription: PushSubscription,\n plaintext: Uint8Array,\n): Promise<Uint8Array> {\n const uaPublic = decodeBase64Url(subscription.keys.p256dh);\n const authSecret = decodeBase64Url(subscription.keys.auth);\n\n if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) {\n throw new WebPushError(\"Invalid subscription p256dh key\", 400, {\n length: uaPublic.length,\n });\n }\n if (authSecret.length < 16) {\n throw new WebPushError(\"Invalid subscription auth key\", 400, {\n length: authSecret.length,\n });\n }\n\n const asKeyPair = await crypto.subtle.generateKey({ name: \"ECDH\", namedCurve: \"P-256\" }, true, [\n \"deriveBits\",\n ]);\n const asPublicRaw = new Uint8Array(await crypto.subtle.exportKey(\"raw\", asKeyPair.publicKey));\n\n const uaKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(uaPublic),\n { name: \"ECDH\", namedCurve: \"P-256\" },\n false,\n [],\n );\n\n const ecdhSecret = new Uint8Array(\n await crypto.subtle.deriveBits({ name: \"ECDH\", public: uaKey }, asKeyPair.privateKey, 256),\n );\n\n const keyInfo = concatBytes(encodeUtf8(\"WebPush: info\\0\"), uaPublic, asPublicRaw);\n const ikm = await hkdf(authSecret, ecdhSecret, keyInfo, 32);\n\n const salt = crypto.getRandomValues(new Uint8Array(16));\n const cekInfo = encodeUtf8(\"Content-Encoding: aes128gcm\\0\");\n const nonceInfo = encodeUtf8(\"Content-Encoding: nonce\\0\");\n const cek = await hkdf(salt, ikm, cekInfo, 16);\n const nonce = await hkdf(salt, ikm, nonceInfo, 12);\n\n // RFC 8188 final-record padding delimiter\n const padded = concatBytes(plaintext, new Uint8Array([0x02]));\n\n const aesKey = await crypto.subtle.importKey(\"raw\", toArrayBuffer(cek), \"AES-GCM\", false, [\n \"encrypt\",\n ]);\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv: toArrayBuffer(nonce) },\n aesKey,\n toArrayBuffer(padded),\n ),\n );\n\n // salt || rs || idlen || keyid || ciphertext\n return concatBytes(\n salt,\n uint32Be(RECORD_SIZE),\n new Uint8Array([asPublicRaw.length]),\n asPublicRaw,\n ciphertext,\n );\n}\n\nasync function signVapidJwt(\n endpoint: string,\n subject: string,\n vapidPublicKey: string,\n vapidPrivateKey: string,\n): Promise<string> {\n const publicKey = decodeBase64Url(vapidPublicKey);\n const privateKey = decodeBase64Url(vapidPrivateKey);\n const jwk = ecKeyPairToJwk(publicKey, privateKey);\n\n const key = await crypto.subtle.importKey(\n \"jwk\",\n jwk,\n { name: \"ECDSA\", namedCurve: \"P-256\" },\n false,\n [\"sign\"],\n );\n\n const origin = new URL(endpoint).origin;\n const header = { typ: \"JWT\", alg: \"ES256\" };\n const payload = {\n aud: origin,\n exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,\n sub: subject,\n };\n\n const encodedHeader = encodeBase64Url(encodeUtf8(JSON.stringify(header)));\n const encodedPayload = encodeBase64Url(encodeUtf8(JSON.stringify(payload)));\n const signingInput = `${encodedHeader}.${encodedPayload}`;\n\n const signature = new Uint8Array(\n await crypto.subtle.sign(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n key,\n toArrayBuffer(encodeUtf8(signingInput)),\n ),\n );\n\n return `${signingInput}.${encodeBase64Url(signature)}`;\n}\n\n/**\n * Web Push transport — VAPID auth + RFC 8291 payload encryption.\n *\n * Endpoint URLs are validated against an allowlist before fetch to mitigate SSRF.\n * Redirects are not followed.\n */\nexport class WebPushTransport implements PushTransport {\n readonly provider = \"webpush\";\n\n private readonly vapidPublicKey: string;\n private readonly vapidPrivateKey: string;\n private readonly subject: string;\n private readonly allowedEndpointHosts: readonly string[];\n\n /** Creates a Web Push transport with VAPID credentials. */\n constructor(config: WebPushConfig) {\n assertVapidSubject(config.subject);\n this.vapidPublicKey = config.vapidPublicKey;\n this.vapidPrivateKey = config.vapidPrivateKey;\n this.subject = config.subject;\n this.allowedEndpointHosts = config.allowedEndpointHosts ?? [];\n }\n\n /** Encrypts and POSTs a notification to the subscription endpoint. */\n async send(options: PushOptions): Promise<PushSendResult> {\n if (!isWebPushOptions(options)) {\n throw new WebPushError(\n \"WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens\",\n 400,\n { hint: \"fcm\" },\n );\n }\n\n try {\n assertSafePushEndpoint(options.subscription.endpoint, this.allowedEndpointHosts);\n } catch (error) {\n let endpointHost: string | undefined;\n try {\n endpointHost = new URL(options.subscription.endpoint).hostname;\n } catch {\n endpointHost = undefined;\n }\n throw new WebPushError(\n error instanceof Error ? error.message : \"Unsafe push subscription endpoint\",\n 400,\n { endpointHost },\n );\n }\n\n const notification = {\n title: options.title,\n body: options.body,\n ...(options.data !== undefined ? { data: options.data } : {}),\n ...(options.icon !== undefined ? { icon: options.icon } : {}),\n };\n const plaintext = encodeUtf8(JSON.stringify(notification));\n const encrypted = await encryptAes128Gcm(options.subscription, plaintext);\n\n if (encrypted.length > MAX_ENCRYPTED_BODY_BYTES) {\n throw new WebPushError(\n `Encrypted push payload exceeds ${MAX_ENCRYPTED_BODY_BYTES} bytes (RFC 8291)`,\n 413,\n { size: encrypted.length },\n );\n }\n\n const jwt = await signVapidJwt(\n options.subscription.endpoint,\n this.subject,\n this.vapidPublicKey,\n this.vapidPrivateKey,\n );\n\n const ttl = options.ttl ?? DEFAULT_TTL_SECONDS;\n const response = await fetch(options.subscription.endpoint, {\n method: \"POST\",\n redirect: \"manual\",\n headers: {\n Authorization: `vapid t=${jwt}, k=${this.vapidPublicKey}`,\n \"Content-Encoding\": \"aes128gcm\",\n TTL: String(ttl),\n \"Content-Type\": \"application/octet-stream\",\n },\n body: toArrayBuffer(encrypted),\n });\n\n // Opaque redirect responses (3xx) must not be followed — SSRF mitigation.\n if (response.status >= 300 && response.status < 400) {\n throw new WebPushError(\n `Push service returned redirect ${response.status}; redirects are not followed`,\n response.status,\n { location: response.headers.get(\"location\") },\n );\n }\n\n if (!response.ok) {\n const bodyText = await response.text().catch(() => \"\");\n throw new WebPushError(\n bodyText || `Web Push request failed with status ${response.status}`,\n response.status,\n bodyText || { status: response.status },\n );\n }\n\n return {\n messageId: options.messageId ?? crypto.randomUUID(),\n status: \"accepted\",\n response: String(response.status),\n provider: \"webpush\",\n };\n }\n\n /** Lightweight VAPID credential shape check. */\n async verify(): Promise<VerifyResult> {\n try {\n const pub = decodeBase64Url(this.vapidPublicKey);\n const priv = decodeBase64Url(this.vapidPrivateKey);\n const ok = pub.length === 65 && priv.length === 32 && this.subject.length > 0;\n return {\n ok,\n provider: \"webpush\",\n message: ok ? \"VAPID credentials present\" : \"Invalid VAPID key lengths\",\n };\n } catch (error) {\n return {\n ok: false,\n provider: \"webpush\",\n message: error instanceof Error ? error.message : \"Invalid VAPID credentials\",\n };\n }\n }\n}\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "DFA1A46FC61CEB2264756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "4593CBC2A87BBFF364756E2164756E21",
9
9
  "names": []
10
10
  }