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