npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ryyr/worker-mailer

v0.2.0

Published

[English](./README.md) | [日本語](./README_ja.md)

Readme

worker-mailer

English | 日本語

License: MIT npm version npm downloads CI

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.

Features

  • 🚀 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 authplain, login, and CRAM-MD5
  • 🪝 Send hooks & pluginsbeforeSend / afterSend / lifecycle plugins
  • 🧪 Mock mailer & assertionsMockMailer plus chainable test helpers
  • 🛰️ Telemetry & dry run — observe sends and validate recipients without sending DATA
  • 👁️ Email previewpreviewEmail() for MIME inspection without sending
  • 🏓 Health checkping() via SMTP NOOP command
  • Zero-config helperssendOnce(), fromEnv(), createFromEnv() read env vars automatically
  • 🏷️ Provider presets — Gmail, Outlook, SendGrid one-liner via preset()
  • 📦 Batch sendingsendBatch() with concurrency control and error handling
  • 🔄 Connection poolWorkerMailerPool with round-robin distribution
  • Email validationvalidateEmail() and validateEmailBatch()
  • 📬 DSN — Delivery Status Notification support
  • 🔁 Auto-reconnect & retries — configurable retry and reconnection
  • 📊 Structured resultsSendResult with detailed response info
  • 🧹 Async disposalSymbol.asyncDispose / await using support
  • 🌐 SMTPUTF8 — international email addresses (RFC 6531)
  • 🔗 Reply threadingthreadHeaders() 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 MailBuilder API with method chaining

Requirements

  • Cloudflare Workers runtime
  • wrangler.toml:
    compatibility_flags = ["nodejs_compat"]

Installation

bun add @ryyr/worker-mailer
# or
npm install @ryyr/worker-mailer

Quick Start

Zero-config with environment variables

The 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: "[email protected]",
			to: "[email protected]",
			subject: "Welcome!",
			text: "Thanks for signing up.",
		})

		return Response.json(result)
	},
}

Provider presets

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: "[email protected]",
	to: "[email protected]",
	subject: "Sent via Gmail",
	text: "Hello from Cloudflare Workers!",
})

await mailer.close()

Available providers: "gmail", "outlook", "sendgrid".

Standard usage

Full control over the connection lifecycle:

import { WorkerMailer } from "@ryyr/worker-mailer"

const mailer = await WorkerMailer.connect({
	host: "smtp.example.com",
	port: 587,
	username: "[email protected]",
	password: "app-password",
	authType: ["plain"],
})

const result = await mailer.send({
	from: { name: "App", email: "[email protected]" },
	to: [
		{ name: "Alice", email: "[email protected]" },
		"[email protected]",
	],
	subject: "Hello from Worker Mailer",
	text: "Plain text body",
	html: "<h1>Hello</h1><p>HTML body</p>",
})

console.log(result.messageId) // "<[email protected]>"
console.log(result.accepted) // ["[email protected]", "[email protected]"]
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.

Mock Mailer (Testing)

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: "[email protected]",
	to: "[email protected]",
	subject: "Test",
	text: "Hello",
})

console.log(mock.sendCount) // 1
console.log(mock.lastEmail?.options.subject) // "Test"
console.log(mock.hasSentTo("[email protected]")) // true
console.log(mock.hasSentWithSubject("Test")) // true
console.log(mock.sentEmails) // ReadonlyArray of all sent emails

mock.clear() // reset state

Assertion helpers

import { MockMailer, assertSent } from "@ryyr/worker-mailer/testing"

const mock = new MockMailer()

await mock.send({
	from: "[email protected]",
	to: "[email protected]",
	subject: "Welcome",
	text: "Hello",
	headers: { "X-Trace": "abc123" },
})

assertSent(mock)
	.from("[email protected]")
	.to("[email protected]")
	.withSubject("Welcome")
	.withHeader("X-Trace", "abc123")
	.exists()

mock.assertSendCount(1)
mock.assertNthSent(1).withText("Hello").exists()

Simulating errors and delays

const failingMock = new MockMailer({
	simulateError: new Error("SMTP connection failed"),
})

const slowMock = new MockMailer({
	simulateDelay: 500, // 500ms delay per send
})

DKIM Signing

Sign outgoing emails with DKIM (RSA-SHA256 via Web Crypto API):

const mailer = await WorkerMailer.connect({
	host: "smtp.example.com",
	port: 587,
	username: "[email protected]",
	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).

Full DKIM options

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"
}

Inline Images

Embed images directly in HTML emails using CID references:

await mailer.send({
	from: "[email protected]",
	to: "[email protected]",
	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",
		},
	],
})

InlineAttachment type

type InlineAttachment = {
	cid: string // Content-ID referenced in HTML (e.g. "cid:logo123")
	filename: string
	content: string | Uint8Array | ArrayBuffer
	mimeType?: string
}

Calendar Invites

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: "[email protected]" },
	attendees: [
		{ name: "Bob", email: "[email protected]", rsvp: true },
	],
	location: "Conference Room A",
	description: "Weekly sync",
})

await mailer.send({
	from: "[email protected]",
	to: "[email protected]",
	subject: "Meeting Invite: Team Meeting",
	text: "You are invited to a meeting.",
	calendarEvent: event,
})

CalendarEventOptions type

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"
}

Send Hooks

Attach lifecycle hooks to intercept and observe email sending:

const mailer = await WorkerMailer.connect({
	host: "smtp.example.com",
	port: 587,
	username: "[email protected]",
	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)
		},
	},
})

SendHooks type

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
}

