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
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:
@@ -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"`
|
||||||
|
|||||||
@@ -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, ¬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 {
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -47,6 +48,7 @@ type storedMessage struct {
|
|||||||
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("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 {
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -654,6 +654,7 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
|||||||
BodyText: bodyText,
|
BodyText: bodyText,
|
||||||
BodyHTML: bodyHTML,
|
BodyHTML: bodyHTML,
|
||||||
IsRead: false,
|
IsRead: false,
|
||||||
|
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
|
||||||
}, parsed.Attachments, nil
|
}, parsed.Attachments, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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("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)
|
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,16 +902,40 @@ 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 {
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
_ = a.moveMessageMaildir(ctx, messageID, spamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ruleMessage struct {
|
type ruleMessage struct {
|
||||||
@@ -916,9 +943,53 @@ type ruleMessage struct {
|
|||||||
MailboxID string
|
MailboxID string
|
||||||
From string
|
From string
|
||||||
To string
|
To string
|
||||||
|
CC string
|
||||||
Subject string
|
Subject string
|
||||||
Snippet string
|
Snippet string
|
||||||
BodyText 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,24 +1003,54 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
value := strings.TrimSpace(item.Value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue
|
return MailRuleCondition{}, false
|
||||||
}
|
}
|
||||||
if field != "from" && field != "to" && field != "subject" && field != "body" {
|
switch field {
|
||||||
continue
|
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
|
||||||
|
default:
|
||||||
|
return MailRuleCondition{}, false
|
||||||
}
|
}
|
||||||
if operator == "" {
|
if operator == "" {
|
||||||
operator = "contains"
|
operator = "contains"
|
||||||
}
|
}
|
||||||
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
|
switch operator {
|
||||||
continue
|
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||||
|
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||||
|
default:
|
||||||
|
return MailRuleCondition{}, false
|
||||||
}
|
}
|
||||||
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
|
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRuleMatchMode(matchMode string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||||
|
case "any", "or":
|
||||||
|
return "any"
|
||||||
|
case "all", "and":
|
||||||
|
return "all"
|
||||||
|
default:
|
||||||
|
return "all"
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
|
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ type MailMessage struct {
|
|||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +248,9 @@ 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"`
|
||||||
|
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||||
SentMessageID string `json:"sentMessageId"`
|
SentMessageID string `json:"sentMessageId"`
|
||||||
|
MessageID string `json:"messageId,omitempty"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]">
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user