sently 0.4.6 → 0.5.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 (108) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +180 -43
  3. package/dist/adapters/bun.js +2 -183
  4. package/dist/adapters/bun.js.map +2 -2
  5. package/dist/adapters/cf.js +2 -77
  6. package/dist/adapters/cf.js.map +2 -2
  7. package/dist/adapters/deno.js +2 -72
  8. package/dist/adapters/deno.js.map +2 -2
  9. package/dist/adapters/node.js +2 -180
  10. package/dist/adapters/node.js.map +2 -2
  11. package/dist/auth/oauth2.js +2 -13
  12. package/dist/auth/oauth2.js.map +1 -1
  13. package/dist/chunk-32dj0v9g.js +5 -0
  14. package/dist/{chunk-794hc3m4.js.map → chunk-32dj0v9g.js.map} +2 -2
  15. package/dist/chunk-cvmk9h1r.js +4 -0
  16. package/dist/chunk-cvmk9h1r.js.map +10 -0
  17. package/dist/chunk-j5m37k1c.js +4 -0
  18. package/dist/{chunk-ym3zzv8b.js.map → chunk-j5m37k1c.js.map} +2 -2
  19. package/dist/chunk-kdqxhybf.js +4 -0
  20. package/dist/chunk-kdqxhybf.js.map +10 -0
  21. package/dist/chunk-m2njnxre.js +13 -0
  22. package/dist/{chunk-tymfm441.js.map → chunk-m2njnxre.js.map} +2 -2
  23. package/dist/chunk-nhec1319.js +5 -0
  24. package/dist/chunk-nhec1319.js.map +10 -0
  25. package/dist/chunk-nj3wmg69.js +3 -0
  26. package/dist/{chunk-7fqv71z1.js.map → chunk-nj3wmg69.js.map} +2 -2
  27. package/dist/chunk-ntqmn6pe.js +4 -0
  28. package/dist/chunk-ntqmn6pe.js.map +10 -0
  29. package/dist/chunk-pjzsvsm6.js +4 -0
  30. package/dist/chunk-pjzsvsm6.js.map +11 -0
  31. package/dist/chunk-rhade81v.js +4 -0
  32. package/dist/{chunk-v0bahtg2.js.map → chunk-rhade81v.js.map} +1 -1
  33. package/dist/chunk-zwm5bfa4.js +4 -0
  34. package/dist/chunk-zwm5bfa4.js.map +10 -0
  35. package/dist/core/idempotency-key.d.ts +6 -0
  36. package/dist/core/rate-limiter.d.ts +23 -0
  37. package/dist/core/smtp.js +2 -31
  38. package/dist/core/smtp.js.map +1 -1
  39. package/dist/core/types.d.ts +42 -0
  40. package/dist/detect.d.ts +2 -0
  41. package/dist/detect.js +2 -180
  42. package/dist/detect.js.map +4 -5
  43. package/dist/dkim.d.ts +21 -0
  44. package/dist/dkim.js +9 -0
  45. package/dist/dkim.js.map +10 -0
  46. package/dist/idempotency.d.ts +46 -0
  47. package/dist/idempotency.js +3 -0
  48. package/dist/idempotency.js.map +10 -0
  49. package/dist/index.d.ts +106 -16
  50. package/dist/index.js +115 -14
  51. package/dist/mailer.d.ts +17 -0
  52. package/dist/mailer.js +3 -0
  53. package/dist/mailer.js.map +9 -0
  54. package/dist/plugins/react.d.ts +35 -0
  55. package/dist/plugins/react.js +3 -0
  56. package/dist/plugins/react.js.map +10 -0
  57. package/dist/plugins/template.js +2 -28
  58. package/dist/plugins/template.js.map +2 -2
  59. package/dist/pool/pool.d.ts +1 -23
  60. package/dist/pool/pool.js +2 -16
  61. package/dist/pool/pool.js.map +5 -3
  62. package/dist/react.d.ts +20 -0
  63. package/dist/react.js +3 -0
  64. package/dist/react.js.map +9 -0
  65. package/dist/transports/brevo.js +2 -115
  66. package/dist/transports/brevo.js.map +2 -2
  67. package/dist/transports/mailgun.js +2 -119
  68. package/dist/transports/mailgun.js.map +2 -2
  69. package/dist/transports/postmark.js +2 -113
  70. package/dist/transports/postmark.js.map +2 -2
  71. package/dist/transports/preview.js +2 -72
  72. package/dist/transports/preview.js.map +2 -2
  73. package/dist/transports/resend.d.ts +16 -0
  74. package/dist/transports/resend.js +2 -109
  75. package/dist/transports/resend.js.map +3 -3
  76. package/dist/transports/retry.d.ts +2 -0
  77. package/dist/transports/retry.js +2 -78
  78. package/dist/transports/retry.js.map +3 -3
  79. package/dist/transports/sendgrid.d.ts +8 -0
  80. package/dist/transports/sendgrid.js +2 -132
  81. package/dist/transports/sendgrid.js.map +3 -3
  82. package/dist/transports/ses.js +5 -251
  83. package/dist/transports/ses.js.map +2 -2
  84. package/dist/transports/smtp.js +2 -26
  85. package/dist/transports/smtp.js.map +1 -1
  86. package/dist/webhooks/brevo.d.ts +3 -0
  87. package/dist/webhooks/mailgun.d.ts +13 -0
  88. package/dist/webhooks/postmark.d.ts +3 -0
  89. package/dist/webhooks/resend.d.ts +12 -0
  90. package/dist/webhooks/sendgrid.d.ts +3 -0
  91. package/dist/webhooks/ses.d.ts +6 -0
  92. package/dist/webhooks/timing-safe-equal.d.ts +9 -0
  93. package/dist/webhooks/types.d.ts +32 -0
  94. package/dist/webhooks.d.ts +26 -0
  95. package/dist/webhooks.js +3 -0
  96. package/dist/webhooks.js.map +9 -0
  97. package/package.json +45 -6
  98. package/dist/chunk-794hc3m4.js +0 -105
  99. package/dist/chunk-7fqv71z1.js +0 -251
  100. package/dist/chunk-f4c9ttmr.js +0 -154
  101. package/dist/chunk-f4c9ttmr.js.map +0 -11
  102. package/dist/chunk-mp5c9bfd.js +0 -270
  103. package/dist/chunk-mp5c9bfd.js.map +0 -11
  104. package/dist/chunk-tymfm441.js +0 -405
  105. package/dist/chunk-v0bahtg2.js +0 -6
  106. package/dist/chunk-x3szga4k.js +0 -367
  107. package/dist/chunk-x3szga4k.js.map +0 -11
  108. package/dist/chunk-ym3zzv8b.js +0 -74
