feat(mail): 支持 SMTP Submission 由 API 接管
- 新增 `465/587` 提交服务与 TLS 配置,支持 StartTLS 和隐式 TLS。 - 提交时校验认证邮箱、权限和 `From` 一致性,先写入 `Sent` 再转发到后端 SMTP。 - 增加 `Message-ID` 去重与失败回滚,避免重复保存发送副本。 - 调整部署与文档,移除 Postfix 的 submission 监听,改由 LanQin API 对外提供提交端口。
This commit is contained in:
@@ -7,9 +7,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
smtpserver "github.com/emersion/go-smtp"
|
||||
|
||||
"lanqin-email-api/internal/app"
|
||||
)
|
||||
|
||||
@@ -29,6 +32,15 @@ func main() {
|
||||
Handler: svc.Router(),
|
||||
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() {
|
||||
logger.Info("LanQin API listening", "addr", cfg.Addr)
|
||||
@@ -37,6 +49,24 @@ func main() {
|
||||
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)
|
||||
defer stop()
|
||||
@@ -48,5 +78,9 @@ func main() {
|
||||
logger.Error("server shutdown failed", "error", err)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // 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/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
|
||||
@@ -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/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/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/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
smtpclient "github.com/emersion/go-smtp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func newTestApp(t *testing.T) *App {
|
||||
@@ -65,6 +70,30 @@ func startFakeSMTP(t *testing.T) (string, string, <-chan string) {
|
||||
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) {
|
||||
defer conn.Close()
|
||||
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) {
|
||||
a := newTestApp(t)
|
||||
host, port, received := startFakeSMTP(t)
|
||||
|
||||
@@ -22,6 +22,11 @@ type Config struct {
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
@@ -55,6 +60,11 @@ func LoadConfig() Config {
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
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", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
|
||||
@@ -1548,6 +1548,11 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
|
||||
_ = 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 }
|
||||
|
||||
func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user