2026-07-26 22:03:38 +00:00
2026-07-26 22:03:38 +00:00
2026-07-26 22:03:38 +00:00
2026-02-10 15:54:09 +00:00
2026-07-26 22:03:38 +00:00

@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 requiredsmartmta 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:

  1. 📨 Rust SMTP server accepts the connection and handles the SMTP protocol
  2. 🛂 If recipient validation is enabled, Rust asks TypeScript to approve each RCPT TO before DATA
  3. 🔒 On DATA completion, Rust runs the security pipeline in-process (DKIM/SPF/DMARC verification, content scanning, IP reputation check) — zero IPC round-trips
  4. 📤 Rust emits an emailReceived event via IPC with pre-computed security results attached
  5. 🔀 TypeScript accepts responsibility or rejects the message through routing and optional hooks
  6. Rust sends the final SMTP response only after TypeScript returns the acceptance decision

Data flow for outbound mail:

  1. 📝 TypeScript constructs the email and calls sendEmail() (defaults to MTA mode)
  2. 🔍 MTA mode automatically resolves MX records for each recipient domain, sorts by priority, and groups recipients for efficient delivery
  3. 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
  4. 📬 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. Run pnpm build first.

SmartMTA requires Node.js 22 or newer.

hostname is the public SMTP identity for greetings and outbound delivery by default. It is not a bind address.

dcRouterRef.storageManager implements IStorageManager for small textual state such as DKIM keys, routes, bounce metadata, and reputation cache; use SmartData for that adapter. Queue bodies and attachment blobs use IBlobStorageManager, 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 delivered only after the remote SMTP server's final 250 response.
  • A permanent 5xx RCPT TO response atomically stores both the recipient's terminal state and its pending bounce operation in one queue-item blob mutation.
  • A temporary 4xx response 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.defaultAction is store, 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.recipients is 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 AUTH in cleartext no longer do. If TLS material never reaches tls.certPem/tls.keyPem, a plain port offers neither STARTTLS nor AUTH — verify that submission ports actually receive certificate material, or declare them in smtp.tlsTerminatedPorts when an upstream proxy terminates TLS.

Note that tls.caPem, tls.minVersion and tls.ciphers are currently accepted but not consumed by the Rust listener; only certPem and keyPem take 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:

  • CredentialsPLAIN/LOGIN passwords are verified against the stored verifier by deriving StoredKey from the presented password (PBKDF2-HMAC-SHA256 on the async crypto threadpool) and comparing in constant time. SCRAM-SHA-256 clients are served the stored verifier fields directly, so the plaintext never needs to exist server-side. Accounts with enabled: false always 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-scope MAIL FROM is refused with 553 before any recipient is accepted (with smtp.recipientValidation: false this check still runs, but only at DATA). At DATA, the envelope sender (taken from session envelope truth, never the parsed message alone) is re-checked per recipient, and every parsed header From mailbox must independently be in scope — header-From spoofing behind an in-scope envelope, including multi-mailbox From: headers, is refused with 553.
  • Recipient scope ("send TO who") — with recipients.mode: 'patterns', every RCPT TO matching no pattern is refused with 550. mode: 'any' or an absent recipients block 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 empty MAIL 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 onRecipientRejected with the reasons sender-scope, recipient-scope, and account-disabled.
  • Live enablement — when a configuration update transitions between zero and one-or-more accounts/users, updateOptions restarts only the Rust SMTP listener so the AUTH capability appears or disappears without a process restart. A failed listener restart logs loudly, emits smtpListenerRestartFailed, and leaves isSmtpListenerActive() false — surface that next to getBridgeState() in health endpoints.
  • An account without a scope is authenticated but unrestricted — identical to legacy auth.users behavior, 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 DKIMCreator for the specified domain and exact dkimSelector (defaulting to default), 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 outbound options on the server
  • 🪪 Uses outbound.hostname as the SMTP identity when configured, otherwise falls back to hostname; a connection proxy's ehloHostname overrides 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 automatic mode, DKIM keys are generated or loaded for every configured domain and rotation follows rotationInterval.
  • 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 deliver route requires a configured hooks.onAcceptEnvelope. Without it the configuration is unservable and is rejected loudly whenever routes are supplied or loaded: the constructor, updateEmailRoutes, updateOptions calls that include routes, and persisted-route hydration all throw. If hook state is nevertheless broken at runtime (for example an updateOptions call that replaces hooks without passing routes), deliver recipients are refused with a temporary 451 Local delivery temporarily unavailable at RCPT TO/DATA. Mail is never accepted and then destroyed.
  • deliver never relays. Non-local recipients matched by a deliver route are refused with 550 at RCPT TO, and allowRelay is ignored for deliver actions.
  • deliver envelopes skip hooks.onMessageData, bounce-notification detection, and the inbound processEmailByMode pipeline — exactly like store envelopes. Rust-computed security verdicts are still delivered in the acceptance context.
  • deliver carries no retention metadata; the consumer owns retention for delivered messages. Use store with store.retentionDays when retention must be explicit.

⚠️ Changed in v9: before v9, deliver silently meant "relay via MX discovery". On a domain whose MX points back at this server, that accepted mail with 250 and then terminally bounced it on the self-MX loop guard — destroying mail. deliver now 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>&copy; 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 ping every 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 maxRestartAttempts fast attempts the bridge enters the failed state 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 surfaceUnifiedEmailServer.getBridgeState() returns 'idle' | 'starting' | 'running' | 'restarting' | 'failed' | 'stopped'; the server emits bridgeFailed, bridgeRestarting, and bridgeRecovered events for embedders' alerting. isSmtpListenerActive() reports whether the Rust SMTP listener is accepting connections, and a failed listener reconfiguration emits smtpListenerRestartFailed.

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

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.

S
Description
an mta implementation as TypeScript package, with network side implemented in rust.
Readme
2.9 MiB
Languages
TypeScript 67.7%
Rust 32.3%