Skip to content

Commit 142b8cc

Browse files
authored
[verified] fix(imap): use native MOVE (#30)
1 parent 68100b9 commit 142b8cc

3 files changed

Lines changed: 98 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
- `mail move`, `mail archive`, and batch move/archive operations now use the
12+
server's atomic IMAP `MOVE` command when available. This removes a data-loss
13+
path where the previous unqualified `EXPUNGE` could permanently delete
14+
unrelated messages already marked `\\Deleted`; the library fallback uses
15+
targeted `UID EXPUNGE` on UIDPLUS servers (#28, #29).
16+
817
## [0.2.6] - 2026-08-09
918

1019
Validated against Proton Bridge 3.25.0 before release: the IMAP no-match detection,

internal/imap/client.go

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -590,21 +590,44 @@ func (c *Client) CopyMessages(mailbox string, ids []string, destMailbox string)
590590
return nil
591591
}
592592

593-
// copyMatchedNothing reports whether a COPY provably affected no messages.
593+
// numSetsMatchedNothing reports whether UIDPLUS response data proves an
594+
// operation matched no messages. MOVE returns imap.NumSet values while COPY
595+
// returns UIDSet values, so the shared check accepts either representation.
594596
//
595-
// The evidence is the COPYUID response code, which is only emitted by servers
596-
// advertising UIDPLUS (folded into IMAP4rev2). Without that capability an empty
597-
// SourceUIDs/DestUIDs pair means "the server never told us", not "nothing was
598-
// copied" — treating it as the latter would fail every successful copy. So we
599-
// only draw the conclusion when the capability guarantees the data is present.
597+
// Without UIDPLUS (or IMAP4rev2, which includes it), empty source and
598+
// destination sets mean "the server never told us," not "nothing matched."
599+
func (c *Client) numSetsMatchedNothing(source, dest imap.NumSet) bool {
600+
if !c.client.Caps().Has(imap.CapUIDPlus) {
601+
return false
602+
}
603+
return numSetEmpty(source) && numSetEmpty(dest)
604+
}
605+
606+
func numSetEmpty(set imap.NumSet) bool {
607+
switch set := set.(type) {
608+
case nil:
609+
return true
610+
case imap.SeqSet:
611+
return len(set) == 0
612+
case imap.UIDSet:
613+
return len(set) == 0
614+
default:
615+
return false
616+
}
617+
}
618+
600619
func (c *Client) copyMatchedNothing(data *imap.CopyData) bool {
601620
if data == nil {
602621
return false
603622
}
604-
if !c.client.Caps().Has(imap.CapUIDPlus) {
623+
return c.numSetsMatchedNothing(data.SourceUIDs, data.DestUIDs)
624+
}
625+
626+
func (c *Client) moveMatchedNothing(data *imapclient.MoveData) bool {
627+
if data == nil {
605628
return false
606629
}
607-
return len(data.SourceUIDs) == 0 && len(data.DestUIDs) == 0
630+
return c.numSetsMatchedNothing(data.SourceUIDs, data.DestUIDs)
608631
}
609632

610633
func (c *Client) MoveMessages(mailbox string, ids []string, destMailbox string) error {
@@ -618,40 +641,14 @@ func (c *Client) MoveMessages(mailbox string, ids []string, destMailbox string)
618641
return err
619642
}
620643

621-
// Copy to destination. As with CopyMessages, a COPY that matches nothing is
622-
// not an error on its own, so verify the COPYUID data before proceeding to
623-
// delete from the source — otherwise a move of non-existent UIDs would
624-
// silently expunge nothing yet report success.
625-
copyCmd := c.client.Copy(numSet, destMailbox)
626-
copyData, err := copyCmd.Wait()
644+
moveData, err := c.client.Move(numSet, destMailbox).Wait()
627645
if err != nil {
628-
return fmt.Errorf("failed to copy messages to %s: %w", destMailbox, err)
646+
return fmt.Errorf("failed to move messages to %s: %w", destMailbox, err)
629647
}
630-
if c.copyMatchedNothing(copyData) {
648+
if c.moveMatchedNothing(moveData) {
631649
return fmt.Errorf("no messages matched the given ID(s) in %s", mailbox)
632650
}
633651

634-
// Delete from source. Count the streamed FETCH responses so a STORE that
635-
// modifies nothing is reported rather than silently expunged as success.
636-
storeCmd := c.client.Store(numSet, &imap.StoreFlags{
637-
Op: imap.StoreFlagsAdd,
638-
Flags: []imap.Flag{imap.FlagDeleted},
639-
}, nil)
640-
affected := 0
641-
for storeCmd.Next() != nil {
642-
affected++
643-
}
644-
if err := storeCmd.Close(); err != nil {
645-
return fmt.Errorf("failed to delete from source: %w", err)
646-
}
647-
if affected == 0 {
648-
return fmt.Errorf("no messages matched the given ID(s) in %s", mailbox)
649-
}
650-
651-
if err := c.client.Expunge().Close(); err != nil {
652-
return fmt.Errorf("failed to expunge: %w", err)
653-
}
654-
655652
return nil
656653
}
657654

internal/imap/client_affected_test.go

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,16 @@ type imapfixture struct {
3636
raw *imapclient.Client
3737
}
3838

39-
// newIMAPFixture starts a fresh in-memory server with an INBOX and an Archive
40-
// mailbox, appends one message to INBOX, and returns a Client bound to it along
41-
// with the UID of the seeded message.
39+
// newIMAPFixture starts a fresh IMAP4rev2 in-memory server with an INBOX and
40+
// Archive mailbox, appends one message to INBOX, and returns a Client bound to
41+
// it along with the UID of the seeded message.
4242
func newIMAPFixture(t *testing.T) (*imapfixture, imap.UID) {
4343
t.Helper()
44+
return newIMAPFixtureWithCaps(t, imap.CapSet{imap.CapIMAP4rev2: {}})
45+
}
46+
47+
func newIMAPFixtureWithCaps(t *testing.T, caps imap.CapSet) (*imapfixture, imap.UID) {
48+
t.Helper()
4449

4550
memServer := imapmemserver.New()
4651
user := imapmemserver.NewUser(testUser, testPass)
@@ -50,8 +55,7 @@ func newIMAPFixture(t *testing.T) (*imapfixture, imap.UID) {
5055
NewSession: func(*imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
5156
return memServer.NewSession(), nil, nil
5257
},
53-
// IMAP4rev2 folds in UIDPLUS/MOVE/ESEARCH, giving us COPYUID data.
54-
Caps: imap.CapSet{imap.CapIMAP4rev2: {}},
58+
Caps: caps,
5559
InsecureAuth: true,
5660
})
5761

@@ -223,6 +227,52 @@ func TestMoveMessagesActuallyMoves(t *testing.T) {
223227
}
224228
}
225229

230+
func TestMoveMessagesDoesNotExpungeUnrelatedDeletedMessage(t *testing.T) {
231+
fx, uidA := newIMAPFixture(t)
232+
uidB := appendMessage(t, fx.raw, "INBOX", "second@example.com")
233+
234+
if err := fx.client.DeleteMessages("INBOX", []string{presentSelector(uidA)}, false); err != nil {
235+
t.Fatalf("soft delete unrelated message: %v", err)
236+
}
237+
if err := fx.client.MoveMessages("INBOX", []string{presentSelector(uidB)}, "Archive"); err != nil {
238+
t.Fatalf("move other message: %v", err)
239+
}
240+
241+
assertMessageStillInInbox(t, fx.client, uidA, uidB)
242+
}
243+
244+
func TestMoveMessagesFallbackDoesNotExpungeUnrelatedDeletedMessage(t *testing.T) {
245+
caps := imap.CapSet{imap.CapIMAP4rev1: {}, imap.CapUIDPlus: {}}
246+
fx, uidA := newIMAPFixtureWithCaps(t, caps)
247+
uidB := appendMessage(t, fx.raw, "INBOX", "second@example.com")
248+
249+
if fx.client.client.Caps().Has(imap.CapMove) {
250+
t.Fatal("fixture unexpectedly advertises MOVE; fallback path not exercised")
251+
}
252+
if err := fx.client.DeleteMessages("INBOX", []string{presentSelector(uidA)}, false); err != nil {
253+
t.Fatalf("soft delete unrelated message: %v", err)
254+
}
255+
if err := fx.client.MoveMessages("INBOX", []string{presentSelector(uidB)}, "Archive"); err != nil {
256+
t.Fatalf("fallback move other message: %v", err)
257+
}
258+
259+
assertMessageStillInInbox(t, fx.client, uidA, uidB)
260+
}
261+
262+
func assertMessageStillInInbox(t *testing.T, client *Client, wantUID, movedUID imap.UID) {
263+
t.Helper()
264+
inbox, err := client.ListMessages(ListOptions{Mailbox: "INBOX", Limit: 50})
265+
if err != nil {
266+
t.Fatalf("ListMessages INBOX: %v", err)
267+
}
268+
for _, message := range inbox {
269+
if imap.UID(message.UID) == wantUID {
270+
return
271+
}
272+
}
273+
t.Errorf("moving UID %d expunged unrelated deleted UID %d", movedUID, wantUID)
274+
}
275+
226276
// --- Security -----------------------------------------------------------
227277

228278
// TestMissingUIDDoesNotMutateMailbox ensures a failed (no-match) delete leaves

0 commit comments

Comments
 (0)