sently 0.8.0 → 0.9.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 (249) hide show
  1. package/AGENTS.md +33 -0
  2. package/CHANGELOG.md +82 -41
  3. package/README.md +89 -960
  4. package/dist/adapters/bun.js +1 -1
  5. package/dist/adapters/cf.js +2 -2
  6. package/dist/adapters/cf.js.map +2 -2
  7. package/dist/adapters/deno.js +1 -1
  8. package/dist/adapters/node.js +1 -1
  9. package/dist/auth/oauth2.js +2 -2
  10. package/dist/auth/oauth2.js.map +1 -1
  11. package/dist/chunk-0gqy32pe.js +4 -0
  12. package/dist/{chunk-n5dan5bz.js.map → chunk-0gqy32pe.js.map} +2 -2
  13. package/dist/{chunk-z62q7kqr.js → chunk-0qxws3kj.js} +2 -2
  14. package/dist/{chunk-z62q7kqr.js.map → chunk-0qxws3kj.js.map} +1 -1
  15. package/dist/chunk-29z7fkzy.js +4 -0
  16. package/dist/{chunk-wsymfbkn.js.map → chunk-29z7fkzy.js.map} +2 -2
  17. package/dist/chunk-5915vbcj.js +4 -0
  18. package/dist/chunk-5915vbcj.js.map +10 -0
  19. package/dist/{chunk-nbmq8ejv.js → chunk-6npp3x3c.js} +2 -2
  20. package/dist/{chunk-nbmq8ejv.js.map → chunk-6npp3x3c.js.map} +1 -1
  21. package/dist/chunk-7gy2q9sh.js +4 -0
  22. package/dist/chunk-7gy2q9sh.js.map +10 -0
  23. package/dist/{chunk-zst8an2p.js → chunk-8dty8c4p.js} +2 -2
  24. package/dist/{chunk-zst8an2p.js.map → chunk-8dty8c4p.js.map} +2 -2
  25. package/dist/{chunk-f3dz43cy.js → chunk-8njyga62.js} +2 -2
  26. package/dist/{chunk-f3dz43cy.js.map → chunk-8njyga62.js.map} +1 -1
  27. package/dist/chunk-93vqxxj2.js +4 -0
  28. package/dist/{chunk-qh90yr6d.js.map → chunk-93vqxxj2.js.map} +2 -2
  29. package/dist/chunk-b602dhck.js +4 -0
  30. package/dist/{chunk-wbvhaqcp.js.map → chunk-b602dhck.js.map} +2 -2
  31. package/dist/{chunk-9zkv5azm.js → chunk-gp4fwrs8.js} +2 -2
  32. package/dist/{chunk-9zkv5azm.js.map → chunk-gp4fwrs8.js.map} +1 -1
  33. package/dist/chunk-n0qeyzqm.js +13 -0
  34. package/dist/{chunk-8phpsns1.js.map → chunk-n0qeyzqm.js.map} +2 -2
  35. package/dist/chunk-qtkd5bak.js +4 -0
  36. package/dist/chunk-qtkd5bak.js.map +11 -0
  37. package/dist/chunk-skqhj0rm.js +4 -0
  38. package/dist/{chunk-vhxzhx8b.js.map → chunk-skqhj0rm.js.map} +2 -2
  39. package/dist/{chunk-6bkj4y4r.js → chunk-tamww15j.js} +2 -2
  40. package/dist/{chunk-6bkj4y4r.js.map → chunk-tamww15j.js.map} +1 -1
  41. package/dist/chunk-vjpds2pf.js +3 -0
  42. package/dist/{chunk-m07smapm.js.map → chunk-vjpds2pf.js.map} +2 -2
  43. package/dist/chunk-yp311efr.js +4 -0
  44. package/dist/chunk-yp311efr.js.map +10 -0
  45. package/dist/chunk-ywq6s10g.js +5 -0
  46. package/dist/{chunk-5scdgffb.js.map → chunk-ywq6s10g.js.map} +3 -3
  47. package/dist/core/base64.d.ts +10 -0
  48. package/dist/core/errors.js +2 -2
  49. package/dist/core/errors.js.map +1 -1
  50. package/dist/core/hooks.d.ts +14 -0
  51. package/dist/core/plugin.d.ts +7 -7
  52. package/dist/core/push-endpoint.d.ts +29 -0
  53. package/dist/core/push-types.d.ts +98 -0
  54. package/dist/core/sms-types.d.ts +76 -0
  55. package/dist/core/smtp.js +2 -2
  56. package/dist/core/smtp.js.map +1 -1
  57. package/dist/core/whatsapp-types.d.ts +116 -0
  58. package/dist/detect.js +2 -2
  59. package/dist/detect.js.map +1 -1
  60. package/dist/dkim.js +2 -2
  61. package/dist/dkim.js.map +2 -2
  62. package/dist/idempotency.js +2 -2
  63. package/dist/idempotency.js.map +1 -1
  64. package/dist/index.d.ts +5 -32
  65. package/dist/index.js +0 -35
  66. package/dist/mailer.js +2 -2
  67. package/dist/mailer.js.map +1 -1
  68. package/dist/observability/console.js +1 -1
  69. package/dist/plugins/react.js +2 -2
  70. package/dist/plugins/react.js.map +2 -2
  71. package/dist/plugins/template.js +1 -1
  72. package/dist/pool/pool.js +2 -2
  73. package/dist/pool/pool.js.map +1 -1
  74. package/dist/push.d.ts +26 -0
  75. package/dist/push.js +3 -0
  76. package/dist/push.js.map +10 -0
  77. package/dist/sms.d.ts +26 -0
  78. package/dist/sms.js +3 -0
  79. package/dist/sms.js.map +10 -0
  80. package/dist/smtp-mailer.js +2 -2
  81. package/dist/smtp-mailer.js.map +2 -2
  82. package/dist/transports/brevo.js +2 -2
  83. package/dist/transports/brevo.js.map +2 -2
  84. package/dist/transports/cloudflare-email.js +2 -2
  85. package/dist/transports/cloudflare-email.js.map +2 -2
  86. package/dist/transports/fallback.js +2 -2
  87. package/dist/transports/fallback.js.map +1 -1
  88. package/dist/transports/loops.js +2 -2
  89. package/dist/transports/loops.js.map +2 -2
  90. package/dist/transports/mailersend.js +2 -2
  91. package/dist/transports/mailersend.js.map +2 -2
  92. package/dist/transports/mailgun.js +2 -2
  93. package/dist/transports/mailgun.js.map +2 -2
  94. package/dist/transports/mailtrap.js +2 -2
  95. package/dist/transports/mailtrap.js.map +2 -2
  96. package/dist/transports/msegat.d.ts +132 -0
  97. package/dist/transports/msegat.js +3 -0
  98. package/dist/transports/msegat.js.map +10 -0
  99. package/dist/transports/plunk.js +2 -2
  100. package/dist/transports/plunk.js.map +2 -2
  101. package/dist/transports/postmark.js +2 -2
  102. package/dist/transports/postmark.js.map +2 -2
  103. package/dist/transports/preview.js +2 -2
  104. package/dist/transports/preview.js.map +2 -2
  105. package/dist/transports/resend.js +2 -2
  106. package/dist/transports/resend.js.map +2 -2
  107. package/dist/transports/retry.js +2 -2
  108. package/dist/transports/retry.js.map +2 -2
  109. package/dist/transports/sendgrid.js +2 -2
  110. package/dist/transports/sendgrid.js.map +2 -2
  111. package/dist/transports/ses.js +2 -2
  112. package/dist/transports/ses.js.map +2 -2
  113. package/dist/transports/smtp.js +2 -2
  114. package/dist/transports/smtp.js.map +1 -1
  115. package/dist/transports/sndr.d.ts +41 -0
  116. package/dist/transports/sndr.js +3 -0
  117. package/dist/transports/sndr.js.map +10 -0
  118. package/dist/transports/sparkpost.js +2 -2
  119. package/dist/transports/sparkpost.js.map +2 -2
  120. package/dist/transports/taqnyat-mail.d.ts +33 -0
  121. package/dist/transports/taqnyat-mail.js +3 -0
  122. package/dist/transports/taqnyat-mail.js.map +10 -0
  123. package/dist/transports/taqnyat-phone.d.ts +4 -0
  124. package/dist/transports/taqnyat-sms.d.ts +131 -0
  125. package/dist/transports/taqnyat-sms.js +3 -0
  126. package/dist/transports/taqnyat-sms.js.map +10 -0
  127. package/dist/transports/taqnyat-whatsapp.d.ts +51 -0
  128. package/dist/transports/taqnyat-whatsapp.js +3 -0
  129. package/dist/transports/taqnyat-whatsapp.js.map +10 -0
  130. package/dist/transports/twilio-sms.d.ts +66 -0
  131. package/dist/transports/twilio-sms.js +3 -0
  132. package/dist/transports/twilio-sms.js.map +10 -0
  133. package/dist/transports/webpush.d.ts +46 -0
  134. package/dist/transports/webpush.js +3 -0
  135. package/dist/transports/webpush.js.map +10 -0
  136. package/dist/transports/weighted-fallback.js +2 -2
  137. package/dist/transports/weighted-fallback.js.map +1 -1
  138. package/dist/transports/whatsapp-cloud.d.ts +56 -0
  139. package/dist/transports/whatsapp-cloud.js +3 -0
  140. package/dist/transports/whatsapp-cloud.js.map +10 -0
  141. package/dist/webhooks/brevo.js +1 -1
  142. package/dist/webhooks/mailgun.js +1 -1
  143. package/dist/webhooks/postmark.js +1 -1
  144. package/dist/webhooks/resend.js +1 -1
  145. package/dist/webhooks/sendgrid.js +1 -1
  146. package/dist/webhooks/ses.js +1 -1
  147. package/dist/webhooks/sndr.d.ts +16 -0
  148. package/dist/webhooks/sndr.js +3 -0
  149. package/dist/webhooks/sndr.js.map +10 -0
  150. package/dist/webhooks/timing-safe-equal.js +1 -1
  151. package/dist/webhooks.d.ts +9 -2
  152. package/dist/webhooks.js +4 -0
  153. package/dist/whatsapp.d.ts +26 -0
  154. package/dist/whatsapp.js +3 -0
  155. package/dist/whatsapp.js.map +10 -0
  156. package/package.json +85 -4
  157. package/site/README.md +26 -0
  158. package/site/content/docs/ai/index.mdx +13 -0
  159. package/site/content/docs/ai/llms-txt.mdx +12 -0
  160. package/site/content/docs/ai/mcp.mdx +16 -0
  161. package/site/content/docs/ai/meta.json +9 -0
  162. package/site/content/docs/channels/email.mdx +100 -0
  163. package/site/content/docs/channels/hooks.mdx +87 -0
  164. package/site/content/docs/channels/index.mdx +81 -0
  165. package/site/content/docs/channels/meta.json +12 -0
  166. package/site/content/docs/channels/push.mdx +92 -0
  167. package/site/content/docs/channels/sms.mdx +88 -0
  168. package/site/content/docs/channels/whatsapp.mdx +95 -0
  169. package/site/content/docs/decorators/fallback.mdx +34 -0
  170. package/site/content/docs/decorators/idempotency.mdx +34 -0
  171. package/site/content/docs/decorators/index.mdx +50 -0
  172. package/site/content/docs/decorators/meta.json +12 -0
  173. package/site/content/docs/decorators/preview.mdx +34 -0
  174. package/site/content/docs/decorators/retry.mdx +34 -0
  175. package/site/content/docs/decorators/weighted-fallback.mdx +34 -0
  176. package/site/content/docs/get-started/entrypoints.mdx +114 -0
  177. package/site/content/docs/get-started/index.mdx +14 -0
  178. package/site/content/docs/get-started/installation.mdx +78 -0
  179. package/site/content/docs/get-started/introduction.mdx +77 -0
  180. package/site/content/docs/get-started/meta.json +12 -0
  181. package/site/content/docs/get-started/migrate-nodemailer.mdx +23 -0
  182. package/site/content/docs/get-started/runtimes.mdx +17 -0
  183. package/site/content/docs/guides/adapters.mdx +26 -0
  184. package/site/content/docs/guides/attachments.mdx +19 -0
  185. package/site/content/docs/guides/bundle-size.mdx +78 -0
  186. package/site/content/docs/guides/dkim.mdx +26 -0
  187. package/site/content/docs/guides/index.mdx +15 -0
  188. package/site/content/docs/guides/meta.json +21 -0
  189. package/site/content/docs/guides/oauth2.mdx +23 -0
  190. package/site/content/docs/guides/observability.mdx +16 -0
  191. package/site/content/docs/guides/plugins-template.mdx +22 -0
  192. package/site/content/docs/guides/pool.mdx +24 -0
  193. package/site/content/docs/guides/react-email.mdx +20 -0
  194. package/site/content/docs/guides/security.mdx +17 -0
  195. package/site/content/docs/guides/send-bulk.mdx +18 -0
  196. package/site/content/docs/guides/vendor-extras-otp.mdx +18 -0
  197. package/site/content/docs/guides/webhooks.mdx +120 -0
  198. package/site/content/docs/guides/webpush-interop.mdx +18 -0
  199. package/site/content/docs/index.mdx +15 -0
  200. package/site/content/docs/meta.json +15 -0
  201. package/site/content/docs/quick-start/email.mdx +40 -0
  202. package/site/content/docs/quick-start/index.mdx +15 -0
  203. package/site/content/docs/quick-start/meta.json +5 -0
  204. package/site/content/docs/quick-start/push.mdx +36 -0
  205. package/site/content/docs/quick-start/sms.mdx +36 -0
  206. package/site/content/docs/quick-start/whatsapp.mdx +38 -0
  207. package/site/content/docs/reference/errors.mdx +22 -0
  208. package/site/content/docs/reference/exports.mdx +83 -0
  209. package/site/content/docs/reference/index.mdx +15 -0
  210. package/site/content/docs/reference/mail-options.mdx +22 -0
  211. package/site/content/docs/reference/meta.json +15 -0
  212. package/site/content/docs/reference/push-options.mdx +20 -0
  213. package/site/content/docs/reference/sms-options.mdx +17 -0
  214. package/site/content/docs/reference/transport-contracts.mdx +17 -0
  215. package/site/content/docs/reference/webhook-events.mdx +70 -0
  216. package/site/content/docs/reference/whatsapp-options.mdx +15 -0
  217. package/site/content/docs/transports/brevo.mdx +40 -0
  218. package/site/content/docs/transports/cloudflare-email.mdx +40 -0
  219. package/site/content/docs/transports/index.mdx +105 -0
  220. package/site/content/docs/transports/loops.mdx +41 -0
  221. package/site/content/docs/transports/mailersend.mdx +40 -0
  222. package/site/content/docs/transports/mailgun.mdx +42 -0
  223. package/site/content/docs/transports/mailtrap.mdx +42 -0
  224. package/site/content/docs/transports/meta.json +32 -0
  225. package/site/content/docs/transports/msegat.mdx +42 -0
  226. package/site/content/docs/transports/plunk.mdx +40 -0
  227. package/site/content/docs/transports/postmark.mdx +40 -0
  228. package/site/content/docs/transports/resend.mdx +43 -0
  229. package/site/content/docs/transports/sendgrid.mdx +40 -0
  230. package/site/content/docs/transports/ses.mdx +44 -0
  231. package/site/content/docs/transports/smtp.mdx +47 -0
  232. package/site/content/docs/transports/sndr.mdx +144 -0
  233. package/site/content/docs/transports/sparkpost.mdx +41 -0
  234. package/site/content/docs/transports/taqnyat-mail.mdx +41 -0
  235. package/site/content/docs/transports/taqnyat-sms.mdx +41 -0
  236. package/site/content/docs/transports/taqnyat-whatsapp.mdx +40 -0
  237. package/site/content/docs/transports/twilio-sms.mdx +128 -0
  238. package/site/content/docs/transports/webpush.mdx +43 -0
  239. package/site/content/docs/transports/whatsapp-cloud.mdx +42 -0
  240. package/dist/chunk-5scdgffb.js +0 -5
  241. package/dist/chunk-8phpsns1.js +0 -13
  242. package/dist/chunk-m07smapm.js +0 -3
  243. package/dist/chunk-n5dan5bz.js +0 -4
  244. package/dist/chunk-qh90yr6d.js +0 -4
  245. package/dist/chunk-vhxzhx8b.js +0 -4
  246. package/dist/chunk-wbvhaqcp.js +0 -4
  247. package/dist/chunk-wsymfbkn.js +0 -4
  248. package/dist/chunk-yr56b0ts.js +0 -4
  249. package/dist/chunk-yr56b0ts.js.map +0 -11
