Implement IMAP REPLACE extension, RFC 8508.

REPLACE can be used to update draft messages as you are editing. Instead of
requiring an APPEND and STORE of \Deleted and EXPUNGE. REPLACE works
atomically.

It has a syntax similar to APPEND, just allows you to specify the message to
replace that's in the currently selected mailbox. The regular REPLACE-command
works on a message sequence number, the UID REPLACE commands on a uid. The
destination mailbox, of the updated message, can be different. For example to
move a draft message from the Drafts folder to the Sent folder.

We have to do quite some bookkeeping, e.g. for updating (message) counts for
the mailbox, checking quota, un/retraining the junk filter. During a
synchronizing literal, we check the parameters early and reject if the replace
would fail (eg over quota, bad destination mailbox).
This commit is contained in:
Mechiel Lukkien 2025-02-25 23:27:19 +01:00
parent 1066eb4c9f
commit 92a87acfcb
No known key found for this signature in database
7 changed files with 602 additions and 4 deletions

View File

@ -139,7 +139,7 @@ https://nlnet.nl/project/Mox/.
- Automate DNS management, for setup and maintenance, such as DANE/DKIM key rotation
- Config options for "transactional email domains", for which mox will only
send messages
- More IMAP extensions (UNAUTHENTICATE, REPLACE, NOTIFY, OBJECTID, UIDONLY)
- More IMAP extensions (UNAUTHENTICATE, NOTIFY, OBJECTID, UIDONLY)
- Encrypted storage of files (email messages, TLS keys), also with per account keys
- Recognize common deliverability issues and help postmasters solve them
- Calendaring with CalDAV/iCal

View File

@ -394,3 +394,44 @@ func (c *Conn) UIDMove(uidSet NumSet, dstMailbox string) (untagged []Untagged, r
defer c.recover(&rerr)
return c.Transactf("uid move %s %s", uidSet.String(), astring(dstMailbox))
}
// Replace replaces a message from the currently selected mailbox with a
// new/different version of the message in the named mailbox, which may be the
// same or different than the currently selected mailbox.
//
// Num is a message sequence number. "*" references the last message.
//
// Servers must have announced the REPLACE capability.
func (c *Conn) Replace(msgseq string, mailbox string, msg Append) (untagged []Untagged, result Result, rerr error) {
// todo: parse msgseq, must be nznumber, with a known msgseq. or "*" with at least one message.
return c.replace("replace", msgseq, mailbox, msg)
}
// UIDReplace is like Replace, but operates on a UID instead of message
// sequence number.
func (c *Conn) UIDReplace(uid string, mailbox string, msg Append) (untagged []Untagged, result Result, rerr error) {
// todo: parse uid, must be nznumber, with a known uid. or "*" with at least one message.
return c.replace("uid replace", uid, mailbox, msg)
}
func (c *Conn) replace(cmd string, num string, mailbox string, msg Append) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
// todo: use synchronizing literal for larger messages.
var date string
if msg.Received != nil {
date = ` "` + msg.Received.Format("_2-Jan-2006 15:04:05 -0700") + `"`
}
// todo: only use literal8 if needed, possibly with "UTF8()"
// todo: encode mailbox
c.Commandf("", "%s %s %s (%s)%s ~{%d+}", cmd, num, astring(mailbox), strings.Join(msg.Flags, " "), date, msg.Size)
_, err := io.Copy(c, msg.Data)
c.xcheckf(err, "write message data")
fmt.Fprintf(c.bw, "\r\n")
c.xflush()
return c.Response()
}

View File

@ -39,6 +39,7 @@ const (
CapCompressDeflate Capability = "COMPRESS=DEFLATE" // ../rfc/4978:65
CapListMetadata Capability = "LIST-METADTA" // ../rfc/9590:73
CapMultiAppend Capability = "MULTIAPPEND" // ../rfc/3502:33
CapReplace Capability = "REPLACE" // ../rfc/8508:155
)
// Status is the tagged final result of a command.

392
imapserver/replace.go Normal file
View File

