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)
├── 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 serverThis 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
- 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"