Skip to content

Commit fb4bd2d

Browse files
author
B. Olausson
committed
test: add X-GM-EXT-1 round-trip tests and fix capability assertions
Covers the previously-untested X-GM-LABELS extension surface: - imap/command: STORE/FETCH/SEARCH parser tests, incl. the bare-label (Python imaplib) form and NOT X-GM-LABELS — the exact wire forms Paperless-NGX emits. - internal/response: itemGmailLabels formatting (empty/quoting/spaces). - connector dummy: store labels as non-exclusive label mailboxes so STORE/FETCH/SEARCH round-trip end-to-end without touching folder membership; previous methods were no-op stubs. - tests: STORE -> FETCH -> STORE remove round trip, multi-message, labels with spaces, and SEARCH / NOT X-GM-LABELS (the Paperless dedupe query). - Fix TestCapability/TestCapabilityAuthenticateDisabled/login/ authenticate which now must advertise X-GM-EXT-1 (sorted last); these were broken when the feature commit started advertising it.
1 parent 8f30a04 commit fb4bd2d

8 files changed

Lines changed: 550 additions & 20 deletions

File tree

connector/dummy.go

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -276,17 +276,57 @@ func (conn *Dummy) MarkMessagesForwarded(ctx context.Context, cache IMAPStateWri
276276
return nil
277277
}
278278

