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.
- package/CHANGELOG.md +81 -0
- package/README.md +180 -43
- package/dist/adapters/bun.js +2 -183
- package/dist/adapters/bun.js.map +2 -2
- package/dist/adapters/cf.js +2 -77
- package/dist/adapters/cf.js.map +2 -2
- package/dist/adapters/deno.js +2 -72
- package/dist/adapters/deno.js.map +2 -2
- package/dist/adapters/node.js +2 -180
- package/dist/adapters/node.js.map +2 -2
- package/dist/auth/oauth2.js +2 -13
- package/dist/auth/oauth2.js.map +1 -1
- package/dist/chunk-32dj0v9g.js +5 -0
- package/dist/{chunk-794hc3m4.js.map → chunk-32dj0v9g.js.map} +2 -2
- package/dist/chunk-cvmk9h1r.js +4 -0
- package/dist/chunk-cvmk9h1r.js.map +10 -0
- package/dist/chunk-j5m37k1c.js +4 -0
- package/dist/{chunk-ym3zzv8b.js.map → chunk-j5m37k1c.js.map} +2 -2
- package/dist/chunk-kdqxhybf.js +4 -0
- package/dist/chunk-kdqxhybf.js.map +10 -0
- package/dist/chunk-m2njnxre.js +13 -0
- package/dist/{chunk-tymfm441.js.map → chunk-m2njnxre.js.map} +2 -2
- package/dist/chunk-nhec1319.js +5 -0
- package/dist/chunk-nhec1319.js.map +10 -0
- package/dist/chunk-nj3wmg69.js +3 -0
- package/dist/{chunk-7fqv71z1.js.map → chunk-nj3wmg69.js.map} +2 -2
- package/dist/chunk-ntqmn6pe.js +4 -0
- package/dist/chunk-ntqmn6pe.js.map +10 -0
- package/dist/chunk-pjzsvsm6.js +4 -0
- package/dist/chunk-pjzsvsm6.js.map +11 -0
- package/dist/chunk-rhade81v.js +4 -0
- package/dist/{chunk-v0bahtg2.js.map → chunk-rhade81v.js.map} +1 -1
- package/dist/chunk-zwm5bfa4.js +4 -0
- package/dist/chunk-zwm5bfa4.js.map +10 -0
- package/dist/core/idempotency-key.d.ts +6 -0
- package/dist/core/rate-limiter.d.ts +23 -0
- package/dist/core/smtp.js +2 -31
- package/dist/core/smtp.js.map +1 -1
- package/dist/core/types.d.ts +42 -0
- package/dist/detect.d.ts +2 -0
- package/dist/detect.js +2 -180
- package/dist/detect.js.map +4 -5
- package/dist/dkim.d.ts +21 -0
- package/dist/dkim.js +9 -0
- package/dist/dkim.js.map +10 -0
- package/dist/idempotency.d.ts +46 -0
- package/dist/idempotency.js +3 -0
- package/dist/idempotency.js.map +10 -0
- package/dist/index.d.ts +106 -16
- package/dist/index.js +115 -14
- package/dist/mailer.d.ts +17 -0
- package/dist/mailer.js +3 -0
- package/dist/mailer.js.map +9 -0
- package/dist/plugins/react.d.ts +35 -0
- package/dist/plugins/react.js +3 -0
- package/dist/plugins/react.js.map +10 -0
- package/dist/plugins/template.js +2 -28
- package/dist/plugins/template.js.map +2 -2
- package/dist/pool/pool.d.ts +1 -23
- package/dist/pool/pool.js +2 -16
- package/dist/pool/pool.js.map +5 -3
- package/dist/react.d.ts +20 -0
- package/dist/react.js +3 -0
- package/dist/react.js.map +9 -0
- package/dist/transports/brevo.js +2 -115
- package/dist/transports/brevo.js.map +2 -2
- package/dist/transports/mailgun.js +2 -119
- package/dist/transports/mailgun.js.map +2 -2
- package/dist/transports/postmark.js +2 -113
- package/dist/transports/postmark.js.map +2 -2
- package/dist/transports/preview.js +2 -72
- package/dist/transports/preview.js.map +2 -2
- package/dist/transports/resend.d.ts +16 -0
- package/dist/transports/resend.js +2 -109
- package/dist/transports/resend.js.map +3 -3
- package/dist/transports/retry.d.ts +2 -0
- package/dist/transports/retry.js +2 -78
- package/dist/transports/retry.js.map +3 -3
- package/dist/transports/sendgrid.d.ts +8 -0
- package/dist/transports/sendgrid.js +2 -132
- package/dist/transports/sendgrid.js.map +3 -3
- package/dist/transports/ses.js +5 -251
- package/dist/transports/ses.js.map +2 -2
- package/dist/transports/smtp.js +2 -26
- package/dist/transports/smtp.js.map +1 -1
- package/dist/webhooks/brevo.d.ts +3 -0
- package/dist/webhooks/mailgun.d.ts +13 -0
- package/dist/webhooks/postmark.d.ts +3 -0
- package/dist/webhooks/resend.d.ts +12 -0
- package/dist/webhooks/sendgrid.d.ts +3 -0
- package/dist/webhooks/ses.d.ts +6 -0
- package/dist/webhooks/timing-safe-equal.d.ts +9 -0
- package/dist/webhooks/types.d.ts +32 -0
- package/dist/webhooks.d.ts +26 -0
- package/dist/webhooks.js +3 -0
- package/dist/webhooks.js.map +9 -0
- package/package.json +45 -6
- package/dist/chunk-794hc3m4.js +0 -105
- package/dist/chunk-7fqv71z1.js +0 -251
- package/dist/chunk-f4c9ttmr.js +0 -154
- package/dist/chunk-f4c9ttmr.js.map +0 -11
- package/dist/chunk-mp5c9bfd.js +0 -270
- package/dist/chunk-mp5c9bfd.js.map +0 -11
- package/dist/chunk-tymfm441.js +0 -405
- package/dist/chunk-v0bahtg2.js +0 -6
- package/dist/chunk-x3szga4k.js +0 -367
- package/dist/chunk-x3szga4k.js.map +0 -11
- 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,
|
|
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 | ~
|
|
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 {
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/adapters/bun.js
CHANGED
|
@@ -1,184 +1,3 @@
|
|
|
1
|
-
import"../chunk-
|
|
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
|
-
|
|
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
|