#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:
Lucas RayanandClaude Sonnet 5 committed 2026-07-24 09:40:47 -03:00
1 parent de082a190c
commit 0ec0698079
15 files changed
+1378 -21

No files matched your search

@@ -123,7 +123,7 @@ func matchFolders(recs []port.ImapFolderRecord, ref string, patterns []string) [
// numSet contains that message (matching imapmemserver's own reference
// pattern of iterating known messages and calling NumSet.Contains, rather
// than trying to resolve "*" ranges ourselves).
func (s *session) numSetIter(numSet imap.NumSet, isUID bool, messages []port.MessageRecord, f func(seqNum uint32, msg port.MessageRecord)) {
func (s *session) numSetIter(numSet imap.NumSet, messages []port.MessageRecord, f func(seqNum uint32, msg port.MessageRecord)) {
for i, msg := range messages {
seqNum := uint32(i) + 1
var contains bool
@@ -149,7 +149,7 @@ func (s *session) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *
}
var fetchErr error
s.numSetIter(numSet, options.UID, messages, func(seqNum uint32, msg port.MessageRecord) {
s.numSetIter(numSet, messages, func(seqNum uint32, msg port.MessageRecord) {
if fetchErr != nil {
return
}
@@ -207,6 +207,157 @@ func (s *session) writeFetchResponse(w *imapserver.FetchResponseWriter, msg port
return w.Close()
}
// expungeUIDs marks messages expunged: when requireDeleted is true, every
// message in the selected folder carrying \Deleted (used by EXPUNGE, per RFC
// 3501); when false, every message matched by uids regardless of flags (used
// by MOVE, per RFC 6851 - "MOVE is not required to use the \Deleted flag to
// mark messages", precisely so a client doesn't have to flag-then-expunge).
// It tells the use case to mark the matched UIDs expunged, and returns their
// sequence numbers in descending order (so callers can report EXPUNGE
// responses without a removal invalidating the sequence numbers of messages
// reported afterward - matches go-imap/v2's own imapmemserver reference
// behavior). Shared by Expunge and Move, which each write their own EXPUNGE
// responses against their own writer type (*imapserver.ExpungeWriter vs
// *imapserver.MoveWriter): both types embed an unexported *imapserver.Conn
// field set only by the imapserver package itself, so neither can be
// constructed outside a live connection, and Move cannot simply delegate to
// Expunge's full method.
func (s *session) expungeUIDs(uids *imap.UIDSet, requireDeleted bool) ([]uint32, error) {
if s.selectedFolder == nil {
return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No mailbox selected"}
}
messages, err := s.useCase.FetchMessages(context.Background(), s.selectedFolder.ID)
if err != nil {
return nil, toIMAPError(err)
}
var toExpunge []uint32
var seqNums []uint32
for i, msg := range messages {
seqNum := uint32(i) + 1
if uids != nil && !uids.Contains(imap.UID(msg.UID)) {
continue
}
if requireDeleted {
hasDeleted := false
for _, f := range msg.Flags {
if imap.Flag(f) == imap.FlagDeleted {
hasDeleted = true
break
}
}
if !hasDeleted {
continue
}
}
toExpunge = append(toExpunge, msg.UID)
seqNums = append(seqNums, seqNum)
}
if len(toExpunge) == 0 {
return nil, nil
}
if err := s.useCase.Expunge(context.Background(), s.selectedFolder.ID, toExpunge); err != nil {
return nil, toIMAPError(err)
}
// Reverse in place: descending sequence-number order.
for i, j := 0, len(seqNums)-1; i < j; i, j = i+1, j-1 {
seqNums[i], seqNums[j] = seqNums[j], seqNums[i]
}
return seqNums, nil
}
func (s *session) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error {
seqNums, err := s.expungeUIDs(uids, true)
if err != nil {
return err
}
// w is nil in unit tests that only assert the use-case call, since
// *imapserver.ExpungeWriter has no exported constructor and can only be
// built by a live *imapserver.Conn (same constraint as *ListWriter -
// see matchFolders' doc comment above). The real imapserver package
// never passes a nil writer.
if w == nil {
return nil
}
for _, seqNum := range seqNums {
if err := w.WriteExpunge(seqNum); err != nil {
return err
}
}
return nil
}
func (s *session) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) {
if s.selectedFolder == nil {
return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No mailbox selected"}
}
destFolder, err := s.useCase.SelectFolder(context.Background(), s.mailboxID, dest)
if err != nil {
return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeTryCreate, Text: "No such destination mailbox"}
}
messages, err := s.useCase.FetchMessages(context.Background(), s.selectedFolder.ID)
if err != nil {
return nil, toIMAPError(err)
}
var uids []uint32
s.numSetIter(numSet, messages, func(_ uint32, msg port.MessageRecord) {
uids = append(uids, msg.UID)
})
copied, err := s.useCase.CopyMessages(context.Background(), s.selectedFolder.ID, uids, destFolder.ID)
if err != nil {
return nil, toIMAPError(err)
}
var sourceUIDs, destUIDs imap.UIDSet
for _, c := range copied {
sourceUIDs.AddNum(imap.UID(c.SourceUID))
destUIDs.AddNum(imap.UID(c.DestUID))
}
return &imap.CopyData{UIDValidity: destFolder.UIDValidity, SourceUIDs: sourceUIDs, DestUIDs: destUIDs}, nil
}
// Move copies numSet into dest (reusing Copy's logic), reports the COPYUID
// data, then expunges the just-copied source messages (RFC 6851: MOVE is
// COPY + EXPUNGE-of-the-moved-messages-only, atomically from the client's
// point of view). It deliberately does NOT call s.Expunge: that method
// writes against *imapserver.ExpungeWriter, a different concrete type from
// the *imapserver.MoveWriter this method receives, even though both expose a
// method named WriteExpunge - so Move writes its own EXPUNGE responses
// through its own writer, sharing only the expungeUIDs marking logic.
func (s *session) Move(w *imapserver.MoveWriter, numSet imap.NumSet, dest string) error {
copyData, err := s.Copy(numSet, dest)
if err != nil {
return err
}
if w != nil {
if err := w.WriteCopyData(copyData); err != nil {
return err
}
}
// MOVE does not require \Deleted (RFC 6851): expunge exactly the
// UIDs Copy just copied, regardless of their flags.
seqNums, err := s.expungeUIDs(&copyData.SourceUIDs, false)
if err != nil {
return err
}
// w is nil in unit tests that only assert the use-case call, mirroring
// Expunge's nil-writer accommodation above.
if w == nil {
return nil
}
for _, seqNum := range seqNums {
if err := w.WriteExpunge(seqNum); err != nil {
return err
}
}
return nil
}
func (s *session) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
if s.selectedFolder == nil {
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No mailbox selected"}
@@ -236,7 +387,7 @@ func (s *session) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *im
// test against, and Nums() returns (nil, false) for them. This also
// naturally supports plain (non-UID) STORE via the SeqSet case.
var storeErr error
s.numSetIter(numSet, true, messages, func(_ uint32, msg port.MessageRecord) {
s.numSetIter(numSet, messages, func(_ uint32, msg port.MessageRecord) {
if storeErr != nil {
return
}
@@ -83,6 +83,32 @@ func (f *fakeBlobReader) ReadByID(_ context.Context, blobID uuid.UUID) ([]byte,
return f.content[blobID], nil
}
// fakeExpungeRepository/fakeCopyRepository duplicate the fakes from
// internal/application/usecase/imap_session_test.go - Go test files in
// different packages can't share unexported test helpers.
type fakeExpungeRepository struct {
calls []struct {
folderID string
uids []uint32
}
}
func (f *fakeExpungeRepository) Expunge(_ context.Context, folderID string, uids []uint32) error {
f.calls = append(f.calls, struct {
folderID string
uids []uint32
}{folderID, uids})
return nil
}
type fakeCopyRepository struct {
result []port.CopiedMessage
}
func (f *fakeCopyRepository) CopyMessages(_ context.Context, _ string, _ []uint32, _ string) ([]port.CopiedMessage, error) {
return f.result, nil
}
// testPasswordHash/testPassword duplicate the constants from
// internal/application/usecase/imap_session_test.go - a real Argon2id PHC
// hash for "correct horse battery staple", generated once with
@@ -94,7 +120,24 @@ const (
)
func newTestSession(auth *fakeAuthRepository, folders *fakeImapFolderRepository, messages *fakeMessageQueryRepository, flags *fakeFlagRepository, blobs *fakeBlobReader) imapserver.Session {
uc := usecase.NewImapSessionUseCase(auth, folders, messages, flags, blobs)
// expunger/copier are nil here: none of the tests using this helper
// exercise Expunge/CopyMessages, and NewImapSessionUseCase only stores
// these interfaces without dereferencing them at construction time.
// Tests that DO exercise Expunge/Copy use
// newTestSessionWithExpungeAndCopy below instead.
return newTestSessionWithExpungeAndCopy(auth, folders, messages, flags, blobs, nil, nil)
}
func newTestSessionWithExpungeAndCopy(auth *fakeAuthRepository, folders *fakeImapFolderRepository, messages *fakeMessageQueryRepository, flags *fakeFlagRepository, blobs *fakeBlobReader, expunger *fakeExpungeRepository, copier *fakeCopyRepository) imapserver.Session {
var expungeRepo port.ExpungeRepository
if expunger != nil {
expungeRepo = expunger
}
var copyRepo port.CopyRepository
if copier != nil {
copyRepo = copier
}
uc := usecase.NewImapSessionUseCase(auth, folders, messages, flags, blobs, expungeRepo, copyRepo)
sess, _, err := NewSession(nil, uc)
if err != nil {
panic(err)
@@ -403,6 +446,124 @@ func TestSession_List_UsesFolderRepository(t *testing.T) {
}
}
func TestSession_Expunge_CallsUseCaseWithDeletedUIDsOnly(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{"INBOX": {ID: "folder-1"}}}
messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{
{UID: 1, Flags: []string{"\\Deleted"}},
{UID: 2, Flags: nil},
{UID: 3, Flags: []string{"\\Deleted", "\\Seen"}},
}}
expunger := &fakeExpungeRepository{}
sess := newTestSessionWithExpungeAndCopy(auth, folders, messages, &fakeFlagRepository{}, &fakeBlobReader{}, expunger, &fakeCopyRepository{})
_ = sess.Login("user@example.test", testPassword)
if _, err := sess.Select("INBOX", &imap.SelectOptions{}); err != nil {
t.Fatalf("Select: %v", err)
}
if err := sess.Expunge(nil, nil); err != nil {
t.Fatalf("Expunge: %v", err)
}
if len(expunger.calls) != 1 {
t.Fatalf("expunger called %d times, want 1", len(expunger.calls))
}
uids := expunger.calls[0].uids
if len(uids) != 2 || uids[0] != 1 || uids[1] != 3 {
t.Errorf("expunged uids = %v, want [1 3] (only \\Deleted messages)", uids)
}
}
func TestSession_Copy_CallsUseCaseAndBuildsCopyData(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{
"INBOX": {ID: "folder-1"},
"Archive": {ID: "folder-2", UIDValidity: 42},
}}
messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{{UID: 5}}}
copier := &fakeCopyRepository{result: []port.CopiedMessage{{SourceUID: 5, DestUID: 1}}}
sess := newTestSessionWithExpungeAndCopy(auth, folders, messages, &fakeFlagRepository{}, &fakeBlobReader{}, &fakeExpungeRepository{}, copier)
_ = sess.Login("user@example.test", testPassword)
_, _ = sess.Select("INBOX", &imap.SelectOptions{})
data, err := sess.Copy(imap.UIDSetNum(5), "Archive")
if err != nil {
t.Fatalf("Copy: %v", err)
}
if data.UIDValidity != 42 {
t.Errorf("UIDValidity = %d, want 42", data.UIDValidity)
}
srcNums, _ := data.SourceUIDs.Nums()
destNums, _ := data.DestUIDs.Nums()
if len(srcNums) != 1 || srcNums[0] != 5 || len(destNums) != 1 || destNums[0] != 1 {
t.Errorf("SourceUIDs=%v DestUIDs=%v, want [5]/[1]", srcNums, destNums)
}
}
func TestSession_Copy_FailsForUnknownDestination(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{"INBOX": {ID: "folder-1"}}}
sess := newTestSessionWithExpungeAndCopy(auth, folders, &fakeMessageQueryRepository{}, &fakeFlagRepository{}, &fakeBlobReader{}, &fakeExpungeRepository{}, &fakeCopyRepository{})
_ = sess.Login("user@example.test", testPassword)
_, _ = sess.Select("INBOX", &imap.SelectOptions{})
_, err := sess.Copy(imap.UIDSetNum(5), "Nonexistent")
if err == nil {
t.Fatal("expected an error copying to a nonexistent folder, got nil")
}
}
// TestSession_Move_ExpungesCopiedMessageEvenWithoutDeletedFlag proves the
// RFC 6851 fix: MOVE must expunge exactly the UIDs it just copied,
// regardless of whether they carry \Deleted. Before this fix, Move called
// the shared expungeUIDs helper in its \Deleted-requiring mode (the one
// correct for EXPUNGE), so a message with no \Deleted flag would be copied
// into the destination but never removed from the source folder - a silent
// duplication instead of a move.
func TestSession_Move_ExpungesCopiedMessageEvenWithoutDeletedFlag(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{
"INBOX": {ID: "folder-1"},
"Archive": {ID: "folder-2", UIDValidity: 42},
}}
// UID 5 has no \Deleted flag - MOVE must still expunge it from the
// source folder, unlike EXPUNGE which would skip it.
messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{{UID: 5, Flags: nil}}}
expunger := &fakeExpungeRepository{}
copier := &fakeCopyRepository{result: []port.CopiedMessage{{SourceUID: 5, DestUID: 1}}}
sess := newTestSessionWithExpungeAndCopy(auth, folders, messages, &fakeFlagRepository{}, &fakeBlobReader{}, expunger, copier)
_ = sess.Login("user@example.test", testPassword)
if _, err := sess.Select("INBOX", &imap.SelectOptions{}); err != nil {
t.Fatalf("Select: %v", err)
}
// w is nil here for the same reason Expunge's tests pass a nil writer:
// *imapserver.MoveWriter has no exported constructor and can only be
// built by a live *imapserver.Conn.
if err := sess.(*session).Move(nil, imap.UIDSetNum(5), "Archive"); err != nil {
t.Fatalf("Move: %v", err)
}
if len(expunger.calls) != 1 {
t.Fatalf("expunger called %d times, want 1", len(expunger.calls))
}
uids := expunger.calls[0].uids
if len(uids) != 1 || uids[0] != 5 {
t.Errorf("expunged uids = %v, want [5] (moved even though it has no \\Deleted flag)", uids)
}
}
func TestStubMethods_ReturnNotYetImplementedError(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
@@ -0,0 +1,283 @@
// Package imappresentation: this file implements IMAP SEARCH (RFC 3501
// section 6.4.4). Criteria matching is a pure function operating on data
// already available via the application layer (FetchMessages, ReadBlob) -
// it intentionally lives here, not in the use case, because imap.
// SearchCriteria is a go-imap/v2 type and the application layer must stay
// framework-free (see this plan's Global Constraints).
package imappresentation
import (
"bufio"
"bytes"
"context"
"io"
"strings"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
gomessage "github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-message/textproto"
)
func (s *session) Search(numKind imapserver.NumKind, criteria *imap.SearchCriteria, _ *imap.SearchOptions) (*imap.SearchData, error) {
if s.selectedFolder == nil {
return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Text: "No mailbox selected"}
}
messages, err := s.useCase.FetchMessages(context.Background(), s.selectedFolder.ID)
if err != nil {
return nil, toIMAPError(err)
}
data := &imap.SearchData{}
var seqSet imap.SeqSet
var uidSet imap.UIDSet
for i, msg := range messages {
seqNum := uint32(i) + 1
flags := make([]imap.Flag, len(msg.Flags))
for j, f := range msg.Flags {
flags[j] = imap.Flag(f)
}
blobID := msg.BlobID
loadRaw := func() ([]byte, error) {
return s.useCase.ReadBlob(context.Background(), blobID)
}
if !matchCriteria(seqNum, msg.UID, msg.ReceivedAt, msg.SizeBytes, flags, criteria, loadRaw) {
continue
}
seqSet.AddNum(seqNum)
uidSet.AddNum(imap.UID(msg.UID))
var num uint32
if numKind == imapserver.NumKindUID {
num = msg.UID
} else {
num = seqNum
}
if data.Min == 0 || num < data.Min {
data.Min = num
}
if num > data.Max {
data.Max = num
}
data.Count++
}
if numKind == imapserver.NumKindUID {
data.All = uidSet
} else {
data.All = seqSet
}
return data, nil
}
// matchCriteria mirrors go-imap/v2's own imapmemserver reference matching
// logic (imapserver/imapmemserver/message.go's (*message).search), adapted
// to lazily load raw message bytes via loadRaw only when a criterion
// actually needs them (Header/Body/Text/SentSince/SentBefore), since this
// codebase is Postgres-backed with blobs fetched on demand rather than
// always held in memory like imapmemserver's reference message buffer.
func matchCriteria(seqNum, uid uint32, receivedAt time.Time, sizeBytes int64, flags []imap.Flag, criteria *imap.SearchCriteria, loadRaw func() ([]byte, error)) bool {
for _, seqSet := range criteria.SeqNum {
if seqNum == 0 || !seqSet.Contains(seqNum) {
return false
}
}
for _, uidSet := range criteria.UID {
if !uidSet.Contains(imap.UID(uid)) {
return false
}
}
if !matchDate(receivedAt, criteria.Since, criteria.Before) {
return false
}
flagSet := make(map[imap.Flag]struct{}, len(flags))
for _, f := range flags {
flagSet[canonicalFlag(f)] = struct{}{}
}
for _, flag := range criteria.Flag {
if _, ok := flagSet[canonicalFlag(flag)]; !ok {
return false
}
}
for _, flag := range criteria.NotFlag {
if _, ok := flagSet[canonicalFlag(flag)]; ok {
return false
}
}
if criteria.Larger != 0 && sizeBytes <= criteria.Larger {
return false
}
if criteria.Smaller != 0 && sizeBytes >= criteria.Smaller {
return false
}
needsRaw := len(criteria.Header) > 0 || len(criteria.Body) > 0 || len(criteria.Text) > 0 ||
!criteria.SentSince.IsZero() || !criteria.SentBefore.IsZero()
var header textproto.Header
var raw []byte
if needsRaw {
var err error
raw, err = loadRaw()
if err != nil {
return false
}
header, err = textproto.ReadHeader(bufio.NewReader(bytes.NewReader(raw)))
if err != nil {
return false
}
}
for _, fieldCriteria := range criteria.Header {
if !matchHeaderField(header, fieldCriteria.Key, fieldCriteria.Value) {
return false
}
}
if !criteria.SentSince.IsZero() || !criteria.SentBefore.IsZero() {
t, err := parseDateHeader(header)
if err != nil || !matchDate(t, criteria.SentSince, criteria.SentBefore) {
return false
}
}
for _, text := range criteria.Text {
if !matchEntity(raw, text, true) {
return false
}
}
for _, body := range criteria.Body {
if !matchEntity(raw, body, false) {
return false
}
}
for _, not := range criteria.Not {
if matchCriteria(seqNum, uid, receivedAt, sizeBytes, flags, &not, loadRaw) {
return false
}
}
for _, or := range criteria.Or {
if !matchCriteria(seqNum, uid, receivedAt, sizeBytes, flags, &or[0], loadRaw) &&
!matchCriteria(seqNum, uid, receivedAt, sizeBytes, flags, &or[1], loadRaw) {
return false
}
}
return true
}
// canonicalFlag normalizes flag comparisons so e.g. "\Seen" and "\seen" are
// treated as equal, matching IMAP's case-insensitive flag semantics.
func canonicalFlag(f imap.Flag) imap.Flag {
return imap.Flag(strings.ToLower(string(f)))
}
func matchDate(t, since, before time.Time) bool {
// RFC 3501 explicitly requires zone-unaware date (not time) comparison.
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
if !since.IsZero() && t.Before(since) {
return false
}
if !before.IsZero() && !t.Before(before) {
return false
}
return true
}
func matchHeaderField(header textproto.Header, key, pattern string) bool {
fields := header.FieldsByKey(key)
if pattern == "" {
return fields.Len() > 0
}
pattern = strings.ToLower(pattern)
for fields.Next() {
if strings.Contains(strings.ToLower(fields.Value()), pattern) {
return true
}
}
return false
}
// parseDateHeader parses the message's Date: header field.
//
// The real API (verified against github.com/emersion/go-message@installed
// version, and against how go-imap/v2's own imapserver/imapmemserver
// reference implementation does this in message.go's (*message).search:
// `header := mail.Header{msg.reader().Header}` then `header.Date()`) is
// *not* a type assertion on Header.Fields() - that sketch does not compile,
// since the iterator returned by Fields() has no Date() method. Instead,
// github.com/emersion/go-message/mail.Header wraps a
// github.com/emersion/go-message.Header (which itself wraps
// textproto.Header) and exposes a `Date() (time.Time, error)` method that
// does the real RFC 5322 date parsing. So a textproto.Header must be
// wrapped twice - once into gomessage.Header, once into mail.Header - to
// reach that method.
func parseDateHeader(header textproto.Header) (time.Time, error) {
h := mail.Header{Header: gomessage.Header{Header: header}}
return h.Date()
}
// matchEntity re-parses raw as a MIME entity and checks whether pattern
// (case-insensitively) appears in the body (and, if includeHeader is true,
// also the header fields) - mirroring imapmemserver's matchEntity, which
// walks multipart parts recursively via gomessage.Entity rather than doing
// a flat byte search over raw, so that HTML/text alternative parts and
// nested MIME structures are still searched correctly.
func matchEntity(raw []byte, pattern string, includeHeader bool) bool {
if pattern == "" {
return true
}
entity, err := gomessage.Read(bytes.NewReader(raw))
if err != nil && entity == nil {
return false
}
return matchEntityRecursive(entity, strings.ToLower(pattern), includeHeader)
}
func matchEntityRecursive(e *gomessage.Entity, patternLower string, includeHeader bool) bool {
if e == nil {
return false
}
if includeHeader && matchHeaderFieldsLower(e.Header.Fields(), patternLower) {
return true
}
if mr := e.MultipartReader(); mr != nil {
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
if matchEntityRecursive(part, patternLower, includeHeader) {
return true
}
}
return false
}
body, err := io.ReadAll(e.Body)
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(body)), patternLower)
}
func matchHeaderFieldsLower(fields gomessage.HeaderFields, patternLower string) bool {
for fields.Next() {
v, _ := fields.Text()
if strings.Contains(strings.ToLower(v), patternLower) {
return true
}
}
return false
}
@@ -0,0 +1,133 @@
package imappresentation
import (
"testing"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
func TestSession_Search_MatchesByFlag(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{
"INBOX": {ID: "folder-1"},
}}
messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{
{UID: 1, ReceivedAt: time.Now(), Flags: []string{"\\Seen"}},
{UID: 2, ReceivedAt: time.Now(), Flags: nil},
{UID: 3, ReceivedAt: time.Now(), Flags: []string{"\\Seen", "\\Flagged"}},
}}
sess := newTestSession(auth, folders, messages, &fakeFlagRepository{}, &fakeBlobReader{})
_ = sess.Login("user@example.test", testPassword)
if _, err := sess.Select("INBOX", &imap.SelectOptions{}); err != nil {
t.Fatalf("Select: %v", err)
}
data, err := sess.Search(imapserver.NumKindUID, &imap.SearchCriteria{Flag: []imap.Flag{imap.FlagSeen}}, &imap.SearchOptions{})
if err != nil {
t.Fatalf("Search: %v", err)
}
uidSet, ok := data.All.(imap.UIDSet)
if !ok {
t.Fatalf("data.All is %T, want imap.UIDSet", data.All)
}
nums, ok := uidSet.Nums()
if !ok || len(nums) != 2 || nums[0] != 1 || nums[1] != 3 {
t.Errorf("matched UIDs = %v (ok=%v), want [1 3]", nums, ok)
}
}
func TestSession_Search_MatchesByUIDSetIntersection(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{"INBOX": {ID: "folder-1"}}}
messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{
{UID: 1, ReceivedAt: time.Now()},
{UID: 2, ReceivedAt: time.Now()},
{UID: 3, ReceivedAt: time.Now()},
}}
sess := newTestSession(auth, folders, messages, &fakeFlagRepository{}, &fakeBlobReader{})
_ = sess.Login("user@example.test", testPassword)
_, _ = sess.Select("INBOX", &imap.SelectOptions{})
var uidFilter imap.UIDSet
uidFilter.AddRange(2, 0) // "2:*"
data, err := sess.Search(imapserver.NumKindUID, &imap.SearchCriteria{UID: []imap.UIDSet{uidFilter}}, &imap.SearchOptions{})
if err != nil {
t.Fatalf("Search: %v", err)
}
uidSet := data.All.(imap.UIDSet)
nums, _ := uidSet.Nums()
if len(nums) != 2 || nums[0] != 2 || nums[1] != 3 {
t.Errorf("matched UIDs = %v, want [2 3]", nums)
}
}
func TestSession_Search_MatchesByHeaderField(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{"INBOX": {ID: "folder-1"}}}
blobA := uuid.New()
blobB := uuid.New()
messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{
{UID: 1, BlobID: blobA, ReceivedAt: time.Now()},
{UID: 2, BlobID: blobB, ReceivedAt: time.Now()},
}}
blobs := &fakeBlobReader{content: map[uuid.UUID][]byte{
blobA: []byte("Subject: hello world\r\n\r\nbody"),
blobB: []byte("Subject: something else\r\n\r\nbody"),
}}
sess := newTestSession(auth, folders, messages, &fakeFlagRepository{}, blobs)
_ = sess.Login("user@example.test", testPassword)
_, _ = sess.Select("INBOX", &imap.SelectOptions{})
data, err := sess.Search(imapserver.NumKindUID, &imap.SearchCriteria{
Header: []imap.SearchCriteriaHeaderField{{Key: "Subject", Value: "hello"}},
}, &imap.SearchOptions{})
if err != nil {
t.Fatalf("Search: %v", err)
}
uidSet := data.All.(imap.UIDSet)
nums, _ := uidSet.Nums()
if len(nums) != 1 || nums[0] != 1 {
t.Errorf("matched UIDs = %v, want [1] (only the message with \"hello\" in Subject)", nums)
}
}
func TestSession_Search_NotCriteriaExcludesMatches(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{"INBOX": {ID: "folder-1"}}}
messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{
{UID: 1, ReceivedAt: time.Now(), Flags: []string{"\\Seen"}},
{UID: 2, ReceivedAt: time.Now(), Flags: nil},
}}
sess := newTestSession(auth, folders, messages, &fakeFlagRepository{}, &fakeBlobReader{})
_ = sess.Login("user@example.test", testPassword)
_, _ = sess.Select("INBOX", &imap.SelectOptions{})
data, err := sess.Search(imapserver.NumKindUID, &imap.SearchCriteria{
Not: []imap.SearchCriteria{{Flag: []imap.Flag{imap.FlagSeen}}},
}, &imap.SearchOptions{})
if err != nil {
t.Fatalf("Search: %v", err)
}
uidSet := data.All.(imap.UIDSet)
nums, _ := uidSet.Nums()
if len(nums) != 1 || nums[0] != 2 {
t.Errorf("matched UIDs = %v, want [2] (NOT SEEN excludes the \\Seen message)", nums)
}
}
@@ -36,12 +36,3 @@ func (s *session) Poll(_ *imapserver.UpdateWriter, _ bool) error { return nil }
func (s *session) Idle(_ *imapserver.UpdateWriter, _ <-chan struct{}) error {
return errNotYetImplemented
}
func (s *session) Expunge(_ *imapserver.ExpungeWriter, _ *imap.UIDSet) error {
return errNotYetImplemented
}
func (s *session) Search(_ imapserver.NumKind, _ *imap.SearchCriteria, _ *imap.SearchOptions) (*imap.SearchData, error) {
return nil, errNotYetImplemented
}
func (s *session) Copy(_ imap.NumSet, _ string) (*imap.CopyData, error) {
return nil, errNotYetImplemented
}