Self-hosting
Deploy MepMail on your own infrastructure with Docker Compose and your own AWS SES account.
Self-hosted MepMail sends through your own AWS SES account. A deployment is two containers: Postgres and one app container running the API (port 3001), the worker, and the web dashboard (port 3000). An optional third container runs the SMTP relay (port 2587). (Prefer not to run infrastructure? MepMail Cloud is the same platform, hosted.)
Prerequisites: Docker with Compose; an AWS account with SES access in your chosen region (sandbox accounts can only send to verified recipients — request production access to send to anyone); a sending domain you control. Domain verification (DKIM records) is done from the dashboard after boot.
Quickstart (build from source)
Clone the MepMail fork and use the root Compose file, which builds the image locally from the checked-out source:
git clone https://github.com/JE4NVRG/mepmail.git mepmail
cd mepmail
cp .env.example .envFill in .env (see the environment reference —
everything else defaults to a working local setup), then:
docker compose up --build -dThe supported installation path for this fork is a local build from source.
The @millionsend/setup package on npm and the images referenced by
deploy/docker-compose.yml are not supported release channels for this fork.
Migrations run automatically on boot. Dashboard: http://localhost:3000.
API: http://localhost:3001.
Upgrades
git pull --ff-only
docker compose up --build -dMigrations run on boot, so that is the whole upgrade for a small instance. Pin the Git revision you deploy when you need a reproducible release or rollback. Schema migrations run forward only, so take a dump before a big jump (Backups); an older revision may not start on a newer schema.
Once tables are large (millions of emails or contacts), a migration that rewrites or indexes them takes minutes. Migrations run in one transaction and their locks block reads and writes on the tables they touch until it commits, so that wait is downtime whether it happens at boot or before the swap. Run it before the swap anyway, from a throwaway container, at a quiet hour: a migration that fails leaves the old container serving instead of a container that will not boot, and the boot-time pass then finds nothing pending:
docker compose build && docker compose run --rm --no-deps millionsend migrate && docker compose up -dDevelopment without Docker
With Node 24+, pnpm 11 and local Postgres: pnpm install, point
DATABASE_URL at your Postgres, pnpm --filter @millionsend/db db:migrate,
then run pnpm --filter @millionsend/api dev,
pnpm --filter @millionsend/worker dev, and
pnpm --filter @millionsend/web dev in separate terminals.
Environment reference
From .env.example. Only the two secrets are required; everything else has
working local defaults. Billing (plans, Stripe) exists only on hosted
deployments — see Billing; a self-hosted instance has no plan limits.
Required
| Variable | Purpose |
|---|---|
DATABASE_URL | Postgres connection string. The default matches the compose postgres service. |
POSTGRES_PASSWORD | Password of the compose postgres service (default millionsend); the setup wizard generates one and puts it in DATABASE_URL too. Keep both in sync. |
MASTER_ENCRYPTION_KEY | Encryption key for email bodies at rest. Generate with openssl rand -base64 32. Losing it makes stored bodies unrecoverable; changing it orphans old bodies. Back it up with the database. |
BETTER_AUTH_SECRET | Dashboard session signing secret. Generate with openssl rand -base64 32. |
APP_BASE_URL | Public base URL of the deployment — the origin browsers use to reach the dashboard (e.g. https://mail.example.com). Sign-in is only accepted from this origin; SNS subscriptions, unsubscribe links, and tracking links derive from it. It must match the exact scheme+host+port you open the dashboard on — including a custom WEB_PORT — or login and signup fail with an "invalid origin" error. Default http://localhost:3000. |
PUBLIC_API_URL | Public origin of the API when a reverse proxy serves it on its own hostname (e.g. https://api.example.com). It is what the dashboard prints as the API base and what MCP tokens are bound to; unset, the API is assumed at port 3001 of the dashboard host. |
AWS SES
| Variable | Purpose |
|---|---|
AWS_REGION | SES region (default us-east-1); also the KMS and SQS client region. |
AWS_REGIONS | Comma-separated SES regions this deployment sends from, the first being the default; unset, the one region in AWS_REGION. Each region needs its own SNS topic and configuration set — see Adding a region. |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY | IAM credentials with ses:SendEmail / ses:SendRawEmail. Omit to use the default AWS credential chain (instance profile, SSO, …). |
SNS_TOPIC_ARNS | Comma-separated SNS topic ARNs allowed to deliver SES events. Unset disables event ingestion entirely. |
SQS_QUEUE_URL | SQS queue the worker long-polls for SES events. Setup always creates it; keep it set even when SNS also pushes to https://<your-host>/ses/events (the app dedupes the two). |
SES_CONFIGURATION_SET | SES configuration set applied to sends that have no per-domain configuration set. Unset sends without one (and without delivery events). |
SES_TENANTS | One SES tenant per team, so SES tracks bounce/complaint reputation per customer and can pause one sender without the rest. Defaults to IS_CLOUD; needs the ses:*Tenant* IAM actions. |
Optional
| Variable | Purpose |
|---|---|
ALLOW_SIGNUP | The first user can always register; after that signup stays closed unless this is true. Keep false when the dashboard is reachable from the internet. |
TRUSTED_PROXIES | Reverse proxies whose forwarded-client-IP headers (X-Forwarded-For, CF-Connecting-IP) are believed, comma-separated IPs or CIDRs. Default 127.0.0.1,::1 covers a proxy on the same host; add your proxy's address when it runs elsewhere. See the nginx section. |
WEBHOOK_ALLOW_LOCALHOST | Local development only: lets webhook endpoints (test fires included) target http:// and loopback/private addresses on any port. Keep false on any internet-reachable instance. |
COMPOSE_PROFILES | Optional compose services, comma-separated: smtp (the relay; mount a STARTTLS keypair first), and in the standalone file also docs (this documentation site) and backup (scheduled dumps). |
PORT | API port (default 3001). Under compose this moves both the container's listen port and the published host port together. |
UNSUBSCRIBE_BASE_URL | Optional own host for the hosted unsubscribe pages (e.g. https://unsubscribe.example.com), pointed at the same web process. Unsubscribe links in mail and the page's redirects use it, and that host serves the unsubscribe flow only, so recipients and link scanners never reach the dashboard's origin, cookies or reputation. Unset: APP_BASE_URL. |
WEB_PORT | Host port the compose file publishes the dashboard on (the web process is always 3000 inside the container). Keep APP_BASE_URL in sync. |
DOCS_PORT | Host port the compose file publishes this documentation site on (the docs process is always 3002 inside the container). |
SMTP_PORT | SMTP relay listen port (default 2587). |
SMTP_TLS_CERT_PATH / SMTP_TLS_KEY_PATH | STARTTLS keypair for the SMTP relay (PEM paths inside the container). Both set: STARTTLS is offered and required before AUTH. Without the pair the relay refuses to start (unless SMTP_ALLOW_INSECURE_AUTH=true). |
SMTP_ALLOW_INSECURE_AUTH | Explicit local/private-network escape hatch for plaintext SMTP AUTH. Keep false; never combine true with a public bind. |
IS_CLOUD | Leave false. true enables hosted-cloud behavior (KMS, Stripe billing). |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PORTAL_CONFIG | Hosted cloud only; ignored when IS_CLOUD=false. Stripe API key, the signing secret of the webhook endpoint at /api/billing/webhook, and an optional customer-portal configuration id. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | OAuth credentials for "Continue with Google". The button appears only when both are set. Callback URL: {APP_BASE_URL}/api/auth/callback/google. |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | Same, for GitHub. Callback URL: {APP_BASE_URL}/api/auth/callback/github. |
AUTH_EMAIL_FROM | Sender for the emails to a person about their own account (password reset, email verification, the welcome, a password-changed receipt, an app granted access), as Name <user@domain> or a bare address; its domain must be a verified identity in this instance's SES account. Password recovery and sign-up verification only exist when this is set and SES credentials are configured — leave unset to skip both. Verify its domain in a team under Domains and those emails are logged there, with new accounts as its contacts (see Account mail). |
TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY | Cloudflare Turnstile keys, both or neither. When set, sign-in, sign-up, password reset and the onboarding "Send email" button verify a challenge token (invisible or managed widgets both work). Unset, every form runs without a captcha. |
ONBOARDING_EMAIL_FROM | Shared sender for the onboarding "Send email" button and snippet, as Name <user@domain> or a bare address on a domain verified in this SES account. Any team may send from it, but only to members who verified their address (where the instance verifies), and always with this exact display name. Leave unset to hide the button; the snippet then asks for the team's own domain. |
NOTIFICATIONS_EMAIL_FROM | Sender for the notices to team owners (quota, bounce/complaint rates, a domain verifying or losing its records, a new API key, a rotated webhook secret, a member joining, a broadcast that went out or is held, billing on the cloud) and for team invitation emails, same forms as AUTH_EMAIL_FROM, which it falls back to. With neither set, only the webhook events go out and invitations are link-only. Verify its domain in a team to log these emails there. |
Worker sizing
Defaults fit the 14/s send rate. Postgres runs with max_connections=200 in
the compose files; each process (api, worker, web) holds a pool of up to 24
connections, so separate containers and worker replicas fit without tuning.
| Variable | Purpose |
|---|---|
SEND_CONCURRENCY | Parallel send lanes in the worker (default 16) — about 1.2 per message/second of SES send rate. |
WORKER_REPLICAS | Number of worker processes running (default 1). The SES rate limiter is a per-process token bucket, so each worker divides the send rate by this to keep the account at its total. |
SES_TRANSACTIONAL_RESERVE | Percent of every region's rolling 24-hour SES quota that broadcasts never touch (default 30, allowed 5–90). Transactional mail may use all of it and borrow beyond it; a broadcast larger than the rest is paced over the following days. Bootstrap value only: Console → Regions overrides it at runtime. |
SQS_POLL_CONCURRENCY | Parallel SQS long-poll loops for SES events (default 4). |
WEBHOOK_DELIVERY_RETENTION_DAYS | How long webhook delivery rows and payloads stay readable in the delivery log (default 30); older ones are purged. |
EMAIL_METADATA_RETENTION_DAYS | Days whole email rows (recipients, subject, status, events) are kept (default 30, the industry norm); bodies leave earlier on the dashboard's retention setting, and daily counters and broadcast results are kept regardless. Releases before v0.6.30 defaulted to 365: set it explicitly before upgrading if that history must stay. |
OPEN_PREFETCH_WINDOW_SECONDS | A tracking-pixel fetch within this many seconds of delivery (or before it) is recorded as prefetched, not opened (default 10); 0 keeps only the user-agent rules. See open-rate accuracy. |
Object storage (uploads & backups)
| Variable | Purpose |
|---|---|
S3_ENDPOINT / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY | ONE S3-compatible credential set shared by team logo uploads and database backups (Cloudflare R2 works out of the box). Set all three together. |
S3_REGION / S3_PROVIDER | Defaults auto / Cloudflare suit R2. Other S3-compatibles set a real region if the endpoint needs one, and rclone's provider name (AWS, Minio, …) for the backup job. |
S3_STORAGE_BUCKET / S3_STORAGE_PUBLIC_URL | Public uploads bucket (team logos) and the public base URL it serves from. Set together; unset hides the upload UI everywhere. |
S3_BACKUP_BUCKET | PRIVATE bucket for scheduled database dumps — never the public uploads bucket. Unset disables the backup service. |
S3_BACKUP_PREFIX / BACKUP_CRON / BACKUP_RETENTION_DAYS | Backup tuning: object key prefix (default backups), daily dump schedule (<minute> <hour> * * *, UTC, default 0 3 * * *; any other shape makes the service exit 1), days of dumps kept (default 14). |
BACKUP_AGE_RECIPIENT | age public key (age1…); set to encrypt dumps before upload. Restore with age --decrypt -i <key file> first. |
AWS setup
The source checkout's setup wizard creates everything MepMail needs
in AWS — IAM policy + user + access key, the SNS event topic, the SQS events
queue (mepmail-events) the worker long-polls, and the SES configuration
set. An HTTPS APP_BASE_URL additionally gets events pushed to your host; the
queue works without any public URL.
Install the locked workspace dependencies and run it from the checkout where
.env lives (Node 22+, pnpm 11):
pnpm install --frozen-lockfile
pnpm setup:awsRun it where your AWS admin credentials live — laptop or server; the MepMail
server never needs admin credentials. It verifies your
AWS identity, shows the plan, creates everything, and writes the AWS_* lines
into the .env in the current directory (no .env there → it prints them to
paste where MepMail runs). --dry-run prints the full plan and exits.
pnpm setup:aws teardown deletes everything the setup created,
including all access keys of the mepmail IAM user, so a running server
stops sending. Re-running setup is safe, but each run mints a new access key —
delete stale ones in the IAM console.
Prefer not to run a CLI? The dashboard's Settings → SES page offers a CloudFormation quick-create link and a pre-filled shell script that create the same resources.
Adding a region
One deployment can send through several SES regions. A domain lives in one region, the one picked when it is added (to move it, delete it and add it again); identities, the 24-hour quota, the send rate and the sandbox status are all per region; every region's events land in the one SQS queue, because SNS delivers across regions.
Run the source wizard's add-region command from the checkout where .env
lives, with short-lived admin credentials in the environment (nothing is
stored):
pnpm setup:aws add-region us-east-1The interactive wizard offers the same step as Add a region at its AWS
step. Run anywhere else, without a .env, the command asks for the install's
SQS_QUEUE_URL, SNS_TOPIC_ARNS, AWS_REGIONS, and optional APP_BASE_URL
(empty: queue only), then confirms before creating anything and prints the two
lines instead of writing them.
It keeps the IAM user, policy and access key (no new key), creates in the new
region the SNS topic, the SES configuration set with its event destination and
the bounce-only suppression setting, subscribes the topic to the existing
queue, and appends to .env:
AWS_REGIONS=sa-east-1,us-east-1 # the first entry stays the default region
SNS_TOPIC_ARNS=<first topic ARN>,<new topic ARN>AWS_REGION and SQS_QUEUE_URL are left as they are. Restart the stack
(docker compose up -d): the region then appears in the add-domain form and on
Settings → SES, marked Sandbox until AWS grants production access there —
request it per region, as for the first one. While one region has production
access, a sandbox region is listed but not selectable in the form; a sandbox
region paces its own sends at its 1/s and holds only its own domains when its
24-hour quota is spent.
Pricing: since 2026-07-21 an SES account × region with no prior sending starts
on the Essentials plan ($0.16 per 1,000 messages instead of the à la carte
$0.10). After provisioning, the wizard reads the region's plan and, on
Essentials, asks whether to cancel it; nothing MepMail uses needs a plan,
and a defaulted plan's cancellation takes effect immediately. By hand:
aws sesv2 put-account-pricing-attributes --plan NONE --region <region>.
Manual equivalent: in the new region, the SNS topic, the configuration set and
the suppression setting exactly as in SES events; a sqs
subscription of that topic to the existing queue's ARN, and the queue's policy
extended so sqs:SendMessage is allowed from the new topic ARN as well; then
the two .env lines above and a restart.
SES events (bounces, complaints, deliveries)
The setup CLI always configures this: an SQS queue (mepmail-events) that
the worker long-polls, its URL in .env as SQS_QUEUE_URL. The queue buffers
events through restarts and needs no inbound reachability, so it is the
transport every deployment gets; a public HTTPS APP_BASE_URL additionally
gets an SNS subscription pushing to your host, and the app dedupes the two.
SNS_TOPIC_ARNS gates ingestion either way: events are only accepted from
topics on that allowlist. Keep SQS_QUEUE_URL set even after switching to an
https APP_BASE_URL — clearing it leaves events piling up in the queue.
Manual equivalent: an SNS standard topic (same region as SES) subscribed to
https://<your-host>/ses/events (or to an SQS queue whose policy lets the
topic send and whose URL is in .env as SQS_QUEUE_URL), its ARN in .env
as SNS_TOPIC_ARNS; an SES configuration set with an event destination
pointing at the topic (event types: Delivery, Delivery Delay, Bounce,
Complaint, Reject, Rendering Failure — do NOT subscribe Open or Click, which
makes SES rewrite every link and inject its own pixel while MepMail tracks
engagement itself), its name in .env as SES_CONFIGURATION_SET. Restart
after setting them. Without SES_CONFIGURATION_SET, sends go out without a
configuration set and emit no events.
The wizard also sets SES's account-level suppression list to bounces only.
That list is per region and shared by every team on the instance: a
hard-bounced mailbox is dead for everyone, so SES may stop it account-wide,
but a spam report is about one sender's mail — MepMail suppresses it for
that team alone, and left on the SES list it would also block an unrelated
team's receipt or a password reset to the same person. If you provisioned
by hand or with the CloudFormation template, set it yourself in the SES
console (Suppression list → Account-level settings) or with
aws sesv2 put-account-suppression-attributes --suppressed-reasons BOUNCE.
The HTTPS SNS subscription confirms itself once the app runs with
SNS_TOPIC_ARNS set; if it stays pending, use "Request confirmation" on it in
the SNS console. Same-account SQS subscriptions need no confirmation.
The subscription endpoint is {APP_BASE_URL}/ses/events, but the API process
serves that path, not the dashboard: a reverse proxy in front of the dashboard
hostname must route that one path to the API (the nginx
section does), or the confirmation POST lands on the
dashboard, 404s, and the subscription stays pending with every bounce and
delivery lost.
SES tenants (per-team reputation)
With SES_TENANTS=true (the default on Cloud) every team gets its own SES
tenant, named by the team id, in each region it has a domain in. A domain's
identity and the shared SES_CONFIGURATION_SET are associated with the
tenant when the domain is created, and every send from it names the tenant, so
SES keeps bounce and complaint metrics — and its own sending pause — per
customer instead of per account. Domains that predate the flag, or whose
association failed, are picked up by the hourly tenants.sync job. The IAM
policy the wizard and the CloudFormation template install includes the
ses:CreateTenant, ses:GetTenant, ses:DeleteTenant,
ses:CreateTenantResourceAssociation and ses:DeleteTenantResourceAssociation
actions; an existing deployment re-runs the wizard (or updates the
mepmail-ses policy) before turning the flag on.
To update the policy in place, publish the JSON the SES settings page shows as a new default version:
aws iam create-policy-version --policy-arn arn:aws:iam::<account-id>:policy/mepmail-ses \
--policy-document file://mepmail-ses.json --set-as-defaultSMTP relay
A drop-in SMTP relay for software that speaks SMTP instead of HTTP — legacy
apps, CMS plugins, anything with an "SMTP settings" form. Messages go through
the same accept pipeline as POST /emails: same domain verification,
suppression checks, request logging, and delivery events.
Connection details:
- Host: wherever the
smtpservice is reachable (the compose files publish it on the Docker host). - Port:
2587(SMTP_PORTto change). - Username:
mepmail(fixed). - Password: an
ms_API key from the dashboard. - Encryption: STARTTLS is offered (and required before AUTH) when
SMTP_TLS_CERT_PATHandSMTP_TLS_KEY_PATHpoint at a PEM keypair. Without one, the relay refuses to start unlessSMTP_ALLOW_INSECURE_AUTH=trueis explicitly enabled for a trusted private network.
STARTTLS with your existing certificates
Before exposing the relay to the internet, give it a certificate — otherwise
SMTP AUTH sends the API key in plaintext. Any PEM keypair works, and if you
followed the nginx guide above you already have one: reuse the Let's Encrypt
certificate certbot issued for your domain. Mount it into the smtp container
with a docker-compose.override.yml:
services:
smtp:
volumes:
- /etc/letsencrypt/live/mail.example.com:/certs:roand point the env at it in .env:
SMTP_TLS_CERT_PATH=/certs/fullchain.pem
SMTP_TLS_KEY_PATH=/certs/privkey.pemWith both set, STARTTLS is required before AUTH — credentials never cross the
wire unencrypted. Mount the live/<domain> directory (a symlink certbot keeps
current), not a copy of the files, so a renewal lands at the same path — and
restart the relay after each renewal, since it reads the keypair when it
starts (certbot:
--deploy-hook 'docker compose -f /opt/mepmail/docker-compose.yml restart smtp').
A wildcard or any other CA-issued PEM works the same way.
Nodemailer example:
import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: "localhost",
port: 2587,
auth: { user: "mepmail", pass: "ms_..." },
});
await transport.sendMail({
from: "[email protected]",
to: "[email protected]",
subject: "Hello",
html: "<p>Sent over SMTP.</p>",
});The smtp service is defined in both compose files behind the smtp
profile, so it stays off until asked for: once the keypair is mounted, add
smtp to COMPOSE_PROFILES in .env (comma-separated with any others) and
docker compose up -d.
Documentation site
The image can also serve this documentation site: a docs compose service
runs with PROCESS=docs and publishes port 3002 (host side tunable via
DOCS_PORT). It needs no database and is entirely optional; in the standalone
file it sits behind the docs profile (COMPOSE_PROFILES=docs).
Production: nginx + TLS
The recommended production shape: nginx on the host terminates TLS and proxies
one hostname per service, and the compose ports bind to loopback so nginx is
the only way in. The API needs its own hostname (or an exposed port): its
routes (/emails, /domains, …) share paths with dashboard pages, so the two
cannot split one hostname by path. Set PUBLIC_API_URL to that hostname — it
is what the dashboard prints as the API base and what MCP tokens are bound to;
unset, the API is assumed at port 3001 of the dashboard host.
/etc/nginx/conf.d/mepmail.conf:
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
# Dashboard.
server {
listen 80;
server_name mail.example.com;
# The broadcast editor posts full HTML bodies through the dashboard.
client_max_body_size 25m;
# SES events: SNS is subscribed at {APP_BASE_URL}/ses/events, and the API
# process serves that path, not the dashboard.
location = /ses/events {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
# API.
server {
listen 80;
server_name api.example.com;
# POST /emails/batch takes up to 100 emails per request; html/text bodies
# carry no schema byte cap, but SES rejects messages over 10 MB anyway.
# 25m covers a full batch of large bodies without unbounded uploads.
client_max_body_size 25m;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Docs (optional).
server {
listen 80;
server_name docs.example.com;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}TLS and the http→https redirect in one line — certbot rewrites the blocks above to listen on 443 with Let's Encrypt certificates, adds the redirect, and installs automatic renewal:
sudo certbot --nginx --redirect -d mail.example.com -d api.example.com -d docs.example.comThen set APP_BASE_URL=https://mail.example.com and
PUBLIC_API_URL=https://api.example.com in .env and restart. APP_BASE_URL
must be the exact public https origin of the dashboard — any other value
makes login and signup fail with an "invalid origin" error. Forward Host and
X-Forwarded-Host to the dashboard and docs upstreams as above, so any
absolute URL either app derives from the request names the public hostname
rather than localhost.
Client addresses (sign-in rate limits, audit entries) come from
X-Forwarded-For, and only proxies listed in TRUSTED_PROXIES (comma-separated
IPs or CIDRs; default 127.0.0.1,::1, which covers nginx on the same host)
are believed. With proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for
each hop appends itself, and the chain is walked right-to-left past every
trusted proxy, so the first untrusted address is the client. Add your proxy's
address when it runs on another host, and a CDN's ranges when one sits in
front of nginx; headers from any other source are ignored and the socket
address is used instead.
The compose files bind every application port to loopback by default
(WEB_BIND_ADDRESS, API_BIND_ADDRESS, DOCS_BIND_ADDRESS,
SMTP_BIND_ADDRESS, all 127.0.0.1), so only a local reverse proxy reaches
them. Docker publishes ports by editing iptables directly, so do not rely on a
host firewall to compensate for a public bind: set a *_BIND_ADDRESS to
0.0.0.0 only for a service that must be reachable directly.
The SMTP relay (:2587) is TCP, not HTTP — an http server block cannot
proxy it. Either publish it directly (SMTP_BIND_ADDRESS=0.0.0.0 and open the
firewall), or keep it on loopback and pass the TCP stream through nginx's
stream module — bytes pass through untouched, so STARTTLS still terminates in
the relay via SMTP_TLS_CERT_PATH/SMTP_TLS_KEY_PATH:
# /etc/nginx/nginx.conf — top level, outside the http {} block
stream {
server {
listen 2587;
proxy_pass 127.0.0.1:2587;
}
}Firewall: allow 80 and 443, plus 2587 only if the SMTP relay is used from outside; everything else closed:
sudo ufw default deny incoming
sudo ufw allow 80,443/tcp
sudo ufw allow 2587/tcp # only if the SMTP relay is exposed
sudo ufw enableObject storage (team logos)
Optional. With an S3-compatible bucket configured, team admins can upload a
team logo in the dashboard; it also brands hosted unsubscribe pages when
MepMail branding is hidden. ONE S3_* credential set is shared with the
backup job below — each feature is then enabled by its own bucket variable.
The storage step of pnpm setup:aws prompts for the endpoint and
keys, creates (or adopts) both buckets — mepmail-storage and
mepmail-backups by default — and writes the S3_* lines to .env.
The one thing it cannot do over the S3 API is make the uploads bucket serve
objects publicly: on R2, enable public access on the bucket (or attach a
custom domain), then set that URL — uploads are addressed as
${S3_STORAGE_PUBLIC_URL}/<key>:
S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_STORAGE_BUCKET=mepmail-storage
S3_STORAGE_PUBLIC_URL=https://<public-bucket-url-or-custom-domain>Keep the two buckets separate: R2 public access is bucket-wide, so a database dump in the public uploads bucket would be world-readable.
Backups
The backup compose service takes a scheduled pg_dump of Postgres and
uploads it to any S3-compatible bucket via rclone — Cloudflare R2 works out of
the box. It is off by default: without S3_BACKUP_BUCKET the container prints
backups disabled — set S3_BACKUP_BUCKET to enable and exits 0, harmless.
Enable it by setting the shared S3 credentials and a backup bucket in .env
(the setup wizard's storage step creates the bucket and writes these lines).
The bucket must exist before the first dump and must stay private — dumps
contain the whole database, and R2 public access is bucket-wide, so never
reuse the public uploads bucket. For R2 the defaults S3_PROVIDER=Cloudflare
and S3_REGION=auto are already right:
S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BACKUP_BUCKET=mepmail-backupsThen add backup to COMPOSE_PROFILES in .env and docker compose up -d:
the service dumps once immediately, and after that daily on BACKUP_CRON
(default 0 3 * * *, UTC). Only the daily form <minute> <hour> * * * is
honoured — the sidecar runs unprivileged as postgres with every capability
dropped, so the schedule is a sleep loop rather than crond, and any other
shape makes the service exit 1. Each dump is pg_dump -Fc
(compressed custom format, named mepmail-YYYYMMDD-HHMMSS.dump), its
uploaded size is verified against the bucket before anything else happens, and
dumps older than BACKUP_RETENTION_DAYS (default 14) are pruned.
S3_BACKUP_PREFIX (default backups) sets the object key prefix. Other
S3-compatible stores work by setting S3_PROVIDER to rclone's provider
name (AWS, Minio, …) and a real region if the endpoint needs one.
Set BACKUP_AGE_RECIPIENT to an age public key
(age1…) to encrypt each dump before upload (.dump.age); the bucket then
never holds a readable copy of the database. Keep the matching private key
with MASTER_ENCRYPTION_KEY, and restore with age --decrypt -i <key file>
before pg_restore.
The dumps contain email bodies encrypted with MASTER_ENCRYPTION_KEY — back
that key up separately, or restored bodies are unrecoverable.
The root Compose file builds the backup service locally from scripts/backup.
Restore
Stop the app first so nothing writes mid-restore:
docker compose stop millionsend smtp
# list the bucket, pick a dump
docker compose run --rm --entrypoint /usr/local/bin/backup.sh backup \
sh -c 'rclone lsl ":s3:$S3_BACKUP_BUCKET/${S3_BACKUP_PREFIX:-backups}"'
# download it and restore over the current database
docker compose run --rm --entrypoint /usr/local/bin/backup.sh backup \
sh -c 'rclone copyto ":s3:$S3_BACKUP_BUCKET/${S3_BACKUP_PREFIX:-backups}/mepmail-YYYYMMDD-HHMMSS.dump" /tmp/restore.dump \
&& pg_restore --clean --if-exists -d "$DATABASE_URL" /tmp/restore.dump'
docker compose start millionsend smtpSignup policy
The first user to register becomes the initial account — no configuration
needed. After that, registration is closed: anyone with an account can create
API keys that send through your SES account, so signup stays off unless you
opt in with ALLOW_SIGNUP=true. Keep port 3000 off the public internet unless
you have opened signup deliberately.
Account mail, contacts and product updates
MepMail's own emails go out from AUTH_EMAIL_FROM and
NOTIFICATIONS_EMAIL_FROM. To a person, about their account: password
resets, email verification, a welcome once the address is theirs, a receipt
when a password reset goes through, and one when an MCP app is granted
access — sent from the request itself. To a team's owners: team invitations,
the quota and deliverability notices, a domain that verified or lost a
record (a domain SES gave up on says to add it again), a new API key (also
to whoever created it), a rotated webhook secret with the old secret's
deadline, a member who joined, a broadcast that went out — or is waiting for
its quota, or held while its region is paused — and, on the cloud, a
plan activated, moved or ended, a scheduled cancellation with a reminder
three days out, and a failed charge. A broadcast report is sent by the
worker as the broadcast finishes and the billing notices by the Stripe
webhook itself (only the cancellation reminder and a plan lapsing at its
period end come from the worker); every other owner notice comes from the
notification sweep, so it arrives within ten minutes of the change. Each is
written in the language of the reader's contact in the team that holds the
sender's domain (the row sign-up enrolls, below), else English. Each owner
chooses which of these notices they get under Settings → Notifications;
mail about a person's own account and the security receipts are always sent.
Verify the sender's
domain under Domains in a team, and from then on those emails are logged
and measured in that team like any other: they appear in its Emails list with
a mepmail_system tag, count in its Metrics, and its Suppressions fill in
from their bounces. Their body is purged the moment SES accepts the message,
since a reset link is a live credential, and their links are never rewritten
for click tracking. Until a team holds the domain they are sent straight
through SES and leave no trace, as before.
That team is the instance's own, and an operator can mark it as such: on the
system plan it is never capped or billed, its badge reads System and the
Billing tab shows a notice instead of plans. On a self-hosted instance plans
carry no limits, so the mark only labels the team.
The same team is the audience for product updates. On an instance with
ALLOW_SIGNUP=true, every new account becomes a contact there with a
source: signup property (name, address, sign-up date and dashboard
locale) once its address is verified — a social sign-in arrives verified, a
password sign-up counts when the emailed link is opened. The sign-up screen
says so, and deleting the account deletes the contact and scrubs the address
from that team's history. A closed instance enrolls nobody. To enroll
accounts that existed before the domain was verified, run once (accounts
that never verified enroll on their own at their next sign-in):
insert into contacts (team_id, email, first_name, last_name, properties)
select '<team id>', email,
split_part(name, ' ', 1),
nullif(substr(name, length(split_part(name, ' ', 1)) + 2), ''),
jsonb_build_object('source', 'backfill', 'signed_up_at', to_char(created_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
from "user"
where email_verified
on conflict do nothing;Sends to these contacts follow the usual rules: create a topic (say, "Product updates") so an unsubscribe applies to it and never to account mail, and send broadcasts from the team's verified domain.
Email verification is on whenever AUTH_EMAIL_FROM is set and SES
credentials are configured — the same condition as password recovery. A
password sign-up gets no session until the emailed link is opened; accounts
from before verify at their next sign-in. Where the instance verifies, the
onboarding sender only reaches members who did.
On an instance upgrading to this release, domains already verified and day-old audit rows are marked as told, but a domain that is demoted at the moment of the upgrade is reported as lost on the first sweep.
Nothing on a self-hosted instance contacts mepmail.je4ndev.com on its own. The
setup wizard offers, once and interactively, to subscribe the operator's
address to MepMail release notes; that is the wizard on your machine
posting your answer, and a confirmation link is sent before anything is
stored. When it cannot reach mepmail.je4ndev.com it prints the page instead,
app.mepmail.je4ndev.com/updates?source=self-host, and Settings →
Instance links to the same page; the source tags you as a self-hoster
either way.
Console
/console is the operator's view of the whole deployment: an overview
(sends, deliverability, teams, contacts, domains, queue, one card per SES
region with quota, pricing plan and enforcement status, and the health probes
with their history), a Regions page, a Teams page with operator actions
(change plan or type, daily send ceiling, pause broadcasts, suspend and
reinstate), a Trust & safety page built on the guardrail, the account score,
the stored content insights (never email bodies) and, when the optional
content monitor is on, the model's sampled verdicts, and an instance-wide
audit log.
Only the instance operator (the first registered user) can open it; everyone
else gets a 404, and nothing in the app links to it. On a self-hosted
instance, Settings → SES shows the operator an "Instance console" card
with an "Open console" button; the direct URL https://<your-host>/console
works too. Every number comes from Postgres or a free SESv2 GetAccount
read per region; no paid AWS API is called, and the per-region cost is a
local estimate.
A suspended team keeps its data and its keys authenticate, but every send
answers 403 team_suspended (SMTP 550); a broadcast pause holds
broadcasts while transactional mail flows; a daily ceiling caps the team's
day under its plan. Owners are emailed about each action (except a phishing
suspension), and every action lands in the audit log with its reason.
Content monitoring (optional)
Off by default. With a judge configured, a sample of accepted mail is scored 0–100 by TypeSafe Jev after SES has taken it and folded into a per-team risk the operator sees on Trust & safety. Nothing on the send path waits for it: a verdict never delays, holds or refuses a message, and a judge failure of any kind (feature off, missing credentials, throttling, timeout, upstream error, unparseable answer, body already purged by retention) records the sample as unjudged and changes nothing else. The deterministic content checks (the insights, the guardrail, the account score) run on every send whether or not the judge is on. Self-hosters can leave it off.
What it does. It opens the monitor flag on Trust & safety when a team's
risk crosses the flag line, emails the operator once a day per team past the
alert line, and, for a team in the New tier only (inside its first 1,000
sends or 72 hours, or under 10,000 sends within 7 days), pauses broadcasts
when the risk passes the pause line and a sampled message scored 90 or more
within a day (transactional mail keeps flowing; the team sees "paused
pending review"; the operator resumes from the review page). It never
suspends a team and never holds transactional mail: a person decides. The
pause policy is a setting and can be switched off.
Turning it on in the instance's .env, read by the worker and the app
(a restart applies it):
ABUSE_JUDGE=typesafe
ABUSE_JUDGE_API_KEY=...
# Optional; jev-latest is the default.
ABUSE_JUDGE_MODEL=jev-latest
ABUSE_JUDGE_TIMEOUT_MS=20000A missing API key fails the boot. The questions Jev answers ship in
packages/core/src/abuse-judge/questions.ts. TypeSafe is a US sub-processor
of the sampled text below; name it in the instance's terms and privacy
notice before turning the judge on.
Exactly what Jev sees, built in memory per call and never stored:
the team's name, verified domains, days since its first send and plan; the
From, Reply-To and Subject headers; the rendered visible text with
hidden elements stripped (up to 6,000 characters); a table of link anchor
texts and their registrable domains (up to 30); the image count, the
attachment names and types, and the count of hidden characters. Never a
recipient address, never the raw HTML, never an attachment's content. A
judged sample keeps its score, verdict, categories, reason codes,
impersonated brand, language, model id, latency and error class; the review
page shows those and never a subject or a body. Sample rows are pruned after
90 days.
Sampling. After each accepted message a keyed draw decides whether it is
judged. Every value below is edited in the console under Trust & safety →
Monitoring settings, or set as its MONITOR_* environment variable until it
is; the console wins.
| Setting | Default | Meaning |
|---|---|---|
MONITOR_FIRST_SENDS | 1000 | A team's first N accepted messages are judged in full |
MONITOR_FIRST_HOURS | 72 | Everything in the first H hours after a team's first send is judged in full |
MONITOR_RAMP_SENDS | 10000 | Up to this lifetime count the ramp rate applies |
MONITOR_RAMP_RATE | 0.25 | The ramp rate; the ramp ends at the count above or on the day below, whichever comes first |
MONITOR_RAMP_DAYS | 7 | |
MONITOR_PROBATION_RATE | 0.05 | The ramp's end to day 30 |
MONITOR_ESTABLISHED_RATE | 0.02 | Day 30 onward |
MONITOR_TRUSTED_RATE | 0.005 | 120 days, 50,000 sends and no flag in 90 days |
MONITOR_BROADCAST_COPIES | 3 | Rendered copies judged per broadcast (plus the broadcast's own HTML), established and trusted teams |
MONITOR_BROADCAST_COPIES_NEW | 10 | The same for new, ramp and probation teams |
MONITOR_ANOMALY_MULTIPLIER | 20 | One failing link-domain, shortener or phishing-pattern check multiplies the rate; two force the sample |
MONITOR_TEAM_DAILY_CAP | 600 | Judged messages per team per UTC day; past it sampling stops silently |
MONITOR_INSTANCE_DAILY_CAP | 50000 | Instance-wide; past it tier sampling stops, first sends and anomalies continue |
MONITOR_FLAG_RISK | 0.5 | The team gets the monitor flag and samples four times as much |
MONITOR_ALERT_RISK | 0.7 | The operator is emailed, once per team per day |
MONITOR_PAUSE_RISK | 0.85 | New teams only: broadcasts pause, with a verdict of 90 or more in the last day |
MONITOR_AUTO_PAUSE | true | Whether the pause policy applies |
MONITOR_FLAG_SCORE | 70 | A sample counts as flagged in the console from this score |
The risk is a decayed mean of the verdicts (half-life 7 days) with a prior that starts new teams higher. The Overview's Monitoring card charts the hourly sample count, and the operator is emailed when more than 20% of an hour's samples (at least 20 of them) went unjudged, at most once every six hours.
Content access (break-glass)
Off by default. With it on, an authorised operator can read the subject and the rendered visible text of specific messages of a flagged team, for a security reason they name and justify before anything is decrypted, for at most 30 minutes. It is the break-glass path for the cases the stored metadata cannot settle: the insights know a link points at a shortener, not whether the text around it is a bank lure or a newsletter.
What an operator can see. The subject, and the rendered visible text of
the HTML with hidden elements stripped (or the plain-text part when there is
no HTML), cut at 20,000 characters and redacted on the way out: every link —
written with a scheme or as a bare www. host — is reduced to its scheme, its registrable domain and at most 24 characters
of path, with the query and the fragment dropped entirely, so a one-time
link cannot be followed; anything shaped like a
credential (a JWT, 32 or more hex characters, 40 or more of base64, one of
this instance's own ms_ API keys) is masked, as is a 4-to-8-digit run
within 40 characters of a word like code, código, OTP, PIN, token,
senha, password or verification.
Never the raw HTML, the recipient addresses, the headers, the attachments or
the click-tracking targets, and the view offers no copy or download. A body
retention has already purged cannot be revealed by anyone.
For how long. A grant lasts 30 minutes from the moment it is made and is never extended; a later look is a new grant, with a new reason and a new audit row. Every view is counted on the grant.
What is logged. The grant row (content_access_grants) keeps the
operator, the reason, the justification they wrote, the scope, the message
ids, the view count and the times. Nothing prunes those rows: they are the
inventory of who read what. An instance audit row (content.revealed) is
written before anything decrypts, with the grant id, the reason and the
number of messages — never the justification's text and never any content.
What the team sees, and when. Seven days later a daily job adds a
content.accessed row to the team's own audit log — dated at the access,
not at the disclosure — and emails the team's owners in their own language:
when it happened, the reason, how many messages, and what was withheld. The
one exception is a team suspended for phishing since the grant, where the
row and the notice are withheld; the grant records that the disclosure step
ran either way, so it is not retried nightly.
Turning it on in the instance's .env, read by the worker and the app
(a restart applies it):
CONTENT_REVEAL=onOff, the console's buttons render disabled with a tooltip naming the variable and both procedures refuse. Reading other people's messages is lawful only as a narrow, recorded, disclosed security measure: say so in the instance's terms and privacy notice before turning it on.
Support view (optional)
Off by default; SUPPORT_VIEW=on turns it on. From the console's Teams
list, "View as owner" opens a team's dashboard as its owner sees it,
read-only, for 30 minutes, after the operator names a reason (support
ticket, billing dispute, other) and the ticket reference. Every reason is a
request the customer made; an operator checking an abuse report works from
the console's own Trust & safety pages instead, and from the content reveal
when the message text itself is needed. The session rides on the operator's
own login; no session is ever minted for the owner.
What the operator sees. The dashboard under a banner ("Support view of <team> · read-only · ends in mm:ss"): emails and their events, contacts, domains, broadcasts, templates, API key names, webhook endpoints, settings and usage.
What stays hidden. The content of sent mail: email bodies (the detail
says "Email content is hidden in support view"); the body and preheader of a
broadcast that has started going out, is sent, or was canceled mid-send;
every template body, since a template's text is copied into the broadcasts
sent from it and nothing records which; API log request and response bodies;
CSV exports (the export route answers 403); and every secret, so API keys,
webhook signing secrets and SMTP credentials are never returned. A draft or
scheduled broadcast stays readable, since nothing of it has reached anyone. Every change is refused:
the server answers FORBIDDEN to any mutation while the view is live,
whatever the screen shows.
How long. 30 minutes, enforced on every request. One live view per operator, starting another ends the previous, and a view cannot start another. The operator ends it from the banner, the owner from Settings → Support access, and expiry ends it on the next request.
What is logged. support.view_started and support.view_ended, in the
instance audit and, at once, in the team's own Settings → Audit log: who,
the reason, the reference, how it ended, the minutes, and how many distinct
procedures were read. The record keeps a count per procedure name and never
anything a procedure returned.
What the owner receives. An email when the session starts, naming who opened it, why, the reference, until when, and where to end it; and the Support access card under Settings while it is live, with an "End session" button.
SUPPORT_VIEW=onOperations
- Send rate and email retention are managed in the dashboard: Settings → Instance (owner/admin). Defaults are 14/s and 30 days until changed there; the worker picks up a rate change within a minute, retention on the next purge run.
- Worker sizing:
SEND_CONCURRENCYlanes (default 16, about 1.2 per message/second of SES rate) andWORKER_REPLICAS(default 1). The SES rate limiter lives in each worker process, so every worker divides the account's rate byWORKER_REPLICAS; set it to the number of worker containers you run. - To run processes in separate containers, set
PROCESStoapi,worker,web,smtp, ordocsper container (defaultall= api + worker + web). Upgrade them in the sameup -d: the Metrics chart counts only what upgraded processes write, so a writer left on an older image during the swap is missing from that day's chart (the daily usage figures are unaffected). - Email bodies are gzipped, then encrypted at rest with
MASTER_ENCRYPTION_KEY, and purged after the retention window. Back up the key with the database.