Maddy 0.9.6 配置参数详解

Maddy 0.9.6 配置参数逐节实测:maddy.conf 基本变量、TLS 证书、pass_table 认证、imapsql 存储选型、msgpipeline 局部投递与远端队列、SPF/DKIM/DMARC 检查启用;本地域名+dnsmasq 模拟环境跑通收发闭环与中继握手,附全量实测回显与报错实录

Maddy邮件服务器2026/10/19

Maddy 0.9.6 配置参数详解

目标:逐节实测 Maddy Mail Server 0.9.6 默认配置文件 maddy.conf 的每个配置块——基本变量、TLS、认证、存储、路由管线、安全检查、出站队列,并在本地域名 + 本地 DNS 模拟环境中跑通"外部来信投递 → 认证提交 → DKIM 签名中继握手"完整闭环。本文所有命令、配置与回显均在 Ubuntu 22.04 + Maddy 0.9.6 实测通过。

一、环境信息

  • 操作系统:Ubuntu 22.04(5.15 内核,systemd 249)
  • Maddy 版本:0.9.6(maddy version → 0.9.6 linux/amd64 go1.27.1),安装与 systemd 托管见《Ubuntu 22.04 安装 Maddy 0.9.6 指南》
  • 配置文件:/etc/maddy/maddy.conf(官方 tarball 默认配置,4739 字节)
  • 数据目录:/var/lib/maddy(state_dir,服务以 maddy 用户运行)
  • 测试域名约定:服务器 mail.lab.example.org、邮件域 lab.example.org;模拟外部发件域 client.mailtest.internal;模拟远端 MX relayhost.mailtest.internal(127.0.0.2:25 起 maddy@relay 第二实例)
  • 测试账号:admin@lab.example.org、demo@lab.example.org、relayuser@mailtest.internal,密码均 q1w2e3r4
  • 本地 DNS 模拟:dnsmasq 监听 127.0.0.1:53,用 mx-host/host-record/txt-record 指令模拟 MX/SPF/DMARC/DKIM 记录(细节见 4.7 节)
  • 端口占用复核(动工前):
ss -ltn | grep -E ':(25|465|587|143|993)\b'

实测输出(本环境 25 端口已按测试约定改绑 127.0.0.1):

LISTEN 0      4096       127.0.0.1:25        0.0.0.0:*
LISTEN 0      4096               *:587             *:*
LISTEN 0      4096               *:993             *:*
LISTEN 0      4096               *:143             *:*
LISTEN 0      4096               *:465             *:*

二、前置条件

  • Maddy 0.9.6 已安装并托管为 systemd 服务(《Ubuntu 22.04 安装 Maddy 0.9.6 指南》第八章之前的内容)
  • 测试邮箱账号已开通(maddy creds create + maddy imap-acct create,见安装指南 7.3 节)
  • 已安装测试工具:apt-get install -y swaks dnsutils(swaks 为 SMTP 测试瑞士军刀,dig 用于核对 DNS 记录)
  • 配置修改流程约定:改 /etc/maddy/maddy.conf → maddy verify-config 校验 → systemctl restart maddy(监听地址/模块实例变更需重启;仅改表文件/TLS 证书可 systemctl reload maddy 热载)

三、参数查看与修改方式

Maddy 配置是其自定义的声明式语法(官方语法手册 https://maddy.email/reference/config-syntax/ ),掌握三条规则即可读懂全部配置:

  1. 指令:指令名 参数1 参数2(如 hostname mail.lab.example.org);
  2. 变量:$(name) = value 定义,$(name) 引用(如 $(primary_domain) = lab.example.org);
  3. 块:类型 名字 { 子指令/子块 } 定义模块实例,&名字 引用已定义实例(如 auth &local_authdb)。

校验与生效:

maddy verify-config
systemctl restart maddy

实测输出(校验通过时的完整回显;表文件缺失类告警不影响启动):

table.file: ignoring non-existent file: /etc/maddy/aliases
No errors detected

⚠️ 注意:配置语法错误时 verify-config 直接报行号,实测少一个右括号:

Error: failed to read config /etc/maddy/maddy.conf: /etc/maddy/maddy.conf:182 - Error during parsing: unexpected EOF when looking for }

