refactor(mail): 统一邮件认证策略请求与返回格式。

- 将 `auth-policy` 调整为读取 `login`、`remote` 等字段,并返回数值型 `status`。
- 支持按邮箱定位用户与邮箱箱体,补充 IMAP/POP3 认证策略校验。
- 更新 Dovecot 与 Postfix 配置,移除原有的发件人 BCC 依赖。
- 补充相关测试并同步部署说明。
This commit is contained in:
LanQin_
2026-06-23 21:52:05 +08:00
parent 8d434b9ca8
commit f2457c63ee
6 changed files with 68 additions and 25 deletions
+3
View File
@@ -0,0 +1,3 @@
*.sh text eol=lf
deploy/**/entrypoint.sh text eol=lf
deploy/**/sync-dkim.sh text eol=lf
+23
View File
@@ -814,6 +814,29 @@ func TestAdminSMTPTestEndpoint(t *testing.T) {
} }
} }
func TestAuthPolicyDovecotResponseFormat(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
var allowed map[string]any
if code := client.do("POST", "/auth-policy?command=allow", map[string]string{"login": "admin@lanqin.local", "protocol": "smtp"}, &allowed); code != http.StatusOK {
t.Fatalf("auth policy allow code=%d body=%v", code, allowed)
}
if allowed["status"] != float64(0) {
t.Fatalf("expected numeric allow status 0, got %#v", allowed["status"])
}
var denied map[string]any
if code := client.do("POST", "/auth-policy?command=allow", map[string]string{"login": "missing@lanqin.local", "protocol": "imap"}, &denied); code != http.StatusOK {
t.Fatalf("auth policy deny code=%d body=%v", code, denied)
}
if denied["status"] != float64(-1) {
t.Fatalf("expected numeric deny status -1, got %#v", denied["status"])
}
}
func TestProfileAndPasswordUpdate(t *testing.T) { func TestProfileAndPasswordUpdate(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
+40 -21
View File
@@ -599,48 +599,67 @@ func (a *App) checkAndRecordProtocolRate(ctx context.Context, user *User, mb *Ma
func (a *App) handleAuthPolicy(w http.ResponseWriter, r *http.Request) { func (a *App) handleAuthPolicy(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Protocol string `json:"protocol"` Login string `json:"login"`
Username string `json:"username"` Protocol string `json:"protocol"`
IP string `json:"ip"` Username string `json:"username"`
IP string `json:"ip"`
Remote string `json:"remote"`
Success *bool `json:"success"`
PolicyReject *bool `json:"policy_reject"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
w.WriteHeader(http.StatusCreated) respondJSON(w, http.StatusOK, map[string]int{"status": 0})
respondJSON(w, http.StatusCreated, map[string]string{"status": "allow"})
return return
} }
var user *User var user *User
if req.Username != "" { var mailbox *Mailbox
var passHash string login := normalizeEmail(req.Login)
user, passHash, _ = a.userByEmail(r.Context(), req.Username) if login == "" {
_ = passHash login = normalizeEmail(req.Username)
}
if login != "" {
if mb, err := a.mailboxByAddress(r.Context(), login); err == nil {
mailbox = mb
user, _ = a.userByID(r.Context(), mb.UserID)
}
if user == nil {
var passHash string
user, passHash, _ = a.userByEmail(r.Context(), login)
_ = passHash
}
} }
if user == nil || user.Disabled { if user == nil || user.Disabled {
w.WriteHeader(http.StatusCreated) respondJSON(w, http.StatusOK, map[string]any{"status": -1, "msg": "user not found or disabled"})
respondJSON(w, http.StatusCreated, map[string]string{"status": "deny", "reason": "user not found or disabled"})
return return
} }
if user.Role == "admin" { if user.Role == "admin" {
w.WriteHeader(http.StatusCreated) respondJSON(w, http.StatusOK, map[string]int{"status": 0})
respondJSON(w, http.StatusCreated, map[string]string{"status": "allow"})
return return
} }
limits := user.Limits limits := user.Limits
var err error var err error
switch req.Protocol { switch strings.ToLower(req.Protocol) {
case "imap", "IMAP": case "imap":
if limits.IMAPMinuteLimit > 0 { if limits.IMAPMinuteLimit > 0 {
err = a.checkAndRecordProtocolRate(r.Context(), user, nil, "imap_events", 0, limits.IMAPMinuteLimit) if mailbox == nil {
err = errors.New("mailbox not found")
} else {
err = a.checkAndRecordProtocolRate(r.Context(), user, mailbox, "imap_events", 0, limits.IMAPMinuteLimit)
}
} }
case "pop3", "POP3": case "pop3":
if limits.POP3MinuteLimit > 0 { if limits.POP3MinuteLimit > 0 {
err = a.checkAndRecordProtocolRate(r.Context(), user, nil, "pop3_events", 0, limits.POP3MinuteLimit) if mailbox == nil {
err = errors.New("mailbox not found")
} else {
err = a.checkAndRecordProtocolRate(r.Context(), user, mailbox, "pop3_events", 0, limits.POP3MinuteLimit)
}
} }
} }
w.WriteHeader(http.StatusCreated)
if err != nil { if err != nil {
respondJSON(w, http.StatusCreated, map[string]any{"status": "deny", "reason": err.Error()}) respondJSON(w, http.StatusOK, map[string]any{"status": -1, "msg": err.Error()})
} else { } else {
respondJSON(w, http.StatusCreated, map[string]any{"status": "allow"}) respondJSON(w, http.StatusOK, map[string]int{"status": 0})
} }
} }
+1 -1
View File
@@ -128,7 +128,7 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim` - Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。 - Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
- 第三方客户端通过 SMTP `465/587` 发信时,Postfix 会把已认证发件人的邮件自动 BCC 到 `发件人+Sent@域名`Dovecot LMTP 会保存到该邮箱的 `Sent` 文件夹,Webmail 扫描后会显示在“已发送” - 第三方客户端通过 SMTP `465/587` 发信;Webmail 内的“已发送”由 Webmail API 发信流程写入
## 邮件客户端 TLS 证书 ## 邮件客户端 TLS 证书
+1 -1
View File
@@ -24,7 +24,7 @@ auth_policy_server_api_header = Content-Type: application/json
auth_policy_hash_mech = sha256 auth_policy_hash_mech = sha256
auth_policy_hash_truncate = 12 auth_policy_hash_truncate = 12
auth_policy_hash_nonce = __LANQIN_AUTH_POLICY_HASH_NONCE__ auth_policy_hash_nonce = __LANQIN_AUTH_POLICY_HASH_NONCE__
auth_policy_request_attributes = protocol=imap username=user auth_policy_request_attributes = login=%{requested_username} remote=%{rip} protocol=%s
namespace inbox { namespace inbox {
inbox = yes inbox = yes
-2
View File
@@ -4,13 +4,11 @@ submission inet n - n - - smtpd
-o smtpd_tls_security_level=may -o smtpd_tls_security_level=may
-o smtpd_sasl_auth_enable=yes -o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf
smtps inet n - n - - smtpd smtps inet n - n - - smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
-o smtpd_sasl_auth_enable=yes -o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf
pickup unix n - n 60 1 pickup pickup unix n - n 60 1 pickup
cleanup unix n - n - 0 cleanup cleanup unix n - n - 0 cleanup
qmgr unix n - n 300 1 qmgr qmgr unix n - n 300 1 qmgr