2026-06-24 15:18:06 +08:00
package app
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
2026-06-24 15:27:30 +08:00
"runtime"
2026-06-24 15:18:06 +08:00
"strings"
"time"
)
func ( a * App ) writeStoredMessageToMaildir ( ctx context . Context , messageID string , msg storedMessage , attachments [] AttachmentInput ) error {
if strings . TrimSpace ( a . cfg . MaildirRoot ) == "" || strings . TrimSpace ( msg . MailboxID ) == "" || strings . TrimSpace ( msg . FolderID ) == "" {
return nil
}
raw , err := 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 ,
})
if err != nil {
return err
}
return a . writeRawMessageToMaildir ( ctx , messageID , raw , false )
}
func ( a * App ) rewriteMessageMaildir ( ctx context . Context , messageID string ) error {
if strings . TrimSpace ( a . cfg . MaildirRoot ) == "" {
return nil
}
msg , err := a . storedMessageByID ( ctx , messageID )
if err != nil {
return err
}
attachments , err := a . attachmentInputsForMessage ( ctx , messageID )
if err != nil {
return err
}
raw , err := 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 ,
})
if err != nil {
return err
}
return a . writeRawMessageToMaildir ( ctx , messageID , raw , true )
}
func ( a * App ) writeRawMessageToMaildir ( ctx context . Context , messageID string , raw [] byte , replace bool ) error {
2026-06-24 15:27:30 +08:00
state , err := a . maildirMessageState ( ctx , messageID )
if err != nil {
return err
}
return a . writeRawMessageToMaildirFolder ( ctx , messageID , state . FolderID , raw , replace , false )
}
func ( a * App ) writeRawMessageToMaildirFolder ( ctx context . Context , messageID , folderID string , raw [] byte , replace bool , updateFolder bool ) error {
2026-06-24 15:18:06 +08:00
if strings . TrimSpace ( a . cfg . MaildirRoot ) == "" {
return nil
}
state , err := a . maildirMessageState ( ctx , messageID )
if err != nil {
return err
}
2026-06-24 15:27:30 +08:00
if folderID != "" {
2026-06-24 16:23:45 +08:00
oldFolderID := state . FolderID
2026-06-24 15:27:30 +08:00
state . FolderID = folderID
2026-06-24 16:23:45 +08:00
if updateFolder && oldFolderID != "" && oldFolderID != state . FolderID {
if _ , err := a . bumpFolderModSeq ( ctx , oldFolderID ); err != nil {
return err
}
}
2026-06-24 15:27:30 +08:00
}
2026-06-24 15:18:06 +08:00
if state . MailboxID == "" || state . FolderID == "" {
return nil
}
if ! replace && state . RawPath != "" {
if ok , err := a . pathIsUnderMaildirRoot ( state . RawPath ); err != nil {
return err
} else if ok {
if _ , err := os . Stat ( state . RawPath ); err == nil {
return nil
} else if ! errors . Is ( err , os . ErrNotExist ) {
return err
}
}
}
mb , err := a . maildirMailboxByID ( ctx , state . MailboxID )
if err != nil {
return err
}
folderName , err := a . folderNameByID ( ctx , state . FolderID )
if err != nil {
return err
}
base := filepath . Join ( strings . TrimSpace ( a . cfg . MaildirRoot ), mb . Domain , mb . LocalPart , "Maildir" )
folderBase := maildirFolderPath ( base , folderName )
subdir := "cur"
if strings . EqualFold ( folderName , "Inbox" ) && ! state . IsRead {
subdir = "new"
}
2026-06-24 19:55:43 +08:00
if err := ensureMaildirFolderDirs ( base , folderBase ); err != nil {
2026-06-24 15:18:06 +08:00
return err
}
filename := maildirFilename ( messageID , state . MessageID )
tmpPath := filepath . Join ( folderBase , "tmp" , filename )
finalPath := filepath . Join ( folderBase , subdir , filename )
2026-06-24 15:33:56 +08:00
finalPath = maildirPathWithFlags ( finalPath , state . IsRead , state . IsStarred )
2026-06-24 15:18:06 +08:00
if err := os . WriteFile ( tmpPath , raw , 0 o600 ); err != nil {
return err
}
2026-06-24 19:55:43 +08:00
if err := applyMaildirOwnership ( tmpPath ); err != nil {
_ = os . Remove ( tmpPath )
return err
}
2026-06-24 15:18:06 +08:00
if err := os . Rename ( tmpPath , finalPath ); err != nil {
_ = os . Remove ( tmpPath )
return err
}
if replace || state . RawPath != "" {
a . removeMaildirPath ( ctx , state . RawPath )
}
2026-06-24 15:27:30 +08:00
if updateFolder {
2026-06-24 16:23:45 +08:00
if state . IMAPUID > 0 && folderID == "" {
modSeq , metaErr := a . bumpFolderModSeq ( ctx , state . FolderID )
if metaErr != nil {
return metaErr
}
_ , err = a . db . ExecContext ( ctx , `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?` , state . FolderID , finalPath , modSeq , modSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), messageID )
} else {
meta , metaErr := a . nextIMAPMetadata ( ctx , a . db , state . FolderID )
if metaErr != nil {
return metaErr
}
_ , err = a . db . ExecContext ( ctx , `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?` , state . FolderID , finalPath , meta . UID , meta . ModSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), messageID )
}
2026-06-24 15:27:30 +08:00
} else {
2026-06-24 16:23:45 +08:00
modSeq , metaErr := a . bumpFolderModSeq ( ctx , state . FolderID )
if metaErr != nil {
return metaErr
}
_ , err = a . db . ExecContext ( ctx , `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?` , finalPath , modSeq , modSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), messageID )
2026-06-24 15:27:30 +08:00
}
2026-06-24 15:18:06 +08:00
return err
}
func ( a * App ) moveMessageMaildir ( ctx context . Context , messageID , targetFolderID string ) error {
if strings . TrimSpace ( a . cfg . MaildirRoot ) == "" {
2026-06-24 16:23:45 +08:00
state , stateErr := a . maildirMessageState ( ctx , messageID )
if stateErr != nil {
return stateErr
}
if state . FolderID != "" && state . FolderID != targetFolderID {
if _ , err := a . bumpFolderModSeq ( ctx , state . FolderID ); err != nil {
return err
}
}
meta , metaErr := a . nextIMAPMetadata ( ctx , a . db , targetFolderID )
if metaErr != nil {
return metaErr
}
_ , err := a . db . ExecContext ( ctx , `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?` , targetFolderID , meta . UID , meta . ModSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), messageID )
2026-06-24 15:18:06 +08:00
return err
}
state , err := a . maildirMessageState ( ctx , messageID )
if err != nil {
return err
}
if state . MailboxID == "" {
return nil
}
state . FolderID = targetFolderID
if state . RawPath == "" {
return a . writeMessageToNewMaildirFolder ( ctx , messageID , targetFolderID )
}
ok , err := a . pathIsUnderMaildirRoot ( state . RawPath )
if err != nil {
return err
}
if ! ok {
return a . writeMessageToNewMaildirFolder ( ctx , messageID , targetFolderID )
}
if _ , err := os . Stat ( state . RawPath ); err != nil {
if errors . Is ( err , os . ErrNotExist ) {
return a . writeMessageToNewMaildirFolder ( ctx , messageID , targetFolderID )
}
return err
}
mb , err := a . maildirMailboxByID ( ctx , state . MailboxID )
if err != nil {
return err
}
folderName , err := a . folderNameByID ( ctx , targetFolderID )
if err != nil {
return err
}
base := filepath . Join ( strings . TrimSpace ( a . cfg . MaildirRoot ), mb . Domain , mb . LocalPart , "Maildir" )
folderBase := maildirFolderPath ( base , folderName )
2026-06-24 19:55:43 +08:00
if err := ensureMaildirFolderDirs ( base , folderBase ); err != nil {
2026-06-24 15:18:06 +08:00
return err
}
subdir := "cur"
if strings . EqualFold ( folderName , "Inbox" ) && ! state . IsRead {
subdir = "new"
}
targetPath := filepath . Join ( folderBase , subdir , filepath . Base ( state . RawPath ))
if filepath . Clean ( targetPath ) != filepath . Clean ( state . RawPath ) {
if err := os . Rename ( state . RawPath , targetPath ); err != nil {
return err
}
2026-06-24 19:55:43 +08:00
if err := applyMaildirOwnership ( targetPath ); err != nil {
return err
}
2026-06-24 15:18:06 +08:00
}
2026-06-24 16:23:45 +08:00
if state . FolderID != "" && state . FolderID != targetFolderID {
if _ , err := a . bumpFolderModSeq ( ctx , state . FolderID ); err != nil {
return err
}
}
meta , err := a . nextIMAPMetadata ( ctx , a . db , targetFolderID )
if err != nil {
return err
}
_ , err = a . db . ExecContext ( ctx , `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?` , targetFolderID , targetPath , meta . UID , meta . ModSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), messageID )
2026-06-24 15:18:06 +08:00
return err
}
func ( a * App ) writeMessageToNewMaildirFolder ( ctx context . Context , messageID , folderID string ) error {
msg , err := a . storedMessageByID ( ctx , messageID )
if err != nil {
return err
}
msg . FolderID = folderID
attachments , err := a . attachmentInputsForMessage ( ctx , messageID )
if err != nil {
return err
}
raw , err := 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 ,
})
if err != nil {
return err
}
2026-06-24 15:27:30 +08:00
return a . writeRawMessageToMaildirFolder ( ctx , messageID , folderID , raw , true , true )
2026-06-24 15:18:06 +08:00
}
func ( a * App ) deleteMessageMaildirFile ( ctx context . Context , messageID string ) {
var rawPath string
if err := a . db . QueryRowContext ( ctx , `SELECT raw_path FROM messages WHERE id=?` , messageID ). Scan ( & rawPath ); err != nil {
return
}
a . removeMaildirPath ( ctx , rawPath )
}
2026-06-24 15:27:30 +08:00
func ( a * App ) updateMessageMaildirFlags ( ctx context . Context , messageID string , read , starred * bool ) error {
if strings . TrimSpace ( a . cfg . MaildirRoot ) == "" {
return nil
}
state , err := a . maildirMessageState ( ctx , messageID )
if err != nil {
return err
}
if state . RawPath == "" {
return nil
}
ok , err := a . pathIsUnderMaildirRoot ( state . RawPath )
if err != nil {
return err
}
if ! ok {
return nil
}
if _ , err := os . Stat ( state . RawPath ); err != nil {
if errors . Is ( err , os . ErrNotExist ) {
return nil
}
return err
}
currentRead := state . IsRead
currentStarred := state . IsStarred
if read != nil {
currentRead = * read
}
if starred != nil {
currentStarred = * starred
}
targetPath := maildirPathWithFlags ( state . RawPath , currentRead , currentStarred )
if filepath . Clean ( targetPath ) == filepath . Clean ( state . RawPath ) {
return nil
}
if err := os . MkdirAll ( filepath . Dir ( targetPath ), 0 o755 ); err != nil {
return err
}
if err := os . Rename ( state . RawPath , targetPath ); err != nil {
return err
}
2026-06-24 19:55:43 +08:00
if err := applyMaildirOwnership ( targetPath ); err != nil {
return err
}
2026-06-24 16:23:45 +08:00
modSeq , err := a . bumpFolderModSeq ( ctx , state . FolderID )
if err != nil {
return err
}
_ , err = a . db . ExecContext ( ctx , `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?` , targetPath , modSeq , modSeq , a . now (). UTC (). Format ( time . RFC3339Nano ), messageID )
2026-06-24 15:27:30 +08:00
return err
}
2026-06-24 15:18:06 +08:00
func ( a * App ) removeMaildirPath ( ctx context . Context , rawPath string ) {
rawPath = strings . TrimSpace ( rawPath )
if rawPath == "" {
return
}
ok , err := a . pathIsUnderMaildirRoot ( rawPath )
if err != nil || ! ok {
if err != nil {
a . log . Warn ( "failed to validate maildir path" , "path" , rawPath , "error" , err )
}
return
}
if err := os . Remove ( rawPath ); err != nil && ! errors . Is ( err , os . ErrNotExist ) {
a . log . Warn ( "failed to remove maildir message" , "path" , rawPath , "error" , err )
}
}
func ( a * App ) backfillSQLiteMessagesToMaildir ( ctx context . Context ) ( int , error ) {
if strings . TrimSpace ( a . cfg . MaildirRoot ) == "" {
return 0 , nil
}
rows , err := a . db . QueryContext ( ctx , `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100` )
if err != nil {
return 0 , err
}
var ids [] string
for rows . Next () {
select {
case <- ctx . Done ():
rows . Close ()
return 0 , ctx . Err ()
default :
}
var id string
if err := rows . Scan ( & id ); err != nil {
rows . Close ()
return 0 , err
}
ids = append ( ids , id )
}
if err := rows . Err (); err != nil {
rows . Close ()
return 0 , err
}
if err := rows . Close (); err != nil {
return 0 , err
}
count := 0
for _ , id := range ids {
if err := a . rewriteMessageMaildir ( ctx , id ); err != nil {
return count , err
}
count ++
}
return count , nil
}
type maildirMessageState struct {
2026-06-24 16:23:45 +08:00
MailboxID string
FolderID string
MessageID string
RawPath string
IsRead bool
IsStarred bool
IMAPUID int64
IMAPModSeq int64
2026-06-24 15:18:06 +08:00
}
func ( a * App ) maildirMessageState ( ctx context . Context , id string ) ( maildirMessageState , error ) {
var state maildirMessageState
var mailboxID , folderID sql . NullString
2026-06-24 15:27:30 +08:00
var read , starred int
2026-06-24 16:23:45 +08:00
err := a . db . QueryRowContext ( ctx , `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?` , id ). Scan ( & mailboxID , & folderID , & state . MessageID , & state . RawPath , & read , & starred , & state . IMAPUID , & state . IMAPModSeq )
2026-06-24 15:18:06 +08:00
if err != nil {
return state , err
}
state . MailboxID = mailboxID . String
state . FolderID = folderID . String
state . IsRead = intBool ( read )
2026-06-24 15:27:30 +08:00
state . IsStarred = intBool ( starred )
2026-06-24 15:18:06 +08:00
return state , nil
}
func ( a * App ) storedMessageByID ( ctx context . Context , id string ) ( storedMessage , error ) {
row := a . db . QueryRowContext ( ctx , `SELECT COALESCE(mailbox_id,''),COALESCE(folder_id,''),recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,raw_path FROM messages WHERE id=?` , id )
var msg storedMessage
var toJSON , ccJSON , bccJSON , sent , received string
var read , starred int
err := row . Scan ( & msg . MailboxID , & msg . FolderID , & msg . RecipientAddr , & msg . MessageUID , & msg . MessageID , & msg . Subject , & msg . From , & msg . FromName , & toJSON , & ccJSON , & bccJSON , & sent , & received , & msg . Snippet , & msg . BodyText , & msg . BodyHTML , & read , & starred , & msg . RawPath )
if err != nil {
return msg , err
}
msg . To = jsonDecodeSlice ( toJSON )
msg . CC = jsonDecodeSlice ( ccJSON )
msg . BCC = jsonDecodeSlice ( bccJSON )
msg . SentAt = parseTime ( sent )
msg . ReceivedAt = parseTime ( received )
msg . IsRead = intBool ( read )
msg . IsStarred = intBool ( starred )
return msg , nil
}
func ( a * App ) attachmentInputsForMessage ( ctx context . Context , messageID string ) ([] AttachmentInput , error ) {
rows , err := a . db . QueryContext ( ctx , `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename` , messageID )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] AttachmentInput
for rows . Next () {
var filename , contentType , storagePath string
if err := rows . Scan ( & filename , & contentType , & storagePath ); err != nil {
return nil , err
}
data , err := os . ReadFile ( storagePath )
if err != nil {
return nil , err
}
out = append ( out , AttachmentInput { Filename : filename , ContentType : contentType , ContentBase64 : base64 . StdEncoding . EncodeToString ( data )})
}
return out , rows . Err ()
}
func ( a * App ) maildirMailboxByID ( ctx context . Context , mailboxID string ) ( maildirMailbox , error ) {
var mb maildirMailbox
err := a . db . QueryRowContext ( 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.id=?` , mailboxID ). Scan ( & mb . ID , & mb . Address , & mb . LocalPart , & mb . Domain )
return mb , err
}
func ( a * App ) folderNameByID ( ctx context . Context , folderID string ) ( string , error ) {
var name string
err := a . db . QueryRowContext ( ctx , `SELECT name FROM folders WHERE id=?` , folderID ). Scan ( & name )
return name , err
}
func ( a * App ) pathIsUnderMaildirRoot ( path string ) ( bool , error ) {
root := strings . TrimSpace ( a . cfg . MaildirRoot )
if root == "" || strings . TrimSpace ( path ) == "" {
return false , nil
}
rootAbs , err := filepath . Abs ( root )
if err != nil {
return false , err
}
pathAbs , err := filepath . Abs ( path )
if err != nil {
return false , err
}
rel , err := filepath . Rel ( rootAbs , pathAbs )
if err != nil {
return false , err
}
return rel != "." && ! strings . HasPrefix ( rel , ".." + string ( filepath . Separator )) && rel != ".." , nil
}
2026-06-24 19:55:43 +08:00
func ensureMaildirFolderDirs ( base , folderBase string ) error {
2026-06-24 15:18:06 +08:00
for _ , sub := range [] string { "tmp" , "new" , "cur" } {
if err := os . MkdirAll ( filepath . Join ( folderBase , sub ), 0 o755 ); err != nil {
return err
}
}
2026-06-24 19:55:43 +08:00
for _ , dir := range maildirOwnershipDirs ( base , folderBase ) {
if err := applyMaildirOwnership ( dir ); err != nil {
return err
}
}
2026-06-24 15:18:06 +08:00
return nil
}
2026-06-24 19:55:43 +08:00
func maildirOwnershipDirs ( base , folderBase string ) [] string {
dirs := [] string {
filepath . Dir ( filepath . Dir ( base )),
filepath . Dir ( base ),
base ,
}
if filepath . Clean ( folderBase ) != filepath . Clean ( base ) {
dirs = append ( dirs , folderBase )
}
dirs = append ( dirs , filepath . Join ( folderBase , "tmp" ), filepath . Join ( folderBase , "new" ), filepath . Join ( folderBase , "cur" ))
return dirs
}
2026-06-24 15:18:06 +08:00
func maildirFilename ( messageID , headerMessageID string ) string {
base := strings . TrimSpace ( headerMessageID )
if base == "" {
base = messageID
}
return fmt . Sprintf ( "%d.%s.%s" , time . Now (). UnixNano (), safeMaildirName ( messageID ), safeMaildirName ( base ))
}
func safeMaildirName ( value string ) string {
value = strings . Trim ( value , "<>" )
var b strings . Builder
for _ , r := range value {
switch {
case r >= 'a' && r <= 'z' , r >= 'A' && r <= 'Z' , r >= '0' && r <= '9' :
b . WriteRune ( r )
case r == '.' , r == '_' , r == '-' , r == '@' :
b . WriteRune ( r )
default :
b . WriteByte ( '_' )
}
}
out := strings . Trim ( b . String (), "._-" )
if out == "" {
out = "message"
}
if len ( out ) > 120 {
out = out [: 120 ]
}
return out
}
func messageDate ( msg storedMessage ) time . Time {
if ! msg . SentAt . IsZero () {
return msg . SentAt
}
if ! msg . ReceivedAt . IsZero () {
return msg . ReceivedAt
}
return time . Now (). UTC ()
}
2026-06-24 15:27:30 +08:00
func maildirPathWithFlags ( path string , read , starred bool ) string {
dir := filepath . Dir ( path )
name := filepath . Base ( path )
if read || starred {
dir = filepath . Join ( filepath . Dir ( dir ), "cur" )
} else if filepath . Base ( dir ) == "cur" {
dir = filepath . Join ( filepath . Dir ( dir ), "new" )
}
base := name
sep := maildirFlagSeparator ()
2026-06-24 15:33:56 +08:00
existingFlags := ""
2026-06-24 15:27:30 +08:00
if idx := strings . LastIndex ( base , sep ); idx >= 0 {
2026-06-24 15:33:56 +08:00
existingFlags = base [ idx + len ( sep ):]
2026-06-24 15:27:30 +08:00
base = base [: idx ]
}
2026-06-24 15:33:56 +08:00
flags := preserveMaildirFlags ( existingFlags , "SF" )
2026-06-24 15:27:30 +08:00
if read {
2026-06-24 15:33:56 +08:00
flags = appendMaildirFlag ( flags , 'S' )
2026-06-24 15:27:30 +08:00
}
if starred {
2026-06-24 15:33:56 +08:00
flags = appendMaildirFlag ( flags , 'F' )
2026-06-24 15:27:30 +08:00
}
if flags != "" {
base += sep + flags
}
return filepath . Join ( dir , base )
}
2026-06-24 15:33:56 +08:00
func preserveMaildirFlags ( flags , managed string ) string {
var b strings . Builder
for _ , flag := range flags {
if strings . ContainsRune ( managed , flag ) || strings . ContainsRune ( b . String (), flag ) {
continue
}
b . WriteRune ( flag )
}
return b . String ()
}
func appendMaildirFlag ( flags string , flag rune ) string {
if strings . ContainsRune ( flags , flag ) {
return flags
}
return flags + string ( flag )
}
2026-06-24 15:27:30 +08:00
func maildirFlagSeparator () string {
if runtime . GOOS == "windows" {
return "!2,"
}
return ":2,"
}