#6: implement IMAP IDLE (RFC 2177) and CONDSTORE (RFC 7162)

This commit is contained in:
Lucas Rayan committed 2026-07-24 11:43:37 -03:00
1 parent 0ec0698079
commit 6b1e4dc00a
20 files changed
+539 -95

No files matched your search

@@ -19,6 +19,7 @@ type session struct {
useCase *usecase.ImapSessionUseCase
mailboxID string
selectedFolder *port.ImapFolderRecord
tracker *imapserver.SessionTracker
}
// NewSession is the imapserver.Options.NewSession callback.
@@ -37,7 +38,13 @@ func toIMAPError(err error) error {
}
}
func (s *session) Close() error { return nil }
func (s *session) Close() error {
if s.tracker != nil {
s.tracker.Close()
s.tracker = nil
}
return nil
}
func (s *session) Login(username, password string) error {
mailboxID, err := s.useCase.Login(context.Background(), username, password)
@@ -53,15 +60,27 @@ func (s *session) Select(mailbox string, _ *imap.SelectOptions) (*imap.SelectDat
if err != nil {
return nil, toIMAPError(err)
}
if s.tracker != nil {
s.tracker.Close()
s.tracker = nil
}
s.selectedFolder = rec
if tm := s.useCase.GetTrackerManager(); tm != nil {
s.tracker = tm.GetTracker(rec.ID, rec.NumMessages).NewSession()
}
return &imap.SelectData{
NumMessages: rec.NumMessages,
UIDNext: imap.UID(rec.UIDNext),
UIDValidity: rec.UIDValidity,
NumMessages: rec.NumMessages,
UIDNext: imap.UID(rec.UIDNext),
UIDValidity: rec.UIDValidity,
HighestModSeq: rec.HighestModSeq,
}, nil
}
func (s *session) Unselect() error {
if s.tracker != nil {
s.tracker.Close()
s.tracker = nil
}
s.selectedFolder = nil
return nil
}
@@ -82,9 +101,26 @@ func (s *session) Status(mailbox string, options *imap.StatusOptions) (*imap.Sta
if options.UIDValidity {
data.UIDValidity = rec.UIDValidity
}
if options.HighestModSeq {
data.HighestModSeq = rec.HighestModSeq
}
return data, nil
}
func (s *session) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
if s.tracker == nil {
return nil
}
return s.tracker.Poll(w, allowExpunge)
}
func (s *session) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
if s.tracker == nil {
return nil
}
return s.tracker.Idle(w, stop)
}
func (s *session) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
recs, err := s.useCase.ListFolders(context.Background(), s.mailboxID)
if err != nil {
@@ -153,6 +189,9 @@ func (s *session) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *
if fetchErr != nil {
return
}
if options.ChangedSince > 0 && msg.ModSeq <= options.ChangedSince {
return
}
respWriter := w.CreateMessage(seqNum)
fetchErr = s.writeFetchResponse(respWriter, msg, options)
})
@@ -273,6 +312,11 @@ func (s *session) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error
if err != nil {
return err
}
if tm := s.useCase.GetTrackerManager(); tm != nil && s.selectedFolder != nil {
for _, seqNum := range seqNums {
tm.NotifyExpunge(s.selectedFolder.ID, seqNum)
}
}
// 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 -
@@ -317,6 +361,9 @@ func (s *session) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error)
sourceUIDs.AddNum(imap.UID(c.SourceUID))
destUIDs.AddNum(imap.UID(c.DestUID))
}
if tm := s.useCase.GetTrackerManager(); tm != nil {
tm.NotifyNumMessages(destFolder.ID, destFolder.NumMessages+uint32(len(copied)))
}
return &imap.CopyData{UIDValidity: destFolder.UIDValidity, SourceUIDs: sourceUIDs, DestUIDs: destUIDs}, nil
}
@@ -345,6 +392,11 @@ func (s *session) Move(w *imapserver.MoveWriter, numSet imap.NumSet, dest string
if err != nil {
return err
}
if tm := s.useCase.GetTrackerManager(); tm != nil && s.selectedFolder != nil {
for _, seqNum := range seqNums {
tm.NotifyExpunge(s.selectedFolder.ID, seqNum)
}
}
// w is nil in unit tests that only assert the use-case call, mirroring
// Expunge's nil-writer accommodation above.
if w == nil {
@@ -376,23 +428,30 @@ func (s *session) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *im
flagNames[i] = string(f)
}
var unchangedSince uint64
if options != nil {
unchangedSince = options.UnchangedSince
}
messages, err := s.useCase.FetchMessages(context.Background(), s.selectedFolder.ID)
if err != nil {
return toIMAPError(err)
}
// Resolve numSet against the folder's real message list (same pattern as
// Fetch), rather than pulling raw values via NumSet.Nums(): dynamic
// ranges like "1:*" cannot be resolved without a concrete candidate to
// 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, messages, func(_ uint32, msg port.MessageRecord) {
s.numSetIter(numSet, messages, func(seqNum uint32, msg port.MessageRecord) {
if storeErr != nil {
return
}
if err := s.useCase.SetFlags(context.Background(), s.selectedFolder.ID, msg.UID, op, flagNames); err != nil {
updated, err := s.useCase.SetFlags(context.Background(), s.selectedFolder.ID, msg.UID, op, flagNames, unchangedSince)
if err != nil {
storeErr = toIMAPError(err)
return
}
if updated {
if tm := s.useCase.GetTrackerManager(); tm != nil {
tm.NotifyMessageFlags(s.selectedFolder.ID, seqNum, msg.UID, flagNames, s.tracker)
}
}
})
if storeErr != nil {
@@ -69,10 +69,10 @@ type fakeFlagCall struct {
flags []string
}
func (f *fakeFlagRepository) SetFlags(_ context.Context, folderID string, uid uint32, op port.FlagOp, flags []string) error {
func (f *fakeFlagRepository) SetFlags(_ context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, _ uint64) (bool, error) {
f.lastFolderID, f.lastUID, f.lastOp, f.lastFlags = folderID, uid, op, flags
f.calls = append(f.calls, fakeFlagCall{folderID: folderID, uid: uid, op: op, flags: flags})
return nil
return true, nil
}
type fakeBlobReader struct {
@@ -451,7 +451,7 @@ func TestSession_Expunge_CallsUseCaseWithDeletedUIDsOnly(t *testing.T) {
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"}}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{"INBOX": {ID: "folder-1", NumMessages: 3}}}
messages := &fakeMessageQueryRepository{messages: []port.MessageRecord{
{UID: 1, Flags: []string{"\\Deleted"}},
{UID: 2, Flags: nil},
@@ -534,7 +534,7 @@ func TestSession_Move_ExpungesCopiedMessageEvenWithoutDeletedFlag(t *testing.T)
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
folders := &fakeImapFolderRepository{byName: map[string]port.ImapFolderRecord{
"INBOX": {ID: "folder-1"},
"INBOX": {ID: "folder-1", NumMessages: 1},
"Archive": {ID: "folder-2", UIDValidity: 42},
}}
// UID 5 has no \Deleted flag - MOVE must still expunge it from the
@@ -46,7 +46,7 @@ func (s *session) Search(numKind imapserver.NumKind, criteria *imap.SearchCriter
return s.useCase.ReadBlob(context.Background(), blobID)
}
if !matchCriteria(seqNum, msg.UID, msg.ReceivedAt, msg.SizeBytes, flags, criteria, loadRaw) {
if !matchCriteria(seqNum, msg.UID, msg.ReceivedAt, msg.SizeBytes, msg.ModSeq, flags, criteria, loadRaw) {
continue
}
@@ -81,7 +81,7 @@ func (s *session) Search(numKind imapserver.NumKind, criteria *imap.SearchCriter
// 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 {
func matchCriteria(seqNum, uid uint32, receivedAt time.Time, sizeBytes int64, modSeq uint64, flags []imap.Flag, criteria *imap.SearchCriteria, loadRaw func() ([]byte, error)) bool {
for _, seqSet := range criteria.SeqNum {
if seqNum == 0 || !seqSet.Contains(seqNum) {
return false
@@ -96,6 +96,12 @@ func matchCriteria(seqNum, uid uint32, receivedAt time.Time, sizeBytes int64, fl
return false
}
if criteria.ModSeq != nil && criteria.ModSeq.ModSeq > 0 {
if modSeq < criteria.ModSeq.ModSeq {
return false
}
}
flagSet := make(map[imap.Flag]struct{}, len(flags))
for _, f := range flags {
flagSet[canonicalFlag(f)] = struct{}{}
@@ -159,13 +165,13 @@ func matchCriteria(seqNum, uid uint32, receivedAt time.Time, sizeBytes int64, fl
}
for _, not := range criteria.Not {
if matchCriteria(seqNum, uid, receivedAt, sizeBytes, flags, &not, loadRaw) {
if matchCriteria(seqNum, uid, receivedAt, sizeBytes, modSeq, 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) {
if !matchCriteria(seqNum, uid, receivedAt, sizeBytes, modSeq, flags, &or[0], loadRaw) &&
!matchCriteria(seqNum, uid, receivedAt, sizeBytes, modSeq, flags, &or[1], loadRaw) {
return false
}
}
@@ -9,7 +9,6 @@ package imappresentation
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
)
var errNotYetImplemented = &imap.Error{
@@ -25,14 +24,4 @@ func (s *session) Unsubscribe(_ string) error { return errN
func (s *session) Append(_ string, _ imap.LiteralReader, _ *imap.AppendOptions) (*imap.AppendData, error) {
return nil, errNotYetImplemented
}
// Poll is called by imapserver before the status response of nearly every
// command in the Authenticated/Selected state (see conn.go's c.poll), not
// just in response to an explicit client IDLE/NOOP - returning an error here
// would fail every real command (FETCH, STORE, ...), not just polling. This
// sub-project doesn't push unsolicited EXISTS/EXPUNGE/FETCH updates (no
// IDLE support - see Idle below), so reporting "no updates" via a nil
// return is the correct, spec-compliant no-op, not a stub.
func (s *session) Poll(_ *imapserver.UpdateWriter, _ bool) error { return nil }
func (s *session) Idle(_ *imapserver.UpdateWriter, _ <-chan struct{}) error {
return errNotYetImplemented
}