fix(submission): 调整 SMTP 提交与发件授权逻辑。
- 启用 SMTP submission 时改为强制使用可读证书文件,并禁止使用容器自带测试证书。 - 收紧发件人校验,拒绝未授权的 From 地址,并支持别名多目标授权。 - 优化发送队列重入与重复消息处理,避免重复记录冲突。 - 让提交证书按需热加载,并稳定 MIME 头序列化顺序。 - 更新部署示例与文档,明确 submission 的证书配置要求。
This commit is contained in:
@@ -194,6 +194,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
|||||||
## SMTP 提交
|
## SMTP 提交
|
||||||
|
|
||||||
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
|
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
|
||||||
|
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`;API 不会用 localhost 自签证书对外提供 465/587。
|
||||||
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
|
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
|
||||||
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
|
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
|
||||||
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
|
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
|
||||||
|
|||||||
@@ -462,6 +462,30 @@ func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
|
|||||||
return err
|
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 <> ''`)
|
_, 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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,21 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -60,6 +67,49 @@ func defaultAdminUserAndMailbox(t *testing.T, a *App) (*User, *Mailbox) {
|
|||||||
return user, mb
|
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) {
|
func startFakeSMTP(t *testing.T) (string, string, <-chan string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
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) {
|
func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
a.cfg.SMTPHost = "postfix"
|
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) {
|
func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
a.cfg.SMTPHost = "127.0.0.1"
|
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: <delivered-requeue@example.test>\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, "<delivered-requeue@example.test>").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) {
|
func TestSubmissionAllowsAuthorizedAliasSendAs(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ctx := context.Background()
|
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 <team-many@lanqin.local>\r\nTo: person@example.com\r\nSubject: alias send-as\r\nMessage-ID: <multi-alias-send-as@example.test>\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) {
|
func TestSubmissionAllowsExplicitSendAsGrant(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ctx := context.Background()
|
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", "<dup@example.test>", 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", "<dup@example.test>", 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='<dup@example.test>'`, 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='<dup@example.test>'`, 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) {
|
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
host, port, received := startCapturingSMTP(t, 2)
|
host, port, received := startCapturingSMTP(t, 2)
|
||||||
a.cfg.SMTPHost = host
|
a.cfg.SMTPHost = host
|
||||||
a.cfg.SMTPPort = port
|
a.cfg.SMTPPort = port
|
||||||
|
certPath, keyPath := writeTestCertificateFiles(t, "mail.example.test")
|
||||||
|
a.cfg.TLSCertFile = certPath
|
||||||
|
a.cfg.TLSKeyFile = keyPath
|
||||||
tlsConfig, err := LoadServerTLSConfig(a.cfg)
|
tlsConfig, err := LoadServerTLSConfig(a.cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -416,6 +416,10 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusTooManyRequests, err.Error())
|
respondError(w, http.StatusTooManyRequests, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, errSenderNotAuthorized) {
|
||||||
|
respondError(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(w, http.StatusInternalServerError, err.Error())
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -426,6 +430,7 @@ var errNoRecipients = errors.New("at least one recipient is required")
|
|||||||
var errInvalidMIME = errors.New("invalid mime message")
|
var errInvalidMIME = errors.New("invalid mime message")
|
||||||
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
|
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
|
||||||
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
|
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) {
|
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||||
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
|
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -26,6 +25,7 @@ const (
|
|||||||
sendSourceSubmission = "submission"
|
sendSourceSubmission = "submission"
|
||||||
|
|
||||||
sendQueueStaleAfter = 15 * time.Minute
|
sendQueueStaleAfter = 15 * time.Minute
|
||||||
|
sendQueueConcurrency = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
type sendQueueInput struct {
|
type sendQueueInput struct {
|
||||||
@@ -81,9 +81,9 @@ func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if existingID != id {
|
if existingID != id {
|
||||||
if status == sendQueueStatusFailed && attemptCount >= maxAttempts {
|
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=? AND attempt_count>=max_attempts`,
|
_, 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, sendQueueStatusFailed)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -154,8 +154,28 @@ func (a *App) processDueSendQueue(ctx context.Context) error {
|
|||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
sem := make(chan struct{}, sendQueueConcurrency)
|
||||||
|
done := make(chan struct{}, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
}
|
||||||
|
go func(id string) {
|
||||||
|
defer func() {
|
||||||
|
<-sem
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
a.processSendQueueItem(ctx, id)
|
a.processSendQueueItem(ctx, id)
|
||||||
|
}(id)
|
||||||
|
}
|
||||||
|
for range ids {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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)
|
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 err == nil {
|
||||||
if enabled == 0 {
|
if enabled == 0 {
|
||||||
return "", "", fmt.Errorf("send-as address is disabled")
|
return "", "", errSenderNotAuthorized
|
||||||
}
|
}
|
||||||
return from, strings.TrimSpace(displayName), nil
|
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
|
var aliasDestination string
|
||||||
err = a.db.QueryRowContext(ctx, `SELECT destination FROM aliases WHERE source=? AND enabled=1`, from).Scan(&aliasDestination)
|
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) {
|
if err == nil {
|
||||||
|
for _, destination := range strings.Split(aliasDestination, ",") {
|
||||||
|
if normalizeEmail(destination) == normalizeEmail(mb.Address) {
|
||||||
return from, mb.DisplayName, nil
|
return from, mb.DisplayName, nil
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("send-as address is not authorized")
|
}
|
||||||
|
}
|
||||||
|
return "", "", errSenderNotAuthorized
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,15 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
|
||||||
"crypto/x509/pkix"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"math/big"
|
|
||||||
netmail "net/mail"
|
netmail "net/mail"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,7 +20,9 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultSubmissionMaxRecipients = 200
|
const (
|
||||||
|
defaultSubmissionMaxRecipients = 200
|
||||||
|
)
|
||||||
|
|
||||||
type SubmissionServers struct {
|
type SubmissionServers struct {
|
||||||
Plain *smtpserver.Server
|
Plain *smtpserver.Server
|
||||||
@@ -76,59 +73,20 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadServerTLSConfig(cfg Config) (*tls.Config, error) {
|
func LoadServerTLSConfig(cfg Config) (*tls.Config, error) {
|
||||||
cert, err := loadOrGenerateCertificate(cfg)
|
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{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &tls.Config{
|
return &cert, nil
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
NotBefore: now.Add(-time.Hour),
|
}, nil
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type submissionLogWriter struct {
|
type submissionLogWriter struct {
|
||||||
@@ -434,7 +392,15 @@ func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) {
|
|||||||
|
|
||||||
func serializeMessage(header textproto.MIMEHeader, body []byte) []byte {
|
func serializeMessage(header textproto.MIMEHeader, body []byte) []byte {
|
||||||
var buf bytes.Buffer
|
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)
|
canonical := textproto.CanonicalMIMEHeaderKey(key)
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
fmt.Fprintf(&buf, "%s: %s\r\n", canonical, strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " "))
|
fmt.Fprintf(&buf, "%s: %s\r\n", canonical, strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " "))
|
||||||
|
|||||||
+4
-4
@@ -28,7 +28,7 @@ LANQIN_PUBLIC_BASE_URL=https://mail.example.com
|
|||||||
|
|
||||||
# 邮件客户端 TLS 证书。用于 SMTP 465/587、IMAP 993、POP3 995。
|
# 邮件客户端 TLS 证书。用于 SMTP 465/587、IMAP 993、POP3 995。
|
||||||
# 生产环境请挂载真实证书,并确保域名包含 LANQIN_PUBLIC_HOSTNAME。
|
# 生产环境请挂载真实证书,并确保域名包含 LANQIN_PUBLIC_HOSTNAME。
|
||||||
# 留空时会使用容器自带 localhost 自签证书,第三方客户端会提示证书不匹配。
|
# 留空时 Dovecot/Postfix 会使用容器自带 localhost 自签证书;LanQin API 的 SMTP submission 不会启用。
|
||||||
LANQIN_TLS_CERT_FILE=
|
LANQIN_TLS_CERT_FILE=
|
||||||
LANQIN_TLS_KEY_FILE=
|
LANQIN_TLS_KEY_FILE=
|
||||||
|
|
||||||
@@ -97,9 +97,9 @@ LANQIN_SMTP_PASSWORD=
|
|||||||
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。
|
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。
|
||||||
LANQIN_SMTP_REQUIRE_TLS=false
|
LANQIN_SMTP_REQUIRE_TLS=false
|
||||||
|
|
||||||
# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465。
|
# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。
|
||||||
LANQIN_SUBMISSION_ADDR=:587
|
LANQIN_SUBMISSION_ADDR=
|
||||||
LANQIN_SUBMISSION_TLS_ADDR=:465
|
LANQIN_SUBMISSION_TLS_ADDR=
|
||||||
LANQIN_SUBMISSION_MAX_MESSAGE_MB=35
|
LANQIN_SUBMISSION_MAX_MESSAGE_MB=35
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
|
|||||||
+3
-1
@@ -134,13 +134,15 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
|||||||
## 邮件客户端 TLS 证书
|
## 邮件客户端 TLS 证书
|
||||||
|
|
||||||
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
|
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` 指向证书文件:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
|
LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
|
||||||
LANQIN_TLS_KEY_FILE=/certs/privkey.pem
|
LANQIN_TLS_KEY_FILE=/certs/privkey.pem
|
||||||
|
LANQIN_SUBMISSION_ADDR=:587
|
||||||
|
LANQIN_SUBMISSION_TLS_ADDR=:465
|
||||||
```
|
```
|
||||||
|
|
||||||
单容器示例:
|
单容器示例:
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ set -eu
|
|||||||
: "${LANQIN_ADDR:=127.0.0.1:8080}"
|
: "${LANQIN_ADDR:=127.0.0.1:8080}"
|
||||||
: "${LANQIN_SMTP_HOST:=127.0.0.1}"
|
: "${LANQIN_SMTP_HOST:=127.0.0.1}"
|
||||||
: "${LANQIN_SMTP_PORT:=25}"
|
: "${LANQIN_SMTP_PORT:=25}"
|
||||||
: "${LANQIN_SUBMISSION_ADDR:=:587}"
|
: "${LANQIN_SUBMISSION_ADDR:=}"
|
||||||
: "${LANQIN_SUBMISSION_TLS_ADDR:=:465}"
|
: "${LANQIN_SUBMISSION_TLS_ADDR:=}"
|
||||||
: "${LANQIN_SUBMISSION_MAX_MESSAGE_MB:=35}"
|
: "${LANQIN_SUBMISSION_MAX_MESSAGE_MB:=35}"
|
||||||
: "${LANQIN_MAILDIR_ROOT:=/var/mail/vhosts}"
|
: "${LANQIN_MAILDIR_ROOT:=/var/mail/vhosts}"
|
||||||
: "${LANQIN_TLS_CERT_FILE:=}"
|
: "${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
|
if [ -f "$LANQIN_TLS_CERT_FILE" ] && [ -f "$LANQIN_TLS_KEY_FILE" ]; then
|
||||||
TLS_CERT="$LANQIN_TLS_CERT_FILE"
|
TLS_CERT="$LANQIN_TLS_CERT_FILE"
|
||||||
TLS_KEY="$LANQIN_TLS_KEY_FILE"
|
TLS_KEY="$LANQIN_TLS_KEY_FILE"
|
||||||
|
: "${LANQIN_SUBMISSION_ADDR:=:587}"
|
||||||
|
: "${LANQIN_SUBMISSION_TLS_ADDR:=:465}"
|
||||||
else
|
else
|
||||||
echo "warning: LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE not readable; using snakeoil localhost certificate" >&2
|
echo "warning: LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE not readable; using snakeoil localhost certificate" >&2
|
||||||
fi
|
fi
|
||||||
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 "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
|
||||||
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
|
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix}
|
LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix}
|
||||||
LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25}
|
LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25}
|
||||||
|
LANQIN_SUBMISSION_ADDR: ${LANQIN_SUBMISSION_ADDR:-}
|
||||||
|
LANQIN_SUBMISSION_TLS_ADDR: ${LANQIN_SUBMISSION_TLS_ADDR:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./mail:/var/mail/vhosts:ro
|
- ./mail:/var/mail/vhosts:ro
|
||||||
|
|||||||
Reference in New Issue
Block a user