2026-06-24 10:47:16 +08:00
package app
import (
"bytes"
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"io"
"log"
netmail "net/mail"
"net/textproto"
2026-06-24 13:10:24 +08:00
"sort"
2026-06-24 10:47:16 +08:00
"strings"
"time"
"github.com/emersion/go-sasl"
smtpserver "github.com/emersion/go-smtp"
"golang.org/x/crypto/bcrypt"
)
2026-06-24 13:10:24 +08:00
const (
defaultSubmissionMaxRecipients = 200
)
2026-06-24 10:47:16 +08:00
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 {
2026-08-03 19:51:27 +08:00
Plain : a . newSubmissionServer ( a . config (). SubmissionAddr , tlsConfig ),
TLS : a . newSubmissionServer ( a . config (). SubmissionTLSAddr , tlsConfig ),
2026-06-24 10:47:16 +08:00
}
}
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
2026-08-03 19:51:27 +08:00
s . Domain = a . config (). PublicHostname
2026-06-24 10:47:16 +08:00
s . TLSConfig = tlsConfig
s . AllowInsecureAuth = false
s . MaxRecipients = defaultSubmissionMaxRecipients
2026-08-03 19:51:27 +08:00
s . MaxMessageBytes = int64 ( a . config (). SubmissionMaxMessageMB ) * 1024 * 1024
2026-06-24 10:47:16 +08:00
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 ) {
certFile , keyFile := strings . TrimSpace ( cfg . TLSCertFile ), strings . TrimSpace ( cfg . TLSKeyFile )
2026-06-24 13:10:24 +08:00
if certFile == "" || keyFile == "" {
return nil , errors . New ( "LANQIN_TLS_CERT_FILE and LANQIN_TLS_KEY_FILE are required when SMTP submission is enabled" )
2026-06-24 10:47:16 +08:00
}
2026-06-24 13:10:24 +08:00
return & tls . Config {
MinVersion : tls . VersionTLS12 ,
GetCertificate : func ( * tls . ClientHelloInfo ) ( * tls . Certificate , error ) {
cert , err := tls . LoadX509KeyPair ( certFile , keyFile )
if err != nil {
return nil , err
}
return & cert , nil
2026-06-24 10:47:16 +08:00
},
2026-06-24 13:10:24 +08:00
}, nil
2026-06-24 10:47:16 +08:00
}
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 {
2026-08-10 15:42:05 +08:00
return [] string { sasl . Plain , sasl . Login }
2026-06-24 10:47:16 +08:00
}
func ( s * submissionSession ) Auth ( mech string ) ( sasl . Server , error ) {
2026-08-10 15:42:05 +08:00
authenticate := func ( username , password string ) error {
2026-06-24 10:47:16 +08:00
user , mailbox , err := s . app . authenticateSubmission ( context . Background (), username , password )
if err != nil {
return smtpserver . ErrAuthFailed
}
s . user , s . mailbox = user , mailbox
return nil
2026-08-10 15:42:05 +08:00
}
switch {
case strings . EqualFold ( mech , sasl . Plain ):
return sasl . NewPlainServer ( func ( _ , username , password string ) error {
return authenticate ( username , password )
}), nil
case strings . EqualFold ( mech , sasl . Login ):
return & submissionLoginServer { authenticate : authenticate }, nil
default :
return nil , smtpserver . ErrAuthUnknownMechanism
}
}
type submissionLoginServer struct {
authenticate func ( username , password string ) error
username string
step int
}
func ( s * submissionLoginServer ) Next ( response [] byte ) ([] byte , bool , error ) {
switch s . step {
case 0 :
if response == nil {
s . step = 1
return [] byte ( "Username:" ), false , nil
}
s . username = string ( response )
s . step = 2
return [] byte ( "Password:" ), false , nil
case 1 :
s . username = string ( response )
s . step = 2
return [] byte ( "Password:" ), false , nil
case 2 :
if err := s . authenticate ( s . username , string ( response )); err != nil {
return nil , false , err
}
s . step = 3
return nil , true , nil
default :
return nil , false , sasl . ErrUnexpectedClientResponse
}
2026-06-24 10:47:16 +08:00
}
func ( s * submissionSession ) Mail ( from string , _ * smtpserver . MailOptions ) error {
if s . user == nil || s . mailbox == nil {
return smtpserver . ErrAuthRequired
}
from = normalizeEmail ( from )
2026-06-24 11:27:32 +08:00
authorized , _ , err := s . app . authorizedSender ( context . Background (), s . mailbox , from )
if err != nil || from == "" || from != authorized {
2026-06-24 10:47:16 +08:00
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
}
2026-06-24 11:27:32 +08:00
prepared , msg , attachments , err := a . prepareSubmittedMessage ( ctx , raw , mb , mailFrom , recipients )
2026-06-24 10:47:16 +08:00
if err != nil {
return err
}
msg . MailboxID = mb . ID
2026-06-24 11:27:32 +08:00
sentID , insertedSent , err := a . insertSentMessageOnce ( ctx , msg , attachments )
2026-06-24 10:47:16 +08:00
if err != nil {
return err
}
2026-06-24 15:18:06 +08:00
if insertedSent {
if err := a . rewriteMessageMaildir ( ctx , sentID ); err != nil {
a . deleteMessage ( ctx , sentID )
if sentFolderID , ferr := a . ensureFolder ( ctx , mb . ID , "Sent" ); ferr == nil {
a . deleteSentDedupeKey ( ctx , mb . ID , sentFolderID , msg . MessageID )
}
return err
}
}
2026-06-24 11:27:32 +08:00
a . recordSendAudit ( ctx , sendAuditAccepted , sendQueueStatusQueued , sendAuditInput { UserID : user . ID , MailboxID : mb . ID , SentMessageID : sentID , Source : sendSourceSubmission , MailFrom : mailFrom , HeaderFrom : msg . From , Recipients : recipients })
if sentID != "" {
if _ , err := a . enqueueSend ( ctx , sendQueueInput { UserID : user . ID , MailboxID : mb . ID , SentMessageID : sentID , MessageID : msg . MessageID , Source : sendSourceSubmission , MailFrom : mailFrom , HeaderFrom : msg . From , Recipients : recipients , MIMEBytes : prepared , Now : a . now (). UTC ()}); err != nil {
if insertedSent {
2026-06-24 10:47:16 +08:00
a . deleteMessage ( ctx , sentID )
2026-06-24 11:03:01 +08:00
if sentFolderID , ferr := a . ensureFolder ( ctx , mb . ID , "Sent" ); ferr == nil {
a . deleteSentDedupeKey ( ctx , mb . ID , sentFolderID , msg . MessageID )
}
2026-06-24 10:47:16 +08:00
}
2026-06-24 11:27:32 +08:00
return err
2026-06-24 10:47:16 +08:00
}
}
return nil
}
2026-06-24 11:27:32 +08:00
func ( a * App ) prepareSubmittedMessage ( ctx context . Context , raw [] byte , mb * Mailbox , mailFrom string , recipients [] string ) ([] byte , storedMessage , [] AttachmentInput , error ) {
2026-06-24 10:47:16 +08:00
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" )
}
2026-06-24 11:27:32 +08:00
authAddress , fromName , err := a . authorizedSender ( ctx , mb , fromAddress )
if err != nil || normalizeEmail ( mailFrom ) != authAddress || normalizeEmail ( fromAddress ) != authAddress {
2026-06-24 10:47:16 +08:00
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
}
2026-06-24 11:27:32 +08:00
func ( a * App ) insertSentMessageOnce ( ctx context . Context , msg storedMessage , attachments [] AttachmentInput ) ( string , bool , error ) {
2026-06-24 10:47:16 +08:00
sentFolderID , err := a . ensureFolder ( ctx , msg . MailboxID , "Sent" )
if err != nil {
2026-06-24 11:27:32 +08:00
return "" , false , err
2026-06-24 10:47:16 +08:00
}
msg . FolderID = sentFolderID
if msg . MessageUID == "" {
msg . MessageUID = newID ( "uid" )
}
2026-06-24 13:26:53 +08:00
tx , err := a . db . BeginTx ( ctx , nil )
if err != nil {
return "" , false , err
}
committed := false
messageIDForCleanup := ""
defer func () {
if ! committed {
_ = tx . Rollback ()
if messageIDForCleanup != "" {
a . deleteMessageFiles ( ctx , messageIDForCleanup )
}
}
}()
2026-06-24 10:47:16 +08:00
if msg . MessageID != "" {
2026-06-24 13:26:53 +08:00
existing , err := sentMessageIDByMessageID ( ctx , tx , msg . MailboxID , sentFolderID , msg . MessageID )
2026-06-24 10:47:16 +08:00
if err == nil {
2026-06-24 13:26:53 +08:00
if err := a . insertSentDedupeKeyWithDB ( ctx , tx , msg . MailboxID , sentFolderID , msg . MessageID ); err != nil && ! errors . Is ( err , errSentDedupeExists ) {
return "" , false , err
}
if err := tx . Commit (); err != nil {
2026-06-24 11:27:32 +08:00
return "" , false , err
2026-06-24 11:03:01 +08:00
}
2026-06-24 13:26:53 +08:00
committed = true
2026-06-24 11:27:32 +08:00
return existing , false , nil
2026-06-24 10:47:16 +08:00
}
if err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
2026-06-24 11:27:32 +08:00
return "" , false , err
2026-06-24 10:47:16 +08:00
}
2026-06-24 13:26:53 +08:00
if err := a . insertSentDedupeKeyWithDB ( ctx , tx , msg . MailboxID , sentFolderID , msg . MessageID ); err != nil {
2026-06-24 11:03:01 +08:00
if errors . Is ( err , errSentDedupeExists ) {
2026-06-24 13:26:53 +08:00
existing , qerr := sentMessageIDByMessageID ( ctx , tx , msg . MailboxID , sentFolderID , msg . MessageID )
if qerr == nil {
if err := tx . Commit (); err != nil {
return "" , false , err
}
committed = true
2026-06-24 11:27:32 +08:00
return existing , false , nil
}
2026-06-24 13:26:53 +08:00
if errors . Is ( qerr , sql . ErrNoRows ) {
return "" , false , fmt . Errorf ( "sent dedupe key exists without sent message: %w" , errSentDedupeExists )
}
return "" , false , qerr
2026-06-24 11:03:01 +08:00
}
2026-06-24 11:27:32 +08:00
return "" , false , err
2026-06-24 11:03:01 +08:00
}
}
2026-06-24 13:26:53 +08:00
id , err := a . insertMessageWithDB ( ctx , tx , msg , attachments )
2026-06-24 11:03:01 +08:00
if err != nil {
2026-06-24 11:27:32 +08:00
return "" , false , err
2026-06-24 11:03:01 +08:00
}
2026-06-24 13:26:53 +08:00
messageIDForCleanup = id
if err := tx . Commit (); err != nil {
return "" , false , err
}
committed = true
2026-06-24 11:27:32 +08:00
return id , true , nil
2026-06-24 11:03:01 +08:00
}
var errSentDedupeExists = errors . New ( "sent message already exists" )
func ( a * App ) insertSentDedupeKey ( ctx context . Context , mailboxID , folderID , messageID string ) error {
2026-06-24 13:26:53 +08:00
return a . insertSentDedupeKeyWithDB ( ctx , a . db , mailboxID , folderID , messageID )
}
func ( a * App ) insertSentDedupeKeyWithDB ( ctx context . Context , db dbExecutor , mailboxID , folderID , messageID string ) error {
2026-06-24 11:03:01 +08:00
if strings . TrimSpace ( messageID ) == "" {
return nil
}
2026-06-24 13:26:53 +08:00
res , err := db . ExecContext ( ctx , `INSERT OR IGNORE INTO sent_message_dedupe_keys(mailbox_id,folder_id,message_id,created_at) VALUES(?,?,?,?)` , mailboxID , folderID , messageID , a . now (). UTC (). Format ( time . RFC3339Nano ))
2026-06-24 11:03:01 +08:00
if err != nil {
return err
}
if rows , err := res . RowsAffected (); err == nil && rows == 0 {
return errSentDedupeExists
}
return nil
}
2026-06-24 13:26:53 +08:00
func sentMessageIDByMessageID ( ctx context . Context , db dbQueryer , mailboxID , folderID , messageID string ) ( string , error ) {
var existing string
err := db . QueryRowContext ( ctx , `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' LIMIT 1` , mailboxID , folderID , messageID ). Scan ( & existing )
return existing , err
}
2026-06-24 11:03:01 +08:00
func ( a * App ) deleteSentDedupeKey ( ctx context . Context , mailboxID , folderID , messageID string ) {
if strings . TrimSpace ( messageID ) == "" {
return
2026-06-24 10:47:16 +08:00
}
2026-06-24 11:03:01 +08:00
_ , _ = a . db . ExecContext ( ctx , `DELETE FROM sent_message_dedupe_keys WHERE mailbox_id=? AND folder_id=? AND message_id=?` , mailboxID , folderID , messageID )
2026-06-24 10:47:16 +08:00
}
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
2026-06-24 13:10:24 +08:00
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 ]
2026-06-24 10:47:16 +08:00
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 }
}