2026-06-14 01:07:48 +08:00
package app
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
netmail "net/mail"
"net/textproto"
"os"
"path/filepath"
2026-06-19 22:49:20 +08:00
"regexp"
2026-06-14 01:07:48 +08:00
"strings"
"time"
2026-06-19 22:15:00 +08:00
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/ianaindex"
2026-06-14 01:07:48 +08:00
)
type maildirMailbox struct {
2026-06-15 00:37:43 +08:00
ID string
Address string
LocalPart string
Domain string
Unregistered bool
RecipientDomain string
2026-06-14 01:07:48 +08:00
}
type maildirFolder struct {
ID string
Name string
Role string
}
type parsedMail struct {
Text string
HTML string
Attachments [] AttachmentInput
}
func ( a * App ) maildirWorker ( ctx context . Context ) {
interval := time . Duration ( a . cfg . MaildirScanSeconds ) * time . Second
if interval <= 0 {
interval = 30 * time . Second
}
2026-06-24 16:08:48 +08:00
nextRunAt := a . now (). UTC ()
a . maildirHealth . markWorkerStarted ( & nextRunAt )
2026-06-14 01:07:48 +08:00
a . log . Info ( "maildir sync worker started" , "root" , a . cfg . MaildirRoot , "interval" , interval . String ())
2026-06-24 16:08:48 +08:00
if counts , err := a . syncMaildirOnceTracked ( ctx , interval ); err != nil {
2026-06-14 01:07:48 +08:00
a . log . Warn ( "initial maildir sync failed" , "error" , err )
2026-06-24 16:08:48 +08:00
} else if n := counts . total (); n > 0 {
2026-06-24 15:18:06 +08:00
a . log . Info ( "initial maildir sync processed messages" , "count" , n )
2026-06-14 01:07:48 +08:00
}
ticker := time . NewTicker ( interval )
defer ticker . Stop ()
for {
select {
case <- ctx . Done ():
2026-06-24 16:08:48 +08:00
a . maildirHealth . markWorkerStopped ()
2026-06-14 01:07:48 +08:00
a . log . Info ( "maildir sync worker stopped" )
return
case <- ticker . C :
2026-06-24 16:08:48 +08:00
counts , err := a . syncMaildirOnceTracked ( ctx , interval )
2026-06-14 01:07:48 +08:00
if err != nil {
a . log . Warn ( "maildir sync failed" , "error" , err )
continue
}
2026-06-24 16:08:48 +08:00
if n := counts . total (); n > 0 {
2026-06-24 15:18:06 +08:00
a . log . Info ( "maildir sync processed messages" , "count" , n )
2026-06-14 01:07:48 +08:00
}
}
}
}
2026-06-24 16:08:48 +08:00
func ( a * App ) syncMaildirOnceTracked ( ctx context . Context , interval time . Duration ) ( maildirSyncCounts , error ) {
startedAt := a . now (). UTC ()
a . maildirHealth . markRunStarted ( startedAt )
counts , err := a . syncMaildirOnceDetailed ( ctx )
finishedAt := a . now (). UTC ()
var nextRunAt * time . Time
if interval > 0 && err == nil {
next := finishedAt . Add ( interval )
nextRunAt = & next
}
a . maildirHealth . markRunFinished ( finishedAt , counts , err , nextRunAt )
return counts , err
}
2026-06-14 01:07:48 +08:00
func ( a * App ) syncMaildirOnce ( ctx context . Context ) ( int , error ) {
2026-06-24 16:08:48 +08:00
counts , err := a . syncMaildirOnceDetailed ( ctx )
return counts . total (), err
}
func ( a * App ) syncMaildirOnceDetailed ( ctx context . Context ) ( maildirSyncCounts , error ) {
2026-06-14 01:07:48 +08:00
root := strings . TrimSpace ( a . cfg . MaildirRoot )
if root == "" {
2026-06-24 16:08:48 +08:00
return maildirSyncCounts {}, nil
2026-06-14 01:07:48 +08:00
}
mailboxes , err := a . maildirMailboxes ( ctx )
if err != nil {
2026-06-24 16:08:48 +08:00
return maildirSyncCounts {}, err
2026-06-14 01:07:48 +08:00
}
2026-06-24 16:08:48 +08:00
counts := maildirSyncCounts {}
2026-06-14 01:07:48 +08:00
for _ , mb := range mailboxes {
2026-06-15 00:37:43 +08:00
if mb . Unregistered {
2026-06-24 16:08:48 +08:00
mbCounts , err := a . syncUnregisteredMaildirDetailed ( ctx , mb )
counts . FilesScanned += mbCounts . FilesScanned
counts . Imported += mbCounts . Imported
counts . FileErrors += mbCounts . FileErrors
counts . fileErrorDetails = append ( counts . fileErrorDetails , mbCounts . fileErrorDetails ... )
2026-06-15 00:37:43 +08:00
if err != nil {
2026-06-24 16:08:48 +08:00
return counts , err
2026-06-15 00:37:43 +08:00
}
continue
}
2026-06-14 01:07:48 +08:00
folders , err := a . maildirFolders ( ctx , mb . ID )
if err != nil {
2026-06-24 16:08:48 +08:00
return counts , err
2026-06-14 01:07:48 +08:00
}
base := filepath . Join ( root , mb . Domain , mb . LocalPart , "Maildir" )
for _ , folder := range folders {
folderBase := maildirFolderPath ( base , folder . Name )
for _ , sub := range [] string { "new" , "cur" } {
select {
case <- ctx . Done ():
2026-06-24 16:08:48 +08:00
return counts , ctx . Err ()
2026-06-14 01:07:48 +08:00
default :
}
dir := filepath . Join ( folderBase , sub )
entries , err := os . ReadDir ( dir )
if err != nil {
if errors . Is ( err , os . ErrNotExist ) {
continue
}
2026-06-24 16:08:48 +08:00
return counts , err
2026-06-14 01:07:48 +08:00
}
for _ , entry := range entries {
if entry . IsDir () || strings . HasPrefix ( entry . Name (), "." ) {
continue
}
path := filepath . Join ( dir , entry . Name ())
2026-06-24 16:08:48 +08:00
counts . FilesScanned ++
2026-06-14 01:07:48 +08:00
ok , err := a . syncMaildirFile ( ctx , mb , folder , path )
if err != nil {
2026-06-24 16:08:48 +08:00
counts . FileErrors ++
counts . fileErrorDetails = append ( counts . fileErrorDetails , fmt . Sprintf ( "%s: %v" , path , err ))
2026-06-14 01:07:48 +08:00
a . log . Warn ( "maildir file import failed" , "path" , path , "error" , err )
continue
}
if ok {
2026-06-24 16:08:48 +08:00
counts . Imported ++
2026-06-14 01:07:48 +08:00
}
}
}
}
}
2026-06-24 15:18:06 +08:00
backfilled , err := a . backfillSQLiteMessagesToMaildir ( ctx )
if err != nil {
2026-06-24 16:08:48 +08:00
return counts , err
2026-06-24 15:18:06 +08:00
}
2026-06-24 16:08:48 +08:00
counts . Backfilled += backfilled
2026-06-24 15:46:17 +08:00
cleaned , err := a . cleanupMissingMaildirMessages ( ctx )
if err != nil {
2026-06-24 16:08:48 +08:00
return counts , err
2026-06-24 15:46:17 +08:00
}
2026-06-24 16:08:48 +08:00
counts . Cleaned += cleaned
return counts , nil
2026-06-14 01:07:48 +08:00
}
func ( a * App ) maildirMailboxes ( ctx context . Context ) ([] maildirMailbox , error ) {
rows , err := a . db . QueryContext ( ctx , `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.status='active' AND d.status='active' ORDER BY m.address` )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] maildirMailbox
for rows . Next () {
var mb maildirMailbox
if err := rows . Scan ( & mb . ID , & mb . Address , & mb . LocalPart , & mb . Domain ); err != nil {
return nil , err
}
out = append ( out , mb )
}
2026-06-15 00:37:43 +08:00
if err := rows . Err (); err != nil {
return nil , err
}
if a . cfg . CatchAllEnabled {
domainRows , err := a . db . QueryContext ( ctx , `SELECT name FROM domains WHERE status='active' ORDER BY name` )
if err != nil {
return nil , err
}
defer domainRows . Close ()
for domainRows . Next () {
var domain string
if err := domainRows . Scan ( & domain ); err != nil {
return nil , err
}
out = append ( out , maildirMailbox {
Address : "__unregistered__@" + domain ,
LocalPart : "__unregistered__" ,
Domain : domain ,
Unregistered : true ,
RecipientDomain : domain ,
})
}
if err := domainRows . Err (); err != nil {
return nil , err
}
}
return out , nil
}
func ( a * App ) syncUnregisteredMaildir ( ctx context . Context , mb maildirMailbox ) ( int , error ) {
2026-06-24 16:08:48 +08:00
counts , err := a . syncUnregisteredMaildirDetailed ( ctx , mb )
return counts . Imported , err
}
func ( a * App ) syncUnregisteredMaildirDetailed ( ctx context . Context , mb maildirMailbox ) ( maildirSyncCounts , error ) {
2026-06-15 00:37:43 +08:00
base := filepath . Join ( strings . TrimSpace ( a . cfg . MaildirRoot ), mb . Domain , mb . LocalPart , "Maildir" )
2026-06-24 16:08:48 +08:00
counts := maildirSyncCounts {}
2026-06-15 00:37:43 +08:00
for _ , sub := range [] string { "new" , "cur" } {
select {
case <- ctx . Done ():
2026-06-24 16:08:48 +08:00
return counts , ctx . Err ()
2026-06-15 00:37:43 +08:00
default :
}
dir := filepath . Join ( base , sub )
entries , err := os . ReadDir ( dir )
if err != nil {
if errors . Is ( err , os . ErrNotExist ) {
continue
}
2026-06-24 16:08:48 +08:00
return counts , err
2026-06-15 00:37:43 +08:00
}
for _ , entry := range entries {
if entry . IsDir () || strings . HasPrefix ( entry . Name (), "." ) {
continue
}
path := filepath . Join ( dir , entry . Name ())
2026-06-24 16:08:48 +08:00
counts . FilesScanned ++
2026-06-15 00:37:43 +08:00
ok , err := a . syncUnregisteredMaildirFile ( ctx , mb , path )
if err != nil {
2026-06-24 16:08:48 +08:00
counts . FileErrors ++
counts . fileErrorDetails = append ( counts . fileErrorDetails , fmt . Sprintf ( "%s: %v" , path , err ))
2026-06-15 00:37:43 +08:00
a . log . Warn ( "unregistered maildir file import failed" , "path" , path , "error" , err )
continue
}
if ok {
2026-06-24 16:08:48 +08:00
counts . Imported ++
2026-06-15 00:37:43 +08:00
}
}
}
2026-06-24 16:08:48 +08:00
return counts , nil
2026-06-15 00:37:43 +08:00
}
func ( a * App ) syncUnregisteredMaildirFile ( ctx context . Context , mb maildirMailbox , path string ) ( bool , error ) {
raw , err := os . ReadFile ( path )
if err != nil {
return false , err
}
msg , attachments , err := a . parseMaildirMessage ( raw , mb . Address )
if err != nil {
return false , err
}
recipient := unregisteredRecipientFromMessage ( msg , mb . RecipientDomain )
if recipient == "" {
recipient = mb . Address
}
msg . MailboxID = ""
msg . FolderID = ""
msg . RecipientAddr = recipient
msg . RawPath = path
if msg . MessageUID == "" {
msg . MessageUID = newID ( "uid" )
}
if msg . MessageID == "" {
msg . MessageID = fmt . Sprintf ( "<%s@lanqin.local>" , newID ( "msg" ))
}
if msg . ReceivedAt . IsZero () {
msg . ReceivedAt = a . now (). UTC ()
}
if msg . SentAt . IsZero () {
msg . SentAt = msg . ReceivedAt
}
if msg . Snippet == "" {
msg . Snippet = snippetFrom ( msg . BodyText , msg . BodyHTML )
}
if exists , err := a . unregisteredMaildirMessageExists ( ctx , path , msg . MessageID , msg . RecipientAddr ); err != nil {
return false , err
} else if exists {
2026-06-24 15:18:06 +08:00
a . attachUnregisteredMaildirRawPathToExisting ( ctx , path , msg . MessageID , msg . RecipientAddr )
2026-06-15 00:37:43 +08:00
return false , nil
}
_ , err = a . insertMessage ( ctx , msg , attachments )
return err == nil , err
2026-06-14 01:07:48 +08:00
}
func ( a * App ) maildirFolders ( ctx context . Context , mailboxID string ) ([] maildirFolder , error ) {
rows , err := a . db . QueryContext ( ctx , `SELECT id,name,role FROM folders WHERE mailbox_id=?` , mailboxID )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] maildirFolder
for rows . Next () {
var f maildirFolder
if err := rows . Scan ( & f . ID , & f . Name , & f . Role ); err != nil {
return nil , err
}
out = append ( out , f )
}
return out , rows . Err ()
}
func maildirFolderPath ( base , folder string ) string {
if strings . EqualFold ( folder , "Inbox" ) {
return base
}
folder = strings . TrimSpace ( folder )
folder = strings . TrimPrefix ( folder , "." )
return filepath . Join ( base , "." + folder )
}
func ( a * App ) syncMaildirFile ( ctx context . Context , mb maildirMailbox , folder maildirFolder , path string ) ( bool , error ) {
raw , err := os . ReadFile ( path )
if err != nil {
return false , err
}
msg , attachments , err := a . parseMaildirMessage ( raw , mb . Address )
if err != nil {
return false , err
}
msg . MailboxID = mb . ID
msg . FolderID = folder . ID
2026-06-24 15:46:17 +08:00
msg . IsRead , msg . IsStarred = maildirFlagsFromPath ( path , folder . Name )
2026-06-14 01:07:48 +08:00
msg . RawPath = path
if msg . MessageUID == "" {
msg . MessageUID = newID ( "uid" )
}
if msg . MessageID == "" {
msg . MessageID = fmt . Sprintf ( "<%s@lanqin.local>" , newID ( "msg" ))
}
if msg . ReceivedAt . IsZero () {
msg . ReceivedAt = a . now (). UTC ()
}
if msg . SentAt . IsZero () {
msg . SentAt = msg . ReceivedAt
}
if msg . Snippet == "" {
msg . Snippet = snippetFrom ( msg . BodyText , msg . BodyHTML )
}
if exists , err := a . maildirMessageExists ( ctx , mb . ID , folder . ID , path , msg . MessageID ); err != nil {
return false , err
} else if exists {
2026-06-24 15:46:17 +08:00
if _ , err := a . syncExistingMaildirMessageState ( ctx , mb . ID , folder . ID , path , msg . MessageID , msg . IsRead , msg . IsStarred ); err != nil {
return false , err
}
return false , nil
}
if handled , err := a . syncExistingMaildirMessageState ( ctx , mb . ID , folder . ID , path , msg . MessageID , msg . IsRead , msg . IsStarred ); err != nil {
return false , err
} else if handled {
2026-06-14 01:07:48 +08:00
return false , nil
}
id , err := a . insertMessage ( ctx , msg , attachments )
if err == nil && strings . EqualFold ( folder . Name , "Inbox" ) {
a . applyInboundControls ( ctx , id , mb . ID , msg . From , msg . Subject )
}
return err == nil , err
}
func ( a * App ) maildirMessageExists ( ctx context . Context , mailboxID , folderID , rawPath , messageID string ) ( bool , error ) {
var count int
err := a . db . QueryRowContext ( ctx , `SELECT COUNT(1) FROM messages WHERE mailbox_id=? AND (raw_path=? OR (folder_id=? AND message_id=? AND message_id <> ''))` , mailboxID , rawPath , folderID , messageID ). Scan ( & count )
if err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return false , err
}
return count > 0 , nil
}
2026-06-15 00:37:43 +08:00
func ( a * App ) unregisteredMaildirMessageExists ( ctx context . Context , rawPath , messageID , recipient string ) ( bool , error ) {
var count int
err := a . db . QueryRowContext ( ctx , `SELECT COUNT(1) FROM messages WHERE mailbox_id IS NULL AND (raw_path=? OR (recipient_addr=? AND message_id=? AND message_id <> ''))` , rawPath , recipient , messageID ). Scan ( & count )
if err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return false , err
}
return count > 0 , nil
}
2026-06-24 15:18:06 +08:00
func ( a * App ) attachMaildirRawPathToExisting ( ctx context . Context , mailboxID , folderID , rawPath , messageID string ) {
if strings . TrimSpace ( messageID ) == "" || strings . TrimSpace ( rawPath ) == "" {
return
}
if _ , err := a . db . ExecContext ( ctx , `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''` ,
rawPath , a . now (). UTC (). Format ( time . RFC3339Nano ), mailboxID , folderID , messageID ); err != nil {
a . log . Warn ( "failed to attach maildir raw path to existing message" , "path" , rawPath , "error" , err )
}
}
2026-06-24 15:46:17 +08:00
func ( a * App ) syncExistingMaildirMessageState ( ctx context . Context , mailboxID , folderID , rawPath , messageID string , read , starred bool ) ( bool , error ) {
now := a . now (). UTC (). Format ( time . RFC3339Nano )
2026-06-24 16:23:45 +08:00
var samePathID , oldFolderID string
var oldRead , oldStarred int
var oldModSeq int64
err := a . db . QueryRowContext ( ctx , `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?` , mailboxID , rawPath ). Scan ( & samePathID , & oldFolderID , & oldRead , & oldStarred , & oldModSeq )
if err == nil {
if oldFolderID != folderID {
if oldFolderID != "" {
if _ , err := a . bumpFolderModSeq ( ctx , oldFolderID ); err != nil {
return false , err
}
}
meta , err := a . nextIMAPMetadata ( ctx , a . db , folderID )
if err != nil {
return false , err
}
_ , err = a . db . ExecContext ( ctx , `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?` ,
folderID , rawPath , boolInt ( read ), boolInt ( starred ), meta . UID , meta . ModSeq , now , samePathID )
return err == nil , err
}
modSeq := oldModSeq
if oldRead != boolInt ( read ) || oldStarred != boolInt ( starred ) {
modSeq , err = a . bumpFolderModSeq ( ctx , folderID )
if err != nil {
return false , err
}
}
_ , err = a . db . ExecContext ( ctx , `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?` ,
rawPath , boolInt ( read ), boolInt ( starred ), modSeq , modSeq , now , samePathID )
return err == nil , err
2026-06-24 15:46:17 +08:00
}
2026-06-24 16:23:45 +08:00
if err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return false , err
2026-06-24 15:46:17 +08:00
}
if strings . TrimSpace ( messageID ) == "" {
return false , nil
}
type candidate struct {
ID string
RawPath string
}
rows , err := a . db . QueryContext ( ctx , `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at` , mailboxID , messageID , folderID )
if err != nil {
return false , err
}
var chosen candidate
for rows . Next () {
var c candidate
if err := rows . Scan ( & c . ID , & c . RawPath ); err != nil {
rows . Close ()
return false , err
}
if c . RawPath == "" || c . RawPath == rawPath {
chosen = c
break
}
ok , err := a . pathIsUnderMaildirRoot ( c . RawPath )
if err != nil {
rows . Close ()
return false , err
}
if ok {
if _ , err := os . Stat ( c . RawPath ); errors . Is ( err , os . ErrNotExist ) {
chosen = c
break
} else if err != nil {
rows . Close ()
return false , err
}
}
}
if err := rows . Err (); err != nil {
rows . Close ()
return false , err
}
if err := rows . Close (); err != nil {
return false , err
}
if chosen . ID == "" {
a . removeDuplicateMaildirMessage ( ctx , rawPath , mailboxID , folderID , messageID )
return false , nil
}
2026-06-24 16:23:45 +08:00
var previousFolderID string
if err := a . db . QueryRowContext ( ctx , `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?` , chosen . ID ). Scan ( & previousFolderID ); err != nil {
return false , err
}
if previousFolderID != "" && previousFolderID != folderID {
if _ , err := a . bumpFolderModSeq ( ctx , previousFolderID ); err != nil {
return false , err
}
}
meta , err := a . nextIMAPMetadata ( ctx , a . db , folderID )
if err != nil {
return false , err
}
_ , err = a . db . ExecContext ( ctx , `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?` , folderID , rawPath , boolInt ( read ), boolInt ( starred ), meta . UID , meta . ModSeq , now , chosen . ID )
2026-06-24 15:46:17 +08:00
return err == nil , err
}
func ( a * App ) removeDuplicateMaildirMessage ( ctx context . Context , rawPath , mailboxID , folderID , messageID string ) {
var existing string
err := a . db . QueryRowContext ( ctx , `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1` , mailboxID , folderID , messageID ). Scan ( & existing )
if err != nil || existing == "" || existing == rawPath {
return
}
a . removeMaildirPath ( ctx , rawPath )
}
func ( a * App ) cleanupMissingMaildirMessages ( ctx context . Context ) ( int , error ) {
if strings . TrimSpace ( a . cfg . MaildirRoot ) == "" {
return 0 , nil
}
cutoff := a . now (). UTC (). Add ( - 5 * time . Minute ). Format ( time . RFC3339Nano )
rows , err := a . db . QueryContext ( ctx , `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?` , cutoff )
if err != nil {
return 0 , err
}
type item struct {
ID string
RawPath string
}
var missing [] item
for rows . Next () {
var it item
if err := rows . Scan ( & it . ID , & it . RawPath ); err != nil {
rows . Close ()
return 0 , err
}
ok , err := a . pathIsUnderMaildirRoot ( it . RawPath )
if err != nil {
rows . Close ()
return 0 , err
}
if ! ok {
continue
}
if _ , err := os . Stat ( it . RawPath ); errors . Is ( err , os . ErrNotExist ) {
missing = append ( missing , it )
} else if err != nil {
rows . Close ()
return 0 , err
}
}
if err := rows . Err (); err != nil {
rows . Close ()
return 0 , err
}
if err := rows . Close (); err != nil {
return 0 , err
}
for _ , it := range missing {
a . deleteMessageFiles ( ctx , it . ID )
2026-06-24 16:23:45 +08:00
var folderID sql . NullString
_ = a . db . QueryRowContext ( ctx , `SELECT folder_id FROM messages WHERE id=?` , it . ID ). Scan ( & folderID )
2026-06-24 15:46:17 +08:00
if _ , err := a . db . ExecContext ( ctx , `DELETE FROM messages WHERE id=?` , it . ID ); err != nil {
return 0 , err
}
2026-06-24 16:23:45 +08:00
if folderID . Valid && folderID . String != "" {
if _ , err := a . bumpFolderModSeq ( ctx , folderID . String ); err != nil {
return 0 , err
}
}
2026-06-24 15:46:17 +08:00
}
return len ( missing ), nil
}
func maildirFlagsFromPath ( path , folderName string ) ( bool , bool ) {
base := filepath . Base ( path )
flags := ""
hasFlags := false
for _ , sep := range [] string { maildirFlagSeparator (), ":2," , "!2," } {
if idx := strings . LastIndex ( base , sep ); idx >= 0 {
flags = base [ idx + len ( sep ):]
hasFlags = true
break
}
}
if hasFlags {
return strings . ContainsRune ( flags , 'S' ), strings . ContainsRune ( flags , 'F' )
}
return ! strings . EqualFold ( folderName , "Inbox" ), false
}
2026-06-24 15:18:06 +08:00
func ( a * App ) attachUnregisteredMaildirRawPathToExisting ( ctx context . Context , rawPath , messageID , recipient string ) {
if strings . TrimSpace ( messageID ) == "" || strings . TrimSpace ( rawPath ) == "" {
return
}
if _ , err := a . db . ExecContext ( ctx , `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''` ,
rawPath , a . now (). UTC (). Format ( time . RFC3339Nano ), recipient , messageID ); err != nil {
a . log . Warn ( "failed to attach unregistered maildir raw path to existing message" , "path" , rawPath , "error" , err )
}
}
2026-06-15 00:37:43 +08:00
func unregisteredRecipientFromMessage ( msg storedMessage , domain string ) string {
domain = normalizeDomain ( domain )
for _ , address := range append ( append ([] string {}, msg . To ... ), msg . CC ... ) {
address = normalizeEmail ( address )
if strings . HasSuffix ( address , "@" + domain ) {
return address
}
}
return ""
}
2026-06-14 01:07:48 +08:00
func ( a * App ) parseMaildirMessage ( raw [] byte , fallbackTo string ) ( storedMessage , [] AttachmentInput , error ) {
m , err := netmail . ReadMessage ( bytes . NewReader ( raw ))
if err != nil {
return storedMessage {}, nil , err
}
2026-06-19 22:15:00 +08:00
subject := decodeMIMEHeader ( m . Header . Get ( "Subject" ))
2026-06-14 01:07:48 +08:00
if strings . TrimSpace ( subject ) == "" {
subject = "(no subject)"
}
2026-06-16 23:29:23 +08:00
from , fromName := firstAddressParts ( m . Header . Get ( "From" ))
2026-06-14 01:07:48 +08:00
to := addressList ( m . Header . Get ( "To" ))
cc := addressList ( m . Header . Get ( "Cc" ))
if len ( to ) == 0 {
to = [] string { fallbackTo }
}
sentAt := parseMailDate ( m . Header . Get ( "Date" ))
parsed := & parsedMail {}
if err := parseMailPart ( textproto . MIMEHeader ( m . Header ), m . Body , parsed ); err != nil {
return storedMessage {}, nil , err
}
bodyHTML := a . policy . Sanitize ( parsed . HTML )
bodyText := parsed . Text
if strings . TrimSpace ( bodyText ) == "" {
bodyText = stripTags ( bodyHTML )
}
if strings . TrimSpace ( bodyHTML ) == "" && strings . TrimSpace ( bodyText ) != "" {
bodyHTML = "<p>" + htmlEscape ( bodyText ) + "</p>"
}
receivedAt := a . now (). UTC ()
if ! sentAt . IsZero () {
receivedAt = sentAt
}
return storedMessage {
2026-06-24 17:16:01 +08:00
MessageUID : newID ( "uid" ),
MessageID : strings . TrimSpace ( m . Header . Get ( "Message-Id" )),
Subject : subject ,
From : from ,
FromName : fromName ,
To : to ,
CC : cc ,
SentAt : sentAt ,
ReceivedAt : receivedAt ,
Snippet : snippetFrom ( bodyText , bodyHTML ),
BodyText : bodyText ,
BodyHTML : bodyHTML ,
IsRead : false ,
Authentication : parseMailAuthentication ( textproto . MIMEHeader ( m . Header )),
2026-06-14 01:07:48 +08:00
}, parsed . Attachments , nil
}
func parseMailPart ( header textproto . MIMEHeader , body io . Reader , parsed * parsedMail ) error {
contentType := header . Get ( "Content-Type" )
mediaType , params , err := mime . ParseMediaType ( contentType )
if err != nil || mediaType == "" {
mediaType = "text/plain"
}
if strings . HasPrefix ( strings . ToLower ( mediaType ), "multipart/" ) {
boundary := params [ "boundary" ]
if boundary == "" {
return nil
}
mr := multipart . NewReader ( body , boundary )
for {
part , err := mr . NextPart ()
if errors . Is ( err , io . EOF ) {
break
}
if err != nil {
return err
}
if err := parseMailPart ( part . Header , part , parsed ); err != nil {
return err
}
}
return nil
}
decoded , err := io . ReadAll ( transferReader ( header . Get ( "Content-Transfer-Encoding" ), body ))
if err != nil {
return err
}
filename := partFilename ( header )
if filename != "" || (! strings . HasPrefix ( strings . ToLower ( mediaType ), "text/" ) && len ( decoded ) > 0 ) {
if filename == "" {
filename = "attachment.bin"
}
parsed . Attachments = append ( parsed . Attachments , AttachmentInput { Filename : filename , ContentType : mediaType , ContentBase64 : base64 . StdEncoding . EncodeToString ( decoded )})
return nil
}
switch strings . ToLower ( mediaType ) {
case "text/html" :
if parsed . HTML == "" {
parsed . HTML = string ( decoded )
}
case "text/plain" :
if parsed . Text == "" {
parsed . Text = string ( decoded )
}
default :
// Ignore unsupported inline parts for now.
}
return nil
}
func transferReader ( encoding string , r io . Reader ) io . Reader {
switch strings . ToLower ( strings . TrimSpace ( encoding )) {
case "base64" :
return base64 . NewDecoder ( base64 . StdEncoding , r )
case "quoted-printable" :
return quotedprintable . NewReader ( r )
default :
return r
}
}
func partFilename ( header textproto . MIMEHeader ) string {
if _ , params , err := mime . ParseMediaType ( header . Get ( "Content-Disposition" )); err == nil {
if name := strings . TrimSpace ( params [ "filename" ]); name != "" {
2026-06-19 22:15:00 +08:00
return filepath . Base ( decodeMIMEHeader ( name ))
2026-06-14 01:07:48 +08:00
}
}
if _ , params , err := mime . ParseMediaType ( header . Get ( "Content-Type" )); err == nil {
if name := strings . TrimSpace ( params [ "name" ]); name != "" {
2026-06-19 22:15:00 +08:00
return filepath . Base ( decodeMIMEHeader ( name ))
2026-06-14 01:07:48 +08:00
}
}
return ""
}
2026-06-16 23:29:23 +08:00
func firstAddressParts ( value string ) ( string , string ) {
2026-06-19 22:49:20 +08:00
items , err := netmail . ParseAddressList ( value )
2026-06-16 23:29:23 +08:00
if err != nil || len ( items ) == 0 {
2026-06-19 22:49:20 +08:00
// ParseAddressList failed — attempt RFC 2047 decode on the raw header,
// then retry parsing. This handles non-standard From headers where
// encoded words (e.g. =?UTF-8?B?…?=) cause the initial parse to fail.
decoded := decodeMIMEHeader ( value )
items , err = netmail . ParseAddressList ( decoded )
if err != nil || len ( items ) == 0 {
// Still unparseable: return the decoded value and try to extract
// a display name from the decoded string.
email , name := splitNameAndEmail ( decoded )
return normalizeEmail ( email ), strings . TrimSpace ( name )
}
2026-06-14 01:07:48 +08:00
}
2026-06-16 23:29:23 +08:00
item := items [ 0 ]
2026-06-19 22:49:20 +08:00
// Decode item.Name individually so that non-UTF-8 charsets (e.g. GBK,
// Shift_JIS) are handled by our CharsetReader, while the address list
// structure is parsed from the raw header (avoiding commas/semicolons
// inside decoded display names breaking the parser).
return normalizeEmail ( item . Address ), strings . TrimSpace ( decodeMIMEHeader ( item . Name ))
2026-06-14 01:07:48 +08:00
}
2026-06-19 21:27:05 +08:00
// decodeMIMEHeader decodes all RFC 2047 encoded words (=?charset?encoding?data?=)
// in the given header value. Falls back to the original value on any error.
2026-06-19 22:15:00 +08:00
// Supports non-UTF-8 charsets (e.g. GBK, GB2312, Shift_JIS) via x/text.
2026-06-19 22:49:20 +08:00
// Per RFC 2047 §6.2, linear whitespace between adjacent encoded words is
// stripped before decoding.
2026-06-19 21:27:05 +08:00
func decodeMIMEHeader ( value string ) string {
if ! strings . Contains ( value , "=?" ) {
return value
}
2026-06-19 22:49:20 +08:00
// RFC 2047 §6.2: ignore whitespace between adjacent encoded words.
collapsed := adjacentEncodedWordSpaceRe . ReplaceAllString ( value , "$1$2" )
2026-06-19 22:15:00 +08:00
decoder := & mime . WordDecoder {
CharsetReader : charsetReader ,
}
2026-06-19 22:49:20 +08:00
decoded , err := decoder . DecodeHeader ( collapsed )
2026-06-19 21:27:05 +08:00
if err != nil {
return value
}
return decoded
}
2026-06-19 22:49:20 +08:00
// adjacentEncodedWordSpaceRe matches whitespace between two adjacent RFC 2047
// encoded words. Per RFC 2047 §6.2, this whitespace must be ignored when
// displaying the header.
var adjacentEncodedWordSpaceRe = regexp . MustCompile ( `(\?=)\s+(=\?)` )
2026-06-19 22:15:00 +08:00
// charsetReader converts a non-UTF-8 charset stream into UTF-8 using x/text encodings.
func charsetReader ( charset string , input io . Reader ) ( io . Reader , error ) {
charset = strings . ToLower ( strings . TrimSpace ( charset ))
if charset == "utf-8" || charset == "us-ascii" {
return input , nil
}
enc , err := ianaindex . IANA . Encoding ( charset )
if err != nil {
return nil , fmt . Errorf ( "unsupported charset %q: %w" , charset , err )
}
2026-06-19 22:49:20 +08:00
if enc == nil || enc == encoding . Nop || enc == encoding . Replacement {
2026-06-19 22:15:00 +08:00
return nil , fmt . Errorf ( "unsupported charset %q" , charset )
}
return enc . NewDecoder (). Reader ( input ), nil
}
2026-06-19 21:27:05 +08:00
// splitNameAndEmail attempts to extract a display name and email address from
// a string like "Display Name <user@example.com>" or plain "user@example.com".
func splitNameAndEmail ( value string ) ( string , string ) {
value = strings . TrimSpace ( value )
if value == "" {
return "" , ""
}
// Try "Name <email>" pattern
2026-06-19 22:15:00 +08:00
if idx := strings . LastIndex ( value , "<" ); idx >= 0 {
2026-06-19 22:49:20 +08:00
email := strings . TrimSpace ( strings . Trim ( value [ idx + 1 :], "> " ))
2026-06-19 21:27:05 +08:00
name := strings . TrimSpace ( strings . Trim ( value [: idx ], `" ` ))
if strings . Contains ( email , "@" ) {
return email , name
}
}
// Plain email or unknown format
if strings . Contains ( value , "@" ) {
return value , ""
}
return value , ""
}
2026-06-14 01:07:48 +08:00
func addressList ( value string ) [] string {
items , err := netmail . ParseAddressList ( value )
2026-06-19 22:15:00 +08:00
if err != nil || len ( items ) == 0 {
// ParseAddressList failed — attempt RFC 2047 decode, then retry.
decoded := decodeMIMEHeader ( value )
items , err = netmail . ParseAddressList ( decoded )
if err != nil || len ( items ) == 0 {
return nil
}
2026-06-14 01:07:48 +08:00
}
out := make ([] string , 0 , len ( items ))
for _ , item := range items {
out = append ( out , normalizeEmail ( item . Address ))
}
return out
}
func parseMailDate ( value string ) time . Time {
if strings . TrimSpace ( value ) == "" {
return time . Time {}
}
if t , err := netmail . ParseDate ( value ); err == nil {
return t . UTC ()
}
return time . Time {}
}