A nodemailer-compatible mail library, Rust-powered, for every runtime: native Rust, Node.js/Bun/Deno (one native addon, no per-runtime rebuild), Cloudflare Workers (real SMTP over genuine outbound TCP, not an HTTP relay), and the browser/other WASM hosts. Same message model, same transport contract β one Rust core, four ways to call it. Built by nesgarbo.
trust because it's Rust (and because you shouldn't have to trust a JS mail stack's
transitive dependency tree with your SMTP credentials).
π Full documentation, in English and Spanish, lives at
trustmailer.nesgarbo.com β a per-transport
option reference (SMTP, Sendmail, SES, Stream, JSON, Cloudflare Workers, browser/WASM),
a Message/Addresses/Attachments guide, an error reference,
and a real, reproducible comparison against nodemailer
β throughput numbers, an output-parity test, and nodemailer's currently-published
advisories, all backed by scripts in benchmarks/. This README stays
intentionally shorter than the docs site; it's the "am I in the right place" overview,
not the reference.
Nodemailer is the de facto standard for composing and sending mail from JavaScript, but
it's Node-only β it reaches into net/tls/child_process, none of which exist in a
browser tab or a true sandboxed edge isolate (Cloudflare Workers). trustmailer keeps the
same mental model (a Message, a pluggable Transport, createTransport/sendMail)
but implements it once in Rust and exposes it everywhere:
- Native Rust β
trustmailercrate,cargo add trustmailer. - Node.js, Bun, Deno β
trustmailer-node, a native addon via napi-rs, API-shaped likenodemailer.createTransport(...).sendMail(...). Node-API is a stable ABI, not a Node-specific one: the exact same compiled.nodebinary runs unmodified under Bun (native N-API support) and Deno (N-API support since Deno 2.0, including on Deno Deploy) β verified empirically for all three, seecrates/trustmailer-node/smoke-test.cjs. - Cloudflare Workers β
trustmailer-smtp-cfworkers, real SMTP over thecloudflare:socketsconnect()API (genuine outbound TCP, not an HTTP relay) β verified against a livewrangler dev/workerdrun, see Security/Runtime sections below. - Browser / other WASM hosts β
trustmailer-wasm, viawasm-bindgen, for hosts that are a genuine sandbox with no native-code or raw-TCP escape hatch at all.
trustmailer-core message model, address parsing, MIME builder, Transport trait
ββ trustmailer-smtp-protocol pure SMTP protocol logic (commands/responses/dot-stuffing) [any runtime]
β ββ trustmailer-smtp SMTP over tokio TCP (TLS/STARTTLS, auth, pool, proxy) [native]
β ββ trustmailer-smtp-cfworkers SMTP over cloudflare:sockets connect() [Cloudflare Workers]
ββ trustmailer-sendmail pipes to a local `sendmail` binary [native]
ββ trustmailer-ses Amazon SES v2 HTTPS API, hand-rolled SigV4 [any runtime]
ββ trustmailer-dkim DKIM signing (RSA-SHA256, relaxed/relaxed) [any runtime]
ββ trustmailer-stream renders raw MIME instead of sending (tests, custom relays) [any runtime]
ββ trustmailer-json renders a JSON message instead of sending (tests) [any runtime]
trustmailer facade crate: re-exports core + all native transports, feature-gated
trustmailer-node napi-rs binding β createTransport()/sendMail() for Node.js/Bun/Deno
trustmailer-wasm wasm-bindgen binding β createTransport()/sendMail() for browser/other WASM hosts
trustmailer-core and trustmailer-smtp-protocol have no I/O and compile to wasm32 as-is:
address parsing, MIME building, and SMTP command/response handling are pure functions over
bytes/strings, shared by every binding β only the byte-stream I/O (tokio TCP vs. cloudflare:sockets
vs. nothing at all) differs per transport.
| Transport | Native Rust | Node.js / Bun / Deno (napi addon) | Cloudflare Workers | Browser / other WASM |
|---|---|---|---|---|
| SMTP (TLS/STARTTLS) | β | β | β
β real TCP via cloudflare:sockets, not a relay |
βΒΉ β no raw TCP sockets in a normal web page |
| Sendmail | β | β | βΒ² β no process spawning in the Worker isolate | β β no process spawning, full stop |
| SES (HTTPS API) | β | β | β | β
β reqwest uses fetch under wasm32 |
| Stream / JSON | β | β | β | β |
| DKIM signing | β
(SigningTransport wraps any transport) |
β
(dkim option on createTransport) |
β
(compose with trustmailer-dkim directly) |
β |
ΒΉ Checked, not assumed: Chrome does have a raw-TCP Direct Sockets API, but it's scoped to Isolated Web Apps β a separately packaged, signed, installed app model, not a page loaded at a URL in a normal tab β so it doesn't apply to "browser" in the sense this crate targets (a library a web page imports). No other browser ships anything equivalent.
Β² Checked, not assumed: the Worker isolate genuinely cannot spawn a process β Cloudflare's
own docs are explicit that "Workers run on a V8 isolate with no disk and no way to spawn a
native process." Cloudflare Containers
(a separate product β real Linux containers, with exec() support as of June 2026) can
run an actual sendmail binary, but that's a different compute primitive you'd reach for
directly, not something trustmailer-smtp-cfworkers's Worker-isolate code can touch β
inside a container, you're just running native Rust (or nodemailer) like any other Linux host.
Cloudflare Workers get real SMTP, not a relay β go verify it, don't take the README's word for it.
Workers ship a genuine outbound-TCP API, cloudflare:sockets' connect(), with the platform
handling TLS for you (secureTransport: "on" for implicit TLS, "starttls" + .startTls()
for STARTTLS). The only restriction is port 25 (MX-to-MX relay, blocked β same as every major
cloud provider, to fight spam); ports 587/465 (SMTP submission, what SmtpConfig defaults to
and what real-world nodemailer-style usage actually means) are unrestricted. This is genuinely
verified, not assumed from documentation: crates/trustmailer-smtp-cfworkers/test-worker/
is a real wrangler dev project that sends a full MIME message over a real TCP SMTP
transaction to a local fake SMTP server, reproducible with:
cd crates/trustmailer-smtp-cfworkers
wasm-pack build --target web --out-dir test-worker/pkg
cd test-worker
node fake-smtp-server.cjs 2525 &
npx wrangler dev --local
curl "http://127.0.0.1:8787/?host=127.0.0.1&port=2525"
# {"ok":true,"info":{"accepted":["bob@example.com"],"response":"OK: queued as 12345", ...}}A genuine, narrower limitation remains: an actual browser tab has no raw-TCP or
native-code API at all (no JS mail library can do SMTP from a web page either), and neither
does any other generic wasm32 host that isn't specifically workerd (a plain Deno/Bun WASM
sandbox, wasm-pack test, etc. β cloudflare:sockets is a workerd built-in, not a web
standard). For those, either call the SES transport (a plain HTTPS call) or use the
stream/JSON transport to render the MIME message and hand it to your own relay/API over fetch.
Feature set is at parity with nodemailer's transport surface, including two security fixes found by porting nodemailer's own regression tests (see below). Done:
- Address parsing ported from nodemailer's actual
addressparsertokenizer/state machine: display names, quoted commas, RFC 5322 groups (with depth-limited nested-group flattening), comments-as-name, escapes, domain literals β infallible/permissive like the original, not a strict validator. - MIME builder: multipart/alternative (text/watchHtml/amp/html), multipart/related
(inline
cid:images), multipart/mixed (attachments), calendar events (icalEvent, as atext/calendaralternative),List-*headers (with the RFC 2919List-IDbare-domain form), RFC 2047 header encoding, RFC 2231 extended parameters (filename*=UTF-8''...) for non-ASCII/quote-containing attachment filenames, quoted-printable/base64. - Every header value is CRLF-stripped before being written (see Security below).
- SMTP transport (native): implicit TLS + STARTTLS, PLAIN/LOGIN/XOAUTH2 auth, connection
pooling, SOCKS5 proxy, OAuth2 access-token refresh (
OAuth2TokenProvider), and aSmtpConfig::from_url("smtps://user:pass@host:port")connection-string constructor. - SMTP transport (Cloudflare Workers): the same protocol logic (
trustmailer-smtp-protocol) driven overcloudflare:socketsinstead oftokioβ real outbound TCP, platform-handled TLS, live-verified againstworkerd(see above). - Sendmail, SES (SigV4 hand-rolled, no AWS SDK, with an endpoint override for VPC/testing), stream, JSON transports.
- DKIM signing (RSA-SHA256, relaxed/relaxed canonicalization), composable as
SigningTransport::new(signer, inner_transport)over any other transport, and wired intotrustmailer-node'screateTransport({ dkim: {...} }). - A
well_knownservices table generated from nodemailer's ownservices.json(86 providers/aliases), resolvable by name, alias, bare domain, or full address (lookup_service("user@gmail.com")works).
Two vulnerability classes were found (and fixed) by porting nodemailer's own security regression tests rather than just its happy-path tests β both are the kind of bug that only shows up when you deliberately go looking for the attacker's-eye view:
- MIME header injection (ported from
test/nodemailer/list-headers-test.js, regression test for GHSA-268h-hp4c-crq3): any header value containing a CRLF could terminate that header early and inject arbitrary follow-on headers into the message. Fixed with universal sanitization at the single point every header is written (mime/node.rs::write_header_line), not per call site. - SMTP command injection (ported from
test/smtp-connection/smtp-connection-test.js): theEHLOname andMAIL FROM/RCPT TOenvelope addresses are written directly into SMTP protocol commands. A CRLF in the EHLO name is now stripped at assignment (SmtpConfig::with_ehlo_name); a CRLF/</>in an envelope address is now rejected before any bytes reach the socket (SmtpConnection::send_transaction).
Note: the address parser normalizes bare \n and bare \r to a space during
tokenization (address.rs::tokenize), so display names round-trip consistently
regardless of which line-ending style shows up in free text. This is purely a
display-name normalization concern, not a security boundary β the two SMTP-layer
fixes above are what actually close the injection path today, and are the correct
place for that check regardless (nodemailer validates in the same place, not in its
address parser).
A third bug, this one not from porting a test but from actually running the Cloudflare
Workers path live end-to-end (see above): sendMail()'s JS return value came back as an
empty {} in both trustmailer-wasm and trustmailer-smtp-cfworkers. serde-wasm-bindgen
serializes struct fields as a JS Map by default, and a Map has no own enumerable
properties, so Response.json()/JSON.stringify() silently rendered it as {} β no error,
just quietly-wrong data reaching the caller. Fixed by serializing with
serialize_maps_as_objects(true) in both crates. This is exactly the kind of bug that a
docs-only review or a compile-only check would never surface β it only showed up because
the actual JSON response was inspected end-to-end against a real workerd run.
The fixes above went through a second, independent audit pass afterward β specifically
tasked with re-verifying every fix and looking for what the first pass missed. It found
three more real issues (a reachable panic in the address parser, a bypassable OAuth2
credential-leak guard, a STARTTLS regression the first pass's own fix introduced), all now
fixed and regression-tested. The full, unabridged log of every finding across both
passes β file:line references, what was fixed, what was deliberately left open and
why β is public in AUDIT.md. See also
trustmailer.nesgarbo.com/docs/security
for the narrative version.
The test suite is substantially ported from nodemailer's own test/ directory
(../nodemailer alongside this repo) β same fixtures, same attack payloads, and where
the architectures line up closely enough, the same expected values:
-
Address parser: ~30 cases ported directly from
test/addressparser/, including the group-nesting DoS-protection cases (depth 3000/10000 without a stack overflow). -
DKIM: two golden cross-implementation tests β the exact same RSA key and message signed independently by real nodemailer (via
node, using its actuallib/dkim) and by trustmailer, asserting byte-identicalbh=/b=values. Plus the relaxed-body hash test against nodemailer's realtest/dkim/fixtures/message1.emlfixture. -
MIME builder: behavioral ports of
test/mail-composer/,test/mime-node/andtest/nodemailer/list-headers-test.js(including the CRLF-injection regression suite for all 7list.*keys) β structure/ordering assertions rather than byte-exact output, since trustmailer's exact serialization (boundary format, default transfer encodings) differs from nodemailer's by design. -
SMTP: integration tests against a small hand-rolled plaintext SMTP server (
crates/trustmailer-smtp/tests/fake_smtp.rs) exercising the full EHLO/AUTH/MAIL/ RCPT/DATA transaction, auth success/failure, envelope/DATA rejection, injection rejection, and pool connection reuse β standing in for nodemailer'ssmtp-server-backed suite. PlusSmtpConfig::from_urlparity tests ported fromtest/shared/url-test.js'sparseConnectionUrlcases. -
SES / OAuth2: against small hand-rolled HTTP/1.1 mock servers (no mocking framework, no real AWS/Google calls).
-
Sendmail: spawns real
/bin/cat//bin/shprocesses as sendmail stand-ins (Unix-only), rather than mockingchild_process.spawnlike nodemailer does. -
Cloudflare Workers SMTP: not something
cargo testcan drive (it needs the realworkerdruntime, not just thewasm32target) β verified instead with a realwrangler devproject against a local fake SMTP server; reproduction steps are in the Runtime support matrix section above.trustmailer-smtp-cfworkers's owncargo testcovers what is portable to a native host: pure config/builder logic, with thecloudflare:sockets-dependent codecfg-gated towasm32so it doesn't need--targetto build and test cleanly.
Run everything with cargo test --workspace --exclude trustmailer-node --all-features
(174 tests across 23 suites as of this writing).
nodemailer's suite is ~12,000 lines across ~40 files; the following were deliberately left out rather than silently skipped:
test/smtp-connection/smtp-connection-test.js's socket-buffering edge cases (stray empty lines before/after the greeting, UTF-8 split across socket chunks, duplicate-listener/teardown races) test nodemailer's specific chunk-reassembly implementation, not SMTP protocol behavior β there's no trustmailer equivalent to port them against.test/smtp-connection/http-proxy-client-test.js,secure-socket-test.js,requiretls-test.js,starttls-buffer-test.js: HTTP CONNECT proxies (only SOCKS5 is implemented), RFC 8689 REQUIRETLS, and low-level STARTTLS buffer-boundary handling aren't implemented yet.test/ethereal-test.js,test/nodemailer/ethereal-tls-test.js,test/nodemailer/get-test-message-url-test.js: wrap the Ethereal.email test-account service, which trustmailer doesn't integrate with.test/fetch/*,test/shared/shared-test.js's resolver/logger sections: test nodemailer's internalfetch/logger polyfills, which trustmailer doesn't have (it usesreqwestdirectly).test/errors/errors-test.js: asserts nodemailer's string error-code constants (err.code === 'ECONNECTION'); trustmailer uses a typedErrorenum instead, so the same test doesn't apply, though the enum covers comparable distinctions.test/syntax-compat.js: checks nodemailer's JS ships as Node 6-compatible syntax β not applicable to a Rust project.test/xoauth2/*,test/smtp-transport/oauth2-*: nodemailer's specific XOAUTH2 provisioning/listener-leak edge cases; trustmailer'sOAuth2TokenProviderhas its own mock-server-backed test set instead, covering the same refresh-flow behavior.- Byte-exact MIME output:
test/mail-composer/andtest/mime-node/assert nodemailer's exact serialized bytes (specific boundary strings, default 7bit/ quoted-printable choices, header ordering). trustmailer's builder makes different (still RFC-compliant) choices by design, so these are ported as behavioral/structural assertions instead of string-equality checks.
use trustmailer::{Message, Transport};
use trustmailer::smtp::{Auth, SmtpConfig, SmtpTransport};
#[tokio::main]
async fn main() -> trustmailer::Result<()> {
let config = SmtpConfig::new("smtp.example.com", 587)
.with_auth(Auth::Plain { username: "user".into(), password: "pass".into() });
let transport = SmtpTransport::new(config);
let message = Message::builder()
.from("Alice <alice@example.com>")
.to("bob@example.com")
.subject("Hello")
.text("Hi Bob!")
.html("<b>Hi Bob!</b>")
.build();
let info = transport.send(&message).await?;
println!("sent: {}", info.message_id);
Ok(())
}Identical code on all three β it's the same native addon underneath. (Deno needs a
.cjs/.mjs extension or a package.json with "type" set, per its CJS-detection rules.)
const { createTransport } = require("trustmailer");
const transporter = createTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "user", pass: "pass" },
// dkim: { domainName: "example.com", keySelector: "default", privateKey: "-----BEGIN PRIVATE KEY-----..." },
});
const info = await transporter.sendMail({
from: "Alice <alice@example.com>",
to: "bob@example.com",
subject: "Hello",
text: "Hi Bob!",
html: "<b>Hi Bob!</b>",
});
console.log("sent:", info.messageId);Build with wasm-pack build --target web, import the generated .wasm file as a module
(Wrangler's bundler turns it into a WebAssembly.Module), and initSync β not the
default async init() export, which needs a top-level await β see
crates/trustmailer-smtp-cfworkers/test-worker/src/index.js for the full working example.
import wasmModule from "./pkg/trustmailer_smtp_cfworkers_bg.wasm";
import { initSync, createTransport } from "./pkg/trustmailer_smtp_cfworkers.js";
initSync({ module: wasmModule });
export default {
async fetch() {
const transporter = createTransport({
host: "smtp.gmail.com",
port: 465,
secure: true,
auth: { user: "user@gmail.com", pass: "app-password" },
});
const info = await transporter.sendMail({
from: "Alice <alice@example.com>",
to: "bob@example.com",
subject: "Hello from a Worker",
text: "Sent over a real TCP connection.",
});
return Response.json(info);
},
};import init, { createTransport } from "trustmailer-wasm";
await init();
const transporter = createTransport({
kind: "ses",
region: "us-east-1",
accessKeyId: "...",
secretAccessKey: "...",
});
const info = await transporter.sendMail({
from: "alice@example.com",
to: "bob@example.com",
subject: "Hello",
text: "Hi Bob!",
});# native workspace (core + all native transports + facade)
cargo build --workspace --exclude trustmailer-node
# Node.js/Bun/Deno native addon (run from crates/trustmailer-node) β one build, all three runtimes
npx @napi-rs/cli build --platform --release
node smoke-test.cjs # or: bun smoke-test.cjs / deno run -A smoke-test.cjs
# Cloudflare Workers SMTP (run from crates/trustmailer-smtp-cfworkers)
wasm-pack build --target web
# then see test-worker/ for a full wrangler dev reproduction against a local fake SMTP server
# Browser / other WASM hosts (run from crates/trustmailer-wasm)
wasm-pack build --target webDual-licensed under either of MIT or Apache License, Version 2.0, at your option.