Skip to content
Open
2 changes: 2 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type serverBuilder struct {
disableParallelism bool
imapLimits limits.IMAP
disableIMAPAuthenticate bool
enableGmailExtension bool
uidValidityGenerator imap.UIDValidityGenerator
panicHandler async.PanicHandler
dbCI db.ClientInterface
Expand Down Expand Up @@ -138,6 +139,7 @@ func (builder *serverBuilder) build() (*Server, error) {
reporter: builder.reporter,
disableParallelism: builder.disableParallelism,
disableIMAPAuthenticate: builder.disableIMAPAuthenticate,
enableGmailExtension: builder.enableGmailExtension,
uidValidityGenerator: builder.uidValidityGenerator,
panicHandler: builder.panicHandler,
observabilitySender: builder.observabilitySender,
Expand Down
13 changes: 13 additions & 0 deletions connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ type Connector interface {
// MarkMessagesForwarded sets the forwarded value of the give messages.
MarkMessagesForwarded(ctx context.Context, cache IMAPStateWrite, messageIDs []imap.MessageID, forwarded bool) error

// MarkMessagesWithGmailLabels applies or removes Gmail-style labels (X-GM-EXT-1 extension).
// Labels are identified by name. If add is true, labels are applied; if false, they are removed.
// This must NOT modify folder membership — messages stay in their current mailbox (e.g., INBOX).
MarkMessagesWithGmailLabels(ctx context.Context, cache IMAPStateWrite, messageIDs []imap.MessageID, labels []string, add bool) error

// GetGmailLabels retrieves the Gmail-style label names for the given message.
// Note: this can get called from different go routines.
GetGmailLabels(ctx context.Context, messageID imap.MessageID) ([]string, error)

// GetGmailLabelMailboxID returns the IMAP mailbox ID for a given Gmail label name.
// Used for efficient SEARCH X-GM-LABELS operations via local DB lookups.
GetGmailLabelMailboxID(ctx context.Context, label string) (imap.MailboxID, bool)

// GetUpdates returns a stream of updates that the gluon server should apply.
GetUpdates() <-chan imap.Update

Expand Down
53 changes: 53 additions & 0 deletions connector/dummy.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,59 @@ func (conn *Dummy) MarkMessagesForwarded(ctx context.Context, cache IMAPStateWri
return nil
}

// MarkMessagesWithGmailLabels applies or removes Gmail-style labels. Each label
// is backed by a non-exclusive mailbox; (un)labelling only (un)links the message
// from that mailbox and never touches folder membership (e.g. INBOX), matching
// the real Bridge connector contract.
func (conn *Dummy) MarkMessagesWithGmailLabels(_ context.Context, _ IMAPStateWrite, messageIDs []imap.MessageID, labels []string, add bool) error {
for _, label := range labels {
var mboxID imap.MailboxID

if add {
id, mbox, created := conn.state.getOrCreateLabelMailbox(label)
mboxID = id

if created {
conn.pushUpdate(imap.NewMailboxCreated(mbox))
}
} else {
id, ok := conn.state.getLabelMailboxID(label)
if !ok {
// Removing a label that was never applied is a no-op.
continue
}

mboxID = id
}

for _, messageID := range messageIDs {
if add {
conn.state.addGmailLabel(messageID, label)
conn.state.addMessageToMailbox(messageID, mboxID)
} else {
conn.state.removeGmailLabel(messageID, label)
conn.state.removeMessageFromMailbox(messageID, mboxID)
}

conn.pushUpdate(imap.NewMessageMailboxesUpdated(
messageID,
conn.state.getMailboxIDs(messageID),
conn.state.getMessageFlags(messageID),
))
}
}

return nil
}

func (conn *Dummy) GetGmailLabels(_ context.Context, messageID imap.MessageID) ([]string, error) {
return conn.state.getGmailLabels(messageID), nil
}

func (conn *Dummy) GetGmailLabelMailboxID(_ context.Context, label string) (imap.MailboxID, bool) {
return conn.state.getLabelMailboxID(label)
}

func (conn *Dummy) Sync(ctx context.Context) error {
for _, mailbox := range conn.state.getMailboxes() {
update := imap.NewMailboxCreated(mailbox)
Expand Down
96 changes: 90 additions & 6 deletions connector/dummy_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package connector

import (
"context"
"sort"
"sync"
"time"

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

// labelMailboxes maps a Gmail label name to the (non-exclusive) mailbox
// that backs it. Used by the X-GM-EXT-1 extension: applying a label adds
// the message to this mailbox without touching folder membership.
labelMailboxes map[string]imap.MailboxID

lock sync.RWMutex
}

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

mboxIDs map[imap.MailboxID]struct{}

// labels holds the Gmail-style labels (X-GM-LABELS) applied to this message.
labels map[string]struct{}
}

func newDummyState(flags, permFlags, attrs imap.FlagSet) *dummyState {
return &dummyState{
flags: flags,
permFlags: permFlags,
attrs: attrs,
messages: make(map[imap.MessageID]*dummyMessage),
mailboxes: make(map[imap.MailboxID]*dummyMailbox),
lastIMAPID: imap.NewIMAPID(),
flags: flags,
permFlags: permFlags,
attrs: attrs,
messages: make(map[imap.MessageID]*dummyMessage),
mailboxes: make(map[imap.MailboxID]*dummyMailbox),
lastIMAPID: imap.NewIMAPID(),
labelMailboxes: make(map[string]imap.MailboxID),
}
}

Expand Down Expand Up @@ -222,6 +232,7 @@ func (state *dummyState) createMessage(
flags: otherFlags,
date: date,
mboxIDs: map[imap.MailboxID]struct{}{mboxID: {}},
labels: make(map[string]struct{}),
}

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

// getOrCreateLabelMailbox returns the (non-exclusive) mailbox backing the given
// Gmail label, creating it on first use. The returned bool reports whether the
// mailbox was newly created so the caller can emit a MailboxCreated update.
func (state *dummyState) getOrCreateLabelMailbox(label string) (imap.MailboxID, imap.Mailbox, bool) {
state.lock.Lock()
defer state.lock.Unlock()

if mboxID, ok := state.labelMailboxes[label]; ok {
return mboxID, state.toMailbox(mboxID), false
}

mboxID := imap.MailboxID(uuid.NewString())

// Labels are non-exclusive: applying one must not evict the message from
// its folder (e.g. INBOX), matching real Bridge behaviour.
state.mailboxes[mboxID] = &dummyMailbox{
mboxName: []string{label},
exclusive: false,
}
state.labelMailboxes[label] = mboxID

return mboxID, state.toMailbox(mboxID), true
}

func (state *dummyState) getLabelMailboxID(label string) (imap.MailboxID, bool) {
state.lock.Lock()
defer state.lock.Unlock()

mboxID, ok := state.labelMailboxes[label]

return mboxID, ok
}

func (state *dummyState) addGmailLabel(messageID imap.MessageID, label string) {
state.lock.Lock()
defer state.lock.Unlock()

msg := state.messages[messageID]
if msg.labels == nil {
msg.labels = make(map[string]struct{})
}

msg.labels[label] = struct{}{}
}

func (state *dummyState) removeGmailLabel(messageID imap.MessageID, label string) {
state.lock.Lock()
defer state.lock.Unlock()

if msg := state.messages[messageID]; msg.labels != nil {
delete(msg.labels, label)
}
}

func (state *dummyState) getGmailLabels(messageID imap.MessageID) []string {
state.lock.Lock()
defer state.lock.Unlock()

msg, ok := state.messages[messageID]
if !ok {
return nil
}

labels := make([]string, 0, len(msg.labels))
for label := range msg.labels {
labels = append(labels, label)
}

sort.Strings(labels)

return labels
}

func (state *dummyState) setSeen(messageID imap.MessageID, seen bool) {
state.lock.Lock()
defer state.lock.Unlock()
Expand Down
3 changes: 2 additions & 1 deletion imap/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ const (
MOVE Capability = `MOVE`
ID Capability = `ID`
AUTHPLAIN Capability = `AUTH=PLAIN`
XGMEXT1 Capability = `X-GM-EXT-1`
)

func IsCapabilityAvailableBeforeAuth(c Capability) bool {
switch c {
case IMAP4rev1, StartTLS, IDLE, ID, AUTHPLAIN:
case IMAP4rev1, StartTLS, IDLE, ID, AUTHPLAIN, XGMEXT1:
return true
case UNSELECT, UIDPLUS, MOVE:
return false
Expand Down
25 changes: 25 additions & 0 deletions imap/command/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ func handleFetchAttribute(name rfcparser.String, p *rfcparser.Parser) (FetchAttr
return handleRFC822FetchAttribute(p)
case "body":
return handleBodyFetchAttribute(p)
case "x":
return handleXExtensionFetchAttribute(p, name)
default:
return nil, p.MakeErrorAtOffset(fmt.Sprintf("unknown fetch attribute '%v'", name.Value), name.Offset)
}
Expand Down Expand Up @@ -433,6 +435,29 @@ func collectBodySectionText(p *rfcparser.Parser) (string, error) {
return strings.ToLower(string(text.Value)), nil
}

func handleXExtensionFetchAttribute(p *rfcparser.Parser, name rfcparser.String) (FetchAttribute, error) {
// Handle X-GM-LABELS for the Gmail X-GM-EXT-1 extension.
// parseFetchAttributeName() only collected "x" (stopped at hyphen).
// Consume the rest: "-GM-LABELS".
if err := p.ConsumeBytesFold('-'); err != nil {
return nil, p.MakeErrorAtOffset(fmt.Sprintf("unknown fetch attribute '%v'", name.Value), name.Offset)
}

if err := p.ConsumeBytesFold('G', 'M'); err != nil {
return nil, p.MakeErrorAtOffset(fmt.Sprintf("unknown fetch attribute '%v'", name.Value), name.Offset)
}

if err := p.ConsumeBytesFold('-'); err != nil {
return nil, p.MakeErrorAtOffset(fmt.Sprintf("unknown fetch attribute '%v'", name.Value), name.Offset)
}

if err := p.ConsumeBytesFold('L', 'A', 'B', 'E', 'L', 'S'); err != nil {
return nil, p.MakeErrorAtOffset(fmt.Sprintf("unknown fetch attribute '%v'", name.Value), name.Offset)
}

return &FetchAttributeGmailLabels{}, nil
}

func parseHeaderList(p *rfcparser.Parser) ([]string, error) {
var result []string

Expand Down
6 changes: 6 additions & 0 deletions imap/command/fetch_attributes.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ func (f FetchAttributeUID) String() string {
return "UID"
}

type FetchAttributeGmailLabels struct{}

func (f FetchAttributeGmailLabels) String() string {
return "X-GM-LABELS"
}

type BodySection interface {
String() string
}
Expand Down
Loading