mirror of
https://github.com/mjl-/mox.git
synced 2025-07-14 06:54:37 +03:00
Improve expunged message/UID tracking in IMAP sessions, track synchronization history for mailboxes/annotations.
Keeping the message files around, and the message details in the database, is useful for IMAP sessions that haven't seen/processed the removal of a message yet and try to fetch it. Before, we would return errors. Similarly, a session that has a mailbox selected that is removed can (at least in theory) still read messages. The mechanics to do this need keeping removed mailboxes around too. JMAP needs that anyway, so we now keep modseq/createseq/expunged history for mailboxes too. And while we're at it, for annotations as well. For future JMAP support, we now also keep the mailbox parent id around for a mailbox, with an upgrade step to set the field for existing mailboxes and fixing up potential missing parents (which could possibly have happened in an obscure corner case that I doubt anyone ran into).
This commit is contained in:
@ -13,10 +13,10 @@ func TestAppend(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t) // note: without switchboard because this connection will break during tests.
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc3 := startNoSwitchboard(t)
|
||||
defer tc3.close()
|
||||
defer tc3.closeNoWait()
|
||||
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Select("inbox")
|
||||
@ -31,19 +31,22 @@ func TestAppend(t *testing.T) {
|
||||
// Syntax error for line ending in literal causes connection abort.
|
||||
tc2.transactf("bad", "append inbox (\\Badflag) {1+}\r\nx") // Unknown flag.
|
||||
tc2 = startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
tc2.transactf("bad", "append inbox () \"bad time\" {1+}\r\nx") // Bad time.
|
||||
tc2 = startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
tc2.transactf("no", "append nobox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" {1}")
|
||||
tc2.xcode("TRYCREATE")
|
||||
|
||||
tc2.transactf("no", "append expungebox (\\Seen) {1}")
|
||||
tc2.xcode("TRYCREATE")
|
||||
|
||||
tc2.transactf("ok", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
tc2.xuntagged(imapclient.UntaggedExists(1))
|
||||
tc2.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("1")})
|
||||
|
@ -85,6 +85,8 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
|
||||
// unmodified. Those messages have modseq 0 in the database. We use append for
|
||||
// convenience, then adjust the records in the database.
|
||||
// We have a workaround below to prevent triggering the consistency checker.
|
||||
tc.account.SetSkipMessageModSeqZeroCheck(true)
|
||||
defer tc.account.SetSkipMessageModSeqZeroCheck(false)
|
||||
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
@ -100,13 +102,13 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
|
||||
|
||||
// tc2 is a client without condstore, so no modseq responses.
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
// tc3 is a client with condstore, so with modseq responses.
|
||||
tc3 := startNoSwitchboard(t)
|
||||
defer tc3.close()
|
||||
defer tc3.closeNoWait()
|
||||
tc3.client.Login("mjl@mox.example", password0)
|
||||
tc3.client.Enable(capability)
|
||||
tc3.client.Select("inbox")
|
||||
@ -124,7 +126,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
|
||||
|
||||
tc.transactf("ok", "Append otherbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
tc.xuntagged()
|
||||
tc.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 2, UIDs: xparseUIDRange("1")})
|
||||
tc.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 3, UIDs: xparseUIDRange("1")})
|
||||
|
||||
tc.transactf("ok", "Append inbox () \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
tc.xuntagged(imapclient.UntaggedExists(5))
|
||||
@ -353,11 +355,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
|
||||
xtc := startNoSwitchboard(t)
|
||||
// We have modified modseq & createseq to 0 above for testing that case. Don't
|
||||
// trigger the consistency checker.
|
||||
store.CheckConsistencyOnClose = false
|
||||
defer func() {
|
||||
xtc.close()
|
||||
store.CheckConsistencyOnClose = true
|
||||
}()
|
||||
defer xtc.closeNoWait()
|
||||
xtc.client.Login("mjl@mox.example", password0)
|
||||
fn(xtc)
|
||||
tagcount++
|
||||
@ -444,13 +442,13 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
|
||||
// and untagged fetch with modseq in destination mailbox.
|
||||
// tc2o is a client without condstore, so no modseq responses.
|
||||
tc2o := startNoSwitchboard(t)
|
||||
defer tc2o.close()
|
||||
defer tc2o.closeNoWait()
|
||||
tc2o.client.Login("mjl@mox.example", password0)
|
||||
tc2o.client.Select("otherbox")
|
||||
|
||||
// tc3o is a client with condstore, so with modseq responses.
|
||||
tc3o := startNoSwitchboard(t)
|
||||
defer tc3o.close()
|
||||
defer tc3o.closeNoWait()
|
||||
tc3o.client.Login("mjl@mox.example", password0)
|
||||
tc3o.client.Enable(capability)
|
||||
tc3o.client.Select("otherbox")
|
||||
@ -484,14 +482,9 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
|
||||
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), noflags, imapclient.FetchModSeq(clientModseq)}},
|
||||
)
|
||||
|
||||
// Restore valid modseq/createseq for the consistency checker.
|
||||
_, err = bstore.QueryDB[store.Message](ctxbg, tc.account.DB).FilterEqual("CreateSeq", int64(0)).UpdateNonzero(store.Message{CreateSeq: 2})
|
||||
tcheck(t, err, "updating modseq/createseq to valid values")
|
||||
_, err = bstore.QueryDB[store.Message](ctxbg, tc.account.DB).FilterEqual("ModSeq", int64(0)).UpdateNonzero(store.Message{ModSeq: 2})
|
||||
tcheck(t, err, "updating modseq/createseq to valid values")
|
||||
tc2o.close()
|
||||
tc2o.closeNoWait()
|
||||
tc2o = nil
|
||||
tc3o.close()
|
||||
tc3o.closeNoWait()
|
||||
tc3o = nil
|
||||
|
||||
// Then we rename inbox, which is special because it moves messages away instead of
|
||||
@ -533,10 +526,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
|
||||
xtc.client.Login("mjl@mox.example", password0)
|
||||
xtc.transactf("ok", "Select inbox (Condstore)")
|
||||
xtc.transactf("bad", "Uid Fetch 1:* (Flags) (Changedsince 1 Vanished)")
|
||||
// Prevent triggering the consistency checker, we still have modseq/createseq at 0.
|
||||
store.CheckConsistencyOnClose = false
|
||||
xtc.close()
|
||||
store.CheckConsistencyOnClose = true
|
||||
xtc.closeNoWait()
|
||||
xtc = nil
|
||||
|
||||
// Check that we get proper vanished responses.
|
||||
@ -557,9 +547,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
|
||||
xtc.client.Login("mjl@mox.example", password0)
|
||||
xtc.transactf("bad", "Select inbox (Qresync 1 0)")
|
||||
// Prevent triggering the consistency checker, we still have modseq/createseq at 0.
|
||||
store.CheckConsistencyOnClose = false
|
||||
xtc.close()
|
||||
store.CheckConsistencyOnClose = true
|
||||
xtc.closeNoWait()
|
||||
xtc = nil
|
||||
|
||||
tc.transactf("bad", "Select inbox (Qresync (0 1))") // Both args must be > 0.
|
||||
|
@ -12,7 +12,7 @@ func TestCopy(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc.client.Select("inbox")
|
||||
@ -34,6 +34,8 @@ func TestCopy(t *testing.T) {
|
||||
|
||||
tc.transactf("no", "copy 1 nonexistent")
|
||||
tc.xcode("TRYCREATE")
|
||||
tc.transactf("no", "copy 1 expungebox")
|
||||
tc.xcode("TRYCREATE")
|
||||
|
||||
tc.transactf("no", "copy 1 inbox") // Cannot copy to same mailbox.
|
||||
|
||||
|
@ -11,7 +11,7 @@ func TestCreate(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
@ -84,4 +84,7 @@ func TestCreate(t *testing.T) {
|
||||
tc.transactf("ok", `create "&"`) // Interpreted as UTF-8, no UTF-7.
|
||||
tc2.transactf("bad", `create "&"`) // Bad UTF-7.
|
||||
tc2.transactf("ok", `create "&Jjo-"`) // ☺, valid UTF-7.
|
||||
|
||||
tc.transactf("ok", "create expungebox") // Existed in past.
|
||||
tc.transactf("ok", "delete expungebox") // Gone again.
|
||||
}
|
||||
|
@ -11,10 +11,10 @@ func TestDelete(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc3 := startNoSwitchboard(t)
|
||||
defer tc3.close()
|
||||
defer tc3.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
@ -24,6 +24,7 @@ func TestDelete(t *testing.T) {
|
||||
tc.transactf("no", "delete inbox") // Cannot delete inbox.
|
||||
tc.transactf("no", "delete nonexistent") // Cannot delete mailbox that does not exist.
|
||||
tc.transactf("no", `delete "nonexistent"`) // Again, with quoted string syntax.
|
||||
tc.transactf("no", `delete "expungebox"`) // Already removed.
|
||||
|
||||
tc.client.Subscribe("x")
|
||||
tc.transactf("no", "delete x") // Subscription does not mean there is a mailbox that can be deleted.
|
||||
|
@ -12,7 +12,7 @@ func TestExpunge(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc.client.Select("inbox")
|
||||
|
@ -5,7 +5,6 @@ package imapserver
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@ -26,11 +25,11 @@ import (
|
||||
// functions to handle fetch attribute requests are defined on fetchCmd.
|
||||
type fetchCmd struct {
|
||||
conn *conn
|
||||
isUID bool // If this is a UID FETCH command.
|
||||
rtx *bstore.Tx // Read-only transaction, kept open while processing all messages.
|
||||
updateSeen []int64 // IDs of messages to mark as seen, after processing all messages.
|
||||
hasChangedSince bool // Whether CHANGEDSINCE was set. Enables MODSEQ in response.
|
||||
expungeIssued bool // Set if any message cannot be read. Can happen for expunged messages.
|
||||
isUID bool // If this is a UID FETCH command.
|
||||
rtx *bstore.Tx // Read-only transaction, kept open while processing all messages.
|
||||
updateSeen []store.UID // To mark as seen after processing all messages. UID instead of message ID since moved messages keep their ID and insert a new ID in the original mailbox.
|
||||
hasChangedSince bool // Whether CHANGEDSINCE was set. Enables MODSEQ in response.
|
||||
expungeIssued bool // Set if any message has been expunged. Can happen for expunged messages.
|
||||
|
||||
uid store.UID // UID currently processing.
|
||||
markSeen bool
|
||||
@ -271,15 +270,16 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
|
||||
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)
|
||||
mb, err := store.MailboxID(wtx, c.mailboxID)
|
||||
if err == store.ErrMailboxExpunged {
|
||||
xusercodeErrorf("NONEXISTENT", "mailbox has been expunged")
|
||||
}
|
||||
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)
|
||||
for _, uid := range cmd.updateSeen {
|
||||
m, err := bstore.QueryTx[store.Message](wtx).FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uid}).Get()
|
||||
xcheckf(err, "get message")
|
||||
if m.Expunged {
|
||||
// Message has been deleted in the mean time.
|
||||
@ -322,7 +322,8 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
|
||||
|
||||
if cmd.expungeIssued {
|
||||
// ../rfc/2180:343
|
||||
c.writeresultf("%s NO [EXPUNGEISSUED] at least one message was expunged", tag)
|
||||
// ../rfc/9051:5102
|
||||
c.writeresultf("%s OK [EXPUNGEISSUED] at least one message was expunged", tag)
|
||||
} else {
|
||||
c.ok(tag, cmdstr)
|
||||
}
|
||||
@ -333,12 +334,16 @@ func (cmd *fetchCmd) xensureMessage() *store.Message {
|
||||
return cmd.m
|
||||
}
|
||||
|
||||
// We do not filter by Expunged, the message may have been deleted in other
|
||||
// sessions, but not in ours.
|
||||
q := bstore.QueryTx[store.Message](cmd.rtx)
|
||||
q.FilterNonzero(store.Message{MailboxID: cmd.conn.mailboxID, UID: cmd.uid})
|
||||
q.FilterEqual("Expunged", false)
|
||||
m, err := q.Get()
|
||||
cmd.xcheckf(err, "get message for uid %d", cmd.uid)
|
||||
cmd.m = &m
|
||||
if m.Expunged {
|
||||
cmd.expungeIssued = true
|
||||
}
|
||||
return cmd.m
|
||||
}
|
||||
|
||||
@ -382,10 +387,6 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
|
||||
if !ok {
|
||||
panic(x)
|
||||
}
|
||||
if errors.Is(err, bstore.ErrAbsent) {
|
||||
cmd.expungeIssued = true
|
||||
return
|
||||
}
|
||||
cmd.conn.log.Infox("processing fetch attribute", err, slog.Any("uid", cmd.uid))
|
||||
xuserErrorf("processing fetch attribute: %v", err)
|
||||
}()
|
||||
@ -401,8 +402,7 @@ func (cmd *fetchCmd) process(atts []fetchAtt) {
|
||||
}
|
||||
|
||||
if cmd.markSeen {
|
||||
m := cmd.xensureMessage()
|
||||
cmd.updateSeen = append(cmd.updateSeen, m.ID)
|
||||
cmd.updateSeen = append(cmd.updateSeen, cmd.uid)
|
||||
}
|
||||
|
||||
if cmd.needFlags {
|
||||
|
@ -444,6 +444,16 @@ Content-Transfer-Encoding: Quoted-printable
|
||||
tc.client.Unselect()
|
||||
tc.client.Examine("inbox")
|
||||
|
||||
// Start a second session. Use it to remove the message. First session should still
|
||||
// be able to access the messages.
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.closeNoWait()
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Select("inbox")
|
||||
tc2.client.StoreFlagsSet("1", true, `\Deleted`)
|
||||
tc2.client.Expunge()
|
||||
tc2.client.Logout()
|
||||
|
||||
tc.transactf("ok", "fetch 1 binary[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1}})
|
||||
|
||||
|
@ -70,7 +70,7 @@ func FuzzServer(f *testing.F) {
|
||||
}
|
||||
defer func() {
|
||||
acc.Close()
|
||||
acc.CheckClosed()
|
||||
acc.WaitClosed()
|
||||
}()
|
||||
err = acc.SetPassword(log, password0)
|
||||
if err != nil {
|
||||
|
@ -13,7 +13,7 @@ func TestIdle(t *testing.T) {
|
||||
defer tc1.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc1.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
|
@ -3,11 +3,12 @@ package imapserver
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
@ -145,10 +146,11 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
|
||||
var nameList []string
|
||||
|
||||
q := bstore.QueryTx[store.Mailbox](tx)
|
||||
q.FilterEqual("Expunged", false)
|
||||
err := q.ForEach(func(mb store.Mailbox) error {
|
||||
names[mb.Name] = info{mailbox: &mb}
|
||||
nameList = append(nameList, mb.Name)
|
||||
for p := path.Dir(mb.Name); p != "."; p = path.Dir(p) {
|
||||
for p := mox.ParentMailboxName(mb.Name); p != ""; p = mox.ParentMailboxName(p) {
|
||||
hasChild[p] = true
|
||||
}
|
||||
return nil
|
||||
@ -163,7 +165,7 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
|
||||
if !ok {
|
||||
nameList = append(nameList, sub.Name)
|
||||
}
|
||||
for p := path.Dir(sub.Name); p != "."; p = path.Dir(p) {
|
||||
for p := mox.ParentMailboxName(sub.Name); p != ""; p = mox.ParentMailboxName(p) {
|
||||
hasSubscribedChild[p] = true
|
||||
}
|
||||
return nil
|
||||
@ -234,7 +236,10 @@ func (c *conn) cmdList(tag, cmd string, p *parser) {
|
||||
if info.mailbox != nil && len(retMetadata) > 0 {
|
||||
var meta listspace
|
||||
for _, k := range retMetadata {
|
||||
a, err := bstore.QueryTx[store.Annotation](tx).FilterNonzero(store.Annotation{MailboxID: info.mailbox.ID, Key: k}).Get()
|
||||
q := bstore.QueryTx[store.Annotation](tx)
|
||||
q.FilterNonzero(store.Annotation{MailboxID: info.mailbox.ID, Key: k})
|
||||
q.FilterEqual("Expunged", false)
|
||||
a, err := q.Get()
|
||||
var v token
|
||||
if err == bstore.ErrAbsent {
|
||||
v = nilt
|
||||
|
@ -26,6 +26,9 @@ func TestListBasic(t *testing.T) {
|
||||
tc.last(tc.client.List("Inbox"))
|
||||
tc.xuntagged(ulist("Inbox"))
|
||||
|
||||
tc.last(tc.client.List("expungebox"))
|
||||
tc.xuntagged()
|
||||
|
||||
tc.last(tc.client.List("%"))
|
||||
tc.xuntagged(ulist("Archive", `\Archive`), ulist("Drafts", `\Drafts`), ulist("Inbox"), ulist("Junk", `\Junk`), ulist("Sent", `\Sent`), ulist("Trash", `\Trash`))
|
||||
|
||||
@ -78,7 +81,7 @@ func TestListExtended(t *testing.T) {
|
||||
for _, name := range store.DefaultInitialMailboxes.Regular {
|
||||
uidvals[name] = 1
|
||||
}
|
||||
var uidvalnext uint32 = 2
|
||||
var uidvalnext uint32 = 3
|
||||
uidval := func(name string) uint32 {
|
||||
v, ok := uidvals[name]
|
||||
if !ok {
|
||||
|
@ -19,6 +19,9 @@ func TestLsub(t *testing.T) {
|
||||
tc.transactf("ok", `lsub "" x*`)
|
||||
tc.xuntagged()
|
||||
|
||||
tc.transactf("ok", `lsub "" expungebox`)
|
||||
tc.xuntagged(imapclient.UntaggedLsub{Separator: '/', Mailbox: "expungebox"})
|
||||
|
||||
tc.transactf("ok", "create a/b/c")
|
||||
tc.transactf("ok", `lsub "" a/*`)
|
||||
tc.xuntagged(imapclient.UntaggedLsub{Separator: '/', Mailbox: "a/b"}, imapclient.UntaggedLsub{Separator: '/', Mailbox: "a/b/c"})
|
||||
|
@ -100,7 +100,7 @@ func (c *conn) cmdGetmetadata(tag, cmd string, p *parser) {
|
||||
mb := c.xmailbox(tx, mailboxName, "TRYCREATE")
|
||||
q.FilterNonzero(store.Annotation{MailboxID: mb.ID})
|
||||
}
|
||||
|
||||
q.FilterEqual("Expunged", false)
|
||||
q.SortAsc("MailboxID", "Key") // For tests.
|
||||
err := q.ForEach(func(a store.Annotation) error {
|
||||
// ../rfc/5464:516
|
||||
@ -246,41 +246,35 @@ func (c *conn) cmdSetmetadata(tag, cmd string, p *parser) {
|
||||
q := bstore.QueryTx[store.Annotation](tx)
|
||||
q.FilterNonzero(store.Annotation{Key: a.Key})
|
||||
q.FilterEqual("MailboxID", mb.ID) // Can be zero.
|
||||
|
||||
q.FilterEqual("Expunged", false)
|
||||
oa, err := q.Get()
|
||||
// Nil means remove. ../rfc/5464:579
|
||||
if a.Value == nil {
|
||||
var deleted []store.Annotation
|
||||
q.Gather(&deleted)
|
||||
_, err := q.Delete()
|
||||
xcheckf(err, "deleting annotation")
|
||||
for _, oa := range deleted {
|
||||
changes = append(changes, oa.Change(mailboxName))
|
||||
}
|
||||
if err == bstore.ErrAbsent && a.Value == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if modseq == 0 {
|
||||
var err error
|
||||
modseq, err = c.account.NextModSeq(tx)
|
||||
xcheckf(err, "get next modseq")
|
||||
}
|
||||
|
||||
a.MailboxID = mb.ID
|
||||
a.ModSeq = modseq
|
||||
a.CreateSeq = modseq
|
||||
|
||||
oa, err := q.Get()
|
||||
if err == bstore.ErrAbsent {
|
||||
a.MailboxID = mb.ID
|
||||
a.CreateSeq = modseq
|
||||
a.ModSeq = modseq
|
||||
err = tx.Insert(&a)
|
||||
xcheckf(err, "inserting annotation")
|
||||
changes = append(changes, a.Change(mailboxName))
|
||||
continue
|
||||
} else {
|
||||
xcheckf(err, "get metadata")
|
||||
oa.ModSeq = modseq
|
||||
if a.Value == nil {
|
||||
oa.Expunged = true
|
||||
}
|
||||
oa.IsString = a.IsString
|
||||
oa.Value = a.Value
|
||||
err = tx.Update(&oa)
|
||||
xcheckf(err, "updating metdata")
|
||||
}
|
||||
xcheckf(err, "looking up existing annotation for entry name")
|
||||
changes = append(changes, a.Change(mailboxName))
|
||||
oa.Value = a.Value
|
||||
err = tx.Update(&oa)
|
||||
xcheckf(err, "updating metadata annotation")
|
||||
}
|
||||
|
||||
c.xcheckMetadataSize(tx)
|
||||
@ -304,7 +298,7 @@ func (c *conn) xcheckMetadataSize(tx *bstore.Tx) {
|
||||
// ../rfc/5464:383
|
||||
var n int
|
||||
var size int
|
||||
err := bstore.QueryTx[store.Annotation](tx).ForEach(func(a store.Annotation) error {
|
||||
err := bstore.QueryTx[store.Annotation](tx).FilterEqual("Expunged", false).ForEach(func(a store.Annotation) error {
|
||||
n++
|
||||
if n > metadataMaxKeys {
|
||||
// ../rfc/5464:590
|
||||
|
@ -23,6 +23,15 @@ func TestMetadata(t *testing.T) {
|
||||
tc.transactf("ok", `setmetadata "" (/PRIVATE/COMMENT "global value")`)
|
||||
tc.transactf("ok", `setmetadata inbox (/private/comment "mailbox value")`)
|
||||
|
||||
tc.transactf("ok", `create metabox`)
|
||||
tc.transactf("ok", `setmetadata metabox (/private/comment "mailbox value")`)
|
||||
tc.transactf("ok", `setmetadata metabox (/shared/comment "mailbox value")`)
|
||||
tc.transactf("ok", `setmetadata metabox (/shared/comment nil)`) // Remove.
|
||||
tc.transactf("ok", `delete metabox`) // Delete mailbox with live and expunged metadata.
|
||||
|
||||
tc.transactf("no", `setmetadata expungebox (/private/comment "mailbox value")`)
|
||||
tc.xcode("TRYCREATE")
|
||||
|
||||
tc.transactf("ok", `getmetadata "" ("/private/comment")`)
|
||||
tc.xuntagged(imapclient.UntaggedMetadataAnnotations{
|
||||
Mailbox: "",
|
||||
@ -176,7 +185,7 @@ func TestMetadata(t *testing.T) {
|
||||
|
||||
// Broadcast should not happen when metadata capability is not enabled.
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
|
@ -12,10 +12,10 @@ func TestMove(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc3 := startNoSwitchboard(t)
|
||||
defer tc3.close()
|
||||
defer tc3.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc.client.Select("inbox")
|
||||
@ -47,6 +47,9 @@ func TestMove(t *testing.T) {
|
||||
tc.transactf("no", "move 1 nonexistent")
|
||||
tc.xcode("TRYCREATE")
|
||||
|
||||
tc.transactf("no", "move 1 expungebox")
|
||||
tc.xcode("TRYCREATE")
|
||||
|
||||
tc.transactf("no", "move 1 inbox") // Cannot move to same mailbox.
|
||||
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
|
@ -12,7 +12,7 @@ func TestRename(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
@ -23,7 +23,9 @@ func TestRename(t *testing.T) {
|
||||
|
||||
tc.transactf("no", "rename doesnotexist newbox") // Does not exist.
|
||||
tc.xcode("NONEXISTENT") // ../rfc/9051:5140
|
||||
tc.transactf("no", `rename "Sent" "Trash"`) // Already exists.
|
||||
tc.transactf("no", "rename expungebox newbox") // No longer exists.
|
||||
tc.xcode("NONEXISTENT")
|
||||
tc.transactf("no", `rename "Sent" "Trash"`) // Already exists.
|
||||
tc.xcode("ALREADYEXISTS")
|
||||
|
||||
tc.client.Create("x", nil)
|
||||
@ -70,28 +72,51 @@ func TestRename(t *testing.T) {
|
||||
tc.client.Unsubscribe("k")
|
||||
tc.transactf("ok", "rename k/l k/l/m") // With "l" renamed, a new "k" will be created.
|
||||
tc.transactf("ok", `list "" "k*" return (subscribed)`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "k"}, imapclient.UntaggedList{Flags: []string{"\\Subscribed"}, Separator: '/', Mailbox: "k/l"}, imapclient.UntaggedList{Separator: '/', Mailbox: "k/l/m"})
|
||||
tc.xuntagged(
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "k"},
|
||||
imapclient.UntaggedList{Flags: []string{"\\Subscribed"}, Separator: '/', Mailbox: "k/l"},
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "k/l/m"},
|
||||
)
|
||||
|
||||
tc.transactf("ok", "rename k/l/m k/l/x/y/m") // k/l/x and k/l/x/y will be created.
|
||||
tc.transactf("ok", `list "" "k/l/x*" return (subscribed)`)
|
||||
tc.xuntagged(
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "k/l/x"},
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "k/l/x/y"},
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "k/l/x/y/m"},
|
||||
)
|
||||
|
||||
// Renaming inbox keeps inbox in existence, moves messages, and does not rename children.
|
||||
tc.transactf("ok", "create inbox/a")
|
||||
// To check if UIDs are renumbered properly, we add UIDs 1 and 2. Expunge 1,
|
||||
// keeping only 2. Then rename the inbox, which should renumber UID 2 in the old
|
||||
// inbox to UID 1 in the newly created mailbox.
|
||||
tc.transactf("ok", "append inbox (\\deleted) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
tc.transactf("ok", "append inbox (label1) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
tc.transactf("ok", "append inbox (\\deleted) {1+}\r\nx")
|
||||
tc.transactf("ok", "append inbox (label1) {1+}\r\nx")
|
||||
tc.transactf("ok", `select inbox`)
|
||||
tc.transactf("ok", "expunge")
|
||||
tc.transactf("ok", "rename inbox minbox")
|
||||
tc.transactf("ok", `list "" (inbox inbox/a minbox)`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"}, imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox/a"}, imapclient.UntaggedList{Separator: '/', Mailbox: "minbox"})
|
||||
tc.transactf("ok", `select minbox`)
|
||||
tc.transactf("ok", "rename inbox x/minbox")
|
||||
tc.transactf("ok", `list "" (inbox inbox/a x/minbox)`)
|
||||
tc.xuntagged(
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"},
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox/a"},
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "x/minbox"},
|
||||
)
|
||||
tc.transactf("ok", `select x/minbox`)
|
||||
tc.transactf("ok", `uid fetch 1:* flags`)
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{"label1"}}})
|
||||
|
||||
// Renaming to new hiearchy that does not have any subscribes.
|
||||
tc.transactf("ok", "rename minbox w/w")
|
||||
tc.transactf("ok", "rename x/minbox w/w")
|
||||
tc.transactf("ok", `list "" "w*"`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "w"}, imapclient.UntaggedList{Separator: '/', Mailbox: "w/w"})
|
||||
|
||||
tc.transactf("ok", "rename inbox misc/old/inbox")
|
||||
tc.transactf("ok", `list "" (misc misc/old/inbox)`)
|
||||
tc.xuntagged(
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "misc"},
|
||||
imapclient.UntaggedList{Separator: '/', Mailbox: "misc/old/inbox"},
|
||||
)
|
||||
|
||||
// todo: test create+delete+rename of/to a name results in a higher uidvalidity.
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
@ -116,7 +115,7 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
|
||||
q := bstore.QueryTx[store.Message](tx)
|
||||
q.FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uidOld})
|
||||
q.FilterEqual("Expunged", false)
|
||||
om, err := q.Get()
|
||||
_, err = q.Get()
|
||||
if err == bstore.ErrAbsent {
|
||||
return nil
|
||||
}
|
||||
@ -124,8 +123,8 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
|
||||
return func() { xserverErrorf("get message to replace: %v", err) }
|
||||
}
|
||||
|
||||
delta := size - om.Size
|
||||
ok, maxSize, err := c.account.CanAddMessageSize(tx, delta)
|
||||
// Check if we can add size bytes. We can't necessarily remove the current message yet.
|
||||
ok, maxSize, err := c.account.CanAddMessageSize(tx, size)
|
||||
if err != nil {
|
||||
return func() { xserverErrorf("check quota: %v", err) }
|
||||
}
|
||||
@ -169,9 +168,7 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
|
||||
var file *os.File
|
||||
var newMsgPath string
|
||||
var f io.Writer
|
||||
var committed bool
|
||||
|
||||
var oldMsgPath string // To remove on success.
|
||||
var commit bool
|
||||
|
||||
if errfn != nil {
|
||||
// We got a non-sync literal, we will consume some data, but abort if there's too
|
||||
@ -197,14 +194,10 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
|
||||
err := file.Close()
|
||||
c.xsanity(err, "close temporary file for replace")
|
||||
}
|
||||
if newMsgPath != "" && !committed {
|
||||
if newMsgPath != "" && !commit {
|
||||
err := os.Remove(newMsgPath)
|
||||
c.xsanity(err, "remove temporary file for replace")
|
||||
}
|
||||
if committed {
|
||||
err := os.Remove(oldMsgPath)
|
||||
c.xsanity(err, "remove old message")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@ -258,8 +251,8 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
|
||||
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)
|
||||
// Check quota for addition of new message. We can't necessarily yet remove the old message.
|
||||
ok, maxSize, err := c.account.CanAddMessageSize(tx, mw.Size)
|
||||
xcheckf(err, "checking quota")
|
||||
if !ok {
|
||||
// ../rfc/9208:472
|
||||
@ -269,31 +262,11 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
|
||||
modseq, err := c.account.NextModSeq(tx)
|
||||
xcheckf(err, "get next mod seq")
|
||||
|
||||
// Subtract counts for message from source mailbox.
|
||||
mbSrc.Sub(om.MailboxCounts())
|
||||
chremuids, _, err := c.account.MessageRemove(c.log, tx, modseq, &mbSrc, store.RemoveOpts{}, om)
|
||||
xcheckf(err, "expunge old message")
|
||||
changes = append(changes, chremuids)
|
||||
// Note: we only add a mbSrc counts change later on, if it is not equal to mbDst.
|
||||
|
||||
// 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})
|
||||
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")
|
||||
|
||||
@ -316,22 +289,16 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
|
||||
|
||||
err = c.account.MessageAdd(c.log, tx, &mbDst, &nm, file, store.AddOpts{})
|
||||
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)
|
||||
|
||||
changes = append(changes,
|
||||
store.ChangeRemoveUIDs{MailboxID: om.MailboxID, UIDs: []store.UID{om.UID}, ModSeq: om.ModSeq},
|
||||
nm.ChangeAddUID(),
|
||||
mbDst.ChangeCounts(),
|
||||
)
|
||||
changes = append(changes, nm.ChangeAddUID(), mbDst.ChangeCounts())
|
||||
if nkeywords != len(mbDst.Keywords) {
|
||||
changes = append(changes, mbDst.ChangeKeywords())
|
||||
}
|
||||
|
||||
err = tx.Update(&mbDst)
|
||||
xcheckf(err, "updating destination mailbox")
|
||||
|
||||
// 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.
|
||||
@ -342,7 +309,7 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
|
||||
}
|
||||
|
||||
// Success, make sure messages aren't cleaned up anymore.
|
||||
committed = true
|
||||
commit = true
|
||||
|
||||
// Broadcast the change to other connections.
|
||||
if mbSrc.ID != mbDst.ID {
|
||||
|
@ -13,7 +13,7 @@ func TestReplace(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc.client.Select("inbox")
|
||||
@ -23,6 +23,9 @@ func TestReplace(t *testing.T) {
|
||||
tc.client.StoreFlagsSet("1", true, `\deleted`)
|
||||
tc.client.Expunge()
|
||||
|
||||
tc.transactf("no", "replace 2 expungebox {1}") // Mailbox no longer exists.
|
||||
tc.xcode("TRYCREATE")
|
||||
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
@ -34,7 +37,7 @@ func TestReplace(t *testing.T) {
|
||||
imapclient.UntaggedExists(3),
|
||||
imapclient.UntaggedExpunge(2),
|
||||
)
|
||||
tc.xcodeArg(imapclient.CodeHighestModSeq(6))
|
||||
tc.xcodeArg(imapclient.CodeHighestModSeq(8))
|
||||
|
||||
// Check that other client sees Exists and Expunge.
|
||||
tc2.transactf("ok", "noop")
|
||||
@ -53,7 +56,7 @@ func TestReplace(t *testing.T) {
|
||||
imapclient.UntaggedExists(3),
|
||||
imapclient.UntaggedVanished{UIDs: xparseNumSet("2")},
|
||||
)
|
||||
tc.xcodeArg(imapclient.CodeHighestModSeq(7))
|
||||
tc.xcodeArg(imapclient.CodeHighestModSeq(9))
|
||||
|
||||
// Leftover data.
|
||||
tc.transactf("bad", "replace 1 inbox () {6+}\r\ntest\r\n ")
|
||||
@ -125,7 +128,7 @@ func TestReplaceExpunged(t *testing.T) {
|
||||
|
||||
// Get in with second client and remove the message we are replacing.
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Select("inbox")
|
||||
tc2.client.StoreFlagsSet("1", true, `\Deleted`)
|
||||
|
@ -406,7 +406,6 @@ func TestSearch(t *testing.T) {
|
||||
writeTextLit(1, true)
|
||||
}
|
||||
writeTextLit(1, false)
|
||||
|
||||
}
|
||||
|
||||
// esearchall makes an UntaggedEsearch response with All set, for comparisons.
|
||||
|
@ -49,6 +49,7 @@ func testSelectExamine(t *testing.T, examine bool) {
|
||||
|
||||
// Mailbox does not exist.
|
||||
tc.transactf("no", "%s bogus", cmd)
|
||||
tc.transactf("no", "%s expungebox", cmd)
|
||||
|
||||
tc.transactf("ok", "%s inbox", cmd)
|
||||
tc.xuntagged(uflags, upermflags, urecent, uexists0, uuidval1, uuidnext1, ulist)
|
||||
|
@ -26,7 +26,6 @@ non-ASCII UTF-8. Until that's enabled, we do use UTF-7 for mailbox names. See
|
||||
|
||||
/*
|
||||
- todo: do not return binary data for a fetch body. at least not for imap4rev1. we should be encoding it as base64?
|
||||
- todo: on expunge we currently remove the message even if other sessions still have a reference to the uid. if they try to query the uid, they'll get an error. we could be nicer and only actually remove the message when the last reference has gone. we could add a new flag to store.Message marking the message as expunged, not give new session access to such messages, and make store remove them at startup, and clean them when the last session referencing the session goes. however, it will get much more complicated. renaming messages would need special handling. and should we do the same for removed mailboxes?
|
||||
- todo: try to recover from syntax errors when the last command line ends with a }, i.e. a literal. we currently abort the entire connection. we may want to read some amount of literal data and continue with a next command.
|
||||
*/
|
||||
|
||||
@ -69,6 +68,7 @@ import (
|
||||
"github.com/mjl-/flate"
|
||||
|
||||
"github.com/mjl-/mox/config"
|
||||
"github.com/mjl-/mox/junk"
|
||||
"github.com/mjl-/mox/message"
|
||||
"github.com/mjl-/mox/metrics"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
@ -1557,10 +1557,12 @@ func (c *conn) xmailbox(tx *bstore.Tx, name string, missingErrCode string) store
|
||||
// If the mailbox does not exist, panic is called with a user error.
|
||||
// Must be called with account rlock held.
|
||||
func (c *conn) xmailboxID(tx *bstore.Tx, id int64) store.Mailbox {
|
||||
mb := store.Mailbox{ID: id}
|
||||
err := tx.Get(&mb)
|
||||
mb, err := store.MailboxID(tx, id)
|
||||
if err == bstore.ErrAbsent {
|
||||
xuserErrorf("%w", store.ErrUnknownMailbox)
|
||||
} else if err == store.ErrMailboxExpunged {
|
||||
// ../rfc/9051:5140
|
||||
xusercodeErrorf("NONEXISTENT", "mailbox has been deleted")
|
||||
}
|
||||
return mb
|
||||
}
|
||||
@ -1589,6 +1591,7 @@ func (c *conn) applyChanges(changes []store.Change, initial bool) {
|
||||
mbID = ch.MailboxID
|
||||
case store.ChangeRemoveUIDs:
|
||||
mbID = ch.MailboxID
|
||||
c.comm.RemovalSeen(ch)
|
||||
case store.ChangeFlags:
|
||||
mbID = ch.MailboxID
|
||||
case store.ChangeRemoveMailbox, store.ChangeAddMailbox, store.ChangeRenameMailbox, store.ChangeAddSubscription:
|
||||
@ -2881,9 +2884,6 @@ func (c *conn) cmdDelete(tag, cmd string, p *parser) {
|
||||
|
||||
name = xcheckmailboxname(name, false)
|
||||
|
||||
// Messages to remove after having broadcasted the removal of messages.
|
||||
var removeMessageIDs []int64
|
||||
|
||||
c.account.WithWLock(func() {
|
||||
var mb store.Mailbox
|
||||
var changes []store.Change
|
||||
@ -2893,7 +2893,7 @@ func (c *conn) cmdDelete(tag, cmd string, p *parser) {
|
||||
|
||||
var hasChildren bool
|
||||
var err error
|
||||
changes, removeMessageIDs, hasChildren, err = c.account.MailboxDelete(context.TODO(), c.log, tx, mb)
|
||||
changes, hasChildren, err = c.account.MailboxDelete(context.TODO(), c.log, tx, &mb)
|
||||
if hasChildren {
|
||||
xusercodeErrorf("HASCHILDREN", "mailbox has a child, only leaf mailboxes can be deleted")
|
||||
}
|
||||
@ -2903,12 +2903,6 @@ func (c *conn) cmdDelete(tag, cmd string, p *parser) {
|
||||
c.broadcast(changes)
|
||||
})
|
||||
|
||||
for _, mID := range removeMessageIDs {
|
||||
p := c.account.MessagePath(mID)
|
||||
err := os.Remove(p)
|
||||
c.xsanity(err, "removing message file %q for mailbox delete", p)
|
||||
}
|
||||
|
||||
c.ok(tag, cmd)
|
||||
}
|
||||
|
||||
@ -2934,14 +2928,21 @@ func (c *conn) cmdRename(tag, cmd string, p *parser) {
|
||||
src = xcheckmailboxname(src, true)
|
||||
dst = xcheckmailboxname(dst, false)
|
||||
|
||||
var cleanupIDs []int64
|
||||
defer func() {
|
||||
for _, id := range cleanupIDs {
|
||||
p := c.account.MessagePath(id)
|
||||
err := os.Remove(p)
|
||||
c.xsanity(err, "cleaning up message")
|
||||
}
|
||||
}()
|
||||
|
||||
c.account.WithWLock(func() {
|
||||
var changes []store.Change
|
||||
|
||||
c.xdbwrite(func(tx *bstore.Tx) {
|
||||
srcMB := c.xmailbox(tx, src, "NONEXISTENT")
|
||||
|
||||
var modseq store.ModSeq
|
||||
|
||||
// Inbox is very special. Unlike other mailboxes, its children are not moved. And
|
||||
// unlike a regular move, its messages are moved to a newly created mailbox. We do
|
||||
// indeed create a new destination mailbox and actually move the messages.
|
||||
@ -2956,111 +2957,53 @@ func (c *conn) cmdRename(tag, cmd string, p *parser) {
|
||||
xuserErrorf("cannot move inbox to itself")
|
||||
}
|
||||
|
||||
uidval, err := c.account.NextUIDValidity(tx)
|
||||
xcheckf(err, "next uid validity")
|
||||
var modseq store.ModSeq
|
||||
dstMB, chl, err := c.account.MailboxEnsure(tx, dst, false, store.SpecialUse{}, &modseq)
|
||||
xcheckf(err, "creating destination mailbox")
|
||||
changes = chl
|
||||
|
||||
modseq, err = c.account.NextModSeq(tx)
|
||||
xcheckf(err, "assigning next modseq")
|
||||
|
||||
dstMB := store.Mailbox{
|
||||
Name: dst,
|
||||
UIDValidity: uidval,
|
||||
UIDNext: 1,
|
||||
Keywords: srcMB.Keywords,
|
||||
ModSeq: modseq,
|
||||
CreateSeq: modseq,
|
||||
HaveCounts: true,
|
||||
// Copy mailbox annotations. ../rfc/5464:368
|
||||
qa := bstore.QueryTx[store.Annotation](tx)
|
||||
qa.FilterNonzero(store.Annotation{MailboxID: srcMB.ID})
|
||||
qa.FilterEqual("Expunged", false)
|
||||
annotations, err := qa.List()
|
||||
xcheckf(err, "get annotations to copy for inbox")
|
||||
for _, a := range annotations {
|
||||
a.ID = 0
|
||||
a.MailboxID = dstMB.ID
|
||||
a.ModSeq = modseq
|
||||
a.CreateSeq = modseq
|
||||
err := tx.Insert(&a)
|
||||
xcheckf(err, "copy annotation to destination mailbox")
|
||||
changes = append(changes, a.Change(dstMB.Name))
|
||||
}
|
||||
err = tx.Insert(&dstMB)
|
||||
xcheckf(err, "create new destination mailbox")
|
||||
c.xcheckMetadataSize(tx)
|
||||
|
||||
changes = make([]store.Change, 2) // Placeholders filled in below.
|
||||
|
||||
// Move existing messages, with their ID's and on-disk files intact, to the new
|
||||
// mailbox. We keep the expunged messages, the destination mailbox doesn't care
|
||||
// about them.
|
||||
var oldUIDs []store.UID
|
||||
// Build query that selects messages to move.
|
||||
q := bstore.QueryTx[store.Message](tx)
|
||||
q.FilterNonzero(store.Message{MailboxID: srcMB.ID})
|
||||
q.FilterEqual("Expunged", false)
|
||||
q.SortAsc("UID")
|
||||
err = q.ForEach(func(m store.Message) error {
|
||||
om := m
|
||||
om.ID = 0
|
||||
om.ModSeq = modseq
|
||||
om.PrepareExpunge()
|
||||
oldUIDs = append(oldUIDs, om.UID)
|
||||
|
||||
mc := m.MailboxCounts()
|
||||
srcMB.Sub(mc)
|
||||
dstMB.Add(mc)
|
||||
|
||||
m.MailboxID = dstMB.ID
|
||||
m.UID = dstMB.UIDNext
|
||||
dstMB.UIDNext++
|
||||
m.CreateSeq = modseq
|
||||
m.ModSeq = modseq
|
||||
if err := tx.Update(&m); err != nil {
|
||||
return fmt.Errorf("updating message to move to new mailbox: %w", err)
|
||||
}
|
||||
|
||||
changes = append(changes, m.ChangeAddUID())
|
||||
|
||||
if err := tx.Insert(&om); err != nil {
|
||||
return fmt.Errorf("adding empty expunge message record to inbox: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
xcheckf(err, "moving messages from inbox to destination mailbox")
|
||||
|
||||
err = tx.Update(&dstMB)
|
||||
xcheckf(err, "updating uidnext and counts in destination mailbox")
|
||||
|
||||
srcMB.ModSeq = modseq
|
||||
err = tx.Update(&srcMB)
|
||||
xcheckf(err, "updating counts for inbox")
|
||||
|
||||
var dstFlags []string
|
||||
if tx.Get(&store.Subscription{Name: dstMB.Name}) == nil {
|
||||
dstFlags = []string{`\Subscribed`}
|
||||
}
|
||||
|
||||
// Copy any annotations. ../rfc/5464:368
|
||||
annotations, err := bstore.QueryTx[store.Annotation](tx).FilterNonzero(store.Annotation{MailboxID: srcMB.ID}).List()
|
||||
xcheckf(err, "get annotations to copy for inbox")
|
||||
for i := range annotations {
|
||||
annotations[i].ID = 0
|
||||
annotations[i].MailboxID = dstMB.ID
|
||||
annotations[i].ModSeq = modseq
|
||||
annotations[i].CreateSeq = modseq
|
||||
err := tx.Insert(&annotations[i])
|
||||
xcheckf(err, "copy annotation to destination mailbox")
|
||||
}
|
||||
|
||||
c.xcheckMetadataSize(tx)
|
||||
|
||||
changes[0] = store.ChangeRemoveUIDs{MailboxID: srcMB.ID, UIDs: oldUIDs, ModSeq: modseq}
|
||||
changes[1] = store.ChangeAddMailbox{Mailbox: dstMB, Flags: dstFlags, ModSeq: modseq}
|
||||
// changes[2:...] are ChangeAddUIDs
|
||||
changes = append(changes, srcMB.ChangeCounts(), dstMB.ChangeCounts())
|
||||
for _, a := range annotations {
|
||||
changes = append(changes, a.Change(dstMB.Name))
|
||||
}
|
||||
newIDs, chl := c.xmoveMessages(tx, q, 0, modseq, &srcMB, &dstMB)
|
||||
changes = append(changes, chl...)
|
||||
cleanupIDs = newIDs
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var notExists, alreadyExists bool
|
||||
var modseq store.ModSeq
|
||||
var alreadyExists bool
|
||||
var err error
|
||||
changes, _, notExists, alreadyExists, err = c.account.MailboxRename(tx, srcMB, dst, &modseq)
|
||||
if notExists {
|
||||
// ../rfc/9051:5140
|
||||
xusercodeErrorf("NONEXISTENT", "%s", err)
|
||||
} else if alreadyExists {
|
||||
changes, _, alreadyExists, err = c.account.MailboxRename(tx, &srcMB, dst, &modseq)
|
||||
if alreadyExists {
|
||||
xusercodeErrorf("ALREADYEXISTS", "%s", err)
|
||||
}
|
||||
xcheckf(err, "renaming mailbox")
|
||||
})
|
||||
|
||||
cleanupIDs = nil
|
||||
|
||||
c.broadcast(changes)
|
||||
})
|
||||
|
||||
@ -3163,7 +3106,7 @@ func (c *conn) cmdLsub(tag, cmd string, p *parser) {
|
||||
for _, sub := range subscriptions {
|
||||
name := sub.Name
|
||||
if ispercent {
|
||||
for p := path.Dir(name); p != "."; p = path.Dir(p) {
|
||||
for p := mox.ParentMailboxName(name); p != ""; p = mox.ParentMailboxName(p) {
|
||||
subscribedKids[p] = true
|
||||
}
|
||||
}
|
||||
@ -3181,6 +3124,7 @@ func (c *conn) cmdLsub(tag, cmd string, p *parser) {
|
||||
return
|
||||
}
|
||||
qmb := bstore.QueryTx[store.Mailbox](tx)
|
||||
qmb.FilterEqual("Expunged", false)
|
||||
qmb.SortAsc("Name")
|
||||
err = qmb.ForEach(func(mb store.Mailbox) error {
|
||||
if have[mb.Name] || !subscribedKids[mb.Name] || !re.MatchString(mb.Name) {
|
||||
@ -3544,12 +3488,11 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) {
|
||||
// todo: do a single junk training
|
||||
err = c.account.MessageAdd(c.log, tx, &mb, &a.m, a.file, store.AddOpts{SkipDirSync: true})
|
||||
xcheckf(err, "delivering message")
|
||||
|
||||
changes = append(changes, a.m.ChangeAddUID())
|
||||
|
||||
// Update path to what is stored in the account. We may still have to clean it up on errors.
|
||||
a.path = c.account.MessagePath(a.m.ID)
|
||||
|
||||
changes = append(changes, a.m.ChangeAddUID())
|
||||
|
||||
msgDirs[filepath.Dir(a.path)] = struct{}{}
|
||||
}
|
||||
|
||||
@ -3759,29 +3702,29 @@ func (c *conn) cmdClose(tag, cmd string, p *parser) {
|
||||
}
|
||||
|
||||
// expunge messages marked for deletion in currently selected/active mailbox.
|
||||
// if uidSet is not nil, only messages matching the set are deleted.
|
||||
// if uidSet is not nil, only messages matching the set are expunged.
|
||||
//
|
||||
// messages that have been marked expunged from the database are returned and
|
||||
// have already been removed.
|
||||
// Messages that have been marked expunged from the database are returned. While
|
||||
// other sessions still reference the message, it is not cleared from the database
|
||||
// yet, and the message file is not yet removed.
|
||||
//
|
||||
// the highest modseq in the mailbox is returned, typically associated with the
|
||||
// The highest modseq in the mailbox is returned, typically associated with the
|
||||
// removal of the messages, but if no messages were expunged the current latest max
|
||||
// modseq for the mailbox is returned.
|
||||
func (c *conn) xexpunge(uidSet *numSet, missingMailboxOK bool) (removed []store.Message, highestModSeq store.ModSeq) {
|
||||
var modseq store.ModSeq
|
||||
|
||||
func (c *conn) xexpunge(uidSet *numSet, missingMailboxOK bool) (expunged []store.Message, highestModSeq store.ModSeq) {
|
||||
c.account.WithWLock(func() {
|
||||
var mb store.Mailbox
|
||||
var changes []store.Change
|
||||
|
||||
c.xdbwrite(func(tx *bstore.Tx) {
|
||||
mb = store.Mailbox{ID: c.mailboxID}
|
||||
err := tx.Get(&mb)
|
||||
if err == bstore.ErrAbsent {
|
||||
mb, err := store.MailboxID(tx, c.mailboxID)
|
||||
if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
|
||||
if missingMailboxOK {
|
||||
return
|
||||
}
|
||||
xuserErrorf("%w", store.ErrUnknownMailbox)
|
||||
// ../rfc/9051:5140
|
||||
xusercodeErrorf("NONEXISTENT", "%w", store.ErrUnknownMailbox)
|
||||
}
|
||||
xcheckf(err, "get mailbox")
|
||||
|
||||
qm := bstore.QueryTx[store.Message](tx)
|
||||
qm.FilterNonzero(store.Message{MailboxID: c.mailboxID})
|
||||
@ -3792,82 +3735,32 @@ func (c *conn) xexpunge(uidSet *numSet, missingMailboxOK bool) (removed []store.
|
||||
return uidSearch(c.uids, m.UID) > 0 && (uidSet == nil || uidSet.containsUID(m.UID, c.uids, c.searchResult))
|
||||
})
|
||||
qm.SortAsc("UID")
|
||||
removed, err = qm.List()
|
||||
xcheckf(err, "listing messages to delete")
|
||||
expunged, err = qm.List()
|
||||
xcheckf(err, "listing messages to expunge")
|
||||
|
||||
if len(removed) == 0 {
|
||||
if len(expunged) == 0 {
|
||||
highestModSeq = mb.ModSeq
|
||||
return
|
||||
}
|
||||
|
||||
// Assign new modseq.
|
||||
modseq, err = c.account.NextModSeq(tx)
|
||||
modseq, err := c.account.NextModSeq(tx)
|
||||
xcheckf(err, "assigning next modseq")
|
||||
highestModSeq = modseq
|
||||
mb.ModSeq = modseq
|
||||
|
||||
removeIDs := make([]int64, len(removed))
|
||||
anyIDs := make([]any, len(removed))
|
||||
var totalSize int64
|
||||
for i, m := range removed {
|
||||
removeIDs[i] = m.ID
|
||||
anyIDs[i] = m.ID
|
||||
mb.Sub(m.MailboxCounts())
|
||||
totalSize += m.Size
|
||||
// Update "remove", because RetrainMessage below will save the message.
|
||||
removed[i].Expunged = true
|
||||
removed[i].ModSeq = modseq
|
||||
}
|
||||
qmr := bstore.QueryTx[store.Recipient](tx)
|
||||
qmr.FilterEqual("MessageID", anyIDs...)
|
||||
_, err = qmr.Delete()
|
||||
xcheckf(err, "removing message recipients")
|
||||
|
||||
qm = bstore.QueryTx[store.Message](tx)
|
||||
qm.FilterIDs(removeIDs)
|
||||
n, err := qm.UpdateNonzero(store.Message{Expunged: true, ModSeq: modseq})
|
||||
if err == nil && n != len(removeIDs) {
|
||||
err = fmt.Errorf("only %d messages set to expunged, expected %d", n, len(removeIDs))
|
||||
}
|
||||
xcheckf(err, "marking messages marked for deleted as expunged")
|
||||
chremuids, chmbcounts, err := c.account.MessageRemove(c.log, tx, modseq, &mb, store.RemoveOpts{}, expunged...)
|
||||
xcheckf(err, "expunging messages")
|
||||
changes = append(changes, chremuids, chmbcounts)
|
||||
|
||||
err = tx.Update(&mb)
|
||||
xcheckf(err, "updating mailbox counts")
|
||||
|
||||
err = c.account.AddMessageSize(c.log, tx, -totalSize)
|
||||
xcheckf(err, "updating disk usage")
|
||||
|
||||
// Mark expunged messages as not needing training, then retrain them, so if they
|
||||
// were trained, they get untrained.
|
||||
for i := range removed {
|
||||
removed[i].Junk = false
|
||||
removed[i].Notjunk = false
|
||||
}
|
||||
err = c.account.RetrainMessages(context.TODO(), c.log, tx, removed)
|
||||
xcheckf(err, "untraining expunged messages")
|
||||
xcheckf(err, "update mailbox")
|
||||
})
|
||||
|
||||
// Broadcast changes to other connections. We may not have actually removed any
|
||||
// messages, so take care not to send an empty update.
|
||||
if len(removed) > 0 {
|
||||
ouids := make([]store.UID, len(removed))
|
||||
for i, m := range removed {
|
||||
ouids[i] = m.UID
|
||||
}
|
||||
changes := []store.Change{
|
||||
store.ChangeRemoveUIDs{MailboxID: c.mailboxID, UIDs: ouids, ModSeq: modseq},
|
||||
mb.ChangeCounts(),
|
||||
}
|
||||
c.broadcast(changes)
|
||||
}
|
||||
|
||||
for _, m := range removed {
|
||||
p := c.account.MessagePath(m.ID)
|
||||
err := os.Remove(p)
|
||||
c.xsanity(err, "removing message file for expunge")
|
||||
}
|
||||
c.broadcast(changes)
|
||||
})
|
||||
return removed, highestModSeq
|
||||
|
||||
return expunged, highestModSeq
|
||||
}
|
||||
|
||||
// Unselect is similar to close in that it closes the currently active mailbox, but
|
||||
@ -4052,9 +3945,9 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
|
||||
uids, uidargs := c.gatherCopyMoveUIDs(isUID, nums)
|
||||
|
||||
// Files that were created during the copy. Remove them if the operation fails.
|
||||
var createdIDs []int64
|
||||
var newIDs []int64
|
||||
defer func() {
|
||||
for _, id := range createdIDs {
|
||||
for _, id := range newIDs {
|
||||
p := c.account.MessagePath(id)
|
||||
err := os.Remove(p)
|
||||
c.xsanity(err, "cleaning up created file")
|
||||
@ -4200,7 +4093,7 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
|
||||
}
|
||||
err := moxio.LinkOrCopy(c.log, dst, src, nil, true)
|
||||
xcheckf(err, "link or copy file %q to %q", src, dst)
|
||||
createdIDs = append(createdIDs, newMsgIDs[i])
|
||||
newIDs = append(newIDs, newMsgIDs[i])
|
||||
}
|
||||
|
||||
for dir := range syncDirs {
|
||||
@ -4212,7 +4105,7 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
|
||||
xcheckf(err, "train copied messages")
|
||||
})
|
||||
|
||||
createdIDs = nil
|
||||
newIDs = nil
|
||||
|
||||
// Broadcast changes to other connections.
|
||||
if len(newUIDs) > 0 {
|
||||
@ -4253,127 +4146,61 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) {
|
||||
|
||||
uids, uidargs := c.gatherCopyMoveUIDs(isUID, nums)
|
||||
|
||||
var mbSrc, mbDst store.Mailbox
|
||||
var changes []store.Change
|
||||
var newUIDs []store.UID
|
||||
var mbDst store.Mailbox
|
||||
var uidFirst store.UID
|
||||
var modseq store.ModSeq
|
||||
|
||||
var cleanupIDs []int64
|
||||
defer func() {
|
||||
for _, id := range cleanupIDs {
|
||||
p := c.account.MessagePath(id)
|
||||
err := os.Remove(p)
|
||||
c.xsanity(err, "removing destination message file %v", p)
|
||||
}
|
||||
}()
|
||||
|
||||
c.account.WithWLock(func() {
|
||||
var changes []store.Change
|
||||
|
||||
c.xdbwrite(func(tx *bstore.Tx) {
|
||||
mbSrc = c.xmailboxID(tx, c.mailboxID) // Validate.
|
||||
mbSrc := c.xmailboxID(tx, c.mailboxID) // Validate.
|
||||
mbDst = c.xmailbox(tx, name, "TRYCREATE")
|
||||
if mbDst.ID == c.mailboxID {
|
||||
xuserErrorf("cannot move to currently selected mailbox")
|
||||
}
|
||||
|
||||
if len(uidargs) == 0 {
|
||||
if len(uids) == 0 {
|
||||
xuserErrorf("no matching messages to move")
|
||||
}
|
||||
|
||||
// Reserve the uids in the destination mailbox.
|
||||
uidFirst := mbDst.UIDNext
|
||||
uidnext := uidFirst
|
||||
mbDst.UIDNext += store.UID(len(uids))
|
||||
uidFirst = mbDst.UIDNext
|
||||
|
||||
// Assign a new modseq, for the new records and for the expunged records.
|
||||
var err error
|
||||
modseq, err = c.account.NextModSeq(tx)
|
||||
xcheckf(err, "assigning next modseq")
|
||||
mbSrc.ModSeq = modseq
|
||||
mbDst.ModSeq = modseq
|
||||
|
||||
// Update existing record with new UID and MailboxID in database for messages. We
|
||||
// add a new but expunged record again in the original/source mailbox, for qresync.
|
||||
// Keeping the original ID for the live message means we don't have to move the
|
||||
// on-disk message contents file.
|
||||
// Make query selecting messages to move.
|
||||
q := bstore.QueryTx[store.Message](tx)
|
||||
q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
|
||||
q.FilterNonzero(store.Message{MailboxID: mbSrc.ID})
|
||||
q.FilterEqual("UID", uidargs...)
|
||||
q.FilterEqual("Expunged", false)
|
||||
q.SortAsc("UID")
|
||||
msgs, err := q.List()
|
||||
xcheckf(err, "listing messages to move")
|
||||
|
||||
if len(msgs) != len(uidargs) {
|
||||
xserverErrorf("uid and message mismatch")
|
||||
}
|
||||
|
||||
keywords := map[string]struct{}{}
|
||||
now := time.Now()
|
||||
|
||||
conf, _ := c.account.Conf()
|
||||
for i := range msgs {
|
||||
m := &msgs[i]
|
||||
if m.UID != uids[i] {
|
||||
xserverErrorf("internal error: got uid %d, expected %d, for index %d", m.UID, uids[i], i)
|
||||
}
|
||||
|
||||
mbSrc.Sub(m.MailboxCounts())
|
||||
|
||||
// Copy of message record that we'll insert when UID is freed up.
|
||||
om := *m
|
||||
om.PrepareExpunge()
|
||||
om.ID = 0 // Assign new ID.
|
||||
om.ModSeq = modseq
|
||||
|
||||
m.MailboxID = mbDst.ID
|
||||
if m.IsReject && m.MailboxDestinedID != 0 {
|
||||
// Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message
|
||||
// is used for reputation calculation during future deliveries.
|
||||
m.MailboxOrigID = m.MailboxDestinedID
|
||||
m.IsReject = false
|
||||
m.Seen = false
|
||||
}
|
||||
mbDst.Add(m.MailboxCounts())
|
||||
m.UID = uidnext
|
||||
m.ModSeq = modseq
|
||||
m.JunkFlagsForMailbox(mbDst, conf)
|
||||
m.SaveDate = &now
|
||||
uidnext++
|
||||
err := tx.Update(m)
|
||||
xcheckf(err, "updating moved message in database")
|
||||
|
||||
// Now that UID is unused, we can insert the old record again.
|
||||
err = tx.Insert(&om)
|
||||
xcheckf(err, "inserting record for expunge after moving message")
|
||||
|
||||
for _, kw := range m.Keywords {
|
||||
keywords[kw] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure destination mailbox has keywords of the moved messages.
|
||||
var mbKwChanged bool
|
||||
mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords))
|
||||
if mbKwChanged {
|
||||
changes = append(changes, mbDst.ChangeKeywords())
|
||||
}
|
||||
|
||||
err = tx.Update(&mbSrc)
|
||||
xcheckf(err, "updating source mailbox counts and modseq")
|
||||
|
||||
err = tx.Update(&mbDst)
|
||||
xcheckf(err, "updating destination mailbox for uids, keywords and counts")
|
||||
|
||||
err = c.account.RetrainMessages(context.TODO(), c.log, tx, msgs)
|
||||
xcheckf(err, "retraining messages after move")
|
||||
|
||||
// Prepare broadcast changes to other connections.
|
||||
changes = make([]store.Change, 0, 1+len(msgs)+2)
|
||||
changes = append(changes, store.ChangeRemoveUIDs{MailboxID: c.mailboxID, UIDs: uids, ModSeq: modseq})
|
||||
for _, m := range msgs {
|
||||
newUIDs = append(newUIDs, m.UID)
|
||||
changes = append(changes, m.ChangeAddUID())
|
||||
}
|
||||
changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts())
|
||||
newIDs, chl := c.xmoveMessages(tx, q, len(uidargs), modseq, &mbSrc, &mbDst)
|
||||
changes = append(changes, chl...)
|
||||
cleanupIDs = newIDs
|
||||
})
|
||||
|
||||
cleanupIDs = nil
|
||||
|
||||
c.broadcast(changes)
|
||||
})
|
||||
|
||||
// ../rfc/9051:4708 ../rfc/6851:254
|
||||
// ../rfc/9051:4713
|
||||
c.bwritelinef("* OK [COPYUID %d %s %s] moved", mbDst.UIDValidity, compactUIDSet(uids).String(), compactUIDSet(newUIDs).String())
|
||||
newUIDs := numSet{ranges: []numRange{{setNumber{number: uint32(uidFirst)}, &setNumber{number: uint32(mbDst.UIDNext - 1)}}}}
|
||||
c.bwritelinef("* OK [COPYUID %d %s %s] moved", mbDst.UIDValidity, compactUIDSet(uids).String(), newUIDs.String())
|
||||
qresync := c.enabled[capQresync]
|
||||
var vanishedUIDs numSet
|
||||
for i := 0; i < len(uids); i++ {
|
||||
@ -4400,6 +4227,132 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) {
|
||||
}
|
||||
}
|
||||
|
||||
// q must yield messages from a single mailbox.
|
||||
func (c *conn) xmoveMessages(tx *bstore.Tx, q *bstore.Query[store.Message], expectCount int, modseq store.ModSeq, mbSrc, mbDst *store.Mailbox) (newIDs []int64, changes []store.Change) {
|
||||
newIDs = make([]int64, 0, expectCount)
|
||||
var commit bool
|
||||
defer func() {
|
||||
if commit {
|
||||
return
|
||||
}
|
||||
for _, id := range newIDs {
|
||||
p := c.account.MessagePath(id)
|
||||
err := os.Remove(p)
|
||||
c.xsanity(err, "removing added message file %v", p)
|
||||
}
|
||||
newIDs = nil
|
||||
}()
|
||||
|
||||
mbSrc.ModSeq = modseq
|
||||
mbDst.ModSeq = modseq
|
||||
|
||||
var jf *junk.Filter
|
||||
defer func() {
|
||||
if jf != nil {
|
||||
err := jf.CloseDiscard()
|
||||
c.log.Check(err, "closing junk filter after error")
|
||||
}
|
||||
}()
|
||||
|
||||
accConf, _ := c.account.Conf()
|
||||
|
||||
changeRemoveUIDs := store.ChangeRemoveUIDs{
|
||||
MailboxID: mbSrc.ID,
|
||||
ModSeq: modseq,
|
||||
}
|
||||
changes = make([]store.Change, 0, expectCount+4) // mbsrc removeuids, mbsrc counts, mbdst counts, mbdst keywords
|
||||
|
||||
nkeywords := len(mbDst.Keywords)
|
||||
now := time.Now()
|
||||
|
||||
l, err := q.List()
|
||||
xcheckf(err, "listing messages to move")
|
||||
|
||||
if expectCount > 0 && len(l) != expectCount {
|
||||
xcheckf(fmt.Errorf("moved %d messages, expected %d", len(l), expectCount), "move messages")
|
||||
}
|
||||
|
||||
for _, om := range l {
|
||||
nm := om
|
||||
nm.MailboxID = mbDst.ID
|
||||
nm.UID = mbDst.UIDNext
|
||||
mbDst.UIDNext++
|
||||
nm.ModSeq = modseq
|
||||
nm.CreateSeq = modseq
|
||||
nm.SaveDate = &now
|
||||
if nm.IsReject && nm.MailboxDestinedID != 0 {
|
||||
// Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message
|
||||
// is used for reputation calculation during future deliveries.
|
||||
nm.MailboxOrigID = nm.MailboxDestinedID
|
||||
nm.IsReject = false
|
||||
nm.Seen = false
|
||||
}
|
||||
|
||||
nm.JunkFlagsForMailbox(*mbDst, accConf)
|
||||
|
||||
err := tx.Update(&nm)
|
||||
xcheckf(err, "updating message with new mailbox")
|
||||
|
||||
mbDst.Add(nm.MailboxCounts())
|
||||
|
||||
mbSrc.Sub(om.MailboxCounts())
|
||||
om.ID = 0
|
||||
om.Expunged = true
|
||||
om.ModSeq = modseq
|
||||
om.TrainedJunk = nil
|
||||
err = tx.Insert(&om)
|
||||
xcheckf(err, "inserting expunged message in old mailbox")
|
||||
|
||||
err = moxio.LinkOrCopy(c.log, c.account.MessagePath(om.ID), c.account.MessagePath(nm.ID), nil, false)
|
||||
xcheckf(err, "duplicating message in old mailbox for current sessions")
|
||||
newIDs = append(newIDs, nm.ID)
|
||||
// We don't sync the directory. In case of a crash and files disappearing, the
|
||||
// eraser will simply not find the file at next startup.
|
||||
|
||||
err = tx.Insert(&store.MessageErase{ID: om.ID, SkipUpdateDiskUsage: true})
|
||||
xcheckf(err, "insert message erase")
|
||||
|
||||
mbDst.Keywords, _ = store.MergeKeywords(mbDst.Keywords, nm.Keywords)
|
||||
|
||||
if accConf.JunkFilter != nil && nm.NeedsTraining() {
|
||||
// Lazily open junk filter.
|
||||
if jf == nil {
|
||||
jf, _, err = c.account.OpenJunkFilter(context.TODO(), c.log)
|
||||
xcheckf(err, "open junk filter")
|
||||
}
|
||||
err := c.account.RetrainMessage(context.TODO(), c.log, tx, jf, &nm)
|
||||
xcheckf(err, "retrain message after moving")
|
||||
}
|
||||
|
||||
changeRemoveUIDs.UIDs = append(changeRemoveUIDs.UIDs, om.UID)
|
||||
changeRemoveUIDs.MsgIDs = append(changeRemoveUIDs.MsgIDs, om.ID)
|
||||
changes = append(changes, nm.ChangeAddUID())
|
||||
}
|
||||
xcheckf(err, "move messages")
|
||||
|
||||
changes = append(changes, changeRemoveUIDs, mbSrc.ChangeCounts())
|
||||
|
||||
err = tx.Update(mbSrc)
|
||||
xcheckf(err, "updating counts for inbox")
|
||||
|
||||
changes = append(changes, mbDst.ChangeCounts())
|
||||
if len(mbDst.Keywords) > nkeywords {
|
||||
changes = append(changes, mbDst.ChangeKeywords())
|
||||
}
|
||||
|
||||
err = tx.Update(mbDst)
|
||||
xcheckf(err, "updating uidnext and counts in destination mailbox")
|
||||
|
||||
if jf != nil {
|
||||
err := jf.Close()
|
||||
jf = nil
|
||||
xcheckf(err, "saving junk filter")
|
||||
}
|
||||
|
||||
commit = true
|
||||
return
|
||||
}
|
||||
|
||||
// Store sets a full set of flags, or adds/removes specific flags.
|
||||
//
|
||||
// State: Selected
|
||||
|
@ -18,6 +18,8 @@ import (
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
@ -309,6 +311,14 @@ func (tc *testconn) waitDone() {
|
||||
}
|
||||
|
||||
func (tc *testconn) close() {
|
||||
tc.close0(true)
|
||||
}
|
||||
|
||||
func (tc *testconn) closeNoWait() {
|
||||
tc.close0(false)
|
||||
}
|
||||
|
||||
func (tc *testconn) close0(waitclose bool) {
|
||||
defer func() {
|
||||
if unhandledPanics.Swap(0) > 0 {
|
||||
tc.t.Fatalf("handled panic in server")
|
||||
@ -319,13 +329,16 @@ func (tc *testconn) close() {
|
||||
// Already closed, we are not strict about closing multiple times.
|
||||
return
|
||||
}
|
||||
err := tc.account.Close()
|
||||
tc.check(err, "close account")
|
||||
// no account.CheckClosed(), the tests open accounts multiple times.
|
||||
tc.account = nil
|
||||
if tc.client != nil {
|
||||
tc.client.Close()
|
||||
}
|
||||
err := tc.account.Close()
|
||||
tc.check(err, "close account")
|
||||
if waitclose {
|
||||
tc.account.WaitClosed()
|
||||
}
|
||||
// no account.CheckClosed(), the tests open accounts multiple times.
|
||||
tc.account = nil
|
||||
tc.serverConn.Close()
|
||||
tc.waitDone()
|
||||
if tc.switchStop != nil {
|
||||
@ -406,6 +419,22 @@ func startArgsMore(t *testing.T, first, immediateTLS bool, serverConfig, clientC
|
||||
switchStop := func() {}
|
||||
if first {
|
||||
switchStop = store.Switchboard()
|
||||
|
||||
// Add a deleted mailbox, may excercise some code paths.
|
||||
err = acc.DB.Write(ctxbg, func(tx *bstore.Tx) error {
|
||||
// todo: add a message to inbox and remove it again. need to change all uids in the tests.
|
||||
// todo: add tests for operating on an expunged mailbox. it should say it doesn't exist.
|
||||
|
||||
mb, _, _, _, err := acc.MailboxCreate(tx, "expungebox", store.SpecialUse{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create mailbox: %v", err)
|
||||
}
|
||||
if _, _, err := acc.MailboxDelete(ctxbg, pkglog, tx, &mb); err != nil {
|
||||
return fmt.Errorf("delete mailbox: %v", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
tcheck(t, err, "add expunged mailbox")
|
||||
}
|
||||
|
||||
if afterInit != nil {
|
||||
@ -698,7 +727,7 @@ func TestMailboxDeleted(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
|
@ -19,6 +19,8 @@ func TestStatus(t *testing.T) {
|
||||
tc.transactf("bad", "status inbox (uidvalidity) ") // Leftover data.
|
||||
tc.transactf("bad", "status inbox (unknown)") // Unknown attribute.
|
||||
|
||||
tc.transactf("no", "status expungebox (messages)") // No longer exists.
|
||||
|
||||
tc.transactf("ok", "status inbox (messages uidnext uidvalidity unseen deleted size recent appendlimit)")
|
||||
tc.xuntagged(imapclient.UntaggedStatus{
|
||||
Mailbox: "Inbox",
|
||||
|
@ -11,7 +11,7 @@ func TestSubscribe(t *testing.T) {
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
defer tc2.closeNoWait()
|
||||
|
||||
tc.client.Login("mjl@mox.example", password0)
|
||||
tc2.client.Login("mjl@mox.example", password0)
|
||||
|
@ -14,10 +14,12 @@ func TestUnsubscribe(t *testing.T) {
|
||||
tc.transactf("bad", "unsubscribe ") // Missing param.
|
||||
tc.transactf("bad", "unsubscribe fine ") // Leftover data.
|
||||
|
||||
tc.transactf("no", "unsubscribe a/b") // Does not exist and is not subscribed.
|
||||
tc.transactf("no", "unsubscribe a/b") // Does not exist and is not subscribed.
|
||||
tc.transactf("ok", "unsubscribe expungebox") // Does not exist anymore but is still subscribed.
|
||||
tc.transactf("no", "unsubscribe expungebox") // Not subscribed.
|
||||
tc.transactf("ok", "create a/b")
|
||||
tc.transactf("ok", "unsubscribe a/b")
|
||||
tc.transactf("ok", "unsubscribe a/b") // Can unsubscribe even if it does not exist.
|
||||
tc.transactf("ok", "unsubscribe a/b") // Can unsubscribe even if there is no subscription.
|
||||
tc.transactf("ok", "subscribe a/b")
|
||||
tc.transactf("ok", "unsubscribe a/b")
|
||||
}
|
||||
|
Reference in New Issue
Block a user