行号 182 即出错位置,从该行向前找未闭合的块。

四、参数总览与核心块详解

默认配置按功能分为四个大区(注释行 # ---- 分隔):Base variables(基本变量)、Local storage & authentication(本地存储与认证)、SMTP endpoints + message routing(SMTP 端点与路由)、IMAP endpoints。以下逐块实测。

4.1 基本变量与 TLS(Base variables)

默认配置头部:

$(hostname) = mail.lab.example.org
$(primary_domain) = lab.example.org
$(local_domains) = $(primary_domain)

tls file /etc/maddy/certs/$(hostname)/fullchain.pem /etc/maddy/certs/$(hostname)/privkey.pem

逐项说明:

参数默认值含义改动影响
$(hostname)example.orgSMTP EHLO 标识、证书查找键、MTA-STS 身份必须与 MX 记录指向的主机名一致,否则对端 SPF/反垃圾校验吃亏
$(primary_domain)example.org主邮件域,DKIM 默认签名域、DSN 地址域决定 MAILER-DAEMON@$(primary_domain) 等系统地址
$(local_domains)$(primary_domain)本地投递域列表多域追加:$(local_domains) = $(primary_domain) other.example.com
tls file <cert> <key>certs 目录规则全局证书服务启动即校验可读,maddy 用户无权限会启动失败

多域名配置(官方 multiple-domains 教程)只需追加 $(local_domains) 并为每个域补 DKIM 签名参数,投递与检查逻辑自动覆盖新域。

TLS 证书三种获取路径实测要点:

# 路径一:自签(测试环境,见安装指南 2.3 节)
openssl req -newkey rsa:2048 -nodes -x509 -days 365 \
  -keyout /etc/maddy/certs/mail.lab.example.org/privkey.pem \
  -out /etc/maddy/certs/mail.lab.example.org/fullchain.pem -subj "/CN=mail.lab.example.org"

# 路径二:certbot(生产),软链 + ACL 授权
ln -s /etc/letsencrypt/live/mail.lab.example.org /etc/maddy/certs/mail.lab.example.org
setfacl -R -m u:maddy:rX /etc/letsencrypt/live /etc/letsencrypt/archive

# 路径三:acme.sh 直接安装到 certs 目录
mkdir -p /etc/maddy/certs/mail.lab.example.org
acme.sh --install-cert -d mail.lab.example.org \
  --key-file /etc/maddy/certs/mail.lab.example.org/privkey.pem \
  --fullchain-file /etc/maddy/certs/mail.lab.example.org/fullchain.pem

证书变更热载:Maddy 每分钟自动重读磁盘证书(官方教程原文),也可手动 systemctl reload maddy(unit 的 ExecReload=/bin/kill -USR2 $MAINPID)。实测 TLS 握手(587 端口 STARTTLS,swaks 输出节选):

=== TLS started with cipher TLSv1.3:TLS_AES_128_GCM_SHA256:128
=== TLS peer DN="/CN=mail.lab.example.org"

4.2 认证配置(auth.pass_table + sql_table)

默认认证块:

auth.pass_table local_authdb {
    table sql_table {
        driver sqlite3
        dsn credentials.db
        table_name passwords
    }
}

逐项说明:auth.pass_table 定义名为 local_authdb 的认证模块实例,从"表"里查 bcrypt 哈希校验密码;table sql_table 把表落在 SQLite 库 credentials.db(相对路径基于 state_dir /var/lib/maddy)的 passwords 表。

💡 技术拓展:Maddy 认证后端选项——

模块凭据来源适用
pass_table + sql_tableSQLite/PG/MySQL 表默认,配 maddy creds 管理
pass_table + file静态文件表少量固定账号
auth.pam / auth.shadow系统 PAM / /etc/shadow复用系统用户
auth.ldapLDAP 目录企业统一身份
auth.dovecot_saslDovecot SASL过渡期共存

默认采用 pass_table + sql_table,凭据与存储账户分离但用户名保持一致。

账号管理命令实测(凭据与存储账户分别创建):

maddy creds create -p q1w2e3r4 admin@lab.example.org
maddy imap-acct create admin@lab.example.org
maddy creds list

实测输出:

admin@lab.example.org
demo@lab.example.org

口令哈希算法默认 bcrypt(creds create --hash 可选 sha256/bcrypt/argon2,--bcrypt-cost 默认 10),手工生成对照:

maddy hash -p q1w2e3r4

实测输出:

bcrypt:$2a$10$clP2S9jMP3voRjxZUOyol.YQ92sKjaH4gHHrI/S0DueT37

⚠️ 注意:只建凭据不建存储账户时,来信投递被拒(实测 RCPT 阶段):501 5.1.1 User does not exist (msg ID = f9c508aa)。两个命令都要执行,用户名用完整邮箱地址。

4.3 存储配置(storage.imapsql 与 blob)

默认存储块:

storage.imapsql local_mailboxes {
    driver sqlite3
    dsn imapsql.db
}

逐项说明:storage.imapsql 把 IMAP 邮箱树/UID/标记等索引与元数据写入关系数据库;driver sqlite3 为默认驱动(可换 postgres/mysql,DSN 语法随驱动变化);消息本体(blob)默认存文件系统 messages/ 目录。

💡 技术拓展:存储驱动与 blob 后端选型——

组件选项适用
imapsql driversqlite3(默认)单机小规模,备份即拷文件
postgres多实例共享、大规模
mysql已有 MySQL 栈
blob 后端fs(默认)本地磁盘
s3消息量大、对象存储可用

本文实测采用默认组合(sqlite3 + fs);官方 README 明确 IMAP 存储为 beta,重 IMAP 生产场景评估 Dovecot 系方案(见《Maddy 0.9.6 介绍与选型指南》)。

存储管理 CLI 实测:

maddy imap-acct list
maddy imap-mboxes list admin@lab.example.org
maddy imap-msgs list admin@lab.example.org INBOX

实测输出:

admin@lab.example.org
demo@lab.example.org
INBOX   [\HasNoChildren]
Sent    [\Sent \HasNoChildren]
Trash    [\Trash \HasNoChildren]
Junk    [\Junk \HasNoChildren]
Drafts  [\Drafts \HasNoChildren]
Archive [\Archive \HasNoChildren]
UID 1:  <news@client.mailtest.internal> - Clean inbox delivery
  [\Recent], 2026-10-19 07:39:03 +0000 UTC

imap-acct create 自动创建的 6 个文件夹全部带 special-use 标记,客户端可直接映射为收件箱/已发送/废篓/垃圾/草稿/归档。

4.4 局部投递与改写(table.chain + msgpipeline)

默认的改写链与本地路由管线:

table.chain local_rewrites {
    optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
    optional_step static {
        entry postmaster postmaster@$(primary_domain)
    }
    optional_step file /etc/maddy/aliases
}

msgpipeline local_routing {
    destination postmaster $(local_domains) {
        modify {
            replace_rcpt &local_rewrites
        }
        deliver_to &local_mailboxes
    }
    default_destination {
        reject 550 5.1.1 "User doesn't exist"
    }
}

逐项说明:table.chain 串起三个 optional_step——子址重写(user+tag@ 折叠为 user@)、postmaster 角色地址、别名文件;msgpipeline 按"目的域"匹配:命中本地域 → 收件人经改写链 → 投入 local_mailboxes 存储;未命中 → 550 拒绝。别名文件 /etc/maddy/aliases 每行 别名 目标,文件缺失时仅日志 table.file: ignoring non-existent file: /etc/maddy/aliases(实测),创建后 systemctl reload maddy 生效。

4.5 SMTP 端点(25 端口 MTA/MX + 检查链)

默认 25 端口块(本文测试环境将绑定改写为 127.0.0.1,生产保持 0.0.0.0):

smtp tcp://127.0.0.1:25 {
    limits {
        all rate 20 1s
        all concurrency 10
    }
    dmarc yes
    check {
        require_mx_record
        dkim
        spf
    }
    source $(local_domains) {
        reject 501 5.1.8 "Use Submission for outgoing SMTP"
    }
    default_source {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.1.1 "User doesn't exist"
        }
    }
}

