- TypeScript 76.2%
- JavaScript 18.4%
- Shell 5.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| deploy | ||
| docs | ||
| scripts | ||
| src | ||
| test | ||
| .editorconfig | ||
| .gitignore | ||
| CHANGELOG.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
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.
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
- How it works
- Features
- Quick start
- Using the relay
- API keys
- Configuration
- Deployment
- Monitoring
- Development
- Project layout
- Limitations
- Credits
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-consumerprotocol: 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 FROMrestricted 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
/smtpis 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
hosttocreateTransport. 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.