Compare commits

...

8 Commits

Author SHA1 Message Date
LanQin_ 2a572bf13a feat(mail): 增强邮件认证与审计能力
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 新增邮件认证结果解析、存储与前端展示,支持查看 SPF、DKIM、DMARC 及原始头信息。
- 扩展邮件规则条件,支持抄送、附件、大小、日期及嵌套条件组合。
- 增加邮箱容量统计与发送前配额校验,超限时返回明确错误。
- 新增管理端发送审计页面与接口,支持按邮箱、事件、Message-ID 和时间筛选。
2026-06-24 17:16:01 +08:00
LanQin_ 2b846ac671 feat(mail): 增加发送队列管理
- 新增发送队列与投递时间线接口,支持查看、筛选、重试和取消发送任务。
- 扩展邮箱页面,加入发送队列入口、状态筛选和时间线弹窗。
- 更新发送队列状态流转,支持 `canceled` 并补充相关测试与前端类型定义。
2026-06-24 16:39:40 +08:00
LanQin_ b3669f189e feat(imap): 补充邮件 IMAP 元数据支持
- 为文件夹和消息增加 `UID/ModSeq` 字段,并在迁移时回填与建索引。
- 让收发、移动、标记、删除、草稿等流程同步维护 `imap_uid` 和 `imap_modseq`。
- 扩展后端与前端类型,并补充相关测试覆盖。
2026-06-24 16:23:45 +08:00
LanQin_ d28ed4adcc feat(admin): 增加 Maildir 同步健康状态
- 新增后端健康检查接口与同步追踪,记录运行状态、最近结果、错误摘要和统计信息。
- 前端管理页在存储设置中展示 Maildir 同步健康卡片,并支持手动刷新。
- 补充相关类型定义与测试,覆盖未配置和同步成功两类场景。
2026-06-24 16:08:48 +08:00
LanQin_ b36adfdac2 fix(maildir): 同步 IMAP 移动、标记与删除状态
- 让 maildir 同步在消息移动到其他文件夹时更新 `folder_id` 和 `raw_path`。
- 根据文件名中的 maildir 标记同步已读、星标状态。
- 增加缺失消息清理,避免数据库残留已被删除的 maildir 邮件。
- 补充回归测试覆盖移动、重复副本、标记更新和删除场景。
2026-06-24 15:46:17 +08:00
LanQin_ 632a8a4896 fix(maildir): 同步邮件已读状态到 maildir 标记
- 读取邮件时同步更新 maildir 的 `S` 标记,避免数据库已读状态与文件名不同步。
- 写入邮件到 `maildir` 时保留已有非托管标记,并确保已读/星标标记正确拼接。
- 补充测试,校验已发送邮件和草稿的原始路径包含已读标记。
2026-06-24 15:33:56 +08:00
LanQin_ f8d058f7e4 fix(maildir): 同步消息标记到 Maildir
- 标记已读、加星时同步重写 `raw_path`,保持数据库与文件系统状态一致。
- 新增 Maildir 标记路径计算与迁移逻辑,支持读/星标变化后调整目录和文件名。
- 补充测试覆盖已读、未读、加星场景的 Maildir 路径变化。
2026-06-24 15:27:30 +08:00
LanQin_ 1788d49a59 feat(maildir): 支持邮件写入与同步联动
- 为已发送邮件、草稿、收件与规则移动增加 maildir 文件写入、重写和删除流程。
- 补充 SQLite 仅有消息的回填逻辑,并在同步时补齐现有 raw_path。
- 调整 Dovecot 共享邮箱行为为订阅方式,避免重复创建文件夹。
- 新增覆盖发送、草稿、移动、删除和回填场景的测试。
2026-06-24 15:18:06 +08:00
19 changed files with 4361 additions and 232 deletions
+113 -3
View File
@@ -715,7 +715,7 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
} }
rows.Close() rows.Close()
for _, messageID := range messageIDs { for _, messageID := range messageIDs {
a.deleteMessageFiles(r.Context(), messageID) a.deleteMessage(r.Context(), messageID)
} }
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id) res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
if err != nil { if err != nil {
@@ -786,7 +786,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
} }
args = append(args, limit+1, offset) args = append(args, limit+1, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,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.is_read,m.is_starred,m.has_attachments,m.size_bytes rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),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.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m FROM messages m
LEFT JOIN folders f ON f.id=m.folder_id LEFT JOIN folders f ON f.id=m.folder_id
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
@@ -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"`
@@ -1039,6 +1149,6 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
} }
role := strings.ToLower(folder) role := strings.ToLower(folder)
id = newID("fld") id = newID("fld")
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, a.now().UTC().Format(time.RFC3339Nano)) _, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano))
return id, err return id, err
} }
+67 -9
View File
@@ -22,12 +22,13 @@ import (
) )
type App struct { type App struct {
cfg Config cfg Config
db *sql.DB db *sql.DB
log *slog.Logger log *slog.Logger
now func() time.Time now func() time.Time
policy *HTMLPolicy policy *HTMLPolicy
workerCancel context.CancelFunc workerCancel context.CancelFunc
maildirHealth *maildirSyncHealthTracker
} }
func New(cfg Config, logger *slog.Logger) (*App, error) { func New(cfg Config, logger *slog.Logger) (*App, error) {
@@ -47,7 +48,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
} }
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy()} a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
if err := a.configureSQLite(context.Background()); err != nil { if err := a.configureSQLite(context.Background()); err != nil {
db.Close() db.Close()
return nil, err return nil, err
@@ -201,6 +202,9 @@ func (a *App) migrate(ctx context.Context) error {
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
uid_validity INTEGER NOT NULL DEFAULT 0,
uid_next INTEGER NOT NULL DEFAULT 1,
highest_modseq INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(mailbox_id, name) UNIQUE(mailbox_id, name)
)`, )`,
@@ -226,7 +230,14 @@ 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_modseq INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
)`, )`,
@@ -413,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
} }
@@ -428,12 +442,56 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateSendQueueMessageID(ctx); err != nil { if err := a.migrateSendQueueMessageID(ctx); err != nil {
return err return err
} }
if err := a.migrateIMAPMetadata(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil { if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err return err
} }
return nil return nil
} }
func (a *App) migrateMessageAuthentication(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
return err
}
defer rows.Close()
columns := map[string]bool{}
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
return err
}
columns[name] = true
}
if err := rows.Err(); err != nil {
return err
}
alter := []struct {
name string
sql string
}{
{"auth_results", `ALTER TABLE messages ADD COLUMN auth_results TEXT NOT NULL DEFAULT ''`},
{"auth_spf", `ALTER TABLE messages ADD COLUMN auth_spf TEXT NOT NULL DEFAULT 'unknown'`},
{"auth_dkim", `ALTER TABLE messages ADD COLUMN auth_dkim TEXT NOT NULL DEFAULT 'unknown'`},
{"auth_dmarc", `ALTER TABLE messages ADD COLUMN auth_dmarc TEXT NOT NULL DEFAULT 'unknown'`},
{"received_spf", `ALTER TABLE messages ADD COLUMN received_spf TEXT NOT NULL DEFAULT ''`},
}
for _, item := range alter {
if !columns[item.name] {
if _, err := a.db.ExecContext(ctx, item.sql); err != nil {
return err
}
}
}
return nil
}
func (a *App) migrateSendQueueMessageID(ctx context.Context) error { func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`) rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
if err != nil { if err != nil {
@@ -582,7 +640,7 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
return err return err
} }
for _, messageID := range messageIDs { for _, messageID := range messageIDs {
a.deleteMessageFiles(ctx, messageID) a.deleteMessage(ctx, messageID)
} }
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil { if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
return err return err
@@ -1029,7 +1087,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
return "", err return "", err
} }
for _, f := range defaultFolderDefs() { for _, f := range defaultFolderDefs() {
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, newID("fld"), id, f.name, f.role, now) _, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, a.newUIDValidity(), 1, 1, now)
if err != nil { if err != nil {
return "", err return "", err
} }
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
package app
import (
"context"
"database/sql"
"math"
"time"
)
type imapMetadata struct {
UID int64
ModSeq int64
}
func (a *App) migrateIMAPMetadata(ctx context.Context) error {
if err := a.ensureTableColumn(ctx, "folders", "uid_validity", `ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "folders", "uid_next", `ALTER TABLE folders ADD COLUMN uid_next INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "folders", "highest_modseq", `ALTER TABLE folders ADD COLUMN highest_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "messages", "imap_uid", `ALTER TABLE messages ADD COLUMN imap_uid INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "messages", "imap_modseq", `ALTER TABLE messages ADD COLUMN imap_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_validity=? WHERE uid_validity=0`, a.newUIDValidity()); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_next=1 WHERE uid_next<1`); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET highest_modseq=1 WHERE highest_modseq<1`); err != nil {
return err
}
if err := a.backfillIMAPUIDs(ctx); err != nil {
return err
}
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_folder_imap_uid ON messages(folder_id, imap_uid) WHERE folder_id IS NOT NULL AND imap_uid > 0`)
return err
}
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
return err
}
if name == column {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, alterSQL)
return err
}
func (a *App) backfillIMAPUIDs(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders ORDER BY created_at,id`)
if err != nil {
return err
}
var folderIDs []string
for rows.Next() {
var folderID string
if err := rows.Scan(&folderID); err != nil {
rows.Close()
return err
}
folderIDs = append(folderIDs, folderID)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, folderID := range folderIDs {
if err := a.backfillFolderIMAPUIDs(ctx, folderID); err != nil {
return err
}
}
return nil
}
func (a *App) backfillFolderIMAPUIDs(ctx context.Context, folderID string) error {
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE folder_id=? AND imap_uid=0 ORDER BY created_at,id`, folderID)
if err != nil {
return err
}
var messageIDs []string
for rows.Next() {
var messageID string
if err := rows.Scan(&messageID); err != nil {
rows.Close()
return err
}
messageIDs = append(messageIDs, messageID)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, messageID := range messageIDs {
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET imap_uid=?,imap_modseq=? WHERE id=?`, meta.UID, meta.ModSeq, messageID); err != nil {
return err
}
}
var maxUID, maxModSeq int64
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(imap_uid),0),COALESCE(MAX(imap_modseq),1) FROM messages WHERE folder_id=?`, folderID).Scan(&maxUID, &maxModSeq); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE folders SET uid_next=MAX(uid_next,?),highest_modseq=MAX(highest_modseq,?) WHERE id=?`, maxUID+1, maxModSeq, folderID)
return err
}
func (a *App) newUIDValidity() int64 {
value := a.now().UTC().Unix()
if value <= 0 {
return time.Now().UTC().Unix()
}
return value
}
func (a *App) nextIMAPMetadata(ctx context.Context, db dbExecutor, folderID string) (imapMetadata, error) {
if folderID == "" {
return imapMetadata{}, nil
}
rowDB, ok := db.(dbQueryer)
if !ok {
return imapMetadata{}, nil
}
var nextUID, highestModSeq int64
err := rowDB.QueryRowContext(ctx, `SELECT uid_next,highest_modseq FROM folders WHERE id=?`, folderID).Scan(&nextUID, &highestModSeq)
if err != nil {
return imapMetadata{}, err
}
if nextUID < 1 {
nextUID = 1
}
nextModSeq := highestModSeq + 1
if nextModSeq < 1 {
nextModSeq = 1
}
if _, err := db.ExecContext(ctx, `UPDATE folders SET uid_next=?,highest_modseq=MAX(highest_modseq,?) WHERE id=?`, nextUID+1, nextModSeq, folderID); err != nil {
return imapMetadata{}, err
}
return imapMetadata{UID: nextUID, ModSeq: nextModSeq}, nil
}
func (a *App) bumpFolderModSeq(ctx context.Context, folderID string) (int64, error) {
return a.bumpFolderModSeqWithDB(ctx, a.db, folderID)
}
func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderID string) (int64, error) {
if folderID == "" {
return 0, nil
}
rowDB, ok := db.(dbQueryer)
if !ok {
return 0, nil
}
var current int64
if err := rowDB.QueryRowContext(ctx, `SELECT highest_modseq FROM folders WHERE id=?`, folderID).Scan(&current); err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, err
}
next := current + 1
if next < 1 || next == math.MaxInt64 {
next = current
}
if _, err := db.ExecContext(ctx, `UPDATE folders SET highest_modseq=MAX(highest_modseq,?) WHERE id=?`, next, folderID); err != nil {
return 0, err
}
return next, nil
}
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
var folderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
return err
}
if !folderID.Valid || folderID.String == "" {
return nil
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
if err != nil {
return err
}
if modSeq == 0 {
return nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return err
}
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" {
var dbFolderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&dbFolderID); err != nil {
return 0, err
}
if !dbFolderID.Valid || dbFolderID.String == "" {
return 0, nil
}
folderID = dbFolderID.String
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID)
if err != nil {
return 0, err
}
if modSeq == 0 {
return 0, nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return modSeq, err
}
+448 -40
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/textproto"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -28,25 +29,26 @@ type AttachmentInput struct {
} }
type storedMessage struct { type storedMessage struct {
MailboxID string MailboxID string
FolderID string FolderID string
RecipientAddr string RecipientAddr string
MessageUID string MessageUID string
MessageID string MessageID string
Subject string Subject string
From string From string
FromName string FromName string
To []string To []string
CC []string CC []string
BCC []string BCC []string
SentAt time.Time SentAt time.Time
ReceivedAt time.Time ReceivedAt time.Time
Snippet string Snippet string
BodyText string BodyText string
BodyHTML string BodyHTML string
IsRead bool IsRead bool
IsStarred bool IsStarred bool
RawPath string RawPath string
Authentication MailAuthentication
} }
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) { func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
@@ -84,7 +86,8 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
} }
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role, rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total COUNT(m.id) AS total,
f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role WHERE f.mailbox_id=? 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, f.name`, mb.ID) 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, f.name`, mb.ID)
@@ -96,7 +99,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
items := []MailFolder{} items := []MailFolder{}
for rows.Next() { for rows.Next() {
var f MailFolder var f MailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount); err != nil { if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders") respondError(w, http.StatusInternalServerError, "failed to scan folders")
return return
} }
@@ -154,7 +157,7 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
args = append(args, like, like, like, like, like) args = append(args, like, like, like, like, like)
} }
args = append(args, limit+1, offset) args = append(args, limit+1, offset)
query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,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.is_read,m.is_starred,m.has_attachments,m.size_bytes query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),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.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE ` + where + ` ORDER BY m.received_at DESC LIMIT ? OFFSET ?` FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE ` + where + ` ORDER BY m.received_at DESC LIMIT ? OFFSET ?`
rows, err := a.db.QueryContext(r.Context(), query, args...) rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil { if err != nil {
@@ -328,7 +331,15 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
return return
} }
if r.URL.Query().Get("markRead") != "0" && !msg.IsRead && userHasPermission(currentUser(r), PermissionMailOrganize) { if r.URL.Query().Get("markRead") != "0" && !msg.IsRead && userHasPermission(currentUser(r), PermissionMailOrganize) {
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID) read := true
if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil {
a.log.Warn("failed to update maildir read flag", "message_id", msg.ID, "error", err)
}
if modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID); err == nil && modSeq > 0 {
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, imap_modseq=?, updated_at=? WHERE id=?`, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
} else {
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
}
msg.IsRead = true msg.IsRead = true
} }
respondJSON(w, http.StatusOK, msg) respondJSON(w, http.StatusOK, msg)
@@ -420,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
} }
@@ -431,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 {
@@ -480,6 +496,10 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to store sent message: %w", err) return nil, fmt.Errorf("failed to store sent message: %w", err)
} }
if err := a.writeRawMessageToMaildir(ctx, sentID, mimeBytes, false); err != nil {
a.deleteMessage(ctx, sentID)
return nil, fmt.Errorf("failed to store sent message in maildir: %w", err)
}
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients}) a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients})
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil { if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil {
a.deleteMessage(ctx, sentID) a.deleteMessage(ctx, sentID)
@@ -503,7 +523,9 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
copyMsg.RecipientAddr = normalizeEmail(rcpt) copyMsg.RecipientAddr = normalizeEmail(rcpt)
copyMsg.MessageUID = newID("uid") copyMsg.MessageUID = newID("uid")
copyMsg.IsRead = false copyMsg.IsRead = false
_, _ = a.insertMessage(ctx, copyMsg, req.Attachments) if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
}
continue continue
} }
if rcptMailbox.Status != "active" { if rcptMailbox.Status != "active" {
@@ -514,7 +536,9 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
copyMsg.RecipientAddr = normalizeEmail(rcpt) copyMsg.RecipientAddr = normalizeEmail(rcpt)
copyMsg.MessageUID = newID("uid") copyMsg.MessageUID = newID("uid")
copyMsg.IsRead = false copyMsg.IsRead = false
_, _ = a.insertMessage(ctx, copyMsg, req.Attachments) if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
}
} }
continue continue
} }
@@ -528,6 +552,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
copyMsg.MessageUID = newID("uid") copyMsg.MessageUID = newID("uid")
copyMsg.IsRead = false copyMsg.IsRead = false
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject) a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
} }
} }
@@ -767,6 +792,11 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to save draft") respondError(w, http.StatusInternalServerError, "failed to save draft")
return return
} }
if err := a.writeStoredMessageToMaildir(r.Context(), draftID, stored, attachments); err != nil {
a.deleteMessage(r.Context(), draftID)
respondError(w, http.StatusInternalServerError, "failed to save draft")
return
}
msg, _ := a.messageByID(r.Context(), draftID, true) msg, _ := a.messageByID(r.Context(), draftID, true)
respondJSON(w, http.StatusCreated, msg) respondJSON(w, http.StatusCreated, msg)
return return
@@ -796,8 +826,13 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
_ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?`, draftID).Scan(&attachmentBytes) _ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?`, draftID).Scan(&attachmentBytes)
size += attachmentBytes size += attachmentBytes
} }
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,updated_at=? WHERE id=?`, modSeq, err := a.updateMessageModSeq(r.Context(), draftID, existing.FolderID)
subject, jsonEncode(compose.To), jsonEncode(compose.CC), jsonEncode(compose.BCC), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), snippetFrom(compose.Text, compose.HTML), compose.Text, compose.HTML, boolInt(hasAttachments), size, now.Format(time.RFC3339Nano), draftID) if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update draft")
return
}
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
subject, jsonEncode(compose.To), jsonEncode(compose.CC), jsonEncode(compose.BCC), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), snippetFrom(compose.Text, compose.HTML), compose.Text, compose.HTML, boolInt(hasAttachments), size, modSeq, modSeq, now.Format(time.RFC3339Nano), draftID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update draft") respondError(w, http.StatusInternalServerError, "failed to update draft")
return return
@@ -810,6 +845,10 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
if err := a.rewriteMessageMaildir(r.Context(), draftID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update draft")
return
}
msg, _ := a.messageByID(r.Context(), draftID, true) msg, _ := a.messageByID(r.Context(), draftID, true)
respondJSON(w, http.StatusOK, msg) respondJSON(w, http.StatusOK, msg)
} }
@@ -820,11 +859,13 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "draft not found") respondError(w, http.StatusNotFound, "draft not found")
return return
} }
a.deleteMessageMaildirFile(r.Context(), msg.ID)
a.deleteMessageFiles(r.Context(), msg.ID) a.deleteMessageFiles(r.Context(), msg.ID)
if _, err := a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID); err != nil { if _, err := a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete draft") respondError(w, http.StatusInternalServerError, "failed to delete draft")
return return
} }
_, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID)
respondJSON(w, http.StatusOK, map[string]any{"ok": true}) respondJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
@@ -873,6 +914,214 @@ func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"items": items}) respondJSON(w, http.StatusOK, map[string]any{"items": items})
} }
func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
status := strings.TrimSpace(r.URL.Query().Get("status"))
cursor, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if cursor < 0 {
cursor = 0
}
limit := 30
args := []any{user.ID, mb.ID}
where := `mb.user_id=? AND sq.mailbox_id=?`
if status != "" {
if !validSendQueueStatus(status) {
badRequest(w, errors.New("invalid send queue status"))
return
}
where += ` AND sq.status=?`
args = append(args, status)
}
args = append(args, limit+1, cursor)
rows, err := a.db.QueryContext(r.Context(), `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE `+where+` ORDER BY sq.created_at DESC, sq.id DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send queue")
return
}
defer rows.Close()
items := []SendQueueEntry{}
for rows.Next() {
item, err := scanSendQueueEntry(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan send queue")
return
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send queue")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
next = strconv.Itoa(cursor + limit)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func (a *App) handleSendQueueAudit(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := strings.TrimSpace(chi.URLParam(r, "id"))
if !a.sendQueueBelongsToUser(r.Context(), id, user.ID) {
respondError(w, http.StatusNotFound, "send queue item not found")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at
FROM send_audit_events WHERE queue_id=? ORDER BY created_at ASC, id ASC`, id)
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.SentMessageID, &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
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleRetrySendQueue(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := strings.TrimSpace(chi.URLParam(r, "id"))
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
if err != nil {
respondError(w, http.StatusNotFound, "send queue item not found")
return
}
if item.Status != sendQueueStatusFailed {
badRequest(w, errors.New("send queue item is not failed"))
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=? AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`,
sendQueueStatusQueued, now, now, id, sendQueueStatusFailed, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to retry send queue item")
return
}
if n, _ := res.RowsAffected(); n == 0 {
respondError(w, http.StatusNotFound, "send queue item not found")
return
}
a.deleteSendQueueDeliveredMarker(id)
a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInput{
QueueID: item.ID,
UserID: user.ID,
MailboxID: item.MailboxID,
SentMessageID: item.SentMessageID,
Source: item.Source,
MailFrom: item.MailFrom,
HeaderFrom: item.HeaderFrom,
Recipients: item.Recipients,
})
updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send queue item")
return
}
respondJSON(w, http.StatusOK, updated)
}
func (a *App) handleCancelSendQueue(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := strings.TrimSpace(chi.URLParam(r, "id"))
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
if err != nil {
respondError(w, http.StatusNotFound, "send queue item not found")
return
}
if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed {
badRequest(w, errors.New("send queue item cannot be canceled"))
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?) AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`,
sendQueueStatusCanceled, now, id, sendQueueStatusQueued, sendQueueStatusFailed, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to cancel send queue item")
return
}
if n, _ := res.RowsAffected(); n == 0 {
respondError(w, http.StatusNotFound, "send queue item not found")
return
}
a.deleteSendQueueDeliveredMarker(id)
a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInput{
QueueID: item.ID,
UserID: user.ID,
MailboxID: item.MailboxID,
SentMessageID: item.SentMessageID,
Source: item.Source,
MailFrom: item.MailFrom,
HeaderFrom: item.HeaderFrom,
Recipients: item.Recipients,
})
updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send queue item")
return
}
respondJSON(w, http.StatusOK, updated)
}
type sendQueueEntryScanner interface{ Scan(dest ...any) error }
func scanSendQueueEntry(row sendQueueEntryScanner) (SendQueueEntry, error) {
var item SendQueueEntry
var recipientsJSON, nextAttemptAt, createdAt, updatedAt string
var deliveredAt sql.NullString
err := row.Scan(&item.ID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Subject, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Status, &item.AttemptCount, &item.MaxAttempts, &nextAttemptAt, &item.LastError, &createdAt, &updatedAt, &deliveredAt)
if err != nil {
return item, err
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
item.NextAttemptAt = parseTime(nextAttemptAt)
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
item.DeliveredAt = nullableTime(deliveredAt)
return item, nil
}
func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string) (SendQueueEntry, error) {
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE sq.id=? AND mb.user_id=?`, id, userID)
return scanSendQueueEntry(row)
}
func (a *App) sendQueueBelongsToUser(ctx context.Context, id, userID string) bool {
var count int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id WHERE sq.id=? AND mb.user_id=?`, id, userID).Scan(&count)
return count > 0
}
func validSendQueueStatus(status string) bool {
switch status {
case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled:
return true
default:
return false
}
}
func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) { func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
MailboxID string `json:"mailboxId"` MailboxID string `json:"mailboxId"`
@@ -1081,8 +1330,7 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
return return
} }
if draftID != "" { if draftID != "" {
a.deleteMessageFiles(ctx, draftID) a.deleteMessage(ctx, draftID)
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, draftID)
} }
sentAt := a.now().UTC().Format(time.RFC3339Nano) sentAt := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='sent',sent_at=?,updated_at=?,error='' WHERE id=?`, sentAt, sentAt, id); err != nil { if _, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='sent',sent_at=?,updated_at=?,error='' WHERE id=?`, sentAt, sentAt, id); err != nil {
@@ -1120,7 +1368,16 @@ func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) {
if req.Read != nil { if req.Read != nil {
read = *req.Read read = *req.Read
} }
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, updated_at=? WHERE id=?`, boolInt(read), a.now().UTC().Format(time.RFC3339Nano), msg.ID) if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message")
return
}
modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message")
return
}
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(read), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message") respondError(w, http.StatusInternalServerError, "failed to update message")
return return
@@ -1142,7 +1399,16 @@ func (a *App) handleStar(w http.ResponseWriter, r *http.Request) {
if req.Starred != nil { if req.Starred != nil {
starred = *req.Starred starred = *req.Starred
} }
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, updated_at=? WHERE id=?`, boolInt(starred), a.now().UTC().Format(time.RFC3339Nano), msg.ID) if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, nil, &starred); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message")
return
}
modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message")
return
}
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(starred), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message") respondError(w, http.StatusInternalServerError, "failed to update message")
return return
@@ -1168,8 +1434,7 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load folder") respondError(w, http.StatusInternalServerError, "failed to load folder")
return return
} }
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), msg.ID) if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to move message") respondError(w, http.StatusInternalServerError, "failed to move message")
return return
} }
@@ -1183,14 +1448,20 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
return return
} }
if strings.EqualFold(msg.Folder, "Trash") { if strings.EqualFold(msg.Folder, "Trash") {
a.deleteMessageMaildirFile(r.Context(), msg.ID)
a.deleteMessageFiles(r.Context(), msg.ID) a.deleteMessageFiles(r.Context(), msg.ID)
_, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID) _, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID)
if err == nil {
_, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID)
}
} else { } else {
trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash") trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash")
if e != nil { if e != nil {
err = e err = e
} else { } else {
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, trashID, a.now().UTC().Format(time.RFC3339Nano), msg.ID) if e := a.moveMessageMaildir(r.Context(), msg.ID, trashID); e != nil {
err = e
}
} }
} }
if err != nil { if err != nil {
@@ -1316,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.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 {
@@ -1359,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
@@ -1366,9 +1640,18 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
if strings.TrimSpace(msg.FolderID) != "" { if strings.TrimSpace(msg.FolderID) != "" {
folderID = msg.FolderID folderID = msg.FolderID
} }
imapUID, imapModSeq := int64(0), int64(1)
if strings.TrimSpace(msg.FolderID) != "" {
meta, err := a.nextIMAPMetadata(ctx, db, msg.FolderID)
if err != nil {
return "", err
}
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,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, 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
} }
@@ -1381,6 +1664,33 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
return id, nil return id, nil
} }
func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, mailboxID string, addBytes int64) error {
mailboxID = strings.TrimSpace(mailboxID)
if mailboxID == "" || addBytes <= 0 {
return nil
}
rowDB, ok := db.(dbQueryer)
if !ok {
return nil
}
var quotaMB int64
if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&quotaMB); err != nil {
return err
}
if quotaMB <= 0 {
return nil
}
var used int64
if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
return err
}
quotaBytes := quotaMB * 1024 * 1024
if used+addBytes > quotaBytes {
return fmt.Errorf("%w: used %d bytes, adding %d bytes exceeds %d bytes", errMailboxQuotaExceeded, used, addBytes, quotaBytes)
}
return nil
}
func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error { func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error {
return a.storeAttachmentWithDB(ctx, a.db, messageID, input) return a.storeAttachmentWithDB(ctx, a.db, messageID, input)
} }
@@ -1578,8 +1888,14 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
} }
func (a *App) deleteMessage(ctx context.Context, messageID string) { func (a *App) deleteMessage(ctx context.Context, messageID string) {
var folderID sql.NullString
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID)
a.deleteMessageMaildirFile(ctx, messageID)
a.deleteMessageFiles(ctx, messageID) a.deleteMessageFiles(ctx, messageID)
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, messageID) _, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, messageID)
if folderID.Valid && folderID.String != "" {
_, _ = a.bumpFolderModSeq(ctx, folderID.String)
}
} }
type messageSummaryScanner interface{ Scan(dest ...any) error } type messageSummaryScanner interface{ Scan(dest ...any) error }
@@ -1588,7 +1904,7 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int var read, starred, hasAtt int
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &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, &read, &starred, &hasAtt, &msg.SizeBytes)
if err != nil { if err != nil {
return msg, err return msg, err
} }
@@ -1602,7 +1918,7 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int var read, starred, hasAtt int
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) err := row.Scan(&msg.ID, &msg.MailboxID, &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, &read, &starred, &hasAtt, &msg.SizeBytes)
if err != nil { if err != nil {
return msg, err return msg, err
} }
@@ -1615,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.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"
}
}
+197
View File
@@ -0,0 +1,197 @@
package app
import (
"net/http"
"strings"
"sync"
"time"
)
const maxMaildirRecentErrors = 10
type maildirSyncCounts struct {
FilesScanned int `json:"filesScanned"`
Imported int `json:"imported"`
Backfilled int `json:"backfilled"`
Cleaned int `json:"cleaned"`
FileErrors int `json:"fileErrors"`
fileErrorDetails []string `json:"-"`
}
func (c maildirSyncCounts) total() int {
return c.Imported + c.Backfilled + c.Cleaned
}
type maildirSyncRun struct {
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Counts maildirSyncCounts `json:"counts"`
}
type maildirSyncHealthResponse struct {
Configured bool `json:"configured"`
Enabled bool `json:"enabled"`
Root string `json:"root"`
ScanSeconds int `json:"scanSeconds"`
WorkerStarted bool `json:"workerStarted"`
Running bool `json:"running"`
LastRun *maildirSyncRun `json:"lastRun,omitempty"`
LastError string `json:"lastError,omitempty"`
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
RecentErrors []string `json:"recentErrors"`
Summary maildirSyncCounts `json:"summary"`
}
type maildirSyncHealthTracker struct {
mu sync.Mutex
workerStarted bool
running bool
current *maildirSyncRun
lastRun *maildirSyncRun
lastError string
nextRunAt *time.Time
recentErrors []string
summary maildirSyncCounts
}
func newMaildirSyncHealthTracker() *maildirSyncHealthTracker {
return &maildirSyncHealthTracker{}
}
func (h *maildirSyncHealthTracker) markWorkerStarted(nextRunAt *time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.workerStarted = true
h.nextRunAt = cloneTimePtr(nextRunAt)
}
func (h *maildirSyncHealthTracker) markWorkerStopped() {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.workerStarted = false
h.nextRunAt = nil
}
func (h *maildirSyncHealthTracker) markRunStarted(startedAt time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
run := &maildirSyncRun{StartedAt: startedAt.UTC(), Status: "running"}
h.running = true
h.current = run
h.lastRun = cloneMaildirSyncRun(run)
}
func (h *maildirSyncHealthTracker) markRunFinished(finishedAt time.Time, counts maildirSyncCounts, err error, nextRunAt *time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
run := h.current
if run == nil {
run = &maildirSyncRun{StartedAt: finishedAt.UTC()}
}
finished := finishedAt.UTC()
run.FinishedAt = &finished
run.DurationMs = finished.Sub(run.StartedAt).Milliseconds()
run.Counts = counts
run.Status = "success"
run.Error = ""
if err != nil {
run.Status = "error"
run.Error = err.Error()
h.lastError = run.Error
h.pushRecentError(run.Error)
} else if counts.FileErrors > 0 {
run.Status = "partial"
if len(counts.fileErrorDetails) > 0 {
run.Error = counts.fileErrorDetails[0]
h.lastError = run.Error
}
for _, detail := range counts.fileErrorDetails {
h.pushRecentError(detail)
}
} else {
h.lastError = ""
}
h.summary.FilesScanned += counts.FilesScanned
h.summary.Imported += counts.Imported
h.summary.Backfilled += counts.Backfilled
h.summary.Cleaned += counts.Cleaned
h.summary.FileErrors += counts.FileErrors
h.running = false
h.current = nil
h.lastRun = cloneMaildirSyncRun(run)
h.nextRunAt = cloneTimePtr(nextRunAt)
}
func (h *maildirSyncHealthTracker) snapshot(cfg Config) maildirSyncHealthResponse {
root := strings.TrimSpace(cfg.MaildirRoot)
scanSeconds := cfg.MaildirScanSeconds
if scanSeconds <= 0 {
scanSeconds = 30
}
out := maildirSyncHealthResponse{
Configured: root != "",
Enabled: root != "",
Root: root,
ScanSeconds: scanSeconds,
}
if h == nil {
return out
}
h.mu.Lock()
defer h.mu.Unlock()
out.WorkerStarted = h.workerStarted
out.Running = h.running
out.LastRun = cloneMaildirSyncRun(h.lastRun)
out.LastError = h.lastError
out.NextRunAt = cloneTimePtr(h.nextRunAt)
out.RecentErrors = append([]string(nil), h.recentErrors...)
out.Summary = h.summary
return out
}
func (h *maildirSyncHealthTracker) pushRecentError(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
h.recentErrors = append([]string{value}, h.recentErrors...)
if len(h.recentErrors) > maxMaildirRecentErrors {
h.recentErrors = h.recentErrors[:maxMaildirRecentErrors]
}
}
func cloneMaildirSyncRun(in *maildirSyncRun) *maildirSyncRun {
if in == nil {
return nil
}
out := *in
out.FinishedAt = cloneTimePtr(in.FinishedAt)
return &out
}
func cloneTimePtr(in *time.Time) *time.Time {
if in == nil {
return nil
}
out := in.UTC()
return &out
}
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
}
+296 -36
View File
@@ -49,54 +49,79 @@ func (a *App) maildirWorker(ctx context.Context) {
if interval <= 0 { if interval <= 0 {
interval = 30 * time.Second interval = 30 * time.Second
} }
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String()) a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
if n, err := a.syncMaildirOnce(ctx); err != nil { if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
a.log.Warn("initial maildir sync failed", "error", err) a.log.Warn("initial maildir sync failed", "error", err)
} else if n > 0 { } else if n := counts.total(); n > 0 {
a.log.Info("initial maildir sync imported messages", "count", n) a.log.Info("initial maildir sync processed messages", "count", n)
} }
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
a.maildirHealth.markWorkerStopped()
a.log.Info("maildir sync worker stopped") a.log.Info("maildir sync worker stopped")
return return
case <-ticker.C: case <-ticker.C:
n, err := a.syncMaildirOnce(ctx) counts, err := a.syncMaildirOnceTracked(ctx, interval)
if err != nil { if err != nil {
a.log.Warn("maildir sync failed", "error", err) a.log.Warn("maildir sync failed", "error", err)
continue continue
} }
if n > 0 { if n := counts.total(); n > 0 {
a.log.Info("maildir sync imported messages", "count", n) a.log.Info("maildir sync processed messages", "count", n)
} }
} }
} }
} }
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
startedAt := a.now().UTC()
a.maildirHealth.markRunStarted(startedAt)
counts, err := a.syncMaildirOnceDetailed(ctx)
finishedAt := a.now().UTC()
var nextRunAt *time.Time
if interval > 0 && err == nil {
next := finishedAt.Add(interval)
nextRunAt = &next
}
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
return counts, err
}
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) { func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
counts, err := a.syncMaildirOnceDetailed(ctx)
return counts.total(), err
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot) root := strings.TrimSpace(a.cfg.MaildirRoot)
if root == "" { if root == "" {
return 0, nil return maildirSyncCounts{}, nil
} }
mailboxes, err := a.maildirMailboxes(ctx) mailboxes, err := a.maildirMailboxes(ctx)
if err != nil { if err != nil {
return 0, err return maildirSyncCounts{}, err
} }
imported := 0 counts := maildirSyncCounts{}
for _, mb := range mailboxes { for _, mb := range mailboxes {
if mb.Unregistered { if mb.Unregistered {
count, err := a.syncUnregisteredMaildir(ctx, mb) mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
counts.FilesScanned += mbCounts.FilesScanned
counts.Imported += mbCounts.Imported
counts.FileErrors += mbCounts.FileErrors
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
if err != nil { if err != nil {
return imported, err return counts, err
} }
imported += count
continue continue
} }
folders, err := a.maildirFolders(ctx, mb.ID) folders, err := a.maildirFolders(ctx, mb.ID)
if err != nil { if err != nil {
return imported, err return counts, err
} }
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir") base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
for _, folder := range folders { for _, folder := range folders {
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
for _, sub := range []string{"new", "cur"} { for _, sub := range []string{"new", "cur"} {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return imported, ctx.Err() return counts, ctx.Err()
default: default:
} }
dir := filepath.Join(folderBase, sub) dir := filepath.Join(folderBase, sub)
@@ -113,26 +138,39 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
continue continue
} }
return imported, err return counts, err
} }
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue continue
} }
path := filepath.Join(dir, entry.Name()) path := filepath.Join(dir, entry.Name())
counts.FilesScanned++
ok, err := a.syncMaildirFile(ctx, mb, folder, path) ok, err := a.syncMaildirFile(ctx, mb, folder, path)
if err != nil { if err != nil {
counts.FileErrors++
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
a.log.Warn("maildir file import failed", "path", path, "error", err) a.log.Warn("maildir file import failed", "path", path, "error", err)
continue continue
} }
if ok { if ok {
imported++ counts.Imported++
} }
} }
} }
} }
} }
return imported, nil backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
if err != nil {
return counts, err
}
counts.Backfilled += backfilled
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
if err != nil {
return counts, err
}
counts.Cleaned += cleaned
return counts, nil
} }
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) { func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
@@ -179,12 +217,17 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
} }
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) { func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
return counts.Imported, err
}
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir") base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
imported := 0 counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} { for _, sub := range []string{"new", "cur"} {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return imported, ctx.Err() return counts, ctx.Err()
default: default:
} }
dir := filepath.Join(base, sub) dir := filepath.Join(base, sub)
@@ -193,24 +236,27 @@ func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (i
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
continue continue
} }
return imported, err return counts, err
} }
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue continue
} }
path := filepath.Join(dir, entry.Name()) path := filepath.Join(dir, entry.Name())
counts.FilesScanned++
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path) ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
if err != nil { if err != nil {
counts.FileErrors++
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err) a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
continue continue
} }
if ok { if ok {
imported++ counts.Imported++
} }
} }
} }
return imported, nil return counts, nil
} }
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) { func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
@@ -248,6 +294,7 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil { if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
return false, err return false, err
} else if exists { } else if exists {
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
return false, nil return false, nil
} }
_, err = a.insertMessage(ctx, msg, attachments) _, err = a.insertMessage(ctx, msg, attachments)
@@ -291,7 +338,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
} }
msg.MailboxID = mb.ID msg.MailboxID = mb.ID
msg.FolderID = folder.ID msg.FolderID = folder.ID
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox") msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
msg.RawPath = path msg.RawPath = path
if msg.MessageUID == "" { if msg.MessageUID == "" {
msg.MessageUID = newID("uid") msg.MessageUID = newID("uid")
@@ -311,6 +358,14 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil { if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
return false, err return false, err
} else if exists { } else if exists {
if _, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
return false, err
}
return false, nil
}
if handled, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
return false, err
} else if handled {
return false, nil return false, nil
} }
id, err := a.insertMessage(ctx, msg, attachments) id, err := a.insertMessage(ctx, msg, attachments)
@@ -338,6 +393,210 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil return count > 0, nil
} }
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string
var oldRead, oldStarred int
var oldModSeq int64
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?`, mailboxID, rawPath).Scan(&samePathID, &oldFolderID, &oldRead, &oldStarred, &oldModSeq)
if err == nil {
if oldFolderID != folderID {
if oldFolderID != "" {
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
return false, err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return false, err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, samePathID)
return err == nil, err
}
modSeq := oldModSeq
if oldRead != boolInt(read) || oldStarred != boolInt(starred) {
modSeq, err = a.bumpFolderModSeq(ctx, folderID)
if err != nil {
return false, err
}
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
rawPath, boolInt(read), boolInt(starred), modSeq, modSeq, now, samePathID)
return err == nil, err
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
if strings.TrimSpace(messageID) == "" {
return false, nil
}
type candidate struct {
ID string
RawPath string
}
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at`, mailboxID, messageID, folderID)
if err != nil {
return false, err
}
var chosen candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.ID, &c.RawPath); err != nil {
rows.Close()
return false, err
}
if c.RawPath == "" || c.RawPath == rawPath {
chosen = c
break
}
ok, err := a.pathIsUnderMaildirRoot(c.RawPath)
if err != nil {
rows.Close()
return false, err
}
if ok {
if _, err := os.Stat(c.RawPath); errors.Is(err, os.ErrNotExist) {
chosen = c
break
} else if err != nil {
rows.Close()
return false, err
}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return false, err
}
if err := rows.Close(); err != nil {
return false, err
}
if chosen.ID == "" {
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
return false, nil
}
var previousFolderID string
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?`, chosen.ID).Scan(&previousFolderID); err != nil {
return false, err
}
if previousFolderID != "" && previousFolderID != folderID {
if _, err := a.bumpFolderModSeq(ctx, previousFolderID); err != nil {
return false, err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return false, err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, chosen.ID)
return err == nil, err
}
func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailboxID, folderID, messageID string) {
var existing string
err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
if err != nil || existing == "" || existing == rawPath {
return
}
a.removeMaildirPath(ctx, rawPath)
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?`, cutoff)
if err != nil {
return 0, err
}
type item struct {
ID string
RawPath string
}
var missing []item
for rows.Next() {
var it item
if err := rows.Scan(&it.ID, &it.RawPath); err != nil {
rows.Close()
return 0, err
}
ok, err := a.pathIsUnderMaildirRoot(it.RawPath)
if err != nil {
rows.Close()
return 0, err
}
if !ok {
continue
}
if _, err := os.Stat(it.RawPath); errors.Is(err, os.ErrNotExist) {
missing = append(missing, it)
} else if err != nil {
rows.Close()
return 0, err
}
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, it := range missing {
a.deleteMessageFiles(ctx, it.ID)
var folderID sql.NullString
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, it.ID).Scan(&folderID)
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
return 0, err
}
if folderID.Valid && folderID.String != "" {
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
return 0, err
}
}
}
return len(missing), nil
}
func maildirFlagsFromPath(path, folderName string) (bool, bool) {
base := filepath.Base(path)
flags := ""
hasFlags := false
for _, sep := range []string{maildirFlagSeparator(), ":2,", "!2,"} {
if idx := strings.LastIndex(base, sep); idx >= 0 {
flags = base[idx+len(sep):]
hasFlags = true
break
}
}
if hasFlags {
return strings.ContainsRune(flags, 'S'), strings.ContainsRune(flags, 'F')
}
return !strings.EqualFold(folderName, "Inbox"), false
}
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil {
a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string { func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
domain = normalizeDomain(domain) domain = normalizeDomain(domain)
for _, address := range append(append([]string{}, msg.To...), msg.CC...) { for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
@@ -382,19 +641,20 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
receivedAt = sentAt receivedAt = sentAt
} }
return storedMessage{ return storedMessage{
MessageUID: newID("uid"), MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")), MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject, Subject: subject,
From: from, From: from,
FromName: fromName, FromName: fromName,
To: to, To: to,
CC: cc, CC: cc,
SentAt: sentAt, SentAt: sentAt,
ReceivedAt: receivedAt, ReceivedAt: receivedAt,
Snippet: snippetFrom(bodyText, bodyHTML), Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: bodyText, BodyText: bodyText,
BodyHTML: bodyHTML, BodyHTML: bodyHTML,
IsRead: false, IsRead: false,
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
}, parsed.Attachments, nil }, parsed.Attachments, nil
} }
+585
View File
@@ -0,0 +1,585 @@
package app
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
return nil
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildir(ctx, messageID, raw, false)
}
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return nil
}
msg, err := a.storedMessageByID(ctx, messageID)
if err != nil {
return err
}
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
if err != nil {
return err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
}
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
}
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if folderID != "" {
oldFolderID := state.FolderID
state.FolderID = folderID
if updateFolder && oldFolderID != "" && oldFolderID != state.FolderID {
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
return err
}
}
}
if state.MailboxID == "" || state.FolderID == "" {
return nil
}
if !replace && state.RawPath != "" {
if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil {
return err
} else if ok {
if _, err := os.Stat(state.RawPath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
}
}
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
if err != nil {
return err
}
folderName, err := a.folderNameByID(ctx, state.FolderID)
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
subdir = "new"
}
if err := ensureMaildirFolderDirs(folderBase); err != nil {
return err
}
filename := maildirFilename(messageID, state.MessageID)
tmpPath := filepath.Join(folderBase, "tmp", filename)
finalPath := filepath.Join(folderBase, subdir, filename)
finalPath = maildirPathWithFlags(finalPath, state.IsRead, state.IsStarred)
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
return err
}
if err := os.Rename(tmpPath, finalPath); err != nil {
_ = os.Remove(tmpPath)
return err
}
if replace || state.RawPath != "" {
a.removeMaildirPath(ctx, state.RawPath)
}
if updateFolder {
if state.IMAPUID > 0 && folderID == "" {
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, state.FolderID, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
} else {
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
} else {
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
return err
}
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil {
return stateErr
}
if state.FolderID != "" && state.FolderID != targetFolderID {
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
return err
}
}
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
if metaErr != nil {
return metaErr
}
_, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if state.MailboxID == "" {
return nil
}
state.FolderID = targetFolderID
if state.RawPath == "" {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
if err != nil {
return err
}
if !ok {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
if _, err := os.Stat(state.RawPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
return err
}
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
if err != nil {
return err
}
folderName, err := a.folderNameByID(ctx, targetFolderID)
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(folderBase); err != nil {
return err
}
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
subdir = "new"
}
targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath))
if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) {
if err := os.Rename(state.RawPath, targetPath); err != nil {
return err
}
}
if state.FolderID != "" && state.FolderID != targetFolderID {
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
return err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error {
msg, err := a.storedMessageByID(ctx, messageID)
if err != nil {
return err
}
msg.FolderID = folderID
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
if err != nil {
return err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
}
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
var rawPath string
if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil {
return
}
a.removeMaildirPath(ctx, rawPath)
}
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if state.RawPath == "" {
return nil
}
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
if err != nil {
return err
}
if !ok {
return nil
}
if _, err := os.Stat(state.RawPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
currentRead := state.IsRead
currentStarred := state.IsStarred
if read != nil {
currentRead = *read
}
if starred != nil {
currentStarred = *starred
}
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
return nil
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.Rename(state.RawPath, targetPath); err != nil {
return err
}
modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID)
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, targetPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
return
}
ok, err := a.pathIsUnderMaildirRoot(rawPath)
if err != nil || !ok {
if err != nil {
a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err)
}
return
}
if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) {
a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err)
}
}
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return 0, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
if err != nil {
return 0, err
}
var ids []string
for rows.Next() {
select {
case <-ctx.Done():
rows.Close()
return 0, ctx.Err()
default:
}
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return 0, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
count := 0
for _, id := range ids {
if err := a.rewriteMessageMaildir(ctx, id); err != nil {
return count, err
}
count++
}
return count, nil
}
type maildirMessageState struct {
MailboxID string
FolderID string
MessageID string
RawPath string
IsRead bool
IsStarred bool
IMAPUID int64
IMAPModSeq int64
}
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
var state maildirMessageState
var mailboxID, folderID sql.NullString
var read, starred int
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred, &state.IMAPUID, &state.IMAPModSeq)
if err != nil {
return state, err
}
state.MailboxID = mailboxID.String
state.FolderID = folderID.String
state.IsRead = intBool(read)
state.IsStarred = intBool(starred)
return state, nil
}
func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) {
row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(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,raw_path FROM messages WHERE id=?`, id)
var msg storedMessage
var toJSON, ccJSON, bccJSON, sent, received string
var read, starred int
err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath)
if err != nil {
return msg, err
}
msg.To = jsonDecodeSlice(toJSON)
msg.CC = jsonDecodeSlice(ccJSON)
msg.BCC = jsonDecodeSlice(bccJSON)
msg.SentAt = parseTime(sent)
msg.ReceivedAt = parseTime(received)
msg.IsRead = intBool(read)
msg.IsStarred = intBool(starred)
return msg, nil
}
func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) {
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AttachmentInput
for rows.Next() {
var filename, contentType, storagePath string
if err := rows.Scan(&filename, &contentType, &storagePath); err != nil {
return nil, err
}
data, err := os.ReadFile(storagePath)
if err != nil {
return nil, err
}
out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)})
}
return out, rows.Err()
}
func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) {
var mb maildirMailbox
err := a.db.QueryRowContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain)
return mb, err
}
func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) {
var name string
err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name)
return name, err
}
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
if root == "" || strings.TrimSpace(path) == "" {
return false, nil
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return false, err
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return false, err
}
rel, err := filepath.Rel(rootAbs, pathAbs)
if err != nil {
return false, err
}
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
}
func ensureMaildirFolderDirs(folderBase string) error {
for _, sub := range []string{"tmp", "new", "cur"} {
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
return err
}
}
return nil
}
func maildirFilename(messageID, headerMessageID string) string {
base := strings.TrimSpace(headerMessageID)
if base == "" {
base = messageID
}
return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base))
}
func safeMaildirName(value string) string {
value = strings.Trim(value, "<>")
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '.', r == '_', r == '-', r == '@':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := strings.Trim(b.String(), "._-")
if out == "" {
out = "message"
}
if len(out) > 120 {
out = out[:120]
}
return out
}
func messageDate(msg storedMessage) time.Time {
if !msg.SentAt.IsZero() {
return msg.SentAt
}
if !msg.ReceivedAt.IsZero() {
return msg.ReceivedAt
}
return time.Now().UTC()
}
func maildirPathWithFlags(path string, read, starred bool) string {
dir := filepath.Dir(path)
name := filepath.Base(path)
if read || starred {
dir = filepath.Join(filepath.Dir(dir), "cur")
} else if filepath.Base(dir) == "cur" {
dir = filepath.Join(filepath.Dir(dir), "new")
}
base := name
sep := maildirFlagSeparator()
existingFlags := ""
if idx := strings.LastIndex(base, sep); idx >= 0 {
existingFlags = base[idx+len(sep):]
base = base[:idx]
}
flags := preserveMaildirFlags(existingFlags, "SF")
if read {
flags = appendMaildirFlag(flags, 'S')
}
if starred {
flags = appendMaildirFlag(flags, 'F')
}
if flags != "" {
base += sep + flags
}
return filepath.Join(dir, base)
}
func preserveMaildirFlags(flags, managed string) string {
var b strings.Builder
for _, flag := range flags {
if strings.ContainsRune(managed, flag) || strings.ContainsRune(b.String(), flag) {
continue
}
b.WriteRune(flag)
}
return b.String()
}
func appendMaildirFlag(flags string, flag rune) string {
if strings.ContainsRune(flags, flag) {
return flags
}
return flags + string(flag)
}
func maildirFlagSeparator() string {
if runtime.GOOS == "windows" {
return "!2,"
}
return ":2,"
}
+304 -64
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found") respondError(w, http.StatusNotFound, "mailbox not found")
return return
} }
matchMode := strings.TrimSpace(req.MatchMode) rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
if matchMode == "" { if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
matchMode = "all"
}
if matchMode != "all" && matchMode != "any" {
badRequest(w, errors.New("invalid match mode")) badRequest(w, errors.New("invalid match mode"))
return return
} }
matchMode := normalizeRuleMatchMode(rawMatchMode)
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains) conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
if len(conditions) == 0 { if len(conditions) == 0 {
badRequest(w, errors.New("rule condition is required")) badRequest(w, errors.New("rule condition is required"))
@@ -639,10 +638,21 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load stats") respondError(w, http.StatusInternalServerError, "failed to load stats")
return return
} }
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount); err != nil { if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load attachment stats") respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
return return
} }
if mailboxID != "" {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0) rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...) WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...)
@@ -723,11 +733,14 @@ func (a *App) deleteMessagesInFolder(ctx context.Context, mailboxID, folder stri
} }
ids = append(ids, id) ids = append(ids, id)
} }
if err := rows.Err(); err != nil {
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, id := range ids { for _, id := range ids {
a.deleteMessageFiles(ctx, id) a.deleteMessage(ctx, id)
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil {
return 0, err
}
} }
return int64(len(ids)), nil return int64(len(ids)), nil
} }
@@ -741,13 +754,31 @@ func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, er
if err != nil { if err != nil {
return 0, err return 0, err
} }
res, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE mailbox_id=? AND folder_id=? AND is_read=1`, rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1`, mailboxID, inboxID)
archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
n, _ := res.RowsAffected() defer rows.Close()
return n, nil var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return 0, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, id := range ids {
if err := a.moveMessageMaildir(ctx, id, archiveID); err != nil {
return 0, err
}
}
return int64(len(ids)), nil
} }
func scanContact(row messageSummaryScanner) (Contact, error) { func scanContact(row messageSummaryScanner) (Contact, error) {
@@ -832,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)
@@ -856,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.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, spamID, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
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)
@@ -878,26 +902,94 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
} }
rows.Close() rows.Close()
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject} msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
_ = a.db.QueryRowContext(ctx, `SELECT trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText) msg, ok := a.ruleMessageByID(ctx, messageID)
if !ok {
msg = ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
}
for _, rule := range rules { for _, rule := range rules {
if !ruleMatches(rule, msg) { if !ruleMatches(rule, msg) {
continue continue
} }
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions) _ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
if rule.StopProcessing { if rule.StopProcessing {
return break
} }
} }
if a.senderBlocked(ctx, userID, mailboxID, from) {
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
}
}
func (a *App) senderBlocked(ctx context.Context, userID, mailboxID, from string) bool {
from = normalizeEmail(from)
if from == "" {
return false
}
var blocked int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
return blocked > 0
}
func (a *App) moveBlockedMessageToSpam(ctx context.Context, messageID, mailboxID string) {
spamID, err := a.ensureFolder(ctx, mailboxID, "Spam")
if err != nil {
return
}
_ = a.moveMessageMaildir(ctx, messageID, spamID)
} }
type ruleMessage struct { type ruleMessage struct {
ID string ID string
MailboxID string MailboxID string
From string From string
To string To string
Subject string CC string
Snippet string Subject string
BodyText string Snippet string
BodyText string
AttachmentNames string
SizeBytes int64
ReceivedAt time.Time
}
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
var msg ruleMessage
var toAddrs, ccAddrs, receivedAt string
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
if err != nil {
return ruleMessage{}, false
}
msg.To = ruleAddressText(toAddrs)
msg.CC = ruleAddressText(ccAddrs)
msg.ReceivedAt = parseTime(receivedAt)
msg.AttachmentNames = a.ruleAttachmentNames(ctx, messageID)
return msg, true
}
func ruleAddressText(raw string) string {
var items []string
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &items) == nil {
return strings.Join(items, " ")
}
return raw
}
func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string {
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
if err != nil {
return ""
}
defer rows.Close()
parts := []string{}
for rows.Next() {
var filename, contentType string
if err := rows.Scan(&filename, &contentType); err != nil {
return strings.Join(parts, " ")
}
parts = append(parts, filename, contentType)
}
return strings.Join(parts, " ")
} }
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition { func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
@@ -911,26 +1003,56 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
} }
out := []MailRuleCondition{} out := []MailRuleCondition{}
for _, item := range items { for _, item := range items {
field := strings.TrimSpace(item.Field) if normalized, ok := normalizeRuleCondition(item); ok {
operator := strings.TrimSpace(item.Operator) out = append(out, normalized)
value := strings.TrimSpace(item.Value)
if value == "" {
continue
} }
if field != "from" && field != "to" && field != "subject" && field != "body" {
continue
}
if operator == "" {
operator = "contains"
}
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
continue
}
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
} }
return out return out
} }
func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
matchMode := normalizeRuleMatchMode(item.MatchMode)
if len(item.Conditions) > 0 {
children := normalizeRuleConditions(item.Conditions, "", "")
if len(children) == 0 {
return MailRuleCondition{}, false
}
return MailRuleCondition{MatchMode: matchMode, Conditions: children}, true
}
field := strings.ToLower(strings.TrimSpace(item.Field))
operator := strings.ToLower(strings.TrimSpace(item.Operator))
value := strings.TrimSpace(item.Value)
if value == "" {
return MailRuleCondition{}, false
}
switch field {
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
default:
return MailRuleCondition{}, false
}
if operator == "" {
operator = "contains"
}
switch operator {
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
case "gt", "gte", "lt", "lte", "before", "after", "on":
default:
return MailRuleCondition{}, false
}
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
}
func normalizeRuleMatchMode(matchMode string) string {
switch strings.ToLower(strings.TrimSpace(matchMode)) {
case "any", "or":
return "any"
case "all", "and":
return "all"
default:
return "all"
}
}
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction { func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" { if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)}) items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)})
@@ -987,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) {
@@ -1006,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":
@@ -1019,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":
@@ -1042,35 +1176,139 @@ 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, "") {
switch action.Type { switch action.Type {
case "archive": case "archive":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil { if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil { if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err return err
} }
} }
case "trash": case "trash":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil { if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil { if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err return err
} }
} }
case "move": case "move":
target := ruleTargetFolder(action.Value) target := ruleTargetFolder(action.Value)
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil { if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil { if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err return err
} }
} }
case "star": case "star":
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, now, messageID); err != nil { starred := true
if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil {
return err
}
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
return err return err
} }
case "mark-read": case "mark-read":
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, now, messageID); err != nil { read := true
if err := a.updateMessageMaildirFlags(ctx, messageID, &read, nil); err != nil {
return err
}
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
return err return err
} }
case "label": case "label":
@@ -1128,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
} }
+6
View File
@@ -72,6 +72,10 @@ func (a *App) Router() http.Handler {
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend) r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send-queue/{id}/retry", a.handleRetrySendQueue)
r.With(a.requirePermission(PermissionMailSend)).Delete("/mail/send-queue/{id}", a.handleCancelSendQueue)
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends) r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend) r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend) r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
@@ -115,9 +119,11 @@ 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)
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings) r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP) r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates) r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
+3 -1
View File
@@ -16,12 +16,14 @@ const (
sendQueueStatusSending = "sending" sendQueueStatusSending = "sending"
sendQueueStatusDelivered = "delivered" sendQueueStatusDelivered = "delivered"
sendQueueStatusFailed = "failed" sendQueueStatusFailed = "failed"
sendQueueStatusCanceled = "canceled"
sendAuditAccepted = "accepted" sendAuditAccepted = "accepted"
sendAuditQueued = "queued" sendAuditQueued = "queued"
sendAuditDelivered = "delivered" sendAuditDelivered = "delivered"
sendAuditFailed = "failed" sendAuditFailed = "failed"
sendAuditRetry = "retry" sendAuditRetry = "retry"
sendAuditCanceled = "canceled"
sendSourceWebmail = "webmail" sendSourceWebmail = "webmail"
sendSourceSubmission = "submission" sendSourceSubmission = "submission"
@@ -85,7 +87,7 @@ func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error
return "", err return "", err
} }
if existingID != id { if existingID != id {
if status == sendQueueStatusDelivered || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) { if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`, _, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status) in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
if err != nil { if err != nil {
+9
View File
@@ -241,6 +241,15 @@ func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, ma
if err != nil { if err != nil {
return err return err
} }
if insertedSent {
if err := a.rewriteMessageMaildir(ctx, sentID); err != nil {
a.deleteMessage(ctx, sentID)
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
}
return err
}
}
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients}) a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients})
if sentID != "" { if sentID != "" {
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil { if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil {
+90 -34
View File
@@ -57,11 +57,14 @@ type Alias struct {
} }
type MailFolder struct { type MailFolder struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Role string `json:"role"` Role string `json:"role"`
UnreadCount int `json:"unreadCount"` UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
UIDValidity int64 `json:"uidValidity"`
UIDNext int64 `json:"uidNext"`
HighestModSeq int64 `json:"highestModseq"`
} }
type MailLabel struct { type MailLabel struct {
@@ -73,32 +76,43 @@ type MailLabel struct {
} }
type MailMessage struct { type MailMessage struct {
ID string `json:"id"` ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"` MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"` MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"` OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"` RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"` FolderID string `json:"folderId"`
Folder string `json:"folder"` Folder string `json:"folder"`
MessageUID string `json:"messageUid"` MessageUID string `json:"messageUid"`
MessageID string `json:"messageId"` IMAPUID int64 `json:"imapUid"`
Subject string `json:"subject"` IMAPModSeq int64 `json:"imapModseq"`
From string `json:"from"` MessageID string `json:"messageId"`
FromName string `json:"fromName,omitempty"` Subject string `json:"subject"`
To []string `json:"to"` From string `json:"from"`
CC []string `json:"cc"` FromName string `json:"fromName,omitempty"`
BCC []string `json:"bcc,omitempty"` To []string `json:"to"`
SentAt time.Time `json:"sentAt"` CC []string `json:"cc"`
ReceivedAt time.Time `json:"receivedAt"` BCC []string `json:"bcc,omitempty"`
Snippet string `json:"snippet"` SentAt time.Time `json:"sentAt"`
BodyText string `json:"bodyText,omitempty"` ReceivedAt time.Time `json:"receivedAt"`
BodyHTML string `json:"bodyHtml,omitempty"` Snippet string `json:"snippet"`
IsRead bool `json:"isRead"` BodyText string `json:"bodyText,omitempty"`
IsStarred bool `json:"isStarred"` BodyHTML string `json:"bodyHtml,omitempty"`
HasAttachments bool `json:"hasAttachments"` IsRead bool `json:"isRead"`
SizeBytes int64 `json:"sizeBytes"` IsStarred bool `json:"isStarred"`
Labels []MailLabel `json:"labels,omitempty"` HasAttachments bool `json:"hasAttachments"`
Attachments []Attachment `json:"attachments,omitempty"` 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 { type Attachment struct {
@@ -168,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 {
@@ -193,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"`
} }
@@ -204,3 +223,40 @@ type MailStatsFolderCount struct {
Unread int64 `json:"unread"` Unread int64 `json:"unread"`
Bytes int64 `json:"bytes"` Bytes int64 `json:"bytes"`
} }
type SendQueueEntry struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId"`
SentMessageID string `json:"sentMessageId"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
Source string `json:"source"`
MailFrom string `json:"mailFrom"`
HeaderFrom string `json:"headerFrom"`
Recipients []string `json:"recipients"`
Status string `json:"status"`
AttemptCount int `json:"attemptCount"`
MaxAttempts int `json:"maxAttempts"`
NextAttemptAt time.Time `json:"nextAttemptAt"`
LastError string `json:"lastError"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
}
type SendAuditEvent struct {
ID string `json:"id"`
QueueID string `json:"queueId"`
MailboxID string `json:"mailboxId"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
SentMessageID string `json:"sentMessageId"`
MessageID string `json:"messageId,omitempty"`
Source string `json:"source"`
Event string `json:"event"`
Status string `json:"status"`
MailFrom string `json:"mailFrom"`
HeaderFrom string `json:"headerFrom"`
Recipients []string `json:"recipients"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
+64 -4
View File
@@ -56,12 +56,14 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string } export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: 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; 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[] }> }
@@ -70,15 +72,73 @@ export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc:
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] } export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string } export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string } export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string }
export type SendQueueStatus = "queued" | "sending" | "delivered" | "failed" | "canceled"
export type SendQueueItem = {
id: string
mailboxId: string
sentMessageId?: string
messageId?: string
mailFrom?: string
headerFrom?: string
subject: string
recipients: string[]
source: string
status: SendQueueStatus
attemptCount: number
maxAttempts: number
nextAttemptAt?: string
lastError?: string
error?: string
failureReason?: string
createdAt: string
updatedAt: string
deliveredAt?: string
}
export type SendQueueAuditEvent = {
id: string
queueId?: string
mailboxId?: string
mailboxAddress?: string
sentMessageId?: string
messageId?: string
source?: string
status?: SendQueueStatus
event?: string
eventType?: string
mailFrom?: string
headerFrom?: string
recipients?: string[]
message?: string
error?: string
attemptCount?: number
createdAt: string
}
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 MaildirSyncRun = { startedAt: string; finishedAt?: string; durationMs: number; status: "running" | "success" | "partial" | "error"; error?: string; counts: MaildirSyncCounts }
export type MaildirSyncHealth = {
configured: boolean
enabled: boolean
root: string
scanSeconds: number
workerStarted: boolean
running: boolean
lastRun?: MaildirSyncRun
lastError?: string
nextRunAt?: string
recentErrors: string[]
summary: MaildirSyncCounts
}
export type SystemSettings = { export type SystemSettings = {
publicHostname: string publicHostname: string
publicBaseUrl: string publicBaseUrl: string
+24 -1
View File
@@ -1,4 +1,4 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types" import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
export * from "./api-types" export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000 const REQUEST_TIMEOUT_MS = 15_000
@@ -94,7 +94,19 @@ 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"),
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) }),
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"), mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
@@ -130,6 +142,17 @@ export const api = {
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }), cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }),
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string } = {}) => {
const query = new URLSearchParams()
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.status && params.status !== "all") query.set("status", params.status)
if (params.cursor) query.set("cursor", params.cursor)
const suffix = query.toString()
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
},
sendQueueAudit: (id: string) => request<ListResponse<SendQueueAuditEvent>>(`/api/mail/send-queue/${id}/audit`),
retrySendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}/retry`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
cancelSendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}`, { method: "DELETE" }),
saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }), deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }),
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }), markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
+246 -9
View File
@@ -2,8 +2,8 @@ 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, 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"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -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
@@ -864,6 +980,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
const canResetTemplates = hasPermission(user, "admin.templates.reset") const canResetTemplates = hasPermission(user, "admin.templates.reset")
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates }) const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base") const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false) const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
const [openRegistration, setOpenRegistration] = React.useState(false) const [openRegistration, setOpenRegistration] = React.useState(false)
@@ -912,6 +1029,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "settings"] }) qc.invalidateQueries({ queryKey: ["admin", "settings"] })
qc.invalidateQueries({ queryKey: ["admin", "maildir-sync", "health"] })
qc.invalidateQueries({ queryKey: ["dns-records"] }) qc.invalidateQueries({ queryKey: ["dns-records"] })
qc.invalidateQueries({ queryKey: ["public-settings"] }) qc.invalidateQueries({ queryKey: ["public-settings"] })
toast({ title: "系统设置已保存" }) toast({ title: "系统设置已保存" })
@@ -1002,12 +1120,15 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
</CardContent> </CardContent>
</Card>} </Card>}
{settingsTab === "storage" && <Card> {settingsTab === "storage" && <div className="space-y-6">
<CardHeader><CardTitle></CardTitle></CardHeader> <Card>
<CardContent> <CardHeader><CardTitle></CardTitle></CardHeader>
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} /> <CardContent>
</CardContent> <Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
</Card>} </CardContent>
</Card>
<MaildirSyncHealthCard health={maildirHealth.data} loading={maildirHealth.isLoading} error={maildirHealth.error} onRefresh={() => maildirHealth.refetch()} refreshing={maildirHealth.isFetching} fallbackRoot={settings?.maildirRoot || ""} />
</div>}
{settingsTab === "mail" && <Card> {settingsTab === "mail" && <Card>
<CardHeader><CardTitle></CardTitle></CardHeader> <CardHeader><CardTitle></CardTitle></CardHeader>
@@ -1081,6 +1202,102 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
) )
} }
function MaildirSyncHealthCard({ health, loading, error, onRefresh, refreshing, fallbackRoot }: { health?: MaildirSyncHealth; loading: boolean; error: Error | null; onRefresh: () => void; refreshing: boolean; fallbackRoot: string }) {
const root = health?.root || fallbackRoot
const configured = health?.configured ?? !!root
const lastRun = health?.lastRun
const counters = lastRun?.counts || health?.summary
const recentErrors = health?.recentErrors || []
const status = health?.running ? "running" : lastRun?.status || (configured ? "idle" : "disabled")
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<CardTitle>Maildir </CardTitle>
<div className="break-all text-xs text-muted-foreground">{root || "未配置 Maildir 根目录"}</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={configured ? "default" : "secondary"}>{configured ? "已配置" : "未配置"}</Badge>
<Badge variant={health?.running ? "default" : health?.workerStarted ? "outline" : "secondary"}>{health?.running ? "运行中" : health?.workerStarted ? "worker 已启动" : "worker 未启动"}</Badge>
<Button type="button" variant="outline" size="sm" onClick={onRefresh} disabled={loading || refreshing}>
<RefreshCcw className={cn("mr-2 h-4 w-4", refreshing && "animate-spin")} />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{error && <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{queryErrorMessage(error)}</div>}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<InfoLine label="当前状态" value={<MaildirStatusBadge status={status} />} />
<InfoLine label="最近开始" value={formatOptionalDate(lastRun?.startedAt)} />
<InfoLine label="最近结束" value={formatOptionalDate(lastRun?.finishedAt)} />
<InfoLine label="最近耗时" value={formatDuration(lastRun?.durationMs)} />
<InfoLine label="扫描间隔" value={health?.scanSeconds ? `${health.scanSeconds}` : "-"} />
<InfoLine label="下次运行" value={formatOptionalDate(health?.nextRunAt)} />
<InfoLine label="最后错误" value={lastRun?.error || health?.lastError || "-"} />
<InfoLine label="错误数" value={counterValue(counters, "fileErrors")} />
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{maildirCounterRows(counters).map((item) => <InfoBox key={item.key} label={item.label} value={item.value} />)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium"></div>
{recentErrors.length === 0 && <Empty text={loading ? "正在读取同步状态..." : "暂无同步错误"} />}
{recentErrors.length > 0 && (
<div className="space-y-2">
{recentErrors.slice(0, 5).map((item, index) => (
<div key={`${item}-${index}`} className="rounded-lg border px-3 py-2 text-sm">
<div className="break-words text-destructive">{item || "未知错误"}</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
)
}
function MaildirStatusBadge({ status }: { status: string }) {
const normalized = status.toLowerCase()
if (normalized === "running") return <Badge></Badge>
if (["ok", "success", "succeeded", "idle"].includes(normalized)) return <Badge variant="outline">{normalized === "idle" ? "等待下次扫描" : "正常"}</Badge>
if (normalized === "partial") return <Badge variant="secondary"></Badge>
if (["error", "failed", "failure"].includes(normalized)) return <Badge variant="destructive"></Badge>
if (["disabled", "not_configured"].includes(normalized)) return <Badge variant="secondary"></Badge>
return <Badge variant="secondary">{status || "-"}</Badge>
}
function maildirCounterRows(counters?: Record<string, number | undefined>) {
return [
{ key: "filesScanned", label: "扫描文件", value: counterValue(counters, "filesScanned") },
{ key: "imported", label: "导入", value: counterValue(counters, "imported") },
{ key: "backfilled", label: "回填", value: counterValue(counters, "backfilled") },
{ key: "cleaned", label: "清理", value: counterValue(counters, "cleaned") },
{ key: "fileErrors", label: "文件错误", value: counterValue(counters, "fileErrors") },
]
}
function counterValue(counters: Record<string, number | undefined> | undefined, key: string) {
return Number(counters?.[key] || 0)
}
function formatOptionalDate(value?: string) {
return value ? formatDate(value) || "-" : "-"
}
function formatDuration(value?: number) {
if (!value) return "-"
if (value < 1000) return `${value} ms`
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}`
}
function queryErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
}
function parseSemver(tag: string): number[] { function parseSemver(tag: string): number[] {
return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(Number) return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(Number)
} }
@@ -1370,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>
} }
+296 -15
View File
@@ -12,8 +12,8 @@ import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style" import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, PermissionLimits } from "@/lib/api" import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useDisplayMode } from "@/lib/display-mode" import { useDisplayMode } from "@/lib/display-mode"
@@ -61,7 +61,7 @@ const folderLabels: Record<string, string> = {
type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean } type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean }
type MailFilter = "all" | "unread" | "starred" | "attachments" type MailFilter = "all" | "unread" | "starred" | "attachments"
type MailView = "folder" | "starred" | "label" | "scheduled" type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue"
type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
type MailNotificationState = { latestId: string; latestReceivedAt: string } type MailNotificationState = { latestId: string; latestReceivedAt: string }
@@ -69,6 +69,7 @@ type ComposeSendIntent = { title: string; description: string; confirmText: stri
type MailMenuItem = type MailMenuItem =
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number } | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number } | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number }
| { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number }
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number } | { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
const filterLabels: Record<MailFilter, string> = { const filterLabels: Record<MailFilter, string> = {
@@ -103,6 +104,9 @@ export function MailPage() {
const [bulkPending, setBulkPending] = React.useState(false) const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null) const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("") const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
const [sendQueueStatus, setSendQueueStatus] = React.useState<SendQueueStatus | "all">("all")
const [sendQueueAuditId, setSendQueueAuditId] = React.useState("")
const [sendQueuePendingId, setSendQueuePendingId] = React.useState("")
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
const [labelEditMode, setLabelEditMode] = React.useState(false) const [labelEditMode, setLabelEditMode] = React.useState(false)
const [newLabelEditing, setNewLabelEditing] = React.useState(false) const [newLabelEditing, setNewLabelEditing] = React.useState(false)
@@ -130,6 +134,9 @@ export function MailPage() {
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") }) const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 }) const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 })
const canViewSendQueue = canReadMail
const sendQueue = useQuery({ queryKey: ["send-queue", activeMailboxId, sendQueueStatus], queryFn: () => api.sendQueue({ mailboxId: activeMailboxId, status: sendQueueStatus }), enabled: !!activeMailboxId && canViewSendQueue, refetchInterval: 15000 })
const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue })
const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
const inboxProbe = useQuery({ const inboxProbe = useQuery({
queryKey: ["mail-notifications", activeMailboxId], queryKey: ["mail-notifications", activeMailboxId],
@@ -148,7 +155,7 @@ export function MailPage() {
}, },
initialPageParam: "", initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && (mailView !== "label" || !!selectedLabelId), enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
}) })
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail }) const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail })
function updateCachedMessage(id: string, patch: Partial<MailMessage>) { function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
@@ -276,6 +283,28 @@ export function MailPage() {
onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }), onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }),
onSettled: () => setCancelingScheduledId(""), onSettled: () => setCancelingScheduledId(""),
}) })
const retrySendQueue = useMutation({
mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id),
onMutate: (item) => setSendQueuePendingId(item.id),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["send-queue"] })
await qc.invalidateQueries({ queryKey: ["send-queue-audit"] })
toast({ title: "已重新加入发送队列" })
},
onError: (error) => toast({ title: "重试失败", description: error instanceof Error ? error.message : "请稍后重试" }),
onSettled: () => setSendQueuePendingId(""),
})
const cancelSendQueue = useMutation({
mutationFn: (item: SendQueueItem) => api.cancelSendQueue(item.id),
onMutate: (item) => setSendQueuePendingId(item.id),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["send-queue"] })
await qc.invalidateQueries({ queryKey: ["send-queue-audit"] })
toast({ title: "已取消发送任务" })
},
onError: (error) => toast({ title: "取消失败", description: error instanceof Error ? error.message : "请稍后重试" }),
onSettled: () => setSendQueuePendingId(""),
})
const markAllRead = useMutation({ const markAllRead = useMutation({
mutationFn: async (items: MailMessage[]) => { mutationFn: async (items: MailMessage[]) => {
const unread = items.filter((message) => !message.isRead) const unread = items.filter((message) => !message.isRead)
@@ -403,6 +432,7 @@ export function MailPage() {
qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }),
qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["labels"] }),
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
qc.invalidateQueries({ queryKey: ["send-queue"] }),
qc.invalidateQueries({ queryKey: ["mail-notifications"] }), qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
]).finally(() => { ]).finally(() => {
setLastAutoRefreshAt(new Date()) setLastAutoRefreshAt(new Date())
@@ -429,10 +459,16 @@ export function MailPage() {
const visibleScheduledItems = scheduledQuery const visibleScheduledItems = scheduledQuery
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery)) ? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
: scheduledItems : scheduledItems
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail) const sendQueueItems = sendQueue.data?.items || []
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
const sendQueueQuery = query.trim().toLowerCase()
const visibleSendQueueItems = sendQueueQuery
? sendQueueItems.filter((item) => [item.subject, item.source, item.lastError, item.error, item.failureReason, ...(item.recipients || [])].join(" ").toLowerCase().includes(sendQueueQuery))
: sendQueueItems
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
const labelItems = labels.data?.items || [] const labelItems = labels.data?.items || []
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
const viewTitle = mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder const viewTitle = mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length) const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length)
const visibleMessageIds = visibleMessages.map((message) => message.id) const visibleMessageIds = visibleMessages.map((message) => message.id)
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
@@ -453,6 +489,7 @@ export function MailPage() {
qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }),
qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["labels"] }),
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
qc.invalidateQueries({ queryKey: ["send-queue"] }),
]) ])
} }
async function runBulkAction(action: BulkAction) { async function runBulkAction(action: BulkAction) {
@@ -576,6 +613,13 @@ export function MailPage() {
setMailFilter("all") setMailFilter("all")
setMobileSidebarOpen(false) setMobileSidebarOpen(false)
} }
function openSendQueue() {
setMailView("sendQueue")
setSelectedLabelId("")
setSelectedId(null)
setMailFilter("all")
setMobileSidebarOpen(false)
}
function openLabel(labelId: string) { function openLabel(labelId: string) {
setSelectedLabelId(labelId) setSelectedLabelId(labelId)
setMailView("label") setMailView("label")
@@ -654,9 +698,9 @@ export function MailPage() {
{mailMenuItems.map((item) => ( {mailMenuItems.map((item) => (
<SidebarMenuItem key={item.key}> <SidebarMenuItem key={item.key}>
<SidebarMenuButton <SidebarMenuButton
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : mailView === "folder" && folder === item.folderName} isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : mailView === "folder" && folder === item.folderName}
className={cn(sidebarCollapsed && "justify-center px-0")} className={cn(sidebarCollapsed && "justify-center px-0")}
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : openFolder(item.folderName)} onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : item.type === "sendQueue" ? openSendQueue() : openFolder(item.folderName)}
> >
{item.icon} {item.icon}
{!sidebarCollapsed && <span>{item.label}</span>} {!sidebarCollapsed && <span>{item.label}</span>}
@@ -772,6 +816,23 @@ export function MailPage() {
/> />
) : mailView === "scheduled" ? ( ) : mailView === "scheduled" ? (
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} /> <PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
) : mailView === "sendQueue" && canViewSendQueue ? (
<SendQueueView
compact={isMobile || displayMode === "compact"}
items={visibleSendQueueItems}
total={sendQueueItems.length}
loading={sendQueue.isLoading}
query={query}
status={sendQueueStatus}
pendingId={sendQueuePendingId}
onStatusChange={setSendQueueStatus}
onRetry={(item) => retrySendQueue.mutate(item)}
onCancel={(item) => cancelSendQueue.mutate(item)}
onAudit={(item) => setSendQueueAuditId(item.id)}
canMutate={canSendMail}
/>
) : mailView === "sendQueue" ? (
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
) : isMobile || displayMode === "compact" ? ( ) : isMobile || displayMode === "compact" ? (
<CompactMailView <CompactMailView
title={viewTitle} title={viewTitle}
@@ -900,7 +961,7 @@ export function MailPage() {
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>} {canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
<div className="relative basis-full"> <div className="relative basis-full">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" /> <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
</div> </div>
</header> </header>
)} )}
@@ -924,7 +985,7 @@ export function MailPage() {
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"} {autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
</div> </div>
)} )}
{mailView !== "scheduled" && ( {mailView !== "scheduled" && mailView !== "sendQueue" && (
<> <>
{canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button>} {canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button>}
<DropdownMenu> <DropdownMenu>
@@ -944,7 +1005,7 @@ export function MailPage() {
</div> </div>
<div className="relative w-full max-w-md"> <div className="relative w-full max-w-md">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" /> <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
</div> </div>
</header> </header>
{contentView} {contentView}
@@ -954,7 +1015,13 @@ export function MailPage() {
)} )}
</SidebarProvider> </SidebarProvider>
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} /> <ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} />
<SendQueueAuditDialog
open={!!sendQueueAuditId}
loading={sendQueueAudit.isLoading}
events={sendQueueAudit.data?.items || []}
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
/>
<ConfirmDialog <ConfirmDialog
open={!!pendingConfirm} open={!!pendingConfirm}
title={pendingConfirm?.title || ""} title={pendingConfirm?.title || ""}
@@ -969,7 +1036,7 @@ export function MailPage() {
) )
} }
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean): MailMenuItem[] { function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean): MailMenuItem[] {
const byName = new Map(folders.map((item) => [item.name, item])) const byName = new Map(folders.map((item) => [item.name, item]))
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 }) const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 })
for (const item of folders) { for (const item of folders) {
@@ -985,10 +1052,13 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
})) }))
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount } const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount }
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount } const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount }
const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: <History className="h-4 w-4" />, count: sendQueueCount }
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox") const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0 const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
if (!includeScheduled) return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)] const specialItems: MailMenuItem[] = [starredItem]
return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)] if (includeScheduled) specialItems.push(scheduledItem)
if (includeSendQueue) specialItems.push(sendQueueItem)
return [...folderItems.slice(0, insertAt), ...specialItems, ...folderItems.slice(insertAt)]
} }
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> } function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
@@ -996,6 +1066,7 @@ function MessageSkeleton() { return <div className="space-y-0">{Array.from({ len
function getEmptyMessage(mailView: MailView, folder: string, total: number) { function getEmptyMessage(mailView: MailView, folder: string, total: number) {
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件" if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务"
if (total > 0) return "当前筛选条件下没有邮件" if (total > 0) return "当前筛选条件下没有邮件"
if (mailView === "starred") return "暂无星标邮件" if (mailView === "starred") return "暂无星标邮件"
if (mailView === "label") return "当前标签没有邮件" if (mailView === "label") return "当前标签没有邮件"
@@ -1114,6 +1185,180 @@ function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) {
) )
} }
const sendQueueStatusOptions: { value: SendQueueStatus | "all"; label: string }[] = [
{ value: "all", label: "全部状态" },
{ value: "queued", label: "排队中" },
{ value: "sending", label: "发送中" },
{ value: "failed", label: "发送失败" },
{ value: "delivered", label: "已投递" },
{ value: "canceled", label: "已取消" },
]
function SendQueueView({
compact,
items,
total,
loading,
query,
status,
pendingId,
onStatusChange,
onRetry,
onCancel,
onAudit,
canMutate,
}: {
compact: boolean
items: SendQueueItem[]
total: number
loading: boolean
query: string
status: SendQueueStatus | "all"
pendingId: string
onStatusChange: (status: SendQueueStatus | "all") => void
onRetry: (item: SendQueueItem) => void
onCancel: (item: SendQueueItem) => void
onAudit: (item: SendQueueItem) => void
canMutate: boolean
}) {
const empty = query.trim() ? "当前搜索没有匹配的发送任务" : "发送队列为空"
return (
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div className={cn("flex shrink-0 items-center justify-between gap-3 border-b", compact ? "min-h-12 px-4 py-2" : "h-14 px-5")}>
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold"><History className="h-4 w-4" /></div>
<div className="text-xs text-muted-foreground">{items.length} / {total} </div>
</div>
<Select value={status} onValueChange={(value) => onStatusChange(value as SendQueueStatus | "all")}>
<SelectTrigger className="h-9 w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sendQueueStatusOptions.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<ScrollArea className="min-h-0 flex-1">
{loading && <ScheduledSendSkeleton />}
{!loading && items.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{empty}</div>}
{!loading && items.map((item) => (
<SendQueueRow
key={item.id}
item={item}
compact={compact}
pending={pendingId === item.id}
onRetry={() => onRetry(item)}
onCancel={() => onCancel(item)}
onAudit={() => onAudit(item)}
canMutate={canMutate}
/>
))}
</ScrollArea>
</div>
)
}
function SendQueueRow({ item, compact, pending, onRetry, onCancel, onAudit, canMutate }: { item: SendQueueItem; compact: boolean; pending: boolean; onRetry: () => void; onCancel: () => void; onAudit: () => void; canMutate: boolean }) {
const recipients = item.recipients?.length ? item.recipients.join(", ") : "未记录收件人"
const failure = item.lastError || item.error || item.failureReason || ""
const canRetry = item.status === "failed"
const canCancel = item.status === "queued" || item.status === "failed"
return (
<div className={cn("border-b transition-colors hover:bg-accent/40", compact ? "p-4" : "px-5 py-4")}>
<div className={cn("gap-4", compact ? "space-y-3" : "grid grid-cols-[minmax(0,1fr)_210px_220px] items-center")}>
<div className="min-w-0">
<div className="mb-1 flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{item.subject || "(无主题)"}</span>
<SendQueueStatusBadge status={item.status} />
</div>
<div className="truncate text-xs text-muted-foreground"> {recipients}</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{sendQueueSourceLabel(item.source)}</span>
<span>{item.attemptCount}/{item.maxAttempts}</span>
{item.nextAttemptAt && <span>{formatDateTime(item.nextAttemptAt)}</span>}
</div>
{failure && <div className="mt-2 line-clamp-2 text-xs text-destructive">{failure}</div>}
</div>
<div className="space-y-1 text-sm">
<div className="text-xs text-muted-foreground"></div>
<div className="font-medium">{formatDateTime(item.updatedAt || item.createdAt)}</div>
{item.deliveredAt && <div className="text-xs text-muted-foreground"> {formatDateTime(item.deliveredAt)}</div>}
</div>
<div className={cn("flex flex-wrap gap-2", compact ? "justify-start" : "justify-end")}>
<Button type="button" variant="outline" size="sm" onClick={onAudit}>
<History className="h-4 w-4" />线
</Button>
{canMutate && canRetry && (
<Button type="button" variant="outline" size="sm" disabled={pending} onClick={onRetry}>
<RotateCcw className="h-4 w-4" />{pending ? "处理中..." : "重试"}
</Button>
)}
{canMutate && canCancel && (
<Button type="button" variant="destructive" size="sm" disabled={pending} onClick={onCancel}>
{pending ? "处理中..." : "取消"}
</Button>
)}
</div>
</div>
</div>
)
}
function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) {
const label = status === "queued" ? "排队中" : status === "sending" ? "发送中" : status === "delivered" ? "已投递" : status === "failed" ? "发送失败" : "已取消"
return (
<Badge variant={status === "failed" ? "destructive" : status === "sending" || status === "queued" ? "secondary" : "outline"} className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal">
{label}
</Badge>
)
}
function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
<DialogHeader>
<DialogTitle>线</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto pr-1">
{loading && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
{!loading && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground"></div>}
{!loading && events.length > 0 && (
<div className="space-y-3">
{events.map((event) => (
<div key={event.id} className="rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
{event.status && <SendQueueStatusBadge status={event.status} />}
<span>{event.message || event.event || event.eventType || "队列事件"}</span>
</div>
<span className="text-xs text-muted-foreground">{formatDateTime(event.createdAt)}</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{typeof event.attemptCount === "number" && <span>{event.attemptCount}</span>}
{event.error && <span className="text-destructive">{event.error}</span>}
</div>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function sendQueueSourceLabel(source: string) {
const normalized = source.toLowerCase()
if (normalized === "submission") return "SMTP Submission"
if (normalized === "webmail") return "Webmail"
if (normalized === "scheduled") return "定时发送"
return source || "未知"
}
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete" type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) { function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
@@ -1718,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">
@@ -1779,6 +2025,41 @@ function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel,
) )
} }
function AuthenticationResultRow({ message }: { message: MailMessage }) {
const auth = message.authentication || { authenticationResults: "", receivedSpf: "", spf: "unknown", dkim: "unknown", dmarc: "unknown" }
const title = [auth.authenticationResults, auth.receivedSpf].filter(Boolean).join("\n\n")
return (
<MessageMetaRow label="Auth">
<div className="flex flex-wrap gap-1.5" title={title || undefined}>
<AuthStatusBadge label="SPF" value={auth.spf} />
<AuthStatusBadge label="DKIM" value={auth.dkim} />
<AuthStatusBadge label="DMARC" value={auth.dmarc} />
</div>
</MessageMetaRow>
)
}
function AuthStatusBadge({ label, value }: { label: string; value?: string }) {
const status = normalizeAuthStatus(value)
return (
<Badge variant="outline" className={cn("rounded-md font-mono text-[11px] font-normal", authStatusClassName(status))}>
{label}:{status}
</Badge>
)
}
function normalizeAuthStatus(value?: string) {
const status = (value || "").trim().toLowerCase()
if (["pass", "fail", "softfail", "neutral", "temperror", "permerror", "none"].includes(status)) return status
return "unknown"
}
function authStatusClassName(status: string) {
if (status === "pass") return "border-emerald-300 bg-emerald-50 text-emerald-700"
if (["fail", "softfail", "permerror"].includes(status)) return "border-red-300 bg-red-50 text-red-700"
return "border-slate-300 bg-slate-50 text-slate-600"
}
function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) { function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]"> <div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
+54 -11
View File
@@ -812,8 +812,14 @@ type RuleCreatePayload = {
enabled: boolean enabled: boolean
} }
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" } type RuleConditionField = NonNullable<MailRuleCondition["field"]>
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" } type RuleConditionOperator = NonNullable<MailRuleCondition["operator"]>
const conditionFieldLabels: Record<RuleConditionField, string> = { from: "发件人地址", to: "收件人地址", cc: "抄送地址", subject: "邮件主题", body: "邮件正文", attachment: "附件名称", size: "邮件大小", date: "收信日期" }
const conditionOperatorLabels: Record<RuleConditionOperator, string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是", gt: "大于", gte: "大于等于", lt: "小于", lte: "小于等于", before: "早于", after: "晚于", on: "当天" }
const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with"]
const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte", "equals", "not-equals"]
const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"]
const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[]
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" } const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) { function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) {
@@ -860,7 +866,14 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
}, [open, labels]) }, [open, labels])
function updateCondition(index: number, patch: Partial<MailRuleCondition>) { function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item)) setConditions((items) => items.map((item, i) => {
if (i !== index) return item
const next = { ...item, ...patch }
if (patch.field && !conditionOperatorsForField(patch.field).includes(next.operator || "contains")) {
next.operator = defaultConditionOperator(patch.field)
}
return next
}))
} }
function updateAction(index: number, patch: Partial<MailRuleAction>) { function updateAction(index: number, patch: Partial<MailRuleAction>) {
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item)) setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
@@ -870,7 +883,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) } function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) } function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
const validConditions = conditions.map((item) => ({ ...item, value: item.value.trim() })).filter((item) => item.value) const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value) const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value)
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
@@ -902,15 +915,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
<div className="space-y-3"> <div className="space-y-3">
{conditions.map((condition, index) => ( {conditions.map((condition, index) => (
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]"> <div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}> <Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent> <SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
</Select> </Select>
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}> <Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent> <SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
</Select> </Select>
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" /> <Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button> <Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button> <Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
</div> </div>
@@ -1012,9 +1025,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
return { type: action.type } return { type: action.type }
} }
function conditionOperatorsForField(field?: MailRuleCondition["field"]) {
if (field === "size") return sizeConditionOperators
if (field === "date") return dateConditionOperators
return textConditionOperators
}
function defaultConditionOperator(field?: MailRuleCondition["field"]): RuleConditionOperator {
if (field === "size") return "gte"
if (field === "date") return "on"
return "contains"
}
function conditionPlaceholder(field?: MailRuleCondition["field"]) {
if (field === "size") return "例如 10mb"
if (field === "date") return "选择日期"
if (field === "attachment") return "输入附件名或扩展名"
return "输入值"
}
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") { function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[] const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join("") || "无条件" return items.map(conditionItemSummary).join("") || "无条件"
}
function conditionItemSummary(item: MailRuleCondition): string {
if (item.conditions?.length) {
const mode = item.matchMode === "any" ? "任一" : "全部"
return `${mode}(${item.conditions.map(conditionItemSummary).join("")})`
}
const field = item.field || "from"
const operator = item.operator || defaultConditionOperator(field)
return `${conditionFieldLabels[field]} ${conditionOperatorLabels[operator]} ${item.value || ""}`
} }
function actionSummary(action: MailRuleAction) { function actionSummary(action: MailRuleAction) {
@@ -1063,7 +1105,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
} }
function StatsSummary({ stats }: { stats?: MailStats }) { function StatsSummary({ stats }: { stats?: MailStats }) {
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: stats?.attachmentCount || 0 }, { label: "容量", value: formatBytes(stats?.storageBytes || 0) }] const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: `${stats?.attachmentCount || 0} / ${formatBytes(stats?.attachmentBytes || 0)}` }, { label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel }]
return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div> return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div>
} }
+5 -5
View File
@@ -29,23 +29,23 @@ auth_policy_request_attributes = login=%{requested_username} remote=%{rip} proto
namespace inbox { namespace inbox {
inbox = yes inbox = yes
mailbox Drafts { mailbox Drafts {
auto = create auto = subscribe
special_use = \Drafts special_use = \Drafts
} }
mailbox Sent { mailbox Sent {
auto = create auto = subscribe
special_use = \Sent special_use = \Sent
} }
mailbox Trash { mailbox Trash {
auto = create auto = subscribe
special_use = \Trash special_use = \Trash
} }
mailbox Archive { mailbox Archive {
auto = create auto = subscribe
special_use = \Archive special_use = \Archive
} }
mailbox Spam { mailbox Spam {
auto = create auto = subscribe
special_use = \Junk special_use = \Junk
} }
} }