逐项说明:limits 全局限流(每秒 20 封、并发 10 连接);dmarc yes 启用 DMARC 策略执行;check 块启用三项入站检查——require_mx_record(HELO 域须有 MX/A 解析)、dkim(验证签名)、spf(发件 IP 授权校验);source $(local_domains) 阻断"本域发件人走 25 端口"(防伪造,强制走认证提交);default_source 是外部来信的正常路由。

实测一:外部来信被 DMARC 拒绝。 用真实公网有 reject 策略的域作发件人:

swaks --to admin@lab.example.org --from extuser@client.example.net --server 127.0.0.1:25 --header "Subject: t" --body "x"

实测输出(节选):

 -> MAIL FROM:<extuser@client.example.net>
<-  250 2.0.0 Roger, accepting mail from <extuser@client.example.net>
 -> RCPT TO:<admin@lab.example.org>
<-  250 2.0.0 I'll make sure <admin@lab.example.org> gets this
 -> .
<** 550 5.7.1 DMARC check failed (msg ID = 0a627cb1)

服务端日志(journalctl -u maddy 实测原文):

smtp: DATA error        {"check":"dmarc","dkim_domain":"","dkim_res":"none","msg_id":"0a627cb1","reason":"No aligned identifiers","smtp_code":550,"smtp_enchcode":"5.7.1","smtp_msg":"DMARC check failed","spf_from":"client.example.net","spf_res":"none"}

