feat(mail): 支持 SMTP Submission 由 API 接管

- 新增 `465/587` 提交服务与 TLS 配置,支持 StartTLS 和隐式 TLS。
- 提交时校验认证邮箱、权限和 `From` 一致性,先写入 `Sent` 再转发到后端 SMTP。
- 增加 `Message-ID` 去重与失败回滚,避免重复保存发送副本。
- 调整部署与文档,移除 Postfix 的 submission 监听,改由 LanQin API 对外提供提交端口。
This commit is contained in:
LanQin_
2026-06-24 10:47:16 +08:00
parent f2457c63ee
commit fa450c9f9b
17 changed files with 792 additions and 28 deletions
+8
View File
@@ -191,6 +191,14 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。 - 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。 - SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
## SMTP 提交
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
- Webmail/API 发信继续由现有 API 发信流程写入 Sent。
- 第三方客户端发信会先校验邮箱密码,写入 Sent,再 relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
## License ## License
[MIT](./LICENSE) [MIT](./LICENSE)
+34
View File
@@ -7,9 +7,12 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
smtpserver "github.com/emersion/go-smtp"
"lanqin-email-api/internal/app" "lanqin-email-api/internal/app"
) )
@@ -29,6 +32,15 @@ func main() {
Handler: svc.Router(), Handler: svc.Router(),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
submissionServers := &app.SubmissionServers{}
if strings.TrimSpace(cfg.SubmissionAddr) != "" || strings.TrimSpace(cfg.SubmissionTLSAddr) != "" {
tlsConfig, err := app.LoadServerTLSConfig(cfg)
if err != nil {
logger.Error("failed to initialize TLS config", "error", err)
os.Exit(1)
}
submissionServers = svc.NewSubmissionServers(tlsConfig)
}
go func() { go func() {
logger.Info("LanQin API listening", "addr", cfg.Addr) logger.Info("LanQin API listening", "addr", cfg.Addr)
@@ -37,6 +49,24 @@ func main() {
os.Exit(1) os.Exit(1)
} }
}() }()
if submissionServers.Plain != nil {
go func() {
logger.Info("LanQin SMTP submission listening", "addr", cfg.SubmissionAddr)
if err := submissionServers.Plain.ListenAndServe(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
logger.Error("smtp submission server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
}
if submissionServers.TLS != nil {
go func() {
logger.Info("LanQin SMTP implicit TLS submission listening", "addr", cfg.SubmissionTLSAddr)
if err := submissionServers.TLS.ListenAndServeTLS(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
logger.Error("smtp tls submission server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
@@ -48,5 +78,9 @@ func main() {
logger.Error("server shutdown failed", "error", err) logger.Error("server shutdown failed", "error", err)
os.Exit(1) os.Exit(1)
} }
if err := submissionServers.Shutdown(shutdownCtx); err != nil {
logger.Error("smtp submission shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped") logger.Info("server stopped")
} }
+2
View File
@@ -13,6 +13,8 @@ require (
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+4
View File
@@ -2,6 +2,10 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+255
View File
@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
@@ -16,6 +17,10 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/emersion/go-sasl"
smtpclient "github.com/emersion/go-smtp"
"golang.org/x/crypto/bcrypt"
) )
func newTestApp(t *testing.T) *App { func newTestApp(t *testing.T) *App {
@@ -65,6 +70,30 @@ func startFakeSMTP(t *testing.T) (string, string, <-chan string) {
return host, port, received return host, port, received
} }
func startCapturingSMTP(t *testing.T, capacity int) (string, string, <-chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
received := make(chan string, capacity)
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleFakeSMTPConn(conn, received)
}
}()
host, port, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
return host, port, received
}
func handleFakeSMTPConn(conn net.Conn, received chan<- string) { func handleFakeSMTPConn(conn net.Conn, received chan<- string) {
defer conn.Close() defer conn.Close()
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)
@@ -772,6 +801,232 @@ func TestMailSendReturnsSMTPFailure(t *testing.T) {
} }
} }
func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
a := newTestApp(t)
user, mailbox, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatalf("authenticate submission: %v", err)
}
if user.Email != "admin@lanqin.local" || mailbox.Address != "admin@lanqin.local" {
t.Fatalf("unexpected auth user=%+v mailbox=%+v", user, mailbox)
}
if _, _, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "wrong-password"); err == nil {
t.Fatal("wrong password should fail")
}
ctx := context.Background()
hash, err := bcrypt.GenerateFromPassword([]byte("Password123!"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
userID := newID("usr")
domainID := mustDefaultDomainID(t, a)
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, userID, "nosend@lanqin.local", "No Send", "user", string(hash), 0, now, now); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `INSERT INTO mailboxes(id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, newID("mb"), userID, domainID, "nosend", "nosend@lanqin.local", "No Send", string(hash), 1024, "active", now, now); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET permissions_json=?, updated_at=? WHERE id=?`, encodePermissions(withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend)), now, PermissionGroupRegular); err != nil {
t.Fatal(err)
}
if _, _, err := a.authenticateSubmission(ctx, "nosend@lanqin.local", "Password123!"); err == nil {
t.Fatal("missing send permission should fail")
}
if _, err := a.db.ExecContext(ctx, `UPDATE users SET disabled=1 WHERE id=?`, userID); err != nil {
t.Fatal(err)
}
if _, _, err := a.authenticateSubmission(ctx, "nosend@lanqin.local", "Password123!"); err == nil {
t.Fatal("disabled owner should fail")
}
}
func TestSubmissionSendsRelayAndStoresSentCopy(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
raw := strings.Join([]string{
"From: Admin <admin@lanqin.local>",
"To: person@example.com",
"Bcc: hidden@example.com",
"Subject: Submission sent",
"Message-ID: <submission-sent@example.test>",
"Date: Tue, 24 Jun 2025 10:00:00 +0000",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"hello from submission",
}, "\r\n")
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
}
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com", "hidden@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatalf("submit smtp message: %v", err)
}
select {
case body := <-received:
if strings.Contains(strings.ToLower(body), "\r\nbcc:") || strings.Contains(body, "hidden@example.com") {
t.Fatalf("relay body leaked bcc: %s", body)
}
case <-time.After(2 * time.Second):
t.Fatal("relay message not received")
}
sentFolderID, err := a.ensureFolder(context.Background(), mb.ID, "Sent")
if err != nil {
t.Fatal(err)
}
var subject, bccJSON string
var read int
if err := a.db.QueryRow(`SELECT subject,bcc_addrs,is_read FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=?`, mb.ID, sentFolderID, "<submission-sent@example.test>").Scan(&subject, &bccJSON, &read); err != nil {
t.Fatal(err)
}
if subject != "Submission sent" || read != 1 {
t.Fatalf("unexpected sent message subject=%q read=%d", subject, read)
}
if got := jsonDecodeSlice(bccJSON); len(got) != 1 || got[0] != "hidden@example.com" {
t.Fatalf("bcc json=%s", bccJSON)
}
}
func TestSubmissionRejectsMismatchedSender(t *testing.T) {
a := newTestApp(t)
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
}
raw := "From: attacker@example.com\r\nTo: person@example.com\r\nSubject: nope\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("mismatched header From should fail")
}
raw = "From: admin@lanqin.local, attacker@example.com\r\nTo: person@example.com\r\nSubject: nope\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("multiple header From addresses should fail")
}
raw = "From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: nope\r\n\r\nbody"
if err := a.submitSMTPMessage(context.Background(), user, mb, "attacker@example.com", []string{"person@example.com"}, strings.NewReader(raw)); err == nil {
t.Fatal("mismatched MAIL FROM should fail")
}
}
func TestSubmissionRelayFailureRemovesSentCopy(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "1"
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: relay fail\r\nMessage-ID: <relay-fail@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("relay failure should fail")
}
var count int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM messages WHERE mailbox_id=? AND message_id=?`, mb.ID, "<relay-fail@example.test>").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("sent copy should be removed after relay failure, count=%d", count)
}
}
func TestSubmissionSentCopyDedupesByMessageID(t *testing.T) {
a := newTestApp(t)
host, port, _ := startCapturingSMTP(t, 4)
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: dedupe\r\nMessage-ID: <dedupe@example.test>\r\n\r\nbody"
for i := 0; i < 2; i++ {
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatalf("submit %d: %v", i, err)
}
}
sentFolderID, err := a.ensureFolder(context.Background(), mb.ID, "Sent")
if err != nil {
t.Fatal(err)
}
var count int
if err := a.db.QueryRow(`SELECT COUNT(1) FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=?`, mb.ID, sentFolderID, "<dedupe@example.test>").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("sent copy count=%d, want 1", count)
}
}
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
tlsConfig, err := LoadServerTLSConfig(a.cfg)
if err != nil {
t.Fatal(err)
}
startServer := func(t *testing.T, implicit bool) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := a.newSubmissionServer(ln.Addr().String(), tlsConfig)
go func() {
if implicit {
_ = srv.Serve(tls.NewListener(ln, tlsConfig))
} else {
_ = srv.Serve(ln)
}
}()
t.Cleanup(func() { _ = srv.Shutdown(context.Background()) })
return ln.Addr().String()
}
raw := "From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: starttls\r\nMessage-ID: <starttls@example.test>\r\n\r\nbody"
addr := startServer(t, false)
client, err := smtpclient.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatal(err)
}
if err := client.Auth(sasl.NewPlainClient("", "admin@lanqin.local", "ChangeMe123!")); err != nil {
t.Fatal(err)
}
if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatal(err)
}
_ = client.Close()
select {
case <-received:
case <-time.After(2 * time.Second):
t.Fatal("starttls relay not received")
}
raw = "From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: smtps\r\nMessage-ID: <smtps@example.test>\r\n\r\nbody"
addr = startServer(t, true)
client, err = smtpclient.DialTLS(addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatal(err)
}
if err := client.Auth(sasl.NewPlainClient("", "admin@lanqin.local", "ChangeMe123!")); err != nil {
t.Fatal(err)
}
if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatal(err)
}
_ = client.Close()
select {
case <-received:
case <-time.After(2 * time.Second):
t.Fatal("implicit tls relay not received")
}
}
func TestAdminSMTPTestEndpoint(t *testing.T) { func TestAdminSMTPTestEndpoint(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
host, port, received := startFakeSMTP(t) host, port, received := startFakeSMTP(t)
+10
View File
@@ -22,6 +22,11 @@ type Config struct {
SMTPUsername string SMTPUsername string
SMTPPassword string SMTPPassword string
SMTPRequireTLS bool SMTPRequireTLS bool
SubmissionAddr string
SubmissionTLSAddr string
SubmissionMaxMessageMB int
TLSCertFile string
TLSKeyFile string
MaildirRoot string MaildirRoot string
MaildirScanSeconds int MaildirScanSeconds int
AllowInsecureHTTP bool AllowInsecureHTTP bool
@@ -55,6 +60,11 @@ func LoadConfig() Config {
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""), SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""), SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false), SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""), MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30), MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true), AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
+5
View File
@@ -1548,6 +1548,11 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
_ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID)) _ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
} }
func (a *App) deleteMessage(ctx context.Context, messageID string) {
a.deleteMessageFiles(ctx, messageID)
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, messageID)
}
type messageSummaryScanner interface{ Scan(dest ...any) error } type messageSummaryScanner interface{ Scan(dest ...any) error }
func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) { func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
+447
View File
@@ -0,0 +1,447 @@
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"
"strings"
"time"
"github.com/emersion/go-sasl"
smtpserver "github.com/emersion/go-smtp"
"golang.org/x/crypto/bcrypt"
)
const defaultSubmissionMaxRecipients = 200
type SubmissionServers struct {
Plain *smtpserver.Server
TLS *smtpserver.Server
}
func (s *SubmissionServers) Shutdown(ctx context.Context) error {
if s == nil {
return nil
}
var errs []error
if s.Plain != nil {
if err := s.Plain.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
errs = append(errs, err)
}
}
if s.TLS != nil {
if err := s.TLS.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
}
}
func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserver.Server {
addr = strings.TrimSpace(addr)
if addr == "" {
return nil
}
s := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr
s.Domain = a.cfg.PublicHostname
s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
s.ReadTimeout = smtpSessionTimeout
s.WriteTimeout = smtpSessionTimeout
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
return s
}
func LoadServerTLSConfig(cfg Config) (*tls.Config, error) {
cert, err := loadOrGenerateCertificate(cfg)
if err != nil {
return nil, err
}
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,
},
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)
}
type submissionLogWriter struct {
log slogLogger
}
func (w submissionLogWriter) Write(p []byte) (int, error) {
if w.log != nil {
w.log.Warn(strings.TrimSpace(string(p)))
}
return len(p), nil
}
type slogLogger interface {
Warn(msg string, args ...any)
}
type submissionBackend struct {
app *App
}
func (b submissionBackend) NewSession(*smtpserver.Conn) (smtpserver.Session, error) {
return &submissionSession{app: b.app}, nil
}
type submissionSession struct {
app *App
user *User
mailbox *Mailbox
mailFrom string
recipients []string
}
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
if !strings.EqualFold(mech, sasl.Plain) {
return nil, smtpserver.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
if err != nil {
return smtpserver.ErrAuthFailed
}
s.user, s.mailbox = user, mailbox
return nil
}), nil
}
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
from = normalizeEmail(from)
if from == "" || from != normalizeEmail(s.mailbox.Address) {
return smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
}
s.mailFrom = from
s.recipients = nil
return nil
}
func (s *submissionSession) Rcpt(to string, _ *smtpserver.RcptOptions) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
to = normalizeEmail(to)
if to == "" || !strings.Contains(to, "@") {
return smtpError(501, smtpserver.EnhancedCode{5, 1, 3}, "invalid recipient")
}
s.recipients = append(s.recipients, to)
return nil
}
func (s *submissionSession) Data(r io.Reader) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
if s.mailFrom == "" || len(s.recipients) == 0 {
return smtpError(503, smtpserver.EnhancedCode{5, 5, 1}, "missing sender or recipients")
}
if err := s.app.submitSMTPMessage(context.Background(), s.user, s.mailbox, s.mailFrom, s.recipients, r); err != nil {
var smtpErr *smtpserver.SMTPError
if errors.As(err, &smtpErr) {
return smtpErr
}
return smtpError(451, smtpserver.EnhancedCode{4, 0, 0}, "message submission failed")
}
s.Reset()
return nil
}
func (s *submissionSession) Reset() {
s.mailFrom = ""
s.recipients = nil
}
func (s *submissionSession) Logout() error {
s.Reset()
return nil
}
func (a *App) authenticateSubmission(ctx context.Context, username, password string) (*User, *Mailbox, error) {
address := normalizeEmail(username)
if address == "" {
return nil, nil, errors.New("missing username")
}
var mb Mailbox
var passwordHash, created string
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at
FROM mailboxes WHERE address=? AND status='active'`, address)
if err := row.Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &passwordHash, &mb.QuotaMB, &mb.Status, &created); err != nil {
return nil, nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return nil, nil, err
}
mb.CreatedAt = parseTime(created)
user, err := a.userByID(ctx, mb.UserID)
if err != nil {
return nil, nil, err
}
if user.Disabled {
return nil, nil, errors.New("user disabled")
}
if !userHasPermission(user, PermissionMailSend) {
return nil, nil, errors.New("send permission required")
}
return user, &mb, nil
}
func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, mailFrom string, recipients []string, r io.Reader) error {
if err := a.recordSMTPRate(ctx, user, mb); err != nil {
if errors.Is(err, errSMTPRateLimited) {
return smtpError(452, smtpserver.EnhancedCode{4, 7, 0}, err.Error())
}
return err
}
raw, err := io.ReadAll(r)
if err != nil {
return err
}
prepared, msg, attachments, err := a.prepareSubmittedMessage(raw, mb.Address, mailFrom, recipients)
if err != nil {
return err
}
msg.MailboxID = mb.ID
sentID, err := a.insertSentMessageOnce(ctx, msg, attachments)
if err != nil {
return err
}
if a.cfg.SMTPHost != "" {
if err := a.sendSMTP(mb.Address, recipients, prepared); err != nil {
if sentID != "" {
a.deleteMessage(ctx, sentID)
}
return smtpError(451, smtpserver.EnhancedCode{4, 4, 0}, "smtp relay failed")
}
}
return nil
}
func (a *App) prepareSubmittedMessage(raw []byte, authenticatedAddress, mailFrom string, recipients []string) ([]byte, storedMessage, []AttachmentInput, error) {
header, body, err := readMessageHeader(raw)
if err != nil {
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
}
fromAddress, fromName, ok := singleHeaderAddress(header.Get("From"))
if !ok || fromAddress == "" {
return nil, storedMessage{}, nil, smtpError(550, smtpserver.EnhancedCode{5, 7, 1}, "From header must contain exactly one address")
}
authAddress := normalizeEmail(authenticatedAddress)
if normalizeEmail(mailFrom) != authAddress || normalizeEmail(fromAddress) != authAddress {
return nil, storedMessage{}, nil, smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
}
now := a.now().UTC()
messageID := strings.TrimSpace(header.Get("Message-Id"))
if messageID == "" {
messageID = fmt.Sprintf("<%s@%s>", newID("msg"), domainPart(authAddress))
header.Set("Message-ID", messageID)
} else {
header.Set("Message-ID", messageID)
}
sentAt := parseMailDate(header.Get("Date"))
if sentAt.IsZero() {
sentAt = now
header.Set("Date", sentAt.Format(time.RFC1123Z))
}
header.Del("Bcc")
prepared := serializeMessage(header, body)
msg, attachments, err := a.parseMaildirMessage(prepared, authAddress)
if err != nil {
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
}
if msg.MessageID == "" {
msg.MessageID = messageID
}
if msg.SentAt.IsZero() {
msg.SentAt = sentAt
}
if msg.ReceivedAt.IsZero() {
msg.ReceivedAt = sentAt
}
msg.From = authAddress
msg.FromName = fromName
msg.To = dedupeEmails(msg.To)
msg.CC = dedupeEmails(msg.CC)
msg.BCC = deduceBCCRecipients(recipients, addressList(header.Get("To")), addressList(header.Get("Cc")))
msg.IsRead = true
msg.RawPath = ""
if msg.Subject == "" {
msg.Subject = "(no subject)"
}
if msg.Snippet == "" {
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
}
return prepared, msg, attachments, nil
}
func (a *App) insertSentMessageOnce(ctx context.Context, msg storedMessage, attachments []AttachmentInput) (string, error) {
sentFolderID, err := a.ensureFolder(ctx, msg.MailboxID, "Sent")
if err != nil {
return "", err
}
msg.FolderID = sentFolderID
if msg.MessageUID == "" {
msg.MessageUID = newID("uid")
}
if msg.MessageID != "" {
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 {
return "", nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
}
return a.insertMessage(ctx, msg, attachments)
}
func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) {
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, nil, err
}
body, err := io.ReadAll(msg.Body)
if err != nil {
return nil, nil, err
}
return textproto.MIMEHeader(msg.Header), body, nil
}
func serializeMessage(header textproto.MIMEHeader, body []byte) []byte {
var buf bytes.Buffer
for key, values := range header {
canonical := textproto.CanonicalMIMEHeaderKey(key)
for _, value := range values {
fmt.Fprintf(&buf, "%s: %s\r\n", canonical, strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " "))
}
}
buf.WriteString("\r\n")
buf.Write(body)
return buf.Bytes()
}
func singleHeaderAddress(value string) (string, string, bool) {
value = strings.TrimSpace(value)
if value == "" {
return "", "", false
}
items, err := netmail.ParseAddressList(value)
if err != nil || len(items) != 1 {
decoded := decodeMIMEHeader(value)
items, err = netmail.ParseAddressList(decoded)
if err != nil || len(items) != 1 {
return "", "", false
}
}
item := items[0]
return normalizeEmail(item.Address), strings.TrimSpace(decodeMIMEHeader(item.Name)), true
}
func deduceBCCRecipients(envelope, to, cc []string) []string {
visible := map[string]bool{}
for _, item := range append(to, cc...) {
if email := normalizeEmail(item); email != "" {
visible[email] = true
}
}
var out []string
seen := map[string]bool{}
for _, item := range envelope {
email := normalizeEmail(item)
if email == "" || visible[email] || seen[email] {
continue
}
seen[email] = true
out = append(out, email)
}
return out
}
func domainPart(email string) string {
parts := strings.SplitN(normalizeEmail(email), "@", 2)
if len(parts) != 2 || parts[1] == "" {
return "lanqin.local"
}
return parts[1]
}
func smtpError(code int, enhanced smtpserver.EnhancedCode, message string) *smtpserver.SMTPError {
return &smtpserver.SMTPError{Code: code, EnhancedCode: enhanced, Message: message}
}
+5
View File
@@ -93,6 +93,11 @@ 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。
LANQIN_SUBMISSION_ADDR=:587
LANQIN_SUBMISSION_TLS_ADDR=:465
LANQIN_SUBMISSION_MAX_MESSAGE_MB=35
# ========================= # =========================
# 收件 / Maildir 同步 # 收件 / Maildir 同步
# ========================= # =========================
+4 -3
View File
@@ -128,12 +128,12 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim` - Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。 - Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。 - Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
- 第三方客户端可通过 SMTP `465/587` 发信;Webmail的“已发送”由 Webmail API 发信流程写入 - 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”API 写入,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重
## 邮件客户端 TLS 证书 ## 邮件客户端 TLS 证书
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。 Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
如果第三方客户端连接 `465/587/993/995` 时提示证书是 `localhost`,说明 Postfix/Dovecot 仍在使用容器自带的测试证书。 如果第三方客户端连接 `465/587/993/995` 时提示证书是 `localhost`,说明 LanQin API 或 Dovecot 仍在使用容器自带的测试证书。
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件: 生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
@@ -174,7 +174,8 @@ LANQIN_SMTP_REQUIRE_TLS=false
```bash ```bash
docker compose exec lanqin-email supervisorctl status docker compose exec lanqin-email supervisorctl status
docker compose exec lanqin-email postconf -M smtp/inet submission/inet docker compose exec lanqin-email postconf -M smtp/inet
# SMTP 提交 465/587 由 LanQin API 提供,不再由 Postfix 监听。
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select key,value from system_settings where key like 'smtp%' order by key;" docker compose exec lanqin-email sqlite3 /data/lanqin.db "select key,value from system_settings where key like 'smtp%' order by key;"
docker compose logs --tail=200 lanqin-email docker compose logs --tail=200 lanqin-email
``` ```
+4 -2
View File
@@ -7,11 +7,14 @@ 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_TLS_ADDR:=:465}"
: "${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:=}"
: "${LANQIN_TLS_KEY_FILE:=}" : "${LANQIN_TLS_KEY_FILE:=}"
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_MAILDIR_ROOT export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR LANQIN_SUBMISSION_MAX_MESSAGE_MB LANQIN_MAILDIR_ROOT LANQIN_TLS_CERT_FILE LANQIN_TLS_KEY_FILE
addgroup --system --gid 5000 vmail 2>/dev/null || true addgroup --system --gid 5000 vmail 2>/dev/null || true
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
@@ -47,7 +50,6 @@ postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "smtpd_tls_cert_file = ${TLS_CERT}" postconf -e "smtpd_tls_cert_file = ${TLS_CERT}"
postconf -e "smtpd_tls_key_file = ${TLS_KEY}" postconf -e "smtpd_tls_key_file = ${TLS_KEY}"
postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24" postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24"
postconf -e "smtpd_sasl_path = inet:127.0.0.1:12345"
postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}" postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
postconf -e "smtpd_milters = inet:127.0.0.1:11332" postconf -e "smtpd_milters = inet:127.0.0.1:11332"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332" postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
+1 -1
View File
@@ -17,5 +17,5 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
WORKDIR /app WORKDIR /app
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
EXPOSE 8080 EXPOSE 8080 465 587
CMD ["lanqin-api"] CMD ["lanqin-api"]
+8 -3
View File
@@ -3,8 +3,15 @@ services:
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
volumes: volumes:
- ./data:/data:ro - ./data:/data
- ./mail:/var/mail/vhosts:ro - ./mail:/var/mail/vhosts:ro
# 生产环境如需第三方客户端校验证书,请取消下面挂载的注释,并在 .env 配置:
# LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
# LANQIN_TLS_KEY_FILE=/certs/privkey.pem
# - /etc/letsencrypt/live/${LANQIN_PUBLIC_HOSTNAME}:/certs:ro
ports:
- "465:465"
- "587:587"
depends_on: depends_on:
- dovecot - dovecot
- postfix - postfix
@@ -36,8 +43,6 @@ services:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro
ports: ports:
- "25:25" - "25:25"
- "465:465"
- "587:587"
depends_on: depends_on:
- dovecot - dovecot
- rspamd - rspamd
+1 -1
View File
@@ -11,5 +11,5 @@ COPY master.cf /etc/postfix/master.cf
COPY sqlite-*.cf /etc/postfix/ COPY sqlite-*.cf /etc/postfix/
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
EXPOSE 25 465 587 EXPOSE 25
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]
+4 -6
View File
@@ -14,13 +14,11 @@ virtual_transport = lmtp:inet:dovecot:24
virtual_mailbox_base = /var/mail/vhosts virtual_mailbox_base = /var/mail/vhosts
smtpd_banner = $myhostname ESMTP LanQin Email smtpd_banner = $myhostname ESMTP LanQin Email
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
# Submission auth via Dovecot. # 465/587 提交由 LanQin API 处理Postfix 25 只负责入站和内部 relay
smtpd_sasl_type = dovecot smtpd_sasl_auth_enable = no
smtpd_sasl_path = inet:dovecot:12345
smtpd_sasl_auth_enable = yes
smtpd_tls_cert_file = /etc/ssl/certs/ssl-cert-snakeoil.pem smtpd_tls_cert_file = /etc/ssl/certs/ssl-cert-snakeoil.pem
smtpd_tls_key_file = /etc/ssl/private/ssl-cert-snakeoil.key smtpd_tls_key_file = /etc/ssl/private/ssl-cert-snakeoil.key
smtpd_tls_security_level = may smtpd_tls_security_level = may
-10
View File
@@ -1,14 +1,4 @@
smtp inet n - n - - smtpd smtp inet n - n - - smtpd
submission inet n - n - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=may
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
smtps inet n - n - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
pickup unix n - n 60 1 pickup pickup unix n - n 60 1 pickup
cleanup unix n - n - 0 cleanup cleanup unix n - n - 0 cleanup
qmgr unix n - n 300 1 qmgr qmgr unix n - n 300 1 qmgr
-2
View File
@@ -1,2 +0,0 @@
dbpath = /data/lanqin.db
query = SELECT local_part || '+Sent@' || substr(address, instr(address, '@') + 1) FROM mailboxes WHERE lower(address)=lower('%s') AND status='active'