diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dc233a1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +deploy/**/entrypoint.sh text eol=lf +deploy/**/sync-dkim.sh text eol=lf diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 043a313..072d502 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 05ed5e2..75bd7a9 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -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) { var req struct { - Protocol string `json:"protocol"` - Username string `json:"username"` - IP string `json:"ip"` + Login string `json:"login"` + Protocol string `json:"protocol"` + 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 { - w.WriteHeader(http.StatusCreated) - respondJSON(w, http.StatusCreated, map[string]string{"status": "allow"}) + respondJSON(w, http.StatusOK, map[string]int{"status": 0}) return } var user *User - if req.Username != "" { - var passHash string - user, passHash, _ = a.userByEmail(r.Context(), req.Username) - _ = passHash + var mailbox *Mailbox + login := normalizeEmail(req.Login) + if login == "" { + 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 { - w.WriteHeader(http.StatusCreated) - respondJSON(w, http.StatusCreated, map[string]string{"status": "deny", "reason": "user not found or disabled"}) + respondJSON(w, http.StatusOK, map[string]any{"status": -1, "msg": "user not found or disabled"}) return } if user.Role == "admin" { - w.WriteHeader(http.StatusCreated) - respondJSON(w, http.StatusCreated, map[string]string{"status": "allow"}) + respondJSON(w, http.StatusOK, map[string]int{"status": 0}) return } limits := user.Limits var err error - switch req.Protocol { - case "imap", "IMAP": + switch strings.ToLower(req.Protocol) { + case "imap": 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 { - 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 { - 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 { - respondJSON(w, http.StatusCreated, map[string]any{"status": "allow"}) + respondJSON(w, http.StatusOK, map[string]int{"status": 0}) } } diff --git a/deploy/README.md b/deploy/README.md index 3527458..d0bff83 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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`。 - Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。 - 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 证书 diff --git a/deploy/dovecot/dovecot.conf b/deploy/dovecot/dovecot.conf index 3dc4345..7ddc120 100644 --- a/deploy/dovecot/dovecot.conf +++ b/deploy/dovecot/dovecot.conf @@ -24,7 +24,7 @@ auth_policy_server_api_header = Content-Type: application/json auth_policy_hash_mech = sha256 auth_policy_hash_truncate = 12 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 { inbox = yes diff --git a/deploy/postfix/master.cf b/deploy/postfix/master.cf index e61413e..245305a 100644 --- a/deploy/postfix/master.cf +++ b/deploy/postfix/master.cf @@ -4,13 +4,11 @@ submission inet n - n - - smtpd -o smtpd_tls_security_level=may -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject - -o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf smtps inet n - n - - smtpd -o syslog_name=postfix/smtps -o smtpd_tls_wrappermode=yes -o smtpd_sasl_auth_enable=yes -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 cleanup unix n - n - 0 cleanup qmgr unix n - n 300 1 qmgr