2026-08-02 07:37:07 +08:00
package app
import (
"bytes"
"context"
2026-08-02 20:32:34 +08:00
"crypto/sha256"
"encoding/hex"
2026-08-02 07:37:07 +08:00
"fmt"
"os"
"strings"
2026-08-02 20:32:34 +08:00
"unicode"
2026-08-02 07:37:07 +08:00
)
const forwardingHeaderName = "X-LanQin-Forwarded-By"
func ( a * App ) processInboundForwarding ( ctx context . Context , messageID , mailboxID string , raw [] byte ) {
2026-08-02 18:33:25 +08:00
targets , userID , mailboxAddress , err := a . inboundForwardingTargets ( ctx , mailboxID )
2026-08-02 07:37:07 +08:00
if err != nil {
a . log . Warn ( "failed to load forwarding target" , "message" , messageID , "mailbox" , mailboxID , "error" , err )
return
}
2026-08-02 18:33:25 +08:00
if len ( targets ) == 0 || userID == "" || mailboxAddress == "" {
2026-08-02 07:37:07 +08:00
return
}
2026-08-02 18:33:25 +08:00
self := normalizeEmail ( mailboxAddress )
filteredTargets := make ([] string , 0 , len ( targets ))
for _ , target := range targets {
if normalizeEmail ( target ) == self {
continue
}
filteredTargets = append ( filteredTargets , target )
}
targets = dedupeEmails ( filteredTargets )
if len ( targets ) == 0 {
2026-08-02 07:37:07 +08:00
return
}
if len ( raw ) == 0 {
raw , err = a . forwardingRawMessage ( ctx , messageID )
if err != nil {
a . log . Warn ( "failed to load raw message for forwarding" , "message" , messageID , "error" , err )
return
}
}
if hasForwardingHeader ( raw ) {
a . log . Warn ( "skip forwarding message that already has LanQin forwarding header" , "message" , messageID , "mailbox" , mailboxID )
return
}
2026-08-03 19:51:27 +08:00
forwarded := addForwardingHeaders ( raw , mailboxAddress , a . config (). PublicHostname )
2026-08-02 07:37:07 +08:00
var rfcMessageID string
_ = a . db . QueryRowContext ( ctx , `SELECT message_id FROM messages WHERE id=?` , messageID ). Scan ( & rfcMessageID )
if strings . TrimSpace ( rfcMessageID ) == "" {
rfcMessageID = messageID
}
queueID , err := a . enqueueSend ( ctx , sendQueueInput {
UserID : userID ,
MailboxID : mailboxID ,
SentMessageID : messageID ,
MessageID : rfcMessageID ,
Source : sendSourceForwarding ,
MailFrom : mailboxAddress ,
HeaderFrom : mailboxAddress ,
2026-08-02 18:33:25 +08:00
Recipients : targets ,
2026-08-02 07:37:07 +08:00
MIMEBytes : forwarded ,
Now : a . now (). UTC (),
})
if err != nil {
2026-08-02 18:33:25 +08:00
a . log . Warn ( "failed to enqueue inbound forwarding" , "message" , messageID , "mailbox" , mailboxID , "targets" , strings . Join ( targets , "," ), "error" , err )
2026-08-02 07:37:07 +08:00
return
}
if queueID == "" {
2026-08-02 18:33:25 +08:00
a . log . Warn ( "forwarding target configured but SMTP sending is not configured" , "message" , messageID , "mailbox" , mailboxID , "targets" , strings . Join ( targets , "," ))
2026-08-02 07:37:07 +08:00
}
}
2026-08-02 20:32:34 +08:00
func ( a * App ) processRuleForwarding ( ctx context . Context , messageID , mailboxID string , action MailRuleAction ) error {
var userID , mailboxAddress string
if err := a . db . QueryRowContext ( ctx , `SELECT user_id,address FROM mailboxes WHERE id=? AND status='active'` , mailboxID ). Scan ( & userID , & mailboxAddress ); err != nil {
return err
}
targets , err := a . cleanForwardingTargets ( ctx , userID , splitRuleForwardTargets ( action . Value ))
if err != nil {
return err
}
self := normalizeEmail ( mailboxAddress )
filteredTargets := make ([] string , 0 , len ( targets ))
for _ , target := range targets {
if normalizeEmail ( target ) == self {
continue
}
filteredTargets = append ( filteredTargets , target )
}
targets = dedupeEmails ( filteredTargets )
if len ( targets ) == 0 {
return nil
}
raw , err := a . forwardingRawMessage ( ctx , messageID )
if err != nil {
return err
}
if hasForwardingHeader ( raw ) {
a . log . Warn ( "skip rule forwarding message that already has LanQin forwarding header" , "message" , messageID , "mailbox" , mailboxID )
return nil
}
2026-08-03 19:51:27 +08:00
forwarded := addForwardingHeaders ( raw , mailboxAddress , a . config (). PublicHostname )
2026-08-02 20:32:34 +08:00
var rfcMessageID string
_ = a . db . QueryRowContext ( ctx , `SELECT message_id FROM messages WHERE id=?` , messageID ). Scan ( & rfcMessageID )
if strings . TrimSpace ( rfcMessageID ) == "" {
rfcMessageID = messageID
}
queueID , err := a . enqueueSend ( ctx , sendQueueInput {
UserID : userID ,
MailboxID : mailboxID ,
SentMessageID : messageID ,
MessageID : ruleForwardQueueMessageID ( rfcMessageID , targets ),
Source : sendSourceRuleForwarding ,
MailFrom : mailboxAddress ,
HeaderFrom : mailboxAddress ,
Recipients : targets ,
MIMEBytes : forwarded ,
Now : a . now (). UTC (),
})
if err != nil {
return err
}
if queueID == "" {
a . log . Warn ( "rule forwarding target configured but SMTP sending is not configured" , "message" , messageID , "mailbox" , mailboxID , "targets" , strings . Join ( targets , "," ))
}
return nil
}
2026-08-02 18:33:25 +08:00
func ( a * App ) inboundForwardingTargets ( ctx context . Context , mailboxID string ) ( targetEmails [] string , userID , mailboxAddress string , err error ) {
var mailboxTarget , mailboxTargetsJSON , accountTarget , accountTargetsJSON string
err = a . db . QueryRowContext ( ctx , `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(mfs.target_emails,'[]'),COALESCE(afs.target_email,''),COALESCE(afs.target_emails,'[]')
2026-08-02 07:37:07 +08:00
FROM mailboxes mb
LEFT JOIN mailbox_forwarding_settings mfs ON mfs.mailbox_id=mb.id
LEFT JOIN account_forwarding_settings afs ON afs.user_id=mb.user_id
2026-08-02 18:33:25 +08:00
WHERE mb.id=? AND mb.status='active'` , mailboxID ). Scan ( & userID , & mailboxAddress , & mailboxTarget , & mailboxTargetsJSON , & accountTarget , & accountTargetsJSON )
2026-08-02 07:37:07 +08:00
if err != nil {
2026-08-02 18:33:25 +08:00
return nil , "" , "" , err
2026-08-02 07:37:07 +08:00
}
2026-08-09 11:25:23 +08:00
targets := dedupeEmails ( append ( forwardingTargetsFromStored ( accountTarget , accountTargetsJSON ), forwardingTargetsFromStored ( mailboxTarget , mailboxTargetsJSON ) ... ))
2026-08-02 18:33:25 +08:00
if len ( targets ) == 0 {
return nil , userID , mailboxAddress , nil
2026-08-02 07:37:07 +08:00
}
2026-08-02 18:33:25 +08:00
verifiedTargets := make ([] string , 0 , len ( targets ))
for _ , target := range targets {
verified , err := a . forwardingEmailVerified ( ctx , userID , target )
if err != nil {
return nil , "" , "" , err
}
if verified {
verifiedTargets = append ( verifiedTargets , target )
}
2026-08-02 07:37:07 +08:00
}
2026-08-02 18:33:25 +08:00
return dedupeEmails ( verifiedTargets ), userID , mailboxAddress , nil
2026-08-02 07:37:07 +08:00
}
func ( a * App ) forwardingRawMessage ( ctx context . Context , messageID string ) ([] byte , error ) {
msg , err := a . storedMessageByID ( ctx , messageID )
if err != nil {
return nil , err
}
if strings . TrimSpace ( msg . RawPath ) != "" {
if ok , err := a . pathIsUnderMaildirRoot ( msg . RawPath ); err == nil && ok {
if raw , err := os . ReadFile ( msg . RawPath ); err == nil {
return raw , nil
}
}
}
attachments , err := a . attachmentInputsForMessage ( ctx , messageID )
if err != nil {
return nil , err
}
return BuildMIME ( MIMEMessage {
From : msg . From ,
FromName : msg . FromName ,
To : msg . To ,
CC : msg . CC ,
BCC : msg . BCC ,
Subject : msg . Subject ,
Text : msg . BodyText ,
HTML : msg . BodyHTML ,
MessageID : msg . MessageID ,
Date : messageDate ( msg ),
Attachments : attachments ,
})
}
2026-08-02 20:32:34 +08:00
func splitRuleForwardTargets ( value string ) [] string {
return strings . FieldsFunc ( value , func ( r rune ) bool {
return unicode . IsSpace ( r ) || r == ',' || r == ', ' || r == ';' || r == '; '
})
}
func ruleForwardQueueMessageID ( messageID string , targets [] string ) string {
base := strings . TrimSpace ( messageID )
if base == "" {
base = newID ( "ruleforward" )
}
sum := sha256 . Sum256 ([] byte ( strings . Join ( dedupeEmails ( targets ), "," )))
return base + "#rule-forward-" + hex . EncodeToString ( sum [:])[: 12 ]
}
2026-08-02 07:37:07 +08:00
func hasForwardingHeader ( raw [] byte ) bool {
header := raw
if idx := bytes . Index ( raw , [] byte ( "\r\n\r\n" )); idx >= 0 {
header = raw [: idx ]
} else if idx := bytes . Index ( raw , [] byte ( "\n\n" )); idx >= 0 {
header = raw [: idx ]
}
return strings . Contains ( strings . ToLower ( string ( header )), strings . ToLower ( forwardingHeaderName ) + ":" )
}
func addForwardingHeaders ( raw [] byte , mailboxAddress , hostname string ) [] byte {
hostname = strings . TrimSpace ( hostname )
if hostname == "" {
hostname = "lanqin.local"
}
header := fmt . Sprintf ( "%s: %s\r\nX-LanQin-Forwarded-For: %s\r\n" , forwardingHeaderName , hostname , normalizeEmail ( mailboxAddress ))
if idx := bytes . Index ( raw , [] byte ( "\r\n\r\n" )); idx >= 0 {
out := make ([] byte , 0 , len ( raw ) + len ( header ))
out = append ( out , raw [: idx ] ... )
out = append ( out , [] byte ( "\r\n" + header ) ... )
out = append ( out , raw [ idx + 2 :] ... )
return out
}
if idx := bytes . Index ( raw , [] byte ( "\n\n" )); idx >= 0 {
lfHeader := strings . ReplaceAll ( header , "\r\n" , "\n" )
out := make ([] byte , 0 , len ( raw ) + len ( lfHeader ))
out = append ( out , raw [: idx ] ... )
out = append ( out , [] byte ( "\n" + lfHeader ) ... )
out = append ( out , raw [ idx + 1 :] ... )
return out
}
return append ([] byte ( header + "\r\n" ), raw ... )
}