diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 5ff360d..2a3524d 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -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 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 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 ( id TEXT PRIMARY KEY, message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index ff620ec..ca1dee2 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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) { a := newTestApp(t) host, port, received := startCapturingSMTP(t, 2) diff --git a/apps/api/internal/app/submission.go b/apps/api/internal/app/submission.go index ef0e764..e745987 100644 --- a/apps/api/internal/app/submission.go +++ b/apps/api/internal/app/submission.go @@ -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 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") } @@ -363,13 +366,52 @@ func (a *App) insertSentMessageOnce(ctx context.Context, msg storedMessage, atta 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) if err == nil { + if err := a.insertSentDedupeKey(ctx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil && !errors.Is(err, errSentDedupeExists) { + return "", err + } return "", nil } if err != nil && !errors.Is(err, sql.ErrNoRows) { 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) { diff --git a/deploy/.env.example b/deploy/.env.example index 161928d..9535933 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -84,9 +84,13 @@ LANQIN_TURNSTILE_SECRET_KEY= # SMTP 发信 # ========================= # 单容器部署默认提交给容器内 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 改成外部服务配置。 LANQIN_SMTP_HOST=127.0.0.1 LANQIN_SMTP_PORT=25 +LANQIN_STACK_SMTP_HOST= +LANQIN_STACK_SMTP_PORT= LANQIN_SMTP_USERNAME= LANQIN_SMTP_PASSWORD= diff --git a/deploy/README.md b/deploy/README.md index 08ea93a..c217419 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -170,6 +170,8 @@ LANQIN_SMTP_PORT=25 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 会话被中断。优先检查: ```bash diff --git a/deploy/docker-compose.stack.yml b/deploy/docker-compose.stack.yml index b11f079..e54624d 100644 --- a/deploy/docker-compose.stack.yml +++ b/deploy/docker-compose.stack.yml @@ -2,6 +2,9 @@ services: api: image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest} env_file: .env + environment: + LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix} + LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25} volumes: - ./data:/data - ./mail:/var/mail/vhosts:ro