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) 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) { func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
DomainID string `json:"domainId"` 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, is_starred INTEGER NOT NULL DEFAULT 0,
has_attachments INTEGER NOT NULL DEFAULT 0, has_attachments INTEGER NOT NULL DEFAULT 0,
size_bytes 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 '', raw_path TEXT NOT NULL DEFAULT '',
imap_uid INTEGER NOT NULL DEFAULT 0, imap_uid INTEGER NOT NULL DEFAULT 0,
imap_modseq INTEGER NOT NULL DEFAULT 1, 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 { if err := a.migrateMessagesFromName(ctx); err != nil {
return err return err
} }
if err := a.migrateMessageAuthentication(ctx); err != nil {
return err
}
if err := a.migrateUsersForTwoFactor(ctx); err != nil { if err := a.migrateUsersForTwoFactor(ctx); err != nil {
return err return err
} }
@@ -443,6 +451,47 @@ func (a *App) migrate(ctx context.Context) error {
return nil 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 { func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`) rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
if err != nil { if err != nil {
+485
View File
@@ -20,6 +20,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/textproto" "net/textproto"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "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) { func TestScheduleSendQueuesFutureMessage(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) 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) { func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
user, mailbox, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!") 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) { func TestMaildirSyncHealthDisabled(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) 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 { func mustDefaultDomainID(t *testing.T, a *App) string {
t.Helper() t.Helper()
var id string var id string
+153 -23
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/textproto"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -28,25 +29,26 @@ type AttachmentInput struct {
} }
type storedMessage struct { type storedMessage struct {
MailboxID string MailboxID string
FolderID string FolderID string
RecipientAddr string RecipientAddr string
MessageUID string MessageUID string
MessageID string MessageID string
Subject string Subject string
From string From string
FromName string FromName string
To []string To []string
CC []string CC []string
BCC []string BCC []string
SentAt time.Time SentAt time.Time
ReceivedAt time.Time ReceivedAt time.Time
Snippet string Snippet string
BodyText string BodyText string
BodyHTML string BodyHTML string
IsRead bool IsRead bool
IsStarred bool IsStarred bool
RawPath string RawPath string
Authentication MailAuthentication
} }
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) { 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()) respondError(w, http.StatusForbidden, err.Error())
return return
} }
if errors.Is(err, errMailboxQuotaExceeded) {
respondError(w, http.StatusInsufficientStorage, err.Error())
return
}
respondError(w, http.StatusInternalServerError, err.Error()) respondError(w, http.StatusInternalServerError, err.Error())
return return
} }
@@ -440,6 +446,7 @@ var errInvalidMIME = errors.New("invalid mime message")
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit") var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded") var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
var errSenderNotAuthorized = errors.New("sender address is not authorized") 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) { func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil { 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) { 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) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
msg, err := scanMessageFull(row, includeBody) msg, err := scanMessageFull(row, includeBody)
if err != nil { if err != nil {
@@ -1623,6 +1630,9 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
size += int64(len(decoded)) size += int64(len(decoded))
} }
} }
if err := a.ensureMailboxQuotaAvailable(ctx, db, msg.MailboxID, size); err != nil {
return "", err
}
var mailboxID, folderID any var mailboxID, folderID any
if strings.TrimSpace(msg.MailboxID) != "" { if strings.TrimSpace(msg.MailboxID) != "" {
mailboxID = 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 imapUID, imapModSeq = meta.UID, meta.ModSeq
} }
recipientAddr := normalizeEmail(msg.RecipientAddr) 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) auth := normalizeMailAuthentication(msg.Authentication)
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) _, 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 { if err != nil {
return "", err return "", err
} }
@@ -1653,6 +1664,33 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
return id, nil 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 { func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error {
return a.storeAttachmentWithDB(ctx, a.db, messageID, input) 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) { func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var auth MailAuthentication
var read, starred, hasAtt int var read, starred, hasAtt int
var bodyText, bodyHTML string 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 { if err != nil {
return msg, err return msg, err
} }
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON) msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received) msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt) msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
msg.Authentication = normalizeMailAuthentication(auth)
if includeBody { if includeBody {
msg.BodyText, msg.BodyHTML = bodyText, bodyHTML msg.BodyText, msg.BodyHTML = bodyText, bodyHTML
} }
return msg, nil 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"
}
}
+14 -13
View File
@@ -641,19 +641,20 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
receivedAt = sentAt receivedAt = sentAt
} }
return storedMessage{ return storedMessage{
MessageUID: newID("uid"), MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")), MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject, Subject: subject,
From: from, From: from,
FromName: fromName, FromName: fromName,
To: to, To: to,
CC: cc, CC: cc,
SentAt: sentAt, SentAt: sentAt,
ReceivedAt: receivedAt, ReceivedAt: receivedAt,
Snippet: snippetFrom(bodyText, bodyHTML), Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: bodyText, BodyText: bodyText,
BodyHTML: bodyHTML, BodyHTML: bodyHTML,
IsRead: false, IsRead: false,
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
}, parsed.Attachments, nil }, parsed.Attachments, nil
} }
+254 -51
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found") respondError(w, http.StatusNotFound, "mailbox not found")
return return
} }
matchMode := strings.TrimSpace(req.MatchMode) rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
if matchMode == "" { if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
matchMode = "all"
}
if matchMode != "all" && matchMode != "any" {
badRequest(w, errors.New("invalid match mode")) badRequest(w, errors.New("invalid match mode"))
return return
} }
matchMode := normalizeRuleMatchMode(rawMatchMode)
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains) conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
if len(conditions) == 0 { if len(conditions) == 0 {
badRequest(w, errors.New("rule condition is required")) 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") respondError(w, http.StatusInternalServerError, "failed to load stats")
return 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") respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
return 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) 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 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...) 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 { if err == nil {
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains) item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
item.Actions = decodeRuleActions(actionsJSON, item.Action) item.Actions = decodeRuleActions(actionsJSON, item.Action)
if item.MatchMode == "" { item.MatchMode = normalizeRuleMatchMode(item.MatchMode)
item.MatchMode = "all"
}
} }
item.ApplyToExisting = intBool(applyToExisting) item.ApplyToExisting = intBool(applyToExisting)
item.StopProcessing = intBool(stopProcessing) 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 { if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
return return
} }
from = normalizeEmail(from) if a.senderBlocked(ctx, userID, mailboxID, from) {
var blocked int a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
_ = 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)
}
return 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) 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,26 +902,94 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
} }
rows.Close() rows.Close()
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject} 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 { for _, rule := range rules {
if !ruleMatches(rule, msg) { if !ruleMatches(rule, msg) {
continue continue
} }
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions) _ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
if rule.StopProcessing { if rule.StopProcessing {
return 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 { type ruleMessage struct {
ID string ID string
MailboxID string MailboxID string
From string From string
To string To string
Subject string CC string
Snippet string Subject string
BodyText 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 { func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
@@ -932,26 +1003,56 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
} }
out := []MailRuleCondition{} out := []MailRuleCondition{}
for _, item := range items { for _, item := range items {
field := strings.TrimSpace(item.Field) if normalized, ok := normalizeRuleCondition(item); ok {
operator := strings.TrimSpace(item.Operator) out = append(out, normalized)
value := strings.TrimSpace(item.Value)
if value == "" {
continue
} }
if field != "from" && field != "to" && field != "subject" && field != "body" {
continue
}
if operator == "" {
operator = "contains"
}
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
continue
}
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
} }
return out 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 == "" {
return MailRuleCondition{}, false
}
switch field {
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
default:
return MailRuleCondition{}, false
}
if operator == "" {
operator = "contains"
}
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
}
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"
}
}
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction { func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" { if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)}) items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)})
@@ -1008,10 +1109,11 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
if len(conditions) == 0 { if len(conditions) == 0 {
return false return false
} }
matchMode := rule.MatchMode matchMode := normalizeRuleMatchMode(rule.MatchMode)
if matchMode == "" { return ruleConditionsMatch(conditions, matchMode, msg)
matchMode = "all" }
}
func ruleConditionsMatch(conditions []MailRuleCondition, matchMode string, msg ruleMessage) bool {
matched := 0 matched := 0
for _, condition := range conditions { for _, condition := range conditions {
if ruleConditionMatches(condition, msg) { if ruleConditionMatches(condition, msg) {
@@ -1027,12 +1129,17 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
} }
func ruleConditionMatches(condition MailRuleCondition, 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 var source string
switch condition.Field { switch strings.ToLower(strings.TrimSpace(condition.Field)) {
case "from": case "from":
source = msg.From source = msg.From
case "to": case "to":
source = msg.To source = msg.To
case "cc":
source = msg.CC
case "subject": case "subject":
source = msg.Subject source = msg.Subject
case "body": case "body":
@@ -1040,12 +1147,18 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
if source == "" { if source == "" {
source = msg.Snippet source = msg.Snippet
} }
case "attachment":
source = msg.AttachmentNames
case "size":
return ruleNumericConditionMatches(condition, msg.SizeBytes)
case "date":
return ruleDateConditionMatches(condition, msg.ReceivedAt)
default: default:
return false return false
} }
source = strings.ToLower(source) source = strings.ToLower(source)
value := strings.ToLower(condition.Value) value := strings.ToLower(condition.Value)
switch condition.Operator { switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
case "contains": case "contains":
return strings.Contains(source, value) return strings.Contains(source, value)
case "not-contains": 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 { func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
for _, action := range normalizeRuleActions(actions, "") { for _, action := range normalizeRuleActions(actions, "") {
@@ -1165,19 +1366,21 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
where += ` AND m.mailbox_id=?` where += ` AND m.mailbox_id=?`
args = append(args, mailboxID) 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 { if err != nil {
return 0, err return 0, err
} }
messages := []ruleMessage{} messages := []ruleMessage{}
var count int64 var count int64
for rows.Next() { for rows.Next() {
var msg ruleMessage var messageID string
var toAddrs sql.NullString if err := rows.Scan(&messageID); err != nil {
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
return count, err return count, err
} }
msg.To = toAddrs.String msg, ok := a.ruleMessageByID(ctx, messageID)
if !ok {
continue
}
if !ruleMatches(rule, msg) { if !ruleMatches(rule, msg) {
continue 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(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias) 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/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(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment) r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings) r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
+59 -43
View File
@@ -76,34 +76,43 @@ type MailLabel struct {
} }
type MailMessage struct { type MailMessage struct {
ID string `json:"id"` ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"` MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"` MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"` OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"` RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"` FolderID string `json:"folderId"`
Folder string `json:"folder"` Folder string `json:"folder"`
MessageUID string `json:"messageUid"` MessageUID string `json:"messageUid"`
IMAPUID int64 `json:"imapUid"` IMAPUID int64 `json:"imapUid"`
IMAPModSeq int64 `json:"imapModseq"` IMAPModSeq int64 `json:"imapModseq"`
MessageID string `json:"messageId"` MessageID string `json:"messageId"`
Subject string `json:"subject"` Subject string `json:"subject"`
From string `json:"from"` From string `json:"from"`
FromName string `json:"fromName,omitempty"` FromName string `json:"fromName,omitempty"`
To []string `json:"to"` To []string `json:"to"`
CC []string `json:"cc"` CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"` BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"` SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"` ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"` Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"` BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"` BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"` IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"` IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"` HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"` SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"` Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,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 { type Attachment struct {
@@ -173,9 +182,11 @@ type MailRule struct {
} }
type MailRuleCondition struct { type MailRuleCondition struct {
Field string `json:"field"` Field string `json:"field,omitempty"`
Operator string `json:"operator"` Operator string `json:"operator,omitempty"`
Value string `json:"value"` Value string `json:"value,omitempty"`
MatchMode string `json:"matchMode,omitempty"`
Conditions []MailRuleCondition `json:"conditions,omitempty"`
} }
type MailRuleAction struct { type MailRuleAction struct {
@@ -198,7 +209,10 @@ type MailStats struct {
UnreadMessages int64 `json:"unreadMessages"` UnreadMessages int64 `json:"unreadMessages"`
StarredMessages int64 `json:"starredMessages"` StarredMessages int64 `json:"starredMessages"`
AttachmentCount int64 `json:"attachmentCount"` AttachmentCount int64 `json:"attachmentCount"`
AttachmentBytes int64 `json:"attachmentBytes"`
StorageBytes int64 `json:"storageBytes"` StorageBytes int64 `json:"storageBytes"`
QuotaBytes int64 `json:"quotaBytes"`
QuotaUsedPct float64 `json:"quotaUsedPct"`
ByFolder []MailStatsFolderCount `json:"byFolder"` ByFolder []MailStatsFolderCount `json:"byFolder"`
} }
@@ -231,16 +245,18 @@ type SendQueueEntry struct {
} }
type SendAuditEvent struct { type SendAuditEvent struct {
ID string `json:"id"` ID string `json:"id"`
QueueID string `json:"queueId"` QueueID string `json:"queueId"`
MailboxID string `json:"mailboxId"` MailboxID string `json:"mailboxId"`
SentMessageID string `json:"sentMessageId"` MailboxAddress string `json:"mailboxAddress,omitempty"`
Source string `json:"source"` SentMessageID string `json:"sentMessageId"`
Event string `json:"event"` MessageID string `json:"messageId,omitempty"`
Status string `json:"status"` Source string `json:"source"`
MailFrom string `json:"mailFrom"` Event string `json:"event"`
HeaderFrom string `json:"headerFrom"` Status string `json:"status"`
Recipients []string `json:"recipients"` MailFrom string `json:"mailFrom"`
Error string `json:"error,omitempty"` HeaderFrom string `json:"headerFrom"`
CreatedAt time.Time `json:"createdAt"` Recipients []string `json:"recipients"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
} }
+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 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 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 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 = { 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[] 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[] labels?: MailLabel[]
authentication?: MailAuthentication
} }
export type DNSRecord = { type: string; name: string; value: string; ttl: number } 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[] }> } 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 id: string
queueId?: string queueId?: string
mailboxId?: string mailboxId?: string
mailboxAddress?: string
sentMessageId?: string sentMessageId?: string
messageId?: string
source?: string source?: string
status?: SendQueueStatus status?: SendQueueStatus
event?: string event?: string
@@ -111,11 +115,13 @@ export type SendQueueAuditEvent = {
} }
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string } 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 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 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 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 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 MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] } export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number } 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}` : ""}`) return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
}, },
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`), 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"), systemSettings: () => request<SystemSettings>("/api/admin/settings"),
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"), maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }), 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 DOMPurify from "dompurify"
import { useSearchParams } from "react-router-dom" import { useSearchParams } from "react-router-dom"
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" 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 { 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 { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -25,7 +25,7 @@ import { useToast } from "@/hooks/use-toast"
import { hasAnyPermission, hasPermission } from "@/lib/permissions" import { hasAnyPermission, hasPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types" 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 } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
const sectionLabels: Record<Section, string> = { const sectionLabels: Record<Section, string> = {
@@ -36,6 +36,7 @@ const sectionLabels: Record<Section, string> = {
mailboxes: "邮箱账号", mailboxes: "邮箱账号",
aliases: "别名转发", aliases: "别名转发",
messages: "全部邮件", messages: "全部邮件",
sendAudit: "发送审计",
settings: "系统设置", settings: "系统设置",
} }
const sectionKeys = Object.keys(sectionLabels) as Section[] const sectionKeys = Object.keys(sectionLabels) as Section[]
@@ -47,6 +48,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
mailboxes: ["admin.mailboxes.view"], mailboxes: ["admin.mailboxes.view"],
aliases: ["admin.aliases.view"], aliases: ["admin.aliases.view"],
messages: ["admin.messages.view"], messages: ["admin.messages.view"],
sendAudit: ["admin.messages.view"],
settings: ["admin.settings.view", "admin.templates.view"], settings: ["admin.settings.view", "admin.templates.view"],
} }
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email" 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 === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />} {section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />} {section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />} {section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
</main> </main>
</ScrollArea> </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[] }) { function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
const me = useMe() const me = useMe()
const user = me.data?.user const user = me.data?.user
@@ -1471,6 +1587,26 @@ function adminSenderTitle(message: MailMessage) {
return name ? `${name} <${from}>` : from 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 }) { 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> 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="接收时间"> <MessageMetaRow label="接收时间">
<span>{formatDateTime(message.receivedAt)}</span> <span>{formatDateTime(message.receivedAt)}</span>
</MessageMetaRow> </MessageMetaRow>
<AuthenticationResultRow message={message} />
{availableLabels && onAddLabel && onRemoveLabel && ( {availableLabels && onAddLabel && onRemoveLabel && (
<MessageMetaRow label="标签"> <MessageMetaRow label="标签">
<div className="flex flex-wrap items-center gap-1.5"> <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 }) { function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]"> <div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
+54 -11
View File
@@ -812,8 +812,14 @@ type RuleCreatePayload = {
enabled: boolean enabled: boolean
} }
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" } type RuleConditionField = NonNullable<MailRuleCondition["field"]>
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" } 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: "移动到" } 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 }) { 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]) }, [open, labels])
function updateCondition(index: number, patch: Partial<MailRuleCondition>) { 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>) { function updateAction(index: number, patch: Partial<MailRuleAction>) {
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item)) 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 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) } 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 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 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"> <div className="space-y-3">
{conditions.map((condition, index) => ( {conditions.map((condition, index) => (
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]"> <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> <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>
<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> <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> </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" 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> <Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
</div> </div>
@@ -1012,9 +1025,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
return { type: action.type } 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 = "") { 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[] 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) { function actionSummary(action: MailRuleAction) {
@@ -1063,7 +1105,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
} }
function StatsSummary({ stats }: { stats?: MailStats }) { 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> 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>
} }