279-
func (conn *Dummy) MarkMessagesWithGmailLabels(_ context.Context, _ IMAPStateWrite, _ []imap.MessageID, _ []string, _ bool) error {
280-
// Dummy connector: no-op for Gmail label operations.
279+
// MarkMessagesWithGmailLabels applies or removes Gmail-style labels. Each label
280+
// is backed by a non-exclusive mailbox; (un)labelling only (un)links the message
281+
// from that mailbox and never touches folder membership (e.g. INBOX), matching
282+
// the real Bridge connector contract.
283+
func (conn *Dummy) MarkMessagesWithGmailLabels(_ context.Context, _ IMAPStateWrite, messageIDs []imap.MessageID, labels []string, add bool) error {
284+
for _, label := range labels {
285+
var mboxID imap.MailboxID
286+
287+
if add {
288+
id, mbox, created := conn.state.getOrCreateLabelMailbox(label)
289+
mboxID = id
290+
291+
if created {
292+
conn.pushUpdate(imap.NewMailboxCreated(mbox))
293+
}
294+
} else {
295+
id, ok := conn.state.getLabelMailboxID(label)
296+
if !ok {
297+
// Removing a label that was never applied is a no-op.
298+
continue
299+
}
300+
301+
mboxID = id
302+
}
303+
304+
for _, messageID := range messageIDs {
305+
if add {
306+
conn.state.addGmailLabel(messageID, label)
307+
conn.state.addMessageToMailbox(messageID, mboxID)
308+
} else {
309+
conn.state.removeGmailLabel(messageID, label)
310+
conn.state.removeMessageFromMailbox(messageID, mboxID)
311+
}
312+
313+
conn.pushUpdate(imap.NewMessageMailboxesUpdated(
314+
messageID,
315+
conn.state.getMailboxIDs(messageID),
316+
conn.state.getMessageFlags(messageID),
317+
))
318+
}
319+
}
320+
281321
return nil
282322
}
283323

284-
func (conn *Dummy) GetGmailLabels(_ context.Context, _ imap.MessageID) ([]string, error) {
285-
return nil, nil
324+
func (conn *Dummy) GetGmailLabels(_ context.Context, messageID imap.MessageID) ([]string, error) {
325+
return conn.state.getGmailLabels(messageID), nil
286326
}
287327

288-
func (conn *Dummy) GetGmailLabelMailboxID(_ context.Context, _ string) (imap.MailboxID, bool) {
289-
return "", false
328+
func (conn *Dummy) GetGmailLabelMailboxID(_ context.Context, label string) (imap.MailboxID, bool) {
329+
return conn.state.getLabelMailboxID(label)
290330
}
291331

292332
func (conn *Dummy) Sync(ctx context.Context) error {

connector/dummy_state.go

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package connector
22

33
import (
44
"context"
5+
"sort"
56
"sync"
67
"time"
78

@@ -18,6 +19,11 @@ type dummyState struct {
1819
mailboxes map[imap.MailboxID]*dummyMailbox
1920
lastIMAPID imap.IMAPID
2021

22+
// labelMailboxes maps a Gmail label name to the (non-exclusive) mailbox
23+
// that backs it. Used by the X-GM-EXT-1 extension: applying a label adds
24+
// the message to this mailbox without touching folder membership.
25+
labelMailboxes map[string]imap.MailboxID
26+
2127
lock sync.RWMutex
2228
}
2329

@@ -36,16 +42,20 @@ type dummyMessage struct {
3642
flags imap.FlagSet
3743

3844
mboxIDs map[imap.MailboxID]struct{}
45+
46+
// labels holds the Gmail-style labels (X-GM-LABELS) applied to this message.
47+
labels map[string]struct{}
3948
}
4049

4150
func newDummyState(flags, permFlags, attrs imap.FlagSet) *dummyState {
4251
return &dummyState{
43-
flags: flags,
44-
permFlags: permFlags,
45-
attrs: attrs,
46-
messages: make(map[imap.MessageID]*dummyMessage),
47-
mailboxes: make(map[imap.MailboxID]*dummyMailbox),
48-
lastIMAPID: imap.NewIMAPID(),
52+
flags: flags,
53+
permFlags: permFlags,
54+
attrs: attrs,
55+
messages: make(map[imap.MessageID]*dummyMessage),
56+
mailboxes: make(map[imap.MailboxID]*dummyMailbox),
57+
lastIMAPID: imap.NewIMAPID(),
58+
labelMailboxes: make(map[string]imap.MailboxID),
4959
}
5060
}
5161

@@ -222,6 +232,7 @@ func (state *dummyState) createMessage(
222232
flags: otherFlags,
223233
date: date,
224234
mboxIDs: map[imap.MailboxID]struct{}{mboxID: {}},
235+
labels: make(map[string]struct{}),
225236
}
226237

227238
return state.toMessage(messageID)
@@ -245,6 +256,79 @@ func (state *dummyState) removeMessageFromMailbox(messageID imap.MessageID, mbox
245256
delete(state.messages[messageID].mboxIDs, mboxID)
246257
}
247258

259+
// getOrCreateLabelMailbox returns the (non-exclusive) mailbox backing the given
260+
// Gmail label, creating it on first use. The returned bool reports whether the
261+
// mailbox was newly created so the caller can emit a MailboxCreated update.
262+
func (state *dummyState) getOrCreateLabelMailbox(label string) (imap.MailboxID, imap.Mailbox, bool) {
263+
state.lock.Lock()
264+
defer state.lock.Unlock()
265+
266+
if mboxID, ok := state.labelMailboxes[label]; ok {
267+
return mboxID, state.toMailbox(mboxID), false
268+
}
269+
270+
mboxID := imap.MailboxID(uuid.NewString())
271+
272+
// Labels are non-exclusive: applying one must not evict the message from
273+
// its folder (e.g. INBOX), matching real Bridge behaviour.
274+
state.mailboxes[mboxID] = &dummyMailbox{
275+
mboxName: []string{label},
276+
exclusive: false,
277+
}
278+
state.labelMailboxes[label] = mboxID
279+
280+
return mboxID, state.toMailbox(mboxID), true
281+
}
282+
283+
func (state *dummyState) getLabelMailboxID(label string) (imap.MailboxID, bool) {
284+
state.lock.Lock()
285+
defer state.lock.Unlock()
286+
287+
mboxID, ok := state.labelMailboxes[label]
288+
289+
return mboxID, ok
290+
}
291+
292+
func (state *dummyState) addGmailLabel(messageID imap.MessageID, label string) {
293+
state.lock.Lock()
294+
defer state.lock.Unlock()
295+
296+
msg := state.messages[messageID]
297+
if msg.labels == nil {
298+
msg.labels = make(map[string]struct{})
299+
}
300+
301+
msg.labels[label] = struct{}{}
302+
}
303+
304+
func (state *dummyState) removeGmailLabel(messageID imap.MessageID, label string) {
305+
state.lock.Lock()
306+
defer state.lock.Unlock()
307+
308+
if msg := state.messages[messageID]; msg.labels != nil {
309+
delete(msg.labels, label)
310+
}
311+
}
312+
313+
func (state *dummyState) getGmailLabels(messageID imap.MessageID) []string {
314+
state.lock.Lock()
315+
defer state.lock.Unlock()
316+
317+
msg, ok := state.messages[messageID]
318+
if !ok {
319+
return nil
320+
}
321+
322+
labels := make([]string, 0, len(msg.labels))
323+
for label := range msg.labels {
324+
labels = append(labels, label)
325+
}
326+
327+
sort.Strings(labels)
328+
329+
return labels
330+
}
331+
248332
func (state *dummyState) setSeen(messageID imap.MessageID, seen bool) {
249333
state.lock.Lock()
250334
defer state.lock.Unlock()

0 commit comments

Comments
 (0)