@@ -342,6 +342,24 @@ func (a *App) MoveToFolder(messageIDs []string, destFolderID string) error {
342342 return fmt .Errorf ("destination folder not found: %s" , destFolderID )
343343 }
344344
345+ // Cross-account move: APPEND raw bytes to destination first, then route source
346+ // cleanup through the existing Trash() pipeline (which handles local DB, IMAP
347+ // COPY+EXPUNGE within source, folder counts, events, undo, Gmail labels).
348+ // Strict ordering: APPEND completes synchronously before Trash runs — if
349+ // APPEND fails, source stays untouched.
350+ if messages [0 ].AccountID != destFolder .AccountID {
351+ if err := a .copyMessagesAcrossAccounts (messages , destFolder ); err != nil {
352+ return fmt .Errorf ("cross-account move: append failed: %w" , err )
353+ }
354+ _ , trashErr := a .Trash (messageIDs )
355+ // Sync destination so appended messages get correct UIDs locally.
356+ go func () {
357+ defer recoverPanic ("app.actions" , "cross-account move dest sync" )
358+ _ = a .SyncFolder (destFolder .AccountID , destFolder .ID )
359+ }()
360+ return trashErr
361+ }
362+
345363 // Group by source folder
346364 byFolder := make (map [string ][]* message.Message )
347365 for _ , m := range messages {
@@ -610,6 +628,28 @@ func (a *App) CopyToFolder(messageIDs []string, destFolderID string) error {
610628 return fmt .Errorf ("destination folder not found: %s" , destFolderID )
611629 }
612630
631+ // Cross-account copy: APPEND raw bytes to destination. Fire-and-forget to
632+ // match intra-account CopyToFolder's existing async behavior.
633+ if messages [0 ].AccountID != destFolder .AccountID {
634+ go func () {
635+ defer recoverPanic ("app.actions" , "cross-account copy" )
636+ if err := a .copyMessagesAcrossAccounts (messages , destFolder ); err != nil {
637+ log .Error ().Err (err ).
638+ Str ("sourceAccountID" , messages [0 ].AccountID ).
639+ Str ("destAccountID" , destFolder .AccountID ).
640+ Str ("destFolderID" , destFolder .ID ).
641+ Msg ("Cross-account copy failed" )
642+ return
643+ }
644+ _ = a .SyncFolder (destFolder .AccountID , destFolder .ID )
645+ wailsRuntime .EventsEmit (a .ctx , "messages:copied" , map [string ]interface {}{
646+ "messageIds" : messageIDs ,
647+ "destFolderId" : destFolder .ID ,
648+ })
649+ }()
650+ return nil
651+ }
652+
613653 // Group by source folder
614654 byFolder := make (map [string ][]* message.Message )
615655 for _ , m := range messages {
@@ -681,6 +721,67 @@ func (a *App) copyMessagesToIMAP(messages []*message.Message, sourceFolderID str
681721 })
682722}
683723
724+ // flagsForAppend maps a local message's flag fields to the IMAP flag slice
725+ // used by AppendMessage on cross-account copy/move.
726+ func flagsForAppend (m * message.Message ) []goImap.Flag {
727+ flags := []goImap.Flag {}
728+ if m .IsRead {
729+ flags = append (flags , goImap .FlagSeen )
730+ }
731+ if m .IsStarred {
732+ flags = append (flags , goImap .FlagFlagged )
733+ }
734+ if m .IsAnswered {
735+ flags = append (flags , goImap .FlagAnswered )
736+ }
737+ if m .IsDraft {
738+ flags = append (flags , goImap .FlagDraft )
739+ }
740+ return flags
741+ }
742+
743+ // copyMessagesAcrossAccounts fetches each message's raw RFC822 bytes from the
744+ // source account's IMAP and APPENDs them to the destination account's IMAP.
745+ // Used by the cross-account branches of MoveToFolder and CopyToFolder.
746+ //
747+ // Synchronous: returns when every APPEND has succeeded. Caller is expected to
748+ // short-circuit on error so the source side is never touched after a partial
749+ // success/failure.
750+ func (a * App ) copyMessagesAcrossAccounts (messages []* message.Message , destFolder * folder.Folder ) error {
751+ log := logging .WithComponent ("app.copyMessagesAcrossAccounts" )
752+
753+ if len (messages ) == 0 {
754+ return nil
755+ }
756+
757+ log .Info ().
758+ Str ("sourceAccountID" , messages [0 ].AccountID ).
759+ Str ("destAccountID" , destFolder .AccountID ).
760+ Str ("destFolder" , destFolder .Path ).
761+ Int ("count" , len (messages )).
762+ Msg ("Starting cross-account append" )
763+
764+ return a .withIMAPRetry (destFolder .AccountID , func (conn * imap.Client ) error {
765+ if _ , err := conn .SelectMailbox (a .ctx , destFolder .Path ); err != nil {
766+ return fmt .Errorf ("failed to select destination mailbox: %w" , err )
767+ }
768+ for _ , m := range messages {
769+ raw , err := a .syncEngine .FetchRawMessage (a .ctx , m .AccountID , m .FolderID , m .UID )
770+ if err != nil {
771+ return fmt .Errorf ("failed to fetch raw message from source: %w" , err )
772+ }
773+ if _ , err := conn .AppendMessage (destFolder .Path , flagsForAppend (m ), m .Date , raw ); err != nil {
774+ return fmt .Errorf ("failed to append to destination: %w" , err )
775+ }
776+ }
777+ log .Info ().
778+ Str ("destFolder" , destFolder .Path ).
779+ Int ("count" , len (messages )).
780+ Msg ("Cross-account append completed" )
781+ return nil
782+ })
783+ }
784+
684785// Archive moves messages to the Archive folder
685786func (a * App ) Archive (messageIDs []string ) error {
686787 if len (messageIDs ) == 0 {
0 commit comments