feat(mail): 增强邮件认证与审计能力
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

- 新增邮件认证结果解析、存储与前端展示,支持查看 SPF、DKIM、DMARC 及原始头信息。
- 扩展邮件规则条件,支持抄送、附件、大小、日期及嵌套条件组合。
- 增加邮箱容量统计与发送前配额校验,超限时返回明确错误。
- 新增管理端发送审计页面与接口,支持按邮箱、事件、Message-ID 和时间筛选。
This commit is contained in:
LanQin_
2026-06-24 17:16:01 +08:00
parent 2b846ac671
commit 2a572bf13a
13 changed files with 1372 additions and 145 deletions
+110
View File
@@ -833,6 +833,116 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, msg)
}
func (a *App) handleAdminSendAudit(w http.ResponseWriter, r *http.Request) {
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
messageID := strings.TrimSpace(r.URL.Query().Get("messageId"))
event := strings.TrimSpace(r.URL.Query().Get("event"))
from, err := adminAuditTimeParam(r.URL.Query().Get("from"), false)
if err != nil {
badRequest(w, err)
return
}
to, err := adminAuditTimeParam(r.URL.Query().Get("to"), true)
if err != nil {
badRequest(w, err)
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if offset < 0 {
offset = 0
}
limit := 50
where := []string{"1=1"}
args := []any{}
if mailboxID != "" && mailboxID != "all" {
where = append(where, "sae.mailbox_id=?")
args = append(args, mailboxID)
}
if messageID != "" {
where = append(where, "(sq.message_id=? OR m.message_id=? OR sae.sent_message_id=?)")
args = append(args, messageID, messageID, messageID)
}
if event != "" && event != "all" {
if !isSendAuditEvent(event) {
badRequest(w, errors.New("invalid event"))
return
}
where = append(where, "sae.event=?")
args = append(args, event)
}
if from != "" {
where = append(where, "sae.created_at>=?")
args = append(args, from)
}
if to != "" {
where = append(where, "sae.created_at<=?")
args = append(args, to)
}
args = append(args, limit+1, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT sae.id,sae.queue_id,sae.mailbox_id,COALESCE(mb.address,''),sae.sent_message_id,COALESCE(sq.message_id,m.message_id,''),sae.source,sae.event,sae.status,sae.mail_from,sae.header_from,sae.recipients_json,sae.error,sae.created_at
FROM send_audit_events sae
LEFT JOIN mailboxes mb ON mb.id=sae.mailbox_id
LEFT JOIN send_queue sq ON sq.id=sae.queue_id
LEFT JOIN messages m ON m.id=sae.sent_message_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY sae.created_at DESC, sae.id DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send audit")
return
}
defer rows.Close()
items := []SendAuditEvent{}
for rows.Next() {
var item SendAuditEvent
var recipientsJSON, createdAt string
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.MailboxAddress, &item.SentMessageID, &item.MessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
return
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
item.CreatedAt = parseTime(createdAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send audit")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
next = strconv.Itoa(offset + limit)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func adminAuditTimeParam(value string, endOfDay bool) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t.UTC().Format(time.RFC3339Nano), nil
}
if t, err := time.Parse("2006-01-02", value); err == nil {
if endOfDay {
t = t.Add(24*time.Hour - time.Nanosecond)
}
return t.UTC().Format(time.RFC3339Nano), nil
}
return "", errors.New("invalid time filter")
}
func isSendAuditEvent(event string) bool {
switch event {
case sendAuditAccepted, sendAuditQueued, sendAuditRetry, sendAuditDelivered, sendAuditFailed, sendAuditCanceled:
return true
default:
return false
}
}
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
var req struct {
DomainID string `json:"domainId"`
+49
View File
@@ -230,6 +230,11 @@ func (a *App) migrate(ctx context.Context) error {
is_starred INTEGER NOT NULL DEFAULT 0,
has_attachments INTEGER NOT NULL DEFAULT 0,
size_bytes INTEGER NOT NULL DEFAULT 0,
auth_results TEXT NOT NULL DEFAULT '',
auth_spf TEXT NOT NULL DEFAULT 'unknown',
auth_dkim TEXT NOT NULL DEFAULT 'unknown',
auth_dmarc TEXT NOT NULL DEFAULT 'unknown',
received_spf TEXT NOT NULL DEFAULT '',
raw_path TEXT NOT NULL DEFAULT '',
imap_uid INTEGER NOT NULL DEFAULT 0,
imap_modseq INTEGER NOT NULL DEFAULT 1,
@@ -419,6 +424,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateMessagesFromName(ctx); err != nil {
return err
}
if err := a.migrateMessageAuthentication(ctx); err != nil {
return err
}
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
return err
}
@@ -443,6 +451,47 @@ func (a *App) migrate(ctx context.Context) error {
return nil
}
func (a *App) migrateMessageAuthentication(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
return err
}
defer rows.Close()
columns := map[string]bool{}
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
return err
}
columns[name] = true
}
if err := rows.Err(); err != nil {
return err
}
alter := []struct {
name string
sql string
}{
{"auth_results", `ALTER TABLE messages ADD COLUMN auth_results TEXT NOT NULL DEFAULT ''`},
{"auth_spf", `ALTER TABLE messages ADD COLUMN auth_spf TEXT NOT NULL DEFAULT 'unknown'`},
{"auth_dkim", `ALTER TABLE messages ADD COLUMN auth_dkim TEXT NOT NULL DEFAULT 'unknown'`},
{"auth_dmarc", `ALTER TABLE messages ADD COLUMN auth_dmarc TEXT NOT NULL DEFAULT 'unknown'`},
{"received_spf", `ALTER TABLE messages ADD COLUMN received_spf TEXT NOT NULL DEFAULT ''`},
}
for _, item := range alter {
if !columns[item.name] {
if _, err := a.db.ExecContext(ctx, item.sql); err != nil {
return err
}
}
}
return nil
}
func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
if err != nil {
+485
View File
@@ -20,6 +20,7 @@ import (
"net/http"
"net/http/httptest"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strings"
@@ -422,6 +423,201 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
}
}
func TestParseMailAuthenticationResults(t *testing.T) {
header := textproto.MIMEHeader{}
header.Add("Authentication-Results", "mx.example.test; spf=pass smtp.mailfrom=sender.example; dkim=fail (bad signature) header.d=sender.example; dmarc=none")
header.Add("Received-SPF", "softfail (mx.example.test: transitioning domain) client-ip=192.0.2.10; envelope-from=sender@example.test")
auth := parseMailAuthentication(header)
if auth.SPF != "pass" || auth.DKIM != "fail" || auth.DMARC != "none" {
t.Fatalf("unexpected auth summary: %+v", auth)
}
if !strings.Contains(auth.AuthenticationResults, "spf=pass") || !strings.Contains(auth.ReceivedSPF, "softfail") {
t.Fatalf("raw auth headers not preserved: %+v", auth)
}
unknown := parseMailAuthentication(textproto.MIMEHeader{})
if unknown.SPF != "unknown" || unknown.DKIM != "unknown" || unknown.DMARC != "unknown" {
t.Fatalf("missing headers should be unknown, got %+v", unknown)
}
}
func TestMailRulesConditionGroupsAndActions(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "rule-sender", "Rule Sender", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "rule-recipient", "Rule Recipient", "Password123!", nil)
rcpt := &testClient{t: t, server: ts}
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("recipient login=%d", code)
}
var rule MailRule
rulePayload := map[string]any{
"mailboxId": recipient.ID,
"name": "priority archive",
"matchMode": "all",
"conditions": []map[string]any{{"matchMode": "any", "conditions": []map[string]string{{"field": "from", "operator": "contains", "value": sender.Address}, {"field": "subject", "operator": "contains", "value": "urgent"}}}, {"field": "cc", "operator": "contains", "value": "lead@example.test"}, {"field": "attachment", "operator": "contains", "value": "plan.pdf"}, {"field": "size", "operator": "gte", "value": "10"}, {"field": "date", "operator": "after", "value": "2020-01-01"}},
"actions": []map[string]string{{"type": "label", "value": "Priority"}, {"type": "move", "value": "Archive"}, {"type": "mark-read"}, {"type": "star"}},
}
if code := rcpt.do("POST", "/api/me/rules", rulePayload, &rule); code != http.StatusCreated {
t.Fatalf("create rule code=%d rule=%+v", code, rule)
}
senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("sender login=%d", code)
}
var sent MailMessage
if code := senderClient.do("POST", "/api/mail/send", map[string]any{
"to": []string{recipient.Address},
"cc": []string{"lead@example.test"},
"subject": "quarterly update",
"text": "body",
"attachments": []map[string]string{{"filename": "plan.pdf", "contentType": "application/pdf", "contentBase64": base64.StdEncoding.EncodeToString([]byte("rule attachment payload"))}},
}, &sent); code != http.StatusCreated {
t.Fatalf("send code=%d sent=%+v", code, sent)
}
var archived struct {
Items []MailMessage `json:"items"`
}
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 1 {
t.Fatalf("archive list code=%d items=%+v", code, archived.Items)
}
msg := archived.Items[0]
if !msg.IsRead || !msg.IsStarred {
t.Fatalf("rule flags read=%v starred=%v", msg.IsRead, msg.IsStarred)
}
if len(msg.Labels) != 1 || msg.Labels[0].Name != "Priority" {
t.Fatalf("labels=%+v, want Priority", msg.Labels)
}
}
func TestMailRulesMailboxIsolation(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
owner := createTestMailbox(t, admin, domainID, "rule-owner", "Rule Owner", "Password123!", nil)
other := createTestMailbox(t, admin, domainID, "rule-other", "Rule Other", "Password123!", nil)
sender := createTestMailbox(t, admin, domainID, "rule-outsider", "Rule Outsider", "Password123!", nil)
ownerClient := &testClient{t: t, server: ts}
if code := ownerClient.do("POST", "/api/auth/login", map[string]string{"email": owner.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("owner login=%d", code)
}
var denied map[string]any
if code := ownerClient.do("POST", "/api/me/rules", map[string]any{"mailboxId": other.ID, "fromContains": sender.Address, "action": "archive"}, &denied); code != http.StatusNotFound {
t.Fatalf("cross-mailbox rule create code=%d body=%v", code, denied)
}
var rule MailRule
if code := ownerClient.do("POST", "/api/me/rules", map[string]any{"mailboxId": owner.ID, "fromContains": sender.Address, "action": "archive"}, &rule); code != http.StatusCreated {
t.Fatalf("create owner rule code=%d rule=%+v", code, rule)
}
senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("sender login=%d", code)
}
var sent MailMessage
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{other.Address}, "subject": "isolation", "text": "body"}, &sent); code != http.StatusCreated {
t.Fatalf("send to other code=%d sent=%+v", code, sent)
}
otherClient := &testClient{t: t, server: ts}
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": other.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("other login=%d", code)
}
var inbox struct {
Items []MailMessage `json:"items"`
}
if code := otherClient.do("GET", "/api/mail/messages?mailboxId="+other.ID+"&folder=Inbox", nil, &inbox); code != http.StatusOK || len(inbox.Items) != 1 {
t.Fatalf("other inbox code=%d items=%+v", code, inbox.Items)
}
var archived struct {
Items []MailMessage `json:"items"`
}
if code := otherClient.do("GET", "/api/mail/messages?mailboxId="+other.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 0 {
t.Fatalf("other archive code=%d items=%+v", code, archived.Items)
}
}
func TestBlockedSenderMovesInboundToSpamAndIsolatesUsers(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
var domains struct {
Items []Domain `json:"items"`
}
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
t.Fatalf("domains code=%d items=%+v", code, domains.Items)
}
sender := createTestMailbox(t, admin, domains.Items[0].ID, "blocked-sender", "Blocked Sender", "Password123!", nil)
recipient := createTestMailbox(t, admin, domains.Items[0].ID, "blocked-recipient", "Blocked Recipient", "Password123!", nil)
other := createTestMailbox(t, admin, domains.Items[0].ID, "blocked-other", "Blocked Other", "Password123!", nil)
recipientClient := &testClient{t: t, server: ts}
if code := recipientClient.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("recipient login code=%d", code)
}
var blocked BlockedSender
if code := recipientClient.do("POST", "/api/me/blocked-senders", map[string]any{"mailboxId": recipient.ID, "email": sender.Address, "reason": "test"}, &blocked); code != http.StatusCreated {
t.Fatalf("blocked sender code=%d body=%+v", code, blocked)
}
senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("sender login code=%d", code)
}
var sent MailMessage
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": "blocked sender test", "text": "body"}, &sent); code != http.StatusCreated {
t.Fatalf("send code=%d sent=%+v", code, sent)
}
var inbox struct {
Items []MailMessage `json:"items"`
}
if code := recipientClient.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Inbox&q=blocked%20sender", nil, &inbox); code != http.StatusOK || len(inbox.Items) != 0 {
t.Fatalf("recipient inbox code=%d items=%+v", code, inbox.Items)
}
var spam struct {
Items []MailMessage `json:"items"`
}
if code := recipientClient.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Spam&q=blocked%20sender", nil, &spam); code != http.StatusOK || len(spam.Items) != 1 {
t.Fatalf("recipient spam code=%d items=%+v", code, spam.Items)
}
otherClient := &testClient{t: t, server: ts}
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": other.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("other login code=%d", code)
}
var denied map[string]any
if code := otherClient.do("GET", "/api/mail/messages/"+spam.Items[0].ID+"?markRead=0", nil, &denied); code != http.StatusNotFound {
t.Fatalf("other user should not read spam message code=%d body=%v", code, denied)
}
}
func TestScheduleSendQueuesFutureMessage(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -1241,6 +1437,108 @@ func TestSendQueueAPIRetryAndCancel(t *testing.T) {
}
}
func TestAdminSendAuditAccessAndFilters(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
user, mb := defaultAdminUserAndMailbox(t, a)
domainID := mustDefaultDomainID(t, a)
otherMB := createTestMailbox(t, admin, domainID, "audit-other", "Audit Other", "Password123!", nil)
now := a.now().UTC()
ctx := context.Background()
sentFolderID, err := a.ensureFolder(ctx, mb.ID, "Sent")
if err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
"msg_audit_one", mb.ID, sentFolderID, "", "uid-audit-one", "<audit-one@example.test>", "audit one", mb.Address, "", jsonEncode([]string{"one@example.test"}), "[]", "[]", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), "audit", "", "", 1, 0, 0, 0, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO send_queue(id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,status,next_attempt_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
"snd_audit_one", user.ID, mb.ID, "msg_audit_one", "<audit-one@example.test>", sendSourceWebmail, mb.Address, mb.Address, jsonEncode([]string{"one@example.test"}), "", sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
events := []struct {
id string
queueID string
mailboxID string
sentMessageID string
event string
status string
recipients []string
errorText string
createdAt time.Time
}{
{"audit_one", "snd_audit_one", mb.ID, "msg_audit_one", sendAuditQueued, sendQueueStatusQueued, []string{"one@example.test"}, "", now.Add(-2 * time.Hour)},
{"audit_two", "snd_audit_one", mb.ID, "msg_audit_one", sendAuditFailed, sendQueueStatusFailed, []string{"one@example.test"}, "temporary failure", now.Add(-1 * time.Hour)},
{"audit_other", "snd_audit_other", otherMB.ID, "", sendAuditDelivered, sendQueueStatusDelivered, []string{"two@example.test"}, "", now},
}
for _, item := range events {
if _, err := a.db.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, item.id, item.queueID, user.ID, item.mailboxID, item.sentMessageID, sendSourceWebmail, item.event, item.status, mb.Address, mb.Address, jsonEncode(item.recipients), item.errorText, item.createdAt.Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
}
if code := (&testClient{t: t, server: ts}).do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusUnauthorized {
t.Fatalf("unauthenticated send audit code=%d", code)
}
regular := &testClient{t: t, server: ts}
if code := regular.do("POST", "/api/auth/login", map[string]string{"email": otherMB.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("regular login code=%d", code)
}
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
t.Fatalf("regular send audit code=%d", code)
}
updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
t.Fatalf("admin access without messages permission code=%d", code)
}
var all struct {
Items []SendAuditEvent `json:"items"`
}
if code := admin.do("GET", "/api/admin/send-audit", nil, &all); code != http.StatusOK || len(all.Items) != 3 {
t.Fatalf("admin all audit code=%d items=%+v", code, all.Items)
}
var byMailbox struct {
Items []SendAuditEvent `json:"items"`
}
if code := admin.do("GET", "/api/admin/send-audit?mailboxId="+mb.ID, nil, &byMailbox); code != http.StatusOK || len(byMailbox.Items) != 2 {
t.Fatalf("mailbox filter code=%d items=%+v", code, byMailbox.Items)
}
var byEvent struct {
Items []SendAuditEvent `json:"items"`
}
if code := admin.do("GET", "/api/admin/send-audit?event=failed", nil, &byEvent); code != http.StatusOK || len(byEvent.Items) != 1 || byEvent.Items[0].Error != "temporary failure" {
t.Fatalf("event filter code=%d items=%+v", code, byEvent.Items)
}
var byMessage struct {
Items []SendAuditEvent `json:"items"`
}
if code := admin.do("GET", "/api/admin/send-audit?messageId="+url.QueryEscape("<audit-one@example.test>"), nil, &byMessage); code != http.StatusOK || len(byMessage.Items) != 2 {
t.Fatalf("message filter code=%d items=%+v", code, byMessage.Items)
}
var byTime struct {
Items []SendAuditEvent `json:"items"`
}
from := now.Add(-90 * time.Minute).Format(time.RFC3339Nano)
if code := admin.do("GET", "/api/admin/send-audit?from="+url.QueryEscape(from), nil, &byTime); code != http.StatusOK || len(byTime.Items) != 2 {
t.Fatalf("time filter code=%d items=%+v", code, byTime.Items)
}
if byEvent.Items[0].MailboxAddress != mb.Address || byEvent.Items[0].MessageID != "<audit-one@example.test>" {
t.Fatalf("audit metadata missing: %+v", byEvent.Items[0])
}
}
func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
a := newTestApp(t)
user, mailbox, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
@@ -2547,6 +2845,87 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
}
}
func TestMaildirImportStoresAuthenticationResults(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
ts := httptest.NewServer(a.Router())
defer ts.Close()
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
var mailboxID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil {
t.Fatal(err)
}
mailboxes, err := a.maildirMailboxes(ctx)
if err != nil {
t.Fatal(err)
}
var admin maildirMailbox
for _, mb := range mailboxes {
if mb.Address == "admin@lanqin.local" {
admin = mb
break
}
}
if admin.ID == "" {
t.Fatal("admin mailbox not found")
}
dir := filepath.Join(root, admin.Domain, admin.LocalPart, "Maildir", "new")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
raw := strings.Join([]string{
"From: sender@example.test",
"To: admin@lanqin.local",
"Subject: auth import test",
"Message-Id: <auth-import@example.test>",
"Date: Sat, 13 Jun 2026 13:00:00 +0000",
"Authentication-Results: mx.lanqin.local; spf=pass smtp.mailfrom=example.test; dkim=fail header.d=example.test; dmarc=temperror",
"Received-SPF: pass (mx.lanqin.local: domain of sender@example.test designates 192.0.2.1 as permitted sender)",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"auth body",
}, "\r\n")
if err := os.WriteFile(filepath.Join(dir, "1749819601.M1P1.auth"), []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if count, err := a.syncMaildirOnce(ctx); err != nil || count != 1 {
t.Fatalf("sync count=%d err=%v", count, err)
}
client := &testClient{t: t, server: ts}
var login map[string]any
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var list struct {
Items []MailMessage `json:"items"`
}
if code := client.do("GET", "/api/mail/messages?folder=Inbox&q=auth%20import", nil, &list); code != http.StatusOK || len(list.Items) != 1 {
t.Fatalf("list code=%d items=%+v", code, list.Items)
}
var detail MailMessage
if code := client.do("GET", "/api/mail/messages/"+list.Items[0].ID+"?markRead=0", nil, &detail); code != http.StatusOK {
t.Fatalf("detail code=%d detail=%+v", code, detail)
}
if detail.Authentication.SPF != "pass" || detail.Authentication.DKIM != "fail" || detail.Authentication.DMARC != "temperror" {
t.Fatalf("unexpected auth detail: %+v", detail.Authentication)
}
if !strings.Contains(detail.Authentication.AuthenticationResults, "spf=pass") || !strings.Contains(detail.Authentication.ReceivedSPF, "sender@example.test") {
t.Fatalf("raw auth headers missing: %+v", detail.Authentication)
}
}
func TestMaildirSyncHealthDisabled(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -3202,6 +3581,112 @@ func TestMaildirSyncDeletesMissingMessage(t *testing.T) {
}
}
func TestMailboxQuotaRejectsNewMessage(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET quota_mb=1 WHERE id=?`, mb.ID); err != nil {
t.Fatal(err)
}
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
MailboxID: mb.ID,
To: []string{"person@example.test"},
Subject: "quota overflow",
Text: strings.Repeat("x", 1024*1024+1),
})
if !errors.Is(err, errMailboxQuotaExceeded) {
t.Fatalf("sendMailNow error=%v, want quota exceeded", err)
}
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
var login map[string]any
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
var errBody map[string]any
if code := client.do("POST", "/api/mail/send", map[string]any{
"mailboxId": mb.ID,
"to": []string{"person@example.test"},
"subject": "quota overflow api",
"text": strings.Repeat("y", 1024*1024+1),
}, &errBody); code != http.StatusInsufficientStorage {
t.Fatalf("quota api code=%d body=%v", code, errBody)
}
}
func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
aliceMB := createTestMailbox(t, admin, domainID, "quota-alice", "Quota Alice", "Password123!", map[string]any{"quotaMb": 2})
bobMB := createTestMailbox(t, admin, domainID, "quota-bob", "Quota Bob", "Password123!", map[string]any{"quotaMb": 2})
aliceUser, _, err := a.userByEmail(ctx, aliceMB.Address)
if err != nil {
t.Fatal(err)
}
bobUser, _, err := a.userByEmail(ctx, bobMB.Address)
if err != nil {
t.Fatal(err)
}
aliceTrash, err := a.ensureFolder(ctx, aliceMB.ID, "Trash")
if err != nil {
t.Fatal(err)
}
bobTrash, err := a.ensureFolder(ctx, bobMB.ID, "Trash")
if err != nil {
t.Fatal(err)
}
attachment := AttachmentInput{Filename: "note.txt", ContentType: "text/plain", ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello attachment"))}
if _, err := a.insertMessage(ctx, storedMessage{MailboxID: aliceMB.ID, FolderID: aliceTrash, MessageUID: newID("uid"), MessageID: "<alice-trash@example.test>", Subject: "alice trash", From: "sender@example.test", To: []string{aliceMB.Address}, SentAt: a.now().UTC(), ReceivedAt: a.now().UTC(), Snippet: "body", BodyText: "body"}, []AttachmentInput{attachment}); err != nil {
t.Fatal(err)
}
if _, err := a.insertMessage(ctx, storedMessage{MailboxID: bobMB.ID, FolderID: bobTrash, MessageUID: newID("uid"), MessageID: "<bob-trash@example.test>", Subject: "bob trash", From: "sender@example.test", To: []string{bobMB.Address}, SentAt: a.now().UTC(), ReceivedAt: a.now().UTC(), Snippet: "body", BodyText: "body"}, nil); err != nil {
t.Fatal(err)
}
alice := &testClient{t: t, server: ts}
if code := alice.do("POST", "/api/auth/login", map[string]string{"email": aliceMB.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("alice login code=%d", code)
}
var stats MailStats
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID, nil, &stats); code != http.StatusOK {
t.Fatalf("stats code=%d stats=%+v", code, stats)
}
if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
t.Fatalf("stats quota/attachment not populated: %+v", stats)
}
var cleanup struct {
OK bool `json:"ok"`
Affected int64 `json:"affected"`
}
if code := alice.do("POST", "/api/me/cleanup", map[string]any{"mailboxId": aliceMB.ID, "target": "empty-trash"}, &cleanup); code != http.StatusOK || cleanup.Affected != 1 {
t.Fatalf("cleanup code=%d body=%+v", code, cleanup)
}
var aliceRemaining, bobRemaining int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE mailbox_id=?`, aliceMB.ID).Scan(&aliceRemaining); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE mailbox_id=?`, bobMB.ID).Scan(&bobRemaining); err != nil {
t.Fatal(err)
}
if aliceRemaining != 0 || bobRemaining != 1 {
t.Fatalf("cleanup isolation alice=%d bob=%d, want 0/1", aliceRemaining, bobRemaining)
}
if aliceUser.ID == "" || bobUser.ID == "" {
t.Fatal("test users were not created")
}
}
func mustDefaultDomainID(t *testing.T, a *App) string {
t.Helper()
var id string
+134 -4
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/textproto"
"os"
"path/filepath"
"strconv"
@@ -47,6 +48,7 @@ type storedMessage struct {
IsRead bool
IsStarred bool
RawPath string
Authentication MailAuthentication
}
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
@@ -429,6 +431,10 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusForbidden, err.Error())
return
}
if errors.Is(err, errMailboxQuotaExceeded) {
respondError(w, http.StatusInsufficientStorage, err.Error())
return
}
respondError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -440,6 +446,7 @@ var errInvalidMIME = errors.New("invalid mime message")
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
var errSenderNotAuthorized = errors.New("sender address is not authorized")
var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded")
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
@@ -1580,7 +1587,7 @@ func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool
}
func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) {
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes,COALESCE(m.auth_results,''),COALESCE(m.auth_spf,'unknown'),COALESCE(m.auth_dkim,'unknown'),COALESCE(m.auth_dmarc,'unknown'),COALESCE(m.received_spf,'')
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
msg, err := scanMessageFull(row, includeBody)
if err != nil {
@@ -1623,6 +1630,9 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
size += int64(len(decoded))
}
}
if err := a.ensureMailboxQuotaAvailable(ctx, db, msg.MailboxID, size); err != nil {
return "", err
}
var mailboxID, folderID any
if strings.TrimSpace(msg.MailboxID) != "" {
mailboxID = msg.MailboxID
@@ -1639,8 +1649,9 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
imapUID, imapModSeq = meta.UID, meta.ModSeq
}
recipientAddr := normalizeEmail(msg.RecipientAddr)
_, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,imap_uid,imap_modseq,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, imapUID, imapModSeq, now, now)
auth := normalizeMailAuthentication(msg.Authentication)
_, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,auth_results,auth_spf,auth_dkim,auth_dmarc,received_spf,raw_path,imap_uid,imap_modseq,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, auth.AuthenticationResults, auth.SPF, auth.DKIM, auth.DMARC, auth.ReceivedSPF, msg.RawPath, imapUID, imapModSeq, now, now)
if err != nil {
return "", err
}
@@ -1653,6 +1664,33 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
return id, nil
}
func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, mailboxID string, addBytes int64) error {
mailboxID = strings.TrimSpace(mailboxID)
if mailboxID == "" || addBytes <= 0 {
return nil
}
rowDB, ok := db.(dbQueryer)
if !ok {
return nil
}
var quotaMB int64
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&quotaMB); err != nil {
return err
}
if quotaMB <= 0 {
return nil
}
var used int64
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
return err
}
quotaBytes := quotaMB * 1024 * 1024
if used+addBytes > quotaBytes {
return fmt.Errorf("%w: used %d bytes, adding %d bytes exceeds %d bytes", errMailboxQuotaExceeded, used, addBytes, quotaBytes)
}
return nil
}
func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error {
return a.storeAttachmentWithDB(ctx, a.db, messageID, input)
}
@@ -1893,17 +1931,109 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) {
var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string
var auth MailAuthentication
var read, starred, hasAtt int
var bodyText, bodyHTML string
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes)
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes, &auth.AuthenticationResults, &auth.SPF, &auth.DKIM, &auth.DMARC, &auth.ReceivedSPF)
if err != nil {
return msg, err
}
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
msg.Authentication = normalizeMailAuthentication(auth)
if includeBody {
msg.BodyText, msg.BodyHTML = bodyText, bodyHTML
}
return msg, nil
}
func parseMailAuthentication(header textproto.MIMEHeader) MailAuthentication {
authResults := strings.Join(header.Values("Authentication-Results"), "\n")
receivedSPF := strings.Join(header.Values("Received-SPF"), "\n")
auth := MailAuthentication{
AuthenticationResults: strings.TrimSpace(authResults),
ReceivedSPF: strings.TrimSpace(receivedSPF),
SPF: "unknown",
DKIM: "unknown",
DMARC: "unknown",
}
for _, value := range header.Values("Authentication-Results") {
for _, field := range strings.FieldsFunc(value, func(r rune) bool {
return r == ';' || r == '\r' || r == '\n'
}) {
key, result, ok := authMethodResult(field)
if !ok {
continue
}
switch key {
case "spf":
auth.SPF = result
case "dkim":
auth.DKIM = result
case "dmarc":
auth.DMARC = result
}
}
}
if auth.SPF == "unknown" {
for _, value := range header.Values("Received-SPF") {
if result := firstAuthStatus(value); result != "unknown" {
auth.SPF = result
break
}
}
}
return normalizeMailAuthentication(auth)
}
func authMethodResult(value string) (string, string, bool) {
value = strings.TrimSpace(value)
if value == "" {
return "", "", false
}
parts := strings.SplitN(value, "=", 2)
if len(parts) != 2 {
return "", "", false
}
method := strings.ToLower(strings.TrimSpace(parts[0]))
if method != "spf" && method != "dkim" && method != "dmarc" {
return "", "", false
}
return method, firstAuthStatus(parts[1]), true
}
func firstAuthStatus(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}
value = strings.ToLower(strings.Fields(value)[0])
if idx := strings.IndexAny(value, "();,"); idx >= 0 {
value = value[:idx]
}
switch value {
case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none":
return value
default:
return "unknown"
}
}
func normalizeMailAuthentication(auth MailAuthentication) MailAuthentication {
auth.AuthenticationResults = strings.TrimSpace(auth.AuthenticationResults)
auth.ReceivedSPF = strings.TrimSpace(auth.ReceivedSPF)
auth.SPF = normalizeAuthStatus(auth.SPF)
auth.DKIM = normalizeAuthStatus(auth.DKIM)
auth.DMARC = normalizeAuthStatus(auth.DMARC)
return auth
}
func normalizeAuthStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none":
return strings.ToLower(strings.TrimSpace(value))
default:
return "unknown"
}
}
+1
View File
@@ -654,6 +654,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
BodyText: bodyText,
BodyHTML: bodyHTML,
IsRead: false,
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
}, parsed.Attachments, nil
}
+241 -38
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
matchMode := strings.TrimSpace(req.MatchMode)
if matchMode == "" {
matchMode = "all"
}
if matchMode != "all" && matchMode != "any" {
rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
badRequest(w, errors.New("invalid match mode"))
return
}
matchMode := normalizeRuleMatchMode(rawMatchMode)
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
if len(conditions) == 0 {
badRequest(w, errors.New("rule condition is required"))
@@ -639,10 +638,21 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load stats")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount); err != nil {
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
return
}
if mailboxID != "" {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...)
@@ -853,9 +863,7 @@ func scanRule(row messageSummaryScanner) (MailRule, error) {
if err == nil {
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
item.Actions = decodeRuleActions(actionsJSON, item.Action)
if item.MatchMode == "" {
item.MatchMode = "all"
}
item.MatchMode = normalizeRuleMatchMode(item.MatchMode)
}
item.ApplyToExisting = intBool(applyToExisting)
item.StopProcessing = intBool(stopProcessing)
@@ -877,13 +885,8 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
return
}
from = normalizeEmail(from)
var blocked int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
if blocked > 0 {
if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil {
_ = a.moveMessageMaildir(ctx, messageID, spamID)
}
if a.senderBlocked(ctx, userID, mailboxID, from) {
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
@@ -899,16 +902,40 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
}
rows.Close()
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
_ = a.db.QueryRowContext(ctx, `SELECT trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
msg, ok := a.ruleMessageByID(ctx, messageID)
if !ok {
msg = ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
}
for _, rule := range rules {
if !ruleMatches(rule, msg) {
continue
}
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
if rule.StopProcessing {
break
}
}
if a.senderBlocked(ctx, userID, mailboxID, from) {
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
}
}
func (a *App) senderBlocked(ctx context.Context, userID, mailboxID, from string) bool {
from = normalizeEmail(from)
if from == "" {
return false
}
var blocked int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
return blocked > 0
}
func (a *App) moveBlockedMessageToSpam(ctx context.Context, messageID, mailboxID string) {
spamID, err := a.ensureFolder(ctx, mailboxID, "Spam")
if err != nil {
return
}
}
_ = a.moveMessageMaildir(ctx, messageID, spamID)
}
type ruleMessage struct {
@@ -916,9 +943,53 @@ type ruleMessage struct {
MailboxID string
From string
To string
CC string
Subject string
Snippet string
BodyText string
AttachmentNames string
SizeBytes int64
ReceivedAt time.Time
}
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
var msg ruleMessage
var toAddrs, ccAddrs, receivedAt string
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
if err != nil {
return ruleMessage{}, false
}
msg.To = ruleAddressText(toAddrs)
msg.CC = ruleAddressText(ccAddrs)
msg.ReceivedAt = parseTime(receivedAt)
msg.AttachmentNames = a.ruleAttachmentNames(ctx, messageID)
return msg, true
}
func ruleAddressText(raw string) string {
var items []string
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &items) == nil {
return strings.Join(items, " ")
}
return raw
}
func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string {
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
if err != nil {
return ""
}
defer rows.Close()
parts := []string{}
for rows.Next() {
var filename, contentType string
if err := rows.Scan(&filename, &contentType); err != nil {
return strings.Join(parts, " ")
}
parts = append(parts, filename, contentType)
}
return strings.Join(parts, " ")
}
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
@@ -932,24 +1003,54 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
}
out := []MailRuleCondition{}
for _, item := range items {
field := strings.TrimSpace(item.Field)
operator := strings.TrimSpace(item.Operator)
if normalized, ok := normalizeRuleCondition(item); ok {
out = append(out, normalized)
}
}
return out
}
func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
matchMode := normalizeRuleMatchMode(item.MatchMode)
if len(item.Conditions) > 0 {
children := normalizeRuleConditions(item.Conditions, "", "")
if len(children) == 0 {
return MailRuleCondition{}, false
}
return MailRuleCondition{MatchMode: matchMode, Conditions: children}, true
}
field := strings.ToLower(strings.TrimSpace(item.Field))
operator := strings.ToLower(strings.TrimSpace(item.Operator))
value := strings.TrimSpace(item.Value)
if value == "" {
continue
return MailRuleCondition{}, false
}
if field != "from" && field != "to" && field != "subject" && field != "body" {
continue
switch field {
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
default:
return MailRuleCondition{}, false
}
if operator == "" {
operator = "contains"
}
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
continue
switch operator {
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
case "gt", "gte", "lt", "lte", "before", "after", "on":
default:
return MailRuleCondition{}, false
}
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
}
func normalizeRuleMatchMode(matchMode string) string {
switch strings.ToLower(strings.TrimSpace(matchMode)) {
case "any", "or":
return "any"
case "all", "and":
return "all"
default:
return "all"
}
return out
}
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
@@ -1008,10 +1109,11 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
if len(conditions) == 0 {
return false
}
matchMode := rule.MatchMode
if matchMode == "" {
matchMode = "all"
}
matchMode := normalizeRuleMatchMode(rule.MatchMode)
return ruleConditionsMatch(conditions, matchMode, msg)
}
func ruleConditionsMatch(conditions []MailRuleCondition, matchMode string, msg ruleMessage) bool {
matched := 0
for _, condition := range conditions {
if ruleConditionMatches(condition, msg) {
@@ -1027,12 +1129,17 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
}
func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
if len(condition.Conditions) > 0 {
return ruleConditionsMatch(condition.Conditions, normalizeRuleMatchMode(condition.MatchMode), msg)
}
var source string
switch condition.Field {
switch strings.ToLower(strings.TrimSpace(condition.Field)) {
case "from":
source = msg.From
case "to":
source = msg.To
case "cc":
source = msg.CC
case "subject":
source = msg.Subject
case "body":
@@ -1040,12 +1147,18 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
if source == "" {
source = msg.Snippet
}
case "attachment":
source = msg.AttachmentNames
case "size":
return ruleNumericConditionMatches(condition, msg.SizeBytes)
case "date":
return ruleDateConditionMatches(condition, msg.ReceivedAt)
default:
return false
}
source = strings.ToLower(source)
value := strings.ToLower(condition.Value)
switch condition.Operator {
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
case "contains":
return strings.Contains(source, value)
case "not-contains":
@@ -1063,6 +1176,94 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
}
}
func ruleNumericConditionMatches(condition MailRuleCondition, source int64) bool {
value, ok := parseRuleSizeValue(condition.Value)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
case "gt":
return source > value
case "gte":
return source >= value
case "lt":
return source < value
case "lte":
return source <= value
case "equals":
return source == value
case "not-equals":
return source != value
default:
return false
}
}
func parseRuleSizeValue(raw string) (int64, bool) {
value := strings.ToLower(strings.TrimSpace(raw))
multiplier := int64(1)
for _, suffix := range []struct {
text string
multiplier int64
}{
{"kb", 1024},
{"k", 1024},
{"mb", 1024 * 1024},
{"m", 1024 * 1024},
{"gb", 1024 * 1024 * 1024},
{"g", 1024 * 1024 * 1024},
{"b", 1},
} {
if strings.HasSuffix(value, suffix.text) {
multiplier = suffix.multiplier
value = strings.TrimSpace(strings.TrimSuffix(value, suffix.text))
break
}
}
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n < 0 {
return 0, false
}
return n * multiplier, true
}
func ruleDateConditionMatches(condition MailRuleCondition, source time.Time) bool {
if source.IsZero() {
return false
}
target, ok := parseRuleDateValue(condition.Value)
if !ok {
return false
}
source = source.UTC()
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
case "before", "lt":
return source.Before(target)
case "after", "gt":
return source.After(target)
case "on", "equals":
y1, m1, d1 := source.Date()
y2, m2, d2 := target.Date()
return y1 == y2 && m1 == m2 && d1 == d2
case "not-equals":
y1, m1, d1 := source.Date()
y2, m2, d2 := target.Date()
return y1 != y2 || m1 != m2 || d1 != d2
default:
return false
}
}
func parseRuleDateValue(raw string) (time.Time, bool) {
value := strings.TrimSpace(raw)
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, value); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
now := a.now().UTC().Format(time.RFC3339Nano)
for _, action := range normalizeRuleActions(actions, "") {
@@ -1165,19 +1366,21 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
where += ` AND m.mailbox_id=?`
args = append(args, mailboxID)
}
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,trim(m.from_addr || ' ' || COALESCE(m.from_name,'')),m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
if err != nil {
return 0, err
}
messages := []ruleMessage{}
var count int64
for rows.Next() {
var msg ruleMessage
var toAddrs sql.NullString
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
var messageID string
if err := rows.Scan(&messageID); err != nil {
return count, err
}
msg.To = toAddrs.String
msg, ok := a.ruleMessageByID(ctx, messageID)
if !ok {
continue
}
if !ruleMatches(rule, msg) {
continue
}
+1
View File
@@ -119,6 +119,7 @@ func (a *App) Router() http.Handler {
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/send-audit", a.handleAdminSendAudit)
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
+19 -3
View File
@@ -104,6 +104,15 @@ type MailMessage struct {
SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Authentication MailAuthentication `json:"authentication"`
}
type MailAuthentication struct {
AuthenticationResults string `json:"authenticationResults"`
ReceivedSPF string `json:"receivedSpf"`
SPF string `json:"spf"`
DKIM string `json:"dkim"`
DMARC string `json:"dmarc"`
}
type Attachment struct {
@@ -173,9 +182,11 @@ type MailRule struct {
}
type MailRuleCondition struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value"`
Field string `json:"field,omitempty"`
Operator string `json:"operator,omitempty"`
Value string `json:"value,omitempty"`
MatchMode string `json:"matchMode,omitempty"`
Conditions []MailRuleCondition `json:"conditions,omitempty"`
}
type MailRuleAction struct {
@@ -198,7 +209,10 @@ type MailStats struct {
UnreadMessages int64 `json:"unreadMessages"`
StarredMessages int64 `json:"starredMessages"`
AttachmentCount int64 `json:"attachmentCount"`
AttachmentBytes int64 `json:"attachmentBytes"`
StorageBytes int64 `json:"storageBytes"`
QuotaBytes int64 `json:"quotaBytes"`
QuotaUsedPct float64 `json:"quotaUsedPct"`
ByFolder []MailStatsFolderCount `json:"byFolder"`
}
@@ -234,7 +248,9 @@ type SendAuditEvent struct {
ID string `json:"id"`
QueueID string `json:"queueId"`
MailboxID string `json:"mailboxId"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
SentMessageID string `json:"sentMessageId"`
MessageID string `json:"messageId,omitempty"`
Source string `json:"source"`
Event string `json:"event"`
Status string `json:"status"`
+8 -2
View File
@@ -59,9 +59,11 @@ export type Alias = { id: string; domainId: string; source: string; destination:
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailAuthentication = { authenticationResults: string; receivedSpf: string; spf: string; dkim: string; dmarc: string }
export type MailMessage = {
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
labels?: MailLabel[]
authentication?: MailAuthentication
}
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
@@ -96,7 +98,9 @@ export type SendQueueAuditEvent = {
id: string
queueId?: string
mailboxId?: string
mailboxAddress?: string
sentMessageId?: string
messageId?: string
source?: string
status?: SendQueueStatus
event?: string
@@ -111,11 +115,13 @@ export type SendQueueAuditEvent = {
}
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
export type MailRuleConditionField = "from" | "to" | "cc" | "subject" | "body" | "attachment" | "size" | "date"
export type MailRuleConditionOperator = "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with" | "gt" | "gte" | "lt" | "lte" | "before" | "after" | "on"
export type MailRuleCondition = { field?: MailRuleConditionField; operator?: MailRuleConditionOperator; value?: string; matchMode?: "all" | "any"; conditions?: MailRuleCondition[] }
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
+11
View File
@@ -94,6 +94,17 @@ export const api = {
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
},
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
adminSendAudit: (params: { mailboxId?: string; messageId?: string; event?: string; from?: string; to?: string; cursor?: string } = {}) => {
const query = new URLSearchParams()
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.messageId) query.set("messageId", params.messageId)
if (params.event) query.set("event", params.event)
if (params.from) query.set("from", params.from)
if (params.to) query.set("to", params.to)
if (params.cursor) query.set("cursor", params.cursor)
const suffix = query.toString()
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
},
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
+138 -2
View File
@@ -2,7 +2,7 @@ import * as React from "react"
import DOMPurify from "dompurify"
import { useSearchParams } from "react-router-dom"
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
import { ArrowRight, BookOpen, CheckCircle2, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -25,7 +25,7 @@ import { useToast } from "@/hooks/use-toast"
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
const sectionLabels: Record<Section, string> = {
@@ -36,6 +36,7 @@ const sectionLabels: Record<Section, string> = {
mailboxes: "邮箱账号",
aliases: "别名转发",
messages: "全部邮件",
sendAudit: "发送审计",
settings: "系统设置",
}
const sectionKeys = Object.keys(sectionLabels) as Section[]
@@ -47,6 +48,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
mailboxes: ["admin.mailboxes.view"],
aliases: ["admin.aliases.view"],
messages: ["admin.messages.view"],
sendAudit: ["admin.messages.view"],
settings: ["admin.settings.view", "admin.templates.view"],
}
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
@@ -108,6 +110,7 @@ export function AdminPage() {
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
</main>
</ScrollArea>
@@ -851,6 +854,119 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
)
}
function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
const qc = useQueryClient()
const [mailboxId, setMailboxId] = React.useState("all")
const [event, setEvent] = React.useState("all")
const [messageId, setMessageId] = React.useState("")
const [from, setFrom] = React.useState("")
const [to, setTo] = React.useState("")
const audit = useInfiniteQuery({
queryKey: ["admin", "send-audit", mailboxId, event, messageId, from, to],
queryFn: ({ pageParam }) => api.adminSendAudit({
mailboxId: mailboxId === "all" ? "" : mailboxId,
event: event === "all" ? "" : event,
messageId: messageId.trim(),
from,
to,
cursor: typeof pageParam === "string" ? pageParam : "",
}),
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
})
const items = audit.data?.pages.flatMap((page) => page.items || []) || []
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" /></CardTitle>
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "send-audit"] })}>
<RefreshCcw className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_180px_160px_160px]">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={messageId} onChange={(event) => setMessageId(event.target.value)} placeholder="Message-ID 或已发送邮件 ID" className="pl-9" />
</div>
<Select value={mailboxId} onValueChange={setMailboxId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}
</SelectContent>
</Select>
<Select value={event} onValueChange={setEvent}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{sendAuditEvents.map((item) => <SelectItem key={item} value={item}>{sendAuditEventLabel(item)}</SelectItem>)}
</SelectContent>
</Select>
<Input type="date" value={from} onChange={(event) => setFrom(event.target.value)} aria-label="开始日期" />
<Input type="date" value={to} onChange={(event) => setTo(event.target.value)} aria-label="结束日期" />
</div>
<div className="space-y-3 md:hidden">
{items.map((item) => (
<div key={item.id} className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">{sendAuditEventLabel(item.event || "")}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{item.mailboxAddress || item.mailboxId || "-"}</div>
</div>
<Badge variant={sendAuditBadgeVariant(item.event)}>{item.status || item.event || "-"}</Badge>
</div>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
<div className="truncate">{(item.recipients || []).join(", ") || "-"}</div>
<div className="truncate">Message-ID{item.messageId || item.sentMessageId || "-"}</div>
{item.error && <div className="line-clamp-2 text-destructive">{item.error}</div>}
</div>
<div className="mt-3 text-xs text-muted-foreground">{formatDate(item.createdAt)}</div>
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Message-ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell><Badge variant={sendAuditBadgeVariant(item.event)}>{sendAuditEventLabel(item.event || "")}</Badge></TableCell>
<TableCell className="max-w-[220px] truncate">{item.mailboxAddress || item.mailboxId || "-"}</TableCell>
<TableCell className="max-w-[260px] truncate" title={(item.recipients || []).join(", ")}>{(item.recipients || []).join(", ") || "-"}</TableCell>
<TableCell className="max-w-[240px] truncate" title={item.messageId || item.sentMessageId || ""}>{item.messageId || item.sentMessageId || "-"}</TableCell>
<TableCell className="max-w-[260px] truncate text-destructive" title={item.error || ""}>{item.error || "-"}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{formatDate(item.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{audit.isLoading && <Empty text="加载中..." />}
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送审计" />}
{!audit.isLoading && audit.hasNextPage && (
<div className="flex justify-center">
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
{audit.isFetchingNextPage ? "加载中..." : "加载更多"}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
const me = useMe()
const user = me.data?.user
@@ -1471,6 +1587,26 @@ function adminSenderTitle(message: MailMessage) {
return name ? `${name} <${from}>` : from
}
const sendAuditEvents = ["accepted", "queued", "retry", "delivered", "failed", "canceled"]
function sendAuditEventLabel(event: string) {
switch (event) {
case "accepted": return "已接受"
case "queued": return "已入队"
case "retry": return "重试"
case "delivered": return "已投递"
case "failed": return "失败"
case "canceled": return "已取消"
default: return event || "-"
}
}
function sendAuditBadgeVariant(event?: string) {
if (event === "failed") return "destructive"
if (event === "delivered" || event === "accepted") return "default"
return "secondary"
}
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
}
+36
View File
@@ -1963,6 +1963,7 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
<MessageMetaRow label="接收时间">
<span>{formatDateTime(message.receivedAt)}</span>
</MessageMetaRow>
<AuthenticationResultRow message={message} />
{availableLabels && onAddLabel && onRemoveLabel && (
<MessageMetaRow label="标签">
<div className="flex flex-wrap items-center gap-1.5">
@@ -2024,6 +2025,41 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
)
}
function AuthenticationResultRow({ message }: { message: MailMessage }) {
const auth = message.authentication || { authenticationResults: "", receivedSpf: "", spf: "unknown", dkim: "unknown", dmarc: "unknown" }
const title = [auth.authenticationResults, auth.receivedSpf].filter(Boolean).join("\n\n")
return (
<MessageMetaRow label="Auth">
<div className="flex flex-wrap gap-1.5" title={title || undefined}>
<AuthStatusBadge label="SPF" value={auth.spf} />
<AuthStatusBadge label="DKIM" value={auth.dkim} />
<AuthStatusBadge label="DMARC" value={auth.dmarc} />
</div>
</MessageMetaRow>
)
}
function AuthStatusBadge({ label, value }: { label: string; value?: string }) {
const status = normalizeAuthStatus(value)
return (
<Badge variant="outline" className={cn("rounded-md font-mono text-[11px] font-normal", authStatusClassName(status))}>
{label}:{status}
</Badge>
)
}
function normalizeAuthStatus(value?: string) {
const status = (value || "").trim().toLowerCase()
if (["pass", "fail", "softfail", "neutral", "temperror", "permerror", "none"].includes(status)) return status
return "unknown"
}
function authStatusClassName(status: string) {
if (status === "pass") return "border-emerald-300 bg-emerald-50 text-emerald-700"
if (["fail", "softfail", "permerror"].includes(status)) return "border-red-300 bg-red-50 text-red-700"
return "border-slate-300 bg-slate-50 text-slate-600"
}
function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
+54 -11
View File
@@ -812,8 +812,14 @@ type RuleCreatePayload = {
enabled: boolean
}
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
type RuleConditionField = NonNullable<MailRuleCondition["field"]>
type RuleConditionOperator = NonNullable<MailRuleCondition["operator"]>
const conditionFieldLabels: Record<RuleConditionField, string> = { from: "发件人地址", to: "收件人地址", cc: "抄送地址", subject: "邮件主题", body: "邮件正文", attachment: "附件名称", size: "邮件大小", date: "收信日期" }
const conditionOperatorLabels: Record<RuleConditionOperator, string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是", gt: "大于", gte: "大于等于", lt: "小于", lte: "小于等于", before: "早于", after: "晚于", on: "当天" }
const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with"]
const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte", "equals", "not-equals"]
const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"]
const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[]
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) {
@@ -860,7 +866,14 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
}, [open, labels])
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item))
setConditions((items) => items.map((item, i) => {
if (i !== index) return item
const next = { ...item, ...patch }
if (patch.field && !conditionOperatorsForField(patch.field).includes(next.operator || "contains")) {
next.operator = defaultConditionOperator(patch.field)
}
return next
}))
}
function updateAction(index: number, patch: Partial<MailRuleAction>) {
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
@@ -870,7 +883,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
const validConditions = conditions.map((item) => ({ ...item, value: item.value.trim() })).filter((item) => item.value)
const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value)
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
@@ -902,15 +915,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
<div className="space-y-3">
{conditions.map((condition, index) => (
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}>
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}>
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" />
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
</div>
@@ -1012,9 +1025,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
return { type: action.type }
}
function conditionOperatorsForField(field?: MailRuleCondition["field"]) {
if (field === "size") return sizeConditionOperators
if (field === "date") return dateConditionOperators
return textConditionOperators
}
function defaultConditionOperator(field?: MailRuleCondition["field"]): RuleConditionOperator {
if (field === "size") return "gte"
if (field === "date") return "on"
return "contains"
}
function conditionPlaceholder(field?: MailRuleCondition["field"]) {
if (field === "size") return "例如 10mb"
if (field === "date") return "选择日期"
if (field === "attachment") return "输入附件名或扩展名"
return "输入值"
}
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join("") || "无条件"
return items.map(conditionItemSummary).join("") || "无条件"
}
function conditionItemSummary(item: MailRuleCondition): string {
if (item.conditions?.length) {
const mode = item.matchMode === "any" ? "任一" : "全部"
return `${mode}(${item.conditions.map(conditionItemSummary).join("")})`
}
const field = item.field || "from"
const operator = item.operator || defaultConditionOperator(field)
return `${conditionFieldLabels[field]} ${conditionOperatorLabels[operator]} ${item.value || ""}`
}
function actionSummary(action: MailRuleAction) {
@@ -1063,7 +1105,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
}
function StatsSummary({ stats }: { stats?: MailStats }) {
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: stats?.attachmentCount || 0 }, { label: "容量", value: formatBytes(stats?.storageBytes || 0) }]
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: `${stats?.attachmentCount || 0} / ${formatBytes(stats?.attachmentBytes || 0)}` }, { label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel }]
return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div>
}