package/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.5.1] — 2026-05-31
6
+
7
+ ### Breaking
8
+
9
+ - **Main barrel** — `reactPlugin` and `ReactMailOptions` are no longer exported from
10
+ `"sently"`. Import them from `"sently/react"` instead.
11
+
12
+ ### Fixed
13
+
14
+ - **Webhook signatures** — `verifyResendSignature` and `verifyMailgunSignature` now
15
+ compare decoded signature bytes with constant-time `timingSafeEqual` (Web Crypto only)
16
+ - **SES webhooks** — SNS `Message` field accepted when already parsed as an object
17
+ - **Batch sendBulk** — per-chunk failure isolation, per-message partial batch errors
18
+ (`SendResult.batchError`), and rate limiting between batch HTTP requests (default 2/s)
19
+
20
+ ### Changed
21
+
22
+ - `RateLimiter` extracted to `src/core/rate-limiter.ts` (shared by pool and mailer)
23
+ - `Transport.batchMax` on `ResendTransport`; `BulkSendOptions.rateDelta` / `rateLimit`
24
+ - Bundle size budgets updated for expanded mailer and Resend transport
25
+
26
+ ## [0.5.0] — 2026-05-31
27
+
28
+ ### Added
29
+
30
+ - **`sently/react`** — React Email integration via `reactPlugin()`; optional peers
31
+ `react` and `@react-email/render`; `options.react` on `MailOptions`
32
+ - **`sently/idempotency`** — `IdempotencyTransport` decorator with
33
+ `MemoryIdempotencyStore`; dedupe on retry/replay; Resend native
34
+ `Idempotency-Key` header support
35
+ - **Native batch in `sendBulk`** — optional `Transport.sendBatch()` on Resend
36
+ (POST `/emails/batch`, `RESEND_BATCH_MAX = 100`) and SendGrid (multi-
37
+ personalization); attachment messages fall back to single-send
38
+ - **`stopOnError`** option on `BulkSendOptions`
39
+ - **`sently/webhooks`** — normalized `EmailEvent` parsers for Resend, SendGrid,
40
+ Postmark, Mailgun, SES (SNS envelope + double-encoded Message), and Brevo;
41
+ optional Mailgun HMAC and Resend Svix signature verification helpers
42
+ - **`deduped`** flag on `SendResult` for idempotency cache hits
43
+
44
+ ### Changed
45
+
46
+ - `sendBulk` uses native batch endpoints when the transport implements
47
+ `sendBatch`; concurrent per-message fallback unchanged for other transports
48
+ - SendGrid single-send puts `subject` in personalization (batch-compatible)
49
+ - Bundle size budgets adjusted for expanded `sently/mailer` and main entry
50
+
51
+ ## [0.4.7] — 2026-05-30
52
+
53
+ ### Added
54
+
55
+ - **`sently/mailer` entry** — transport-only `createMailer` without SMTP code in the
56
+ bundle (~4.3 KB with HTTP transports vs ~14 KB from the main entry)
57
+ - **`sently/dkim` entry** — optional DKIM signing; MIME lazy-loads it only when
58
+ `dkim` config is passed (~1.7 KB)
59
+ - **`TransportMailerOptions`** type for the `sently/mailer` entry
60
+ - **Bundle size CI gate** — `bun run check:size` enforces gzip budgets in
61
+ `tools/bundle-size-budgets.json`
62
+ - **`bun run measure:size`** / **`measure:size:md`** — categorized bundle reports
63
+ for docs and CI
64
+ - **Deno and Cloudflare adapter smoke tests** in CI
65
+
66
+ ### Changed
67
+
68
+ - Lazy-load SMTP transport and pool from `createMailer` when using SMTP config
69
+ - Extract `MailerImpl` to `src/mailer.ts`; full `createMailer` in `detect.ts` delegates
70
+ to it for custom transports
71
+ - **`dist/index.js` generated from `src/index.ts`** via `scripts/generate-index-js.ts`
72
+ (no hand-maintained export list)
73
+ - **Minified `dist/` output** in production builds
74
+ - **Pinned devDependency versions** (Biome 2.4.16, TypeScript 6.0.3, `@types/node`
75
+ 25.9.1, MCP SDK 1.29.0)
76
+
77
+ ### Documentation
78
+
79
+ - README **Bundle Size** section — import-path guide, common stacks, per-subpath
80
+ tables, and HTTP stack breakdown
81
+ - Inline JSDoc on `src/index.ts` barrel re-exports for JSR symbol documentation
82
+ - Corrected bundle size figures (minified + gzip, one decimal KB)
83
+
3
84
  ## [0.4.6] — 2026-05-30