根因:_dmarc.example.net 公网记录为 "v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s"(dig 实查),发件无 DKIM 且 SPF 无记录 → 无对齐标识 → 按 reject 策略拒收。这证明 dmarc/check 链真实生效。

实测二:发件域无 MX 记录 → 隔离进 Junk。 发件域 client.mailtest.internal 未配 MX 时:

quarantined        {"check":"require_mx_record","msg_id":"747152a4","reason":"no such host","smtp_code":550,"smtp_enchcode":"5.7.0","smtp_msg":"DNS error during policy check"}
smtp: accepted        {"msg_id":"747152a4"}

消息被接受但落入收件人的 Junk 文件夹(IMAP LIST 实测 Junk -> 1 msgs)——quarantine 动作即"投进垃圾箱"而非拒收。

实测三:本域发件人走 25 端口被拒。

swaks --to admin@lab.example.org --from demo@lab.example.org --server 127.0.0.1:25 --header "Subject: spoof" --body "x"

实测输出(节选):

 -> RCPT TO:<admin@lab.example.org>
<** 501 5.1.8 Use Submission for outgoing SMTP (msg ID = a1816d2a)

实测四:检查链通过后正常入站投递。 为模拟发件域补齐 MX/SPF/DMARC 记录后(见 4.7 节 dnsmasq 配置):

swaks --to admin@lab.example.org --from news@client.mailtest.internal --server 127.0.0.1:25 --header "Subject: Clean inbox delivery" --body "clean local delivery"

实测输出(节选):

<-  250 2.0.0 Roger, accepting mail from <news@client.mailtest.internal>
 -> RCPT TO:<admin@lab.example.org>
<-  250 2.0.0 I'll make sure <admin@lab.example.org> gets this
 -> .
<-  250 2.0.0 OK: queued

IMAP 侧确认到达(993 端口 Python imaplib 实测):

SELECT INBOX -> OK [b'1']
1 | Subject: Clean inbox delivery / From: news@client.mailtest.internal

25 端口会话能力(swaks 握手实测原文,EHLO 响应):

<-  220 mail.lab.example.org ESMTP Service Ready
<-  250-Hello mail.lab.example.org
<-  250-PIPELINING
<-  250-8BITMIME
<-  250-ENHANCEDSTATUSCODES
<-  250-CHUNKING
<-  250-STARTTLS
<-  250-SMTPUTF8
<-  250-SIZE 33554432
<-  250 LIMITS RCPTMAX=20000

4.6 Submission 端点与出站队列(465/587 + dkim 签名 + target.remote/queue)

默认提交与出站块:

submission tls://0.0.0.0:465 tcp://0.0.0.0:587 {
    limits {
        all rate 50 1s
    }
    auth &local_authdb
    source $(local_domains) {
        check {
            authorize_sender {
                prepare_email &local_rewrites
                user_to_email identity
            }
        }
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            modify {
                dkim $(primary_domain) $(local_domains) default
            }
            deliver_to &remote_queue
        }
    }
    default_source {
        reject 501 5.1.8 "Non-local sender domain"
    }
}

