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)
|
||||
}
|
||||
|
||||
func (a *App) handleAdminSendAudit(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
messageID := strings.TrimSpace(r.URL.Query().Get("messageId"))
|
||||
event := strings.TrimSpace(r.URL.Query().Get("event"))
|
||||
from, err := adminAuditTimeParam(r.URL.Query().Get("from"), false)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
to, err := adminAuditTimeParam(r.URL.Query().Get("to"), true)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := 50
|
||||
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if mailboxID != "" && mailboxID != "all" {
|
||||
where = append(where, "sae.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
if messageID != "" {
|
||||
where = append(where, "(sq.message_id=? OR m.message_id=? OR sae.sent_message_id=?)")
|
||||
args = append(args, messageID, messageID, messageID)
|
||||
}
|
||||
if event != "" && event != "all" {
|
||||
if !isSendAuditEvent(event) {
|
||||
badRequest(w, errors.New("invalid event"))
|
||||
return
|
||||
}
|
||||
where = append(where, "sae.event=?")
|
||||
args = append(args, event)
|
||||
}
|
||||
if from != "" {
|
||||
where = append(where, "sae.created_at>=?")
|
||||
args = append(args, from)
|
||||
}
|
||||
if to != "" {
|
||||
where = append(where, "sae.created_at<=?")
|
||||
args = append(args, to)
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT sae.id,sae.queue_id,sae.mailbox_id,COALESCE(mb.address,''),sae.sent_message_id,COALESCE(sq.message_id,m.message_id,''),sae.source,sae.event,sae.status,sae.mail_from,sae.header_from,sae.recipients_json,sae.error,sae.created_at
|
||||
FROM send_audit_events sae
|
||||
LEFT JOIN mailboxes mb ON mb.id=sae.mailbox_id
|
||||
LEFT JOIN send_queue sq ON sq.id=sae.queue_id
|
||||
LEFT JOIN messages m ON m.id=sae.sent_message_id
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY sae.created_at DESC, sae.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SendAuditEvent{}
|
||||
for rows.Next() {
|
||||
var item SendAuditEvent
|
||||
var recipientsJSON, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.MailboxAddress, &item.SentMessageID, &item.MessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
|
||||
return
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = strconv.Itoa(offset + limit)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func adminAuditTimeParam(value string, endOfDay bool) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", value); err == nil {
|
||||
if endOfDay {
|
||||
t = t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
return "", errors.New("invalid time filter")
|
||||
}
|
||||
|
||||
func isSendAuditEvent(event string) bool {
|
||||
switch event {
|
||||
case sendAuditAccepted, sendAuditQueued, sendAuditRetry, sendAuditDelivered, sendAuditFailed, sendAuditCanceled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
|
||||
@@ -230,6 +230,11 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||
has_attachments INTEGER NOT NULL DEFAULT 0,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
auth_results TEXT NOT NULL DEFAULT '',
|
||||
auth_spf TEXT NOT NULL DEFAULT 'unknown',
|
||||
auth_dkim TEXT NOT NULL DEFAULT 'unknown',
|
||||
auth_dmarc TEXT NOT NULL DEFAULT 'unknown',
|
||||
received_spf TEXT NOT NULL DEFAULT '',
|
||||
raw_path TEXT NOT NULL DEFAULT '',
|
||||
imap_uid INTEGER NOT NULL DEFAULT 0,
|
||||
imap_modseq INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -419,6 +424,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateMessagesFromName(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateMessageAuthentication(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -443,6 +451,47 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateMessageAuthentication(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
alter := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"auth_results", `ALTER TABLE messages ADD COLUMN auth_results TEXT NOT NULL DEFAULT ''`},
|
||||
{"auth_spf", `ALTER TABLE messages ADD COLUMN auth_spf TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"auth_dkim", `ALTER TABLE messages ADD COLUMN auth_dkim TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"auth_dmarc", `ALTER TABLE messages ADD COLUMN auth_dmarc TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"received_spf", `ALTER TABLE messages ADD COLUMN received_spf TEXT NOT NULL DEFAULT ''`},
|
||||
}
|
||||
for _, item := range alter {
|
||||
if !columns[item.name] {
|
||||
if _, err := a.db.ExecContext(ctx, item.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -422,6 +423,201 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMailAuthenticationResults(t *testing.T) {
|
||||
header := textproto.MIMEHeader{}
|
||||
header.Add("Authentication-Results", "mx.example.test; spf=pass smtp.mailfrom=sender.example; dkim=fail (bad signature) header.d=sender.example; dmarc=none")
|
||||
header.Add("Received-SPF", "softfail (mx.example.test: transitioning domain) client-ip=192.0.2.10; envelope-from=sender@example.test")
|
||||
|
||||
auth := parseMailAuthentication(header)
|
||||
if auth.SPF != "pass" || auth.DKIM != "fail" || auth.DMARC != "none" {
|
||||
t.Fatalf("unexpected auth summary: %+v", auth)
|
||||
}
|
||||
if !strings.Contains(auth.AuthenticationResults, "spf=pass") || !strings.Contains(auth.ReceivedSPF, "softfail") {
|
||||
t.Fatalf("raw auth headers not preserved: %+v", auth)
|
||||
}
|
||||
|
||||
unknown := parseMailAuthentication(textproto.MIMEHeader{})
|
||||
if unknown.SPF != "unknown" || unknown.DKIM != "unknown" || unknown.DMARC != "unknown" {
|
||||
t.Fatalf("missing headers should be unknown, got %+v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailRulesConditionGroupsAndActions(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d", code)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
sender := createTestMailbox(t, admin, domainID, "rule-sender", "Rule Sender", "Password123!", nil)
|
||||
recipient := createTestMailbox(t, admin, domainID, "rule-recipient", "Rule Recipient", "Password123!", nil)
|
||||
|
||||
rcpt := &testClient{t: t, server: ts}
|
||||
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("recipient login=%d", code)
|
||||
}
|
||||
var rule MailRule
|
||||
rulePayload := map[string]any{
|
||||
"mailboxId": recipient.ID,
|
||||
"name": "priority archive",
|
||||
"matchMode": "all",
|
||||
"conditions": []map[string]any{{"matchMode": "any", "conditions": []map[string]string{{"field": "from", "operator": "contains", "value": sender.Address}, {"field": "subject", "operator": "contains", "value": "urgent"}}}, {"field": "cc", "operator": "contains", "value": "lead@example.test"}, {"field": "attachment", "operator": "contains", "value": "plan.pdf"}, {"field": "size", "operator": "gte", "value": "10"}, {"field": "date", "operator": "after", "value": "2020-01-01"}},
|
||||
"actions": []map[string]string{{"type": "label", "value": "Priority"}, {"type": "move", "value": "Archive"}, {"type": "mark-read"}, {"type": "star"}},
|
||||
}
|
||||
if code := rcpt.do("POST", "/api/me/rules", rulePayload, &rule); code != http.StatusCreated {
|
||||
t.Fatalf("create rule code=%d rule=%+v", code, rule)
|
||||
}
|
||||
|
||||
senderClient := &testClient{t: t, server: ts}
|
||||
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("sender login=%d", code)
|
||||
}
|
||||
var sent MailMessage
|
||||
if code := senderClient.do("POST", "/api/mail/send", map[string]any{
|
||||
"to": []string{recipient.Address},
|
||||
"cc": []string{"lead@example.test"},
|
||||
"subject": "quarterly update",
|
||||
"text": "body",
|
||||
"attachments": []map[string]string{{"filename": "plan.pdf", "contentType": "application/pdf", "contentBase64": base64.StdEncoding.EncodeToString([]byte("rule attachment payload"))}},
|
||||
}, &sent); code != http.StatusCreated {
|
||||
t.Fatalf("send code=%d sent=%+v", code, sent)
|
||||
}
|
||||
|
||||
var archived struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 1 {
|
||||
t.Fatalf("archive list code=%d items=%+v", code, archived.Items)
|
||||
}
|
||||
msg := archived.Items[0]
|
||||
if !msg.IsRead || !msg.IsStarred {
|
||||
t.Fatalf("rule flags read=%v starred=%v", msg.IsRead, msg.IsStarred)
|
||||
}
|
||||
if len(msg.Labels) != 1 || msg.Labels[0].Name != "Priority" {
|
||||
t.Fatalf("labels=%+v, want Priority", msg.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailRulesMailboxIsolation(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d", code)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
owner := createTestMailbox(t, admin, domainID, "rule-owner", "Rule Owner", "Password123!", nil)
|
||||
other := createTestMailbox(t, admin, domainID, "rule-other", "Rule Other", "Password123!", nil)
|
||||
sender := createTestMailbox(t, admin, domainID, "rule-outsider", "Rule Outsider", "Password123!", nil)
|
||||
|
||||
ownerClient := &testClient{t: t, server: ts}
|
||||
if code := ownerClient.do("POST", "/api/auth/login", map[string]string{"email": owner.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("owner login=%d", code)
|
||||
}
|
||||
var denied map[string]any
|
||||
if code := ownerClient.do("POST", "/api/me/rules", map[string]any{"mailboxId": other.ID, "fromContains": sender.Address, "action": "archive"}, &denied); code != http.StatusNotFound {
|
||||
t.Fatalf("cross-mailbox rule create code=%d body=%v", code, denied)
|
||||
}
|
||||
var rule MailRule
|
||||
if code := ownerClient.do("POST", "/api/me/rules", map[string]any{"mailboxId": owner.ID, "fromContains": sender.Address, "action": "archive"}, &rule); code != http.StatusCreated {
|
||||
t.Fatalf("create owner rule code=%d rule=%+v", code, rule)
|
||||
}
|
||||
|
||||
senderClient := &testClient{t: t, server: ts}
|
||||
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("sender login=%d", code)
|
||||
}
|
||||
var sent MailMessage
|
||||
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{other.Address}, "subject": "isolation", "text": "body"}, &sent); code != http.StatusCreated {
|
||||
t.Fatalf("send to other code=%d sent=%+v", code, sent)
|
||||
}
|
||||
|
||||
otherClient := &testClient{t: t, server: ts}
|
||||
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": other.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("other login=%d", code)
|
||||
}
|
||||
var inbox struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := otherClient.do("GET", "/api/mail/messages?mailboxId="+other.ID+"&folder=Inbox", nil, &inbox); code != http.StatusOK || len(inbox.Items) != 1 {
|
||||
t.Fatalf("other inbox code=%d items=%+v", code, inbox.Items)
|
||||
}
|
||||
var archived struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := otherClient.do("GET", "/api/mail/messages?mailboxId="+other.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 0 {
|
||||
t.Fatalf("other archive code=%d items=%+v", code, archived.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedSenderMovesInboundToSpamAndIsolatesUsers(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d", code)
|
||||
}
|
||||
var domains struct {
|
||||
Items []Domain `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
|
||||
t.Fatalf("domains code=%d items=%+v", code, domains.Items)
|
||||
}
|
||||
sender := createTestMailbox(t, admin, domains.Items[0].ID, "blocked-sender", "Blocked Sender", "Password123!", nil)
|
||||
recipient := createTestMailbox(t, admin, domains.Items[0].ID, "blocked-recipient", "Blocked Recipient", "Password123!", nil)
|
||||
other := createTestMailbox(t, admin, domains.Items[0].ID, "blocked-other", "Blocked Other", "Password123!", nil)
|
||||
|
||||
recipientClient := &testClient{t: t, server: ts}
|
||||
if code := recipientClient.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("recipient login code=%d", code)
|
||||
}
|
||||
var blocked BlockedSender
|
||||
if code := recipientClient.do("POST", "/api/me/blocked-senders", map[string]any{"mailboxId": recipient.ID, "email": sender.Address, "reason": "test"}, &blocked); code != http.StatusCreated {
|
||||
t.Fatalf("blocked sender code=%d body=%+v", code, blocked)
|
||||
}
|
||||
|
||||
senderClient := &testClient{t: t, server: ts}
|
||||
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("sender login code=%d", code)
|
||||
}
|
||||
var sent MailMessage
|
||||
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": "blocked sender test", "text": "body"}, &sent); code != http.StatusCreated {
|
||||
t.Fatalf("send code=%d sent=%+v", code, sent)
|
||||
}
|
||||
|
||||
var inbox struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := recipientClient.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Inbox&q=blocked%20sender", nil, &inbox); code != http.StatusOK || len(inbox.Items) != 0 {
|
||||
t.Fatalf("recipient inbox code=%d items=%+v", code, inbox.Items)
|
||||
}
|
||||
var spam struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := recipientClient.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Spam&q=blocked%20sender", nil, &spam); code != http.StatusOK || len(spam.Items) != 1 {
|
||||
t.Fatalf("recipient spam code=%d items=%+v", code, spam.Items)
|
||||
}
|
||||
|
||||
otherClient := &testClient{t: t, server: ts}
|
||||
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": other.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("other login code=%d", code)
|
||||
}
|
||||
var denied map[string]any
|
||||
if code := otherClient.do("GET", "/api/mail/messages/"+spam.Items[0].ID+"?markRead=0", nil, &denied); code != http.StatusNotFound {
|
||||
t.Fatalf("other user should not read spam message code=%d body=%v", code, denied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleSendQueuesFutureMessage(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -1241,6 +1437,108 @@ func TestSendQueueAPIRetryAndCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSendAuditAccessAndFilters(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d", code)
|
||||
}
|
||||
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
otherMB := createTestMailbox(t, admin, domainID, "audit-other", "Audit Other", "Password123!", nil)
|
||||
now := a.now().UTC()
|
||||
ctx := context.Background()
|
||||
sentFolderID, err := a.ensureFolder(ctx, mb.ID, "Sent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
"msg_audit_one", mb.ID, sentFolderID, "", "uid-audit-one", "<audit-one@example.test>", "audit one", mb.Address, "", jsonEncode([]string{"one@example.test"}), "[]", "[]", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), "audit", "", "", 1, 0, 0, 0, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO send_queue(id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,status,next_attempt_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
"snd_audit_one", user.ID, mb.ID, "msg_audit_one", "<audit-one@example.test>", sendSourceWebmail, mb.Address, mb.Address, jsonEncode([]string{"one@example.test"}), "", sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events := []struct {
|
||||
id string
|
||||
queueID string
|
||||
mailboxID string
|
||||
sentMessageID string
|
||||
event string
|
||||
status string
|
||||
recipients []string
|
||||
errorText string
|
||||
createdAt time.Time
|
||||
}{
|
||||
{"audit_one", "snd_audit_one", mb.ID, "msg_audit_one", sendAuditQueued, sendQueueStatusQueued, []string{"one@example.test"}, "", now.Add(-2 * time.Hour)},
|
||||
{"audit_two", "snd_audit_one", mb.ID, "msg_audit_one", sendAuditFailed, sendQueueStatusFailed, []string{"one@example.test"}, "temporary failure", now.Add(-1 * time.Hour)},
|
||||
{"audit_other", "snd_audit_other", otherMB.ID, "", sendAuditDelivered, sendQueueStatusDelivered, []string{"two@example.test"}, "", now},
|
||||
}
|
||||
for _, item := range events {
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, item.id, item.queueID, user.ID, item.mailboxID, item.sentMessageID, sendSourceWebmail, item.event, item.status, mb.Address, mb.Address, jsonEncode(item.recipients), item.errorText, item.createdAt.Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if code := (&testClient{t: t, server: ts}).do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated send audit code=%d", code)
|
||||
}
|
||||
regular := &testClient{t: t, server: ts}
|
||||
if code := regular.do("POST", "/api/auth/login", map[string]string{"email": otherMB.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("regular login code=%d", code)
|
||||
}
|
||||
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("regular send audit code=%d", code)
|
||||
}
|
||||
updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
|
||||
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("admin access without messages permission code=%d", code)
|
||||
}
|
||||
|
||||
var all struct {
|
||||
Items []SendAuditEvent `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/send-audit", nil, &all); code != http.StatusOK || len(all.Items) != 3 {
|
||||
t.Fatalf("admin all audit code=%d items=%+v", code, all.Items)
|
||||
}
|
||||
var byMailbox struct {
|
||||
Items []SendAuditEvent `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/send-audit?mailboxId="+mb.ID, nil, &byMailbox); code != http.StatusOK || len(byMailbox.Items) != 2 {
|
||||
t.Fatalf("mailbox filter code=%d items=%+v", code, byMailbox.Items)
|
||||
}
|
||||
var byEvent struct {
|
||||
Items []SendAuditEvent `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/send-audit?event=failed", nil, &byEvent); code != http.StatusOK || len(byEvent.Items) != 1 || byEvent.Items[0].Error != "temporary failure" {
|
||||
t.Fatalf("event filter code=%d items=%+v", code, byEvent.Items)
|
||||
}
|
||||
var byMessage struct {
|
||||
Items []SendAuditEvent `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/send-audit?messageId="+url.QueryEscape("<audit-one@example.test>"), nil, &byMessage); code != http.StatusOK || len(byMessage.Items) != 2 {
|
||||
t.Fatalf("message filter code=%d items=%+v", code, byMessage.Items)
|
||||
}
|
||||
var byTime struct {
|
||||
Items []SendAuditEvent `json:"items"`
|
||||
}
|
||||
from := now.Add(-90 * time.Minute).Format(time.RFC3339Nano)
|
||||
if code := admin.do("GET", "/api/admin/send-audit?from="+url.QueryEscape(from), nil, &byTime); code != http.StatusOK || len(byTime.Items) != 2 {
|
||||
t.Fatalf("time filter code=%d items=%+v", code, byTime.Items)
|
||||
}
|
||||
if byEvent.Items[0].MailboxAddress != mb.Address || byEvent.Items[0].MessageID != "<audit-one@example.test>" {
|
||||
t.Fatalf("audit metadata missing: %+v", byEvent.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
user, mailbox, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
|
||||
@@ -2547,6 +2845,87 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaildirImportStoresAuthenticationResults(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
a.cfg.MaildirRoot = root
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
|
||||
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mailboxID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mailboxes, err := a.maildirMailboxes(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var admin maildirMailbox
|
||||
for _, mb := range mailboxes {
|
||||
if mb.Address == "admin@lanqin.local" {
|
||||
admin = mb
|
||||
break
|
||||
}
|
||||
}
|
||||
if admin.ID == "" {
|
||||
t.Fatal("admin mailbox not found")
|
||||
}
|
||||
dir := filepath.Join(root, admin.Domain, admin.LocalPart, "Maildir", "new")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := strings.Join([]string{
|
||||
"From: sender@example.test",
|
||||
"To: admin@lanqin.local",
|
||||
"Subject: auth import test",
|
||||
"Message-Id: <auth-import@example.test>",
|
||||
"Date: Sat, 13 Jun 2026 13:00:00 +0000",
|
||||
"Authentication-Results: mx.lanqin.local; spf=pass smtp.mailfrom=example.test; dkim=fail header.d=example.test; dmarc=temperror",
|
||||
"Received-SPF: pass (mx.lanqin.local: domain of sender@example.test designates 192.0.2.1 as permitted sender)",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"",
|
||||
"auth body",
|
||||
}, "\r\n")
|
||||
if err := os.WriteFile(filepath.Join(dir, "1749819601.M1P1.auth"), []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, err := a.syncMaildirOnce(ctx); err != nil || count != 1 {
|
||||
t.Fatalf("sync count=%d err=%v", count, err)
|
||||
}
|
||||
|
||||
client := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
var list struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := client.do("GET", "/api/mail/messages?folder=Inbox&q=auth%20import", nil, &list); code != http.StatusOK || len(list.Items) != 1 {
|
||||
t.Fatalf("list code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
var detail MailMessage
|
||||
if code := client.do("GET", "/api/mail/messages/"+list.Items[0].ID+"?markRead=0", nil, &detail); code != http.StatusOK {
|
||||
t.Fatalf("detail code=%d detail=%+v", code, detail)
|
||||
}
|
||||
if detail.Authentication.SPF != "pass" || detail.Authentication.DKIM != "fail" || detail.Authentication.DMARC != "temperror" {
|
||||
t.Fatalf("unexpected auth detail: %+v", detail.Authentication)
|
||||
}
|
||||
if !strings.Contains(detail.Authentication.AuthenticationResults, "spf=pass") || !strings.Contains(detail.Authentication.ReceivedSPF, "sender@example.test") {
|
||||
t.Fatalf("raw auth headers missing: %+v", detail.Authentication)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaildirSyncHealthDisabled(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -3202,6 +3581,112 @@ func TestMaildirSyncDeletesMissingMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxQuotaRejectsNewMessage(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||
clearMailboxMessagesForTest(t, a, mb.ID)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET quota_mb=1 WHERE id=?`, mb.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
||||
MailboxID: mb.ID,
|
||||
To: []string{"person@example.test"},
|
||||
Subject: "quota overflow",
|
||||
Text: strings.Repeat("x", 1024*1024+1),
|
||||
})
|
||||
if !errors.Is(err, errMailboxQuotaExceeded) {
|
||||
t.Fatalf("sendMailNow error=%v, want quota exceeded", err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
client := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
var errBody map[string]any
|
||||
if code := client.do("POST", "/api/mail/send", map[string]any{
|
||||
"mailboxId": mb.ID,
|
||||
"to": []string{"person@example.test"},
|
||||
"subject": "quota overflow api",
|
||||
"text": strings.Repeat("y", 1024*1024+1),
|
||||
}, &errBody); code != http.StatusInsufficientStorage {
|
||||
t.Fatalf("quota api code=%d body=%v", code, errBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
aliceMB := createTestMailbox(t, admin, domainID, "quota-alice", "Quota Alice", "Password123!", map[string]any{"quotaMb": 2})
|
||||
bobMB := createTestMailbox(t, admin, domainID, "quota-bob", "Quota Bob", "Password123!", map[string]any{"quotaMb": 2})
|
||||
aliceUser, _, err := a.userByEmail(ctx, aliceMB.Address)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobUser, _, err := a.userByEmail(ctx, bobMB.Address)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aliceTrash, err := a.ensureFolder(ctx, aliceMB.ID, "Trash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobTrash, err := a.ensureFolder(ctx, bobMB.ID, "Trash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attachment := AttachmentInput{Filename: "note.txt", ContentType: "text/plain", ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello attachment"))}
|
||||
if _, err := a.insertMessage(ctx, storedMessage{MailboxID: aliceMB.ID, FolderID: aliceTrash, MessageUID: newID("uid"), MessageID: "<alice-trash@example.test>", Subject: "alice trash", From: "sender@example.test", To: []string{aliceMB.Address}, SentAt: a.now().UTC(), ReceivedAt: a.now().UTC(), Snippet: "body", BodyText: "body"}, []AttachmentInput{attachment}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.insertMessage(ctx, storedMessage{MailboxID: bobMB.ID, FolderID: bobTrash, MessageUID: newID("uid"), MessageID: "<bob-trash@example.test>", Subject: "bob trash", From: "sender@example.test", To: []string{bobMB.Address}, SentAt: a.now().UTC(), ReceivedAt: a.now().UTC(), Snippet: "body", BodyText: "body"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
alice := &testClient{t: t, server: ts}
|
||||
if code := alice.do("POST", "/api/auth/login", map[string]string{"email": aliceMB.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("alice login code=%d", code)
|
||||
}
|
||||
var stats MailStats
|
||||
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID, nil, &stats); code != http.StatusOK {
|
||||
t.Fatalf("stats code=%d stats=%+v", code, stats)
|
||||
}
|
||||
if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
|
||||
t.Fatalf("stats quota/attachment not populated: %+v", stats)
|
||||
}
|
||||
var cleanup struct {
|
||||
OK bool `json:"ok"`
|
||||
Affected int64 `json:"affected"`
|
||||
}
|
||||
if code := alice.do("POST", "/api/me/cleanup", map[string]any{"mailboxId": aliceMB.ID, "target": "empty-trash"}, &cleanup); code != http.StatusOK || cleanup.Affected != 1 {
|
||||
t.Fatalf("cleanup code=%d body=%+v", code, cleanup)
|
||||
}
|
||||
var aliceRemaining, bobRemaining int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE mailbox_id=?`, aliceMB.ID).Scan(&aliceRemaining); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE mailbox_id=?`, bobMB.ID).Scan(&bobRemaining); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if aliceRemaining != 0 || bobRemaining != 1 {
|
||||
t.Fatalf("cleanup isolation alice=%d bob=%d, want 0/1", aliceRemaining, bobRemaining)
|
||||
}
|
||||
if aliceUser.ID == "" || bobUser.ID == "" {
|
||||
t.Fatal("test users were not created")
|
||||
}
|
||||
}
|
||||
|
||||
func mustDefaultDomainID(t *testing.T, a *App) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -28,25 +29,26 @@ type AttachmentInput struct {
|
||||
}
|
||||
|
||||
type storedMessage struct {
|
||||
MailboxID string
|
||||
FolderID string
|
||||
RecipientAddr string
|
||||
MessageUID string
|
||||
MessageID string
|
||||
Subject string
|
||||
From string
|
||||
FromName string
|
||||
To []string
|
||||
CC []string
|
||||
BCC []string
|
||||
SentAt time.Time
|
||||
ReceivedAt time.Time
|
||||
Snippet string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
RawPath string
|
||||
MailboxID string
|
||||
FolderID string
|
||||
RecipientAddr string
|
||||
MessageUID string
|
||||
MessageID string
|
||||
Subject string
|
||||
From string
|
||||
FromName string
|
||||
To []string
|
||||
CC []string
|
||||
BCC []string
|
||||
SentAt time.Time
|
||||
ReceivedAt time.Time
|
||||
Snippet string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
RawPath string
|
||||
Authentication MailAuthentication
|
||||
}
|
||||
|
||||
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -429,6 +431,10 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errMailboxQuotaExceeded) {
|
||||
respondError(w, http.StatusInsufficientStorage, err.Error())
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -440,6 +446,7 @@ var errInvalidMIME = errors.New("invalid mime message")
|
||||
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
|
||||
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
|
||||
var errSenderNotAuthorized = errors.New("sender address is not authorized")
|
||||
var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded")
|
||||
|
||||
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
|
||||
@@ -1580,7 +1587,7 @@ func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool
|
||||
}
|
||||
|
||||
func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes,COALESCE(m.auth_results,''),COALESCE(m.auth_spf,'unknown'),COALESCE(m.auth_dkim,'unknown'),COALESCE(m.auth_dmarc,'unknown'),COALESCE(m.received_spf,'')
|
||||
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
|
||||
msg, err := scanMessageFull(row, includeBody)
|
||||
if err != nil {
|
||||
@@ -1623,6 +1630,9 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
|
||||
size += int64(len(decoded))
|
||||
}
|
||||
}
|
||||
if err := a.ensureMailboxQuotaAvailable(ctx, db, msg.MailboxID, size); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var mailboxID, folderID any
|
||||
if strings.TrimSpace(msg.MailboxID) != "" {
|
||||
mailboxID = msg.MailboxID
|
||||
@@ -1639,8 +1649,9 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
|
||||
imapUID, imapModSeq = meta.UID, meta.ModSeq
|
||||
}
|
||||
recipientAddr := normalizeEmail(msg.RecipientAddr)
|
||||
_, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,imap_uid,imap_modseq,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, imapUID, imapModSeq, now, now)
|
||||
auth := normalizeMailAuthentication(msg.Authentication)
|
||||
_, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,auth_results,auth_spf,auth_dkim,auth_dmarc,received_spf,raw_path,imap_uid,imap_modseq,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, auth.AuthenticationResults, auth.SPF, auth.DKIM, auth.DMARC, auth.ReceivedSPF, msg.RawPath, imapUID, imapModSeq, now, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1653,6 +1664,33 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, mailboxID string, addBytes int64) error {
|
||||
mailboxID = strings.TrimSpace(mailboxID)
|
||||
if mailboxID == "" || addBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
rowDB, ok := db.(dbQueryer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var quotaMB int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan("aMB); err != nil {
|
||||
return err
|
||||
}
|
||||
if quotaMB <= 0 {
|
||||
return nil
|
||||
}
|
||||
var used int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
|
||||
return err
|
||||
}
|
||||
quotaBytes := quotaMB * 1024 * 1024
|
||||
if used+addBytes > quotaBytes {
|
||||
return fmt.Errorf("%w: used %d bytes, adding %d bytes exceeds %d bytes", errMailboxQuotaExceeded, used, addBytes, quotaBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error {
|
||||
return a.storeAttachmentWithDB(ctx, a.db, messageID, input)
|
||||
}
|
||||
@@ -1893,17 +1931,109 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
||||
func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) {
|
||||
var msg MailMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var auth MailAuthentication
|
||||
var read, starred, hasAtt int
|
||||
var bodyText, bodyHTML string
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes, &auth.AuthenticationResults, &auth.SPF, &auth.DKIM, &auth.DMARC, &auth.ReceivedSPF)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
|
||||
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
|
||||
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
|
||||
msg.Authentication = normalizeMailAuthentication(auth)
|
||||
if includeBody {
|
||||
msg.BodyText, msg.BodyHTML = bodyText, bodyHTML
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func parseMailAuthentication(header textproto.MIMEHeader) MailAuthentication {
|
||||
authResults := strings.Join(header.Values("Authentication-Results"), "\n")
|
||||
receivedSPF := strings.Join(header.Values("Received-SPF"), "\n")
|
||||
auth := MailAuthentication{
|
||||
AuthenticationResults: strings.TrimSpace(authResults),
|
||||
ReceivedSPF: strings.TrimSpace(receivedSPF),
|
||||
SPF: "unknown",
|
||||
DKIM: "unknown",
|
||||
DMARC: "unknown",
|
||||
}
|
||||
for _, value := range header.Values("Authentication-Results") {
|
||||
for _, field := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ';' || r == '\r' || r == '\n'
|
||||
}) {
|
||||
key, result, ok := authMethodResult(field)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "spf":
|
||||
auth.SPF = result
|
||||
case "dkim":
|
||||
auth.DKIM = result
|
||||
case "dmarc":
|
||||
auth.DMARC = result
|
||||
}
|
||||
}
|
||||
}
|
||||
if auth.SPF == "unknown" {
|
||||
for _, value := range header.Values("Received-SPF") {
|
||||
if result := firstAuthStatus(value); result != "unknown" {
|
||||
auth.SPF = result
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizeMailAuthentication(auth)
|
||||
}
|
||||
|
||||
func authMethodResult(value string) (string, string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(value, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
method := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
if method != "spf" && method != "dkim" && method != "dmarc" {
|
||||
return "", "", false
|
||||
}
|
||||
return method, firstAuthStatus(parts[1]), true
|
||||
}
|
||||
|
||||
func firstAuthStatus(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
value = strings.ToLower(strings.Fields(value)[0])
|
||||
if idx := strings.IndexAny(value, "();,"); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
switch value {
|
||||
case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none":
|
||||
return value
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMailAuthentication(auth MailAuthentication) MailAuthentication {
|
||||
auth.AuthenticationResults = strings.TrimSpace(auth.AuthenticationResults)
|
||||
auth.ReceivedSPF = strings.TrimSpace(auth.ReceivedSPF)
|
||||
auth.SPF = normalizeAuthStatus(auth.SPF)
|
||||
auth.DKIM = normalizeAuthStatus(auth.DKIM)
|
||||
auth.DMARC = normalizeAuthStatus(auth.DMARC)
|
||||
return auth
|
||||
}
|
||||
|
||||
func normalizeAuthStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,19 +641,20 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
receivedAt = sentAt
|
||||
}
|
||||
return storedMessage{
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
|
||||
}, parsed.Attachments, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
matchMode := strings.TrimSpace(req.MatchMode)
|
||||
if matchMode == "" {
|
||||
matchMode = "all"
|
||||
}
|
||||
if matchMode != "all" && matchMode != "any" {
|
||||
rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
|
||||
if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
|
||||
badRequest(w, errors.New("invalid match mode"))
|
||||
return
|
||||
}
|
||||
matchMode := normalizeRuleMatchMode(rawMatchMode)
|
||||
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
|
||||
if len(conditions) == 0 {
|
||||
badRequest(w, errors.New("rule condition is required"))
|
||||
@@ -639,10 +638,21 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load stats")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount); err != nil {
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
|
||||
return
|
||||
}
|
||||
if mailboxID != "" {
|
||||
var quotaMB int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
return
|
||||
}
|
||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||
if stats.QuotaBytes > 0 {
|
||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
||||
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...)
|
||||
@@ -853,9 +863,7 @@ func scanRule(row messageSummaryScanner) (MailRule, error) {
|
||||
if err == nil {
|
||||
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
|
||||
item.Actions = decodeRuleActions(actionsJSON, item.Action)
|
||||
if item.MatchMode == "" {
|
||||
item.MatchMode = "all"
|
||||
}
|
||||
item.MatchMode = normalizeRuleMatchMode(item.MatchMode)
|
||||
}
|
||||
item.ApplyToExisting = intBool(applyToExisting)
|
||||
item.StopProcessing = intBool(stopProcessing)
|
||||
@@ -877,13 +885,8 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
|
||||
return
|
||||
}
|
||||
from = normalizeEmail(from)
|
||||
var blocked int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
|
||||
if blocked > 0 {
|
||||
if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil {
|
||||
_ = a.moveMessageMaildir(ctx, messageID, spamID)
|
||||
}
|
||||
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
|
||||
@@ -899,26 +902,94 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
}
|
||||
rows.Close()
|
||||
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
msg = ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||
if rule.StopProcessing {
|
||||
return
|
||||
break
|
||||
}
|
||||
}
|
||||
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) senderBlocked(ctx context.Context, userID, mailboxID, from string) bool {
|
||||
from = normalizeEmail(from)
|
||||
if from == "" {
|
||||
return false
|
||||
}
|
||||
var blocked int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
|
||||
return blocked > 0
|
||||
}
|
||||
|
||||
func (a *App) moveBlockedMessageToSpam(ctx context.Context, messageID, mailboxID string) {
|
||||
spamID, err := a.ensureFolder(ctx, mailboxID, "Spam")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = a.moveMessageMaildir(ctx, messageID, spamID)
|
||||
}
|
||||
|
||||
type ruleMessage struct {
|
||||
ID string
|
||||
MailboxID string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Snippet string
|
||||
BodyText string
|
||||
ID string
|
||||
MailboxID string
|
||||
From string
|
||||
To string
|
||||
CC string
|
||||
Subject string
|
||||
Snippet string
|
||||
BodyText string
|
||||
AttachmentNames string
|
||||
SizeBytes int64
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||
var msg ruleMessage
|
||||
var toAddrs, ccAddrs, receivedAt string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||
if err != nil {
|
||||
return ruleMessage{}, false
|
||||
}
|
||||
msg.To = ruleAddressText(toAddrs)
|
||||
msg.CC = ruleAddressText(ccAddrs)
|
||||
msg.ReceivedAt = parseTime(receivedAt)
|
||||
msg.AttachmentNames = a.ruleAttachmentNames(ctx, messageID)
|
||||
return msg, true
|
||||
}
|
||||
|
||||
func ruleAddressText(raw string) string {
|
||||
var items []string
|
||||
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &items) == nil {
|
||||
return strings.Join(items, " ")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rows.Close()
|
||||
parts := []string{}
|
||||
for rows.Next() {
|
||||
var filename, contentType string
|
||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
parts = append(parts, filename, contentType)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
|
||||
@@ -932,26 +1003,56 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
|
||||
}
|
||||
out := []MailRuleCondition{}
|
||||
for _, item := range items {
|
||||
field := strings.TrimSpace(item.Field)
|
||||
operator := strings.TrimSpace(item.Operator)
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
if normalized, ok := normalizeRuleCondition(item); ok {
|
||||
out = append(out, normalized)
|
||||
}
|
||||
if field != "from" && field != "to" && field != "subject" && field != "body" {
|
||||
continue
|
||||
}
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
|
||||
continue
|
||||
}
|
||||
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
||||
matchMode := normalizeRuleMatchMode(item.MatchMode)
|
||||
if len(item.Conditions) > 0 {
|
||||
children := normalizeRuleConditions(item.Conditions, "", "")
|
||||
if len(children) == 0 {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{MatchMode: matchMode, Conditions: children}, true
|
||||
}
|
||||
field := strings.ToLower(strings.TrimSpace(item.Field))
|
||||
operator := strings.ToLower(strings.TrimSpace(item.Operator))
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
switch field {
|
||||
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
switch operator {
|
||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||
}
|
||||
|
||||
func normalizeRuleMatchMode(matchMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||
case "any", "or":
|
||||
return "any"
|
||||
case "all", "and":
|
||||
return "all"
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
|
||||
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
|
||||
items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)})
|
||||
@@ -1008,10 +1109,11 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
||||
if len(conditions) == 0 {
|
||||
return false
|
||||
}
|
||||
matchMode := rule.MatchMode
|
||||
if matchMode == "" {
|
||||
matchMode = "all"
|
||||
}
|
||||
matchMode := normalizeRuleMatchMode(rule.MatchMode)
|
||||
return ruleConditionsMatch(conditions, matchMode, msg)
|
||||
}
|
||||
|
||||
func ruleConditionsMatch(conditions []MailRuleCondition, matchMode string, msg ruleMessage) bool {
|
||||
matched := 0
|
||||
for _, condition := range conditions {
|
||||
if ruleConditionMatches(condition, msg) {
|
||||
@@ -1027,12 +1129,17 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
||||
}
|
||||
|
||||
func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
if len(condition.Conditions) > 0 {
|
||||
return ruleConditionsMatch(condition.Conditions, normalizeRuleMatchMode(condition.MatchMode), msg)
|
||||
}
|
||||
var source string
|
||||
switch condition.Field {
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Field)) {
|
||||
case "from":
|
||||
source = msg.From
|
||||
case "to":
|
||||
source = msg.To
|
||||
case "cc":
|
||||
source = msg.CC
|
||||
case "subject":
|
||||
source = msg.Subject
|
||||
case "body":
|
||||
@@ -1040,12 +1147,18 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
if source == "" {
|
||||
source = msg.Snippet
|
||||
}
|
||||
case "attachment":
|
||||
source = msg.AttachmentNames
|
||||
case "size":
|
||||
return ruleNumericConditionMatches(condition, msg.SizeBytes)
|
||||
case "date":
|
||||
return ruleDateConditionMatches(condition, msg.ReceivedAt)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
source = strings.ToLower(source)
|
||||
value := strings.ToLower(condition.Value)
|
||||
switch condition.Operator {
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "contains":
|
||||
return strings.Contains(source, value)
|
||||
case "not-contains":
|
||||
@@ -1063,6 +1176,94 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func ruleNumericConditionMatches(condition MailRuleCondition, source int64) bool {
|
||||
value, ok := parseRuleSizeValue(condition.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "gt":
|
||||
return source > value
|
||||
case "gte":
|
||||
return source >= value
|
||||
case "lt":
|
||||
return source < value
|
||||
case "lte":
|
||||
return source <= value
|
||||
case "equals":
|
||||
return source == value
|
||||
case "not-equals":
|
||||
return source != value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuleSizeValue(raw string) (int64, bool) {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
multiplier := int64(1)
|
||||
for _, suffix := range []struct {
|
||||
text string
|
||||
multiplier int64
|
||||
}{
|
||||
{"kb", 1024},
|
||||
{"k", 1024},
|
||||
{"mb", 1024 * 1024},
|
||||
{"m", 1024 * 1024},
|
||||
{"gb", 1024 * 1024 * 1024},
|
||||
{"g", 1024 * 1024 * 1024},
|
||||
{"b", 1},
|
||||
} {
|
||||
if strings.HasSuffix(value, suffix.text) {
|
||||
multiplier = suffix.multiplier
|
||||
value = strings.TrimSpace(strings.TrimSuffix(value, suffix.text))
|
||||
break
|
||||
}
|
||||
}
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n * multiplier, true
|
||||
}
|
||||
|
||||
func ruleDateConditionMatches(condition MailRuleCondition, source time.Time) bool {
|
||||
if source.IsZero() {
|
||||
return false
|
||||
}
|
||||
target, ok := parseRuleDateValue(condition.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
source = source.UTC()
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "before", "lt":
|
||||
return source.Before(target)
|
||||
case "after", "gt":
|
||||
return source.After(target)
|
||||
case "on", "equals":
|
||||
y1, m1, d1 := source.Date()
|
||||
y2, m2, d2 := target.Date()
|
||||
return y1 == y2 && m1 == m2 && d1 == d2
|
||||
case "not-equals":
|
||||
y1, m1, d1 := source.Date()
|
||||
y2, m2, d2 := target.Date()
|
||||
return y1 != y2 || m1 != m2 || d1 != d2
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuleDateValue(raw string) (time.Time, bool) {
|
||||
value := strings.TrimSpace(raw)
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, action := range normalizeRuleActions(actions, "") {
|
||||
@@ -1165,19 +1366,21 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
where += ` AND m.mailbox_id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,trim(m.from_addr || ' ' || COALESCE(m.from_name,'')),m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
messages := []ruleMessage{}
|
||||
var count int64
|
||||
for rows.Next() {
|
||||
var msg ruleMessage
|
||||
var toAddrs sql.NullString
|
||||
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
|
||||
var messageID string
|
||||
if err := rows.Scan(&messageID); err != nil {
|
||||
return count, err
|
||||
}
|
||||
msg.To = toAddrs.String
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/send-audit", a.handleAdminSendAudit)
|
||||
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||
|
||||
@@ -76,34 +76,43 @@ type MailLabel struct {
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
}
|
||||
|
||||
type MailAuthentication struct {
|
||||
AuthenticationResults string `json:"authenticationResults"`
|
||||
ReceivedSPF string `json:"receivedSpf"`
|
||||
SPF string `json:"spf"`
|
||||
DKIM string `json:"dkim"`
|
||||
DMARC string `json:"dmarc"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
@@ -173,9 +182,11 @@ type MailRule struct {
|
||||
}
|
||||
|
||||
type MailRuleCondition struct {
|
||||
Field string `json:"field"`
|
||||
Operator string `json:"operator"`
|
||||
Value string `json:"value"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
MatchMode string `json:"matchMode,omitempty"`
|
||||
Conditions []MailRuleCondition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
type MailRuleAction struct {
|
||||
@@ -198,7 +209,10 @@ type MailStats struct {
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StarredMessages int64 `json:"starredMessages"`
|
||||
AttachmentCount int64 `json:"attachmentCount"`
|
||||
AttachmentBytes int64 `json:"attachmentBytes"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
QuotaBytes int64 `json:"quotaBytes"`
|
||||
QuotaUsedPct float64 `json:"quotaUsedPct"`
|
||||
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
||||
}
|
||||
|
||||
@@ -231,16 +245,18 @@ type SendQueueEntry struct {
|
||||
}
|
||||
|
||||
type SendAuditEvent struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
Source string `json:"source"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user