@nds-stack/bun-mail
v0.1.0-alpha.1
Published
Email sender for Bun — SMTP client, MIME builder, HTML templates, zero dependencies
Downloads
31
Maintainers
Readme
@nds-stack/bun-mail
Bun-native email sender — SMTP client, MIME builder, HTML templates, zero dependencies.
Why bun-mail
Nodemailer has 15.8M weekly downloads but depends on Node.js stream, tls, and dns modules. Bun-native applications shouldn't need Node.js polyfills just to send email.
bun-mail is a lightweight, Bun-native email sender:
import { BunMail } from "@nds-stack/bun-mail";
const mailer = new BunMail({
host: "smtp.gmail.com",
port: 587,
auth: { username: "[email protected]", password: "app-password" },
});
const result = await mailer.send({
from: { name: "You", address: "[email protected]" },
to: "[email protected]",
subject: "Hello from Bun!",
text: "Plain text version",
html: "<h1>Hello!</h1><p>HTML version</p>",
});How It Works
bun-mail consists of three layers:
BunMail (facade) — validates input, orchestrates send flow
├── MimeBuilder — builds RFC 5322/2045 MIME message
└── SmtpClient — handles SMTP wire protocol via Bun.connect()MIME Construction: MimeBuilder builds structured email messages — plain text, HTML, multipart/alternative (text+HTML), or multipart/mixed (with attachments). Content is base64-encoded and formatted per RFC standards.
SMTP Protocol: SmtpClient connects to the SMTP server using Bun.connect() (native TCP/TLS). The handshake flow is: connect → 220 greeting → EHLO → AUTH LOGIN (optional) → MAIL FROM → RCPT TO → DATA → QUIT. Each command awaits a response code; multi-line responses are consumed properly per RFC 5321.
Connection: Each send() call opens a fresh TCP/TLS connection, performs the SMTP handshake, transmits the message, and closes with QUIT. Connection pooling is planned for beta.
Installation
bun add @nds-stack/bun-mailAPI
Constructor
new BunMail(options: BunMailOptions)| Option | Type | Default | Description |
|--------|------|---------|-------------|
| host | string | — | SMTP server hostname (required) |
| port | number | 587 or 465 | SMTP port. Defaults to 465 if tls: true |
| tls | boolean | false | Use TLS. Auto-set for port 465 |
| auth | { username, password } | — | SMTP AUTH PLAIN (preferred) / AUTH LOGIN credentials |
| timeout | number | 30000 | Connection/SMTP timeout in ms |
| defaultFrom | string | — | Default sender address fallback |
| maxConcurrency | number | 10 | Max parallel connections in sendBulk() |
| dkim | DkimOptions | — | DKIM signing configuration |
Methods
send(msg: EmailMessage): Promise<SendResult>
Send an email via SMTP.
| Field | Type | Description |
|-------|------|-------------|
| from | string \| EmailAddress | Sender address |
| to | string \| EmailAddress \| (string \| EmailAddress)[] | Recipients |
| cc | Same as to | CC recipients |
| bcc | Same as to | BCC recipients |
| subject | string | Email subject (UTF-8) |
| text | string | Plain text body |
| html | string | HTML body |
| attachments | Attachment[] | File attachments |
| headers | Record<string, string> | Custom headers |
Returns SendResult:
{
messageId: string; // Generated Message-ID
accepted: string[]; // Accepted recipients
rejected: string[]; // Rejected recipients
}sendBulk(messages: EmailMessage[]): Promise<SendResult[]>
Send multiple emails concurrently.
verifyConnectivity(): Promise<boolean>
Test SMTP connection without sending email.
EmailAddress
{ name?: string; address: string }Attachment
{
filename: string;
content: string | Uint8Array;
contentType?: string; // Default: application/octet-stream
encoding?: "base64";
}Error Handling
All SMTP errors throw SmtpError:
import { SmtpError } from "@nds-stack/bun-mail";
try {
await mailer.send({ ... });
} catch (err) {
if (err instanceof SmtpError) {
console.error(`SMTP ${err.code}: ${err.message}`);
console.error(`Server response: ${err.response}`);
}
}Error codes:
535— Authentication failed550— Mailbox unavailable554— Transaction failed- Validation errors throw plain
Error(missing host, no recipients, etc.)
Limitations
- SMTP only (no SendGrid/Mailgun/SES API providers — future)
- AUTH LOGIN and AUTH PLAIN (auto-negotiation, PLAIN tried first)
- WARNING: AUTH LOGIN credentials are base64-encoded, not encrypted. Always use TLS (port 465) when authentication is enabled.
- No STARTTLS upgrade (uses direct TLS on port 465)
- DKIM signing via RSA-SHA256 (relaxed canonicalization) — PEM private key required
- Connection per send (pooling coming in beta)
- Base64 content transfer only (QP planned)
Multi-Instance / Cross-Boundary
Each BunMail instance is independent with its own SMTP connection pool (once implemented). For multi-process/worker scenarios:
// Worker 1
const mailer1 = new BunMail({ host: "smtp.example.com", auth: { ... } });
// Worker 2
const mailer2 = new BunMail({ host: "smtp.example.com", auth: { ... } });No shared state between instances. For centralized email sending in a distributed system, route through a queue or Bunova message bus.
DKIM Signing
DKIM (DomainKeys Identified Mail) is required for deliverability to Gmail, Yahoo, Outlook, ProtonMail, and other major providers. Without DKIM, emails are likely to be marked as spam.
Setup
Generate a DKIM private key and DNS record:
# Generate 1024-bit RSA private key
openssl genrsa -out dkim.pem 1024
# Extract public key for DNS
openssl rsa -in dkim.pem -puboutAdd a TXT record to your domain's DNS:
{selector}._domainkey.{domain} TXT "v=DKIM1; k=rsa; p={base64-public-key}"Usage
import { BunMail } from "@nds-stack/bun-mail";
import { readFileSync } from "fs";
const privateKey = readFileSync("./dkim.pem", "utf-8");
const mailer = new BunMail({
host: "smtp.example.com",
auth: { username: "...", password: "..." },
dkim: {
privateKey, // PEM-encoded RSA private key
selector: "default", // DNS selector name
domain: "example.com",
headers: ["from", "to", "subject", "date", "message-id"], // optional
},
});How it Works
- The MIME message is built (headers + body)
- DKIM signs the selected headers + body hash using RSA-SHA256
- The
DKIM-Signatureheader is prepended to the email - Relaxed canonicalization is used (whitespace normalization)
- Authentication is automatic via Web Crypto API (Bun native)
DkimOptions
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| privateKey | string | — | PEM-encoded RSA private key (required) |
| selector | string | — | DKIM DNS selector (required) |
| domain | string | — | Signing domain (required) |
| headers | string[] | All standard headers | Headers to include in signature |
Customization Guide
Subclassing
class CustomMailer extends BunMail {
async sendWithTracking(msg: EmailMessage) {
const result = await this.send(msg);
await this.logToDb(result);
return result;
}
}Custom Headers
await mailer.send({
...msg,
headers: {
"X-Application": "my-app",
"List-Unsubscribe": "<mailto:[email protected]>",
"Priority": "urgent",
},
});Template Integration
function renderTemplate(template: string, data: Record<string, unknown>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(data[key] ?? ""));
}
await mailer.send({
...msg,
html: renderTemplate("<h1>Hello {{name}}</h1>", { name: "Alice" }),
});Comparison Table
| Feature | @nds-stack/bun-mail | nodemailer |
|---------|:---------------------:|:------------:|
| Runtime | Bun native | Node.js + polyfills |
| Dependencies | 0 | 8+ (nodemailer + deps) |
| SMTP TLS | ✅ via Bun.connect() | ✅ via tls module |
| AUTH LOGIN | ✅ | ✅ |
| AUTH PLAIN | ⏳ Beta | ✅ |
| HTML + Text | ✅ | ✅ |
| Attachments | ✅ | ✅ |
| DKIM | ✅ via RSA-SHA256 | ✅ |
| Connection Pool | ⏳ Beta | ✅ |
| SendGrid / SES | ⏳ Future | ✅ (via plugins) |
| Bundle Size | ~15KB | ~200KB+ |
Benchmarks
| Operation | @nds-stack/bun-mail | nodemailer | Overhead |
|-----------|:---------------------:|:------------:|:--------:|
| MIME build (plain) | ~175K ops/s | — | Baseline |
| MIME build (HTML+text) | ~181K ops/s | — | — |
| MIME build (10KB attach) | ~34K ops/s | — | — |
Run your own: bun run bench
Real-World Example
import { BunMail } from "@nds-stack/bun-mail";
interface WelcomeEmail {
to: string;
name: string;
verifyLink: string;
}
async function sendWelcomeEmail(mailer: BunMail, data: WelcomeEmail) {
return mailer.send({
from: { name: "My App", address: "[email protected]" },
to: data.to,
subject: `Welcome, ${data.name}!`,
text: `Hi ${data.name},\n\nPlease verify your email: ${data.verifyLink}`,
html: `
<h1>Welcome ${data.name}!</h1>
<p>Please <a href="${data.verifyLink}">verify your email</a>.</p>
`,
});
}
const mailer = new BunMail({
host: process.env.SMTP_HOST!,
auth: {
username: process.env.SMTP_USER!,
password: process.env.SMTP_PASS!,
},
});
await sendWelcomeEmail(mailer, {
to: "[email protected]",
name: "Alice",
verifyLink: "https://myapp.com/verify?token=abc123",
});