A lightweight, zero-dependency SMTP mailer for Cloudflare Workers.
Built entirely on the cloudflare:sockets TCP API — no Node.js polyfills required beyond the compatibility flag.
- 🚀 Zero dependencies — runs natively on the Cloudflare Workers runtime
- 📝 Full TypeScript support — every API is fully typed
- 📧 Plain text, HTML & attachments — with automatic MIME type inference
- 🖼️ Inline images — CID-embedded images in HTML emails
- 📅 Calendar invites — iCalendar (.ics) generation with MIME integration
- 🔏 DKIM signing — RSA-SHA256 via Web Crypto API
- 🔒 SMTP auth —
plain,login, andCRAM-MD5 - 🪝 Send hooks & plugins —
beforeSend/afterSend/ lifecycle plugins - 🧪 Mock mailer & assertions —
MockMailerplus chainable test helpers - 🛰️ Telemetry & dry run — observe sends and validate recipients without sending DATA
- 👁️ Email preview —
previewEmail()for MIME inspection without sending - 🏓 Health check —
ping()via SMTP NOOP command - ⚡ Zero-config helpers —
sendOnce(),fromEnv(),createFromEnv()read env vars automatically - 🏷️ Provider presets — Gmail, Outlook, SendGrid one-liner via
preset() - 📦 Batch sending —
sendBatch()with concurrency control and error handling - 🔄 Connection pool —
WorkerMailerPoolwith round-robin distribution - ✅ Email validation —
validateEmail()andvalidateEmailBatch() - 📬 DSN — Delivery Status Notification support
- 🔁 Auto-reconnect & retries — configurable retry and reconnection
- 📊 Structured results —
SendResultwith detailed response info - 🧹 Async disposal —
Symbol.asyncDispose/await usingsupport - 🌐 SMTPUTF8 — international email addresses (RFC 6531)
- 🔗 Reply threading —
threadHeaders()for In-Reply-To / References - 🎨 Template engine — Mustache-like
{{variable}}rendering with HTML escaping - 📝 HTML → Text — automatic plain text generation from HTML
- 🚫 List-Unsubscribe — RFC 8058 one-click unsubscribe headers
- 🔨 Mail builder — fluent
MailBuilderAPI with method chaining
- Cloudflare Workers runtime
wrangler.toml:compatibility_flags = ["nodejs_compat"]
bun add @ryyr/worker-mailer
# or
npm install @ryyr/worker-mailerThe fastest way to send an email. Set your SMTP_* env vars in wrangler.toml (or the dashboard) and call sendOnce():
import { sendOnce } from "@ryyr/worker-mailer/convenience"
export default {
async fetch(request, env) {
const result = await sendOnce(env, {
from: "noreply@example.com",
to: "user@example.com",
subject: "Welcome!",
text: "Thanks for signing up.",
})
return Response.json(result)
},
}Pre-configured settings for popular providers. Just supply SMTP_USER and SMTP_PASS:
import { WorkerMailer } from "@ryyr/worker-mailer"
import { preset } from "@ryyr/worker-mailer/convenience"
const mailer = await WorkerMailer.connect(preset("gmail", env))
await mailer.send({
from: "you@gmail.com",
to: "friend@example.com",
subject: "Sent via Gmail",
text: "Hello from Cloudflare Workers!",
})
await mailer.close()Available providers: "gmail", "outlook", "sendgrid".
Full control over the connection lifecycle:
import { WorkerMailer } from "@ryyr/worker-mailer"
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "app-password",
authType: ["plain"],
})
const result = await mailer.send({
from: { name: "App", email: "noreply@example.com" },
to: [
{ name: "Alice", email: "alice@example.com" },
"bob@example.com",
],
subject: "Hello from Worker Mailer",
text: "Plain text body",
html: "<h1>Hello</h1><p>HTML body</p>",
})
console.log(result.messageId) // "<...@example.com>"
console.log(result.accepted) // ["alice@example.com", "bob@example.com"]
console.log(result.responseTime) // 230 (ms)
await mailer.close()Note: Port auto-inference sets TLS mode automatically:
- Port 465 →
secure: true, startTls: false(implicit TLS)- Other ports →
secure: false, startTls: true(STARTTLS)- Invalid combinations (e.g. port 587 +
secure: true, port 465 +startTls: true) throw immediately.
MockMailer implements the Mailer interface without making any network connections. Use it for unit tests:
import { MockMailer } from "@ryyr/worker-mailer/testing"
const mock = new MockMailer()
await mock.send({
from: "test@example.com",
to: "user@example.com",
subject: "Test",
text: "Hello",
})
console.log(mock.sendCount) // 1
console.log(mock.lastEmail?.options.subject) // "Test"
console.log(mock.hasSentTo("user@example.com")) // true
console.log(mock.hasSentWithSubject("Test")) // true
console.log(mock.sentEmails) // ReadonlyArray of all sent emails
mock.clear() // reset stateimport { MockMailer, assertSent } from "@ryyr/worker-mailer/testing"
const mock = new MockMailer()
await mock.send({
from: "app@example.com",
to: "user@example.com",
subject: "Welcome",
text: "Hello",
headers: { "X-Trace": "abc123" },
})
assertSent(mock)
.from("app@example.com")
.to("user@example.com")
.withSubject("Welcome")
.withHeader("X-Trace", "abc123")
.exists()
mock.assertSendCount(1)
mock.assertNthSent(1).withText("Hello").exists()const failingMock = new MockMailer({
simulateError: new Error("SMTP connection failed"),
})
const slowMock = new MockMailer({
simulateDelay: 500, // 500ms delay per send
})Sign outgoing emails with DKIM (RSA-SHA256 via Web Crypto API):
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
authType: ["plain"],
dkim: {
domainName: "example.com",
keySelector: "mail",
privateKey: env.DKIM_PRIVATE_KEY, // PKCS#8 PEM string or CryptoKey
},
})DKIM can also be configured via environment variables (SMTP_DKIM_DOMAIN, SMTP_DKIM_SELECTOR, SMTP_DKIM_PRIVATE_KEY).
type DkimOptions = {
domainName: string
keySelector: string
privateKey: string | CryptoKey
headerFieldNames?: string[] // headers to sign (default: from, to, subject, date, message-id)
canonicalization?: "relaxed/relaxed" | "relaxed/simple" | "simple/relaxed" | "simple/simple"
}Embed images directly in HTML emails using CID references:
await mailer.send({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Check out this image",
html: '<p>Here is the logo:</p><img src="cid:logo123" />',
inlineAttachments: [
{
cid: "logo123",
filename: "logo.png",
content: pngUint8Array,
mimeType: "image/png",
},
],
})type InlineAttachment = {
cid: string // Content-ID referenced in HTML (e.g. "cid:logo123")
filename: string
content: string | Uint8Array | ArrayBuffer
mimeType?: string
}Generate iCalendar (.ics) invitations and attach them to emails:
import { createCalendarEvent } from "@ryyr/worker-mailer/calendar"
const event = createCalendarEvent({
summary: "Team Meeting",
start: new Date("2025-02-01T10:00:00Z"),
end: new Date("2025-02-01T11:00:00Z"),
organizer: { name: "Alice", email: "alice@example.com" },
attendees: [
{ name: "Bob", email: "bob@example.com", rsvp: true },
],
location: "Conference Room A",
description: "Weekly sync",
})
await mailer.send({
from: "alice@example.com",
to: "bob@example.com",
subject: "Meeting Invite: Team Meeting",
text: "You are invited to a meeting.",
calendarEvent: event,
})type CalendarEventOptions = {
summary: string
start: Date
end: Date
organizer: { name?: string; email: string }
attendees?: { name?: string; email: string; rsvp?: boolean }[]
location?: string
description?: string
uid?: string
reminderMinutes?: number
method?: "REQUEST" | "CANCEL" | "REPLY"
url?: string
}
type CalendarEventPart = {
content: string
method?: "REQUEST" | "CANCEL" | "REPLY"
}Attach lifecycle hooks to intercept and observe email sending:
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
authType: ["plain"],
hooks: {
beforeSend: async (email) => {
// Modify the email before sending
return { ...email, subject: `[PREFIX] ${email.subject}` }
// Return `false` to skip sending, or `undefined` to send as-is
},
afterSend: (email, result) => {
console.log(`Sent ${result.messageId} to ${result.accepted.join(", ")}`)
},
onSendError: (email, error) => {
console.error(`Failed to send to ${email.to}:`, error.message)
},
onConnected: (info) => {
console.log(`Connected to ${info.host}:${info.port}`)
},
onDisconnected: (info) => {
console.log("Disconnected:", info.reason)
},
onReconnecting: (info) => {
console.log(`Reconnecting (attempt ${info.attempt})...`)
},
onFatalError: (error) => {
console.error("Fatal SMTP error:", error.message)
},
},
})type SendHooks = {
beforeSend?: (email: EmailOptions) => Promise<EmailOptions | false | undefined> | EmailOptions | false | undefined
afterSend?: (email: EmailOptions, result: SendResult) => Promise<void> | void
onSendError?: (email: EmailOptions, error: Error) => Promise<void> | void
onConnected?: (info: { host: string; port: number }) => void
onDisconnected?: (info: { reason?: string }) => void
onReconnecting?: (info: { attempt: number }) => void
onFatalError?: (error: Error) => void
}Hooks remain supported, but you can now attach reusable plugins through plugins:
import type { MailPlugin } from "@ryyr/worker-mailer"
import { telemetryPlugin } from "@ryyr/worker-mailer/plugins"
const auditPlugin: MailPlugin = {
name: "audit",
beforeSend: (email) => ({ ...email, subject: `[AUDIT] ${email.subject}` }),
}
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
authType: ["plain"],
plugins: [
auditPlugin,
telemetryPlugin({
onEvent: (event) => {
console.log(event.type, event)
},
}),
],
})
const result = await mailer.send(
{
from: "sender@example.com",
to: ["ok@example.com", "reject@example.com"],
subject: "Recipient validation",
text: "This is a dry run.",
},
{ dryRun: true },
)
console.log(result.accepted)
console.log(result.rejected)
console.log(result.response) // "DRY RUN: no message sent"Render the raw MIME message of an email without sending it. Useful for debugging and testing:
import { previewEmail } from "@ryyr/worker-mailer/preview"
const preview = previewEmail({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Preview this",
html: "<h1>Hello</h1>",
})
console.log(preview.raw) // Full MIME message string
console.log(preview.headers) // Parsed headers as Record<string, string>
console.log(preview.html) // HTML body (if present)Check whether the SMTP connection is alive using the SMTP NOOP command:
const isAlive = await mailer.ping()
console.log(isAlive) // true or falseBoth WorkerMailer and WorkerMailerPool support ping().
fromEnv() (and helpers that use it) reads the following variables. The default prefix is SMTP_ but can be customized:
| Variable | Required | Description |
|---|---|---|
SMTP_HOST |
✅ | SMTP server hostname |
SMTP_PORT |
✅ | SMTP server port |
SMTP_USER |
Username for authentication | |
SMTP_PASS |
Password for authentication | |
SMTP_SECURE |
Use TLS from the start (true/false/1/0/yes/no) |
|
SMTP_START_TLS |
Upgrade to TLS via STARTTLS (true/false/1/0/yes/no) |
|
SMTP_AUTH_TYPE |
Auth methods, comma-separated (plain,login,cram-md5) |
|
SMTP_EHLO_HOSTNAME |
Custom EHLO hostname | |
SMTP_LOG_LEVEL |
Log level (NONE / ERROR / WARN / INFO / DEBUG) |
|
SMTP_MAX_RETRIES |
Maximum retry count on transient failures | |
SMTP_DKIM_DOMAIN |
DKIM signing domain | |
SMTP_DKIM_SELECTOR |
DKIM key selector | |
SMTP_DKIM_PRIVATE_KEY |
DKIM private key (PKCS#8 PEM) |
Custom prefix example — fromEnv(env, "MAIL_") reads MAIL_HOST, MAIL_PORT, etc.
// Create a connected instance
static connect(options: WorkerMailerOptions): Promise<WorkerMailer>
// Send an email
send(options: EmailOptions): Promise<SendResult>
// Close the connection
close(): Promise<void>
// Health check via SMTP NOOP
ping(): Promise<boolean>
// Async disposal (await using)
[Symbol.asyncDispose](): Promise<void>Round-robin connection pool. Distributes send() calls across multiple connections.
// Create a pool (default poolSize: 3)
new WorkerMailerPool(options: WorkerMailerOptions & { poolSize?: number })
// Open all connections
connect(): Promise<this>
// Send via the next connection (round-robin)
send(options: EmailOptions): Promise<SendResult>
// Health check — pings all connections
ping(): Promise<boolean>
// Close all connections
close(): Promise<void>
// Async disposal
[Symbol.asyncDispose](): Promise<void>// Parse env vars into WorkerMailerOptions
fromEnv(env: Record<string, unknown>, prefix?: string): WorkerMailerOptions
// Parse env vars and connect in one step
createFromEnv(env: Record<string, unknown>, prefix?: string): Promise<WorkerMailer>
// Parse, connect, send, and close — all in one call
sendOnce(env: Record<string, unknown>, email: EmailOptions, prefix?: string): Promise<SendResult>Returns a WorkerMailerOptions with the provider's host/port/TLS pre-filled.
Credentials are read from SMTP_USER and SMTP_PASS in the env object.
preset(provider: SmtpProvider, env: Record<string, unknown>): WorkerMailerOptions
type SmtpProvider = "gmail" | "outlook" | "sendgrid"
// Gmail → smtp.gmail.com:587, STARTTLS, auth: plain
// Outlook → smtp.office365.com:587, STARTTLS, auth: plain
// SendGrid → smtp.sendgrid.net:587, STARTTLS, auth: plainsendBatch(
mailer: Mailer,
emails: EmailOptions[],
options?: BatchOptions,
): Promise<BatchResult[]>type BatchOptions = {
continueOnError?: boolean // default: true
concurrency?: number // default: 1 (sequential)
}
type BatchResult = {
success: boolean
email: EmailOptions
result?: SendResult
error?: Error
}validateEmail(address: string): ValidationResult
validateEmailBatch(addresses: string[]): Map<string, ValidationResult>
type ValidationResult = { valid: true } | { valid: false; reason: string }Full EmailOptions type:
type User = { name?: string; email: string }
type EmailOptions = {
from: string | User
to: string | string[] | User | User[]
reply?: string | User
cc?: string | string[] | User | User[]
bcc?: string | string[] | User | User[]
subject: string
text?: string
html?: string
headers?: Record<string, string>
attachments?: Attachment[]
inlineAttachments?: InlineAttachment[]
calendarEvent?: CalendarEventPart
dsnOverride?: DsnOptions
}
type Attachment = {
filename: string
content: string | Uint8Array | ArrayBuffer
mimeType?: string // e.g. "text/plain", "application/pdf"
}
type InlineAttachment = {
cid: string
filename: string
content: string | Uint8Array | ArrayBuffer
mimeType?: string
}
type CalendarEventPart = {
content: string
method?: "REQUEST" | "CANCEL" | "REPLY"
}Note: Attachment content can be a base64-encoded
string,Uint8Array, orArrayBuffer. IfmimeTypeis omitted, it will be inferred from the filename extension.
Example with an attachment:
await mailer.send({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Invoice attached",
text: "Please find the invoice attached.",
attachments: [
{
filename: "invoice.pdf",
content: base64EncodedString,
mimeType: "application/pdf",
},
],
})Every send() call returns a SendResult:
type SendResult = {
messageId: string // Message-ID assigned by the server
accepted: string[] // Addresses accepted by the server
rejected: string[] // Addresses rejected by the server
responseTime: number // Round-trip time in milliseconds
response: string // Raw SMTP response string
}createTestEmail() generates a ready-to-send email for verifying your SMTP setup:
import { createTestEmail } from "@ryyr/worker-mailer/testing"
const testEmail = createTestEmail({
from: "sender@example.com",
to: "recipient@example.com",
smtpHost: "smtp.gmail.com", // optional — shown in the email body
})
await mailer.send(testEmail)The generated email includes a timestamp and connection details so you can confirm delivery at a glance.
Set DSN options when connecting. They apply to all emails sent through that connection:
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
authType: ["plain"],
dsn: {
RET: { FULL: true },
NOTIFY: { SUCCESS: true, FAILURE: true },
},
})Override connection-level DSN for individual emails via dsnOverride:
await mailer.send({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Important",
text: "Please confirm receipt.",
dsnOverride: {
envelopeId: "unique-envelope-id-123",
RET: { HEADERS: true },
NOTIFY: { SUCCESS: true, FAILURE: true, DELAY: true },
},
})Receive connection-level fatal errors (disconnect, reconnection failure, etc.) without wrapping every call in try/catch:
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
authType: ["plain"],
hooks: {
onFatalError: (error) => {
console.error("SMTP fatal error:", error.message)
},
onSendError: (email, error) => {
console.error(`Send failed for ${email.subject}:`, error.message)
},
},
})Automatically retry transient failures (default: 3):
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
authType: ["plain"],
maxRetries: 5,
})Automatically reconnect if the underlying TCP connection is lost (default: false):
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
authType: ["plain"],
autoReconnect: true,
})WorkerMailerPool manages multiple SMTP connections and distributes sends via round-robin:
import { WorkerMailerPool } from "@ryyr/worker-mailer"
const pool = new WorkerMailerPool({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
authType: ["plain"],
poolSize: 5,
})
await pool.connect()
// Sends are distributed across 5 connections
await pool.send({
from: "a@example.com",
to: "b@example.com",
subject: "Hi",
text: "Hello",
})
await pool.close()Send multiple emails through a single connection with concurrency control:
import { WorkerMailer } from "@ryyr/worker-mailer"
import { sendBatch } from "@ryyr/worker-mailer/batch"
const mailer = await WorkerMailer.connect({ /* ... */ })
const emails = [
{ from: "noreply@example.com", to: "user1@example.com", subject: "Hello 1", text: "Hi" },
{ from: "noreply@example.com", to: "user2@example.com", subject: "Hello 2", text: "Hi" },
{ from: "noreply@example.com", to: "user3@example.com", subject: "Hello 3", text: "Hi" },
]
const results = await sendBatch(mailer, emails, {
concurrency: 3, // send up to 3 emails at a time
continueOnError: true, // don't stop on individual failures
})
for (const r of results) {
if (r.success) {
console.log(`✅ ${r.result!.messageId}`)
} else {
console.error(`❌ ${r.error!.message}`)
}
}
await mailer.close()Both WorkerMailer and WorkerMailerPool implement Symbol.asyncDispose, so you can use await using for automatic cleanup:
{
await using mailer = await WorkerMailer.connect({ /* ... */ })
await mailer.send({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Auto-cleanup",
text: "Connection is closed automatically when this block exits.",
})
} // mailer.close() is called automatically hereFull reference for all connection options:
type WorkerMailerOptions = {
host: string // SMTP server hostname
port: number // SMTP server port (587, 465, etc.)
secure?: boolean // Use TLS from the start (default: false)
startTls?: boolean // Upgrade to TLS via STARTTLS (default: true)
username?: string // SMTP auth username
password?: string // SMTP auth password
authType?: AuthType[] // ["plain"] | ["login"] | ["cram-md5"] — always an array
logLevel?: LogLevel // NONE, ERROR, WARN, INFO, DEBUG
dsn?: Omit<DsnOptions, "envelopeId"> // Connection-level DSN settings
socketTimeoutMs?: number // Socket timeout in ms (default: 60000)
responseTimeoutMs?: number // SMTP response timeout in ms (default: 30000)
ehloHostname?: string // Custom EHLO hostname (default: host)
maxRetries?: number // Retry count on failure (default: 3)
autoReconnect?: boolean // Auto-reconnect on disconnect (default: false)
hooks?: SendHooks // Send & lifecycle hooks
plugins?: MailPlugin[] // Reusable send/lifecycle plugins
dkim?: DkimOptions // DKIM signing configuration
}type SendOptions = {
dryRun?: boolean // Validate MAIL FROM / RCPT TO only, skip DATA
}Send emails to/from internationalized addresses (e.g. 用户@例え.jp) per RFC 6531. SMTPUTF8 is fully automatic — the mailer detects non-ASCII characters in email addresses, checks server SMTPUTF8 capability via EHLO, and adds the SMTPUTF8 parameter to MAIL FROM when needed. No configuration required.
import { WorkerMailer } from "@ryyr/worker-mailer"
const mailer = await WorkerMailer.connect({
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "password",
})
// SMTPUTF8 is automatically used when addresses contain non-ASCII characters
await mailer.send({
from: { name: "送信者", email: "用户@例え.jp" },
to: "受信者@example.com",
subject: "Hello",
text: "International email!",
})Build proper In-Reply-To and References headers for email threading.
import { threadHeaders } from "@ryyr/worker-mailer/thread"
const headers = threadHeaders({
inReplyTo: "<original-msg-id@example.com>",
references: "<root-msg-id@example.com>",
})
// { "In-Reply-To": "<original-msg-id@example.com>", References: "<root-msg-id@example.com> <original-msg-id@example.com>" }
await mailer.send({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Re: Discussion",
text: "Reply content",
headers,
})Generate RFC 8058 one-click unsubscribe headers — required by Gmail and Yahoo since February 2024 for bulk senders.
import { unsubscribeHeaders } from "@ryyr/worker-mailer/unsubscribe"
const headers = unsubscribeHeaders({
url: "https://example.com/unsubscribe?token=abc123",
mailto: "unsubscribe@example.com?subject=unsubscribe",
})
// { "List-Unsubscribe": "<https://example.com/unsubscribe?token=abc123>, <mailto:unsubscribe@example.com?subject=unsubscribe>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" }
await mailer.send({
from: "newsletter@example.com",
to: "subscriber@example.com",
subject: "Weekly Update",
html: "<p>Newsletter content</p>",
headers,
})Convert HTML emails to plain text automatically. Useful for generating the text part of multipart emails.
import { htmlToText } from "@ryyr/worker-mailer/html-to-text"
const html = `<h1>Hello</h1><p>Check out <a href="https://example.com">our site</a>.</p>`
// Default: 78-character word wrap, links preserved
htmlToText(html)
// "Hello\n\nCheck out our site (https://example.com)."
// Strip link URLs from output
htmlToText(html, { preserveLinks: false })
// "Hello\n\nCheck out our site."
// Custom word wrap width (or false to disable)
htmlToText(html, { wordwrap: 40 })
htmlToText(html, { wordwrap: false })Mustache-like template engine with HTML auto-escaping. Supports variables, sections, and raw output.
import { render, compile } from "@ryyr/worker-mailer/template"
// Simple variable substitution (HTML-escaped by default)
render("Hello, {{name}}!", { name: "Alice" })
// "Hello, Alice!"
// Raw output with triple braces (no escaping)
render("Hello, {{{html}}}!", { html: "<b>World</b>" })
// "Hello, <b>World</b>!"
// Sections — conditionally render blocks
render("{{#premium}}Welcome, premium user!{{/premium}}", { premium: true })
// "Welcome, premium user!"
// Sections — iterate over arrays
render("{{#items}}- {{name}}\n{{/items}}", { items: [{ name: "A" }, { name: "B" }] })
// "- A\n- B\n"
// Pre-compile for repeated use
const template = compile("Hello, {{name}}!")
template({ name: "Alice" }) // "Hello, Alice!"
template({ name: "Bob" }) // "Hello, Bob!"Fluent builder API with method chaining for constructing emails programmatically.
import { MailBuilder } from "@ryyr/worker-mailer/builder"
const email = new MailBuilder()
.from("sender@example.com")
.to("recipient@example.com", "another@example.com")
.cc({ name: "CC User", email: "cc@example.com" })
.replyTo("replies@example.com")
.subject("Hello from MailBuilder")
.text("Plain text body")
.html("<p>HTML body</p>")
.header("X-Custom", "value")
.attach({
filename: "report.pdf",
content: pdfBuffer,
mimeType: "application/pdf",
})
.build()
await mailer.send(email)The .build() method returns an EmailOptions object compatible with mailer.send(). All setter methods return this for chaining.
- Port 25 is blocked: Cloudflare Workers cannot make outbound connections on port 25. Use port 587 or 465 instead.
- Connection limits: Each Worker instance has a limit on concurrent TCP connections. Close connections when done, or use
await usingfor automatic cleanup.
This project is a fork of zou-yu/worker-mailer. Thanks to the original author for the foundational SMTP implementation on Cloudflare Workers.