target.remote outbound_delivery {
    limits {
        destination rate 20 1s
        destination concurrency 10
    }
    mx_auth {
        dane
        mtasts {
            cache fs
            fs_dir mtasts_cache/
        }
        local_policy {
            min_tls_level encrypted
            min_mx_level none
        }
    }
}

target.queue remote_queue {
    target &outbound_delivery
    autogenerated_msg_domain $(primary_domain)
    bounce {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
        }
    }
}

逐项说明:submission 同时监听 465(隐式 TLS)与 587(STARTTLS),强制 auth &local_authdb 认证;authorize_sender 校验认证账号与 From 一致(identity 即完全一致);发往本地域的照常入 local_routing,发往外部域的先 dkim $(primary_domain) ... default 签名再进 remote_queue;target.remote 负责按 DNS MX 发现对端并执行 mx_auth 安全策略(DANE + MTA-STS + 本地 TLS 下限 encrypted);target.queue 做磁盘队列与退信(DSN bounce 回本地投递)。

实测五:587 认证提交(STARTTLS + PLAIN)。 明文阶段 587 不提供 AUTH(仅 STARTTLS),TLS 后才出现 AUTH PLAIN 与 REQUIRETLS(swaks 实测 EHLO 响应节选):

<~  250-AUTH PLAIN
<~  250-SMTPUTF8
<~  250-REQUIRETLS

swaks 用 --auth LOGIN 会因服务器只广告 PLAIN 而失败(实测 *** Auth not attempted, requested type not available),改用 PLAIN:

swaks --to relayuser@mailtest.internal --from admin@lab.example.org --server 127.0.0.1:587 \
  --auth PLAIN --auth-user admin@lab.example.org --auth-password q1w2e3r4 -tls \
  --header "Subject: Relay handshake test" --body "submission 587 outbound relay"

实测输出(节选):

<-  235 2.7.0 Authentication succeeded
 -> To: relayuser@mailtest.internal
 -> .
<~  250 2.0.0 OK: queued

服务端出站投递日志(journalctl 实测原文):

submission: accepted        {"msg_id":"7df9ed6e","sender":"admin@lab.example.org","src_ip":"127.0.0.1:43200","username":"admin@lab.example.org"}
TLS verify error, trying without authentication        {"domain":"mailtest.internal","msg_id":"7df9ed6e-6ad5c975","reason":"tls: failed to verify certificate: x509: certificate relies on legacy Common Name field, use SANs instead","remote_server":"relayhost.mailtest.internal."}
delivered        {"attempt":1,"msg_id":"7df9ed6e","rcpt":"relayuser@mailtest.internal"}

三行日志完整呈现出站链路:入队 → 对端证书校验(自签证书只有 CN 无 SAN 时的真实告警,降级后继续)→ delivered attempt:1 中继握手成功。生产证书应含 SAN,此告警即排障线索。

实测六:MX 缺失 → 永久失败与 DSN 退信。 收件域无 MX 时:

delivery attempt failed        {"msg_id":"f8ffd613","rcpt":"relayuser@mailtest.internal","reason":"dns: rcode NXDOMAIN when looking up mailtest.internal.","smtp_code":554,"smtp_enchcode":"5.4.4","smtp_msg":"MX lookup error","target":"remote"}
not delivered, permanent error        {"msg_id":"f8ffd613","rcpt":"relayuser@mailtest.internal"}
generated failed DSN        {"dsn_id":"56f764a2","msg_id":"f8ffd613"}

DSN 自动生成并投回发件人信箱(admin INBOX 实测出现 Subject: Undelivered Mail Returned to Sender、From: MAILER-DAEMON@lab.example.org),对应配置即 bounce 块。

实测七:IMAP 端点(993/143)。 默认块:

imap tls://0.0.0.0:993 tcp://0.0.0.0:143 {
    auth &local_authdb
    storage &local_mailboxes
}

143 明文连接拒绝认证(实测 Python imaplib 报错原文):

imaplib.IMAP4.error: b'Authentication disabled'

993 TLS 登录读取正常(实测节选):

