A performant, self-hosted mail server written in Zig. Supports SMTP, IMAP, POP3, CalDAV/CardDAV, and more.
mail/
├── packages/
│ ├── zig/ # Core mail server (Zig 0.16.0-dev)
│ │ ├── src/
│ │ │ ├── main.zig # Server entry point
│ │ │ ├── mail_cli.zig # CLI entry point (zig-cli)
│ │ │ ├── core/ # Core: config, logging, protocol, TLS, sockets
│ │ │ ├── protocol/ # IMAP, POP3, CalDAV, ActiveSync, etc.
│ │ │ ├── auth/ # Auth, password hashing (Argon2id), CSRF
│ │ │ ├── storage/ # SQLite database layer
│ │ │ ├── delivery/ # Queue, bounce handling, TLS-RPT
│ │ │ ├── antispam/ # DKIM, SPF, DMARC, ARC, DNSBL
│ │ │ ├── observability/ # Logging, metrics, tracing, Discord alerts
│ │ │ ├── features/ # Sieve, quotas, templates, autoresponder
│ │ │ ├── infrastructure/ # Clustering, io_uring, connection pooling
│ │ │ └── api/ # REST API, health checks
│ │ ├── build.zig
│ │ └── pantry/ # Zig dependencies (zig-tls, zig-cli)
│ ├── cloud/ # AWS infrastructure (ts-cloud / CloudFormation)
│ │ └── cloud.config.ts # EC2, SES, Route53, IAM config
│ └── ts/ # TypeScript SDK (ts-mail) + MIME parser (src/mime, docs/MIME_PARSER.md)
├── pantry.jsonc # Monorepo config (workspaces, scripts)
└── docs/ # Architecture, security, protocol docs
# Package manager is `pantry` (like npm/bun)
pantry run build # ReleaseFast build
pantry run build:debug # Debug build
pantry run dev # Build + run server locally
pantry run test # Run all tests
pantry run fmt # Format Zig source
# Or directly from packages/zig:
cd packages/zig
zig build # Debug build
zig build -Doptimize=ReleaseFast # Release build
zig build -Dtarget=x86_64-linux # Cross-compile for Linux
zig build test # Run tests
zig build run -- serve # Run serverbetter-dx supplies the dev tooling (bumpx, logsmith, pickier, gitlint) — run
bun install once at the repo root.
pantry run release:patch # or release:minor, release:major
pantry run release # prompts for the level
pantry run release:dry # preview, writes nothingThey all go through scripts/release.ts, which gates the release (clean main
in sync with origin, every manifest on the same version) and then runs bumpx
over an explicit manifest list — package.json, every
packages/*/package.json, packages/zig/build.zig.zon. The list is explicit
because --recursive discovery also reaches the vendored Zig dependencies under
packages/zig/{vendor,zig-pkg,pantry}/ and would bump those too. bumpx
regenerates CHANGELOG.md via logsmith, commits, tags and pushes; the tag push
starts .github/workflows/release.yml.
bumpx only rewrites a manifest whose current version matches the one it is
bumping from, so a drifted manifest is skipped silently. bun test scripts
asserts they are all in lockstep and CI runs it on every PR.
This project uses Zig 0.16.0-dev which has breaking changes from 0.15:
std.ArrayListis unmanaged: init with.empty, pass allocator to every method (append(allocator, ...),deinit(allocator))std.time.sleepremoved: usetime_compat.sleep()(our wrapper usingstd.c.nanosleep)std.fs.cwd()removed: usefs_compatmodule for file operationsstd.process.Child.runremoved: useextern "c" fn system()or C file I/Ostd.c.stat/remove/systemremoved: declareextern "c"functions directly- Argon2 password hashing uses
p=1(single-threaded) to avoid async I/O requirements in tests - Custom compat layers:
src/core/io_compat.zig,src/core/fs_compat.zig,src/core/time_compat.zig,src/core/socket_compat.zig
- Instance:
i-0e365c6bd31da4678(EC2, Amazon Linux 2023, us-east-1) - Domain:
mail.stacksjs.com - Binary:
/opt/mail/mail-server(renamed frommailto avoid conflict withmail/mailbox dir) - Mailboxes:
/opt/mail/mail/{username}/new/(Maildir format) - Database:
/opt/mail/smtp.db(SQLite) - Config:
/etc/mail/mail.env - TLS: Let's Encrypt at
/etc/letsencrypt/live/mail.stacksjs.com/ - Service:
mail.service(systemd, runs asmail-serveruser withCAP_NET_BIND_SERVICE) - Delivery: Amazon SES (
SMTP_DELIVERY_METHOD=ses) - Ports: 25 (SMTP), 465 (SMTPS), 587 (Submission), 143 (IMAP), 993 (IMAPS)
Deploy new builds without SSH. The server has SSM agent running and an IAM role with SSM permissions.
# 1. Cross-compile for Linux
cd packages/zig
zig build -Dtarget=x86_64-linux
# 2. Upload binary to S3
aws s3 cp zig-out/bin/mail s3://stacks-production-s3-email/deploy/mail-server-new
# 3. Deploy via SSM (stop service, swap binary, restart)
aws ssm send-command \
--instance-ids i-0e365c6bd31da4678 \
--document-name "AWS-RunShellScript" \
--parameters 'commands=[
"aws s3 cp s3://stacks-production-s3-email/deploy/mail-server-new /tmp/mail-server-new",
"chmod +x /tmp/mail-server-new",
"systemctl stop mail",
"cp /opt/mail/mail-server /opt/mail/mail-server.bak",
"mv /tmp/mail-server-new /opt/mail/mail-server",
"chown mail-server:mail-server /opt/mail/mail-server",
"systemctl start mail",
"sleep 2",
"systemctl is-active mail"
]'
# 4. Check result
aws ssm get-command-invocation \
--command-id <COMMAND_ID> \
--instance-id i-0e365c6bd31da4678
# View logs
aws ssm send-command \
--instance-ids i-0e365c6bd31da4678 \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["journalctl -u mail --no-pager -n 100"]'The old host key may need updating after instance rebuilds:
ssh-keygen -R mail.stacksjs.comDefined in cloud.config.ts using ts-cloud (CloudFormation wrapper):
- EC2: t3 instance with security groups for mail ports
- SES: Domain verification, DKIM signing
- Route 53: MX, A, SPF, DMARC, DKIM DNS records
- IAM: Role with SES, S3, Route53, SSM permissions
- S3:
stacks-production-s3-emailfor deployments and backups - User data script handles: Zig install, build from git, Let's Encrypt, fail2ban, systemd service, log rotation, certbot renewal
Deploy infrastructure:
pantry run deploy # Deploy CloudFormation stack
pantry run cloud:status # Check stack status
pantry run cloud:diff # Preview changesThe server includes a background health monitor (src/observability/discord.zig) that sends alerts to Discord via webhook. Checks include: active connections, database health, TLS cert expiry, disk space, and heartbeat.
Configure via environment variable:
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
- Maildir format: Messages stored as individual files with
:2,FLAGSsuffix (S=Seen, R=Answered, F=Flagged, D=Draft, T=Deleted) - IMAP flag persistence: Flags are persisted by renaming maildir files.
BODY[](non-PEEK) auto-sets\Seenper RFC 3501 - UID STORE: Handles comma-separated UID sets (e.g.,
83,85,88,90) by converting UIDs to sequence numbers viauidSetToSeqSet() - Password hashing: Argon2id with 64MB memory, 3 iterations, p=1 (format:
$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>) - SQLite: Primary database for user accounts, UID mappings, sessions
- TLS: Custom TLS 1.3 implementation via zig-tls dependency
cd packages/zig && zig build testTests are split across multiple binaries (main, auth, imap, protocol, config, connection_wrapper, etc.). The logging tests produce stderr output which Zig's test runner reports as warnings — this is expected behavior, not a failure.
Test binaries link against sqlite3 system library. The argon2 password tests use p=1 to avoid needing async I/O vtable (which is uninitialized in the test runner).
- Add a user:
mail-server user add <email> <password>(on server) - Check service:
systemctl status mail(on server via SSM) - View IMAP logs:
journalctl -u mail | grep IMAP - Backup:
mail-server backup create→ S3 bucketstacks-production-s3-backups - List domains:
mail-server domain list - Rename a domain:
mail-server domain migrate <old> <new>(see below)
A project renames itself and its mail domain has to follow. Doing that by hand means editing five things that must agree, and the mailbox goes dark if any one is missed:
users—usernameandemailare both the full address, both UNIQUEimap_mailboxes/imap_uids— keyed byusername; losing these makes every client re-download the mailbox and lose read/flag statewebmail_sessions— carry a baked-in identity- Maildirs at
/opt/mail/mail/<address> /opt/mail/forwards.json— keys and destinations
domain migrate does all five in one pass. It prints a plan and changes
nothing unless --yes is given:
mail-server domain list # what is in use
mail-server domain migrate ghostanalytics.org analyticshq.org
mail-server domain migrate ghostanalytics.org analyticshq.org --yesIt refuses up front when a target address is already taken — username and
email are UNIQUE, so the UPDATE would abort partway and strand the domain
across two names. Merge or delete the conflicting mailbox first.
The database moves in one transaction; maildir renames follow. A maildir that fails to rename is reported by name, because the alternative (rolling back with directories already moved) is worse.
Not automated, and printed as a reminder at the end of every run:
- add the new domain to
SMTP_LOCAL_DOMAINSin/etc/mail/mail.env, and keep the old one until mail has drained - generate a DKIM key for the new domain and publish its TXT record — mail from a domain with no key arrives unsigned and gets filtered
- MX, SPF and DMARC for the new domain
Paths come from MAIL_ROOT, MAIL_FORWARDS_PATH and MAIL_DKIM_DIR, all
relative to the working directory by default (/opt/mail in production).
Source: src/domain_migrate.zig (planning), src/cli/domain.zig (CLI),
Database.renameDomain (the SQL, in one transaction).
- Use pickier for linting — never use eslint directly
- Run
bunx --bun pickier .to lint,bunx --bun pickier . --fixto auto-fix - When fixing unused variable warnings, prefer
// eslint-disable-next-linecomments over prefixing with_
- Use stx for templating — never write vanilla JS (
var,document.*,window.*) in stx templates - Use crosswind as the default CSS framework which enables standard Tailwind-like utility classes
- stx
<script>tags should only contain stx-compatible code (signals, composables, directives)
- buddy-bot handles dependency updates — not renovatebot
- better-dx provides shared dev tooling as peer dependencies — do not install its peers (e.g.,
typescript,pickier,bun-plugin-dtsx) separately ifbetter-dxis already inpackage.json - If
better-dxis inpackage.json, ensurebunfig.tomlincludeslinker = "hoisted"