@push.rocks/smartmta
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. Automatic MX record discovery means you just call sendEmail() and smartmta figures out where to deliver. 🚀
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm install @push.rocks/smartmta
# or
npm install @push.rocks/smartmta
After installation, run pnpm build to compile the Rust binary (mailer-bin). The Rust binary is required — smartmta will not start without it.
Overview
@push.rocks/smartmta is a complete mail server solution — SMTP server, SMTP client, email security, content scanning, and delivery management — all built with a custom SMTP implementation. The SMTP engine runs as a Rust binary for maximum performance, communicating with the TypeScript orchestration layer via JSON-over-stdin/stdout IPC.
⚡ What's Inside
| Module | What It Does |
|---|---|
| Rust SMTP Server | High-performance SMTP engine in Rust — TCP/TLS listener, STARTTLS, AUTH, pipelining, per-connection rate limiting |
| Rust SMTP Client | Outbound delivery with connection pooling, retry logic, TLS negotiation, DKIM signing — all in Rust |
| DKIM | Key generation, signing, and verification — per domain, with automatic rotation |
| SPF | Full SPF record validation via Rust |
| DMARC | Policy enforcement and verification |
| Email Router | Pattern-based routing with priority, forward/process/reject actions plus terminal local deliver/store actions |
| Bounce Manager | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
| Content Scanner | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
| IP Reputation | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
| Rate Limiter | Hierarchical rate limiting (global, per-domain, per-IP) |
| Delivery Queue | Persistent queue with exponential backoff retry |
| Template Engine | Email templates with variable substitution |
| Domain Registry | Multi-domain management with per-domain configuration |
| DNS Manager | Automatic DNS record management (MX, SPF, DKIM, DMARC) |
🏗️ Architecture
┌──────────────────────────────────────────────────────────────┐
│ UnifiedEmailServer │
│ (orchestrates all components, emits events) │
├───────────┬───────────┬──────────────┬───────────────────────┤
│ Email │ Security │ Delivery │ Configuration │
│ Router │ Stack │ System │ │
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
│ │ Act │ │ │ DMARC │ │ │ Retry │ │ │ DKIMCreator │ │
│ └──────┘ │ │ IPRep │ │ └──────────┘ │ │ Templates │ │
│ │ │ Scan │ │ │ └────────────────┘ │
│ │ └───────┘ │ │ │
├───────────┴───────────┴──────────────┴───────────────────────┤
│ Rust Security Bridge (smartrust IPC) │
├──────────────────────────────────────────────────────────────┤
│ Rust Acceleration Layer │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
│ │ SMTP Client │ │IP Rep/Content │ │ MIME/Bounce │ │
│ │ TLS/AUTH │ │ Scanning │ │ Detection │ │
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Data flow for inbound mail:
- 📨 Rust SMTP server accepts the connection and handles the SMTP protocol
- 🛂 If recipient validation is enabled, Rust asks TypeScript to approve each
RCPT TObeforeDATA - 🔒 On
DATAcompletion, Rust runs the security pipeline in-process (DKIM/SPF/DMARC verification, content scanning, IP reputation check) — zero IPC round-trips - 📤 Rust emits an
emailReceivedevent via IPC with pre-computed security results attached - 🔀 TypeScript accepts responsibility or rejects the message through routing and optional hooks
- ✅ Rust sends the final SMTP response only after TypeScript returns the acceptance decision
Data flow for outbound mail:
- 📝 TypeScript constructs the email and calls
sendEmail()(defaults to MTA mode) - 🔍 MTA mode automatically resolves MX records for each recipient domain, sorts by priority, and groups recipients for efficient delivery
- 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
- 📬 Result (accepted/rejected recipients, server response) returned to TypeScript
Usage
🚀 Setting Up the Email Server
The central entry point is UnifiedEmailServer, which orchestrates the Rust SMTP server, routing, security, and delivery:
import { UnifiedEmailServer } from '@push.rocks/smartmta';
const emailServer = new UnifiedEmailServer(dcRouterRef, {
// Ports to listen on (465 = implicit TLS by default, 25/587 = STARTTLS)
ports: [25, 587, 465],
// Public SMTP hostname used for greeting/banner and as the default outbound identity
hostname: 'mail.example.com',
// Multi-domain configuration
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns',
dkim: {
selector: 'default',
keySize: 2048,
rotateKeys: true,
rotationInterval: 90,
},
rateLimits: {
outbound: { messagesPerMinute: 100 },
inbound: { messagesPerMinute: 200, connectionsPerIp: 20 },
},
},
],
// Routing rules (evaluated by priority, highest first)
routes: [
{
name: 'catch-all-forward',
priority: 10,
match: {
recipients: '*@example.com',
},
action: {
type: 'forward',
forward: {
host: 'internal-mail.example.com',
port: 25,
},
},
},
{
name: 'reject-spam-senders',
priority: 100,
match: {
senders: '*@spamdomain.com',
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Sender rejected by policy',
},
},
},
{
name: 'authenticated-outbound-relay',
priority: 90,
match: {
authenticated: true,
recipients: '*@*',
},
action: {
type: 'process',
// External relay is denied by default. Set allowRelay only on routes that explicitly permit it.
allowRelay: true,
},
},
],
smtp: {
// Enabled by default: ask TypeScript to approve every RCPT TO before DATA.
recipientValidation: true,
// Listener port that uses implicit TLS. Defaults to 465 when present in ports.
// Set this for remapped backend listeners, e.g. 10465 behind a proxy.
securePort: 465,
recipientValidationTimeoutMs: 5000,
// Timeout for hooks.onMessageData and hooks.onAcceptEnvelope before
// returning a temporary SMTP failure.
messageAcceptanceTimeoutMs: 30000,
// Set required to true only on backend listeners reached through trusted proxies.
proxyProtocol: {
required: false,
trustedIps: [],
},
},
hooks: {
async onRcptTo(context) {
if (context.abortSignal?.aborted) {
return { accepted: false, smtpCode: 451, smtpMessage: 'Recipient policy timeout' };
}
const recipientDomain = context.rcptTo.split('@').pop()?.toLowerCase();
if (recipientDomain === 'example.com') {
return { accepted: true };
}
if (context.authenticated) {
// External relay still requires a matched route with action.allowRelay: true.
return { accepted: true };
}
return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
},
async onMessageData(context) {
// Runs for envelopes WITHOUT a resolved store/deliver action.
// Return accepted only after the consumer has taken responsibility for the message.
// await durableStore.save(context.email, context.rawMessage, { signal: context.abortSignal });
return { accepted: true, continueProcessing: false };
},
async onAcceptEnvelope(context) {
// Required terminal durable owner for envelopes with a resolved
// `store` or `deliver` action. Durably persist the EXACT raw bytes and
// the per-recipient plan before resolving — the SMTP 250 is only sent
// after this hook resolves.
// const plan = emailServer.createAcceptedEnvelopeDispatchPlan(context, idempotencyKeys);
// await durableStore.saveEnvelope(context.rawMessage, plan, { signal: context.abortSignal });
},
},
// Authentication settings for the SMTP server.
// Legacy plaintext `users` and hashed `accounts` coexist; accounts win on
// username collision. See "Authenticated Accounts And Send Scopes" below.
auth: {
required: false,
methods: ['PLAIN', 'LOGIN'],
users: [{ username: 'outbound', password: 'secret' }],
accounts: [{
username: 'newsletter-service',
credential: { verifier: storedScramVerifier },
scope: {
senders: ['newsletter@example.com'],
recipients: { mode: 'any' },
},
}],
},
// TLS material is provided in memory; SmartMTA does not read certificate files.
tls: {
certPem: await certificateStore.getCertificatePem('mail.example.com'),
keyPem: await certificateStore.getPrivateKeyPem('mail.example.com'),
},
outbound: {
// Optional override for outbound EHLO/HELO identity
hostname: 'smtp-out.example.com',
// Optional physical connection proxy for one-shot edge egress tunnels.
// The logical SMTP host remains the MX/target host for TLS identity.
connectionProxyProvider: async ({ host, port, ehloDomain }) => null,
},
// Durable message bodies belong in SmartBucket (or another managed blob store).
// Use { storageMode: 'memory' } only for deliberately non-durable runtimes.
queue: {
storageMode: 'managed',
storageManager: smartBucketBlobStorage,
},
maxMessageSize: 25 * 1024 * 1024, // 25 MB
maxClients: 500,
});
// start() boots the Rust SMTP server, security bridge, DNS records, and delivery queue
await emailServer.start();
🔒 Note:
start()will throw if the Rust binary is not compiled. Runpnpm buildfirst.SmartMTA requires Node.js 22 or newer.
hostnameis the public SMTP identity for greetings and outbound delivery by default. It is not a bind address.
dcRouterRef.storageManagerimplementsIStorageManagerfor small textual state such as DKIM keys, routes, bounce metadata, and reputation cache; use SmartData for that adapter. Queue bodies and attachment blobs useIBlobStorageManager, normally backed by SmartBucket. SmartMTA has no local-filesystem persistence fallback.
Durable Delivery Checkpoints
Managed queues store complete queue items as SmartBucket blobs. Schema V2 adds per-recipient delivery states and a durable bounce outbox:
- A recipient becomes
deliveredonly after the remote SMTP server's final250response. - A permanent
5xx RCPT TOresponse atomically stores both the recipient's terminal state and its pending bounce operation in one queue-item blob mutation. - A temporary
4xxresponse remains unresolved, so only that recipient is attempted against the next MX host or a later queue retry. Delivered and permanently rejected recipients are never resent. - Bounce operation IDs are deterministic hashes of the queue item, normalized recipient, and operation kind. Pending work is drained immediately at delivery-system startup and periodically afterward, including work on terminal queue items.
- Observer and event-listener failures are logged and isolated after the durable state transition; they cannot reclassify delivery state or statistics.
V1 queue blobs remain readable. They hydrate with an empty bounce outbox and are upgraded to V2 on their next mutation; SmartMTA never invents retroactive bounce work. V2 blobs are intentionally not readable by V7, so rolling back after a V2 write is unsupported.
The managed-prefix ownership guard is process-local only. It coordinates queues only when they share the exact same IBlobStorageManager object instance and normalized storage prefix. Different storage-wrapper instances and different JavaScript processes are not coordinated; the embedder must enforce one delivery owner or provide an external distributed lease. This guard does not provide distributed ownership. Because queue writes are forward-only to schema V2, V7 rollback remains unsupported after the first V2 mutation.
Bounce delivery is at-least-once. BounceManager uses the operation ID as a durable processed marker and deduplicates concurrent/restarted calls. Its in-process completed-operation LRU is only a hot-duplicate optimization, bounded to 10,000 entries with a 24-hour TTL; cache misses always re-read the durable marker. A process crash after an external side effect but before the queue completion checkpoint can cause the operation to be presented again. Custom bounceHandler.processSmtpFailure implementations receive options.operationId and must deduplicate it in their own durable store; SmartMTA does not claim exactly-once execution for custom handlers.
🛂 Inbound Recipient Policy And Relay Control
SmartMTA validates each RCPT TO before the client can enter DATA by default. The default policy evaluates routes and accepts configured local domains. External recipients are rejected unless the matched route action explicitly sets allowRelay: true. Set smtp.recipientValidation: false only on fully trusted private listeners where another layer performs recipient and relay policy.
Use allowRelay only for intentional relay routes, for example authenticated submission or tightly scoped internal networks. Broad process routes do not grant relay permission by themselves, and deliver routes can never relay — allowRelay is ignored for them.
Submission-permission routes never claim a locally terminated recipient
A submission route grants an authenticated client permission to relay. It does not turn a local delivery into a remote one. A matched route is therefore ignored for a given recipient, and resolution falls through to that domain's inbound policy, when all of the following hold:
- the recipient domain is registered and its
inboundPolicy.defaultActionisstore, so this server really is the destination; - the action is
process— the only action whose transport is DNS MX relay; - the route names no recipient scope:
match.recipientsis absent, empty, or consists only of match-all patterns (*,*@*).
Without this, an authenticated submission addressed to a domain this server hosts is accepted with 250, relayed to that domain's MX, and destroyed by the self-MX loop guard. Everything else keeps today's behaviour: recipient-scoped process routes still win (consumers use them for local addresses handled by their own acceptance pipeline), store/deliver/forward/reject actions always win, and a domain registered for outbound identity only — no inboundPolicy, or the send-only defaultAction: 'reject' — keeps relaying because its real MX lives elsewhere. Encoding recipient intent in match.headers is not recognised for this purpose.
hooks.onRcptTo, hooks.onMessageData, and hooks.onAcceptEnvelope are awaited asynchronously. If a hook throws or times out, SmartMTA returns a temporary SMTP failure. The hook context includes abortSignal; long-running consumers should observe it and stop work promptly. A custom onRcptTo hook may accept local recipients using application-specific lookup, but external relay is still blocked unless a matched route explicitly sets allowRelay: true.
hooks.onMessageData runs after DATA and before the final SMTP response — but only for envelopes with no resolved store or deliver action; terminal local deliveries go through hooks.onAcceptEnvelope instead and never invoke onMessageData. Return { accepted: true } only after your application has accepted responsibility for the message, such as after durable persistence. Set continueProcessing: false when the consumer owns all downstream processing.
hooks.onAcceptEnvelope is the terminal durable owner for any envelope containing a resolved store or deliver action. It receives the exact raw RFC822 bytes (rawMessage), the complete per-recipient route resolution (resolvedRecipientRoutes), and the Rust security verdicts (securityResults). It must durably persist the message before resolving — the SMTP 250 is only sent after the hook resolves. Throwing an AcceptEnvelopeRejectionError with a 5xx code rejects the envelope permanently with that status; any other error or a smtp.messageAcceptanceTimeoutMs timeout refuses the envelope with a temporary 451. Use createAcceptedEnvelopeDispatchPlan() inside the hook to snapshot the resolved plan for later replay-safe dispatchAcceptedEnvelope() calls. Both AcceptEnvelopeRejectionError and IAcceptEnvelopeContext are exported.
Use smtp.securePort when the implicit TLS listener uses a remapped backend port instead of public 465; the configured secure port must also be present in ports, otherwise start() throws.
Trusted PROXY protocol v1 support is configured with smtp.proxyProtocol. Only enable required: true on backend listeners that are reached through a trusted local or private proxy, and always provide trustedIps. Required PROXY mode fails closed when no trusted peers are configured.
🔒 AUTH Is Only Offered On An Encrypted Transport
AUTH is never advertised in the EHLO capabilities of a cleartext session, and an AUTH command on a cleartext session is refused with 538 5.7.11 Encryption required for requested authentication mechanism — so credentials are never solicited or accepted over an unencrypted channel, even by a client that ignores the missing capability. This applies to PLAIN, LOGIN and SCRAM-SHA-256 alike. A cleartext listener with TLS material still advertises STARTTLS, so clients upgrade first and then see AUTH.
A transport counts as encrypted when this server terminated TLS (implicit TLS on smtp.securePort, or after a successful STARTTLS) or when the port is declared in smtp.tlsTerminatedPorts:
smtp: {
// Public 465 is terminated by the edge proxy, which forwards the decrypted
// stream to this backend port. The transport is encrypted end-to-client even
// though this process performed no handshake.
tlsTerminatedPorts: [10465],
proxyProtocol: { required: true, trustedIps: ['127.0.0.1', '::1'] },
}
That is a trust assertion about the peer, so it is only honoured together with proxyProtocol.required and a non-empty trustedIps — the exact-peer PROXY check is what proves the connection really came from the terminator. start() throws when a listed port is not one of the configured ports, is the implicit-TLS securePort, or PROXY protocol is not required. Sessions on such a port report secure: true to hooks and do not advertise STARTTLS.
updateOptions restarts only the Rust SMTP listener when the AUTH presence transitions, when tls.certPem/tls.keyPem are replaced (certificate renewal), or when smtp.tlsTerminatedPorts changes — those are listener-level Rust configuration and would otherwise be merged into the options and never take effect.
⚠️ Changed in v9.3: listeners that previously advertised and accepted
AUTHin cleartext no longer do. If TLS material never reachestls.certPem/tls.keyPem, a plain port offers neitherSTARTTLSnorAUTH— verify that submission ports actually receive certificate material, or declare them insmtp.tlsTerminatedPortswhen an upstream proxy terminates TLS.Note that
tls.caPem,tls.minVersionandtls.ciphersare currently accepted but not consumed by the Rust listener; onlycertPemandkeyPemtake effect.
🔐 Authenticated Accounts And Send Scopes
auth.accounts defines authenticated SMTP submission accounts with hashed credentials and per-account send authorization. It coexists with the legacy plaintext auth.users array; when both define the same username, the account wins.
import type { ISmtpAuthAccount } from '@push.rocks/smartmta';
const accounts: ISmtpAuthAccount[] = [
{
username: 'newsletter-service',
// Hashed SCRAM-SHA-256 verifier — the plaintext password is never stored.
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
// StoredKey = SHA256(HMAC-SHA256(SaltedPassword, 'Client Key'))
// ServerKey = HMAC-SHA256(SaltedPassword, 'Server Key')
credential: {
verifier: {
algorithm: 'SCRAM-SHA-256',
salt: '<base64 salt>',
iterations: 4096, // minimum
storedKey: '<base64 StoredKey>',
serverKey: '<base64 ServerKey>',
},
},
scope: {
// "send AS who": enforced against the SMTP MAIL FROM envelope sender
// AND the parsed message header From address.
senders: ['newsletter@example.com', '*@mail.example.com'],
// "send TO who": optional; absent or mode 'any' = unrestricted.
recipients: { mode: 'patterns', patterns: ['*@example.com'] },
},
},
{
username: 'trusted-app',
// Plaintext credential is supported for migration, but verifiers are preferred.
credential: { password: 'app-password' },
// No scope: unrestricted, exactly like a legacy auth.users entry.
},
];
server.updateOptions({ auth: { accounts } });
Enforcement semantics:
- Credentials —
PLAIN/LOGINpasswords are verified against the stored verifier by derivingStoredKeyfrom the presented password (PBKDF2-HMAC-SHA256 on the async crypto threadpool) and comparing in constant time.SCRAM-SHA-256clients are served the stored verifier fields directly, so the plaintext never needs to exist server-side. Accounts withenabled: falsealways fail authentication on both mechanisms. Verification is fully in-memory — no I/O inside the Rust auth callback window. - Sender scope ("send AS who") — enforced twice. At the first
RCPT TO, an out-of-scopeMAIL FROMis refused with553before any recipient is accepted (withsmtp.recipientValidation: falsethis check still runs, but only atDATA). AtDATA, the envelope sender (taken from session envelope truth, never the parsed message alone) is re-checked per recipient, and every parsed headerFrommailbox must independently be in scope — header-From spoofing behind an in-scope envelope, including multi-mailboxFrom:headers, is refused with553. - Recipient scope ("send TO who") — with
recipients.mode: 'patterns', everyRCPT TOmatching no pattern is refused with550.mode: 'any'or an absentrecipientsblock leaves recipients unrestricted. - Patterns are exact addresses (
a@b.com) or glob patterns (*@b.com), matched case-insensitively with identical semantics to route matching. A scoped account sending with an emptyMAIL FROM <>(null sender) is refused unless a pattern explicitly matches —senders: ['*']is the documented escape hatch. - Route binding — a route with
match.authenticatedUser: 'newsletter-service'(or an array of usernames) matches only sessions authenticated as that account (exact, case-sensitive username comparison), so dedicated processing or relay routes can be attached per account. Unauthenticated sessions never match such routes. - Rejections surface through
onRecipientRejectedwith the reasonssender-scope,recipient-scope, andaccount-disabled. - Live enablement — when a configuration update transitions between zero and one-or-more accounts/users,
updateOptionsrestarts only the Rust SMTP listener so theAUTHcapability appears or disappears without a process restart. A failed listener restart logs loudly, emitssmtpListenerRestartFailed, and leavesisSmtpListenerActive()false — surface that next togetBridgeState()in health endpoints. - An account without a
scopeis authenticated but unrestricted — identical to legacyauth.usersbehavior, which itself is unchanged.
Configuration is validated loudly at construction and updateOptions time: unusable accounts (empty scope.senders, mode: 'patterns' without patterns, both or neither credential kind, malformed base64 keys, iterations outside [4096, 1000000], duplicate usernames) throw instead of degrading into silent deny-all or silent auth failure. Note that updateOptions({ auth }) replaces the whole auth block — include users, required, and methods again if they should persist.
Deriving verifiers: deriveScramVerifier(password, iterations?) is the canonical embedder-facing derivation, matching exactly what the server verifies at AUTH time — persist only the returned verifier. The exported SCRAM_DEFAULT_ITERATIONS (600,000) is OWASP-aligned to protect human-chosen passwords against offline attack on a leaked verifier store, at roughly 100-150ms per derivation and per PLAIN/LOGIN authentication on the bounded async pool. Machine-generated high-entropy secrets gain nothing from high counts — callers may pass any lower value within [SCRAM_MIN_ITERATIONS, SCRAM_MAX_ITERATIONS]. assertValidAuthAccounts is exported too, so embedders can pre-validate database-sourced account sets with the exact rules the server enforces.
Compatibility probe: smartMtaCapabilities.smtpAuthAccountsAndScopes.
📧 Sending Emails (Automatic MX Discovery)
The recommended way to send email is sendEmail(). It defaults to MTA mode, which automatically resolves MX records for each recipient domain via DNS — you don't need to know the destination mail server:
import { Email, UnifiedEmailServer } from '@push.rocks/smartmta';
// Build an email
const email = new Email({
from: 'sender@example.com',
to: ['alice@gmail.com', 'bob@company.org'],
subject: 'Hello from smartmta! 🚀',
text: 'Plain text body',
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
priority: 'high',
attachments: [
{
filename: 'report.pdf',
content: pdfBuffer,
contentType: 'application/pdf',
},
],
});
// Send — MTA mode auto-discovers MX servers for gmail.com and company.org
const emailId = await emailServer.sendEmail(email);
// Optionally specify a delivery mode explicitly
const emailId2 = await emailServer.sendEmail(email, 'mta');
sendEmail() returns the delivery queue item ID, which you can later use with queue/status APIs.
In MTA mode, smartmta:
- 🔍 Resolves MX records for each recipient domain (e.g.
gmail.com,company.org) - 📊 Sorts MX hosts by priority (lowest = highest priority per RFC 5321)
- 🔄 Tries each MX host in order until delivery succeeds
- 🌐 Falls back to the domain's A record if no MX records exist
- 📦 Groups recipients by domain for efficient batch delivery
- 🔑 Signs outbound mail with DKIM automatically
📮 Delivery Modes
sendEmail() accepts a mode parameter that controls how the email is delivered:
public async sendEmail(
email: Email,
mode: EmailProcessingMode = 'mta', // 'mta' | 'forward' | 'process'
route?: IEmailRoute,
options?: {
skipSuppressionCheck?: boolean;
ipAddress?: string;
isTransactional?: boolean;
}
): Promise<string>
| Mode | Description |
|---|---|
mta (default) |
Auto MX discovery — resolves MX records via DNS, delivers directly to the recipient's mail server. No relay configuration needed. |
forward |
Relay delivery — forwards the email to a configured SMTP host (e.g. an internal mail gateway or third-party relay). |
process |
Scan + deliver — runs the content scanning / security pipeline first, then delivers via auto MX resolution. |
Self-MX loop guard: MTA delivery refuses any MX host that equals the server's own hostname (hostname or outbound.hostname). A domain whose MX record points back at this server would otherwise re-enter the inbound pipeline on every delivery attempt — an infinite mail loop. Such deliveries fail with MX for <domain> points back at this server — refusing self-delivery to avoid a mail loop. The comparison is hostname-based; if a target only reveals the loop at the IP level (e.g. an A-record fallback), embedders should add an IP-level guard in their connectionProxyProvider.
Routes with action deliver never enter MTA mode: they are terminal local deliveries through the durable acceptance path (see Route Action Types below), so the self-MX guard is structurally unreachable for them. Pre-9.x persisted queue items that still carry a deliver action fail loudly at delivery time with error type deliver_action_relay_unsupported (failure class localTopology) instead of being relayed or bounced as a misleading self-MX loop.
📬 Direct SMTP Delivery (Low-Level)
For cases where you know the exact target SMTP server (e.g. relaying to a specific host), use the lower-level sendOutboundEmail():
// Send directly to a known SMTP server (bypasses MX resolution)
const result = await emailServer.sendOutboundEmail('smtp.example.com', 587, email, {
auth: { user: 'sender@example.com', pass: 'your-password' },
dkimDomain: 'example.com',
dkimSelector: 'default',
});
console.log(`Accepted: ${result.accepted.join(', ')}`);
console.log(`Response: ${result.response}`);
// -> Accepted: recipient@example.com
// -> Response: 2.0.0 Ok: queued
The sendOutboundEmail method:
- 🔑 Automatically resolves DKIM keys from the
DKIMCreatorfor the specified domain and exactdkimSelector(defaulting todefault), so the loaded private key always matches the selector written into the signature - 🔗 Uses connection pooling in Rust for direct sends — reuses TCP/TLS connections across sends
- ⏱️ Configurable connection and socket timeouts via
outboundoptions on the server - 🪪 Uses
outbound.hostnameas the SMTP identity when configured, otherwise falls back tohostname; a connection proxy'sehloHostnameoverrides both for that connection
For deployments that need outbound traffic to leave through a separate edge, configure outbound.connectionProxyProvider. SmartMTA keeps the logical host/port as the SMTP target for MX identity and TLS/SNI, but dials the returned connectHost/connectPort as the physical TCP endpoint. Proxied sends automatically disable Rust connection pooling and call close() in a finally block, which is suitable for one-shot local proxies. The provider context also includes senderDomain (lowercased domain of the sending address, undefined when unparsable) so providers can route by sender, and the returned proxy may set ehloHostname to override the EHLO/HELO identity for that connection — e.g. the selected egress edge's PTR hostname.
const emailServer = new UnifiedEmailServer(dcRouter, {
hostname: 'mx.example.com',
outbound: {
hostname: 'mx.example.com',
connectionProxyProvider: async (context) => {
const proxy = await remoteIngressHub.startEgressTcpProxy({
edgeId: 'edge-fra-01',
logicalHost: context.host,
port: context.port,
serverFirst: true,
});
return {
connectHost: proxy.listenHost,
connectPort: proxy.listenPort,
poolKey: proxy.proxyId,
close: () => remoteIngressHub.stopEgressTcpProxy(proxy.proxyId),
};
},
},
});
🔑 DKIM Signing & Key Management
DKIM key management is handled by DKIMCreator, which generates, stores, and rotates keys per domain. Signing is performed automatically by the Rust SMTP client during outbound delivery:
import { DKIMCreator } from '@push.rocks/smartmta';
// Small structured key state is supplied by the embedder through managed storage.
// Use a SmartData-backed IStorageManager; SmartMTA never writes keys to local files.
const dkimCreator = new DKIMCreator(storageManager);
// Auto-generate keys if they don't exist
await dkimCreator.handleDKIMKeysForDomain('example.com');
// Get the DNS record you need to publish
const dnsRecord = await dkimCreator.getDNSRecordForDomain('example.com');
console.log(dnsRecord);
// -> { type: 'TXT', name: 'default._domainkey.example.com', value: 'v=DKIM1; k=rsa; p=...' }
// Check if keys need rotation
const needsRotation = await dkimCreator.needsRotation('example.com', 'default', 90);
if (needsRotation) {
const newSelector = await dkimCreator.rotateDkimKeys('example.com', 'default', 2048);
console.log(`Rotated to selector: ${newSelector}`);
}
When UnifiedEmailServer.start() is called:
- In the default
automaticmode, DKIM keys are generated or loaded for every configured domain and rotation followsrotationInterval. - With
dkimKeyProvisioning: 'caller-managed', SmartMTA only loads the requested selector from managed storage; it never generates or rotates keys. - A requested missing or invalid key fails closed before SMTP delivery. SmartMTA never falls back to unsigned delivery.
- Signing is applied exactly once, by the Rust SMTP client at final outbound transmission.
- When a route enables signing without explicit
dkimOptions, the selector is resolved from the sending domain's registry configuration (domains[].dkim.selector); the hardcoded'default'selector is only the last fallback.
Embedders can check smartMtaCapabilities.selectorCorrectDkimSigning, callerManagedDkimKeys, structuredSmtpTransactions, typedDeliveryFailures, terminalLocalDeliverActions (deliver routes are terminal local deliveries through the durable acceptance path), and smtpAuthAccountsAndScopes (authenticated accounts with hashed credentials and send scopes) as compatibility probes.
🛡️ Email Authentication (SPF, DKIM, DMARC)
All verification is powered by the Rust binary. For inbound mail, UnifiedEmailServer runs the full security pipeline automatically — DKIM, SPF, DMARC, content scanning, and IP reputation in a single Rust pass. Results are attached as headers (Received-SPF, X-DKIM-Result, X-DMARC-Result).
For direct TypeScript usage, SpfVerifier is exported as a lightweight SPF record parser. DKIM signing/key management is handled through DKIMCreator and the Rust bridge; SPF, DKIM, and DMARC verification results are produced by the automatic inbound security pipeline.
import { SpfVerifier } from '@push.rocks/smartmta';
// Local SPF record parsing
const spfVerifier = new SpfVerifier();
const spfRecord = spfVerifier.parseSpfRecord('v=spf1 ip4:192.0.2.0/24 -all');
// -> { version: 'spf1', mechanisms: [...], modifiers: {} }
🔀 Email Routing
Pattern-based routing engine with priority ordering and flexible match criteria. Routes are evaluated by priority (highest first):
import { EmailRouter } from '@push.rocks/smartmta';
const router = new EmailRouter([
{
name: 'admin-mail',
priority: 100,
match: {
recipients: 'admin@example.com',
authenticated: true,
},
action: {
// Terminal local delivery. In UnifiedEmailServer this requires a
// configured hooks.onAcceptEnvelope durable local-delivery owner.
type: 'deliver',
},
},
{
name: 'external-forward',
priority: 50,
match: {
recipients: '*@example.com',
sizeRange: { max: 10 * 1024 * 1024 }, // under 10MB
},
action: {
type: 'forward',
forward: {
host: 'backend-mail.internal',
port: 25,
preserveHeaders: true,
},
},
},
{
name: 'process-with-scanning',
priority: 10,
match: {
recipients: '*@*',
},
action: {
type: 'process',
process: {
scan: true,
dkim: true,
queue: 'normal',
},
},
},
]);
// Evaluate routes against an email context
const matchedRoute = await router.evaluateRoutes(emailContext);
Route Action Types
| Action | Description |
|---|---|
forward |
Forward the email to another SMTP server via the Rust SMTP client |
deliver |
Terminal local delivery. The envelope is durably accepted through hooks.onAcceptEnvelope before the SMTP 250; nothing is enqueued for outbound MX relay. |
store |
Terminal local delivery like deliver, with explicit retention configuration (store.retentionDays) |
process |
Queue for processing (with optional content scanning and DKIM signing), then deliver via automatic MX resolution |
reject |
Reject with a configurable SMTP error code and message |
Local delivery: deliver and store
deliver and store are the two terminal local-delivery actions. An envelope that resolves to either goes through the durable acceptance path: SmartMTA calls hooks.onAcceptEnvelope with the exact raw message and the complete per-recipient plan, and only sends the SMTP 250 after the hook resolves — the consumer's durably persisted copy IS the delivery. In dispatchAcceptedEnvelope, both report status: 'stored' with no queueId; the actionType field distinguishes them.
- A
deliverroute requires a configuredhooks.onAcceptEnvelope. Without it the configuration is unservable and is rejected loudly whenever routes are supplied or loaded: the constructor,updateEmailRoutes,updateOptionscalls that includeroutes, and persisted-route hydration all throw. If hook state is nevertheless broken at runtime (for example anupdateOptionscall that replaceshookswithout passingroutes), deliver recipients are refused with a temporary451 Local delivery temporarily unavailableatRCPT TO/DATA. Mail is never accepted and then destroyed. delivernever relays. Non-local recipients matched by a deliver route are refused with550atRCPT TO, andallowRelayis ignored for deliver actions.deliverenvelopes skiphooks.onMessageData, bounce-notification detection, and the inboundprocessEmailByModepipeline — exactly likestoreenvelopes. Rust-computed security verdicts are still delivered in the acceptance context.delivercarries no retention metadata; the consumer owns retention for delivered messages. Usestorewithstore.retentionDayswhen retention must be explicit.
⚠️ Changed in v9: before v9,
deliversilently meant "relay via MX discovery". On a domain whose MX points back at this server, that accepted mail with250and then terminally bounced it on the self-MX loop guard — destroying mail.delivernow delivers locally, and configurations that cannot do so fail at config/accept time.
Every action can include allowRelay?: boolean. It defaults to false. Non-local recipients are accepted only when the matched action explicitly sets allowRelay: true; configured local domains remain eligible for inbound delivery without relay permission. allowRelay has no effect on deliver actions.
Match Criteria
| Criterion | Description |
|---|---|
recipients |
Glob patterns for recipient addresses (*@example.com) |
senders |
Glob patterns for sender addresses |
clientIp |
IP addresses or CIDR ranges |
authenticated |
Require authentication status |
authenticatedUser |
Require the session to be authenticated as this exact username (string or array of usernames) |
headers |
Match specific headers (string or RegExp) |
sizeRange |
Message size constraints ({ min?, max? }) |
subject |
Subject line pattern (string or RegExp) |
hasAttachments |
Filter by attachment presence |
⏱️ Rate Limiting
Hierarchical rate limiting to protect your server and maintain deliverability:
import { Delivery } from '@push.rocks/smartmta';
const { UnifiedRateLimiter } = Delivery;
const rateLimiter = new UnifiedRateLimiter({
global: {
maxMessagesPerMinute: 1000,
maxRecipientsPerMessage: 500,
maxConnectionsPerIP: 20,
maxErrorsPerIP: 10,
maxAuthFailuresPerIP: 5,
blockDuration: 600000, // 10 minutes
},
domains: {
'example.com': {
maxMessagesPerMinute: 100,
maxRecipientsPerMessage: 50,
},
},
});
// Check before sending
const allowed = rateLimiter.checkMessageLimit(
'sender@example.com',
'192.168.1.1',
recipientCount,
undefined,
'example.com'
);
if (!allowed.allowed) {
console.log(`Rate limited: ${allowed.reason}`);
}
📬 Bounce Management
Automatic bounce detection (via Rust), classification, and suppression tracking:
import { Core } from '@push.rocks/smartmta';
const { BounceManager } = Core;
const bounceManager = new BounceManager({ storageManager: smartDataStorage });
// Process an SMTP failure
const bounce = await bounceManager.processSmtpFailure(
'recipient@example.com',
'550 5.1.1 User unknown',
{
operationId: 'queue-item-and-recipient-derived-operation-id',
originalEmailId: 'msg-123',
}
);
// -> { bounceType: 'invalid_recipient', bounceCategory: 'hard', ... }
// Check if an address is suppressed due to bounces
const suppressed = bounceManager.isEmailSuppressed('recipient@example.com');
// Manage the suppression list
bounceManager.addToSuppressionList('bad@example.com', 'repeated hard bounces');
bounceManager.removeFromSuppressionList('recovered@example.com');
📝 Email Templates
Template engine with variable substitution for transactional and notification emails:
import { Core } from '@push.rocks/smartmta';
const { TemplateManager } = Core;
const templates = new TemplateManager({
from: 'noreply@example.com',
footerHtml: '<p>© 2026 Example Corp</p>',
});
// Register a template
templates.registerTemplate({
id: 'welcome',
name: 'Welcome Email',
description: 'Sent to new users',
from: 'welcome@example.com',
subject: 'Welcome, {{name}}!',
bodyHtml: '<h1>Welcome, {{name}}!</h1><p>Your account is ready.</p>',
bodyText: 'Welcome, {{name}}! Your account is ready.',
category: 'transactional',
});
// Create an Email object from the template
const email = await templates.createEmail('welcome', {
to: 'newuser@example.com',
variables: { name: 'Alice' },
});
🌍 DNS Management
DNS behavior is explicit per domain and DKIM ownership mode:
const emailServer = new UnifiedEmailServer(dcRouterRef, {
hostname: 'mail.example.com',
domains: [
{
domain: 'example.com',
dnsMode: 'external-dns', // managed via Cloudflare API
},
],
// ... other config
});
// In automatic key-provisioning mode, internal DNS domains register the
// configured MX/SPF/DMARC handlers and the generated selector-specific DKIM record.
// External DNS domains are validated and report the required public changes.
await emailServer.start();
// With dkimKeyProvisioning: 'caller-managed', startup only validates the exact
// configured selector in managed storage and reads public DNS for validation.
// It performs no key generation/rotation, local DNS handler registration,
// DNS record write/delete, or provisioning fallback.
🪵 Logging
smartmta logs through its own Smartlog instance, exported as baseLogger. Console output is enabled by default so an embedded smartmta never logs into the void. Embedders can attach additional destinations (e.g. an ops log buffer):
import { baseLogger } from '@push.rocks/smartmta';
baseLogger.addLogDestination(myLogDestination); // any smartlog ILogDestination
♻️ Bridge Resilience
The Rust mailer-bin process is supervised by RustSecurityBridge:
- Concurrent command handling — outbound SMTP sends run as detached tasks in the Rust management loop; an in-flight delivery never blocks health-check pings, recipient-policy callbacks, or inbound event forwarding.
- Health checks — a
pingevery 30 s; two consecutive failures kill and restart the process. - Auto-restart — any unexpected exit triggers restart with exponential backoff (1 s → 30 s). After
maxRestartAttemptsfast attempts the bridge enters thefailedstate but keeps retrying at the slow cadence forever — it never silently stays dead. - SMTP server restore — after a restart the SMTP listener is restored; if the restore fails, the process is killed and the restart cycle continues rather than reporting a half-alive bridge.
- State surface —
UnifiedEmailServer.getBridgeState()returns'idle' | 'starting' | 'running' | 'restarting' | 'failed' | 'stopped'; the server emitsbridgeFailed,bridgeRestarting, andbridgeRecoveredevents for embedders' alerting.isSmtpListenerActive()reports whether the Rust SMTP listener is accepting connections, and a failed listener reconfiguration emitssmtpListenerRestartFailed.
Tune with RustSecurityBridge.configure({ maxRestartAttempts, healthCheckIntervalMs, healthCheckTimeoutMs, restartBackoffBaseMs, restartBackoffMaxMs }).
🦀 Rust Acceleration Layer
Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via @push.rocks/smartrust (JSON-over-stdin/stdout IPC). The Rust workspace lives at rust/ with four crates:
| Crate | Status | Purpose |
|---|---|---|
mailer-core |
Complete | Email types, validation, MIME building, bounce detection |
mailer-security |
Complete | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
mailer-smtp |
Complete | Full SMTP protocol engine — TCP/TLS server + client, STARTTLS, AUTH, pipelining, connection pooling, in-process security pipeline |
mailer-bin |
Complete | CLI + smartrust IPC bridge — wires everything together |
What Runs Where
| Operation | Runs In | Why |
|---|---|---|
| SMTP server (port listening, protocol, TLS) | 🦀 Rust | Performance, memory safety, zero-copy parsing |
| SMTP client (outbound delivery, connection pooling) | 🦀 Rust | Connection management, TLS negotiation |
| DKIM signing & verification | 🦀 Rust | Crypto-heavy, benefits from native speed |
| SPF validation | 🦀 Rust | DNS lookups with async resolver |
| DMARC policy checking | 🦀 Rust | Integrates with SPF/DKIM results |
| IP reputation / DNSBL | 🦀 Rust | Parallel DNS queries |
| Content scanning (text patterns) | 🦀 Rust | Regex engine performance |
| Bounce detection (pattern matching) | 🦀 Rust | Regex engine performance |
| Email validation & MIME building | 🦀 Rust | Parsing performance |
| Email routing & orchestration | 🟦 TypeScript | Business logic, flexibility |
| Delivery queue & retry | 🟦 TypeScript | State management, persistence |
| Template rendering | 🟦 TypeScript | String interpolation |
| Domain & DNS management | 🟦 TypeScript | API integrations |
📁 Project Structure
smartmta/
├── ts/ # TypeScript source
│ ├── mail/
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
│ │ ├── delivery/ # DeliveryQueue, DeliverySystem, RateLimiter
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
│ │ └── security/ # DKIMCreator, SpfVerifier
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge
├── rust/ # Rust workspace
│ └── crates/
│ ├── mailer-core/ # Email types, validation, MIME, bounce detection
│ ├── mailer-security/ # DKIM, SPF, DMARC, IP reputation, content scanning
│ ├── mailer-smtp/ # Full SMTP server + client (TCP/TLS, rate limiting, pooling)
│ └── mailer-bin/ # CLI + smartrust IPC bridge
├── test/ # TypeScript test suite
├── dist_ts/ # Compiled TypeScript output
└── dist_rust/ # Compiled Rust binaries
🧪 Testing
The project has comprehensive test coverage with both unit and end-to-end tests:
# Build Rust binary first
pnpm build
# Run all tests
pnpm test
# Run specific test files
tstest test/test.e2e.server-lifecycle.node.ts --verbose --timeout 60
tstest test/test.e2e.inbound-smtp.node.ts --verbose --timeout 60
tstest test/test.e2e.routing-actions.node.ts --verbose --timeout 60
tstest test/test.e2e.outbound-delivery.node.ts --verbose --timeout 60
E2E tests exercise the full pipeline — starting UnifiedEmailServer, connecting via raw TCP sockets, sending SMTP transactions, verifying routing actions, and testing outbound delivery through a mock SMTP receiver.
API Reference
Exported Classes (top-level)
| Class | Description |
|---|---|
UnifiedEmailServer |
🎯 Main entry point — orchestrates SMTP server, routing, security, and delivery |
Email |
Email message class with validation, attachments, headers, and RFC 822 serialization |
EmailRouter |
Pattern-based route matching and evaluation engine |
DomainRegistry |
Multi-domain configuration manager |
DnsManager |
Automatic DNS record management |
DKIMCreator |
DKIM key generation, storage, rotation |
SpfVerifier |
Lightweight SPF record parser |
Namespaced Exports
| Namespace | Classes |
|---|---|
Core |
Email, EmailValidator, TemplateManager, BounceManager |
Delivery |
UnifiedDeliveryQueue, MultiModeDeliverySystem, DeliveryStatus, UnifiedRateLimiter |
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.