Plugins, telemetry, and dry run

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: "[email protected]",
	password: "password",
	authType: ["plain"],
	plugins: [
		auditPlugin,
		telemetryPlugin({
			onEvent: (event) => {
				console.log(event.type, event)
			},
		}),
	],
})

const result = await mailer.send(
	{
		from: "[email protected]",
		to: ["[email protected]", "[email protected]"],
		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"

Email Preview

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: "[email protected]",
	to: "[email protected]",
	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)

Health Check (ping)

Check whether the SMTP connection is alive using the SMTP NOOP command:

const isAlive = await mailer.ping()
console.log(isAlive) // true or false

Both WorkerMailer and WorkerMailerPool support ping().

Environment Variables

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.

API Reference

WorkerMailer

// 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>

WorkerMailerPool

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>

fromEnv / createFromEnv / sendOnce

// 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>

Provider Presets

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: plain

sendBatch

sendBatch(
	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 / validateEmailBatch

validateEmail(address: string): ValidationResult
validateEmailBatch(addresses: string[]): Map<string, ValidationResult>

type ValidationResult = { valid: true } | { valid: false; reason: string }

Email Options

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, or ArrayBuffer. If mimeType is omitted, it will be inferred from the filename extension.

Example with an attachment:

await mailer.send({
	from: "[email protected]",
	to: "[email protected]",
	subject: "Invoice attached",
	text: "Please find the invoice attached.",
	attachments: [
		{
			filename: "invoice.pdf",
			content: base64EncodedString,
			mimeType: "application/pdf",
		},
	],
})

Send Result

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
}

Test Helper

createTestEmail() generates a ready-to-send email for verifying your SMTP setup:

import { createTestEmail } from "@ryyr/worker-mailer/testing"

const testEmail = createTestEmail({
	from: "[email protected]",
	to: "[email protected]",
	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.

DSN (Delivery Status Notification)

Connection-level DSN

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: "[email protected]",
	password: "password",
	authType: ["plain"],
	dsn: {
		RET: { FULL: true },
		NOTIFY: { SUCCESS: true, FAILURE: true },
	},
})

Per-email DSN override

Override connection-level DSN for individual emails via dsnOverride:

await mailer.send({
	from: "[email protected]",
	to: "[email protected]",
	subject: "Important",
	text: "Please confirm receipt.",
	dsnOverride: {
		envelopeId: "unique-envelope-id-123",
		RET: { HEADERS: true },
		NOTIFY: { SUCCESS: true, FAILURE: true, DELAY: true },
	},
})

Error Handling

hooks.onFatalError callback

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: "[email protected]",
	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)
		},
	},
})

maxRetries

Automatically retry transient failures (default: 3):

const mailer = await WorkerMailer.connect({
	host: "smtp.example.com",
	port: 587,
	username: "[email protected]",
	password: "password",
	authType: ["plain"],
	maxRetries: 5,
})

autoReconnect

Automatically reconnect if the underlying TCP connection is lost (default: false):

const mailer = await WorkerMailer.connect({
	host: "smtp.example.com",
	port: 587,
	username: "[email protected]",
	password: "password",
	authType: ["plain"],
	autoReconnect: true,
})

Connection Pool

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: "[email protected]",
	password: "password",
	authType: ["plain"],
	poolSize: 5,
})

await pool.connect()

// Sends are distributed across 5 connections
await pool.send({
	from: "[email protected]",
	to: "[email protected]",
	subject: "Hi",
	text: "Hello",
})

await pool.close()

Batch Sending

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: "[email protected]", to: "[email protected]", subject: "Hello 1", text: "Hi" },
	{ from: "[email protected]", to: "[email protected]", subject: "Hello 2", text: "Hi" },
	{ from: "[email protected]", to: "[email protected]", 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()

Using with Async Disposal

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: "[email protected]",
		to: "[email protected]",
		subject: "Auto-cleanup",
		text: "Connection is closed automatically when this block exits.",
	})
} // mailer.close() is called automatically here

WorkerMailerOptions

Full 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
}

SMTPUTF8 (International Email Addresses)

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: "[email protected]",
  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!",
})

Reply Thread Management

Build proper In-Reply-To and References headers for email threading.

import { threadHeaders } from "@ryyr/worker-mailer/thread"

const headers = threadHeaders({
  inReplyTo: "<[email protected]>",
  references: "<[email protected]>",
})
// { "In-Reply-To": "<[email protected]>", References: "<[email protected]> <[email protected]>" }

await mailer.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Re: Discussion",
  text: "Reply content",
  headers,
})

List-Unsubscribe 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: "[email protected]?subject=unsubscribe",
})
// { "List-Unsubscribe": "<https://example.com/unsubscribe?token=abc123>, <mailto:[email protected]?subject=unsubscribe>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" }

await mailer.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Weekly Update",
  html: "<p>Newsletter content</p>",
  headers,
})

HTML to Plain Text

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 })

Template Engine

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!"

Mail Builder API

Fluent builder API with method chaining for constructing emails programmatically.

import { MailBuilder } from "@ryyr/worker-mailer/builder"

const email = new MailBuilder()
  .from("[email protected]")
  .to("[email protected]", "[email protected]")
  .cc({ name: "CC User", email: "[email protected]" })
  .replyTo("[email protected]")
  .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.

Limitations

  • 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 using for automatic cleanup.

Acknowledgements

This project is a fork of zou-yu/worker-mailer. Thanks to the original author for the foundational SMTP implementation on Cloudflare Workers.

License

MIT