MailRelay turns authenticated email into a small, configuration-driven command protocol:
Email -> Parser -> Authentication -> Router -> Handler -> Result -> Email reply
It is a single-machine Go service. SQLite stores deduplication keys, execution audits, the generated command catalog, runtime health, and queued jobs.
go install github.com/becomeopc/opc-mailrelay/cmd/mailrelay@latest
mailrelay initEdit mailrelay.yaml, replace all change-me values, set the sender allowlist, and declare commands. Check the installation before running it:
mailrelay doctor
mailrelay once
mailrelay runSecrets can be environment references such as ${IMAP_PASSWORD}. An unresolved reference is rejected. Keep the configuration file readable only by the service account.
The optional operations console is embedded in the MailRelay binary and is disabled by default. Generate an Argon2id administrator hash without placing the password in shell history:
MAILRELAY_ADMIN_PASSWORD='choose-a-strong-password' mailrelay hash-passwordStore the resulting hash and independent secrets through environment references:
web:
enabled: true
address: 127.0.0.1:8787
session_secret: "${MAILRELAY_SESSION_SECRET}"
admin_password_hash: "${MAILRELAY_ADMIN_PASSWORD_HASH}"
session_ttl: 8hOpen http://127.0.0.1:8787. The authenticated console can edit the command catalog, command token, sender allowlist, HTTP host allowlist, and catalog-change notification recipients through the draft configuration API. Its mail simulator checks parsing, sender/token authentication, command routing, and parameter validation without executing a handler, calling an external API, writing an audit record, or sending a reply. The API never returns mailbox credentials, web session secrets, full mail bodies, or raw provider errors. Handler secrets should stay in ${ENV_VAR} references or sensitive parameters. Keep the default loopback binding unless the console is placed behind a trusted HTTPS reverse proxy.
Send mail from an allowlisted address:
Subject: push
X-MailRelay-Token: your-token
message=hello
Plain text key=value bodies and JSON object bodies are supported. For JSON, use Content-Type: application/json. The reserved _token body field can be used when the sending client cannot add headers. A header token takes precedence. Message-ID is used for durable deduplication; messages without one receive a deterministic content hash.
Commands using the http_request handler are different: the mail body is treated as an HTTP request transcript and forwarded. Use X-MailRelay-Token for those messages, because _token would be part of the forwarded HTTP body.
Discovery is generated from commands; there is no separate command manual.
- Subject
helpreturns every command and description. - Subject
help deployreturns the command description, parameters, required markers, and examples. - Startup and valid configuration reloads calculate a canonical Catalog hash.
- SQLite stores the Catalog snapshot. Changes produce
Added,Removed, andUpdatedsections and notifyruntime.catalog_notifyrecipients.
Example command:
commands:
- name: push
description: Send a notification
handler: http
parameters:
message:
description: Notification text
type: string
required: true
example: hello
config:
method: POST
url: https://api.example.com/push
body: '{"message":"{{message}}"}'Parameter types are string, integer, number, and boolean. Mark a parameter sensitive: true to redact it from audit records.
All handlers implement the same consumer-side interface. The Router only resolves the configured handler name.
Support levels are part of generated Discovery output:
- Stable:
http,http_request,webhook,workflow,queue— supported for the v0.1 golden path and recovery lifecycle. - Experimental:
plugin,shell,agent,mcp, and custom handlers — APIs and safety limits may still change.
Calls a configured HTTP endpoint. The hostname must be listed in security.http_hosts. Scheme, credentials, and host must be static; path segments may use {{param}} templates, and query parameters should be configured with query so values are encoded safely. Resolved private, loopback, link-local, multicast, unspecified, carrier-grade NAT, and metadata addresses are rejected at validation and dial time. Cross-host redirects are rejected.
Configuration keys: url, method, headers, body, and query.
config:
method: GET
url: https://api.example.com/push/{{message}}
query:
source: mailrelayIf body is empty, MailRelay does not add a default Content-Type header.
Forwards the mail body as an HTTP/1.1 request. The request line may contain an absolute URL, or an origin-form path when config.base_url supplies the scheme and fallback host. The final destination is still checked by security.http_hosts and the outbound network policy.
commands:
- name: forward
handler: http_request
config:
base_url: https://api.example.comExample mail body:
POST /events HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"message":"hello"}POSTs a standard JSON envelope containing version, command, request ID, timestamp, and parameters. secret adds an X-MailRelay-Signature: sha256=... HMAC header. It uses the same outbound policy as HTTP.
Runs a bounded list of configured command steps through the Router. Each step has command and an explicit params mapping for the target command's declared parameters. Values such as {{env}} read workflow request parameters; fixed values are passed as-is. Required target parameters must be mapped, unknown target parameters are rejected, and sensitive target parameters must be sourced from sensitive workflow parameters. Missing targets, direct or indirect recursion, excessive depth, timeouts, and excessive step counts fail safely. Execution stops on the first failed step.
Inserts a target command into the SQLite queue with an idempotency key derived from the message. max_attempts bounds retry. The local worker leases jobs transactionally and recovers expired leases after restart. Queue wrapper parameters must match the target schema and cannot be sensitive because delayed execution requires their durable plaintext value.
Starts a configured absolute executable without a shell. It writes a versioned JSON request to stdin and expects a JSON Result on stdout. Output and environment are bounded.
Starts a configured absolute executable directly with an argument array. Each argument is expanded independently, so characters such as ;, $(), and pipes are data rather than shell syntax. Arbitrary executables cannot be selected by email.
Calls a configured OpenAI-compatible chat-completions endpoint. Endpoint, model, system prompt, and API key are fixed by configuration. Email supplies only declared parameters. Add the endpoint host to security.http_hosts.
Calls one allowlisted MCP tool using JSON-RPC over configured HTTP or stdio transport. HTTP is subject to outbound network policy; stdio uses a fixed executable. Email cannot select a server or unlisted tool.
Applications embedding MailRelay may pass additional command.Handler implementations to app.Build. Runtime loading of arbitrary code is intentionally unsupported.
mailrelay init create a safe single-file configuration
mailrelay run run IMAP IDLE, polling fallback, queue worker, and hot reload
mailrelay once process one bounded mail and queue batch
mailrelay status show queue depth, Catalog hash, and latest execution
mailrelay doctor validate configuration, addresses, SQLite, and command policies
mailrelay replay queue ID replay one dead queue job
mailrelay replay reply ID replay one dead SMTP reply
mailrelay soak --duration 72h run the live reliability acceptance check
mailrelay version print version, commit, and build time
mailrelay hash-password generate the Web console Argon2id password hash
mailrelay help print CLI usage
Use --config path before or after the command, or set MAILRELAY_CONFIG.
When working from the repository, the Taskfile includes the common development loops:
task dev watch files, rebuild the embedded console, and restart via Air
task console-embed build console/dist and copy it into internal/web/ui
task build build the binary with Version, Commit, and BuildTime metadata
task test-all run Go race tests and console tests- Dangerous capabilities are disabled until a command explicitly selects them.
- Every request requires both an allowlisted sender and a constant-time token match.
- Unknown configuration fields, duplicate commands, invalid parameter types, relative executables, and unallowlisted HTTP hosts fail startup.
mailrelay statusreads SQLite health state and reports queue, reply, dead-letter, stale execution, and recent failure summaries.runtime.config_reload: falsedisables hot reload; invalid reloads keep the last valid runtime and are recorded as runtime events.- Experimental handlers require explicit opt-in and should not be used for the stable HTTP/Webhook golden path.
- Configuration reload is parse/validate/build/atomic-swap. Invalid changes are logged and the last valid configuration remains active.
- IMAP prefers IDLE and falls back to bounded polling/reconnect backoff.
- Command execution and SMTP delivery are separated by a durable SQLite outbox. SMTP retry never executes a Handler twice.
- Exhausted queue jobs and replies remain in dead-letter state until an operator uses
mailrelay replay. - SQLite audit records never store tokens, mailbox passwords, API keys, or full mail bodies. Operational logs redact sensitive parameters and include truncated HTTP request/response snapshots for
http,http_request, andwebhooktroubleshooting. - Back up the YAML file and SQLite database together while MailRelay is stopped, or use SQLite's online backup tooling.
go test ./...
go test -race ./...
go vet ./...
go build ./cmd/mailrelayThe architecture and accepted security boundaries are documented in docs/superpowers/specs/2026-07-07-mailrelay-design.md.
Use a dedicated mailbox and one Stable HTTP-family/Webhook command. Start with an empty queue and outbox:
mailrelay doctor
mailrelay soak --duration 72hDuring the run, send valid commands, one unauthorized command, duplicate Message-ID messages, and temporarily interrupt IMAP, SMTP, and the target HTTP endpoint. Acceptance requires soak_result: pass, no unauthorized or duplicate execution in the audit, and no pending/dead reply or queue item. If a deliberate outage exhausts retries, inspect mailrelay status, repair the dependency, and explicitly replay the dead item before restarting the acceptance window.