package/README.md CHANGED
@@ -1,991 +1,120 @@
1
- # sently
2
-
3
- > Nodemailer is Node.js–only and ships the full mail stack on every import (~58 KB gzip for [v8.0.10](https://bundlephobia.com/package/nodemailer@8.0.10)).
4
- > sently runs on Bun, Deno, and Cloudflare Workers — same familiar API, HTTP stacks from ~6.1 KB via `sently/mailer`.
1
+ <p align="center">
2
+ <picture>
3
+ <source
4
+ media="(prefers-color-scheme: dark)"
5
+ srcset="https://shieldcn.dev/header/glow.svg?title=sently&subtitle=One+API.+Four+channels.+Every+runtime.&logo=https://raw.githubusercontent.com/alialnaghmoush/sently/main/site/public/sentlyIconLogo-w.svg&theme=zinc&size=banner&mode=dark&font=geist"
6
+ />
7
+ <img
8
+ alt="sently — One API. Four channels. Every runtime."
9
+ src="https://shieldcn.dev/header/glow.svg?title=sently&subtitle=One+API.+Four+channels.+Every+runtime.&logo=https://raw.githubusercontent.com/alialnaghmoush/sently/main/site/public/sentlyIconLogo-k.svg&theme=zinc&size=banner&mode=light&font=geist"
10
+ width="750"
11
+ />
12
+ </picture>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="https://www.npmjs.com/package/sently"><img alt="npm" src="https://shieldcn.dev/npm/sently.svg?size=sm" /></a>
17
+ <a href="https://jsr.io/@alialnaghmoush/sently"><img alt="JSR" src="https://shieldcn.dev/jsr/@alialnaghmoush/sently.svg?size=sm&variant=outline" /></a>
18
+ <a href="https://bundlephobia.com/package/sently"><img alt="bundle" src="https://shieldcn.dev/bundlephobia/minzip/sently.svg?size=sm&variant=secondary" /></a>
19
+ <a href="https://opensource.org/licenses/MIT"><img alt="MIT" src="https://shieldcn.dev/npm/license/sently.svg?size=sm" /></a>
20
+ <a href="https://bun.sh"><img alt="Bun" src="https://shieldcn.dev/badge/Bun-ready-000000.svg?logo=bun&size=sm&variant=outline" /></a>
21
+ <a href="https://github.com/alialnaghmoush/sently/stargazers"><img alt="stars" src="https://shieldcn.dev/github/stars/alialnaghmoush/sently.svg?size=sm&variant=outline" /></a>
22
+ <a href="https://github.com/alialnaghmoush/sently/actions"><img alt="CI" src="https://shieldcn.dev/github/ci/alialnaghmoush/sently.svg?size=sm" /></a>
23
+ </p>
24
+
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>
27
+ </p>
28
+
29
+ <p align="center">
30
+ <a href="https://sently.omqkhafi.dev"><strong>Docs</strong></a> ·
31
+ <a href="https://sently.omqkhafi.dev/docs"><strong>Handbook</strong></a> ·
32
+ <a href="https://sently.omqkhafi.dev/llms.txt"><code>llms.txt</code></a> ·
33
+ <a href="https://www.npmjs.com/package/sently"><code>sently</code></a>
34
+ </p>
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
+ ## Install
5
42
 
6
43
  ```bash
7
- bun add sently
44
+ bun add sently # npm / Bun / yarn / pnpm
45
+ bunx jsr add @alialnaghmoush/sently # JSR
8
46
  ```
9
47
 
10
- [![npm version](https://img.shields.io/npm/v/sently.svg)](https://www.npmjs.com/package/sently)
11
- [![JSR](https://jsr.io/badges/@alialnaghmoush/sently)](https://jsr.io/@alialnaghmoush/sently)
12
- [![bundle size](https://img.shields.io/bundlephobia/minzip/sently)](https://bundlephobia.com/package/sently)
13
- [![license](https://img.shields.io/npm/l/sently.svg)](LICENSE)
14
- [![tests](https://img.shields.io/badge/tests-passing-brightgreen)](#)
15
- [![GitHub](https://img.shields.io/github/stars/alialnaghmoush/sently?style=social&label=GitHub)](https://github.com/alialnaghmoush/sently)
16
-
17
- > **Pre-1.0 — API may change.** sently is pre-1.0 and the public API is still being refined ahead of a stable v1.0.0. Breaking changes can land in any 0.x release; review the [CHANGELOG](CHANGELOG.md) before upgrading. Pin an exact version (e.g. `"sently": "0.8.0"`) for production until v1.0.0.
18
-
19
- ## Index
20
-
21
- **Getting started**
22
-
23
- - [Why not Nodemailer?](#why-not-nodemailer)
24
- - [The 30-second tour](#the-30-second-tour)
25
- - [Installation](#installation)
26
- - [Quick Start](#quick-start)
27
- - [SMTP with auto-detected adapter](#smtp-with-auto-detected-adapter)
28
- - [Resend HTTP transport](#resend-http-transport-vercel-edge-compatible)
29
- - [Cloudflare Worker](#cloudflare-worker)
30
- - [Choosing an entrypoint](#choosing-an-entrypoint)
31
- - [Migrating from Nodemailer](#migrating-from-nodemailer)
32
-
33
- **Sending mail**
34
-
35
- - [Adapters](#adapters)
36
- - [Transports](#transports)
37
- - [SMTP](#smtp)
38
- - [HTTP APIs](#http-apis)
39
- - [FallbackTransport](#fallbacktransport)
40
- - [PreviewTransport](#previewtransport)
41
- - [RetryTransport](#retrytransport)
42
- - [sendBulk()](#sendbulk)
43
- - [Mailer lifecycle hooks](#mailer-lifecycle-hooks)
44
- - [IdempotencyTransport](#idempotencytransport)
45
- - [Plugin system](#plugin-system)
46
- - [TemplatePlugin](#templateplugin)
47
- - [React Email plugin](#react-email-plugin)
48
- - [Webhook parsing](#webhook-parsing)
49
-
50
- **Reference**
51
-
52
- - [MailOptions Reference](#mailoptions-reference)
53
- - [Attachments](#attachments)
54
- - [Error Handling](#error-handling)
55
- - [Security](#security)
56
- - [Bundle size](#bundle-size)
57
- - [TypeScript](#typescript)
58
- - [Links](#links)
59
- - [License](#license)
60
-
61
- ---
62
-
63
- ## Why not Nodemailer?
48
+ Optional peers (React Email only): `react`, `@react-email/render`.
64
49
 
65
- | Feature | Nodemailer | sently |
66
- |---------|-----------|--------|
67
- | Bundle size | ~58 KB gzip always ([v8.0.10](https://bundlephobia.com/package/nodemailer@8.0.10)) | ~6.1 KB HTTP · ~15 KB SMTP |
68
- | Runtimes | Node.js only | Node, Bun, Deno, CF Workers |
69
- | Module format | CommonJS | ESM only |
70
- | Dependencies | 0 | 0 |
71
- | DKIM signing | ✓ via `nodemailer-dkim` | ✓ built-in (Web Crypto) |
72
- | OAuth2 / XOAUTH2 | ✓ via plugin | ✓ built-in |
73
- | Connection pooling | ✓ | ✓ |
74
- | HTTP transports | ✓ via plugins | ✓ built-in (11 HTTP APIs + CF Email binding) |
75
- | Provider failover | ✗ | ✓ `FallbackTransport` + weighted routing |
76
- | Retry transport | ✗ | ✓ |
77
- | Preview transport | ✗ | ✓ |
78
- | Template engine | ✗ | ✓ |
79
- | `sendBulk()` | ✗ | ✓ (native batch on Resend/SendGrid) |
80
- | React Email | ✗ via plugin | ✓ `sently/react` |
81
- | Idempotency keys | ✗ | ✓ `sently/idempotency` |
82
- | Webhook parsing | ✗ | ✓ `sently/webhooks` |
83
- | TypeScript | via `@types/nodemailer` | ✓ built-in |
84
- | Last release | 2026 (8.0.x) | 2026 |
85
-
86
- ---
87
-
88
- ## The 30-second tour
50
+ ## Quick start
89
51
 
90
52
  ```typescript
91
- import type { MailOptions } from "sently";
92
53
  import { createMailer } from "sently/mailer";
93
54
  import { ResendTransport } from "sently/transports/resend";
94
- import { PreviewTransport } from "sently/transports/preview";
95
-
96
- const addFooter = (options: MailOptions): MailOptions => ({
97
- ...options,
98
- html: (options.html ?? "") + '<p style="color:#999">Unsubscribe</p>',
99
- });
100
55
 
101
- // Swap providers without changing send code
102
56
  const mailer = await createMailer({
103
57
  transport: new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
104
- plugins: [addFooter],
105
- });
106
-
107
- await mailer.send({
108
- from: "you@example.com",
109
- to: "recipient@example.com",
110
- subject: "Hello from sently",
111
- html: "<p>Hello!</p>",
112
- });
113
-
114
- // Bulk send with concurrency control
115
- await mailer.sendBulk(recipients, { concurrency: 5 });
116
-
117
- // Local dev — write to disk instead of sending
118
- const devMailer = await createMailer({
119
- transport: process.env.CI
120
- ? new ResendTransport({ apiKey: process.env.RESEND_API_KEY! })
121
- : new PreviewTransport({ outDir: ".emails", open: true }),
122
- });
123
- ```
124
-
125
- ---
126
-
127
- ## Installation
128
-
129
- **npm** ([sently](https://www.npmjs.com/package/sently)):
130
-
131
- ```bash
132
- bun add sently
133
- npm install sently
134
- pnpm add sently
135
- ```
136
-
137
- **JSR** ([@alialnaghmoush/sently](https://jsr.io/@alialnaghmoush/sently)) — Deno, Bun, and other JSR-aware runtimes:
138
-
139
- ```bash
140
- deno add jsr:@alialnaghmoush/sently
141
- bunx jsr add @alialnaghmoush/sently
142
- ```
143
-
144
- ```typescript
145
- import { createMailer } from "sently/mailer"; // HTTP stack ~6.1 KB with a transport
146
- import { createSMTPMailer } from "sently/smtp"; // SMTP relay ~15 KB
147
- // Or: import { createSMTPMailer } from "sently";
148
- ```
149
-
150
- ---
151
-
152
- ## Quick Start
153
-
154
- ### SMTP with auto-detected adapter
155
-
156
- ```typescript
157
- import { createSMTPMailer } from "sently/smtp";
158
-
159
- const mailer = await createSMTPMailer({
160
- host: "smtp.example.com",
161
- port: 587,
162
- auth: { user: "you@example.com", pass: "secret" },
163
58
  });
164
59
 
165
60
  await mailer.send({
166
- from: "you@example.com",
167
- to: "recipient@example.com",
168
- subject: "Hello from sently",
169
- text: "Plain text body",
170
- html: "<p>HTML body</p>",
171
- });
172
-
173
- await mailer.close();
174
- ```
175
-
176
- ### Resend HTTP transport (Vercel Edge compatible)
177
-
178
- ```typescript
179
- import { createMailer } from "sently/mailer";
180
- import { ResendTransport } from "sently/transports/resend";
181
-
182
- const mailer = await createMailer({
183
- transport: new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
184
- });
185
-
186
- await mailer.send({
187
- from: "onboarding@yourdomain.com",
188
- to: "recipient@example.com",
189
- subject: "Hello from the edge",
190
- html: "<p>Sent via Resend + sently</p>",
191
- });
192
- ```
193
-
194
- ### Cloudflare Worker
195
-
196
- **SMTP relay** (outbound TCP via `cloudflare:sockets`):
197
-
198
- ```typescript
199
- import { createSMTPMailer } from "sently/smtp";
200
- import { CloudflareAdapter } from "sently/adapters/cf";
201
-
202
- export default {
203
- async fetch() {
204
- const mailer = await createSMTPMailer({
205
- host: "smtp.example.com",
206
- port: 587,
207
- auth: { user: "relay@example.com", pass: "secret" },
208
- adapter: new CloudflareAdapter(),
209
- });
210
-
211
- await mailer.send({
212
- from: "relay@example.com",
213
- to: "user@example.com",
214
- subject: "From a Worker",
215
- text: "Hello from Cloudflare Workers",
216
- });
217
-
218
- return new Response("Sent");
219
- },
220
- };
221
- ```
222
-
223
- **Workers Email binding** (`[[send_email]]` in `wrangler.toml` — no fetch HTTP API):
224
-
225
- ```typescript
226
- import { createMailer } from "sently/mailer";
227
- import { CloudflareEmailTransport } from "sently/transports/cloudflare-email";
228
-
229
- export default {
230
- async fetch(_request, env) {
231
- const mailer = await createMailer({
232
- transport: new CloudflareEmailTransport({ sendEmail: env.SEND_EMAIL }),
233
- });
234
-
235
- await mailer.send({
236
- from: "noreply@yourdomain.com",
237
- to: "user@example.com",
238
- subject: "From a Worker",
239
- text: "Sent via send_email binding",
240
- });
241
-
242
- return new Response("Sent");
243
- },
244
- };
245
- ```
246
-
247
- ---
248
-
249
- ## Adapters
250
-
251
- | Runtime | Import | Notes |
252
- |---------|--------|-------|
253
- | Node.js (auto) | `createSMTPMailer` from `sently/smtp` | Auto-detected adapter |
254
- | Node.js (explicit) | `sently/adapters/node` → `NodeAdapter` | Reference implementation |
255
- | Bun (auto) | `createSMTPMailer` from `sently/smtp` | Auto-detected adapter |
256
- | Bun (explicit) | `sently/adapters/bun` → `BunAdapter` | Node compat layer |
257
- | Deno | `sently/adapters/deno` → `DenoAdapter` | Native `Deno.startTls` |
258
- | Cloudflare Workers | `sently/adapters/cf` → `CloudflareAdapter` | `cloudflare:sockets` |
259
-
260
- ```typescript
261
- import { createSMTPMailer } from "sently/smtp";
262
- import { NodeAdapter } from "sently/adapters/node";
263
-
264
- const mailer = await createSMTPMailer({
265
- host: "smtp.example.com",
266
- adapter: new NodeAdapter({ secure: false }),
267
- auth: { user: "you@example.com", pass: "secret" },
268
- });
269
- ```
270
-
271
- ---
272
-
273
- ## Transports
274
-
275
- ### SMTP
276
-
277
- ```typescript
278
- import { createMailer } from "sently/mailer";
279
- import { SMTPTransport } from "sently/transports/smtp";
280
- import { NodeAdapter } from "sently/adapters/node";
281
-
282
- const transport = new SMTPTransport({
283
- host: "smtp.example.com",
284
- port: 587,
285
- auth: { user: "you@example.com", pass: "secret" },
286
- adapter: new NodeAdapter(),
287
- });
288
-
289
- const mailer = await createMailer({ transport });
290
- await mailer.verify(); // test connection + auth
291
- ```
292
-
293
- For relay config (`host` / `port` / `auth`), prefer [`sently/smtp`](#smtp-with-auto-detected-adapter). Use `mailer` + `SMTPTransport` when you need an explicit adapter or transport-level options.
294
-
295
- **AUTH methods:** XOAUTH2, CRAM-MD5, LOGIN, and PLAIN (auto-negotiated from EHLO unless `auth.type` is set).
296
-
297
- **`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).
298
-
299
- #### DKIM signing
300
-
301
- ```typescript
302
- import { createSMTPMailer } from "sently/smtp";
303
-
304
- const mailer = await createSMTPMailer({
305
- host: "smtp.example.com",
306
- auth: { user: "you@example.com", pass: "secret" },
307
- dkim: {
308
- domainName: "example.com",
309
- keySelector: "2024",
310
- privateKey: await Bun.file("dkim-private.pem").text(),
311
- },
312
- });
313
- ```
314
-
315
- Pass `dkim` on SMTP config or use `signDKIM` from `sently/dkim` directly. MIME lazy-loads DKIM only when the option is set.
316
-
317
- #### Gmail OAuth2 (XOAUTH2)
318
-
319
- ```typescript
320
- import { createSMTPMailer } from "sently/smtp";
321
-
322
- const mailer = await createSMTPMailer({
323
- host: "smtp.gmail.com",
324
- port: 465,
325
- secure: true,
326
- auth: {
327
- type: "OAUTH2",
328
- user: "me@gmail.com",
329
- oauth2: {
330
- user: "me@gmail.com",
331
- clientId: process.env.GOOGLE_CLIENT_ID!,
332
- clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
333
- refreshToken: process.env.GOOGLE_REFRESH_TOKEN!,
334
- },
335
- },
336
- });
337
- ```
338
-
339
- #### Microsoft 365 OAuth2 (XOAUTH2)
340
-
341
- ```typescript
342
- import { MICROSOFT_TOKEN_URL } from "sently";
343
- import { createSMTPMailer } from "sently/smtp";
344
-
345
- const mailer = await createSMTPMailer({
346
- host: "smtp.office365.com",
347
- port: 587,
348
- auth: {
349
- type: "OAUTH2",
350
- user: "you@yourtenant.onmicrosoft.com",
351
- oauth2: {
352
- user: "you@yourtenant.onmicrosoft.com",
353
- clientId: process.env.AZURE_CLIENT_ID!,
354
- clientSecret: process.env.AZURE_CLIENT_SECRET!,
355
- refreshToken: process.env.AZURE_REFRESH_TOKEN!,
356
- tokenUrl: MICROSOFT_TOKEN_URL,
357
- },
358
- },
359
- });
360
- ```
361
-
362
- #### Connection pooling
363
-
364
- ```typescript
365
- import { createSMTPMailer } from "sently/smtp";
366
-
367
- const mailer = await createSMTPMailer({
368
- host: "smtp.example.com",
369
- pool: true,
370
- maxConnections: 5,
371
- maxMessages: 100,
372
- rateDelta: 10,
373
- rateLimit: 1000,
374
- auth: { user: "you@example.com", pass: "secret" },
375
- });
376
- ```
377
-
378
- Or use `SMTPPool` directly:
379
-
380
- ```typescript
381
- import { SMTPPool } from "sently/pool";
382
-
383
- const pool = new SMTPPool({
384
- host: "smtp.example.com",
385
- adapter: new NodeAdapter(),
386
- auth: { user: "you@example.com", pass: "secret" },
387
- });
388
- ```
389
-
390
- ### HTTP APIs
391
-
392
- | Transport | Import path | Required config |
393
- |-----------|-------------|-----------------|
394
- | Mailer wrapper | `sently/mailer` | — (use with any transport below) |
395
- | Resend | `sently/transports/resend` | `apiKey` |
396
- | SendGrid | `sently/transports/sendgrid` | `apiKey` |
397
- | Postmark | `sently/transports/postmark` | `serverToken` |
398
- | Mailgun | `sently/transports/mailgun` | `apiKey`, `domain` |
399
- | AWS SES | `sently/transports/ses` | `accessKeyId`, `secretAccessKey`, `region` |
400
- | Brevo | `sently/transports/brevo` | `apiKey` |
401
- | MailerSend | `sently/transports/mailersend` | `apiToken` |
402
- | Plunk | `sently/transports/plunk` | `apiKey` |
403
- | SparkPost | `sently/transports/sparkpost` | `apiKey`, `euRegion?` |
404
- | Mailtrap | `sently/transports/mailtrap` | `apiToken`, `sandbox?`, `inboxId?` |
405
- | Loops | `sently/transports/loops` | `apiKey`, `defaultTransactionalId?` |
406
- | Cloudflare Email | `sently/transports/cloudflare-email` | `sendEmail` binding (`env.SEND_EMAIL`) |
407
-
408
- All transports implement the same interface — swap without changing your send code.
409
-
410
- **Routing decorators** (compose with any transport above):
411
-
412
- | Transport | Import path | Purpose |
413
- |-----------|-------------|---------|
414
- | Fallback | `sently/transports/fallback` | Ordered provider failover |
415
- | Weighted fallback | `sently/transports/weighted-fallback` | Weighted-random primary + failover |
416
- | Retry | `sently/transports/retry` | Per-provider retries before failing over |
417
- | Idempotency | `sently/idempotency` | Dedupe sends on retry/replay |
418
-
419
- **Loops** is template-first: `subject`/`html`/`text` are ignored. Set `options.headers['x-loops-transactional-id']` (or `defaultTransactionalId` on the transport) and pass template variables via `options.data`.
420
-
421
- **Plunk** sends one HTTP request per `to` address and aggregates results when multiple recipients are provided.
422
-
423
- ### FallbackTransport
424
-
425
- Route through an ordered list of providers — if your primary has an outage, the next takes over. Compose with `RetryTransport` to retry within a provider before failing over:
426
-
427
- ```typescript
428
- import { FallbackTransport } from "sently/transports/fallback";
429
- import { RetryTransport } from "sently/transports/retry";
430
- import { ResendTransport } from "sently/transports/resend";
431
- import { SESTransport } from "sently/transports/ses";
432
- import { createMailer } from "sently/mailer";
433
-
434
- const transport = new FallbackTransport([
435
- new RetryTransport(new ResendTransport({ apiKey: process.env.RESEND_API_KEY! })),
436
- new RetryTransport(
437
- new SESTransport({
438
- accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
439
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
440
- }),
441
- ),
442
- ]);
443
-
444
- const mailer = await createMailer({ transport });
445
-
446
- const result = await mailer.send({ from: "...", to: "...", subject: "...", html: "..." });
447
- // result.provider === "ses", result.providerIndex === 1 → primary failed, secondary won
448
- ```
449
-
450
- Permanent client errors (HTTP 400/401/403, SMTP 535) are **not** retried on the next provider — they would fail identically everywhere. When all providers fail, `FallbackError.attempts` lists each `{ provider, error }` in order for debugging.
451
-
452
- **Cooldown** — skip providers that recently failed until a cooldown expires:
453
-
454
- ```typescript
455
- const transport = new FallbackTransport(transports, { cooldownMs: 300_000 });
456
- ```
457
-
458
- **Full-chain verify** — `verify()` returns the first healthy provider; use `verifyAll()` for per-provider visibility:
459
-
460
- ```typescript
461
- const { ok, providers } = await transport.verifyAll();
462
- // providers: [{ provider: "resend", ok: true }, { provider: "ses", ok: false, message: "..." }]
463
- ```
464
-
465
- **Mailer `onFallback` hook** — observability when failover happens (requires `FallbackTransport` in the stack):
466
-
467
- ```typescript
468
- const mailer = await createMailer({
469
- transport,
470
- hooks: {
471
- onFallback: (_ctx, failedProvider, nextProvider, error) => {
472
- console.log(`failover ${failedProvider} → ${nextProvider}`, error);
473
- },
474
- },
475
- });
476
- ```
477
-
478
- **Weighted routing** — shift traffic gradually between providers:
479
-
480
- ```typescript
481
- import { WeightedFallbackTransport } from "sently/transports/weighted-fallback";
482
-
483
- const transport = new WeightedFallbackTransport([
484
- { transport: new ResendTransport({ apiKey }), weight: 80 },
485
- { transport: new SESTransport({ accessKeyId, secretAccessKey }), weight: 20 },
486
- ]);
487
- ```
488
-
489
- Every transport exposes a stable **`Transport.provider`** string (e.g. `"resend"`, `"ses"`) for hooks, logs, and `SendResult.provider`.
490
-
491
- **Cloudflare Workers Email** — use the `send_email` binding (not fetch HTTP). Configure `[[send_email]]` in `wrangler.toml`, then pass `env.SEND_EMAIL`:
492
-
493
- ```typescript
494
- import { CloudflareEmailTransport } from "sently/transports/cloudflare-email";
495
-
496
- const mailer = await createMailer({
497
- transport: new CloudflareEmailTransport({ sendEmail: env.SEND_EMAIL }),
498
- });
499
- ```
500
-
501
- Attachments are base64-encoded in the binding payload; use `content: Uint8Array` on Workers (no `attachment.path`).
502
-
503
- ### PreviewTransport
504
-
505
- Write emails to disk during local development instead of sending them:
506
-
507
- ```typescript
508
- import { PreviewTransport } from "sently/transports/preview";
509
- import { createMailer } from "sently/mailer";
510
-
511
- const mailer = await createMailer({
512
- transport: new PreviewTransport({
513
- outDir: "./.emails",
514
- open: true,
515
- format: "html",
516
- }),
517
- });
518
-
519
- await mailer.send({
520
- from: "dev@localhost",
61
+ from: "hello@example.com",
521
62
  to: "you@example.com",
522
- subject: "Preview me",
523
- html: "<h1>Hello</h1>",
524
- });
525
- ```
526
-
527
- ### RetryTransport
528
-
529
- Wrap any transport with automatic retries and configurable backoff:
530
-
531
- ```typescript
532
- import { RetryTransport } from "sently/transports/retry";
533
- import { ResendTransport } from "sently/transports/resend";
534
- import { createMailer } from "sently/mailer";
535
-
536
- const transport = new RetryTransport(
537
- new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
538
- { maxAttempts: 3, backoff: "exponential", retryOn: [429, 503] },
539
- );
540
-
541
- const mailer = await createMailer({ transport });
542
- ```
543
-
544
- ### sendBulk()
545
-
546
- 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.
547
-
548
- ```typescript
549
- const result = await mailer.sendBulk(
550
- [
551
- { from: "a@b.com", to: "1@example.com", subject: "One", text: "Hi" },
552
- { from: "a@b.com", to: "2@example.com", subject: "Two", text: "Hi" },
553
- ],
554
- {
555
- concurrency: 2,
556
- stopOnError: false, // halt remaining sends after first failure when true
557
- onSuccess: (_msg, index) => console.log(`Sent #${index}`),
558
- onError: (_msg, index, err) => console.error(`Failed #${index}`, err),
559
- },
560
- );
561
-
562
- console.log(result.sent, result.failed);
563
- ```
564
-
565
- Resend batches up to `RESEND_BATCH_MAX` (100) messages per request — export from `sently/transports/resend`.
566
-
567
- ### Mailer lifecycle hooks
568
-
569
- Optional observability hooks on `createMailer` fire for every `send()` and `sendBulk()` message (batch paths invoke hooks once per message, without double-firing). Hook context carries `{ messageId?, to, subject, provider }` — no body fields, to avoid leaking PII into logs. `onSuccess` and `onError` accept an optional third argument `durationMs` (elapsed milliseconds).
570
-
571
- ```typescript
572
- import { consoleObserver } from "sently/observability";
573
-
574
- const mailer = await createMailer({
575
- transport: new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
576
- hooks: {
577
- onSend: (ctx) => metrics.increment("email.send", { provider: ctx.provider }),
578
- onSuccess: (ctx, result, durationMs) =>
579
- metrics.histogram("email.duration", durationMs ?? 0),
580
- onError: (ctx, err, durationMs) => metrics.increment("email.error"),
581
- onRetry: (ctx, attempt, err) => metrics.increment("email.retry", { attempt }),
582
- onFallback: (ctx, failed, next, err) =>
583
- metrics.increment("email.fallback", { from: failed, to: next }),
584
- },
585
- });
586
-
587
- // Quick start — log lifecycle events to the console
588
- const devMailer = await createMailer({
589
- transport: new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
590
- hooks: consoleObserver("[myapp]"),
591
- });
592
- ```
593
-
594
- Hooks are fully optional and zero-cost when unset. A throwing hook does **not** break the send — in non-production environments the error is logged with `console.warn` and the send continues. Pair `onRetry` with `RetryTransport` for per-attempt retry metrics; pair `onFallback` with `FallbackTransport` or `WeightedFallbackTransport` for failover observability.
595
-
596
- Works with both `sently/mailer` (`{ transport, hooks }`) and SMTP config via `createSMTPMailer` (`{ host, auth, hooks }`).
597
-
598
- ### IdempotencyTransport
599
-
600
- Prevent duplicate sends on retry or replay. Wrap **outside** `RetryTransport` so all retry attempts share one key:
601
-
602
- ```typescript
603
- import { IdempotencyTransport } from "sently/idempotency";
604
- import { RetryTransport } from "sently/transports/retry";
605
- import { ResendTransport } from "sently/transports/resend";
606
-
607
- const transport = new IdempotencyTransport(
608
- new RetryTransport(new ResendTransport({ apiKey: process.env.RESEND_API_KEY! })),
609
- { ttlMs: 86_400_000 },
610
- );
611
-
612
- await mailer.send({
613
- from: "you@example.com",
614
- to: "user@example.com",
615
- subject: "Hello",
616
- text: "Hi",
617
- idempotencyKey: "order-123-email", // or derive from messageId
618
- });
619
- ```
620
-
621
- Resend sends the `Idempotency-Key` HTTP header natively. Supply a shared store (Redis, Dragonfly) in production — `MemoryIdempotencyStore` is for single-process use.
622
-
623
- ---
624
-
625
- ## Plugin system
626
-
627
- Plugins transform `MailOptions` before the transport builds and sends the message. They run sequentially — each receives the output of the previous plugin.
628
-
629
- ```typescript
630
- import type { MailOptions } from "sently";
631
- import { createSMTPMailer } from "sently/smtp";
632
-
633
- const addFooter = (options: MailOptions) => ({
634
- ...options,
635
- html: (options.html ?? "") + '<p style="color:#999">Unsubscribe</p>',
636
- });
637
-
638
- const mailer = await createSMTPMailer({
639
- host: "smtp.resend.com",
640
- port: 465,
641
- secure: true,
642
- auth: { user: "resend", pass: process.env.RESEND_API_KEY! },
643
- plugins: [addFooter],
644
- });
645
- ```
646
-
647
- Works with SMTP config or custom transports:
648
-
649
- ```typescript
650
- import { createMailer } from "sently/mailer";
651
- import { ResendTransport } from "sently/transports/resend";
652
-
653
- const mailer = await createMailer({
654
- transport: new ResendTransport({ apiKey: "re_..." }),
655
- plugins: [addFooter],
656
- });
657
- ```
658
-
659
- ### TemplatePlugin
660
-
661
- Render HTML from named templates with zero dependencies:
662
-
663
- ```typescript
664
- import { templatePlugin, simpleEngine } from "sently/plugins/template";
665
- import { createMailer } from "sently/mailer";
666
- import { ResendTransport } from "sently/transports/resend";
667
-
668
- const mailer = await createMailer({
669
- transport: new ResendTransport({ apiKey: "re_..." }),
670
- plugins: [
671
- templatePlugin({
672
- engine: simpleEngine,
673
- templates: {
674
- welcome: "<h1>Hello, {{name}}!</h1>",
675
- },
676
- }),
677
- ],
678
- });
679
-
680
- await mailer.send({
681
- from: "onboarding@yourdomain.com",
682
- to: "user@example.com",
683
63
  subject: "Welcome",
684
- template: "welcome",
685
- data: { name: "Ali" },
64
+ html: "<p>Sent with sently.</p>",
686
65
  });
687
66
  ```
688
67
 
689
- Use a custom engine by passing any `(template, data) => string` function to `templatePlugin`.
68
+ Same shape for every channel apps use **sently senders**, not vendor SDKs:
690
69
 
691
- ### React Email plugin
70
+ | Channel | Sender | Example transport |
71
+ | -------- | ------------------------------------ | ------------------------------------------ |
72
+ | Email | `createMailer` / `createSMTPMailer` | `sently/transports/resend`, `sently/smtp` |
73
+ | SMS | `createSmsSender` | `sently/transports/twilio-sms` |
74
+ | WhatsApp | `createWhatsAppSender` | `sently/transports/whatsapp-cloud` |
75
+ | Push | `createPushSender` | `sently/transports/webpush` |
692
76
 
693
- Render React Email components to HTML and plain text (optional peers: `react`, `@react-email/render`):
77
+ Full walkthrough: [Get started](https://sently.omqkhafi.dev/docs/get-started).
694
78
 
695
- ```typescript
696
- import { reactPlugin } from "sently/react";
697
- import { createMailer } from "sently/mailer";
698
- import { ResendTransport } from "sently/transports/resend";
699
- import { WelcomeEmail } from "./emails/welcome";
79
+ ## Why sently?
700
80
 
701
- const mailer = await createMailer({
702
- transport: new ResendTransport({ apiKey: "re_..." }),
703
- plugins: [reactPlugin()],
704
- });
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.
705
82
 
706
- await mailer.send({
707
- from: "onboarding@yourdomain.com",
708
- to: "user@example.com",
709
- subject: "Welcome",
710
- react: WelcomeEmail({ name: "Ali" }),
711
- });
712
- ```
713
-
714
- Explicit `html` / `text` always win over rendered output.
715
-
716
- ### Webhook parsing
717
-
718
- Normalize provider webhooks into a single event type — no server framework required:
719
-
720
- ```typescript
721
- import { parseResendWebhook, parseSesWebhook } from "sently/webhooks";
722
-
723
- // Resend (Svix-style payload)
724
- const events = parseResendWebhook(await request.json());
725
-
726
- // AWS SES via SNS (handles SubscriptionConfirmation + double-encoded Message)
727
- const sesEvents = parseSesWebhook(await request.json());
728
-
729
- for (const event of events) {
730
- console.log(event.type, event.messageId, event.recipient);
731
- }
732
- ```
733
-
734
- Parsers: Resend, SendGrid, Postmark, Mailgun, SES, Brevo. Optional HMAC verification helpers for Mailgun and Resend (`verifyMailgunSignature`, `verifyResendSignature`).
735
-
736
- ---
737
-
738
- ## MailOptions Reference
739
-
740
- | Field | Type | Default | Description |
741
- |-------|------|---------|-------------|
742
- | `from` | `AddressInput` | *required* | Sender address |
743
- | `to` | `AddressInput` | *required* | Recipients |
744
- | `cc` | `AddressInput` | — | CC recipients (visible in headers) |
745
- | `bcc` | `AddressInput` | — | BCC recipients (envelope only, not in headers) |
746
- | `replyTo` | `AddressInput` | — | Reply-To header |
747
- | `subject` | `string` | *required* | Email subject (RFC 2047 for non-ASCII) |
748
- | `text` | `string` | — | Plain text body |
749
- | `html` | `string` | — | HTML body |
750
- | `attachments` | `Attachment[]` | — | File attachments |
751
- | `headers` | `Record<string, string>` | — | Custom headers |
752
- | `messageId` | `string` | auto | Message-ID header |
753
- | `idempotencyKey` | `string` | — | Dedupe key for retry/replay (Resend sends as `Idempotency-Key` header) |
754
- | `react` | `unknown` | — | React element — use with `reactPlugin()` from `sently/react` |
755
- | `date` | `Date` | now | Date header |
756
- | `priority` | `'high' \| 'normal' \| 'low'` | — | X-Priority / Importance |
757
- | `encoding` | `'utf-8' \| 'ascii'` | `'utf-8'` | Character encoding hint |
758
-
759
- ---
760
-
761
- ## Attachments
762
-
763
- ### In-memory (all runtimes)
764
-
765
- ```typescript
766
- await mailer.send({
767
- from: "you@example.com",
768
- to: "user@example.com",
769
- subject: "With attachment",
770
- text: "See attached",
771
- attachments: [
772
- {
773
- filename: "report.pdf",
774
- content: pdfBytes, // Uint8Array
775
- contentType: "application/pdf",
776
- },
777
- ],
778
- });
779
- ```
780
-
781
- ### File path (Node.js / Bun / Deno only)
782
-
783
- `attachment.path` reads from disk — see [Security](#security) (Attachments) for validation and `basePath`.
784
-
785
- ```typescript
786
- attachments: [
787
- {
788
- filename: "report.pdf",
789
- path: "/path/to/report.pdf",
790
- },
791
- ],
792
- ```
793
-
794
- On Cloudflare Workers and browsers, use `content: Uint8Array` — `attachment.path` is not supported.
795
-
796
- ---
797
-
798
- ## Error Handling
799
-
800
- All transport errors extend `SentlyError` for unified handling while preserving existing class names and properties:
801
-
802
- ```typescript
803
- import { SentlyError } from "sently/errors";
804
- import { SMTPError } from "sently/transports/smtp";
805
- import { ResendError } from "sently/transports/resend";
806
- // Each HTTP transport exports its own error class:
807
- // SendGridError → sently/transports/sendgrid
808
- // PostmarkError → sently/transports/postmark
809
- // MailgunError → sently/transports/mailgun
810
- // SESError → sently/transports/ses
811
- // BrevoError → sently/transports/brevo
812
- // CloudflareEmailError → sently/transports/cloudflare-email
813
- // FallbackError → sently/transports/fallback (all providers failed; see .attempts)
814
-
815
- try {
816
- await mailer.send({ ... });
817
- } catch (err) {
818
- if (err instanceof SentlyError) {
819
- console.error(err.sentlyCode); // e.g. "BAD_REQUEST", "RATE_LIMITED"
820
- console.error(err.statusCode); // HTTP status when applicable
821
- }
822
- if (err instanceof SMTPError) {
823
- console.error(err.code); // SMTP response code, e.g. 550 (numeric)
824
- console.error(err.command); // failed command, e.g. "RCPT TO"
825
- }
826
- if (err instanceof ResendError) {
827
- console.error(err.statusCode); // HTTP status code
828
- console.error(err.code); // machine-readable, e.g. "BAD_REQUEST"
829
- }
830
- }
831
- ```
832
-
833
- Use `sentlyCode` for unified machine-readable codes when a subclass shadows `code` (e.g. SMTP numeric codes, Brevo/SES provider API codes). Import error classes from their transport subpath — HTTP failures also expose `statusCode`.
834
-
835
- ---
836
-
837
- ## Security
838
-
839
- sently is built to be secure by default — protections are enforced at the library's core chokepoints, so they apply to every transport and every address field without any extra configuration.
840
-
841
- ### Email header & SMTP command injection
842
-
843
- All addresses **and** display names are validated centrally in `parseAddresses()` (and re-asserted when rendering headers), before any normalization:
844
-
845
- - Rejects CR, LF, NUL, every other C0 control (`0x00`–`0x1F`), DEL (`0x7F`), and the Unicode line/paragraph separators `U+2028`/`U+2029`.
846
- - **Fails closed:** hostile input throws a clear error (with the offending code point) — it is never stripped, repaired, and then accepted.
847
- - Protects the **display name** too, so an ASCII name like `"Foo\r\nBcc: attacker@evil.com"` can no longer inject a header.
848
- - Enforced consistently across **From, To, Cc, Bcc, and Reply-To**, and across **every transport** (SMTP, SES, Mailgun, Postmark, Resend, SendGrid, Brevo).
849
-
850
- ```typescript
851
- await mailer.send({
852
- from: "you@example.com",
853
- to: { address: "victim@x.com\r\nBcc: attacker@evil.com" },
854
- subject: "Hi",
855
- text: "...",
856
- });
857
- // → throws: Email address contains a forbidden control character (0x0d)
858
- ```
859
-
860
- MIME attachment filenames and custom attachment headers are likewise sanitized against header injection.
861
-
862
- ### Credential protection
863
-
864
- - **`requireTLS`** (default `true` when `auth` is set) refuses to authenticate over a cleartext connection, defeating STARTTLS-stripping downgrade attacks.
865
- - **OAuth2 / XOAUTH2** and DKIM signing are built in via Web Crypto — no plaintext secrets in transit beyond what the protocol requires.
866
-
867
- ### Attachments
868
-
869
- > ⚠️ `attachment.path` reads files from disk. Never pass user-controlled paths without validation.
870
-
871
- `resolveAttachments()` accepts an opt-in `basePath` that confines reads to an allowed directory and rejects path-traversal (including sibling-directory prefix tricks like `/var/data-secret` vs `/var/data`). Note: `basePath` does not dereference symlinks — use `fs.realpath()` first if symlink traversal is a concern.
872
-
873
- ### Supply chain
874
-
875
- **Zero runtime dependencies** — there is no transitive dependency tree to audit or to be compromised.
876
-
877
- ---
878
-
879
- ## Bundle size
880
-
881
- Sizes are **minified + gzip** per import path (`bun run measure:size`; CI: `bun run check:size`). Node built-ins and `cloudflare:sockets` are external.
882
-
883
- Nodemailer ships **~58 KB gzip** regardless of transport ([BundlePhobia, v8.0.10](https://bundlephobia.com/package/nodemailer@8.0.10)). sently tree-shakes by subpath — pick the entry that matches how you send:
884
-
885
- | How you send | Import | ~gzip |
886
- |--------------|--------|-------|
887
- | HTTP API (Resend, SendGrid, …) | `sently/mailer` + `sently/transports/<provider>` | **~6.1 KB** |
888
- | SMTP relay (`host` / `port`) | `sently/smtp` (or `createSMTPMailer` from `sently`) | **~15 KB** |
889
- | Transport only (no mailer wrapper) | `sently/transports/<provider>` | **~4.7 KB** |
890
-
891
- Regenerate full tables with `bun run measure:size:md`. Measured **2026-05-31** (minified + gzip):
892
-
893
- #### Common stacks
894
-
895
- | What | Imports | ~gzip |
896
- |------|---------|-------|
897
- | HTTP — Resend | `sently/mailer` + `sently/transports/resend` | ~6.1 KB |
898
- | HTTP — SendGrid | `sently/mailer` + `sently/transports/sendgrid` | ~5.9 KB |
899
- | HTTP — transport only | `sently/transports/resend` (no `createMailer` wrapper) | ~4.7 KB |
900
- | SMTP relay | `sently/smtp` with `{ host, port, auth }` | ~14.8 KB |
901
- | SMTP + Node adapter | `sently/smtp` + `sently/adapters/node` | ~14.8 KB |
902
- | Main entry + HTTP | `sently` + HTTP transport via main `createMailer` | ~6.1 KB |
903
-
904
- #### Core entries
905
-
906
- | What | Imports | ~gzip |
907
- |------|---------|-------|
908
- | sently/mailer | Transport-only `createMailer` (plugins, sendBulk) | ~2.6 KB |
909
- | sently | Main entry — types, factories, OAuth2, `SentlyError` | ~2.6 KB |
910
- | sently/smtp | SMTP `createSMTPMailer` — host/port, pool, adapters | ~14.7 KB |
911
-
912
- ```ts
913
- // HTTP
914
- import { createMailer } from "sently/mailer";
915
- import { ResendTransport } from "sently/transports/resend";
916
-
917
- // SMTP
918
- import { createSMTPMailer } from "sently/smtp";
919
- ```
920
-
921
- Main `"sently"` exports shared types, `createMailer`, `createSMTPMailer`, `detectRuntime`, OAuth2, `SentlyError`, `consoleObserver`, and the v0.8 HTTP providers (`LoopsTransport`, `MailerSendTransport`, …), plus `FallbackTransport`, `WeightedFallbackTransport`, and `CloudflareEmailTransport`. Webhooks, idempotency, DKIM, SMTP-only transports, and plugins remain separate subpaths for smallest bundles.
922
-
923
- ---
924
-
925
- ## Choosing an entrypoint
926
-
927
- ```
928
- How do you send mail?
929
-
930
- ├─ HTTP API (Resend, SendGrid, …)
931
- │ import { createMailer } from "sently/mailer"
932
- │ import { ResendTransport } from "sently/transports/resend"
933
- │ createMailer({ transport: new ResendTransport({ apiKey }) })
934
-
935
- ├─ SMTP relay (host / port / auth)
936
- │ import { createSMTPMailer } from "sently/smtp"
937
- │ createSMTPMailer({ host, port, auth })
938
-
939
- ├─ Provider failover / weighted routing
940
- │ import { FallbackTransport } from "sently/transports/fallback"
941
- │ import { WeightedFallbackTransport } from "sently/transports/weighted-fallback"
942
- │ createMailer({ transport: new FallbackTransport([primary, backup]) })
943
-
944
- └─ Custom / decorated transport (Retry, Idempotency, Preview)
945
- import { createMailer } from "sently/mailer"
946
- createMailer({ transport: new RetryTransport(inner) })
947
- ```
948
-
949
- ---
950
-
951
- ## Migrating from Nodemailer
952
-
953
- | Nodemailer | sently |
954
- |------------|--------|
955
- | `nodemailer.createTransport({...})` | `await createSMTPMailer({...})` or `createMailer({ transport })` |
956
- | `transporter.sendMail(options)` | `mailer.send(options)` |
957
- | `transporter.verify()` | `mailer.verify()` |
958
- | `options.attachments[].path` | Same (Node/Bun/Deno); use `content` on edge |
959
- | `import nodemailer from 'nodemailer'` | `import { createMailer } from 'sently/mailer'` (HTTP) or `createSMTPMailer` from `'sently/smtp'` |
960
- | CommonJS | ESM only |
961
- | Node.js only | Node, Bun, Deno, CF Workers |
962
-
963
- ---
964
-
965
- ## TypeScript
966
-
967
- ```typescript
968
- import type {
969
- MailOptions,
970
- MailPlugin,
971
- SendResult,
972
- Attachment,
973
- SMTPConfig,
974
- SMTPMailerOptions,
975
- TransportMailerOptions,
976
- } from "sently";
977
- ```
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 |
978
93
 
979
- All types ship with the package — no separate `@types/` install needed.
94
+ ## Entrypoints
980
95
 
981
- ---
96
+ | Import | Use when |
97
+ | ---------------------- | --------------------------------------------- |
98
+ | `sently/mailer` | HTTP / custom email transports (smallest) |
99
+ | `sently/smtp` | SMTP host, pool, adapters, DKIM |
100
+ | `sently/sms` | SMS |
101
+ | `sently/whatsapp` | WhatsApp |
102
+ | `sently/push` | Web Push |
103
+ | `sently/transports/*` | One provider per subpath |
982
104
 
983
- ## Links
105
+ ## Documentation
984
106
 
985
- - **Source & issues:** [github.com/alialnaghmoush/sently](https://github.com/alialnaghmoush/sently)
986
- - **npm:** [npmjs.com/package/sently](https://www.npmjs.com/package/sently)
987
- - **JSR:** [jsr.io/@alialnaghmoush/sently](https://jsr.io/@alialnaghmoush/sently)
107
+ | Resource | Link |
108
+ | ------------ | ------------------------------------------------------------ |
109
+ | Docs site | [sently.omqkhafi.dev](https://sently.omqkhafi.dev) |
110
+ | Handbook | [/docs](https://sently.omqkhafi.dev/docs) |
111
+ | Get started | [/docs/get-started](https://sently.omqkhafi.dev/docs/get-started) |
112
+ | Channels | [/docs/channels](https://sently.omqkhafi.dev/docs/channels) |
113
+ | Transports | [/docs/transports](https://sently.omqkhafi.dev/docs/transports) |
114
+ | Agents index | [/llms.txt](https://sently.omqkhafi.dev/llms.txt) |
115
+ | Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
116
+ | Agents | [`AGENTS.md`](AGENTS.md) |
988
117
 
989
- ## License
118
+ Local docs: `bun run site:dev`. Verify: `bun run verify`.
990
119
 
991
- MIT
120
+ Pre-1.0. Published on [npm](https://www.npmjs.com/package/sently) and [JSR](https://jsr.io/@alialnaghmoush/sently). MIT.