imapserver: Don't keep account write-locked during IMAP FETCH command

We effectively held the account write-locked by using a writable transaction
while processing the FETCH command. We did this because we may have to update
\Seen flags, for non-PEEK attribute fetches. This meant other FETCHes would
block, and other write access to the account too.

We now read the messages in a read-only transaction. We gather messages that
need marking as \Seen, and make that change in one (much shorter) database
transaction at the end of the FETCH command.

In practice, it doesn't seem too sensible to mark messages as seen
automatically. Most clients probably use the PEEK-variant of attribute fetches.

Related to issue #128.
This commit is contained in:
Mechiel Lukkien 2025-02-27 09:35:14 +01:00
parent caaace403a
commit b822533df3
No known key found for this signature in database
4 changed files with 215 additions and 138 deletions

View File

@ -4,6 +4,7 @@ package imapserver
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -25,18 +26,16 @@ import (
// functions to handle fetch attribute requests are defined on fetchCmd. // functions to handle fetch attribute requests are defined on fetchCmd.
type fetchCmd struct { type fetchCmd struct {
conn *conn conn *conn
mailboxID int64 isUID bool // If this is a UID FETCH command.
uid store.UID rtx *bstore.Tx // Read-only transaction, kept open while processing all messages.
tx *bstore.Tx // Writable tx, for storing message when first parsed as mime parts. updateSeen []int64 // IDs of messages to mark as seen, after processing all messages.
changes []store.Change // For updated Seen flag. hasChangedSince bool // Whether CHANGEDSINCE was set. Enables MODSEQ in response.
expungeIssued bool // Set if any message cannot be read. Can happen for expunged messages.
uid store.UID // UID currently processing.
markSeen bool markSeen bool
needFlags bool needFlags bool
needModseq bool // Whether untagged responses needs modseq. needModseq bool // Whether untagged responses needs modseq.
expungeIssued bool // Set if a message cannot be read. Can happen for expunged messages.
modseq store.ModSeq // Initialized on first change, for marking messages as seen.
isUID bool // If this is a UID FETCH command.
hasChangedSince bool // Whether CHANGEDSINCE was set. Enables MODSEQ in response.
deltaCounts store.MailboxCounts // By marking \Seen, the number of unread/unseen messages will go down. We update counts at the end.
// Loaded when first needed, closed when message was processed. // Loaded when first needed, closed when message was processed.
m *store.Message // Message currently being processed. m *store.Message // Message currently being processed.
@ -125,31 +124,49 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
} }
p.xempty() p.xempty()
// We don't use c.account.WithRLock because we write to the client while reading messages. // We only keep a wlock, only for initial checks and listing the uids. Then we
// We get the rlock, then we check the mailbox, release the lock and read the messages. // unlock and work without a lock. So changes to the store can happen, and we need
// The db transaction still locks out any changes to the database... // to deal with that. If we need to mark messages as seen, we do so after
c.account.RLock() // processing the fetch for all messages, in a single write transaction. We don't
runlock := c.account.RUnlock // send untagged changes for those \seen flag changes before finishing this
// Note: we call runlock in a closure because we replace it below. // command, because we have to sequence all changes properly, and since we don't
defer func() { // (want to) hold a wlock while processing messages (can be many!), other changes
runlock() // may have happened to the store. So instead, we'll silently mark messages as seen
}() // (the client should know this is happening anyway!), then broadcast the changes
// to everyone, including ourselves. A noop/idle command that may come next will
var vanishedUIDs []store.UID // return the \seen flag changes, in the correct order, with the correct modseq. We
cmd := &fetchCmd{conn: c, mailboxID: c.mailboxID, isUID: isUID, hasChangedSince: haveChangedSince} // also cannot just apply pending changes while processing. It is not allowed at
c.xdbwrite(func(tx *bstore.Tx) { // all for non-uid-fetch. It would also make life more complicated, e.g. we would
cmd.tx = tx // perhaps have to check if newly added messages also match uid fetch set that was
// requested.
// Ensure the mailbox still exists.
mb := c.xmailboxID(tx, c.mailboxID)
var uids []store.UID var uids []store.UID
var vanishedUIDs []store.UID
cmd := &fetchCmd{conn: c, isUID: isUID, hasChangedSince: haveChangedSince}
defer func() {
if cmd.rtx == nil {
return
}
err := cmd.rtx.Rollback()
c.log.Check(err, "rollback rtx")
cmd.rtx = nil
}()
c.account.WithRLock(func() {
var err error
cmd.rtx, err = c.account.DB.Begin(context.TODO(), false)
cmd.xcheckf(err, "begin transaction")
// Ensure the mailbox still exists.
c.xmailboxID(cmd.rtx, c.mailboxID)
// With changedSince, the client is likely asking for a small set of changes. Use a // With changedSince, the client is likely asking for a small set of changes. Use a
// database query to trim down the uids we need to look at. // database query to trim down the uids we need to look at.
// ../rfc/7162:871 // ../rfc/7162:871
if changedSince > 0 { if changedSince > 0 {
q := bstore.QueryTx[store.Message](tx) q := bstore.QueryTx[store.Message](cmd.rtx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID}) q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
q.FilterGreater("ModSeq", store.ModSeqFromClient(changedSince)) q.FilterGreater("ModSeq", store.ModSeqFromClient(changedSince))
if !vanished { if !vanished {
@ -176,10 +193,16 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
} }
// Send vanished for all missing requested UIDs. ../rfc/7162:1718 // Send vanished for all missing requested UIDs. ../rfc/7162:1718
if vanished { if !vanished {
delModSeq, err := c.account.HighestDeletedModSeq(tx) return
}
delModSeq, err := c.account.HighestDeletedModSeq(cmd.rtx)
xcheckf(err, "looking up highest deleted modseq") xcheckf(err, "looking up highest deleted modseq")
if changedSince < delModSeq.Client() { if changedSince >= delModSeq.Client() {
return
}
// First sort the uids we already found, for fast lookup. // First sort the uids we already found, for fast lookup.
sort.Slice(vanishedUIDs, func(i, j int) bool { sort.Slice(vanishedUIDs, func(i, j int) bool {
return vanishedUIDs[i] < vanishedUIDs[j] return vanishedUIDs[i] < vanishedUIDs[j]
@ -210,12 +233,8 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
} }
} }
vanishedUIDs = append(vanishedUIDs, maps.Keys(more)...) vanishedUIDs = append(vanishedUIDs, maps.Keys(more)...)
} })
} // We are continuing without a lock, working off our snapshot of uids to process.
// Release the account lock.
runlock()
runlock = func() {} // Prevent defer from unlocking again.
// First report all vanished UIDs. ../rfc/7162:1714 // First report all vanished UIDs. ../rfc/7162:1714
if len(vanishedUIDs) > 0 { if len(vanishedUIDs) > 0 {
@ -231,26 +250,74 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
} }
} }
for _, uid := range uids { for _, cmd.uid = range uids {
cmd.uid = uid cmd.conn.log.Debug("processing uid", slog.Any("uid", cmd.uid))
cmd.conn.log.Debug("processing uid", slog.Any("uid", uid))
cmd.process(atts) cmd.process(atts)
} }
var zeromc store.MailboxCounts // We've returned all data. Now we mark messages as seen in one go, in a new write
if cmd.deltaCounts != zeromc || cmd.modseq != 0 { // transaction. We don't send untagged messages for the changes, since there may be
mb.Add(cmd.deltaCounts) // Unseen/Unread will be <= 0. // unprocessed pending changes. Instead, we broadcast them to ourselve too, so a
mb.ModSeq = cmd.modseq // next noop/idle will return the flags to the client.
err := tx.Update(&mb)
xcheckf(err, "updating mailbox counts") err := cmd.rtx.Rollback()
cmd.changes = append(cmd.changes, mb.ChangeCounts()) c.log.Check(err, "fetch read tx rollback")
// No need to update account total message size. cmd.rtx = nil
// ../rfc/9051:4432 We mark all messages that need it as seen at the end of the
// command, in a single transaction.
if len(cmd.updateSeen) > 0 {
c.account.WithWLock(func() {
changes := make([]store.Change, 0, len(cmd.updateSeen)+1)
c.xdbwrite(func(wtx *bstore.Tx) {
mb := store.Mailbox{ID: c.mailboxID}
err = wtx.Get(&mb)
xcheckf(err, "get mailbox for updating counts after marking as seen")
var modseq store.ModSeq
for _, id := range cmd.updateSeen {
m := store.Message{ID: id}
err := wtx.Get(&m)
xcheckf(err, "get message")
if m.Expunged {
// Message has been deleted in the mean time.
cmd.expungeIssued = true
continue
} }
if m.Seen {
// Message already marked as seen by another process.
continue
}
if modseq == 0 {
modseq, err = c.account.NextModSeq(wtx)
xcheckf(err, "get next mod seq")
}
oldFlags := m.Flags
mb.Sub(m.MailboxCounts())
m.Seen = true
mb.Add(m.MailboxCounts())
changes = append(changes, m.ChangeFlags(oldFlags))
m.ModSeq = modseq
err = wtx.Update(&m)
xcheckf(err, "mark message as seen")
}
changes = append(changes, mb.ChangeCounts())
mb.ModSeq = modseq
err = wtx.Update(&mb)
xcheckf(err, "update mailbox with counts and modseq")
}) })
if len(cmd.changes) > 0 { // Broadcast these changes also to ourselves, so we'll send the updated flags, but
// Broadcast seen updates to other connections. // in the correct order, after other changes.
c.broadcast(cmd.changes) store.BroadcastChanges(c.account, changes)
})
} }
if cmd.expungeIssued { if cmd.expungeIssued {
@ -261,22 +328,13 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
} }
} }
func (cmd *fetchCmd) xmodseq() store.ModSeq {
if cmd.modseq == 0 {
var err error
cmd.modseq, err = cmd.conn.account.NextModSeq(cmd.tx)
cmd.xcheckf(err, "assigning next modseq")
}
return cmd.modseq
}
func (cmd *fetchCmd) xensureMessage() *store.Message { func (cmd *fetchCmd) xensureMessage() *store.Message {
if cmd.m != nil { if cmd.m != nil {
return cmd.m return cmd.m
} }
q := bstore.QueryTx[store.Message](cmd.tx) q := bstore.QueryTx[store.Message](cmd.rtx)
q.FilterNonzero(store.Message{MailboxID: cmd.mailboxID, UID: cmd.uid}) q.FilterNonzero(store.Message{MailboxID: cmd.conn.mailboxID, UID: cmd.uid})
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
m, err := q.Get() m, err := q.Get()
cmd.xcheckf(err, "get message for uid %d", cmd.uid) cmd.xcheckf(err, "get message for uid %d", cmd.uid)
@ -344,16 +402,7 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
if cmd.markSeen { if cmd.markSeen {
m := cmd.xensureMessage() m := cmd.xensureMessage()
cmd.deltaCounts.Sub(m.MailboxCounts()) cmd.updateSeen = append(cmd.updateSeen, m.ID)
origFlags := m.Flags
m.Seen = true
cmd.deltaCounts.Add(m.MailboxCounts())
m.ModSeq = cmd.xmodseq()
err := cmd.tx.Update(m)
xcheckf(err, "marking message as seen")
// No need to update account total message size.
cmd.changes = append(cmd.changes, m.ChangeFlags(origFlags))
} }
if cmd.needFlags { if cmd.needFlags {
@ -376,7 +425,7 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
// other mentioning of cases elsewhere in the RFC would be too superfluous. // other mentioning of cases elsewhere in the RFC would be too superfluous.
// //
// ../rfc/7162:877 ../rfc/7162:388 ../rfc/7162:909 ../rfc/7162:1426 // ../rfc/7162:877 ../rfc/7162:388 ../rfc/7162:909 ../rfc/7162:1426
if cmd.needModseq || cmd.hasChangedSince || cmd.conn.enabled[capQresync] && (cmd.isUID || cmd.markSeen) { if cmd.needModseq || cmd.hasChangedSince || cmd.conn.enabled[capQresync] && cmd.isUID {
m := cmd.xensureMessage() m := cmd.xensureMessage()
data = append(data, bare("MODSEQ"), listspace{bare(fmt.Sprintf("%d", m.ModSeq.Client()))}) data = append(data, bare("MODSEQ"), listspace{bare(fmt.Sprintf("%d", m.ModSeq.Client()))})
} }
@ -395,6 +444,7 @@ func (cmd *fetchCmd) xprocessAtt(a fetchAtt) []token {
case "UID": case "UID":
// Always present. // Always present.
return nil return nil
case "ENVELOPE": case "ENVELOPE":
_, part := cmd.xensureParsed() _, part := cmd.xensureParsed()
envelope := xenvelope(part) envelope := xenvelope(part)

View File

@ -98,26 +98,37 @@ func TestFetch(t *testing.T) {
// Should be returned unmodified, because there is no content-transfer-encoding. // Should be returned unmodified, because there is no content-transfer-encoding.
tc.transactf("ok", "fetch 1 binary[]") tc.transactf("ok", "fetch 1 binary[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.transactf("ok", "fetch 1 binary[1]") tc.transactf("ok", "fetch 1 binary[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypart1}}) // Seen flag not changed. tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypart1}}) // Seen flag not changed.
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[]<1.1>") tc.transactf("ok", "uid fetch 1 binary[]<1.1>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartial1, flagsSeen}}) tc.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartial1, noflags}},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}, // For UID FETCH, we get the flags during the command.
)
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[1]<1.1>") tc.transactf("ok", "fetch 1 binary[1]<1.1>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartpartial1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartpartial1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[]<10000.10001>") tc.transactf("ok", "fetch 1 binary[]<10000.10001>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binaryend1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binaryend1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[1]<10000.10001>") tc.transactf("ok", "fetch 1 binary[1]<10000.10001>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartend1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartend1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary.size[]") tc.transactf("ok", "fetch 1 binary.size[]")
@ -128,29 +139,43 @@ func TestFetch(t *testing.T) {
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[]") tc.transactf("ok", "fetch 1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.transactf("ok", "fetch 1 body[]<1.2>") tc.transactf("ok", "fetch 1 body[]<1.2>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyoff1}}) // Already seen. tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyoff1}}) // Already seen.
tc.transactf("ok", "noop")
tc.xuntagged() // Already seen.
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]") tc.transactf("ok", "fetch 1 body[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodypart1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodypart1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]<1.2>") tc.transactf("ok", "fetch 1 body[1]<1.2>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1off1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1off1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]<100000.100000>") tc.transactf("ok", "fetch 1 body[1]<100000.100000>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyend1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyend1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[header]") tc.transactf("ok", "fetch 1 body[header]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyheader1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyheader1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[text]") tc.transactf("ok", "fetch 1 body[text]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodytext1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodytext1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
// equivalent to body.peek[header], ../rfc/3501:3183 // equivalent to body.peek[header], ../rfc/3501:3183
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
@ -160,12 +185,16 @@ func TestFetch(t *testing.T) {
// equivalent to body[text], ../rfc/3501:3199 // equivalent to body[text], ../rfc/3501:3199
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 rfc822.text") tc.transactf("ok", "fetch 1 rfc822.text")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfctext1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfctext1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
// equivalent to body[], ../rfc/3501:3179 // equivalent to body[], ../rfc/3501:3179
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 rfc822") tc.transactf("ok", "fetch 1 rfc822")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfc1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfc1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
// With PEEK, we should not get the \Seen flag. // With PEEK, we should not get the \Seen flag.
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
@ -194,7 +223,9 @@ func TestFetch(t *testing.T) {
tc.transactf("bad", "fetch 2 body[]") tc.transactf("bad", "fetch 2 body[]")
tc.transactf("ok", "fetch 1:1 body[]") tc.transactf("ok", "fetch 1:1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, flagsSeen}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, noflags}})
tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
// UID fetch // UID fetch
tc.transactf("ok", "uid fetch 1 body[]") tc.transactf("ok", "uid fetch 1 body[]")

View File

@ -337,9 +337,7 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
}) })
// Fetch pending changes, possibly with new UIDs, so we can apply them before adding our own new UID. // Fetch pending changes, possibly with new UIDs, so we can apply them before adding our own new UID.
if c.comm != nil {
pendingChanges = c.comm.Get() pendingChanges = c.comm.Get()
}
if oldMsgExpunged { if oldMsgExpunged {
return return

View File

@ -3565,9 +3565,7 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) {
committed = true committed = true
// Fetch pending changes, possibly with new UIDs, so we can apply them before adding our own new UID. // Fetch pending changes, possibly with new UIDs, so we can apply them before adding our own new UID.
if c.comm != nil {
pendingChanges = c.comm.Get() pendingChanges = c.comm.Get()
}
// Broadcast the change to other connections. // Broadcast the change to other connections.
for _, a := range appends { for _, a := range appends {