#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

@@ -23,17 +23,37 @@ var ErrNoSuchFolder = errors.New("imap session: no such folder")
// HandleImapCommandUseCase (this sub-project's scope: LOGIN, LIST, SELECT,
// FETCH, STORE; SEARCH/EXPUNGE/IDLE/MOVE land in later sub-projects).
type ImapSessionUseCase struct {
auth port.AuthRepository
folders port.ImapFolderRepository
messages port.MessageQueryRepository
flags port.FlagRepository
blobs port.BlobReader
expunger port.ExpungeRepository
copier port.CopyRepository
auth port.AuthRepository
folders port.ImapFolderRepository
messages port.MessageQueryRepository
flags port.FlagRepository
blobs port.BlobReader
expunger port.ExpungeRepository
copier port.CopyRepository
trackerManager *MailboxTrackerManager
}
func NewImapSessionUseCase(auth port.AuthRepository, folders port.ImapFolderRepository, messages port.MessageQueryRepository, flags port.FlagRepository, blobs port.BlobReader, expunger port.ExpungeRepository, copier port.CopyRepository) *ImapSessionUseCase {
return &ImapSessionUseCase{auth: auth, folders: folders, messages: messages, flags: flags, blobs: blobs, expunger: expunger, copier: copier}
return &ImapSessionUseCase{
auth: auth,
folders: folders,
messages: messages,
flags: flags,
blobs: blobs,
expunger: expunger,
copier: copier,
trackerManager: NewMailboxTrackerManager(),
}
}
func (uc *ImapSessionUseCase) SetTrackerManager(tm *MailboxTrackerManager) {
if tm != nil {
uc.trackerManager = tm
}
}
func (uc *ImapSessionUseCase) GetTrackerManager() *MailboxTrackerManager {
return uc.trackerManager
}
func (uc *ImapSessionUseCase) Login(ctx context.Context, address, password string) (string, error) {
@@ -77,8 +97,8 @@ func (uc *ImapSessionUseCase) ReadBlob(ctx context.Context, blobID uuid.UUID) ([
return uc.blobs.ReadByID(ctx, blobID)
}
func (uc *ImapSessionUseCase) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string) error {
return uc.flags.SetFlags(ctx, folderID, uid, op, flags)
func (uc *ImapSessionUseCase) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, unchangedSince uint64) (bool, error) {
return uc.flags.SetFlags(ctx, folderID, uid, op, flags, unchangedSince)
}
func (uc *ImapSessionUseCase) Expunge(ctx context.Context, folderID string, uids []uint32) error {
@@ -149,6 +149,32 @@ func TestImapSessionUseCase_Expunge_DelegatesToRepository(t *testing.T) {
}
}
type fakeFlagRepository struct {
lastFolderID string
lastUID uint32
lastOp port.FlagOp
lastFlags []string
lastUnchangedSince uint64
}
func (f *fakeFlagRepository) SetFlags(_ context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, unchangedSince uint64) (bool, error) {
f.lastFolderID, f.lastUID, f.lastOp, f.lastFlags, f.lastUnchangedSince = folderID, uid, op, flags, unchangedSince
return true, nil
}
func TestImapSessionUseCase_SetFlags_DelegatesToRepository(t *testing.T) {
flagRepo := &fakeFlagRepository{}
uc := NewImapSessionUseCase(nil, nil, nil, flagRepo, nil, nil, nil)
updated, err := uc.SetFlags(context.Background(), "folder-1", 7, port.FlagOpAdd, []string{"\\Seen"}, 10)
if err != nil || !updated {
t.Fatalf("SetFlags error: %v, updated=%v", err, updated)
}
if flagRepo.lastFolderID != "folder-1" || flagRepo.lastUID != 7 || flagRepo.lastUnchangedSince != 10 {
t.Errorf("flagRepo state = %+v, want folder-1 uid 7 unchangedSince 10", flagRepo)
}
}
func TestImapSessionUseCase_CopyMessages_ReturnsRepositoryResult(t *testing.T) {
copier := &fakeCopyRepository{result: []port.CopiedMessage{{SourceUID: 3, DestUID: 1}, {SourceUID: 7, DestUID: 2}}}
uc := NewImapSessionUseCase(nil, nil, nil, nil, nil, nil, copier)
@@ -0,0 +1,72 @@
package usecase
import (
"sync"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
)
// MailboxTrackerManager maintains imapserver.MailboxTracker instances per folder ID.
// This allows active IMAP sessions to receive real-time notifications when emails arrive,
// flags change, or messages are expunged (IDLE / RFC 2177).
type MailboxTrackerManager struct {
mu sync.RWMutex
trackers map[string]*imapserver.MailboxTracker
}
func NewMailboxTrackerManager() *MailboxTrackerManager {
return &MailboxTrackerManager{
trackers: make(map[string]*imapserver.MailboxTracker),
}
}
// GetTracker retrieves or initializes the MailboxTracker for folderID.
func (m *MailboxTrackerManager) GetTracker(folderID string, initialNumMessages uint32) *imapserver.MailboxTracker {
m.mu.Lock()
defer m.mu.Unlock()
tracker, ok := m.trackers[folderID]
if !ok {
tracker = imapserver.NewMailboxTracker(initialNumMessages)
m.trackers[folderID] = tracker
}
return tracker
}
// NotifyNumMessages informs all sessions watching folderID of a new EXISTS count.
func (m *MailboxTrackerManager) NotifyNumMessages(folderID string, n uint32) {
m.mu.RLock()
tracker, ok := m.trackers[folderID]
m.mu.RUnlock()
if ok {
tracker.QueueNumMessages(n)
}
}
// NotifyMessageFlags informs sessions watching folderID of updated flags on a message.
func (m *MailboxTrackerManager) NotifyMessageFlags(folderID string, seqNum uint32, uid uint32, flags []string, source *imapserver.SessionTracker) {
m.mu.RLock()
tracker, ok := m.trackers[folderID]
m.mu.RUnlock()
if ok {
imapFlags := make([]imap.Flag, len(flags))
for i, f := range flags {
imapFlags[i] = imap.Flag(f)
}
tracker.QueueMessageFlags(seqNum, imap.UID(uid), imapFlags, source)
}
}
// NotifyExpunge informs sessions watching folderID that seqNum was expunged.
func (m *MailboxTrackerManager) NotifyExpunge(folderID string, seqNum uint32) {
m.mu.RLock()
tracker, ok := m.trackers[folderID]
m.mu.RUnlock()
if ok {
tracker.QueueExpunge(seqNum)
}
}
@@ -36,15 +36,22 @@ type ProcessInboundEmailInput struct {
// resolution + durable persistence; SPF/DKIM/DMARC/spam/virus checks land in
// later sub-projects).
type ProcessInboundEmailUseCase struct {
mailboxes port.MailboxRepository
blobs port.BlobStorage
messages port.InboundMessageRepository
mailboxes port.MailboxRepository
blobs port.BlobStorage
messages port.InboundMessageRepository
folders port.ImapFolderRepository
trackerManager *MailboxTrackerManager
}
func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase {
return &ProcessInboundEmailUseCase{mailboxes: mailboxes, blobs: blobs, messages: messages}
}
func (uc *ProcessInboundEmailUseCase) SetTrackerManager(tm *MailboxTrackerManager, folders port.ImapFolderRepository) {
uc.trackerManager = tm
uc.folders = folders
}
// ResolveRecipient is called from the SMTP session's RCPT TO handler. It
// returns every mailbox address should be delivered to (more than one for
// an alias that fans out), or ErrRecipientNotFound / ErrMailboxQuotaExceeded.
@@ -88,6 +95,13 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
if err != nil {
return err
}
if uc.trackerManager != nil && uc.folders != nil {
folderRec, err := uc.folders.FindByName(ctx, recipient.ID.String(), "INBOX")
if err == nil && folderRec != nil {
uc.trackerManager.NotifyNumMessages(folderRec.ID, folderRec.NumMessages)
}
}
}
return nil
}