4
85
 
5
86
  ### Documentation
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # sently
2
2
 
3
3
  > Nodemailer hasn't been updated in years, doesn't run on Bun or Deno, and ships at 220KB.
4
- > sently is the modern replacement — same familiar API, runs everywhere, tree-shakes to ~6KB.
4
+ > sently is the modern replacement — same familiar API, runs everywhere, HTTP stacks from ~5.4 KB via `sently/mailer`.
5
5
 
6
6
  ```bash
7
7
  bun add sently
@@ -20,7 +20,7 @@ bun add sently
20
20
 
21
21
  | Feature | Nodemailer | sently |
22
22
  |---------|-----------|--------|
23
- | Bundle size | ~220 KB | ~6 KB core |
23
+ | Bundle size | ~220 KB always | ~5.4 KB HTTP · ~14 KB SMTP |
24
24
  | Runtimes | Node.js only | Node, Bun, Deno, CF Workers |
25
25
  | Module format | CommonJS | ESM only |
26
26
  | Dependencies | 3 | 0 |
@@ -31,7 +31,10 @@ bun add sently
31
31
  | Retry transport | ✗ | ✓ |
32
32
  | Preview transport | ✗ | ✓ |
33
33
  | Template engine | ✗ | ✓ |
34
- | `sendBulk()` | ✗ | ✓ |
34
+ | `sendBulk()` | ✗ | ✓ (native batch on Resend/SendGrid) |
35
+ | React Email | ✗ via plugin | ✓ `sently/react` |
36
+ | Idempotency keys | ✗ | ✓ `sently/idempotency` |
37
+ | Webhook parsing | ✗ | ✓ `sently/webhooks` |
35
38
  | TypeScript | via `@types/nodemailer` | ✓ built-in |
36
39
  | Last release | 2021 | 2026 |
37
40
 
@@ -40,7 +43,8 @@ bun add sently
40
43
  ## The 30-second tour
41
44
 
42
45
  ```typescript
43
- import { createMailer, type MailOptions } from "sently";
46
+ import type { MailOptions } from "sently";
47
+ import { createMailer } from "sently/mailer";
44
48
  import { ResendTransport } from "sently/transports/resend";
45
49
  import { PreviewTransport } from "sently/transports/preview";
46
50
 
@@ -93,7 +97,8 @@ bunx jsr add @alialnaghmoush/sently
93
97
  ```
94
98
 
95
99
  ```typescript
96
- import { createMailer } from "sently";
100
+ import { createMailer } from "sently/mailer"; // HTTP transports
101
+ import { createMailer as createSMTPMailer } from "sently"; // SMTP host/port config
97
102
  ```
98
103
 
99
104
  ---
@@ -125,7 +130,7 @@ await mailer.close();
125
130
  ### Resend HTTP transport (Vercel Edge compatible)
126
131
 
127
132
  ```typescript
128
- import { createMailer } from "sently";
133
+ import { createMailer } from "sently/mailer";
129
134
  import { ResendTransport } from "sently/transports/resend";
130
135
 