@ -0,0 +1,392 @@
package imapserver
import (
"context"
"errors"
"io"
"os"
"time"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/store"
)
// Replace relaces a message for another, atomically, possibly in another mailbox,
// without needing a sequence of: append message, store \deleted flag, expunge.
//
// State: Selected
func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
// Command: ../rfc/8508:158 ../rfc/8508:198
// Request syntax: ../rfc/8508:471
p.xspace()
star := p.take("*")
var num uint32
if !star {
num = p.xnznumber()
}
p.xspace()
name := p.xmailbox()
// ../rfc/4466:473
p.xspace()
var storeFlags store.Flags
var keywords []string
if p.hasPrefix("(") {
// Error must be a syntax error, to properly abort the connection due to literal.
var err error
storeFlags, keywords, err = store.ParseFlagsKeywords(p.xflagList())
if err != nil {
xsyntaxErrorf("parsing flags: %v", err)
}
p.xspace()
}
var tm time.Time
if p.hasPrefix(`"`) {
tm = p.xdateTime()
p.xspace()
} else {
tm = time.Now()
}
// todo: only with utf8 should we we accept message headers with utf-8. we currently always accept them.
// todo: this is only relevant if we also support the CATENATE extension?
// ../rfc/6855:204
utf8 := p.take("UTF8 (")
if utf8 {
p.xtake("~")
}
// Always allow literal8, for binary extension. ../rfc/4466:486
// For utf8, we already consumed the required ~ above.
size, synclit := p.xliteralSize(!utf8, false)
// Check the request, including old message in database, whether the message fits
// in quota. If a non-nil func is returned, an error was found. Calling the
// function aborts handling this command.
var uidOld store.UID
checkMessage := func(tx *bstore.Tx) func() {
mb, err := c.account.MailboxFind(tx, name)
if err != nil {
return func() { xserverErrorf("finding mailbox: %v", err) }
}
if mb == nil {
xusercodeErrorf("TRYCREATE", "%w", store.ErrUnknownMailbox)
}
// Resolve "*" for UID or message sequence.
if star {
if len(c.uids) == 0 {
return func() { xuserErrorf("cannot use * on empty mailbox") }
}
if isUID {
num = uint32(c.uids[len(c.uids)-1])
} else {
num = uint32(len(c.uids))
}
star = false
}
// Find or verify UID of message to replace.
var seq msgseq
if isUID {
seq = c.sequence(store.UID(num))
if seq <= 0 {
return func() { xuserErrorf("unknown uid %d", num) }
}
} else if num > uint32(len(c.uids)) {
return func() { xuserErrorf("invalid msgseq") }
} else {
seq = msgseq(num)
}
uidOld = c.uids[int(seq)-1]
// Check the message still exists in the database. If it doesn't, it may have been
// deleted just now and we won't check the quota. We'll raise an error later on,
// when we are not possibly reading a sync literal and can respond with unsolicited
// expunges.
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uidOld})
q.FilterEqual("Expunged", false)
om, err := q.Get()
if err == bstore.ErrAbsent {
return nil
}
if err != nil {
return func() { xserverErrorf("get message to replace: %v", err) }
}
delta := size - om.Size
ok, maxSize, err := c.account.CanAddMessageSize(tx, delta)
if err != nil {
return func() { xserverErrorf("check quota: %v", err) }
}
if !ok {
// ../rfc/9208:472
return func() { xusercodeErrorf("OVERQUOTA", "account over maximum total message size %d", maxSize) }
}
return nil
}
var errfn func()
if synclit {
// Check request, if it cannot succeed, fail it now before client is sending the data.
name = xcheckmailboxname(name, true)
c.account.WithRLock(func() {
c.xdbread(func(tx *bstore.Tx) {
errfn = checkMessage(tx)
if errfn != nil {
errfn()
}
})
})
c.writelinef("+ ")
} else {
var err error
name, _, err = store.CheckMailboxName(name, true)
if err != nil {
errfn = func() { xusercodeErrorf("CANNOT", "%s", err) }
} else {
c.account.WithRLock(func() {
c.xdbread(func(tx *bstore.Tx) {
errfn = checkMessage(tx)
})
})
}
}
var file *os.File
var newMsgPath string
var f io.Writer
var committed bool
var oldMsgPath string // To remove on success.
if errfn != nil {
// We got a non-sync literal, we will consume some data, but abort if there's too
// much. We draw the line at 1mb. Client should have used synchronizing literal.
if size > 1000*1000 {
// ../rfc/9051:357 ../rfc/3501:347
err := errors.New("error condition and non-synchronizing literal too big")
bye := "* BYE [ALERT] " + err.Error()
panic(syntaxError{bye, "TOOBIG", err.Error(), err})
}
// Message will not be accepted.
f = io.Discard
} else {
// Read the message into a temporary file.
var err error
file, err = store.CreateMessageTemp(c.log, "imap-replace")
xcheckf(err, "creating temp file for message")
newMsgPath = file.Name()
f = file
defer func() {
if file != nil {
err := file.Close()
c.xsanity(err, "close temporary file for replace")
}
if newMsgPath != "" && !committed {
err := os.Remove(newMsgPath)
c.xsanity(err, "remove temporary file for replace")
}
if committed {
err := os.Remove(oldMsgPath)
c.xsanity(err, "remove old message")
}
}()
}
// Read the message data.
defer c.xtrace(mlog.LevelTracedata)()
mw := message.NewWriter(f)
msize, err := io.Copy(mw, io.LimitReader(c.br, size))
c.xtrace(mlog.LevelTrace) // Restore.
if err != nil {
// Cannot use xcheckf due to %w handling of errIO.
xserverErrorf("reading literal message: %s (%w)", err, errIO)
}
if msize != size {
xserverErrorf("read %d bytes for message, expected %d (%w)", msize, size, errIO)
}
// Finish reading the command.
line := c.readline(false)
p = newParser(line, c)
if utf8 {
p.xtake(")")
}
p.xempty()
// If an error was found earlier, abort the command now that we've read the message.
if errfn != nil {
errfn()
}
var oldMsgExpunged bool
var om, nm store.Message
var mbSrc, mbDst store.Mailbox // Src and dst mailboxes can be different. ../rfc/8508:263
var pendingChanges []store.Change
c.account.WithWLock(func() {
var changes []store.Change
c.xdbwrite(func(tx *bstore.Tx) {
mbSrc = c.xmailboxID(tx, c.mailboxID)
// Get old message. If it has been expunged, we should have a pending change for
// it. We'll send untagged responses and fail the command.
var err error
qom := bstore.QueryTx[store.Message](tx)
qom.FilterNonzero(store.Message{MailboxID: mbSrc.ID, UID: uidOld})
om, err = qom.Get()
xcheckf(err, "get old message to replace from database")
if om.Expunged {
oldMsgExpunged = true
return
}
// Check quota. Even if the delta is negative, the quota may have changed.
ok, maxSize, err := c.account.CanAddMessageSize(tx, mw.Size-om.Size)
xcheckf(err, "checking quota")
if !ok {
// ../rfc/9208:472
xusercodeErrorf("OVERQUOTA", "account over maximum total message size %d", maxSize)
}
modseq, err := c.account.NextModSeq(tx)
xcheckf(err, "get next mod seq")
// Subtract counts for message from source mailbox.
mbSrc.Sub(om.MailboxCounts())
// Remove message recipients for old message.
_, err = bstore.QueryTx[store.Recipient](tx).FilterNonzero(store.Recipient{MessageID: om.ID}).Delete()
xcheckf(err, "removing message recipients")
// Subtract size of old message from account.
err = c.account.AddMessageSize(c.log, tx, -om.Size)
xcheckf(err, "updating disk usage")
// Undo any junk filter training for the old message.
om.Junk = false
om.Notjunk = false
err = c.account.RetrainMessages(context.TODO(), c.log, tx, []store.Message{om}, false)
xcheckf(err, "untraining expunged messages")
// Mark old message expunged.
om.ModSeq = modseq
om.PrepareExpunge()
err = tx.Update(&om)
xcheckf(err, "mark old message as expunged")
// Update source mailbox.
mbSrc.ModSeq = modseq
err = tx.Update(&mbSrc)
xcheckf(err, "updating source mailbox counts")
// The destination mailbox may be the same as source (currently selected), but
// doesn't have to be.
mbDst = c.xmailbox(tx, name, "TRYCREATE")
// Ensure keywords of message are present in destination mailbox.
var mbKwChanged bool
mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, keywords)
if mbKwChanged {
changes = append(changes, mbDst.ChangeKeywords())
}
// Make new message to deliver.
nm = store.Message{
MailboxID: mbDst.ID,
MailboxOrigID: mbDst.ID,
Received: tm,
Flags: storeFlags,
Keywords: keywords,
Size: mw.Size,
ModSeq: modseq,
CreateSeq: modseq,
}
// Add counts about new message to mailbox.
mbDst.Add(nm.MailboxCounts())
// Update mailbox before delivering, which updates uidnext which we mustn't overwrite.
mbDst.ModSeq = modseq
err = tx.Update(&mbDst)
xcheckf(err, "updating destination mailbox counts")
err = c.account.DeliverMessage(c.log, tx, &nm, file, true, false, false, true)
xcheckf(err, "delivering message")
// Update path to what is stored in the account. We may still have to clean it up on errors.
newMsgPath = c.account.MessagePath(nm.ID)
oldMsgPath = c.account.MessagePath(om.ID)
})
// 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()
}
if oldMsgExpunged {
return
}
// Success, make sure messages aren't cleaned up anymore.
committed = true
// Broadcast the change to other connections.
changes = append(changes,
store.ChangeRemoveUIDs{MailboxID: om.MailboxID, UIDs: []store.UID{om.UID}, ModSeq: om.ModSeq},
nm.ChangeAddUID(),
mbDst.ChangeCounts(),
)
if mbSrc.ID != mbDst.ID {
changes = append(changes, mbSrc.ChangeCounts())
}
c.broadcast(changes)
})
// Must update our msgseq/uids tracking with latest pending changes.
c.applyChanges(pendingChanges, false)
// If we couldn't find the message, send a NO response. We've just applied pending
// changes, which should have expunged the absent message.
if oldMsgExpunged {
xuserErrorf("message to be replaced has been expunged")
}
// If the destination mailbox is our currently selected mailbox, we register and
// announce the new message.
if mbDst.ID == c.mailboxID {
c.uidAppend(nm.UID)
// We send an untagged OK with APPENDUID, for sane bookkeeping in clients. ../rfc/8508:401
c.bwritelinef("* OK [APPENDUID %d %d] ", mbDst.UIDValidity, nm.UID)
c.bwritelinef("* %d EXISTS", len(c.uids))
}
// We must return vanished instead of expunge, and also highestmodseq, when qresync
// was enabled. ../rfc/8508:422 ../rfc/7162:1883
qresync := c.enabled[capQresync]
// Now that we are in sync with msgseq, we can find our old msgseq and say it is
// expunged or vanished. ../rfc/7162:1900
omsgseq := c.xsequence(om.UID)
c.sequenceRemove(omsgseq, om.UID)
if qresync {
c.bwritelinef("* VANISHED %d", om.UID)
// ../rfc/7162:1916
} else {
c.bwritelinef("* %d EXPUNGE", omsgseq)
}
c.writeresultf("%s OK [HIGHESTMODSEQ %d] replaced", tag, nm.ModSeq.Client())
}

