diff --git a/README.md b/README.md index a9dc0b2..9ed124d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/imapclient/cmds.go b/imapclient/cmds.go index 5d58751..86f6bfa 100644 --- a/imapclient/cmds.go +++ b/imapclient/cmds.go @@ -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() +} diff --git a/imapclient/protocol.go b/imapclient/protocol.go index df15762..5e726ff 100644 --- a/imapclient/protocol.go +++ b/imapclient/protocol.go @@ -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. diff --git a/imapserver/replace.go b/imapserver/replace.go new file mode 100644 index 0000000..29f999f --- /dev/null +++ b/imapserver/replace.go @@ -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()) +} diff --git a/imapserver/replace_test.go b/imapserver/replace_test.go new file mode 100644 index 0000000..5a0a691 --- /dev/null +++ b/imapserver/replace_test.go @@ -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), + ) +} diff --git a/imapserver/server.go b/imapserver/server.go index 5b72471..9b42a0a 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -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 diff --git a/rfc/index.txt b/rfc/index.txt index f6f76af..23b770d 100644 --- a/rfc/index.txt +++ b/rfc/index.txt @@ -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