131
136
  const mailer = await createMailer({
@@ -197,7 +202,7 @@ const mailer = await createMailer({
197
202
  ### SMTP
198
203
 
199
204
  ```typescript
200
- import { createMailer } from "sently";
205
+ import { createMailer } from "sently/mailer";
201
206
  import { SMTPTransport } from "sently/transports/smtp";
202
207
  import { NodeAdapter } from "sently/adapters/node";
203
208
 
@@ -212,6 +217,8 @@ const mailer = await createMailer({ transport });
212
217
  await mailer.verify(); // test connection + auth
213
218
  ```
214
219
 
220
+ Use `sently/mailer` instead of `sently` when passing `{ transport }` — keeps HTTP-only bundles ~10 KB smaller.
221
+
215
222
  **AUTH methods:** XOAUTH2, CRAM-MD5, LOGIN, and PLAIN (auto-negotiated from EHLO unless `auth.type` is set).
216
223
 
217
224
  **`requireTLS` (default `true` when `auth` is set):** sently refuses to send credentials over an unencrypted connection. If the link is not secured by direct TLS (`secure: true`) or a successful `STARTTLS` upgrade, authentication throws an `SMTPError` instead of leaking credentials — this defends against STARTTLS-stripping MITM attacks. Set `requireTLS: false` only if you fully trust the network (not recommended).
@@ -230,6 +237,8 @@ const mailer = await createMailer({
230
237
  });
231
238
  ```
232
239
 
240
+ Pass `dkim` on SMTP config or use `signDKIM` from `sently/dkim` directly. MIME lazy-loads DKIM only when the option is set.
241
+
233
242
  #### Gmail OAuth2 (XOAUTH2)
234
243
 
235
244
  ```typescript
@@ -282,6 +291,7 @@ const pool = new SMTPPool({
282
291
 
283
292
  | Transport | Import path | Required config |
284
293
  |-----------|-------------|-----------------|
294
+ | Mailer wrapper | `sently/mailer` | — (use with any transport below) |
285
295
  | Resend | `sently/transports/resend` | `apiKey` |
286
296
  | SendGrid | `sently/transports/sendgrid` | `apiKey` |
287
297
  | Postmark | `sently/transports/postmark` | `serverToken` |
@@ -299,7 +309,7 @@ Write emails to disk during local development instead of sending them:
299
309
 
300
310
  ```typescript
301
311
  import { PreviewTransport } from "sently/transports/preview";
302
- import { createMailer } from "sently";
312
+ import { createMailer } from "sently/mailer";
303
313
 
304
314
  const mailer = await createMailer({
305
315
  transport: new PreviewTransport({
@@ -324,7 +334,7 @@ Wrap any transport with automatic retries and configurable backoff:
324
334
  ```typescript
325
335
  import { RetryTransport } from "sently/transports/retry";
326
336
  import { ResendTransport } from "sently/transports/resend";
327
- import { createMailer } from "sently";
337
+ import { createMailer } from "sently/mailer";
328
338
 
329
339
  const transport = new RetryTransport(
330
340
  new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
@@ -336,7 +346,7 @@ const mailer = await createMailer({ transport });
336
346
 
337
347
  ### sendBulk()
338
348
 
339
- Send multiple messages with concurrency control and per-message callbacks:
349
+ Send multiple messages with concurrency control and per-message callbacks. When the transport implements `sendBatch` (Resend, SendGrid), attachment-free messages are sent via native batch endpoints; messages with attachments fall back to individual sends.
340
350
 
341
351
  ```typescript
342
352
  const result = await mailer.sendBulk(
@@ -346,6 +356,7 @@ const result = await mailer.sendBulk(
346
356
  ],
347
357
  {
348
358
  concurrency: 2,
359
+ stopOnError: false, // halt remaining sends after first failure when true
349
360
  onSuccess: (_msg, index) => console.log(`Sent #${index}`),
350
361
  onError: (_msg, index, err) => console.error(`Failed #${index}`, err),
351
362
  },
@@ -354,6 +365,33 @@ const result = await mailer.sendBulk(
354
365
  console.log(result.sent, result.failed);
355
366
  ```
356
367
 
368
+ Resend batches up to `RESEND_BATCH_MAX` (100) messages per request — export from `sently/transports/resend`.
369
+
370
+ ### IdempotencyTransport
371
+
372
+ Prevent duplicate sends on retry or replay. Wrap **outside** `RetryTransport` so all retry attempts share one key:
373
+
374
+ ```typescript
375
+ import { IdempotencyTransport } from "sently/idempotency";
376
+ import { RetryTransport } from "sently/transports/retry";
377
+ import { ResendTransport } from "sently/transports/resend";
378
+
379
+ const transport = new IdempotencyTransport(
380
+ new RetryTransport(new ResendTransport({ apiKey: process.env.RESEND_API_KEY! })),
381
+ { ttlMs: 86_400_000 },
382
+ );
383
+
384
+ await mailer.send({
385
+ from: "you@example.com",
386
+ to: "user@example.com",
387
+ subject: "Hello",
388
+ text: "Hi",
389
+ idempotencyKey: "order-123-email", // or derive from messageId
390
+ });
391
+ ```
392
+
393
+ Resend sends the `Idempotency-Key` HTTP header natively. Supply a shared store (Redis, Dragonfly) in production — `MemoryIdempotencyStore` is for single-process use.
394
+
357
395
  ---
358
396
 
359
397
  ## Plugin system
@@ -380,6 +418,9 @@ const mailer = await createMailer({
380
418
  Works with SMTP config or custom transports:
381
419
 
382
420
  ```typescript
421
+ import { createMailer } from "sently/mailer";
422
+ import { ResendTransport } from "sently/transports/resend";
423
+
383
424
  const mailer = await createMailer({
384
425
  transport: new ResendTransport({ apiKey: "re_..." }),
385
426
  plugins: [addFooter],
@@ -392,7 +433,7 @@ Render HTML from named templates with zero dependencies:
392
433
 
393
434
  ```typescript
394
435
  import { templatePlugin, simpleEngine } from "sently/plugins/template";
395
- import { createMailer } from "sently";
436
+ import { createMailer } from "sently/mailer";
396
437
  import { ResendTransport } from "sently/transports/resend";
397
438
 
398
439
  const mailer = await createMailer({
@@ -418,6 +459,51 @@ await mailer.send({
418
459
 
419
460
  Use a custom engine by passing any `(template, data) => string` function to `templatePlugin`.
420
461
 
462
+ ### React Email plugin
463
+
464
+ Render React Email components to HTML and plain text (optional peers: `react`, `@react-email/render`):
465
+
466
+ ```typescript
467
+ import { reactPlugin } from "sently/react";
468
+ import { createMailer } from "sently/mailer";
469
+ import { ResendTransport } from "sently/transports/resend";
470
+ import { WelcomeEmail } from "./emails/welcome";
471
+
472
+ const mailer = await createMailer({
473
+ transport: new ResendTransport({ apiKey: "re_..." }),
474
+ plugins: [reactPlugin()],
475
+ });
476
+
477
+ await mailer.send({
478
+ from: "onboarding@yourdomain.com",
479
+ to: "user@example.com",
480
+ subject: "Welcome",
481
+ react: WelcomeEmail({ name: "Ali" }),
482
+ });
483
+ ```
484
+
485
+ Explicit `html` / `text` always win over rendered output.
486
+
487
+ ### Webhook parsing
488
+
489
+ Normalize provider webhooks into a single event type — no server framework required:
490
+
491
+ ```typescript
492
+ import { parseResendWebhook, parseSesWebhook } from "sently/webhooks";
493
+
494
+ // Resend (Svix-style payload)
495
+ const events = parseResendWebhook(await request.json());
496
+
497
+ // AWS SES via SNS (handles SubscriptionConfirmation + double-encoded Message)
498
+ const sesEvents = parseSesWebhook(await request.json());
499
+
500
+ for (const event of events) {
501
+ console.log(event.type, event.messageId, event.recipient);
502
+ }
503
+ ```
504
+
505
+ Parsers: Resend, SendGrid, Postmark, Mailgun, SES, Brevo. Optional HMAC verification helpers for Mailgun and Resend (`verifyMailgunSignature`, `verifyResendSignature`).
506
+
421
507
  ---
422
508
 
423
509
  ## MailOptions Reference
@@ -435,6 +521,8 @@ Use a custom engine by passing any `(template, data) => string` function to `tem
435
521
  | `attachments` | `Attachment[]` | — | File attachments |
436
522
  | `headers` | `Record<string, string>` | — | Custom headers |
437
523
  | `messageId` | `string` | auto | Message-ID header |
524
+ | `idempotencyKey` | `string` | — | Dedupe key for retry/replay (Resend sends as `Idempotency-Key` header) |
525
+ | `react` | `unknown` | — | React element — use with `reactPlugin()` from `sently/react` |
438
526
  | `date` | `Date` | now | Date header |
439
527
  | `priority` | `'high' \| 'normal' \| 'low'` | — | X-Priority / Importance |
440
528
  | `encoding` | `'utf-8' \| 'ascii'` | `'utf-8'` | Character encoding hint |
@@ -550,59 +638,108 @@ MIME attachment filenames and custom attachment headers are likewise sanitized a
550
638
 
551
639
  ---
552
640
 
553
- ## Tree-Shaking
641
+ ## Bundle Size
642
+
643
+ All sizes are **minified + gzip**, measured by bundling each import path in isolation (`bun run measure:size`). CI enforces budgets on key entries (`bun run check:size`). Node built-ins and `cloudflare:sockets` are external — same as in your app bundle.
644
+
645
+ **Nodemailer ships ~220 KB** whether you use SMTP or an HTTP plugin. sently tree-shakes per subpath.
554
646
 
555
- Each import path is a separate build entry point:
647
+ ### Choosing an import path
556
648
 
649
+ | You send via… | Import | Why |
650
+ |---------------|--------|-----|
651
+ | Resend, SendGrid, Postmark, etc. | `sently/mailer` + `sently/transports/<provider>` | **~5.4 KB** — no SMTP code in the bundle |
652
+ | SMTP relay (`host` / `port`) | `sently` | **~14 KB** — includes MIME + SMTP stack |
653
+ | Raw transport, no plugins | `sently/transports/<provider>` only | **~4 KB** — skip `createMailer` wrapper |
654
+ | DKIM signing | `sently/dkim` or `dkim` option on send | **~2 KB** add-on, lazy-loaded by MIME |
655
+ | React Email | `sently/react` + peers | **~0.3 KB** plugin shell (+ `@react-email/render` peer) |
656
+ | Webhook parsing | `sently/webhooks` | **~0.5 KB** |
657
+ | Idempotency | `sently/idempotency` | **~1.2 KB** |
658
+
659
+ ```ts
660
+ // Recommended — HTTP API (~5.4 KB bundled)
661
+ import { createMailer } from "sently/mailer";
662
+ import { ResendTransport } from "sently/transports/resend";
663
+
664
+ // Avoid for HTTP-only apps — pulls SMTP into flat bundles (~15 KB)
665
+ import { createMailer } from "sently";
557
666
  ```
558
- import { createMailer } from "sently"
559
- + import { ResendTransport } from "sently/transports/resend"
560
- → Bundle: core/mime (~8KB) + core/address (~2KB) + transports/resend (~2KB) ≈ ~12KB gzip
561
667
 
562
- vs. full Nodemailer: ~220KB
668
+ ### Common stacks
669
+
670
+ | Use case | What you import | ~gzip |
671
+ |----------|-----------------|-------|
672
+ | HTTP — Resend | `sently/mailer` + `transports/resend` | **5.4 KB** |
673
+ | HTTP — SendGrid | `sently/mailer` + `transports/sendgrid` | **5.2 KB** |
674
+ | HTTP — transport only | `transports/resend` (call `.send()` directly) | **4.5 KB** |
675
+ | SMTP relay | `sently` + `{ host, port, auth }` | **14.2 KB** |
676
+ | SMTP + explicit adapter | `sently` + `adapters/node` | **14.2 KB** |
677
+ | Main entry + HTTP ⚠️ | `sently` + `transports/resend` | **15.0 KB** |
678
+
679
+ Adapters are **auto-selected at runtime** for SMTP unless you pass `adapter` explicitly. Only the adapter for your runtime is fetched (dynamic import).
680
+
681
+ ### Core entries
682
+
683
+ | Export | ~gzip | Notes |
684
+ |--------|-------|-------|
685
+ | `sently/mailer` | 1.4 KB | `createMailer({ transport })` — plugins, `sendBulk`, rate-limited batch |
686
+ | `sently` | 14.1 KB | Full `createMailer` — SMTP config + lazy SMTP chunks |
687
+ | `sently/dkim` | 1.7 KB | `signDKIM`, `importPrivateKey` — loaded when `dkim` option is set |
688
+ | `sently/react` | 0.3 KB | `reactPlugin()` — excludes `@react-email/render` peer |
689
+ | `sently/idempotency` | 1.2 KB | `IdempotencyTransport`, `MemoryIdempotencyStore` |
690
+ | `sently/webhooks` | 0.5 KB | Provider webhook parsers + optional signature verification |
691
+
692
+ ### Transports
693
+
694
+ | Export | ~gzip | Protocol |
695
+ |--------|-------|----------|
696
+ | `sently/transports/resend` | 4.5 KB | HTTP (+ batch endpoint, rate limit) |
697
+ | `sently/transports/sendgrid` | 4.0 KB | HTTP (+ batch personalizations) |
698
+ | `sently/transports/postmark` | 3.9 KB | HTTP |
699
+ | `sently/transports/mailgun` | 4.0 KB | HTTP |
700
+ | `sently/transports/brevo` | 3.8 KB | HTTP |
701
+ | `sently/transports/ses` | 7.3 KB | HTTP (SigV4) |
702
+ | `sently/transports/smtp` | 10.0 KB | SMTP + MIME |
703
+ | `sently/transports/preview` | 6.4 KB | Dev disk preview |
704
+
705
+ HTTP transports share MIME/address parsing (~3.8 KB). SES is larger due to SigV4 signing.
706
+
707
+ ### Adapters (SMTP socket layer)
708
+
709
+ | Export | ~gzip | Runtime |
710
+ |--------|-------|---------|
711
+ | `sently/adapters/node` | 1.2 KB | Node.js |
712
+ | `sently/adapters/bun` | 1.2 KB | Bun |
713
+ | `sently/adapters/deno` | 0.5 KB | Deno |
714
+ | `sently/adapters/cf` | 0.6 KB | Cloudflare Workers |
715
+
716
+ ### What's inside an HTTP stack (~5.4 KB)
717
+
718
+ ```
719
+ sently/mailer 1.4 KB createMailer wrapper, plugins, sendBulk + rate limit
720
+ transports/resend 4.5 KB fetch client + MIME/address parsing + batch
721
+ ─────
722
+ total 5.4 KB vs Nodemailer ~220 KB
563
723
  ```
564
724
 
565
- Only code you import is bundled. Adapters and transports you never import are never included.
725
+ Regenerate tables after changes: `bun run measure:size` (full report) or `bun tools/measure-bundle-size.ts --markdown`.
566
726
 
567
727
  ---
568
728
 
569
729
  ## Migrating from Nodemailer
570
730
 
571
731
  | Nodemailer | sently |
572
- |------------|-------|
732
+ |------------|--------|
573
733
  | `nodemailer.createTransport({...})` | `await createMailer({...})` |
574
734
  | `transporter.sendMail(options)` | `mailer.send(options)` |
575
735
  | `transporter.verify()` | `mailer.verify()` |
576
736
  | `options.attachments[].path` | Same (Node/Bun/Deno); use `content` on edge |
577
- | `import nodemailer from 'nodemailer'` | `import { createMailer } from 'sently'` |
737
+ | `import nodemailer from 'nodemailer'` | `import { createMailer } from 'sently/mailer'` (HTTP) or `'sently'` (SMTP) |
578
738
  | CommonJS | ESM only |
579
739
  | Node.js only | Node, Bun, Deno, CF Workers |
580
740
 
581
741
  ---
582
742
 
583
- ## Bundle Size
584
-
585
- Approximate gzip sizes per subpath export:
586
-
587
- | Export | ~gzip |
588
- |--------|-------|
589
- | `sently` | ~6 KB |
590
- | `sently/transports/smtp` | ~10 KB |
591
- | `sently/transports/resend` | ~2 KB |
592
- | `sently/transports/sendgrid` | ~2 KB |
593
- | `sently/transports/postmark` | ~2 KB |
594
- | `sently/transports/mailgun` | ~3 KB |
595
- | `sently/transports/ses` | ~5 KB |
596
- | `sently/transports/brevo` | ~2 KB |
597
- | `sently/adapters/node` | ~3 KB |
598
- | `sently/adapters/bun` | ~3 KB |
599
- | `sently/adapters/deno` | ~2 KB |
600
- | `sently/adapters/cf` | ~2 KB |
601
-
602
- > **Example:** Resend only = core (~6 KB) + transport (~2 KB) = **~8 KB total**. Nodemailer ships 220 KB regardless of which transport you use.
603
-
604
- ---
605
-
606
743
  ## TypeScript
607
744
 
608
745
  ```typescript
@@ -1,184 +1,3 @@
1
- import"../chunk-v0bahtg2.js";
1
+ import"../chunk-rhade81v.js";import W from"node:net";import Q from"node:tls";function V(f){if(f.rejectUnauthorized===!1)console.warn("[sently] TLS certificate verification is disabled. Never use rejectUnauthorized: false in production.")}class X{socket=null;_secure;_connected=!1;connectionTimeout;tlsOptions;constructor(f={}){if(typeof Bun>"u")throw Error("BunAdapter requires the Bun runtime");this._secure=f.secure??!1,this.connectionTimeout=f.connectionTimeout??30000,this.tlsOptions=f.tls??{}}get secure(){return this._secure}get connected(){return this._connected}async connect(f,B){if(this._secure)await this.connectTls(f,B);else await this.connectPlain(f,B);this._connected=!0}async startTLS(f){if(!this.socket||this._secure)throw Error("Cannot STARTTLS: no plain socket available");let B=this.socket,y={...this.tlsOptions,...f};V(y),await new Promise((F,A)=>{let G=Q.connect({socket:B,servername:y.servername,rejectUnauthorized:y.rejectUnauthorized??!0,minVersion:y.minVersion});G.once("secureConnect",()=>{this.socket=G,this._secure=!0,F()}),G.once("error",A)})}async write(f){if(!this.socket)throw Error("Socket not connected");await new Promise((B,y)=>{this.socket?.write(Buffer.from(f),(F)=>{if(F)y(F);else B()})})}async*read(){if(!this.socket)throw Error("Socket not connected");let f=this.socket,B=[],y=null,F=!1,A=null,G=(H)=>{let I=new Uint8Array(H);if(y)y({value:I,done:!1}),y=null;else B.push(I)},J=(H)=>{if(A=H,F=!0,y)y({value:void 0,done:!0}),y=null},K=()=>{if(F=!0,y)y({value:void 0,done:!0}),y=null};f.on("data",G),f.on("error",J),f.on("close",K);try{while(!F||B.length>0){if(A)throw A;if(B.length>0){yield B.shift();continue}if(F)break;let H=await new Promise((I)=>{y=I});if(H.done)break;yield H.value}}finally{f.off("data",G),f.off("error",J),f.off("close",K)}}async close(){if(!this.socket)return;await new Promise((f)=>{this.socket?.end(()=>f())}),this.socket=null,this._connected=!1}connectPlain(f,B){return new Promise((y,F)=>{let A=W.connect({host:f,port:B},()=>y());A.setTimeout(this.connectionTimeout),A.once("timeout",()=>{A.destroy(),F(Error("Connection timeout"))}),A.once("error",F),this.socket=A})}connectTls(f,B){return V(this.tlsOptions),new Promise((y,F)=>{let A=Q.connect({host:f,port:B,servername:this.tlsOptions.servername??f,rejectUnauthorized:this.tlsOptions.rejectUnauthorized??!0,minVersion:this.tlsOptions.minVersion},()=>y());A.setTimeout(this.connectionTimeout),A.once("timeout",()=>{A.destroy(),F(Error("Connection timeout"))}),A.once("error",F),this.socket=A})}}export{X as BunAdapter};
2
2
 
3
- // src/adapters/bun.ts
4
- import net from "node:net";
5
- import tls from "node:tls";
6
- function warnRejectUnauthorizedDisabled(tls2) {
7
- if (tls2.rejectUnauthorized === false) {
8
- console.warn("[sently] TLS certificate verification is disabled. " + "Never use rejectUnauthorized: false in production.");
9
- }
10
- }
11
-
12
- class BunAdapter {
13
- socket = null;
14
- _secure;
15
- _connected = false;
16
- connectionTimeout;
17
- tlsOptions;
18
- constructor(options = {}) {
19
- if (typeof Bun === "undefined") {
20
- throw new Error("BunAdapter requires the Bun runtime");
21
- }
22
- this._secure = options.secure ?? false;
23
- this.connectionTimeout = options.connectionTimeout ?? 30000;
24
- this.tlsOptions = options.tls ?? {};
25
- }
26
- get secure() {
27
- return this._secure;
28
- }
29
- get connected() {
30
- return this._connected;
31
- }
32
- async connect(host, port) {
33
- if (this._secure) {
34
- await this.connectTls(host, port);
35
- } else {
36
- await this.connectPlain(host, port);
37
- }
38
- this._connected = true;
39
- }
40
- async startTLS(options) {
41
- if (!this.socket || this._secure) {
42
- throw new Error("Cannot STARTTLS: no plain socket available");
43
- }
44
- const plain = this.socket;
45
- const merged = { ...this.tlsOptions, ...options };
46
- warnRejectUnauthorizedDisabled(merged);
47
- await new Promise((resolve, reject) => {
48
- const tlsSocket = tls.connect({
49
- socket: plain,
50
- servername: merged.servername,
51
- rejectUnauthorized: merged.rejectUnauthorized ?? true,
52
- minVersion: merged.minVersion
53
- });
54
- tlsSocket.once("secureConnect", () => {
55
- this.socket = tlsSocket;
56
- this._secure = true;
57
- resolve();
58
- });
59
- tlsSocket.once("error", reject);
60
- });
61
- }
62
- async write(data) {
63
- if (!this.socket) {
64
- throw new Error("Socket not connected");
65
- }
66
- await new Promise((resolve, reject) => {
67
- this.socket?.write(Buffer.from(data), (err) => {
68
- if (err) {
69
- reject(err);
70
- } else {
71
- resolve();
72
- }
73
- });
74
- });
75
- }
76
- async* read() {
77
- if (!this.socket) {
78
- throw new Error("Socket not connected");
79
- }
80
- const socket = this.socket;
81
- const queue = [];
82
- let resolveNext = null;
83
- let done = false;
84
- let error = null;
85
- const onData = (chunk) => {
86
- const data = new Uint8Array(chunk);
87
- if (resolveNext) {
88
- resolveNext({ value: data, done: false });
89
- resolveNext = null;
90
- } else {
91
- queue.push(data);
92
- }
93
- };
94
- const onError = (err) => {
95
- error = err;
96
- done = true;
97
- if (resolveNext) {
98
- resolveNext({ value: undefined, done: true });
99
- resolveNext = null;
100
- }
101
- };
102
- const onClose = () => {
103
- done = true;
104
- if (resolveNext) {
105
- resolveNext({ value: undefined, done: true });
106
- resolveNext = null;
107
- }
108
- };
109
- socket.on("data", onData);
110
- socket.on("error", onError);
111
- socket.on("close", onClose);
112
- try {
113
- while (!done || queue.length > 0) {
114
- if (error) {
115
- throw error;
116
- }
117
- if (queue.length > 0) {
118
- yield queue.shift();
119
- continue;
120
- }
121
- if (done) {
122
- break;
123
- }
124
- const chunk = await new Promise((resolve) => {
125
- resolveNext = resolve;
126
- });
127
- if (chunk.done) {
128
- break;
129
- }
130
- yield chunk.value;
131
- }
132
- } finally {
133
- socket.off("data", onData);
134
- socket.off("error", onError);
135
- socket.off("close", onClose);
136
- }
137
- }
138
- async close() {
139
- if (!this.socket) {
140
- return;
141
- }
142
- await new Promise((resolve) => {
143
- this.socket?.end(() => resolve());
144
- });
145
- this.socket = null;
146
- this._connected = false;
147
- }
148
- connectPlain(host, port) {
149
- return new Promise((resolve, reject) => {
150
- const socket = net.connect({ host, port }, () => resolve());
151
- socket.setTimeout(this.connectionTimeout);
152
- socket.once("timeout", () => {
153
- socket.destroy();
154
- reject(new Error("Connection timeout"));
155
- });
156
- socket.once("error", reject);
157
- this.socket = socket;
158
- });
159
- }
160
- connectTls(host, port) {
161
- warnRejectUnauthorizedDisabled(this.tlsOptions);
162
- return new Promise((resolve, reject) => {
163
- const socket = tls.connect({
164
- host,
165
- port,
166
- servername: this.tlsOptions.servername ?? host,
167
- rejectUnauthorized: this.tlsOptions.rejectUnauthorized ?? true,
168
- minVersion: this.tlsOptions.minVersion
169
- }, () => resolve());
170
- socket.setTimeout(this.connectionTimeout);
171
- socket.once("timeout", () => {
172
- socket.destroy();
173
- reject(new Error("Connection timeout"));
174
- });
175
- socket.once("error", reject);
176
- this.socket = socket;
177
- });
178
- }
179
- }
180
- export {
181
- BunAdapter
182
- };
183
-
184
- //# debugId=55F886DB16D90CED64756E2164756E21
3
+ //# debugId=94AAFD676203266764756E2164756E21