150
imapserver/replace_test.go Normal file
View File

@ -0,0 +1,150 @@
package imapserver
import (
"testing"
"github.com/mjl-/mox/imapclient"
)
func TestReplace(t *testing.T) {
defer mockUIDValidity()()
tc := start(t)
defer tc.close()
tc2 := startNoSwitchboard(t)
defer tc2.close()
tc.client.Login("mjl@mox.example", password0)
tc.client.Select("inbox")
// Append 3 messages, remove first. Leaves msgseq 1,2 with uid 2,3.
tc.client.Append("inbox", makeAppend(exampleMsg), makeAppend(exampleMsg), makeAppend(exampleMsg))
tc.client.StoreFlagsSet("1", true, `\deleted`)
tc.client.Expunge()
tc2.client.Login("mjl@mox.example", password0)
tc2.client.Select("inbox")
// Replace last message (msgseq 2, uid 3) in same mailbox.
tc.lastUntagged, tc.lastResult, tc.lastErr = tc.client.Replace("2", "INBOX", makeAppend(searchMsg))
tcheck(tc.t, tc.lastErr, "read imap response")
tc.xuntagged(
imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "APPENDUID", CodeArg: imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("4")}, More: ""}},
imapclient.UntaggedExists(3),
imapclient.UntaggedExpunge(2),
)
tc.xcodeArg(imapclient.CodeHighestModSeq(6))
// Check that other client sees Exists and Expunge.
tc2.transactf("ok", "noop")
tc2.xuntagged(
imapclient.UntaggedExpunge(2),
imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), imapclient.FetchFlags(nil)}},
)
// Enable qresync, replace uid 2 (msgseq 1) to different mailbox, see that we get vanished instead of expunged.
tc.transactf("ok", "enable qresync")
tc.lastUntagged, tc.lastResult, tc.lastErr = tc.client.UIDReplace("2", "INBOX", makeAppend(searchMsg))
tcheck(tc.t, tc.lastErr, "read imap response")
tc.xuntagged(
imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "APPENDUID", CodeArg: imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("5")}, More: ""}},
imapclient.UntaggedExists(3),
imapclient.UntaggedVanished{UIDs: xparseNumSet("2")},
)
tc.xcodeArg(imapclient.CodeHighestModSeq(7))
// Leftover data.
tc.transactf("bad", "replace 1 inbox () {6+}\r\ntest\r\n ")
}
func TestReplaceBigNonsyncLit(t *testing.T) {
tc := start(t)
defer tc.close()
tc.client.Login("mjl@mox.example", password0)
tc.client.Select("inbox")
// Adding a message >1mb with non-sync literal to non-existent mailbox should abort entire connection.
tc.transactf("bad", "replace 12345 inbox {2000000+}")
tc.xuntagged(
imapclient.UntaggedBye{Code: "ALERT", More: "error condition and non-synchronizing literal too big"},
)
tc.xcode("TOOBIG")
}
func TestReplaceQuota(t *testing.T) {
// with quota limit
tc := startArgs(t, true, false, true, true, "limit")
defer tc.close()
tc.client.Login("limit@mox.example", password0)
tc.client.Select("inbox")
tc.client.Append("inbox", makeAppend("x"))
// Synchronizing literal, we get failure immediately.
tc.transactf("no", "replace 1 inbox {6}\r\n")
tc.xcode("OVERQUOTA")
// Synchronizing literal to non-existent mailbox, we get failure immediately.
tc.transactf("no", "replace 1 badbox {6}\r\n")
tc.xcode("TRYCREATE")
buf := make([]byte, 4000, 4002)
for i := range buf {
buf[i] = 'x'
}
buf = append(buf, "\r\n"...)
// Non-synchronizing literal. We get to write our data.
tc.client.Commandf("", "replace 1 inbox ~{4000+}")
_, err := tc.client.Write(buf)
tc.check(err, "write replace message")
tc.response("no")
tc.xcode("OVERQUOTA")
// Non-synchronizing literal to bad mailbox.
tc.client.Commandf("", "replace 1 badbox {4000+}")
_, err = tc.client.Write(buf)
tc.check(err, "write replace message")
tc.response("no")
tc.xcode("TRYCREATE")
}
func TestReplaceExpunged(t *testing.T) {
tc := start(t)
defer tc.close()
tc.client.Login("mjl@mox.example", password0)
tc.client.Select("inbox")
tc.client.Append("inbox", makeAppend(exampleMsg))
// We start the command, but don't write data yet.
tc.client.Commandf("", "replace 1 inbox {4000}")
// Get in with second client and remove the message we are replacing.
tc2 := startNoSwitchboard(t)
defer tc2.close()
tc2.client.Login("mjl@mox.example", password0)
tc2.client.Select("inbox")
tc2.client.StoreFlagsSet("1", true, `\Deleted`)
tc2.client.Expunge()
tc2.client.Unselect()
tc2.client.Close()
// Now continue trying to replace the message. We should get an error and an expunge.
tc.readprefixline("+ ")
buf := make([]byte, 4000, 4002)
for i := range buf {
buf[i] = 'x'
}
buf = append(buf, "\r\n"...)
_, err := tc.client.Write(buf)
tc.check(err, "write replace message")
tc.response("no")
tc.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{`\Deleted`}}},
imapclient.UntaggedExpunge(1),
)
}

