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:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user