From d1876691dd448a45a81c943b24a8231ad82f6d06 Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Wed, 24 Jun 2026 13:10:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(submission):=20=E8=B0=83=E6=95=B4=20SMTP=20?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=B8=8E=E5=8F=91=E4=BB=B6=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启用 SMTP submission 时改为强制使用可读证书文件,并禁止使用容器自带测试证书。 - 收紧发件人校验,拒绝未授权的 From 地址,并支持别名多目标授权。 - 优化发送队列重入与重复消息处理,避免重复记录冲突。 - 让提交证书按需热加载,并稳定 MIME 头序列化顺序。 - 更新部署示例与文档,明确 submission 的证书配置要求。 --- README.md | 1 + apps/api/internal/app/app.go | 24 +++ apps/api/internal/app/app_test.go | 238 +++++++++++++++++++++++++ apps/api/internal/app/mail_handlers.go | 5 + apps/api/internal/app/send_queue.go | 44 +++-- apps/api/internal/app/submission.go | 82 +++------ deploy/.env.example | 8 +- deploy/README.md | 4 +- deploy/all-in-one/entrypoint.sh | 12 +- deploy/docker-compose.stack.yml | 2 + 10 files changed, 345 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index d5a7649..d5650cf 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build ## SMTP 提交 - 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。 +- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`;API 不会用 localhost 自签证书对外提供 465/587。 - Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。 - Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。 - 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。 diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 29689f5..5069cb4 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -462,6 +462,30 @@ func (a *App) migrateSendQueueMessageID(ctx context.Context) error { return err } } + if _, err := a.db.ExecContext(ctx, `DELETE FROM send_queue + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY mailbox_id, source, message_id + ORDER BY + CASE status + WHEN 'queued' THEN 0 + WHEN 'sending' THEN 1 + WHEN 'failed' THEN 2 + WHEN 'delivered' THEN 3 + ELSE 4 + END, + created_at DESC, + id DESC + ) AS row_num + FROM send_queue + WHERE message_id <> '' + ) + WHERE row_num > 1 + )`); 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 } diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 65eaed3..7e16843 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -4,14 +4,21 @@ import ( "bufio" "bytes" "context" + "crypto/rand" + "crypto/rsa" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/base64" "encoding/json" + "encoding/pem" "io" "log/slog" + "math/big" "net" "net/http" "net/http/httptest" + "net/textproto" "os" "path/filepath" "strings" @@ -60,6 +67,49 @@ func defaultAdminUserAndMailbox(t *testing.T, a *App) (*User, *Mailbox) { return user, mb } +func writeTestCertificateFiles(t *testing.T, hostname string) (string, string) { + t.Helper() + if strings.TrimSpace(hostname) == "" { + hostname = "localhost" + } + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: hostname, + }, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{hostname, "localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certPath := filepath.Join(t.TempDir(), "cert.pem") + keyPath := filepath.Join(filepath.Dir(certPath), "key.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + t.Fatal(err) + } + return certPath, keyPath +} + func startFakeSMTP(t *testing.T) (string, string, <-chan string) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -829,6 +879,27 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) { } } +func TestMailSendRejectsUnauthorizedFrom(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, login) + } + var errBody map[string]any + if code := admin.do("POST", "/api/mail/send", map[string]any{ + "from": "attacker@example.com", + "to": []string{"person@example.com"}, + "subject": "bad from", + "text": "hello", + }, &errBody); code != http.StatusForbidden { + t.Fatalf("unauthorized from code=%d body=%v", code, errBody) + } +} + func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) { a := newTestApp(t) a.cfg.SMTPHost = "postfix" @@ -1023,6 +1094,25 @@ func TestSubmissionRejectsMismatchedSender(t *testing.T) { } } +func TestSerializeMessageUsesStableHeaderOrder(t *testing.T) { + header := textproto.MIMEHeader{ + "Subject": {"stable"}, + "From": {"admin@lanqin.local"}, + "Message": {"custom"}, + "X-Zebra": {"z"}, + "X-Answer": {"a"}, + } + first := string(serializeMessage(header, []byte("body"))) + for i := 0; i < 20; i++ { + if got := string(serializeMessage(header, []byte("body"))); got != first { + t.Fatalf("serializeMessage is not stable:\nfirst=%q\ngot=%q", first, got) + } + } + if !strings.HasPrefix(first, "From: admin@lanqin.local\r\n") { + t.Fatalf("unexpected header order: %q", first) + } +} + func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) { a := newTestApp(t) a.cfg.SMTPHost = "127.0.0.1" @@ -1129,6 +1219,48 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) { } } +func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) { + a := newTestApp(t) + host, port, received := startCapturingSMTP(t, 2) + a.cfg.SMTPHost = host + a.cfg.SMTPPort = port + user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!") + if err != nil { + t.Fatal(err) + } + raw := "From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: resend\r\nMessage-ID: \r\n\r\nbody" + if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil { + t.Fatal(err) + } + if err := a.processDueSendQueue(context.Background()); err != nil { + t.Fatal(err) + } + select { + case <-received: + case <-time.After(2 * time.Second): + t.Fatal("first delivery not received") + } + if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil { + t.Fatal(err) + } + if err := a.processDueSendQueue(context.Background()); err != nil { + t.Fatal(err) + } + select { + case <-received: + case <-time.After(2 * time.Second): + t.Fatal("requeued delivered message was not relayed") + } + var status string + var attemptCount int + if err := a.db.QueryRow(`SELECT status,attempt_count FROM send_queue WHERE mailbox_id=? AND message_id=?`, mb.ID, "").Scan(&status, &attemptCount); err != nil { + t.Fatal(err) + } + if status != sendQueueStatusDelivered || attemptCount != 1 { + t.Fatalf("queue status=%q attempts=%d, want delivered attempts=1", status, attemptCount) + } +} + func TestSubmissionAllowsAuthorizedAliasSendAs(t *testing.T) { a := newTestApp(t) ctx := context.Background() @@ -1156,6 +1288,22 @@ func TestSubmissionAllowsAuthorizedAliasSendAs(t *testing.T) { } } +func TestSubmissionAllowsMultiDestinationAliasSendAs(t *testing.T) { + a := newTestApp(t) + ctx := context.Background() + if _, err := a.db.ExecContext(ctx, `INSERT INTO aliases(id,domain_id,source,destination,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`, newID("als"), mustDefaultDomainID(t, a), "team-many@lanqin.local", "other@lanqin.local, admin@lanqin.local", 1, a.now().UTC().Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil { + t.Fatal(err) + } + user, mb, err := a.authenticateSubmission(ctx, "admin@lanqin.local", "ChangeMe123!") + if err != nil { + t.Fatal(err) + } + raw := "From: Team \r\nTo: person@example.com\r\nSubject: alias send-as\r\nMessage-ID: \r\n\r\nbody" + if err := a.submitSMTPMessage(ctx, user, mb, "team-many@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil { + t.Fatalf("authorized multi-destination alias send-as should submit: %v", err) + } +} + func TestSubmissionAllowsExplicitSendAsGrant(t *testing.T) { a := newTestApp(t) ctx := context.Background() @@ -1195,11 +1343,101 @@ func TestSentMessageDedupeTableExists(t *testing.T) { } } +func TestSendQueueMessageIDMigrationDropsDuplicatesBeforeUniqueIndex(t *testing.T) { + a := newTestApp(t) + user, mb := defaultAdminUserAndMailbox(t, a) + if _, err := a.db.Exec(`DROP INDEX IF EXISTS idx_send_queue_mailbox_source_message_id`); err != nil { + t.Fatal(err) + } + if _, err := a.db.Exec(`INSERT INTO send_queue(id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,status,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + "dup_old", user.ID, mb.ID, "sent1", "", sendSourceSubmission, "admin@lanqin.local", "admin@lanqin.local", "[]", "bWVzc2FnZQ==", sendQueueStatusDelivered, a.now().UTC().Format(time.RFC3339Nano), "2026-06-24T00:00:00Z", "2026-06-24T00:00:00Z"); err != nil { + t.Fatal(err) + } + if _, err := a.db.Exec(`INSERT INTO send_queue(id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,status,next_attempt_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + "dup_keep", user.ID, mb.ID, "sent2", "", sendSourceSubmission, "admin@lanqin.local", "admin@lanqin.local", "[]", "bWVzc2FnZQ==", sendQueueStatusQueued, a.now().UTC().Format(time.RFC3339Nano), "2026-06-24T00:01:00Z", "2026-06-24T00:01:00Z"); err != nil { + t.Fatal(err) + } + if err := a.migrateSendQueueMessageID(context.Background()); err != nil { + t.Fatal(err) + } + var count int + if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE mailbox_id=? AND source=? AND message_id=''`, mb.ID, sendSourceSubmission).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("duplicate queue rows count=%d, want 1", count) + } + var keptID string + if err := a.db.QueryRow(`SELECT id FROM send_queue WHERE mailbox_id=? AND source=? AND message_id=''`, mb.ID, sendSourceSubmission).Scan(&keptID); err != nil { + t.Fatal(err) + } + if keptID != "dup_keep" { + t.Fatalf("kept queue id=%q, want dup_keep", keptID) + } +} + +func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) { + a := newTestApp(t) + a.cfg.SubmissionAddr = ":587" + a.cfg.SubmissionTLSAddr = ":465" + if _, err := LoadServerTLSConfig(a.cfg); err == nil { + t.Fatal("submission TLS config should require certificate files") + } +} + +func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) { + a := newTestApp(t) + certPath, keyPath := writeTestCertificateFiles(t, "first.example.test") + a.cfg.TLSCertFile = certPath + a.cfg.TLSKeyFile = keyPath + tlsConfig, err := LoadServerTLSConfig(a.cfg) + if err != nil { + t.Fatal(err) + } + first, err := tlsConfig.GetCertificate(&tls.ClientHelloInfo{}) + if err != nil { + t.Fatal(err) + } + firstLeaf, err := x509.ParseCertificate(first.Certificate[0]) + if err != nil { + t.Fatal(err) + } + nextCertPath, nextKeyPath := writeTestCertificateFiles(t, "second.example.test") + nextCert, err := os.ReadFile(nextCertPath) + if err != nil { + t.Fatal(err) + } + nextKey, err := os.ReadFile(nextKeyPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(certPath, nextCert, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, nextKey, 0o600); err != nil { + t.Fatal(err) + } + second, err := tlsConfig.GetCertificate(&tls.ClientHelloInfo{}) + if err != nil { + t.Fatal(err) + } + secondLeaf, err := x509.ParseCertificate(second.Certificate[0]) + if err != nil { + t.Fatal(err) + } + if firstLeaf.Subject.CommonName != "first.example.test" || secondLeaf.Subject.CommonName != "second.example.test" { + t.Fatalf("cert reload common names first=%q second=%q", firstLeaf.Subject.CommonName, secondLeaf.Subject.CommonName) + } +} + func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) { a := newTestApp(t) host, port, received := startCapturingSMTP(t, 2) a.cfg.SMTPHost = host a.cfg.SMTPPort = port + certPath, keyPath := writeTestCertificateFiles(t, "mail.example.test") + a.cfg.TLSCertFile = certPath + a.cfg.TLSKeyFile = keyPath tlsConfig, err := LoadServerTLSConfig(a.cfg) if err != nil { t.Fatal(err) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 43bb84e..f49b6a5 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -416,6 +416,10 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusTooManyRequests, err.Error()) return } + if errors.Is(err, errSenderNotAuthorized) { + respondError(w, http.StatusForbidden, err.Error()) + return + } respondError(w, http.StatusInternalServerError, err.Error()) return } @@ -426,6 +430,7 @@ var errNoRecipients = errors.New("at least one recipient is required") var errInvalidMIME = errors.New("invalid mime message") var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit") var errSMTPRateLimited = errors.New("smtp send rate limit exceeded") +var errSenderNotAuthorized = errors.New("sender address is not authorized") func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) { if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil { diff --git a/apps/api/internal/app/send_queue.go b/apps/api/internal/app/send_queue.go index 8bff406..91d911a 100644 --- a/apps/api/internal/app/send_queue.go +++ b/apps/api/internal/app/send_queue.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/base64" "errors" - "fmt" "strings" "time" ) @@ -25,7 +24,8 @@ const ( sendSourceWebmail = "webmail" sendSourceSubmission = "submission" - sendQueueStaleAfter = 15 * time.Minute + sendQueueStaleAfter = 15 * time.Minute + sendQueueConcurrency = 4 ) type sendQueueInput struct { @@ -81,9 +81,9 @@ func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error return "", err } if existingID != id { - if 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=? AND attempt_count>=max_attempts`, - in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, sendQueueStatusFailed) + if status == sendQueueStatusDelivered || (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=?`, + 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 { return "", err } @@ -154,8 +154,28 @@ func (a *App) processDueSendQueue(ctx context.Context) error { if err := rows.Err(); err != nil { return err } + sem := make(chan struct{}, sendQueueConcurrency) + done := make(chan struct{}, len(ids)) for _, id := range ids { - a.processSendQueueItem(ctx, id) + select { + case <-ctx.Done(): + return ctx.Err() + case sem <- struct{}{}: + } + go func(id string) { + defer func() { + <-sem + done <- struct{}{} + }() + a.processSendQueueItem(ctx, id) + }(id) + } + for range ids { + select { + case <-ctx.Done(): + return ctx.Err() + case <-done: + } } return nil } @@ -322,7 +342,7 @@ func (a *App) authorizedSender(ctx context.Context, mb *Mailbox, from string) (s err := a.db.QueryRowContext(ctx, `SELECT display_name,enabled FROM send_as_grants WHERE mailbox_id=? AND address=?`, mb.ID, from).Scan(&displayName, &enabled) if err == nil { if enabled == 0 { - return "", "", fmt.Errorf("send-as address is disabled") + return "", "", errSenderNotAuthorized } return from, strings.TrimSpace(displayName), nil } @@ -331,8 +351,12 @@ func (a *App) authorizedSender(ctx context.Context, mb *Mailbox, from string) (s } var aliasDestination string err = a.db.QueryRowContext(ctx, `SELECT destination FROM aliases WHERE source=? AND enabled=1`, from).Scan(&aliasDestination) - if err == nil && normalizeEmail(aliasDestination) == normalizeEmail(mb.Address) { - return from, mb.DisplayName, nil + if err == nil { + for _, destination := range strings.Split(aliasDestination, ",") { + if normalizeEmail(destination) == normalizeEmail(mb.Address) { + return from, mb.DisplayName, nil + } + } } - return "", "", fmt.Errorf("send-as address is not authorized") + return "", "", errSenderNotAuthorized } diff --git a/apps/api/internal/app/submission.go b/apps/api/internal/app/submission.go index 16ac7c2..d81b3db 100644 --- a/apps/api/internal/app/submission.go +++ b/apps/api/internal/app/submission.go @@ -3,20 +3,15 @@ package app import ( "bytes" "context" - "crypto/rand" - "crypto/rsa" "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" "database/sql" - "encoding/pem" "errors" "fmt" "io" "log" - "math/big" netmail "net/mail" "net/textproto" + "sort" "strings" "time" @@ -25,7 +20,9 @@ import ( "golang.org/x/crypto/bcrypt" ) -const defaultSubmissionMaxRecipients = 200 +const ( + defaultSubmissionMaxRecipients = 200 +) type SubmissionServers struct { Plain *smtpserver.Server @@ -76,59 +73,20 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve } func LoadServerTLSConfig(cfg Config) (*tls.Config, error) { - cert, err := loadOrGenerateCertificate(cfg) - if err != nil { - return nil, err + certFile, keyFile := strings.TrimSpace(cfg.TLSCertFile), strings.TrimSpace(cfg.TLSKeyFile) + if certFile == "" || keyFile == "" { + return nil, errors.New("LANQIN_TLS_CERT_FILE and LANQIN_TLS_KEY_FILE are required when SMTP submission is enabled") } return &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, - }, nil -} - -func loadOrGenerateCertificate(cfg Config) (tls.Certificate, error) { - certFile, keyFile := strings.TrimSpace(cfg.TLSCertFile), strings.TrimSpace(cfg.TLSKeyFile) - if certFile != "" || keyFile != "" { - if certFile == "" || keyFile == "" { - return tls.Certificate{}, errors.New("both TLS certificate and key files are required") - } - return tls.LoadX509KeyPair(certFile, keyFile) - } - return generateSelfSignedCertificate(cfg.PublicHostname) -} - -func generateSelfSignedCertificate(hostname string) (tls.Certificate, error) { - if strings.TrimSpace(hostname) == "" { - hostname = "localhost" - } - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return tls.Certificate{}, err - } - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return tls.Certificate{}, err - } - now := time.Now().UTC() - tmpl := x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{ - CommonName: hostname, + MinVersion: tls.VersionTLS12, + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + return &cert, nil }, - NotBefore: now.Add(-time.Hour), - NotAfter: now.Add(24 * time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - DNSNames: []string{hostname, "localhost"}, - } - der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) - if err != nil { - return tls.Certificate{}, err - } - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) - return tls.X509KeyPair(certPEM, keyPEM) + }, nil } type submissionLogWriter struct { @@ -434,7 +392,15 @@ func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) { func serializeMessage(header textproto.MIMEHeader, body []byte) []byte { var buf bytes.Buffer - for key, values := range header { + keys := make([]string, 0, len(header)) + for key := range header { + keys = append(keys, key) + } + sort.SliceStable(keys, func(i, j int) bool { + return textproto.CanonicalMIMEHeaderKey(keys[i]) < textproto.CanonicalMIMEHeaderKey(keys[j]) + }) + for _, key := range keys { + values := header[key] canonical := textproto.CanonicalMIMEHeaderKey(key) for _, value := range values { fmt.Fprintf(&buf, "%s: %s\r\n", canonical, strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " ")) diff --git a/deploy/.env.example b/deploy/.env.example index 9535933..5ca83e2 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -28,7 +28,7 @@ LANQIN_PUBLIC_BASE_URL=https://mail.example.com # 邮件客户端 TLS 证书。用于 SMTP 465/587、IMAP 993、POP3 995。 # 生产环境请挂载真实证书,并确保域名包含 LANQIN_PUBLIC_HOSTNAME。 -# 留空时会使用容器自带 localhost 自签证书,第三方客户端会提示证书不匹配。 +# 留空时 Dovecot/Postfix 会使用容器自带 localhost 自签证书;LanQin API 的 SMTP submission 不会启用。 LANQIN_TLS_CERT_FILE= LANQIN_TLS_KEY_FILE= @@ -97,9 +97,9 @@ LANQIN_SMTP_PASSWORD= # 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。 LANQIN_SMTP_REQUIRE_TLS=false -# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465。 -LANQIN_SUBMISSION_ADDR=:587 -LANQIN_SUBMISSION_TLS_ADDR=:465 +# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。 +LANQIN_SUBMISSION_ADDR= +LANQIN_SUBMISSION_TLS_ADDR= LANQIN_SUBMISSION_MAX_MESSAGE_MB=35 # ========================= diff --git a/deploy/README.md b/deploy/README.md index 0a3a03e..9597cc1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -134,13 +134,15 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up ## 邮件客户端 TLS 证书 Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。 -如果第三方客户端连接 `465/587/993/995` 时提示证书是 `localhost`,说明 LanQin API 或 Dovecot 仍在使用容器自带的测试证书。 +如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。 生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件: ```env LANQIN_TLS_CERT_FILE=/certs/fullchain.pem LANQIN_TLS_KEY_FILE=/certs/privkey.pem +LANQIN_SUBMISSION_ADDR=:587 +LANQIN_SUBMISSION_TLS_ADDR=:465 ``` 单容器示例: diff --git a/deploy/all-in-one/entrypoint.sh b/deploy/all-in-one/entrypoint.sh index 4443330..1ca11d6 100644 --- a/deploy/all-in-one/entrypoint.sh +++ b/deploy/all-in-one/entrypoint.sh @@ -7,8 +7,8 @@ set -eu : "${LANQIN_ADDR:=127.0.0.1:8080}" : "${LANQIN_SMTP_HOST:=127.0.0.1}" : "${LANQIN_SMTP_PORT:=25}" -: "${LANQIN_SUBMISSION_ADDR:=:587}" -: "${LANQIN_SUBMISSION_TLS_ADDR:=:465}" +: "${LANQIN_SUBMISSION_ADDR:=}" +: "${LANQIN_SUBMISSION_TLS_ADDR:=}" : "${LANQIN_SUBMISSION_MAX_MESSAGE_MB:=35}" : "${LANQIN_MAILDIR_ROOT:=/var/mail/vhosts}" : "${LANQIN_TLS_CERT_FILE:=}" @@ -40,10 +40,18 @@ if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then if [ -f "$LANQIN_TLS_CERT_FILE" ] && [ -f "$LANQIN_TLS_KEY_FILE" ]; then TLS_CERT="$LANQIN_TLS_CERT_FILE" TLS_KEY="$LANQIN_TLS_KEY_FILE" + : "${LANQIN_SUBMISSION_ADDR:=:587}" + : "${LANQIN_SUBMISSION_TLS_ADDR:=:465}" else echo "warning: LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE not readable; using snakeoil localhost certificate" >&2 fi fi +if [ -n "$LANQIN_SUBMISSION_ADDR$LANQIN_SUBMISSION_TLS_ADDR" ] && { [ "$TLS_CERT" = "/etc/ssl/certs/ssl-cert-snakeoil.pem" ] || [ "$TLS_KEY" = "/etc/ssl/private/ssl-cert-snakeoil.key" ]; }; then + echo "warning: SMTP submission disabled because LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE are not configured with readable certificate files" >&2 + LANQIN_SUBMISSION_ADDR="" + LANQIN_SUBMISSION_TLS_ADDR="" +fi +export LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}" postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}" diff --git a/deploy/docker-compose.stack.yml b/deploy/docker-compose.stack.yml index e54624d..5d85790 100644 --- a/deploy/docker-compose.stack.yml +++ b/deploy/docker-compose.stack.yml @@ -5,6 +5,8 @@ services: environment: LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix} LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25} + LANQIN_SUBMISSION_ADDR: ${LANQIN_SUBMISSION_ADDR:-} + LANQIN_SUBMISSION_TLS_ADDR: ${LANQIN_SUBMISSION_TLS_ADDR:-} volumes: - ./data:/data - ./mail:/var/mail/vhosts:ro