SELECT INBOX -> OK [b'1']
LIST:
  (\HasNoChildren) "." INBOX
  (\Sent \HasNoChildren) "." "Sent"
  (\Trash \HasNoChildren) "." "Trash"
  (\Junk \HasNoChildren) "." "Junk"
  (\Drafts \HasNoChildren) "." "Drafts"
  (\Archive \HasNoChildren) "." "Archive"

4.7 本地域名模拟环境(dnsmasq + 双实例,收发闭环基础设施)

本文收发闭环不依赖公网域名,用 dnsmasq 在本机模拟全部 DNS 记录(/etc/dnsmasq.d/lab.conf 实测配置,含注释):

# 本地收发模拟域(仅测试环境使用)
domain-needed
bogus-priv
server=8.8.8.8
server=1.1.1.1
# 第一实例主机名与模拟外部客户端域
host-record=mail.lab.example.org,127.0.0.1
host-record=client.mailtest.internal,127.0.0.1
mx-host=client.mailtest.internal,mail.lab.example.org,10
txt-record=client.mailtest.internal,"v=spf1 ip4:127.0.0.1 ~all"
txt-record=_dmarc.mailtest.internal,"v=DMARC1; p=none"
# 第一实例 DKIM 公钥(内容取自 dkim_keys/lab.example.org_default.dns)
txt-record=default._domainkey.lab.example.org,"v=DKIM1; k=rsa; p=MIIBIjANBgkq..."
# 第二实例(模拟远端 MX)
host-record=relayhost.mailtest.internal,127.0.0.2
mx-host=mailtest.internal,relayhost.mailtest.internal,10
mx-host=relay.mailtest.internal,relayhost.mailtest.internal,10

记录生效核对(dig 实测):

$ dig +short MX mailtest.internal
10 relayhost.mailtest.internal.
$ dig +short TXT client.mailtest.internal
"v=spf1 ip4:127.0.0.1 ~all"
$ dig +short TXT _dmarc.mailtest.internal
"v=DMARC1; p=none"

DKIM 公钥 TXT 的取值直接来自 Maddy 生成的 dkim_keys/lab.example.org_default.dns 文件(内容以 v=DKIM1; k=rsa; p=MIIB... 开头),发布到 default._domainkey.lab.example.org 后,接收方即可验证签名——这是 DKIM 出站签名闭环的 DNS 侧前提。

第二实例(maddy@relay,模拟远端 MX)配置 /etc/maddy/relay.conf 的关键差异:$(hostname) = relayhost.mailtest.internal、SMTP 监听 tcp://127.0.0.2:25、dmarc no(简化对端检查)、存储 DSN 用绝对路径 /var/lib/maddy-relay/imapsql.db。启动与验证:

systemctl start maddy@relay
ss -ltn | grep ':25'

实测输出:

LISTEN 0      4096       127.0.0.1:25        0.0.0.0:*
LISTEN 0      4096       127.0.0.2:25        0.0.0.0:*

至此闭环三要素齐备:本地实例(收+发+签)、dnsmasq(MX/SPF/DMARC/DKIM 记录)、远端实例(收+验)。实测五的中继握手 delivered attempt:1 即跑在这套环境上。

五、生产配置模板

以下模板面向「4GB 内存、40GB SSD、个人/小团队自托管、1 个邮件域、50 邮箱以内」场景,在默认配置基础上做最小必要修改(差异处以 # ← 改 标注):

$(hostname) = mail.example.com          # ← 公网 MX 主机名
$(primary_domain) = example.com          # ← 邮件域
$(local_domains) = $(primary_domain)

tls file /etc/maddy/certs/$(hostname)/fullchain.pem /etc/maddy/certs/$(hostname)/privkey.pem
# 证书用 Let's Encrypt:软链 /etc/letsencrypt/live/<主机名> 并 setfacl 授权 maddy 用户

auth.pass_table local_authdb {
    table sql_table {
        driver sqlite3
        dsn credentials.db
        table_name passwords
    }
}

storage.imapsql local_mailboxes {
    driver sqlite3
    dsn imapsql.db
}

table.chain local_rewrites {
    optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
    optional_step static {
        entry postmaster postmaster@$(primary_domain)
    }
    optional_step file /etc/maddy/aliases
}

msgpipeline local_routing {
    destination postmaster $(local_domains) {
        modify {
            replace_rcpt &local_rewrites
        }
        deliver_to &local_mailboxes
    }
    default_destination {
        reject 550 5.1.1 "User doesn't exist"
    }
}

smtp tcp://0.0.0.0:25 {                  # 生产保持对外监听
    limits {
        all rate 20 1s
        all concurrency 10
    }
    dmarc yes
    check {
        require_mx_record
        dkim
        spf
    }
    source $(local_domains) {
        reject 501 5.1.8 "Use Submission for outgoing SMTP"
    }
    default_source {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.1.1 "User doesn't exist"
        }
    }
}

