SMTP over WebSocket gateway: send mail through port 443 with per-app API keys. Server for the @siwats/mxrelay-consumer protocol, plus a local SMTP agent.
  • TypeScript 76.2%
  • JavaScript 18.4%
  • Shell 5.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Thiraphat 2f93bed20b
All checks were successful
ci / test (push) Successful in 10s
rename to mxrelay-server
2026-10-24 17:40:42 +07:00
.forgejo/workflows ci: audit against the public registry; read log level at call time so tests stay quiet 2026-10-24 17:27:40 +07:00
deploy mxrelay 0.2.1 2026-10-24 17:25:32 +07:00
docs mxrelay 0.2.1 2026-10-24 17:25:32 +07:00
scripts bundle: omit macOS extended attributes from release tarballs 2026-10-24 17:28:37 +07:00
src ci: audit against the public registry; read log level at call time so tests stay quiet 2026-10-24 17:27:40 +07:00
test ci: audit against the public registry; read log level at call time so tests stay quiet 2026-10-24 17:27:40 +07:00
.editorconfig mxrelay 0.2.1 2026-10-24 17:25:32 +07:00
.gitignore mxrelay 0.2.1 2026-10-24 17:25:32 +07:00
CHANGELOG.md mxrelay 0.2.1 2026-10-24 17:25:32 +07:00
LICENSE mxrelay 0.2.1 2026-10-24 17:25:32 +07:00
package-lock.json rename to mxrelay-server 2026-10-24 17:40:42 +07:00
package.json rename to mxrelay-server 2026-10-24 17:40:42 +07:00
README.md rename to mxrelay-server 2026-10-24 17:40:42 +07:00
tsconfig.json mxrelay 0.2.1 2026-10-24 17:25:32 +07:00

mxrelay-server

SMTP over WebSocket. Let any application send mail as your domain from anywhere that can reach HTTPS — even networks that block every SMTP port — with one revocable API key per application. Built and run by MikroJit.

Node TypeScript Tests License

mxrelay is the server side of the protocol spoken by @siwats/mxrelay-consumer. Any app using that client — or its Nodemailer transport — works against an mxrelay server without changes. For software that can only speak plain SMTP, the bundled agent listens on 127.0.0.1:2525 and forwards over the same secure channel.


Contents

Why

Problem What mxrelay does
Customer and campus networks (Fortinet, captive portals) block ports 25/465/587 and VPNs, but allow HTTPS Carries SMTP inside a WebSocket on port 443, through Cloudflare
Every app had its own copy of a shared mail credential One API key per app or site, each with its own limits, expiry and kill switch
Mail sent from many places is hard to audit Every message is logged with key, client IP, sender, recipient count and the MTA's answer
A second sending path means a second reputation to manage The relay never delivers mail itself. Everything goes through your existing MTA, so DKIM, queueing, retries and outbound routing stay in one place

How it works

flowchart LR
    subgraph Clients
        A["Node app<br/>(Nodemailer transport)"]
        B["Proxmox / Postfix / NAS<br/>→ mxrelay-agent :2525"]
    end

    subgraph Edge["Edge (port 443)"]
        CF["Cloudflare (optional)<br/>proxy + origin-pull mTLS"]
        NG["Reverse proxy<br/>/smtp only, rate limit"]
    end

    subgraph Relay["mxrelay-server"]
        K["API key check<br/>(WebSocket + SMTP AUTH)"]
        L["Sender lock · limits · bans"]
    end

    subgraph Mail["Your MTA (e.g. Stalwart)"]
        S["Submission :587<br/>DKIM · queue · retries"]
    end

    OUT(("Internet"))

    A -- "WSS" --> CF
    B -- "WSS" --> CF
    CF --> NG --> K --> L -- "SMTP + STARTTLS<br/>authenticated" --> S --> OUT

A message travels like this:

