#6: implement IMAP IDLE (RFC 2177) and CONDSTORE (RFC 7162)
This commit is contained in:
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, ¬, loadRaw) {
|
||||
if matchCriteria(seqNum, uid, receivedAt, sizeBytes, modSeq, flags, ¬, 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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user