submission tls://0.0.0.0:465 tcp://0.0.0.0:587 {
    limits {
        all rate 50 1s
    }
    auth &local_authdb
    source $(local_domains) {
        check {
            authorize_sender {
                prepare_email &local_rewrites
                user_to_email identity
            }
        }
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            modify {
                dkim $(primary_domain) $(local_domains) default
            }
            deliver_to &remote_queue
        }
    }
    default_source {
        reject 501 5.1.8 "Non-local sender domain"
    }
}

target.remote outbound_delivery {
    limits {
        destination rate 20 1s
        destination concurrency 10
    }
    mx_auth {
        dane
        mtasts {
            cache fs
            fs_dir mtasts_cache/
        }
        local_policy {
            min_tls_level encrypted
            min_mx_level none
        }
    }
}

target.queue remote_queue {
    target &outbound_delivery
    autogenerated_msg_domain $(primary_domain)
    bounce {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
        }
    }
}

imap tls://0.0.0.0:993 tcp://0.0.0.0:143 {
    auth &local_authdb
    storage &local_mailboxes
}

配套的 DNS 侧清单(公网权威 DNS 上必须发布):A/AAAA(mail.example.com)、MX 10、SPF v=spf1 mx ~all、DMARC _dmarc、DKIM default._domainkey(取自 dkim_keys/example.com_default.dns)、可选 MTA-STS/TLSRPT。日常巡检与备份见《Maddy 0.9.6 日常运维手册》。

六、常见问题(FAQ)

Q1:verify-config 提示 unused configuration block xxx?

定义了模块实例却没有任何 &名字 引用。实测第二实例配置里定义了 relay_authdb 但 SMTP 块没写 auth &relay_authdb:Error: unused configuration block relay_authdb (auth.pass_table)。删除未用块或补上引用。

Q2:missing required directive: hostname?

msgpipeline 等部分块依赖全局 hostname 指令(或块内显式 hostname)。实测 relay.conf 在 pipeline 前漏写 hostname $(hostname) 触发该错,补上即通过。

Q3:第二实例报 attempt to write a readonly database?

storage.imapsql 的 DSN 指向的目录不是 maddy 用户属主(实测 /var/lib/maddy-relay 由 root 创建)。修复:chown -R maddy:maddy /var/lib/maddy-relay && systemctl restart maddy@relay。

Q4:证书报 permission denied?

maddy 永不以 root 运行,证书与 key 必须对 maddy 用户可读。自签场景 chown maddy:maddy;Let's Encrypt 场景用官方推荐的 setfacl -R -m u:maddy:rX /etc/letsencrypt/{live,archive}。

Q5:为什么 587 端口明文阶段看不到 AUTH?

Maddy 的 submission 端点把认证保护在 TLS 之后(明文 EHLO 只广告 STARTTLS),TLS 建立后才广告 AUTH PLAIN。客户端务必启用 STARTTLS;465 端口则全程隐式 TLS。

Q6:来信为什么进了 Junk 而不是被拒收?

check 块里 require_mx_record/spf 等检查未通过时的默认动作是 quarantine(隔离到收件人 Junk 文件夹,日志 quarantined {"check":...}),只有 DMARC 执行到 reject 策略才当场 550 拒收。两种行为在 4.5 节实测二、实测一中均有原文回显。

Q7:出站日志出现 x509: certificate relies on legacy Common Name field?

对端(或本端自签)证书只有 CN 没有 SAN 扩展,Go TLS 校验按现代标准告警并降级。生产证书(Let's Encrypt 等)天然含 SAN;自签证书生成时加 -addext "subjectAltName=DNS:mail.example.com" 可消除。

七、参考链接