Stack
Same mail stack on every node. Domains, mailboxes, aliases, routes are replicated rows; Postfix and Dovecot read them live. No reloads.
| Part | Role |
|---|---|
| Postfix | SMTP, submission, queue; live MySQL maps; SNI map |
| Dovecot | IMAP, POP3, LMTP, Sieve, ManageSieve, SASL |
| Rspamd | Spam, DKIM signing, ARC, DMARC, greylisting, rate limits; state in Redis |
| ClamAV | Malware, reject; on by default |
ozpanel-mailgw | Sending limits, external forwards, Sieve outbound, login throttle |
| Port | Service |
|---|---|
| 25 | SMTP in; never rate-deferred |
| 587 / 465 | Submission STARTTLS / implicit TLS; AUTH after TLS only |
| 143 / 993, 110 / 995 | IMAP, POP3 |
| 4190, 10025, 10041, 11332–11334 | Loopback: ManageSieve, Sieve reinjection, login policy, Rspamd |
- StorageMaildir
~/mail/<domain>/<localpart>in the tenant home, shared storage. No mount, no mail services; senders retry. - ClusterDKIM keys, counters, greylist, Bayes shared. Every node in the MX set by default.
- AuthHashed password · up to 5 app passwords (
user+tag@example.com) · access tokens (XOAUTH2, OAUTHBEARER) · sender must own the From. - Login throttlePer client IP: delay after 5, refuse after 20, 10 min window.
- Suspended tenantNo send, no receive. Mail kept.
Mail domains
POST /mail/domains {"domain":"example.com"} # selector, DKIM key, DNS, SNI, webmail opt-in
POST /mail/domains/example.com/mailboxes {"localpart":"user","quota_mb":2048}
| Per-domain setting | Notes | Who |
|---|---|---|
tls_cert_file, tls_key_file | SNI certificate for SMTP/IMAP/POP. POST …/tls/from-caddy wires the web cert. | tenant, operator |
transport | Postfix transport for inbound; empty = local | tenant, operator |
relayhost | Default smarthost for outbound | tenant, operator |
rate_hourly, rate_daily | Sending limit override; ≤ account limit; 0 = plan | tenant, operator |
helo_identity | Read-only: the node's name. No per-domain HELO. |
GET/PUT /mail/domains/{domain}/settings (PUT merges). Disable: DELETE /mail/domains/{domain}, mail kept.
Routing
Aliases, forwarders, lists, catch-all
POST /mail/domains/example.com/aliases {"source":"sales","destinations":["user@example.com","other@example.net"]}
- sourceLocal part, full address, or
@for catch-all (explicit only). - destinationsSeveral = distribution list. Local mailbox = delivery; other = external forward.
- LimitPlan
mail_aliases, per source. - Not availableMailing lists (subscribe, archive, moderation).
External forwarding
Off by default. Needs plan feature external_forwarding (operator) and the tenant switch PUT /mail/account/forwarding {"enabled":true,"default_mailbox_id":N}.
| Case | Result |
|---|---|
| Allowed, under limit | Re-sent with signed return path on the tenant's domain; SPF passes; counted |
| Off, or over limit | Held in the owner's mailbox, owner notified |
| Spam | Not forwarded |
| Counter down | Local copy now; external retried 4 h |
| Bounce | Becomes a notice, never sent to the original sender |
Relays
POST /mail/domains/example.com/relays {"name":"smarthost","nexthop":"[smtp.example.net]:587",
"auth_enabled":true,"username":"user@example.net","password":"…","tls_level":"encrypt"}
| Field | Values | Default |
|---|---|---|
name, nexthop | required; brackets skip MX lookup | |
auth_enabled, username, password | SASL; password AES-256-GCM, never returned | off |
tls_level | none · may · encrypt · dane | may |
Platform relays: operator, /mail/relays. Tenant relays: own domains; may also use platform relays.
Routes
| Match | Who | Call |
|---|---|---|
| Sender mailbox | tenant, operator | POST /mail/domains/{d}/routes {"scope":"mailbox","localpart":"user","relay_id":1} |
| Sender domain | tenant, operator | {"scope":"domain","relay_id":1} |
| Destination domain | operator | POST /mail/routes {"match_type":"destination_domain","match_value":"example.net","relay_id":1} |
Order: mailbox → domain → relayhost → direct. Destination route beats transport. Most specific relay login wins. GET /mail/domains/{d}/routing.
Journaling
BCC to an archive. Domain: PUT /mail/domains/{d}/journal {"direction":"in|out|both","address","label"}. Platform: PUT /mail/journal. One address per scope.
Sending limits
- AccountPlan
email_per_hour,email_per_day; cluster-wide counter. - Domain
rate_hourly/rate_daily. Per mailbox: not available. - CountedExternal recipients only, after acceptance.
- Over
4.7.1 sending limit reached; counter down4.3.0. Both temporary; sender notified. - Outbound limitsCluster-wide;
GET /mail/ratelimits. - LockedRelay lock (
/postfix/relay/lock): submission only, sendmail root-only, PHPmail()off. Tenant outbound 25/465/587 denied. Outbound scan: mark 10, reject 15. - Sieve mail
PUT /mail/sieve-route {"enabled":true}: redirects and vacation counted, From checked. Off by default.
Import and sync
mbsync from any IMAP. Gmail and Outlook folders handled. Tenant (/mail/migrations) and mailbox owner (/mail/me/migrations).
POST /mail/migrations {"dest_domain":"example.com","dest_localpart":"user",
"source_host":"imap.example.net","source_port":993,"source_user":"user@example.net",
"source_password":"…","mode":"pull","interval_minutes":15,"scan_spam":true}
| Option | Values | Default |
|---|---|---|
mode | once · pull (never deletes) · two_way (flags, moves, deletes) | |
source_port | 993 TLS, 143 STARTTLS | 993 |
interval_minutes | 5–1440 | 15 |
scan_spam | spam to Junk; carried both ways | |
remote_junk | remote spam folder | detected |
- Control
PATCH /mail/migrations/{id}·POST …/actionpause | resume | run | cancel ·DELETEkeeps copied mail. - BehaviourCert verified, password encrypted. Backoff to 6 h, pause after 10 failures. 3 per node. Resumes on another node.
- Moving a domain inEnable mail, create mailboxes (new passwords), aliases, rules,
pullsyncs, publish new DKIM, switch MX, cancel syncs. - Not availablecPanel, Virtualmin or other panel importers.
Mail backup jobs
POST /mail/backups {"name":"nightly","kind":"backup","scope":"domain","target":"example.com",
"dest_path":"/var/backups/ozpanel/mail","schedule":"0 3 * * *","active":true}
ozpanel mail-backup-run --job 3
Operator. kind backup (maildir copy) | sync (remote). scope all | domain | mailbox. Cron or @daily etc. Status ok · partial · failed.
Spam and malware
| Protection | Default |
|---|---|
| ClamAV | on, reject |
| Phishing | on |
| Bayes, autolearn | on; per-user statistics a switch |
| Greylisting | on |
| Inbound rate limits | ip 500/h · to_ip 100/h · to 1000/h · bounce_to 50/h |
| DMARC reports | off; /rspamd/dmarc |
| ARC | built in |
| Control | Call | Who |
|---|---|---|
| Thresholds (greylist < spam < reject, 0–100) | PUT /mail/domains/{d}/spam (domain or mailbox) · PUT /mail/me/spam | tenant, mailbox owner |
skip_greylist | same | tenant |
| Allow / block lists | POST …/spam/lists {"list_type":"block","value":"spammer@example.net","mailbox":"user"} · /mail/me/spam/lists | tenant, mailbox owner |
| Relaxations | PUT …/spam/relax/{key}: no_reverse_dns, no_message_id, no_date_header, html_only | tenant |
| Why this score | POST /rspamd/explain | tenant |
| Engine, platform thresholds, rate limits | /rspamd/engine, /rspamd/ratelimit, …/selftest | operator |
- QuarantineSpam held. Release (
?learn=ham) or delete (?learn=spam) by tenant (/mail/quarantine) or owner (/mail/me/quarantine). - Digest
POST /mail/quarantine/digest/schedule {"hour":7}; send now…/digest. - LearningMove in or out of Junk in any client trains (IMAPSieve).
/mail/junk-learning.
Deliverability
One-call DNS
POST /mail/domains/{domain}/dns publishes, into the cluster's zone:
| Name | Type | Default value |
|---|---|---|
mail.example.com | A | One per node (all_nodes) · floating · pinned {"ip":"203.0.113.10"} |
example.com | MX | 10 mail.example.com. |
example.com | TXT | v=spf1 a mx ~all (merged) |
<selector>._domainkey | TXT | DKIM, RSA 2048 |
_dmarc | TXT | v=DMARC1; p=none; rua=mailto:postmaster@example.com |
autoconfig, autodiscover | CNAME | mail.example.com. |
_submission._tcp, _imaps._tcp | SRV | 587, 993 |
Missing MX, host, SPF, DMARC republished every pass; custom values left alone. Autoconfig/autodiscover XML endpoint: not available (DNS only).
| Feature | Call | Notes |
|---|---|---|
| DKIM | GET …/dkim, POST …/dkim/rotate | Generate, publish, rotate |
| MTA-STS + TLS-RPT | POST …/mta-sts {"mode":"testing|enforce|none","max_age":604800,"report_to"} | Policy site and 3 records |
| DANE | POST …/tlsa | 3 1 1 on 25, 465, 587, 993; DNSSEC zones only |
| Server identity | GET /mail/identity, POST …/publish | HELO A, SPF, PTR check |
| DMARC reports | /rspamd/dmarc | Send aggregate reports |
Scorecard
GET /mail/domains/{d}/deliverability · /mail/me/deliverability. Grade: good · improvable · action needed.
Checks: MX · SPF (exactly one) · DKIM published and signed · DMARC policy · DNSSEC · MTA-STS · TLS-RPT · TLSA · mail certificate (14 days) · PTR · HELO · Spamhaus ZEN.
Mailboxes and client servicing
| Action | Call | Who |
|---|---|---|
| Create | POST …/mailboxes localpart, quota_mb, password (generated, shown once) | tenant |
| Quota, suspend | PUT …/mailboxes/{lp} quota_mb, status | tenant |
| Reset password | POST …/password | tenant |
| Compromised | POST …/secure: new password, app passwords revoked, connections dropped | tenant |
| Connected devices | GET …/devices, POST …/devices/disconnect | tenant, owner |
| App passwords | List, revoke; owner creates | tenant, owner |
| Vacation | …/autoresponder: enabled, subject, message, start_date, end_date | tenant, owner |
| Filters | …/filters: from/to/cc/subject → fileinto, forward, discard, keep | tenant, owner |
| Sign in as this mailbox | POST …/mailboxes/{lp}/impersonate; audited, actions attributed | tenant |
| Quota view | GET /mail/domains/{d}/quota: per mailbox and tenant total | tenant |
- Own passwordMailbox and panel login stay in sync. Reset by email link (
/auth/password/reset-request). - QuotasPer mailbox (Dovecot, at delivery) and tenant total (
mail_quota_mb, at allocation). - FiltersOne Sieve script per mailbox, shared by panel, Roundcube, SOGo.
- Self-diagnostics
GET /mail/me/diagnostics: health, storage, counts, size and send limits. - Apple MailProfile per access token:
GET /tokens/me/{jti}/apple-mail.
| Client setting | Value |
|---|---|
| Server | mail.example.com (domain cert via SNI) |
| IMAP / POP3 | 993 or 143 STARTTLS / 995 or 110 STARTTLS |
| SMTP | 465 TLS or 587 STARTTLS, auth required |
| User | Full address, or the app-password name |
Failover
- InboundAll nodes in the mail host's A set; dead node withdrawn; never emptied. Senders retry.
- QueuePer node; a stopped node delivers on return.
- MailboxesShared storage; any node serves them. Dovecot replication not used.
Troubleshooting
| Question | Call |
|---|---|
| Working? | GET /mail/status, /mail/health |
| Stuck message | GET /mail/queue?domain=&sender=&status= · POST /mail/queue/flush · DELETE /mail/queue/{id} |
| Volumes | GET /mail/stats, /mail/flow, /mail/activity |
| Drift | GET /mail/verify, /mail/postfix/verify, /mail/dovecot/verify, /mailgw/status |
| Sessions | GET /dovecot/sessions |
| Rebuild data plane | POST /mail/reconfigure, /mail/dovecot/reconcile |
| Postfix | /postfix/status, health, limits, raw, maps, POST /postfix/param |
CLI: ozpanel mail-backup-run --job N · ozpanel mail-digest · ozpanel mailgw (unit only).
Previous: Apps and containers Next: Webmail, webapp and push