sequenceDiagram
    autonumber
    participant App as App / agent
    participant R as mxrelay-server
    participant S as MTA :587

    App->>R: WebSocket upgrade on /smtp
    App->>R: authenticate { apiKey }
    R-->>App: authenticate_response { success: true }
    App->>R: smtp_channel_open
    R-->>App: smtp_channel_ready + "220 relay.example.com"
    App->>R: EHLO / AUTH PLAIN (same key)
    R-->>App: 250 / 235
    App->>R: MAIL FROM:<[email protected]>
    Note over R: domain allowed for this key?
    R-->>App: 250 Sender OK
    App->>R: RCPT TO / DATA / message
    Note over R: size, recipient and rate budgets
    R->>S: submit (envelope + raw message)
    S-->>R: 250 Message queued
    R-->>App: 250 Message queued (the MTA's reply)
    App->>R: QUIT
    R-->>App: 221 + smtp_channel_closed

The client only gets 250 once the MTA has accepted the message. If the MTA says no, the client gets the MTA's own answer (4xx to retry later, 5xx to give up).

The full wire format is in docs/protocol.md.

Features

Compatible

  • Server for the @siwats/mxrelay-consumer protocol: the client library and its Nodemailer transport work unchanged.
  • Local SMTP agent for everything else: point any SMTP relay setting at 127.0.0.1:2525.

Secure by default

  • 256-bit API keys, stored only as scrypt hashes, compared in constant time.
  • The key must match on both the WebSocket and SMTP AUTH.
  • Sender lock: MAIL FROM restricted to the key's domains (and, if you configure it, again by the MTA).
  • IP bans after repeated auth failures, connection caps, auth and idle timeouts.
  • Designed to sit behind a reverse proxy (optionally Cloudflare with origin-pull mTLS); only /smtp is published.
  • Hardened systemd unit, no capabilities, read-only filesystem.

Controlled

  • Per-key hourly and daily recipient budgets, message size and recipient caps.
  • Keys can expire, be disabled, rotated or revoked — live, no restart.

Operable

  • /healthz (includes an upstream check) and Prometheus /metrics.
  • Structured JSON logs with key, IP, sender, size, timing and the MTA's reply. Bodies are never logged.
  • Atomic deploys with automatic rollback if the new release is unhealthy.
  • 29 tests, including end-to-end runs of the real client library against the server.

Quick start

You need a key. On the relay host:

mxrelay-keys add --name my-app --domain example.com
# id:    3f9c2a7b1d04
# name:  my-app
# token: mxr_3f9c2a7b1d04_…        ← shown once, store it in a password manager

Then send:

import nodemailer from 'nodemailer';
import { createTransport } from './vendor/mxrelay-consumer';

const transporter = nodemailer.createTransport(
    createTransport(process.env.MXRELAY_API_KEY!, {
        host: 'relay.example.com',
        port: 443,
        secure: true
    })
);

await transporter.sendMail({
    from: 'Example <[email protected]>',
    to: '[email protected]',
    subject: 'Hello',
    text: 'Sent through mxrelay.'
});

Using the relay

From a Node application

Use the Nodemailer transport as above, or the client directly when you want queue control and events:

import { SMTPOverWSClient, MessagePriority } from './vendor/mxrelay-consumer';

const client = new SMTPOverWSClient({
    url: 'wss://relay.example.com/smtp',
    apiKey: process.env.MXRELAY_API_KEY!
});

const reply = await client.queueEmail(
    '[email protected]',
    ['[email protected]'],
    'Subject: Disk almost full\r\n\r\n/var is at 92%.',
    { priority: MessagePriority.HIGH, retries: 3 }
);
console.log(reply); // "250 2.0.0 Message queued with id …"

await client.shutdown();

Always pass your own host to createTransport. The library's default host is SiwatSystem's service, not yours.

From anything that speaks SMTP

Install the agent next to the software (Proxmox, Postfix, a NAS, a printer, a Python script):

# /etc/mxrelay/agent.env
MXRELAY_URL=wss://relay.example.com/smtp
MXRELAY_API_KEY=mxr_…
MXRELAY_AGENT_LISTEN_HOST=127.0.0.1
MXRELAY_AGENT_LISTEN_PORT=2525

systemctl enable --now mxrelay-agent

Then set the software's SMTP server to 127.0.0.1, port 2525, no TLS, no authentication. For Postfix:

relayhost = [127.0.0.1]:2525

The agent answers the sender only after the relay has answered, so a failure upstream shows up as an SMTP error in the sender's own logs and queue — nothing is silently dropped.

API keys

Command Does
mxrelay-keys add --name N --domain D [--domain D2] [limits] [--expires DATE] Create a key, print the token once
mxrelay-keys list [--json] Show keys (never secrets)
mxrelay-keys show <id> One key in detail
mxrelay-keys set <id> [--domain D] [limits] [--expires DATE | --no-expiry] Change domains, limits or expiry
mxrelay-keys rotate <id> New secret, same id and settings
mxrelay-keys disable <id> / enable <id> Reversible off switch
mxrelay-keys revoke <id> Delete for good
Limit flag Default Meaning
--rate-hour N 100 Recipients per hour
--rate-day N 1000 Recipients per day
--max-bytes N 26214400 Message size (25 MiB)
--max-rcpt N 50 Recipients per message

Changes apply within 5 seconds. A key can also carry its own Stalwart login (--upstream-user / --upstream-pass-file) if you want that app to appear as a separate sender inside Stalwart.

Configuration

mxrelay-server reads environment variables (in production from /etc/mxrelay/server.env):

Variable Default Purpose
MXRELAY_LISTEN_HOST / MXRELAY_LISTEN_PORT 0.0.0.0 / 8025 HTTP + WebSocket listener
MXRELAY_HOSTNAME system hostname Name in the SMTP greeting and in EHLO to the MTA
MXRELAY_KEYS_FILE /var/lib/mxrelay/keys.json Key store
MXRELAY_TRUSTED_PROXIES 127.0.0.1,::1 Peers allowed to set X-Real-IP
MXRELAY_UPSTREAM_HOST / _PORT 127.0.0.1 / 587 MTA submission port
MXRELAY_UPSTREAM_REQUIRE_TLS true Insist on STARTTLS
MXRELAY_UPSTREAM_TLS_SERVERNAME — Certificate name to verify when connecting by IP
MXRELAY_UPSTREAM_USER / _PASS — Default MTA login
MXRELAY_MAX_MESSAGE_BYTES 26214400 Server-wide size cap
MXRELAY_MAX_RECIPIENTS 50 Server-wide recipient cap
MXRELAY_AUTH_TIMEOUT_MS 10000 Time to send authenticate
MXRELAY_IDLE_TIMEOUT_MS 300000 Drop idle connections
MXRELAY_AUTH_FAIL_MAX / _WINDOW_MS / _BAN_MS 10 / 900000 / 3600000 Ban policy
MXRELAY_MAX_CONNECTIONS_PER_IP 10 Concurrent connections per client IP
MXRELAY_LOG_LEVEL info debug, info, warn, error

The server refuses to start on an invalid configuration and says why.

Deployment

scripts/bundle.sh                                   # build/mxrelay-<version>-<sha>.tgz
PVE_HOST=root@pve1 CTID=124 scripts/deploy.sh build/mxrelay-<version>-<sha>.tgz   # install, switch, health-check, auto-rollback
PVE_HOST=root@pve1 CTID=124 scripts/deploy.sh --rollback                        # back to the previous release

Releases live side by side in /opt/mxrelay/releases/, with /opt/mxrelay/current pointing at the active one. deploy.sh targets a Proxmox LXC container; the operations guide shows the same steps for any systemd host. Installation, keys, logs, troubleshooting and restore are in docs/operations.md. An example reverse-proxy configuration is in deploy/nginx-mxrelay.conf.

Monitoring

curl -s http://<relay-host>:8025/healthz
{
  "ok": true,
  "version": "0.2.1",
  "uptimeSeconds": 5123,
  "keys": 3,
  "connections": 0,
  "upstream": { "target": "mail.example.com:587", "ok": true, "latencyMs": 4, "checkedAt": "2026-10-24T10:30:00.000Z" }
}

/healthz turns 503 when the MTA is unreachable. /metrics exposes mxrelay_messages_total{key,result}, mxrelay_auth_failures_total, mxrelay_ip_bans_total, mxrelay_rejected_total{reason} and connection gauges. Neither endpoint is published through nginx.

Development

npm ci
npm test          # type-check, build, 29 unit and end-to-end tests

The end-to-end suite starts a fake MTA, the relay and the agent in-process, then drives them with the vendored client library and with Nodemailer — the same code paths production uses. CI runs the same (.forgejo/workflows/ci.yml) together with npm audit.

Project layout

src/
  server/
    index.ts        entry point
    relay.ts        HTTP + WebSocket server, /healthz, /metrics
    connection.ts   one client: authentication, channels, ordering
    channel.ts      SMTP state machine inside a channel
    upstream.ts     submission to the MTA
    keys.ts         key store (scrypt), reloads on change
    limits.ts       rate windows and auth bans
    health.ts       upstream reachability probe
    protocol.ts     frame types and close codes
    config.ts       environment parsing and validation
  agent/index.ts    local SMTP → relay forwarder
  cli/keys.ts       mxrelay-keys
  vendor/mxrelay-consumer/   client library (MIT, SiwatSystem)
test/               unit and end-to-end tests
deploy/             systemd units, env examples, nginx vhost, CLI wrapper
scripts/            bundle.sh, deploy.sh
docs/               protocol, operations, security

Limitations

  • Message bodies cross the WebSocket as JSON strings. Everything ordinary mailers produce (7-bit, base64, quoted-printable, UTF-8) is fine; raw non-UTF-8 8-bit bodies are not preserved byte for byte.
  • Rate budgets and IP bans are kept in memory and reset when the service restarts.
  • The relay decides who may send, not whether mail arrives. Deliverability is up to the MTA's outbound path.

See docs/security.md for the threat model and what to do if a key leaks.

Credits

The client library and the wire protocol come from @siwats/mxrelay-consumer by SiwatSystem (MIT), vendored unchanged in src/vendor/mxrelay-consumer. The server, agent and tooling in this repository are MikroJit's, under the MIT license.