feat(app): 引入发送队列与可授权发件人

- Webmail/API 与第三方提交统一先写入已发送,再入发送队列异步投递。
- 新增队列重试、失败审计、陈旧任务恢复与发送状态追踪。
- 支持本人邮箱、启用别名和 `send_as_grants` 显式授权的 send-as。
- 更新 README 与部署文档,说明新的外发流程和排障方式。
This commit is contained in:
LanQin_
2026-06-24 11:27:32 +08:00
parent 5c9bea075a
commit 7b0717412c
7 changed files with 646 additions and 46 deletions
+83
View File
@@ -74,6 +74,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
go a.maildirWorker(workerCtx)
}
go a.sendQueueWorker(workerCtx)
go a.smtpEventsCleanupWorker(workerCtx)
return a, nil
}
@@ -240,6 +241,53 @@ func (a *App) migrate(ctx context.Context) error {
created_at TEXT NOT NULL,
PRIMARY KEY(mailbox_id, folder_id, message_id)
)`,
`CREATE TABLE IF NOT EXISTS send_as_grants (
id TEXT PRIMARY KEY,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
address TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(mailbox_id, address)
)`,
`CREATE TABLE IF NOT EXISTS send_queue (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
sent_message_id TEXT NOT NULL DEFAULT '',
message_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
mail_from TEXT NOT NULL,
header_from TEXT NOT NULL,
recipients_json TEXT NOT NULL,
mime_base64 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
next_attempt_at TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
delivered_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_send_queue_due ON send_queue(status, next_attempt_at, created_at)`,
`CREATE TABLE IF NOT EXISTS send_audit_events (
id TEXT PRIMARY KEY,
queue_id TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL DEFAULT '',
mailbox_id TEXT NOT NULL DEFAULT '',
sent_message_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
event TEXT NOT NULL,
status TEXT NOT NULL,
mail_from TEXT NOT NULL DEFAULT '',
header_from TEXT NOT NULL DEFAULT '',
recipients_json TEXT NOT NULL DEFAULT '[]',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_send_audit_events_created ON send_audit_events(created_at)`,
`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
@@ -377,12 +425,47 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migratePermissionGroupLimits(ctx); err != nil {
return err
}
if err := a.migrateSendQueueMessageID(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err
}
return nil
}
func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
if err != nil {
return err
}
hasMessageID := false
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 {
rows.Close()
return err
}
if name == "message_id" {
hasMessageID = true
}
}
if err := rows.Close(); err != nil {
return err
}
if !hasMessageID {
if _, err := a.db.ExecContext(ctx, `ALTER TABLE send_queue ADD COLUMN message_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
_, err = a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_send_queue_mailbox_source_message_id ON send_queue(mailbox_id, source, message_id) WHERE message_id <> ''`)
return err
}
func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(permission_groups)`)
if err != nil {