chore(deploy): 优化 split stack 的 SMTP 配置

- 为 `docker-compose.stack.yml` 的 API 容器默认注入 `LANQIN_SMTP_HOST/PORT`,避免 Webmail 发信配置漂移。
- 补充 `.env.example` 和 `README`,说明 split stack 默认走 `postfix`,以及外部 SMTP 的覆盖方式。
- 新增 `sent_message_dedupe_keys` 表和相关读写逻辑,确保已发送消息去重可回收,并补充对应测试。
This commit is contained in:
LanQin_
2026-06-24 11:03:01 +08:00
parent fa450c9f9b
commit 5c9bea075a
6 changed files with 70 additions and 1 deletions
+7
View File
@@ -233,6 +233,13 @@ func (a *App) migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, from_name, snippet)`, `CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, from_name, snippet)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
`CREATE TABLE IF NOT EXISTS sent_message_dedupe_keys (
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
message_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(mailbox_id, folder_id, message_id)
)`,
`CREATE TABLE IF NOT EXISTS attachments ( `CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
+11
View File
@@ -960,6 +960,17 @@ func TestSubmissionSentCopyDedupesByMessageID(t *testing.T) {
} }
} }
func TestSentMessageDedupeTableExists(t *testing.T) {
a := newTestApp(t)
var count int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='sent_message_dedupe_keys'`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("sent message dedupe table count=%d, want 1", count)
}
}
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) { func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2) host, port, received := startCapturingSMTP(t, 2)
+43 -1
View File
@@ -286,6 +286,9 @@ func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, ma
if err := a.sendSMTP(mb.Address, recipients, prepared); err != nil { if err := a.sendSMTP(mb.Address, recipients, prepared); err != nil {
if sentID != "" { if sentID != "" {
a.deleteMessage(ctx, sentID) a.deleteMessage(ctx, sentID)
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
}
} }
return smtpError(451, smtpserver.EnhancedCode{4, 4, 0}, "smtp relay failed") return smtpError(451, smtpserver.EnhancedCode{4, 4, 0}, "smtp relay failed")
} }
@@ -363,13 +366,52 @@ func (a *App) insertSentMessageOnce(ctx context.Context, msg storedMessage, atta
var existing string var existing string
err := a.db.QueryRowContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' LIMIT 1`, msg.MailboxID, sentFolderID, msg.MessageID).Scan(&existing) err := a.db.QueryRowContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' LIMIT 1`, msg.MailboxID, sentFolderID, msg.MessageID).Scan(&existing)
if err == nil { if err == nil {
if err := a.insertSentDedupeKey(ctx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil && !errors.Is(err, errSentDedupeExists) {
return "", err
}
return "", nil return "", nil
} }
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err return "", err
} }
if err := a.insertSentDedupeKey(ctx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil {
if errors.Is(err, errSentDedupeExists) {
return "", nil
}
return "", err
}
} }
return a.insertMessage(ctx, msg, attachments) id, err := a.insertMessage(ctx, msg, attachments)
if err != nil {
if msg.MessageID != "" {
a.deleteSentDedupeKey(ctx, msg.MailboxID, sentFolderID, msg.MessageID)
}
return "", err
}
return id, nil
}
var errSentDedupeExists = errors.New("sent message already exists")
func (a *App) insertSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) error {
if strings.TrimSpace(messageID) == "" {
return nil
}
res, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO sent_message_dedupe_keys(mailbox_id,folder_id,message_id,created_at) VALUES(?,?,?,?)`, mailboxID, folderID, messageID, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return errSentDedupeExists
}
return nil
}
func (a *App) deleteSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) {
if strings.TrimSpace(messageID) == "" {
return
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM sent_message_dedupe_keys WHERE mailbox_id=? AND folder_id=? AND message_id=?`, mailboxID, folderID, messageID)
} }
func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) { func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) {
+4
View File
@@ -84,9 +84,13 @@ LANQIN_TURNSTILE_SECRET_KEY=
# SMTP 发信 # SMTP 发信
# ========================= # =========================
# 单容器部署默认提交给容器内 Postfix。 # 单容器部署默认提交给容器内 Postfix。
# Split stack 会由 docker-compose.stack.yml 默认覆盖为 postfix:25。
# 如需在 split stack 使用外部 SMTP,可设置 LANQIN_STACK_SMTP_HOST / LANQIN_STACK_SMTP_PORT。
# 如果要走外部 SMTP,把 Host/Port/Username/Password 改成外部服务配置。 # 如果要走外部 SMTP,把 Host/Port/Username/Password 改成外部服务配置。
LANQIN_SMTP_HOST=127.0.0.1 LANQIN_SMTP_HOST=127.0.0.1
LANQIN_SMTP_PORT=25 LANQIN_SMTP_PORT=25
LANQIN_STACK_SMTP_HOST=
LANQIN_STACK_SMTP_PORT=
LANQIN_SMTP_USERNAME= LANQIN_SMTP_USERNAME=
LANQIN_SMTP_PASSWORD= LANQIN_SMTP_PASSWORD=
+2
View File
@@ -170,6 +170,8 @@ LANQIN_SMTP_PORT=25
LANQIN_SMTP_REQUIRE_TLS=false LANQIN_SMTP_REQUIRE_TLS=false
``` ```
Split stack 使用 `docker-compose.stack.yml` 时,API 容器默认会把 `LANQIN_SMTP_HOST` 覆盖为 `postfix`,让 Webmail 和 SMTP 提交都 relay 到 Postfix service。只有改用外部 SMTP 时才需要在 `.env` 明确填写 `LANQIN_STACK_SMTP_HOST` / `LANQIN_STACK_SMTP_PORT`
如果页面提示 `smtp delivery failed: EOF`,通常是 Postfix 会话被中断。优先检查: 如果页面提示 `smtp delivery failed: EOF`,通常是 Postfix 会话被中断。优先检查:
```bash ```bash
+3
View File
@@ -2,6 +2,9 @@ services:
api: api:
image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest} image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest}
env_file: .env env_file: .env
environment:
LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix}
LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25}
volumes: volumes:
- ./data:/data - ./data:/data
- ./mail:/var/mail/vhosts:ro - ./mail:/var/mail/vhosts:ro