diff --git a/benchmarks/gluon_bench/gluon_benchmarks/sync.go b/benchmarks/gluon_bench/gluon_benchmarks/sync.go index b3c22a5f..d378f49d 100644 --- a/benchmarks/gluon_bench/gluon_benchmarks/sync.go +++ b/benchmarks/gluon_bench/gluon_benchmarks/sync.go @@ -156,6 +156,14 @@ func init() { type nullIMAPStateWriter struct{} +func (n nullIMAPStateWriter) AddFlagsToAllMailboxes(ctx context.Context, flags ...string) error { + panic("implement me") +} + +func (n nullIMAPStateWriter) AddPermFlagsToAllMailboxes(ctx context.Context, flags ...string) error { + panic("implement me") +} + func (n nullIMAPStateWriter) GetSettings(ctx context.Context) (string, bool, error) { return "", false, nil } diff --git a/connector/connector.go b/connector/connector.go index d68062cc..2b3f1092 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -54,6 +54,9 @@ type Connector interface { // MarkMessagesFlagged sets the flagged value of the given messages. MarkMessagesFlagged(ctx context.Context, cache IMAPStateWrite, messageIDs []imap.MessageID, flagged bool) error + // MarkMessagesForwarded sets the forwarded value of the give messages. + MarkMessagesForwarded(ctx context.Context, cache IMAPStateWrite, messageIDs []imap.MessageID, forwarded bool) error + // GetUpdates returns a stream of updates that the gluon server should apply. GetUpdates() <-chan imap.Update diff --git a/connector/dummy.go b/connector/dummy.go index cca4580d..e4369819 100644 --- a/connector/dummy.go +++ b/connector/dummy.go @@ -263,6 +263,19 @@ func (conn *Dummy) MarkMessagesFlagged(_ context.Context, _ IMAPStateWrite, mess return nil } +func (conn *Dummy) MarkMessagesForwarded(ctx context.Context, cache IMAPStateWrite, messageIDs []imap.MessageID, forwarded bool) error { + for _, messageID := range messageIDs { + conn.state.setForwarded(messageID, forwarded) + + conn.pushUpdate(imap.NewMessageFlagsUpdated( + messageID, + conn.state.getMessageFlags(messageID), + )) + } + + return nil +} + func (conn *Dummy) Sync(ctx context.Context) error { for _, mailbox := range conn.state.getMailboxes() { update := imap.NewMailboxCreated(mailbox) diff --git a/connector/dummy_state.go b/connector/dummy_state.go index bf59dd9e..5f89376b 100644 --- a/connector/dummy_state.go +++ b/connector/dummy_state.go @@ -27,12 +27,13 @@ type dummyMailbox struct { } type dummyMessage struct { - literal []byte - parsed *imap.ParsedMessage - seen bool - flagged bool - date time.Time - flags imap.FlagSet + literal []byte + parsed *imap.ParsedMessage + seen bool + flagged bool + forwarded bool + date time.Time + flags imap.FlagSet mboxIDs map[imap.MailboxID]struct{} } @@ -256,6 +257,13 @@ func (state *dummyState) setFlagged(messageID imap.MessageID, flagged bool) { state.messages[messageID].flagged = flagged } +func (state *dummyState) setForwarded(messageID imap.MessageID, forwarded bool) { + state.lock.Lock() + defer state.lock.Unlock() + + state.messages[messageID].forwarded = forwarded +} + func (state *dummyState) isSeen(messageID imap.MessageID) bool { state.lock.Lock() defer state.lock.Unlock() @@ -294,6 +302,11 @@ func (state *dummyState) getMessageFlags(messageID imap.MessageID) imap.FlagSet flags.AddToSelf(imap.FlagFlagged) } + if msg.forwarded { + flags.AddToSelf(imap.XFlagForwarded) + flags.AddToSelf(imap.XFlagDollarForwarded) + } + return flags } diff --git a/connector/state.go b/connector/state.go index e33e4953..dadcf0b5 100644 --- a/connector/state.go +++ b/connector/state.go @@ -58,4 +58,8 @@ type IMAPStateWrite interface { // transformation necessary to ensure that new parent or child mailboxes are created as expected by a regular // IMAP rename operation. PatchMailboxHierarchyWithoutTransforms(ctx context.Context, id imap.MailboxID, newName []string) error + + AddFlagsToAllMailboxes(ctx context.Context, flags ...string) error + + AddPermFlagsToAllMailboxes(ctx context.Context, flags ...string) error } diff --git a/db/ops_mailbox.go b/db/ops_mailbox.go index 27155be0..142c95dc 100644 --- a/db/ops_mailbox.go +++ b/db/ops_mailbox.go @@ -105,6 +105,10 @@ type MailboxWriteOps interface { UpdateRemoteMailboxID(ctx context.Context, mobxID imap.InternalMailboxID, remoteID imap.MailboxID) error SetMailboxUIDValidity(ctx context.Context, mboxID imap.InternalMailboxID, uidValidity imap.UID) error + + AddFlagsToAllMailboxes(ctx context.Context, flags ...string) error + + AddPermFlagsToAllMailboxes(ctx context.Context, flags ...string) error } type SnapshotMessageResult struct { diff --git a/imap/flags.go b/imap/flags.go index f5e822c6..8f0057cc 100644 --- a/imap/flags.go +++ b/imap/flags.go @@ -9,23 +9,37 @@ import ( ) const ( - FlagSeen = `\Seen` - FlagAnswered = `\Answered` - FlagFlagged = `\Flagged` - FlagDeleted = `\Deleted` - FlagDraft = `\Draft` - FlagRecent = `\Recent` // Read-only!. + FlagSeen = `\Seen` + FlagAnswered = `\Answered` + FlagFlagged = `\Flagged` + FlagDeleted = `\Deleted` + FlagDraft = `\Draft` + FlagRecent = `\Recent` // Read-only!. + XFlagDollarForwarded = "$Forwarded" // Non-Standard flag + XFlagForwarded = "Forwarded" // Non-Standard flag ) const ( - FlagSeenLowerCase = `\seen` - FlagAnsweredLowerCase = `\answered` - FlagFlaggedLowerCase = `\flagged` - FlagDeletedLowerCase = `\deleted` - FlagDraftLowerCase = `\draft` - FlagRecentLowerCase = `\recent` // Read-only!. + FlagSeenLowerCase = `\seen` + FlagAnsweredLowerCase = `\answered` + FlagFlaggedLowerCase = `\flagged` + FlagDeletedLowerCase = `\deleted` + FlagDraftLowerCase = `\draft` + FlagRecentLowerCase = `\recent` // Read-only!. + XFlagDollarForwardedLowerCase = "$forwarded" + XFlagForwardedLowerCase = "forwarded" ) +var ForwardFlagList = []string{ + XFlagDollarForwarded, + XFlagForwarded, +} + +var ForwardFlagListLowerCase = []string{ + XFlagDollarForwardedLowerCase, + XFlagForwardedLowerCase, +} + // FlagSet represents a set of IMAP flags. Flags are case-insensitive and no duplicates are allowed. type FlagSet map[string]string @@ -89,6 +103,14 @@ func (fs FlagSet) ContainsAny(flags ...string) bool { }) >= 0 } +// ContainsAnyUnchecked returns true if and only if any of the flags are in the set. The flag list is not converted to +// lower case. +func (fs FlagSet) ContainsAnyUnchecked(flags ...string) bool { + return xslices.IndexFunc(flags, func(f string) bool { + return fs.ContainsUnchecked(f) + }) >= 0 +} + // ContainsAll returns true if and only if all of the flags are in the set. func (fs FlagSet) ContainsAll(flags ...string) bool { return xslices.IndexFunc(flags, func(f string) bool { diff --git a/internal/backend/connector_state_write.go b/internal/backend/connector_state_write.go index c52a9f7f..03bab106 100644 --- a/internal/backend/connector_state_write.go +++ b/internal/backend/connector_state_write.go @@ -90,6 +90,14 @@ func (d *DBIMAPStateWrite) PatchMailboxHierarchyWithoutTransforms(ctx context.Co return d.tx.RenameMailboxWithRemoteID(ctx, id, combined) } +func (d *DBIMAPStateWrite) AddFlagsToAllMailboxes(ctx context.Context, flags ...string) error { + return d.tx.AddFlagsToAllMailboxes(ctx, flags...) +} + +func (d *DBIMAPStateWrite) AddPermFlagsToAllMailboxes(ctx context.Context, flags ...string) error { + return d.tx.AddPermFlagsToAllMailboxes(ctx, flags...) +} + func (d *DBIMAPStateWrite) wrapStateUpdates(ctx context.Context, f func(ctx context.Context, tx db.Transaction) ([]state.Update, error)) error { updates, err := f(ctx, d.tx) if err == nil { diff --git a/internal/backend/state_connector_impl.go b/internal/backend/state_connector_impl.go index 65f7fd49..9320ccf0 100644 --- a/internal/backend/state_connector_impl.go +++ b/internal/backend/state_connector_impl.go @@ -191,6 +191,23 @@ func (sc *stateConnectorImpl) GetMailboxVisibility(ctx context.Context, return sc.connector.GetMailboxVisibility(ctx, id) } +func (sc *stateConnectorImpl) SetMessagesForwarded( + ctx context.Context, + tx db.Transaction, + messageIDs []imap.MessageID, + forwarded bool, +) ([]state.Update, error) { + ctx = sc.newContextWithMetadata(ctx) + + cache := sc.newDBIMAPWrite(tx) + + if err := sc.connector.MarkMessagesForwarded(ctx, &cache, messageIDs, forwarded); err != nil { + return nil, err + } + + return cache.stateUpdates, nil +} + func (sc *stateConnectorImpl) getMetadataValue(key string) any { v, ok := sc.metadata[key] if !ok { diff --git a/internal/db_impl/sqlite3/utils/tracer.go b/internal/db_impl/sqlite3/utils/tracer.go index d20ff165..8d131eec 100644 --- a/internal/db_impl/sqlite3/utils/tracer.go +++ b/internal/db_impl/sqlite3/utils/tracer.go @@ -460,3 +460,15 @@ func (w WriteTracer) StoreConnectorSettings(ctx context.Context, settings string return w.TX.StoreConnectorSettings(ctx, settings) } + +func (w WriteTracer) AddFlagsToAllMailboxes(ctx context.Context, flags ...string) error { + w.Entry.Tracef("AddFlagsToAllMailboxes") + + return w.TX.AddFlagsToAllMailboxes(ctx, flags...) +} + +func (w WriteTracer) AddPermFlagsToAllMailboxes(ctx context.Context, flags ...string) error { + w.Entry.Tracef("AddPermFlagsToAllMailboxes") + + return w.TX.AddPermFlagsToAllMailboxes(ctx, flags...) +} diff --git a/internal/db_impl/sqlite3/write_ops.go b/internal/db_impl/sqlite3/write_ops.go index 3554d017..7ef8021d 100644 --- a/internal/db_impl/sqlite3/write_ops.go +++ b/internal/db_impl/sqlite3/write_ops.go @@ -669,3 +669,43 @@ func (w writeOps) StoreConnectorSettings(ctx context.Context, settings string) e return err } + +func (w writeOps) AddFlagsToAllMailboxes(ctx context.Context, flags ...string) error { + flagsJoined := strings.Join(xslices.Map(flags, func(s string) string { + return "('" + s + "')" + }), ",") + + queryInsert := fmt.Sprintf( + "INSERT OR IGNORE INTO %v (`%v`, `%v`) SELECT `%v`,`value` FROM %v CROSS JOIN (WITH T(value) AS (VALUES %v) SELECT * FROM T)", + v1.MailboxFlagsTableName, + v1.MailboxFlagsFieldMailboxID, + v1.MailboxFlagsFieldValue, + v1.MailboxesFieldID, + v1.MailboxesTableName, + flagsJoined, + ) + + _, err := utils.ExecQuery(ctx, w.qw, queryInsert) + + return err +} + +func (w writeOps) AddPermFlagsToAllMailboxes(ctx context.Context, flags ...string) error { + flagsJoined := strings.Join(xslices.Map(flags, func(s string) string { + return "('" + s + "')" + }), ",") + + queryInsert := fmt.Sprintf( + "INSERT OR IGNORE INTO %v (`%v`, `%v`) SELECT `%v`,`value` FROM %v CROSS JOIN (WITH T(value) AS (VALUES %v) SELECT * FROM T)", + v1.MailboxPermFlagsTableName, + v1.MailboxPermFlagsFieldMailboxID, + v1.MailboxPermFlagsFieldValue, + v1.MailboxesFieldID, + v1.MailboxesTableName, + flagsJoined, + ) + + _, err := utils.ExecQuery(ctx, w.qw, queryInsert) + + return err +} diff --git a/internal/state/connector.go b/internal/state/connector.go index 1dfbae2f..b7d01c73 100644 --- a/internal/state/connector.go +++ b/internal/state/connector.go @@ -80,4 +80,7 @@ type Connector interface { // GetMailboxVisibility retrieves the visibility status of a mailbox for a client. GetMailboxVisibility(ctx context.Context, id imap.MailboxID) imap.MailboxVisibility + + // SetMessagesForwarded marks the message with the given ID as forwarded. + SetMessagesForwarded(ctx context.Context, tx db.Transaction, messageIDs []imap.MessageID, forwarded bool) ([]Update, error) } diff --git a/internal/state/updates.go b/internal/state/updates.go index b4ca720d..39725589 100644 --- a/internal/state/updates.go +++ b/internal/state/updates.go @@ -112,44 +112,59 @@ func (state *State) applyMessageFlagsAdded(ctx context.Context, return nil, err } - // If setting messages as seen, only set those messages that aren't currently seen. - if addFlags.ContainsUnchecked(imap.FlagSeenLowerCase) { - var messagesToApply []imap.MessageID + doFlagAdd := func(check func(*imap.FlagSet) bool, remoteDo func([]imap.MessageID) ([]Update, error)) error { + if check(&addFlags) { + var messagesToApply []imap.MessageID + + for _, msg := range curFlags { + if !check(&msg.FlagSet) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { + messagesToApply = append(messagesToApply, msg.RemoteID) + } + } - for _, msg := range curFlags { - if !msg.FlagSet.ContainsUnchecked(imap.FlagSeenLowerCase) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { - messagesToApply = append(messagesToApply, msg.RemoteID) + if len(messagesToApply) != 0 { + updates, err := remoteDo(messagesToApply) + if err != nil { + return err + } + + allUpdates = append(allUpdates, updates...) } } - if len(messagesToApply) != 0 { - updates, err := state.user.GetRemote().SetMessagesSeen(ctx, tx, messagesToApply, true) - if err != nil { - return nil, err - } + return nil + } - allUpdates = append(allUpdates, updates...) - } + // If setting messages as seen, only set those messages that aren't currently seen. + if err := doFlagAdd(func(set *imap.FlagSet) bool { + return set.ContainsUnchecked(imap.FlagSeenLowerCase) + }, func(ids []imap.MessageID) ([]Update, error) { + return state.user.GetRemote().SetMessagesSeen(ctx, tx, ids, true) + }); err != nil { + return nil, err } // If setting messages as flagged, only set those messages that aren't currently flagged. - if addFlags.ContainsUnchecked(imap.FlagFlaggedLowerCase) { - var messagesToApply []imap.MessageID - - for _, msg := range curFlags { - if !msg.FlagSet.ContainsUnchecked(imap.FlagFlaggedLowerCase) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { - messagesToApply = append(messagesToApply, msg.RemoteID) - } - } + if err := doFlagAdd(func(set *imap.FlagSet) bool { + return set.ContainsUnchecked(imap.FlagFlaggedLowerCase) + }, func(ids []imap.MessageID) ([]Update, error) { + return state.user.GetRemote().SetMessagesFlagged(ctx, tx, ids, true) + }); err != nil { + return nil, err + } - if len(messagesToApply) != 0 { - updates, err := state.user.GetRemote().SetMessagesFlagged(ctx, tx, messagesToApply, true) - if err != nil { - return nil, err - } + // If setting messages as forwarded, only set those messages that aren't currently forwarded. + if err := doFlagAdd(func(set *imap.FlagSet) bool { + return set.ContainsAnyUnchecked(imap.ForwardFlagListLowerCase...) + }, func(ids []imap.MessageID) ([]Update, error) { + return state.user.GetRemote().SetMessagesForwarded(ctx, tx, ids, true) + }); err != nil { + return nil, err + } - allUpdates = append(allUpdates, updates...) - } + // Add all known variations of forward flags to the list if one of them is present. + if addFlags.ContainsAnyUnchecked(imap.ForwardFlagListLowerCase...) { + addFlags.AddToSelf(imap.ForwardFlagList...) } flagStateUpdate := newMessageFlagsComboStateUpdate() @@ -244,44 +259,60 @@ func (state *State) applyMessageFlagsRemoved(ctx context.Context, if err != nil { return nil, err } - // If setting messages as unseen, only set those messages that are currently seen. - if remFlags.ContainsUnchecked(imap.FlagSeenLowerCase) { - var messagesToApply []imap.MessageID - for _, msg := range curFlags { - if msg.FlagSet.ContainsUnchecked(imap.FlagSeenLowerCase) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { - messagesToApply = append(messagesToApply, msg.RemoteID) - } - } + doRemoveFlags := func(check func(set *imap.FlagSet) bool, remoteDo func([]imap.MessageID) ([]Update, error)) error { + if check(&remFlags) { + var messagesToApply []imap.MessageID - if len(messagesToApply) != 0 { - updates, err := state.user.GetRemote().SetMessagesSeen(ctx, tx, messagesToApply, false) - if err != nil { - return nil, err + for _, msg := range curFlags { + if check(&msg.FlagSet) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { + messagesToApply = append(messagesToApply, msg.RemoteID) + } } - allUpdates = append(allUpdates, updates...) + if len(messagesToApply) != 0 { + updates, err := remoteDo(messagesToApply) + if err != nil { + return err + } + + allUpdates = append(allUpdates, updates...) + } } + + return nil } - // If setting messages as unflagged, only set those messages that are currently flagged. - if remFlags.ContainsUnchecked(imap.FlagFlaggedLowerCase) { - var messagesToApply []imap.MessageID + // If setting messages as unseen, only set those messages that are currently seen. + if err := doRemoveFlags(func(set *imap.FlagSet) bool { + return set.ContainsUnchecked(imap.FlagSeenLowerCase) + }, func(messageIDS []imap.MessageID) ([]Update, error) { + return state.user.GetRemote().SetMessagesSeen(ctx, tx, messageIDS, false) + }); err != nil { + return nil, err + } - for _, msg := range curFlags { - if msg.FlagSet.ContainsUnchecked(imap.FlagFlaggedLowerCase) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { - messagesToApply = append(messagesToApply, msg.RemoteID) - } - } + // If setting messages as unflagged, only set those messages that are currently flagged. + if err := doRemoveFlags(func(set *imap.FlagSet) bool { + return set.ContainsUnchecked(imap.FlagFlaggedLowerCase) + }, func(messageIDS []imap.MessageID) ([]Update, error) { + return state.user.GetRemote().SetMessagesFlagged(ctx, tx, messageIDS, false) + }); err != nil { + return nil, err + } - if len(messagesToApply) != 0 { - updates, err := state.user.GetRemote().SetMessagesFlagged(ctx, tx, messagesToApply, false) - if err != nil { - return nil, err - } + // If setting messages as unforwarded, only set those messages that are currently forwarded + if err := doRemoveFlags(func(set *imap.FlagSet) bool { + return set.ContainsAnyUnchecked(imap.ForwardFlagListLowerCase...) + }, func(messageIDS []imap.MessageID) ([]Update, error) { + return state.user.GetRemote().SetMessagesForwarded(ctx, tx, messageIDS, false) + }); err != nil { + return nil, err + } - allUpdates = append(allUpdates, updates...) - } + // Add all known variations of forward flags to the list if one of them is present. + if remFlags.ContainsAnyUnchecked(imap.ForwardFlagListLowerCase...) { + remFlags.AddToSelf(imap.ForwardFlagList...) } flagStateUpdate := newMessageFlagsComboStateUpdate() @@ -379,42 +410,59 @@ func (state *State) applyMessageFlagsSet(ctx context.Context, return nil, err } - // If setting messages as seen, only set those messages that aren't currently seen, and vice versa. - setSeen := map[bool][]imap.MessageID{true: {}, false: {}} + var allUpdates []Update + + doSetFlag := func(check func(set *imap.FlagSet) bool, remoteDo func([]imap.MessageID, bool) ([]Update, error)) error { + setList := map[bool][]imap.MessageID{true: {}, false: {}} - for _, msg := range curFlags { - if seen := setFlags.ContainsUnchecked(imap.FlagSeenLowerCase); seen != msg.FlagSet.ContainsUnchecked(imap.FlagSeenLowerCase) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { - setSeen[seen] = append(setSeen[seen], msg.RemoteID) + for _, msg := range curFlags { + if hasValue := check(&setFlags); hasValue != check(&msg.FlagSet) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { + setList[hasValue] = append(setList[hasValue], msg.RemoteID) + } } - } - var allUpdates []Update + for seen, messageIDs := range setList { + updates, err := remoteDo(messageIDs, seen) + if err != nil { + return err + } - for seen, messageIDs := range setSeen { - updates, err := state.user.GetRemote().SetMessagesSeen(ctx, tx, messageIDs, seen) - if err != nil { - return nil, err + allUpdates = append(allUpdates, updates...) } - allUpdates = append(allUpdates, updates...) + return nil } - // If setting messages as flagged, only set those messages that aren't currently flagged, and vice versa. - setFlagged := map[bool][]imap.MessageID{true: {}, false: {}} + // If setting messages as seen, only set those messages that aren't currently seen, and vice versa. + if err := doSetFlag(func(set *imap.FlagSet) bool { + return set.ContainsUnchecked(imap.FlagSeenLowerCase) + }, func(messageIDs []imap.MessageID, b bool) ([]Update, error) { + return state.user.GetRemote().SetMessagesSeen(ctx, tx, messageIDs, b) + }); err != nil { + return nil, err + } - for _, msg := range curFlags { - if flagged := setFlags.ContainsUnchecked(imap.FlagFlaggedLowerCase); flagged != msg.FlagSet.ContainsUnchecked(imap.FlagFlaggedLowerCase) && !ids.IsRecoveredRemoteMessageID(msg.RemoteID) { - setFlagged[flagged] = append(setFlagged[flagged], msg.RemoteID) - } + // If setting messages as flagged, only set those messages that aren't currently flagged, and vice versa. + if err := doSetFlag(func(set *imap.FlagSet) bool { + return set.ContainsUnchecked(imap.FlagFlaggedLowerCase) + }, func(messageIDs []imap.MessageID, b bool) ([]Update, error) { + return state.user.GetRemote().SetMessagesFlagged(ctx, tx, messageIDs, b) + }); err != nil { + return nil, err } - for flagged, messageIDs := range setFlagged { - updates, err := state.user.GetRemote().SetMessagesFlagged(ctx, tx, messageIDs, flagged) - if err != nil { - return nil, err - } + // If setting messages as forwarded, only set those messages that aren't currently forwarded, and vice versa. + if err := doSetFlag(func(set *imap.FlagSet) bool { + return set.ContainsAnyUnchecked(imap.ForwardFlagListLowerCase...) + }, func(messageIDs []imap.MessageID, b bool) ([]Update, error) { + return state.user.GetRemote().SetMessagesForwarded(ctx, tx, messageIDs, b) + }); err != nil { + return nil, err + } - allUpdates = append(allUpdates, updates...) + // Add all known variations of forward flags to the list if one of them is present. + if setFlags.ContainsAnyUnchecked(imap.ForwardFlagListLowerCase...) { + setFlags.AddToSelf(imap.ForwardFlagList...) } if err := tx.SetMailboxMessagesDeletedFlag(ctx, state.snap.mboxID.InternalID, messageIDs, setFlags.Contains(imap.FlagDeleted)); err != nil { diff --git a/tests/store_test.go b/tests/store_test.go index 0745ed4f..859bb34c 100644 --- a/tests/store_test.go +++ b/tests/store_test.go @@ -2,6 +2,9 @@ package tests import ( "fmt" + "github.com/ProtonMail/gluon/imap" + "golang.org/x/exp/slices" + "strings" "testing" goimap "github.com/emersion/go-imap" @@ -58,6 +61,78 @@ func TestStore(t *testing.T) { }) } +func TestStoreForwarding(t *testing.T) { + // Ensure forwarding sets and removes all known forwarding flags. + runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { + c.C("b001 CREATE saved-messages") + c.S("b001 OK CREATE") + + c.doAppend(`saved-messages`, buildRFC5322TestLiteral(`To: 1@pm.me`)).expect("OK") + c.doAppend(`saved-messages`, buildRFC5322TestLiteral(`To: 2@pm.me`)).expect("OK") + + c.C(`A002 SELECT saved-messages`) + c.Se(`A002 OK [READ-WRITE] SELECT`) + + fullForwardFlagList := make([]string, len(imap.ForwardFlagList)) + copy(fullForwardFlagList, imap.ForwardFlagList) + slices.Sort(fullForwardFlagList) + + fullForwardFlagStr := strings.Join(fullForwardFlagList, " ") + + // Mar messages as forwarded. + c.C(`A003 STORE 1 +FLAGS ($Forwarded)`) + c.S(fmt.Sprintf(`* 1 FETCH (FLAGS (%v \Recent))`, fullForwardFlagStr)) + c.OK(`A003`) + c.C(`A005 FETCH 1 (FLAGS)`) + c.S(fmt.Sprintf(`* 1 FETCH (FLAGS (%v \Recent))`, fullForwardFlagStr)) + c.OK(`A005`) + + c.C(`A003 STORE 2 +FLAGS (Forwarded)`) + c.S(fmt.Sprintf(`* 2 FETCH (FLAGS (%v \Recent))`, fullForwardFlagStr)) + c.OK(`A003`) + c.C(`A005 FETCH 2 (FLAGS)`) + c.S(fmt.Sprintf(`* 2 FETCH (FLAGS (%v \Recent))`, fullForwardFlagStr)) + c.OK(`A005`) + + // Mark messages as not forwarded. + c.C(`A003 STORE 1 -FLAGS ($Forwarded)`) + c.S(`* 1 FETCH (FLAGS (\Recent))`) + c.OK(`A003`) + c.C(`A005 FETCH 1 (FLAGS)`) + c.S(`* 1 FETCH (FLAGS (\Recent))`) + c.OK(`A005`) + + c.C(`A003 STORE 2 -FLAGS (Forwarded)`) + c.S(`* 2 FETCH (FLAGS (\Recent))`) + c.OK(`A003`) + c.C(`A005 FETCH 2 (FLAGS)`) + c.S(`* 2 FETCH (FLAGS (\Recent))`) + c.OK(`A005`) + + // Set message as forwarded. + c.C(`A003 STORE 1 FLAGS ($Forwarded)`) + c.S(fmt.Sprintf(`* 1 FETCH (FLAGS (%v \Recent))`, fullForwardFlagStr)) + c.OK(`A003`) + c.C(`A005 FETCH 1 (FLAGS)`) + c.S(fmt.Sprintf(`* 1 FETCH (FLAGS (%v \Recent))`, fullForwardFlagStr)) + c.OK(`A005`) + + c.C(`A003 STORE 2 FLAGS (Forwarded)`) + c.S(fmt.Sprintf(`* 2 FETCH (FLAGS (%v \Recent))`, fullForwardFlagStr)) + c.OK(`A003`) + c.C(`A005 FETCH 2 (FLAGS)`) + c.S(fmt.Sprintf(`* 2 FETCH (FLAGS (%v \Recent))`, fullForwardFlagStr)) + c.OK(`A005`) + + c.C(`A003 STORE 2 FLAGS (\Answered)`) + c.S(`* 2 FETCH (FLAGS (\Answered \Recent))`) + c.OK(`A003`) + c.C(`A005 FETCH 2 (FLAGS)`) + c.S(`* 2 FETCH (FLAGS (\Answered \Recent))`) + c.OK(`A005`) + }) +} + func TestSetStoreDeletedDoesNotCrash(t *testing.T) { runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { c.C("b001 CREATE saved-messages")