#5: F2 sub-project 2 - IMAP SEARCH, EXPUNGE, COPY, MOVE
Adds the second F2 sub-project on top of the merged IMAP core: full SEARCH criteria matching (flags, dates, size, headers, body/text, NOT/OR), EXPUNGE (soft-delete via expunged_at), COPY, and MOVE (RFC 6851, advertised via CapMove/CapUIDPlus). Verified end-to-end with a real go-imap/v2 client against embedded Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
de082a190c
commit
0ec0698079
15 files changed
+1378
-21
No files matched your search
@@ -108,11 +108,22 @@ func TestImapCoreEndToEnd(t *testing.T) {
|
||||
postgres.NewMessageQueryRepository(pool),
|
||||
postgres.NewFlagRepository(pool),
|
||||
diskstorage.NewLocalDiskBlobReader(pool),
|
||||
postgres.NewExpungeRepository(pool),
|
||||
postgres.NewCopyRepository(pool),
|
||||
)
|
||||
server := imapserver.New(&imapserver.Options{
|
||||
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return imappresentation.NewSession(c, imapUC)
|
||||
},
|
||||
// Mirror main.go's Caps: without this, imapserver defaults to bare
|
||||
// IMAP4rev1 and won't advertise MOVE/UIDPLUS, which the SEARCH/COPY/
|
||||
// EXPUNGE scenarios below (and the server-side COPYUID response)
|
||||
// depend on the client library correctly attributing.
|
||||
Caps: imap.CapSet{
|
||||
imap.CapIMAP4rev1: {},
|
||||
imap.CapMove: {},
|
||||
imap.CapUIDPlus: {},
|
||||
},
|
||||
InsecureAuth: true, // no TLS in this test - InsecureAuth lets LOGIN proceed over plaintext
|
||||
})
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
@@ -196,4 +207,93 @@ func TestImapCoreEndToEnd(t *testing.T) {
|
||||
if !found {
|
||||
t.Errorf("flags after STORE = %v, want to include \\Seen", fetchAfterStore[0].Flags)
|
||||
}
|
||||
|
||||
// --- SEARCH: the STORE above set \Seen on the one seeded message -------
|
||||
//
|
||||
// SearchData.Count is only populated when the client requests an
|
||||
// ESEARCH RETURN option (COUNT/MIN/MAX/ALL) - without SearchOptions the
|
||||
// client sends a bare SEARCH command and the server replies with the
|
||||
// classic "* SEARCH <seq-nums>" response, which the client library only
|
||||
// folds into SearchData.All (verified against imapclient/search.go's
|
||||
// handleSearch, which only appends to cmd.data.All for that response
|
||||
// form, vs handleESearch which is the only path that sets data.Count).
|
||||
// So matches must be counted via AllSeqNums(), not Count.
|
||||
searchData, err := client.Search(&imap.SearchCriteria{
|
||||
Flag: []imap.Flag{imap.FlagSeen},
|
||||
}, nil).Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("SEARCH: %v", err)
|
||||
}
|
||||
if matched := searchData.AllSeqNums(); len(matched) != 1 {
|
||||
t.Errorf("SEARCH \\Seen matched %v, want exactly 1 sequence number", matched)
|
||||
}
|
||||
|
||||
// --- COPY + EXPUNGE: seed a second folder the same way INBOX was seeded,
|
||||
// copy the message into it, verify blob dedup accounting in Postgres,
|
||||
// then expunge the (now \Deleted) INBOX original and confirm EXPUNGE
|
||||
// only touched the selected folder.
|
||||
archiveFolderID := "00000000-0000-0000-0000-000000000004"
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'Archive', 'archive')`, archiveFolderID, mailboxID); err != nil {
|
||||
t.Fatalf("seed Archive folder: %v", err)
|
||||
}
|
||||
|
||||
copyData, err := client.Copy(imap.UIDSetNum(1), "Archive").Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("COPY: %v", err)
|
||||
}
|
||||
destUIDs, ok := copyData.DestUIDs.Nums()
|
||||
if !ok || len(destUIDs) != 1 {
|
||||
t.Fatalf("COPY DestUIDs = %v (ok=%v), want exactly 1 UID", destUIDs, ok)
|
||||
}
|
||||
|
||||
var inboxBlobID, archiveBlobID string
|
||||
if err := pool.QueryRow(ctx, `SELECT blob_id FROM email_messages WHERE folder_id = $1 AND uid = 1 AND expunged_at IS NULL`, folderID).Scan(&inboxBlobID); err != nil {
|
||||
t.Fatalf("query INBOX blob_id: %v", err)
|
||||
}
|
||||
var archiveCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM email_messages WHERE folder_id = $1 AND expunged_at IS NULL`, archiveFolderID).Scan(&archiveCount); err != nil {
|
||||
t.Fatalf("count Archive messages: %v", err)
|
||||
}
|
||||
if archiveCount != 1 {
|
||||
t.Errorf("Archive message count = %d, want 1", archiveCount)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT blob_id FROM email_messages WHERE folder_id = $1 AND expunged_at IS NULL`, archiveFolderID).Scan(&archiveBlobID); err != nil {
|
||||
t.Fatalf("query Archive blob_id: %v", err)
|
||||
}
|
||||
if archiveBlobID != inboxBlobID {
|
||||
t.Errorf("Archive blob_id = %s, want same blob as INBOX (%s)", archiveBlobID, inboxBlobID)
|
||||
}
|
||||
var refCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT ref_count FROM message_blobs WHERE id = $1`, inboxBlobID).Scan(&refCount); err != nil {
|
||||
t.Fatalf("query blob ref_count: %v", err)
|
||||
}
|
||||
if refCount != 2 {
|
||||
t.Errorf("message_blobs.ref_count = %d, want 2 after COPY", refCount)
|
||||
}
|
||||
|
||||
if _, err := client.Store(imap.UIDSetNum(1), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted},
|
||||
}, nil).Collect(); err != nil {
|
||||
t.Fatalf("STORE \\Deleted: %v", err)
|
||||
}
|
||||
|
||||
if _, err := client.Expunge().Collect(); err != nil {
|
||||
t.Fatalf("EXPUNGE: %v", err)
|
||||
}
|
||||
|
||||
var inboxCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM email_messages WHERE folder_id = $1 AND expunged_at IS NULL`, folderID).Scan(&inboxCount); err != nil {
|
||||
t.Fatalf("count INBOX messages after EXPUNGE: %v", err)
|
||||
}
|
||||
if inboxCount != 0 {
|
||||
t.Errorf("INBOX message count after EXPUNGE = %d, want 0", inboxCount)
|
||||
}
|
||||
|
||||
var archiveCountAfter int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM email_messages WHERE folder_id = $1 AND expunged_at IS NULL`, archiveFolderID).Scan(&archiveCountAfter); err != nil {
|
||||
t.Fatalf("count Archive messages after EXPUNGE: %v", err)
|
||||
}
|
||||
if archiveCountAfter != 1 {
|
||||
t.Errorf("Archive message count after INBOX EXPUNGE = %d, want 1 (untouched)", archiveCountAfter)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user