View File

@ -165,12 +165,13 @@ var authFailDelay = time.Second // After authentication failure.
// COMPRESS=DEFLATE: ../rfc/4978
// LIST-METADATA: ../rfc/9590
// MULTIAPPEND: ../rfc/3502
// REPLACE: ../rfc/8508
//
// We always announce support for SCRAM PLUS-variants, also on connections without
// TLS. The client should not be selecting PLUS variants on non-TLS connections,
// instead opting to do the bare SCRAM variant without indicating the server claims
// to support the PLUS variant (skipping the server downgrade detection check).
const serverCapabilities = "IMAP4rev2 IMAP4rev1 ENABLE LITERAL+ IDLE SASL-IR BINARY UNSELECT UIDPLUS ESEARCH SEARCHRES MOVE UTF8=ACCEPT LIST-EXTENDED SPECIAL-USE CREATE-SPECIAL-USE LIST-STATUS AUTH=SCRAM-SHA-256-PLUS AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1-PLUS AUTH=SCRAM-SHA-1 AUTH=CRAM-MD5 ID APPENDLIMIT=9223372036854775807 CONDSTORE QRESYNC STATUS=SIZE QUOTA QUOTA=RES-STORAGE METADATA SAVEDATE WITHIN NAMESPACE COMPRESS=DEFLATE LIST-METADATA MULTIAPPEND"
const serverCapabilities = "IMAP4rev2 IMAP4rev1 ENABLE LITERAL+ IDLE SASL-IR BINARY UNSELECT UIDPLUS ESEARCH SEARCHRES MOVE UTF8=ACCEPT LIST-EXTENDED SPECIAL-USE CREATE-SPECIAL-USE LIST-STATUS AUTH=SCRAM-SHA-256-PLUS AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1-PLUS AUTH=SCRAM-SHA-1 AUTH=CRAM-MD5 ID APPENDLIMIT=9223372036854775807 CONDSTORE QRESYNC STATUS=SIZE QUOTA QUOTA=RES-STORAGE METADATA SAVEDATE WITHIN NAMESPACE COMPRESS=DEFLATE LIST-METADATA MULTIAPPEND REPLACE"
type conn struct {
cid int64
@ -268,7 +269,7 @@ var (
commandsStateAny = stateCommands("capability", "noop", "logout", "id")
commandsStateNotAuthenticated = stateCommands("starttls", "authenticate", "login")
commandsStateAuthenticated = stateCommands("enable", "select", "examine", "create", "delete", "rename", "subscribe", "unsubscribe", "list", "namespace", "status", "append", "idle", "lsub", "getquotaroot", "getquota", "getmetadata", "setmetadata", "compress")
commandsStateSelected = stateCommands("close", "unselect", "expunge", "search", "fetch", "store", "copy", "move", "uid expunge", "uid search", "uid fetch", "uid store", "uid copy", "uid move")
commandsStateSelected = stateCommands("close", "unselect", "expunge", "search", "fetch", "store", "copy", "move", "uid expunge", "uid search", "uid fetch", "uid store", "uid copy", "uid move", "replace", "uid replace")
)
var commands = map[string]func(c *conn, tag, cmd string, p *parser){
@ -320,6 +321,9 @@ var commands = map[string]func(c *conn, tag, cmd string, p *parser){
"uid copy": (*conn).cmdUIDCopy,
"move": (*conn).cmdMove,
"uid move": (*conn).cmdUIDMove,
// ../rfc/8508:289
"replace": (*conn).cmdReplace,
"uid replace": (*conn).cmdUIDReplace,
}
var errIO = errors.New("io error") // For read/write errors and errors that should close the connection.
@ -4001,6 +4005,16 @@ func (c *conn) cmdUIDMove(tag, cmd string, p *parser) {
c.cmdxMove(true, tag, cmd, p)
}
// State: Selected
func (c *conn) cmdReplace(tag, cmd string, p *parser) {
c.cmdxReplace(false, tag, cmd, p)
}
// State: Selected
func (c *conn) cmdUIDReplace(tag, cmd string, p *parser) {
c.cmdxReplace(true, tag, cmd, p)
}
func (c *conn) gatherCopyMoveUIDs(isUID bool, nums numSet) ([]store.UID, []any) {
// Gather uids, then sort so we can return a consistently simple and hard to
// misinterpret COPYUID/MOVEUID response. It seems safer to have UIDs in ascending

View File

@ -231,7 +231,7 @@ https://www.iana.org/assignments/message-headers/message-headers.xhtml
8440 ? - IMAP4 Extension for Returning MYRIGHTS Information in Extended LIST
8457 No - IMAP "$Important" Keyword and "\Important" Special-Use Attribute
8474 Roadmap - IMAP Extension for Object Identifiers
8508 Roadmap - IMAP REPLACE Extension
8508 Yes - IMAP REPLACE Extension
8514 Yes - Internet Message Access Protocol (IMAP) - SAVEDATE Extension
8970 Roadmap - IMAP4 Extension: Message Preview Generation
9208 Partial - IMAP QUOTA Extension