Configuration
Environment variables and configuration options
Mailyard reads ./mailyard.yaml (override with --config) and every key can also be set as an environment variable.
The variable name is the YAML path, uppercased, with dots replaced by underscores and a MAILYARD_ prefix —
server.public_url
is MAILYARD_SERVER_PUBLIC_URL. Environment wins over the file, and the file is optional, so a deployment can be
configured entirely from the environment.
Put secrets in the environment rather than the file: MAILYARD_AUTH_JWT_SECRET,
MAILYARD_DATABASE_CRYPTO_ENCRYPTION_KEY, MAILYARD_DATABASE_DSN and
MAILYARD_RELAY_NODES_AUTO_REGISTER_TOKEN.
Server
| Variable | Default | Description |
|---|---|---|
MAILYARD_SERVER_ADDR | :3000 | Bind address for the HTTP console and API |
MAILYARD_SERVER_PUBLIC_URL | — | Public base URL, e.g. https://mail.example.com. Required for anything that has to build an absolute link: invitations, password resets, OIDC redirects, tracking links, hosted unsubscribe pages and images uploaded in the template builder. It also decides the Secure flag on the session cookie |
MAILYARD_SERVER_TRUSTED_PROXIES | — | Proxy IPs or CIDRs whose X-Forwarded-For is believed. Empty means the direct peer is the client, which is correct for direct exposure. Set it to your load balancer’s CIDRs when TLS terminates upstream, otherwise the rate limiter, audit log and access log all record the proxy’s address. List every hop, not just the nearest one — see Trusting a proxy |
MAILYARD_SERVER_MAX_CONCURRENT_REQUESTS | 4096 | Requests handled at once. A request body is read into memory before any handler runs, so this is what bounds how many large uploads can exist at the same time. Past it a request answers 503 in the JSON error envelope with Retry-After, while /healthz and /readyz are never refused. The HTTP library’s connection ceiling sits a quarter above it as a backstop, and only that answers in plain text. 0 removes the cap |
MAILYARD_SERVER_MAX_CONCURRENT_PER_IP | 256 | Requests one caller may have in progress at once, refused with 429 past it. 0 turns it off. The caller is the address resolved through MAILYARD_SERVER_TRUSTED_PROXIES, so behind a proxy that has to be set, or every caller shares the proxy’s budget |
MAILYARD_SERVER_TLS_ENABLED | false | Terminate TLS here rather than at a proxy in front — see TLS |
public_url must match the scheme and host you actually browse
Two ways to get locked out of a console that is working perfectly, neither of which reports anything:
- Scheme.
https://…setsSecureon the session cookie. Open the console over plainhttp://and the login returns 200, the browser discards the cookie, and every request after it is unauthenticated. - A bare IP address. Safari will not keep a cookie for a host like
http://192.168.1.10:3000— an IP literal has no registrable domain and WebKit needs one. Chrome and Firefox are fine with it. Give the installation a hostname instead.
Both land on the login page with authentication required.
The console frontend and the documentation you are reading are compiled into the binary, so there is no asset path to configure.
Database (PostgreSQL)
PostgreSQL is the only supported engine. One connection string, no host/port/user parts - put it in
MAILYARD_DATABASE_DSN rather than YAML so the password stays out of the config file.
| Variable | Default | Description |
|---|---|---|
MAILYARD_DATABASE_DSN | — | Required. postgres://user:pass@host:5432/mailyard?sslmode=require |
MAILYARD_DATABASE_REPLICA_DSNS | — | Read-only followers, comma separated. See Read replicas |
database:
dsn: postgres://mailyard:secret@localhost:5432/mailyard?sslmode=disable
Migrations run automatically at startup (goose, embedded in the binary), so a fresh empty database is all the service needs.
PostgreSQL is the only service to configure: it holds the delivery queue and is how several nodes coordinate - see Scaling out .
Logging
One sink, written exactly where you point it.
| Variable | Default | Description |
|---|---|---|
MAILYARD_LOGGING_LEVEL | info | trace, debug, info, warn or error |
MAILYARD_LOGGING_FORMAT | json | json for shippers, text for reading |
MAILYARD_LOGGING_OUTPUT | stdout | stdout, stderr, or a file path (opened for append, created when missing) |
MAILYARD_LOGGING_COLOR | true | ANSI colors, text format only. Honored only when the output is an interactive terminal, so a file or a piped stdout stays plain. |
An invalid format or level fails at startup rather than falling back, because a silent fallback to human-readable
text produces a log your shipper cannot parse and never says why.
The first-run bootstrap password is written straight to stderr, not through the logger, so it is still visible when logs are directed to a file.
Authentication
| Variable | Default | Description |
|---|---|---|
MAILYARD_AUTH_JWT_SECRET | — | Required. Signs session JWTs. Generate with openssl rand -hex 32. The OIDC state cookie uses a separate subkey derived from it, never the raw value. Rotating it ends every session. Tracking and unsubscribe links are keyed on the encryption key instead, so they survive a rotation |
MAILYARD_AUTH_LOCAL_ENABLED | false | Email and password sign-in |
MAILYARD_AUTH_LOCAL_EMAIL | — | Bootstrap admin address. On first start against an empty users table this account is created with a generated password, printed to stderr once |
MAILYARD_AUTH_SESSION_TTL | 12h | Session cookie lifetime, as a Go duration |
MAILYARD_AUTH_REGISTRATION_ENABLED | false | Opens POST /app/api/auth/register for public self-signup. Off by default: this is an operator console, and an open signup on an internet-facing install means strangers with accounts. New accounts are always plain users — an admin grants roles afterwards |
MAILYARD_AUTH_DISABLED | false | Removes the authentication gate from every API surface. Local development only |
Danger
MAILYARD_AUTH_DISABLED=true makes every console endpoint reachable without credentials, including the platform-admin
routes. It exists so a developer can poke at the API without logging in. Never set it on anything reachable from a
network you do not control.
Single sign-on is configured per provider in the console under Admin → OAuth Providers, not through the environment, because the settings are stored in the database and editable at runtime. See OAuth Providers .
Encryption at rest
| Variable | Default | Description |
|---|---|---|
MAILYARD_DATABASE_CRYPTO_ENCRYPTION_KEY | required | Keys the at-rest encryption of secrets stored in the database: tenant SMTP passwords, DKIM private keys, TOTP secrets, OAuth client secrets, certificate private keys. At least 32 characters - generate with openssl rand -hex 32. The AES-256 key is derived with HKDF-SHA256, which stretches a short secret without adding entropy to it. To rotate it, stop every node and run mailyard rekey, which re-encrypts every sealed column under the new key. It also signs the tracking links: unsubscribe links already delivered keep working after a rekey, while web view, open and click links signed under the old key do not. Pass --forget-tracking to refuse the old unsubscribe links too, when the old key may have been used to forge them |
Required. Mailyard refuses to start without it.
Changing it later orphans every row already sealed under the old key, so treat it like a database credential.
A stored value is base64(nonce||ciphertext) and nothing else.
Amazon SES notifications
SES replaces the envelope sender with its own, so no return path can collect its bounces. It reports over SNS instead. See Bounce Handling .
There is nothing to configure here. POST /webhooks/ses always exists, and the SNS topic ARN is set on the SMTP
server that uses SES — under SMTP Servers, or Shared SMTP Pool for a platform-owned one.
That is deliberate. SES belongs to one server: a project configures its own SES account as its own server, so a platform-wide list could only ever serve an operator who owned the account themselves, and a tenant had no way to register a topic at all.
The endpoint is public, because SNS presents no session and no API key. Two things authenticate a notification, and it
needs both: the SNS signature, verified against a certificate fetched only from sns.<region>.amazonaws.com, and the
topic being configured on a server. The signature alone proves Amazon sent the message, not that your account did —
anyone can open an AWS account and point a topic at your URL.
With no server carrying a topic, nothing is accepted. That is why the endpoint needs no switch: it gates itself on the data.
A notification is additionally required to be about a message that actually left through a server publishing to that
topic. Attribution still comes from the
X-Mailyard-Email-Id header — this is what stops one tenant’s topic reporting on another tenant’s mail.
Metrics
| Variable | Default | Description |
|---|---|---|
MAILYARD_METRICS_ENABLED | false | Binds the Prometheus scrape listener and serves GET /metrics on it |
MAILYARD_METRICS_ADDR | 127.0.0.1:9090 | Where that listener binds. Its own port |
MAILYARD_METRICS_TOKEN | — | When set, the endpoint requires it as a bearer token. Needed once the bind is widened |
Rate Limiting
Fixed-window counters held in process memory, so on a multi-node deployment the effective ceiling is the value here times the node count. Size accordingly, or terminate the limit at a shared proxy instead. Zero on an individual limit disables just that one.
| Variable | Default | Description |
|---|---|---|
MAILYARD_RATELIMIT_ENABLED | true | Master switch for every HTTP limiter |
MAILYARD_RATELIMIT_LOGIN_PER_MINUTE | 10 | POST /app/api/auth/login per client IP |
MAILYARD_RATELIMIT_OIDC_PER_MINUTE | 30 | OIDC callback per client IP |
MAILYARD_RATELIMIT_API_PER_MINUTE | 120 | /api/v1 per API key, or per IP for callers with no usable token |
MAILYARD_RATELIMIT_SESSION_PER_MINUTE | 600 | /api/v1 per signed-in console session |
Per-project sending volume is a separate mechanism: it comes from the plan assigned to the project, not from
configuration. See Plans
. The SMTP listeners have their own per-IP session limits,
MAILYARD_SUBMISSION_RATE_PER_MINUTE and MAILYARD_INBOUND_RATE_PER_MINUTE.
Worker
The delivery worker runs inside the server process, so there is nothing to enable. Running more nodes runs more
workers - every serve node claims from the same queue.
Exactly one node carries --init, which applies pending migrations. The rest start without it and skip goose entirely.
Two nodes migrating an empty database at the same time race, and a node that starts before anybody has applied the
schema says so rather than failing on the first query. See Scaling out
.
These settings are per node. Three nodes at concurrency 4 give you twelve parallel deliveries, not four.
| Variable | Default | Description |
|---|---|---|
MAILYARD_WORKER_CONCURRENCY | 4 | Parallel delivery goroutines |
MAILYARD_WORKER_POLL_INTERVAL | 2s | How often the queue is checked for due work. A send also wakes the worker immediately - on every node, not just the one that accepted it - so this only governs scheduled and retried mail |
MAILYARD_WORKER_MAX_ATTEMPTS | 5 | Delivery attempts before an email is marked failed |
MAILYARD_WORKER_RETRY_BASE_DELAY | 30s | Seeds the exponential backoff: base × 2^(attempt−1) |
MAILYARD_WORKER_RETRY_MAX_DELAY | 1h | Ceiling for that backoff |
MAILYARD_WORKER_CLAIM_TIMEOUT | 15m | Re-queues processing rows older than this, which is how a crashed node’s in-flight mail is recovered. Must be longer than MAILYARD_WORKER_ATTEMPT_TIMEOUT, or a running attempt is requeued and sent twice - the server refuses to start otherwise |
MAILYARD_WORKER_ATTEMPT_TIMEOUT | 10m | Ceiling on one delivery attempt, connect to QUIT. Two minutes of silence on the connection also ends it. An attempt that runs out is scored as a transient failure and retried |
MAILYARD_WORKER_MAX_PER_PROJECT | half of concurrency | Rows one project may have in delivery at once, counted across every node rather than per node, so one tenant cannot occupy the whole pool. Unset means half of MAILYARD_WORKER_CONCURRENCY rounded up. 0 turns the cap off |
MAILYARD_WORKER_ATTACHMENT_CACHE_BYTES | 67108864 | Memory, in bytes, holding template attachment and embedded image content between messages, so a campaign reads each file once rather than once per recipient. An entry is reloaded after ten minutes. 0 turns the cache off |
Campaigns
| Variable | Default | Description |
|---|---|---|
MAILYARD_CAMPAIGN_BATCH_SIZE | 100 | Recipients rendered and queued per batch. The campaign’s own send rate throttles between batches |
MAILYARD_CAMPAIGN_POLL_INTERVAL | 5s | How often the runner looks for due campaigns |
Webhooks
| Variable | Default | Description |
|---|---|---|
MAILYARD_WEBHOOK_MAX_ATTEMPTS | 3 | Total delivery attempts per event, including the first |
MAILYARD_WEBHOOK_TIMEOUT | 10s | Per-request timeout, as a Go duration |
MAILYARD_WEBHOOK_RETRY_DELAY | 10s | Fixed wait between attempts, as a Go duration |
MAILYARD_WEBHOOK_ALLOW_PRIVATE_TARGETS | false | Permit deliveries to private and reserved addresses |
Private webhook targets
Webhook URLs are chosen by project members, not operators. By default a delivery to a loopback, RFC 1918, link-local or
otherwise reserved address is refused at connection time, which stops a member from aiming one at the cloud metadata
service (169.254.169.254), at a neighbouring container, or at this process’s own admin API and using the platform as a
proxy into your network. Redirects are not followed, for the same reason.
Set MAILYARD_WEBHOOK_ALLOW_PRIVATE_TARGETS=true only when your receivers really do live on the same private network as
this deployment, and understand that it re-opens that path to anyone who can create a webhook.
Delivery
| Variable | Default | Description |
|---|---|---|
MAILYARD_SENDING_AUTO_SUPPRESS_ON_REJECT | true | Add a recipient to the suppression list (and stop retrying) after a permanent 5xx rejection at RCPT TO, e.g. 550 user unknown |
Email Verification
Controls the POST /api/v1/emails/verify endpoint: syntax, disposable and role-account checks, and an MX lookup with an
A/AAAA fallback per RFC 5321. Off by default because the MX check makes outbound DNS queries, which an operator on a
locked-down network should opt into rather than discover. Results are cached in process memory, so each node builds its
own cache and the TTLs below are per node.
| Variable | Default | Description |
|---|---|---|
MAILYARD_EMAIL_VERIFY_ENABLED | false | Enable the endpoint |
MAILYARD_EMAIL_VERIFY_CACHE_TTL | 24h | How long an address-level verdict is reused, as a Go duration |
MAILYARD_EMAIL_VERIFY_MX_CACHE_TTL | 1h | How long a domain’s MX answer is reused |
MAILYARD_EMAIL_VERIFY_LOOKUP_TIMEOUT | 5s | Bounds one DNS resolution |
There is deliberately no SMTP probe, so mailbox_verified is always false. See
Email Verification
for why.
Platform mail
The platform’s own outbound mail: project invitations, password resets, signup confirmations and alerts. It is sent as a message of a project you name, through that project’s servers and DKIM key, marked as system mail so it never consumes the project’s plan quota, appears in its email log, or fires its webhooks.
There is nothing to configure here. Both the address and the project are platform settings rather than config:
| Setting | Where | Description |
|---|---|---|
platform_mail_from | Admin → Settings | Sender address, on a domain the project has verified |
platform_mail_from_name | Admin → Settings | Optional display name |
platform_mail_project | Admin → Settings | The project platform mail is sent as. Off until this and the address are set |
They are settings and not config keys on purpose: the project beside them is edited in the console by the same administrator, and a value that needs a restart to correct is the one thing they cannot fix when it is wrong.
platform_mail_from requires MAILYARD_SERVER_PUBLIC_URL, since every link it sends is absolute — and that requirement
is checked at the moment somebody writes the address, which is where it is actionable.
Until both are set, invitations still work — the console returns a copyable link instead — but password reset is not
offered at all. Status and a test live at GET/POST /api/v1/admin/system-mail[/test]. See
Platform Mail
.
Relay nodes
A relay node is a machine somewhere else — often another provider, often another continent — that delivers straight to recipient mail exchangers from its own address, and can run an MX of its own. Two config blocks, and they point in opposite directions.
The control plane, on an API node
| Variable | Default | Description |
|---|---|---|
MAILYARD_RELAY_NODES_ENABLED | false | Registers the enrolment endpoints at all. Off means the routes do not exist rather than refusing |
MAILYARD_RELAY_NODES_AUTO_REGISTER_TOKEN | — | The shared secret a platform node presents to enrol. Required when enabled |
MAILYARD_RELAY_NODES_CA_COMMON_NAME | Mailyard Relay CA | Names the authority in its own certificate |
A leaked token is not a disaster on its own: enrolment lands in pending, and a node carries no mail until somebody
approves it. Whether that approval is automatic is the relay_nodes_auto_approve platform setting, off by default.
A project’s own node enrols with an API key carrying relay:enroll instead of this token, and is approved by an
administrator of that project.
The node itself
Read only by mailyard relay. A node ships neither the database DSN nor the encryption key.
| Variable | Default | Description |
|---|---|---|
MAILYARD_RELAY_NODE_CONTROL_URL | — | Where the platform lives. The node only ever connects out |
MAILYARD_RELAY_NODE_ENROLL_TOKEN | — | The shared secret, or a project API key. First run only |
MAILYARD_RELAY_NODE_ADDR | :2587 | Where delivery workers connect. Implicit TLS, client certificate required |
MAILYARD_RELAY_NODE_HOSTNAME | — | The certificate name workers dial AND the HELO it announces |
MAILYARD_RELAY_NODE_SERVER_GROUP | — | Slug of the project server group to join. Ignored by a platform node |
MAILYARD_RELAY_NODE_SPOOL_DIR | ./relay-spool | The queue. Metadata in bbolt, message bytes as files beside it |
MAILYARD_RELAY_NODE_MAX_LIFETIME | 72h | How long a message may keep failing before it is given up on |
MAILYARD_RELAY_NODE_HEARTBEAT_INTERVAL | 2m | How often the node reports in |
MAILYARD_RELAY_NODE_DELIVERY_CONCURRENCY | 8 | Simultaneous outbound sessions |
MAILYARD_RELAY_NODE_SMTP_PORT | 25 | Destination port. Configurable only so a test can point it elsewhere |
MAILYARD_RELAY_NODE_IPV6 | false | Allow delivery over IPv6 |
MAILYARD_RELAY_NODE_ALLOW_PRIVATE_MX | false | Allow delivery to an MX in loopback, private or reserved address space |
IPv6 is off by default, and that default is the point: a box with a AAAA address will happily prefer v6, where receivers want a PTR and SPF coverage a v4-only setup does not have, and the mail is refused for reasons that look nothing like the cause.
The control channel is plain HTTPS with a nil transport, so HTTPS_PROXY,
ALL_PROXY and SOCKS5 work with no configuration — which is the whole point for a node behind a national firewall or a
VPN.
The node verifies your certificate
It uses the system trust store. If the platform serves a self-signed certificate, no node will enrol until that root is installed on each machine.
A node that also receives
| Variable | Default | Description |
|---|---|---|
MAILYARD_RELAY_NODE_INBOUND_ENABLED | false | Take mail on port 25 and forward it over the control channel |
MAILYARD_RELAY_NODE_INBOUND_ADDR | :25 | Where the internet delivers |
MAILYARD_RELAY_NODE_INBOUND_MAX_MESSAGE_SIZE | 26214400 | Bytes per received message |
MAILYARD_RELAY_NODE_INBOUND_RATE_PER_MINUTE | 120 | New sessions per client IP per minute |
MAILYARD_RELAY_NODE_INBOUND_TLS_CERT | — | STARTTLS certificate on the node’s own disk. Empty generates a self-signed pair |
MAILYARD_RELAY_NODE_INBOUND_TLS_KEY | — | The matching key. Both or neither |
Point the MX record at the node rather than at Mailyard. See Relay Nodes .
Sending Limits
Caps applied to every send, on both the console API and the machine API.
| Variable | Default | Description |
|---|---|---|
MAILYARD_SENDING_MAX_RECIPIENTS | 50 | Recipients per message |
MAILYARD_SENDING_MAX_ATTACHMENT_SIZE | 10485760 | Bytes per attachment (10 MiB) |
MAILYARD_SENDING_MAX_TOTAL_ATTACHMENT_SIZE | 26214400 | Bytes of attachments per message (25 MiB) |
MAILYARD_SENDING_ALLOW_PRIVATE_SMTP_TARGETS | false | Let a project’s SMTP server point at a private or reserved address |
MAILYARD_SENDING_SMTP_CONVERSATION_TIMEOUT | 2m | Ceiling on one conversation with a server a project owns, connect to QUIT, however much the peer keeps sending |
These size limits set the HTTP body limit
Attachments travel base64-encoded, which inflates them by 4/3, so the request body cap is computed from
max_total_attachment_size at startup rather than configured separately. That cap applies only to the routes that
carry attachments (/api/v1/emails/send, /send-template, /batch, template attachment uploads and template import).
Every other API route takes 8 MiB, and everything outside the API - login, webhooks, tracking - takes 1 MiB. Anything
above 1 MiB goes only to a request whose API key or session verifies. Lower
these values if the deployment does not send large files.
Two configurations are refused at startup: a max_attachment_size larger than
max_total_attachment_size (unreachable, and it reads as a promise the total overrides), and a total above 256 MiB.
See Attachments
for the request shape and the
GET /api/v1/emails/limits endpoint that reports the effective values.
Sender Authentication
| Variable | Default | Description |
|---|---|---|
MAILYARD_SENDING_BOUNCE_ADDRESS | — | Return path for mail leaving through the shared platform pool only, where the sending IPs are ours. A project sending through its own server sets its own under Project settings. Empty leaves MAIL FROM as the From address. See Bounce Handling |
MAILYARD_SENDING_SPF_INCLUDE | — | Host tenants should name in their SPF record, e.g. _spf.mail.example.com. Empty means the console tells the operator to set it rather than printing a placeholder. |
MAILYARD_INBOUND_REJECT_ON_DMARC_FAIL | false | Refuse received mail when the From domain publishes p=reject and nothing it vouches for passed |
MAILYARD_SENDING_DKIM_KEY_CACHE | false | Keep parsed DKIM private keys in memory between messages instead of parsing the key for every message. Saves CPU on a busy sender, at a security cost described below |
Outbound mail is DKIM signed automatically for every domain a project has verified. Keys are RSA-2048, generated when
ownership verifies, and the private half is encrypted with database.crypto.encryption_key before it reaches the
database. There is no variable to turn signing on: a verified domain with a key gets signed mail.
sending.dkim_key_cache trades key exposure for CPU
Without the cache a decrypted DKIM private key exists in memory only while one message is signed. With it, a key stays in the worker’s memory for up to an hour after it was first used, and a rotated key stays until its hour is up. A memory dump or a core file of a worker then holds the private keys of every domain it signed for recently.
What it saves is parsing the key, roughly a tenth of the time spent signing one message. It does not save a database read: which key signs a message, and whether its domain is still verified and still the project’s to send as, is read for every message, so unverifying or unsharing a domain stops signing at once either way.
Turn it on for a worker that signs a large volume and whose memory you already treat as holding secrets. Leave it off otherwise.
Inbound mail is always authenticated and the verdict always stored.
MAILYARD_INBOUND_REJECT_ON_DMARC_FAIL only decides whether a failure is also a refusal.
See Domain Verification
.
Attachment Storage
Where attachment bytes live. The default keeps them inline as base64 in the database, which needs no configuration at
all. The fs and s3 backends move the bytes out and store only metadata plus a storage key.
Inline storage is for testing, not production
Inline attachments are written into PostgreSQL as base64, a third larger than the files themselves. On a production
installation that grows the database, its backups and its replicas with every message that carries a file. Set
MAILYARD_STORAGE_BACKEND=fs with MAILYARD_STORAGE_FS_PATH on a persistent volume shared by every node, or s3. The
backend applies to mail accepted after it is set - attachments already stored inline are not moved and stay in the
database until the attachment retention window clears them.
| Variable | Default | Description |
|---|---|---|
MAILYARD_STORAGE_BACKEND | — | Empty for inline, or fs or s3 |
MAILYARD_STORAGE_FS_PATH | data/attachments | Base directory for the fs backend |
MAILYARD_STORAGE_S3_ENDPOINT | — | S3-compatible endpoint (e.g. MinIO, R2) |
MAILYARD_STORAGE_S3_REGION | — | S3 region |
MAILYARD_STORAGE_S3_BUCKET | — | S3 bucket name, required for the s3 backend |
MAILYARD_STORAGE_S3_ACCESS_KEY | — | S3 access key |
MAILYARD_STORAGE_S3_SECRET_KEY | — | S3 secret key |
MAILYARD_STORAGE_S3_USE_PATH_STYLE | false | Path-style addressing, required by some MinIO setups |
Inbound Email
| Variable | Default | Description |
|---|---|---|
MAILYARD_INBOUND_ENABLED | false | Master toggle — enables the MX listener |
MAILYARD_INBOUND_ADDR | :25 | Bind address, host and port together. 25 is where MX delivery arrives, and it is privileged: see Privileged ports |
MAILYARD_INBOUND_HOSTNAME | mailyard | Hostname announced in the SMTP EHLO greeting — should match the MX record |
MAILYARD_INBOUND_MAX_MESSAGE_SIZE | 26214400 | Max raw message size in bytes (default 25 MiB) |
MAILYARD_INBOUND_RATE_PER_MINUTE | 120 | Per-IP max SMTP sessions per minute (0 disables) |
MAILYARD_INBOUND_REJECT_ON_DMARC_FAIL | false | See Sender Authentication |
MAILYARD_INBOUND_TLS_ENABLED | true | Offer STARTTLS |
Recipients are gated at RCPT TO on domains the project verified by DNS TXT — there is no authentication on this
listener, and mail for anything unclaimed is refused. See Inbound Email
.
SMTP submission
Outbound submission for applications that speak SMTP rather than HTTP. Off by default,
MAILYARD_SUBMISSION_ENABLED=true turns it on and MAILYARD_SUBMISSION_ADDR
defaults to :587. The full table, including AUTH and STARTTLS, is in
SMTP Submission
.
CORS
Off by default, and it should stay off unless something needs it: the console is served from the same origin as the API, so it never does. This exists for a separately hosted front end or a browser-based integration.
cors:
enabled: true
allowed_origins:
- https://app.example.com
allow_credentials: true
| Key | Env | Default | Description |
|---|---|---|---|
cors.enabled | MAILYARD_CORS_ENABLED | false | Turn CORS on |
cors.allowed_origins | MAILYARD_CORS_ALLOWED_ORIGINS | - | Full origins including the scheme. Required when enabled. |
cors.allowed_methods | MAILYARD_CORS_ALLOWED_METHODS | GET,POST,PUT,PATCH,DELETE,OPTIONS | |
cors.allowed_headers | MAILYARD_CORS_ALLOWED_HEADERS | Content-Type,Authorization,X-Mailyard-Project-Id | |
cors.expose_headers | MAILYARD_CORS_EXPOSE_HEADERS | - | Response headers the browser may read |
cors.allow_credentials | MAILYARD_CORS_ALLOW_CREDENTIALS | false | Let the browser send the session cookie cross-origin |
cors.max_age | MAILYARD_CORS_MAX_AGE | 300 | Preflight cache, in seconds |
The configuration is checked at startup and the server refuses to boot on these, rather than leaving you to debug a silent browser rejection:
enabledwith no origins listed.- An origin without a scheme (
app.example.cominstead ofhttps://app.example.com). allowed_origins: ["*"]together withallow_credentials: true- browsers reject that combination anyway, and configuring it means somebody expected cookies to work.
allow_credentials is the dangerous switch
It turns every listed origin into somewhere an authenticated request can be made from. List only origins you control.
TLS
Two separate questions, answered in two separate places, and keeping them apart is the point.
Whether a listener terminates TLS is configuration. It is a port-level decision — the common deployment puts a reverse proxy in front that terminates TLS and speaks HTTP upstream — and it needs a restart either way.
| Variable | Default | Description |
|---|---|---|
MAILYARD_SERVER_TLS_ENABLED | false | HTTPS on the console and API |
MAILYARD_SUBMISSION_TLS_ENABLED | true | STARTTLS on the submission listener |
MAILYARD_INBOUND_TLS_ENABLED | true | STARTTLS on the MX listener |
The HTTP default differs from the two SMTP ones deliberately. On 25 and 587 a client prefers encryption and almost none
verify the certificate, so even a self-signed pair is a real improvement over cleartext. In front of the console there
is usually a proxy, and terminating here would make it talk HTTPS upstream to a certificate it has no reason to trust.
Relay nodes are the sharper half of that: a node verifies this certificate with its system trust store, so turning
SERVER_TLS_ENABLED on with a self-signed pair stops every node enrolling until you install the root on each machine.
Which certificate is served is not configured in this file. It is assigned in the console under Administration → Certificates, stored in the database, and resolved per handshake — so a replacement takes effect with no restart and reaches every node. Each listener walks one chain:
- the certificate assigned to it, if any
- the ACME certificate, if ACME is configured for that hostname
- a self-signed pair, generated on first use and shared by every node
A listener always has something to present, and an unreachable console is recoverable with
mailyard tls unassign --listener server, which writes to the database with no server running.
ACME
Whether to order from Let’s Encrypt, for which hosts and under which account are platform settings (acme_enabled,
acme_hosts, acme_email, acme_directory_url), set in the console under Administration → Certificates and read
fresh on every handshake and every order. None of them is in this file.
One key is here, because it binds a port:
| Variable | Default | Description |
|---|---|---|
MAILYARD_ACME_CHALLENGE_ADDR | empty | HTTP-01 responder. Empty means tls-alpn-01 only, which needs no port |
Empty is right whenever the CA can reach this process’s own TLS handshake. Behind a proxy that terminates TLS, set it to
an address the proxy forwards /.well-known/acme-challenge/* to - see
Certificates
. The port is bound at startup whether ACME
is on or not.
A name that is not in acme_hosts falls through to the self-signed pair rather than failing the handshake. That is the
ordinary state of an MX: the list is what somebody typed for the console, and the mail listeners may answer under a
different hostname.
Issued certificates are stored in the database, so every node serves the same one and a restart re-issues nothing. Renewal happens about 30 days before expiry, and Mailyard touches each configured host at startup and hourly after that, so a listener that has seen no traffic still renews.
Example environment
# Server
MAILYARD_SERVER_ADDR=:3000
MAILYARD_SERVER_PUBLIC_URL=https://mail.example.com
MAILYARD_SERVER_TRUSTED_PROXIES=10.0.0.0/8
# Database
MAILYARD_DATABASE_DSN=postgres://mailyard:secure-password@localhost:5432/mailyard?sslmode=require
# Secrets - generate both with `openssl rand -hex 32`
MAILYARD_AUTH_JWT_SECRET=...
MAILYARD_DATABASE_CRYPTO_ENCRYPTION_KEY=...
# Sign-in
MAILYARD_AUTH_LOCAL_ENABLED=true
MAILYARD_AUTH_LOCAL_EMAIL=admin@example.com
# HTTPS here rather than at a proxy. Which certificate is served, and
# whether Let's Encrypt is asked for one, is chosen in the console.
MAILYARD_SERVER_TLS_ENABLED=true
# Optional SMTP listeners, both off unless enabled
MAILYARD_SUBMISSION_ENABLED=true
MAILYARD_INBOUND_ENABLED=true
# Observability
MAILYARD_LOGGING_FORMAT=json
MAILYARD_METRICS_ENABLED=true
MAILYARD_METRICS_TOKEN=...
On first start against an empty database the bootstrap admin password is written to stderr once. Migrations run automatically, so nothing else is needed.
Platform mail — invitations, password resets, signup confirmations — is not in this list because it is not configured
here. Create the project it is sent as, verify its domain, and set platform_mail_from and platform_mail_project in
Admin → Settings. See Platform mail
.