imapserver: implement UIDONLY extension, RFC 9586

Once clients enable this extension, commands can no longer refer to "message
sequence numbers" (MSNs), but can only refer to messages with UIDs. This means
both sides no longer have to carefully keep their sequence numbers in sync
(error-prone), and don't have to keep track of a mapping of sequence numbers to
UIDs (saves resources).

With UIDONLY enabled, all FETCH responses are replaced with UIDFETCH response.
This commit is contained in:
Mechiel Lukkien 2025-04-11 11:45:49 +02:00
parent 8bab38eac4
commit 507ca73b96
No known key found for this signature in database
41 changed files with 2405 additions and 1545 deletions

View File

@ -145,7 +145,6 @@ support:
- Automate DNS management, for setup and maintenance, such as DANE/DKIM key rotation - Automate DNS management, for setup and maintenance, such as DANE/DKIM key rotation
- Config options for "transactional email domains", for which mox will only - Config options for "transactional email domains", for which mox will only
send messages send messages
- More IMAP extensions (UIDONLY)
- Encrypted storage of files (email messages, TLS keys), also with per account keys - Encrypted storage of files (email messages, TLS keys), also with per account keys
- Recognize common deliverability issues and help postmasters solve them - Recognize common deliverability issues and help postmasters solve them
- JMAP, IMAP OBJECTID extension, IMAP JMAPACCESS extension - JMAP, IMAP OBJECTID extension, IMAP JMAPACCESS extension

View File

@ -388,6 +388,42 @@ func (c *Conn) StoreFlagsClear(seqset string, silent bool, flags ...string) (unt
return c.Transactf("store %s %s (%s)", seqset, item, strings.Join(flags, " ")) return c.Transactf("store %s %s (%s)", seqset, item, strings.Join(flags, " "))
} }
// UIDStoreFlagsSet stores a new set of flags for messages from uid set with
// the UID STORE command.
//
// If silent, no untagged responses with the updated flags will be sent by the
// server.
func (c *Conn) UIDStoreFlagsSet(seqset string, silent bool, flags ...string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
item := "flags"
if silent {
item += ".silent"
}
return c.Transactf("uid store %s %s (%s)", seqset, item, strings.Join(flags, " "))
}
// UIDStoreFlagsAdd is like UIDStoreFlagsSet, but only adds flags, leaving
// current flags on the message intact.
func (c *Conn) UIDStoreFlagsAdd(seqset string, silent bool, flags ...string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
item := "+flags"
if silent {
item += ".silent"
}
return c.Transactf("uid store %s %s (%s)", seqset, item, strings.Join(flags, " "))
}
// UIDStoreFlagsClear is like UIDStoreFlagsSet, but only removes flags, leaving
// other flags on the message intact.
func (c *Conn) UIDStoreFlagsClear(seqset string, silent bool, flags ...string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr)
item := "-flags"
if silent {
item += ".silent"
}
return c.Transactf("uid store %s %s (%s)", seqset, item, strings.Join(flags, " "))
}
// Copy adds messages from the sequences in seqSet in the currently selected/active mailbox to dstMailbox. // Copy adds messages from the sequences in seqSet in the currently selected/active mailbox to dstMailbox.
func (c *Conn) Copy(seqSet NumSet, dstMailbox string) (untagged []Untagged, result Result, rerr error) { func (c *Conn) Copy(seqSet NumSet, dstMailbox string) (untagged []Untagged, result Result, rerr error) {
defer c.recover(&rerr) defer c.recover(&rerr)

View File

@ -136,6 +136,7 @@ var knownCodes = stringMap(
"INPROGRESS", // ../rfc/9585:104 "INPROGRESS", // ../rfc/9585:104
"BADEVENT", "NOTIFICATIONOVERFLOW", // ../rfc/5465:1023 "BADEVENT", "NOTIFICATIONOVERFLOW", // ../rfc/5465:1023
"SERVERBUG", "SERVERBUG",
"UIDREQUIRED", // ../rfc/9586:136
) )
func stringMap(l ...string) map[string]struct{} { func stringMap(l ...string) map[string]struct{} {
@ -654,14 +655,17 @@ func (c *Conn) xuntagged() Untagged {
w = c.xword() w = c.xword()
W = strings.ToUpper(w) W = strings.ToUpper(w)
switch W { switch W {
case "FETCH": case "FETCH", "UIDFETCH":
if num == 0 { if num == 0 {
c.xerrorf("invalid zero number for untagged fetch response") c.xerrorf("invalid zero number for untagged fetch response")
} }
c.xspace() c.xspace()
r := c.xfetch(num) attrs := c.xfetch()
c.xcrlf() c.xcrlf()
return r if W == "UIDFETCH" {
return UntaggedUIDFetch{num, attrs}
}
return UntaggedFetch{num, attrs}
case "EXPUNGE": case "EXPUNGE":
if num == 0 { if num == 0 {
@ -691,14 +695,14 @@ func (c *Conn) xuntagged() Untagged {
// ../rfc/3501:4864 ../rfc/9051:6742 // ../rfc/3501:4864 ../rfc/9051:6742
// Already parsed: "*" SP nznumber SP "FETCH" SP // Already parsed: "*" SP nznumber SP "FETCH" SP
func (c *Conn) xfetch(num uint32) UntaggedFetch { func (c *Conn) xfetch() []FetchAttr {
c.xtake("(") c.xtake("(")
attrs := []FetchAttr{c.xmsgatt1()} attrs := []FetchAttr{c.xmsgatt1()}
for c.space() { for c.space() {
attrs = append(attrs, c.xmsgatt1()) attrs = append(attrs, c.xmsgatt1())
} }
c.xtake(")") c.xtake(")")
return UntaggedFetch{num, attrs} return attrs
} }
// ../rfc/9051:6746 // ../rfc/9051:6746

View File

@ -43,6 +43,7 @@ const (
CapPreview Capability = "PREVIEW" // ../rfc/8970:114 CapPreview Capability = "PREVIEW" // ../rfc/8970:114
CapMultiSearch Capability = "MULTISEARCH" // ../rfc/7377:187 CapMultiSearch Capability = "MULTISEARCH" // ../rfc/7377:187
CapNotify Capability = "NOTIFY" // ../rfc/5465:195 CapNotify Capability = "NOTIFY" // ../rfc/5465:195
CapUIDOnly Capability = "UIDONLY" // ../rfc/9586:129
) )
// Status is the tagged final result of a command. // Status is the tagged final result of a command.
@ -261,6 +262,14 @@ type UntaggedFetch struct {
Seq uint32 Seq uint32
Attrs []FetchAttr Attrs []FetchAttr
} }
// UntaggedUIDFetch is like UntaggedFetch, but with UIDs instead of message
// sequence numbers, and returned instead of regular fetch responses when UIDONLY
// is enabled.
type UntaggedUIDFetch struct {
UID uint32
Attrs []FetchAttr
}
type UntaggedSearch []uint32 type UntaggedSearch []uint32
// ../rfc/7162:1101 // ../rfc/7162:1101

View File

@ -7,22 +7,30 @@ import (
) )
func TestAppend(t *testing.T) { func TestAppend(t *testing.T) {
testAppend(t, false)
}
func TestAppendUIDOnly(t *testing.T) {
testAppend(t, true)
}
func testAppend(t *testing.T, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) // note: with switchboard because this connection stays alive unlike tc2. tc := start(t, uidonly) // note: with switchboard because this connection stays alive unlike tc2.
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) // note: without switchboard because this connection will break during tests. tc2 := startNoSwitchboard(t, uidonly) // note: without switchboard because this connection will break during tests.
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc3 := startNoSwitchboard(t) tc3 := startNoSwitchboard(t, uidonly)
defer tc3.closeNoWait() defer tc3.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc3.client.Login("mjl@mox.example", password0) tc3.login("mjl@mox.example", password0)
tc2.transactf("bad", "append") // Missing params. tc2.transactf("bad", "append") // Missing params.
tc2.transactf("bad", `append inbox`) // Missing message. tc2.transactf("bad", `append inbox`) // Missing message.
@ -30,15 +38,15 @@ func TestAppend(t *testing.T) {
// Syntax error for line ending in literal causes connection abort. // Syntax error for line ending in literal causes connection abort.
tc2.transactf("bad", "append inbox (\\Badflag) {1+}\r\nx") // Unknown flag. tc2.transactf("bad", "append inbox (\\Badflag) {1+}\r\nx") // Unknown flag.
tc2 = startNoSwitchboard(t) tc2 = startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc2.transactf("bad", "append inbox () \"bad time\" {1+}\r\nx") // Bad time. tc2.transactf("bad", "append inbox () \"bad time\" {1+}\r\nx") // Bad time.
tc2 = startNoSwitchboard(t) tc2 = startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc2.transactf("no", "append nobox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" {1}") tc2.transactf("no", "append nobox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" {1}")
@ -52,9 +60,8 @@ func TestAppend(t *testing.T) {
tc2.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("1")}) tc2.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("1")})
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
uid1 := imapclient.FetchUID(1)
flags := imapclient.FetchFlags{`\Seen`, "$label2", "label1"} flags := imapclient.FetchFlags{`\Seen`, "$label2", "label1"}
tc.xuntagged(imapclient.UntaggedExists(1), imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flags}}) tc.xuntagged(imapclient.UntaggedExists(1), tc.untaggedFetch(1, 1, flags))
tc3.transactf("ok", "noop") tc3.transactf("ok", "noop")
tc3.xuntagged() // Inbox is not selected, nothing to report. tc3.xuntagged() // Inbox is not selected, nothing to report.
@ -69,7 +76,6 @@ func TestAppend(t *testing.T) {
// Messages that we cannot parse are marked as application/octet-stream. Perhaps // Messages that we cannot parse are marked as application/octet-stream. Perhaps
// the imap client knows how to deal with them. // the imap client knows how to deal with them.
tc2.transactf("ok", "uid fetch 2 body") tc2.transactf("ok", "uid fetch 2 body")
uid2 := imapclient.FetchUID(2)
xbs := imapclient.FetchBodystructure{ xbs := imapclient.FetchBodystructure{
RespAttr: "BODY", RespAttr: "BODY",
Body: imapclient.BodyTypeBasic{ Body: imapclient.BodyTypeBasic{
@ -80,7 +86,7 @@ func TestAppend(t *testing.T) {
}, },
}, },
} }
tc2.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, xbs}}) tc2.xuntagged(tc.untaggedFetch(2, 2, xbs))
// Multiappend with two messages. // Multiappend with two messages.
tc.transactf("ok", "noop") // Flush pending untagged responses. tc.transactf("ok", "noop") // Flush pending untagged responses.
@ -91,9 +97,9 @@ func TestAppend(t *testing.T) {
// Cancelled with zero-length message. // Cancelled with zero-length message.
tc.transactf("no", "append inbox {6+}\r\ntest\r\n {0+}\r\n") tc.transactf("no", "append inbox {6+}\r\ntest\r\n {0+}\r\n")
tclimit := startArgs(t, false, false, true, true, "limit") tclimit := startArgs(t, uidonly, false, false, true, true, "limit")
defer tclimit.close() defer tclimit.close()
tclimit.client.Login("limit@mox.example", password0) tclimit.login("limit@mox.example", password0)
tclimit.client.Select("inbox") tclimit.client.Select("inbox")
// First message of 1 byte is within limits. // First message of 1 byte is within limits.
tclimit.transactf("ok", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx") tclimit.transactf("ok", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
@ -103,7 +109,11 @@ func TestAppend(t *testing.T) {
tclimit.xcode("OVERQUOTA") tclimit.xcode("OVERQUOTA")
// Empty mailbox. // Empty mailbox.
tclimit.transactf("ok", `store 1 flags (\deleted)`) if uidonly {
tclimit.transactf("ok", `uid store 1 flags (\deleted)`)
} else {
tclimit.transactf("ok", `store 1 flags (\deleted)`)
}
tclimit.transactf("ok", "expunge") tclimit.transactf("ok", "expunge")
// Multiappend with first message within quota, and second message with sync // Multiappend with first message within quota, and second message with sync

View File

@ -27,13 +27,13 @@ import (
func TestAuthenticateLogin(t *testing.T) { func TestAuthenticateLogin(t *testing.T) {
// NFD username and PRECIS-cleaned password. // NFD username and PRECIS-cleaned password.
tc := start(t) tc := start(t, false)
tc.client.Login("mo\u0301x@mox.example", password1) tc.client.Login("mo\u0301x@mox.example", password1)
tc.close() tc.close()
} }
func TestAuthenticatePlain(t *testing.T) { func TestAuthenticatePlain(t *testing.T) {
tc := start(t) tc := start(t, false)
tc.transactf("no", "authenticate bogus ") tc.transactf("no", "authenticate bogus ")
tc.transactf("bad", "authenticate plain not base64...") tc.transactf("bad", "authenticate plain not base64...")
@ -54,20 +54,20 @@ func TestAuthenticatePlain(t *testing.T) {
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0))) tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0)))
tc.close() tc.close()
tc = start(t) tc = start(t, false)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("mjl@mox.example\u0000mjl@mox.example\u0000"+password0))) tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("mjl@mox.example\u0000mjl@mox.example\u0000"+password0)))
tc.close() tc.close()
// NFD username and PRECIS-cleaned password. // NFD username and PRECIS-cleaned password.
tc = start(t) tc = start(t, false)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("mo\u0301x@mox.example\u0000mo\u0301x@mox.example\u0000"+password1))) tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("mo\u0301x@mox.example\u0000mo\u0301x@mox.example\u0000"+password1)))
tc.close() tc.close()
tc = start(t) tc = start(t, false)
tc.client.AuthenticatePlain("mjl@mox.example", password0) tc.client.AuthenticatePlain("mjl@mox.example", password0)
tc.close() tc.close()
tc = start(t) tc = start(t, false)
defer tc.close() defer tc.close()
tc.cmdf("", "authenticate plain") tc.cmdf("", "authenticate plain")
@ -82,7 +82,7 @@ func TestAuthenticatePlain(t *testing.T) {
} }
func TestLoginDisabled(t *testing.T) { func TestLoginDisabled(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
acc, err := store.OpenAccount(pkglog, "disabled", false) acc, err := store.OpenAccount(pkglog, "disabled", false)
@ -120,7 +120,7 @@ func TestAuthenticateSCRAMSHA256PLUS(t *testing.T) {
} }
func testAuthenticateSCRAM(t *testing.T, tls bool, method string, h func() hash.Hash) { func testAuthenticateSCRAM(t *testing.T, tls bool, method string, h func() hash.Hash) {
tc := startArgs(t, true, tls, true, true, "mjl") tc := startArgs(t, false, true, tls, true, true, "mjl")
tc.client.AuthenticateSCRAM(method, h, "mjl@mox.example", password0) tc.client.AuthenticateSCRAM(method, h, "mjl@mox.example", password0)
tc.close() tc.close()
@ -168,7 +168,7 @@ func testAuthenticateSCRAM(t *testing.T, tls bool, method string, h func() hash.
} }
} }
tc = startArgs(t, true, tls, true, true, "mjl") tc = startArgs(t, false, true, tls, true, true, "mjl")
auth("no", scram.ErrInvalidProof, "mjl@mox.example", "badpass") auth("no", scram.ErrInvalidProof, "mjl@mox.example", "badpass")
auth("no", scram.ErrInvalidProof, "mjl@mox.example", "") auth("no", scram.ErrInvalidProof, "mjl@mox.example", "")
// todo: server aborts due to invalid username. we should probably make client continue with fake determinisitically generated salt and result in error in the end. // todo: server aborts due to invalid username. we should probably make client continue with fake determinisitically generated salt and result in error in the end.
@ -185,7 +185,7 @@ func testAuthenticateSCRAM(t *testing.T, tls bool, method string, h func() hash.
} }
func TestAuthenticateCRAMMD5(t *testing.T) { func TestAuthenticateCRAMMD5(t *testing.T) {
tc := start(t) tc := start(t, false)
tc.transactf("no", "authenticate bogus ") tc.transactf("no", "authenticate bogus ")
tc.transactf("bad", "authenticate CRAM-MD5 not base64...") tc.transactf("bad", "authenticate CRAM-MD5 not base64...")
@ -234,13 +234,13 @@ func TestAuthenticateCRAMMD5(t *testing.T) {
tc.close() tc.close()
// NFD username, with PRECIS-cleaned password. // NFD username, with PRECIS-cleaned password.
tc = start(t) tc = start(t, false)
auth("ok", "mo\u0301x@mox.example", password1) auth("ok", "mo\u0301x@mox.example", password1)
tc.close() tc.close()
} }
func TestAuthenticateTLSClientCert(t *testing.T) { func TestAuthenticateTLSClientCert(t *testing.T) {
tc := startArgsMore(t, true, true, nil, nil, true, true, "mjl", nil) tc := startArgsMore(t, false, true, true, nil, nil, true, true, "mjl", nil)
tc.transactf("no", "authenticate external ") // No TLS auth. tc.transactf("no", "authenticate external ") // No TLS auth.
tc.close() tc.close()
@ -263,7 +263,7 @@ func TestAuthenticateTLSClientCert(t *testing.T) {
} }
// No preauth, explicit authenticate with TLS. // No preauth, explicit authenticate with TLS.
tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, "mjl", addClientCert) tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
if tc.client.Preauth { if tc.client.Preauth {
t.Fatalf("preauthentication while not configured for tls public key") t.Fatalf("preauthentication while not configured for tls public key")
} }
@ -271,7 +271,7 @@ func TestAuthenticateTLSClientCert(t *testing.T) {
tc.close() tc.close()
// External with explicit username. // External with explicit username.
tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, "mjl", addClientCert) tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
if tc.client.Preauth { if tc.client.Preauth {
t.Fatalf("preauthentication while not configured for tls public key") t.Fatalf("preauthentication while not configured for tls public key")
} }
@ -279,12 +279,12 @@ func TestAuthenticateTLSClientCert(t *testing.T) {
tc.close() tc.close()
// No preauth, also allow other mechanisms. // No preauth, also allow other mechanisms.
tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, "mjl", addClientCert) tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0))) tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0)))
tc.close() tc.close()
// No preauth, also allow other username for same account. // No preauth, also allow other username for same account.
tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, "mjl", addClientCert) tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000móx@mox.example\u0000"+password0))) tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000móx@mox.example\u0000"+password0)))
tc.close() tc.close()
@ -295,12 +295,12 @@ func TestAuthenticateTLSClientCert(t *testing.T) {
tcheck(t, err, "set password") tcheck(t, err, "set password")
err = acc.Close() err = acc.Close()
tcheck(t, err, "close account") tcheck(t, err, "close account")
tc = startArgsMore(t, true, true, nil, &clientConfig, false, true, "mjl", addClientCert) tc = startArgsMore(t, false, true, true, nil, &clientConfig, false, true, "mjl", addClientCert)
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000other@mox.example\u0000test1234"))) tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000other@mox.example\u0000test1234")))
tc.close() tc.close()
// Starttls and external auth. // Starttls and external auth.
tc = startArgsMore(t, true, false, nil, &clientConfig, false, true, "mjl", addClientCert) tc = startArgsMore(t, false, true, false, nil, &clientConfig, false, true, "mjl", addClientCert)
tc.client.Starttls(&clientConfig) tc.client.Starttls(&clientConfig)
tc.transactf("ok", "authenticate external =") tc.transactf("ok", "authenticate external =")
tc.close() tc.close()
@ -318,7 +318,7 @@ func TestAuthenticateTLSClientCert(t *testing.T) {
defer cancel() defer cancel()
mox.StartTLSSessionTicketKeyRefresher(ctx, pkglog, &serverConfig) mox.StartTLSSessionTicketKeyRefresher(ctx, pkglog, &serverConfig)
clientConfig.ClientSessionCache = tls.NewLRUClientSessionCache(10) clientConfig.ClientSessionCache = tls.NewLRUClientSessionCache(10)
tc = startArgsMore(t, true, true, &serverConfig, &clientConfig, false, true, "mjl", addClientCert) tc = startArgsMore(t, false, true, true, &serverConfig, &clientConfig, false, true, "mjl", addClientCert)
if !tc.client.Preauth { if !tc.client.Preauth {
t.Fatalf("not preauthentication while configured for tls public key") t.Fatalf("not preauthentication while configured for tls public key")
} }
@ -330,7 +330,7 @@ func TestAuthenticateTLSClientCert(t *testing.T) {
tc.close() tc.close()
// Authentication works with TLS resumption. // Authentication works with TLS resumption.
tc = startArgsMore(t, true, true, &serverConfig, &clientConfig, false, true, "mjl", addClientCert) tc = startArgsMore(t, false, true, true, &serverConfig, &clientConfig, false, true, "mjl", addClientCert)
if !tc.client.Preauth { if !tc.client.Preauth {
t.Fatalf("not preauthentication while configured for tls public key") t.Fatalf("not preauthentication while configured for tls public key")
} }

View File

@ -10,10 +10,10 @@ import (
) )
func TestCompress(t *testing.T) { func TestCompress(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.transactf("bad", "compress") tc.transactf("bad", "compress")
tc.transactf("bad", "compress bogus ") tc.transactf("bad", "compress bogus ")
@ -30,11 +30,11 @@ func TestCompress(t *testing.T) {
} }
func TestCompressStartTLS(t *testing.T) { func TestCompressStartTLS(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Starttls(&tls.Config{InsecureSkipVerify: true}) tc.client.Starttls(&tls.Config{InsecureSkipVerify: true})
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.CompressDeflate() tc.client.CompressDeflate()
tc.client.Select("inbox") tc.client.Select("inbox")
tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(exampleMsg), exampleMsg) tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(exampleMsg), exampleMsg)
@ -53,7 +53,7 @@ func TestCompressBreak(t *testing.T) {
// state inconsistent. We must not call into the flate writer again because due to // state inconsistent. We must not call into the flate writer again because due to
// its broken internal state it may cause array out of bounds accesses. // its broken internal state it may cause array out of bounds accesses.
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
msg := exampleMsg msg := exampleMsg
@ -69,7 +69,7 @@ func TestCompressBreak(t *testing.T) {
text = text[n:] text = text[n:]
} }
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.CompressDeflate() tc.client.CompressDeflate()
tc.client.Select("inbox") tc.client.Select("inbox")
tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(msg), msg) tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(msg), msg)

View File

@ -14,16 +14,24 @@ import (
) )
func TestCondstore(t *testing.T) { func TestCondstore(t *testing.T) {
testCondstoreQresync(t, false) testCondstoreQresync(t, false, false)
}
func TestCondstoreUIDOnly(t *testing.T) {
testCondstoreQresync(t, false, true)
} }
func TestQresync(t *testing.T) { func TestQresync(t *testing.T) {
testCondstoreQresync(t, true) testCondstoreQresync(t, true, false)
} }
func testCondstoreQresync(t *testing.T, qresync bool) { func TestQresyncUIDOnly(t *testing.T) {
testCondstoreQresync(t, true, true)
}
func testCondstoreQresync(t *testing.T, qresync, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
// todo: check whether marking \seen will cause modseq to be returned in case of qresync. // todo: check whether marking \seen will cause modseq to be returned in case of qresync.
@ -35,7 +43,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
capability = "Qresync" capability = "Qresync"
} }
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Enable(capability) tc.client.Enable(capability)
tc.transactf("ok", "Select inbox") tc.transactf("ok", "Select inbox")
tc.xuntaggedOpt(false, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(2), More: "x"}}) tc.xuntaggedOpt(false, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(2), More: "x"}})
@ -57,25 +65,27 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.transactf("ok", "Uid Fetch 1:* (Flags Modseq) (Changedsince 1)") tc.transactf("ok", "Uid Fetch 1:* (Flags Modseq) (Changedsince 1)")
tc.xuntagged() tc.xuntagged()
// Search with modseq search criteria. if !uidonly {
tc.transactf("ok", "Search Modseq 0") // Zero is valid, matches all. // Search with modseq search criteria.
tc.xsearch() tc.transactf("ok", "Search Modseq 0") // Zero is valid, matches all.
tc.xsearch()
tc.transactf("ok", "Search Modseq 1") // Converted to zero internally. tc.transactf("ok", "Search Modseq 1") // Converted to zero internally.
tc.xsearch() tc.xsearch()
tc.transactf("ok", "Search Modseq 12345") tc.transactf("ok", "Search Modseq 12345")
tc.xsearch() tc.xsearch()
tc.transactf("ok", `Search Modseq "/Flags/\\Draft" All 12345`) tc.transactf("ok", `Search Modseq "/Flags/\\Draft" All 12345`)
tc.xsearch() tc.xsearch()
tc.transactf("ok", `Search Or Modseq 12345 Modseq 54321`) tc.transactf("ok", `Search Or Modseq 12345 Modseq 54321`)
tc.xsearch() tc.xsearch()
// esearch // esearch
tc.transactf("ok", "Search Return (All) Modseq 123") tc.transactf("ok", "Search Return (All) Modseq 123")
tc.xesearch(imapclient.UntaggedEsearch{}) tc.xesearch(imapclient.UntaggedEsearch{})
}
// Now we add, delete, expunge, modify some message flags and check if the // Now we add, delete, expunge, modify some message flags and check if the
// responses are correct. We check in both a condstore-enabled and one without that // responses are correct. We check in both a condstore-enabled and one without that
@ -102,15 +112,15 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.client.Create("otherbox", nil) tc.client.Create("otherbox", nil)
// tc2 is a client without condstore, so no modseq responses. // tc2 is a client without condstore, so no modseq responses.
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
// tc3 is a client with condstore, so with modseq responses. // tc3 is a client with condstore, so with modseq responses.
tc3 := startNoSwitchboard(t) tc3 := startNoSwitchboard(t, uidonly)
defer tc3.closeNoWait() defer tc3.closeNoWait()
tc3.client.Login("mjl@mox.example", password0) tc3.login("mjl@mox.example", password0)
tc3.client.Enable(capability) tc3.client.Enable(capability)
tc3.client.Select("inbox") tc3.client.Select("inbox")
@ -141,24 +151,26 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
noflags := imapclient.FetchFlags(nil) noflags := imapclient.FetchFlags(nil)
tc2.xuntagged( tc2.xuntagged(
imapclient.UntaggedExists(6), imapclient.UntaggedExists(6),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), noflags}}, tc2.untaggedFetch(4, 4, noflags),
imapclient.UntaggedFetch{Seq: 5, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags}}, tc2.untaggedFetch(5, 5, noflags),
imapclient.UntaggedFetch{Seq: 6, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags}}, tc2.untaggedFetch(6, 6, noflags),
) )
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
tc3.xuntagged( tc3.xuntagged(
imapclient.UntaggedExists(6), imapclient.UntaggedExists(6),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), noflags, imapclient.FetchModSeq(clientModseq + 1)}}, tc3.untaggedFetch(4, 4, noflags, imapclient.FetchModSeq(clientModseq+1)),
imapclient.UntaggedFetch{Seq: 5, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(clientModseq + 3)}}, tc3.untaggedFetch(5, 5, noflags, imapclient.FetchModSeq(clientModseq+3)),
imapclient.UntaggedFetch{Seq: 6, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq + 4)}}, tc3.untaggedFetch(6, 6, noflags, imapclient.FetchModSeq(clientModseq+4)),
) )
mox.SetPedantic(true) if !uidonly {
tc.transactf("bad", `Fetch 1 Flags (Changedsince 0)`) // 0 not allowed in syntax. mox.SetPedantic(true)
mox.SetPedantic(false) tc.transactf("bad", `Fetch 1 Flags (Changedsince 0)`) // 0 not allowed in syntax.
mox.SetPedantic(false)
}
tc.transactf("ok", "Uid fetch 1 (Flags) (Changedsince 0)") tc.transactf("ok", "Uid fetch 1 (Flags) (Changedsince 0)")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(1)}}) tc.xuntagged(tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(1)))
// Check highestmodseq for mailboxes. // Check highestmodseq for mailboxes.
tc.transactf("ok", "Status inbox (highestmodseq)") tc.transactf("ok", "Status inbox (highestmodseq)")
@ -176,54 +188,67 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
clientModseq += 4 clientModseq += 4
// Check fetch modseq response and changedsince. if !uidonly {
tc.transactf("ok", `Fetch 1 (Modseq)`) // Check fetch modseq response and changedsince.
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchModSeq(1)}}) tc.transactf("ok", `Fetch 1 (Modseq)`)
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchModSeq(1)))
}
// Without modseq attribute, even with condseq enabled, there is no modseq response. // Without modseq attribute, even with condseq enabled, there is no modseq response.
// For QRESYNC, we must always send MODSEQ for UID FETCH commands, but not for FETCH commands. ../rfc/7162:1427 // For QRESYNC, we must always send MODSEQ for UID FETCH commands, but not for FETCH commands. ../rfc/7162:1427
tc.transactf("ok", `Uid Fetch 1 Flags`) tc.transactf("ok", `Uid Fetch 1 Flags`)
if qresync { if qresync {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(1)}}) tc.xuntagged(tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(1)))
} else { } else {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
} }
tc.transactf("ok", `Fetch 1 Flags`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags}})
// When CHANGEDSINCE is present, MODSEQ is automatically added to the response. if !uidonly {
// ../rfc/7162:871 tc.transactf("ok", `Fetch 1 Flags`)
// ../rfc/7162:877 tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
tc.transactf("ok", `Fetch 1 Flags (Changedsince 1)`) }
tc.xuntagged()
tc.transactf("ok", `Fetch 1,4 Flags (Changedsince 1)`) if !uidonly {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), noflags, imapclient.FetchModSeq(3)}}) // When CHANGEDSINCE is present, MODSEQ is automatically added to the response.
tc.transactf("ok", `Fetch 2 Flags (Changedsince 2)`) // ../rfc/7162:871
tc.xuntagged() // ../rfc/7162:877
tc.transactf("ok", `Fetch 1 Flags (Changedsince 1)`)
tc.xuntagged()
tc.transactf("ok", `Fetch 1,4 Flags (Changedsince 1)`)
tc.xuntagged(tc.untaggedFetch(4, 4, noflags, imapclient.FetchModSeq(3)))
tc.transactf("ok", `Fetch 2 Flags (Changedsince 2)`)
tc.xuntagged()
}
// store and uid store. // store and uid store.
// unchangedsince 0 never passes the check. ../rfc/7162:640 if !uidonly {
tc.transactf("ok", `Store 1 (Unchangedsince 0) +Flags ()`) // unchangedsince 0 never passes the check. ../rfc/7162:640
tc.xcodeArg(imapclient.CodeModified(xparseNumSet("1"))) tc.transactf("ok", `Store 1 (Unchangedsince 0) +Flags ()`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(1)}}) tc.xcodeArg(imapclient.CodeModified(xparseNumSet("1")))
tc.xuntagged(tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(1)))
}
// Modseq is 2 for first condstore-aware-appended message, so also no match. // Modseq is 2 for first condstore-aware-appended message, so also no match.
tc.transactf("ok", `Uid Store 4 (Unchangedsince 1) +Flags ()`) tc.transactf("ok", `Uid Store 4 (Unchangedsince 1) +Flags ()`)
tc.xcodeArg(imapclient.CodeModified(xparseNumSet("4"))) tc.xcodeArg(imapclient.CodeModified(xparseNumSet("4")))
// Modseq is 1 for original message. if uidonly {
tc.transactf("ok", `Store 1 (Unchangedsince 1) +Flags (label1)`) tc.transactf("ok", `Uid Store 1 (Unchangedsince 1) +Flags (label1)`)
} else {
// Modseq is 1 for original message.
tc.transactf("ok", `Store 1 (Unchangedsince 1) +Flags (label1)`)
}
tc.xcode("") // No MODIFIED. tc.xcode("") // No MODIFIED.
clientModseq++ clientModseq++
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{"label1"}, imapclient.FetchModSeq(clientModseq)}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{"label1"}, imapclient.FetchModSeq(clientModseq)))
tc2.transactf("ok", "Noop") tc2.transactf("ok", "Noop")
tc2.xuntagged( tc2.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{"label1"}}}, tc2.untaggedFetch(1, 1, imapclient.FetchFlags{"label1"}),
) )
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
tc3.xuntagged( tc3.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{"label1"}, imapclient.FetchModSeq(clientModseq)}}, tc3.untaggedFetch(1, 1, imapclient.FetchFlags{"label1"}, imapclient.FetchModSeq(clientModseq)),
) )
// Modify same message twice. Check that second application doesn't fail due to // Modify same message twice. Check that second application doesn't fail due to
@ -232,65 +257,76 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
clientModseq++ clientModseq++
tc.xcode("") // No MODIFIED. tc.xcode("") // No MODIFIED.
tc.xuntagged( tc.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)),
) )
// We do broadcast the changes twice. Not great, but doesn't hurt. This isn't common. // We do broadcast the changes twice. Not great, but doesn't hurt. This isn't common.
tc2.transactf("ok", "Noop") tc2.transactf("ok", "Noop")
tc2.xuntagged( tc2.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil)}}, tc2.untaggedFetch(1, 1, imapclient.FetchFlags(nil)),
) )
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
tc3.xuntagged( tc3.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)}}, tc3.untaggedFetch(1, 1, imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)),
) )
// Modify without actually changing flags, there will be no new modseq and no broadcast. if !uidonly {
tc.transactf("ok", `Store 1 (Unchangedsince %d) -Flags (label1)`, clientModseq) // Modify without actually changing flags, there will be no new modseq and no broadcast.
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)}}) tc.transactf("ok", `Store 1 (Unchangedsince %d) -Flags (label1)`, clientModseq)
tc.xcode("") // No MODIFIED. tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil), imapclient.FetchModSeq(clientModseq)))
tc2.transactf("ok", "Noop") tc.xcode("") // No MODIFIED.
tc2.xuntagged() tc2.transactf("ok", "Noop")
tc3.transactf("ok", "Noop") tc2.xuntagged()
tc3.xuntagged() tc3.transactf("ok", "Noop")
tc3.xuntagged()
// search with modseq criteria and modseq in response // search with modseq criteria and modseq in response
tc.transactf("ok", "Search Modseq %d", clientModseq) tc.transactf("ok", "Search Modseq %d", clientModseq)
tc.xsearchmodseq(clientModseq, 1) tc.xsearchmodseq(clientModseq, 1)
}
tc.transactf("ok", "Uid Search Or Modseq %d Modseq %d", clientModseq, clientModseq) tc.transactf("ok", "Uid Search Or Modseq %d Modseq %d", clientModseq, clientModseq)
tc.xsearchmodseq(clientModseq, 1) tc.xsearchmodseq(clientModseq, 1)
// esearch if !uidonly {
tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq %d", clientModseq) // esearch
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1, All: esearchall0("1"), ModSeq: clientModseq}) tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq %d", clientModseq)
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1, All: esearchall0("1"), ModSeq: clientModseq})
tc.transactf("ok", "Search Return (Count) 1:* Modseq 0") tc.transactf("ok", "Search Return (Count) 1:* Modseq 0")
tc.xesearch(imapclient.UntaggedEsearch{Count: uint32ptr(6), ModSeq: clientModseq}) tc.xesearch(imapclient.UntaggedEsearch{Count: uint32ptr(6), ModSeq: clientModseq})
tc.transactf("ok", "Search Return (Min Max) 1:* Modseq 0") tc.transactf("ok", "Search Return (Min Max) 1:* Modseq 0")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 6, ModSeq: clientModseq}) tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 6, ModSeq: clientModseq})
tc.transactf("ok", "Search Return (Min) 1:* Modseq 0") tc.transactf("ok", "Search Return (Min) 1:* Modseq 0")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, ModSeq: clientModseq}) tc.xesearch(imapclient.UntaggedEsearch{Min: 1, ModSeq: clientModseq})
// expunge, we expunge the third and fourth messages. The third was originally with // expunge, we expunge the third and fourth messages. The third was originally with
// modseq 0, the fourth was added with condstore-aware append. // modseq 0, the fourth was added with condstore-aware append.
tc.transactf("ok", `Store 3:4 +Flags (\Deleted)`) tc.transactf("ok", `Store 3:4 +Flags (\Deleted)`)
clientModseq++ clientModseq++
} else {
tc.transactf("ok", `Uid Store 3,4 +Flags (\Deleted)`)
clientModseq++
}
tc2.transactf("ok", "Noop") tc2.transactf("ok", "Noop")
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
tc.transactf("ok", "Expunge") tc.transactf("ok", "Expunge")
clientModseq++ clientModseq++
if qresync { if qresync || uidonly {
tc.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("3:4")}) tc.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("3:4")})
} else { } else {
tc.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3)) tc.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3))
} }
tc.xcodeArg(imapclient.CodeHighestModSeq(clientModseq)) tc.xcodeArg(imapclient.CodeHighestModSeq(clientModseq))
tc2.transactf("ok", "Noop") tc2.transactf("ok", "Noop")
tc2.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3)) if uidonly {
tc2.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("3:4")})
} else {
tc2.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3))
}
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
if qresync { if qresync || uidonly {
tc3.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("3:4")}) tc3.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("3:4")})
} else { } else {
tc3.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3)) tc3.xuntagged(imapclient.UntaggedExpunge(3), imapclient.UntaggedExpunge(3))
@ -307,28 +343,32 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(clientModseq), More: "x"}}, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(clientModseq), More: "x"}},
) )
tc.transactf("ok", `Fetch 1:* (Modseq)`) if !uidonly {
tc.xuntagged( tc.transactf("ok", `Fetch 1:* (Modseq)`)
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchModSeq(8)}}, tc.xuntagged(
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), imapclient.FetchModSeq(1)}}, tc.untaggedFetch(1, 1, imapclient.FetchModSeq(8)),
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), imapclient.FetchModSeq(5)}}, tc.untaggedFetch(2, 2, imapclient.FetchModSeq(1)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), imapclient.FetchModSeq(6)}}, tc.untaggedFetch(3, 5, imapclient.FetchModSeq(5)),
) tc.untaggedFetch(4, 6, imapclient.FetchModSeq(6)),
)
}
// Expunged messages, with higher modseq, should not show up. // Expunged messages, with higher modseq, should not show up.
tc.transactf("ok", "Uid Fetch 1:* (flags) (Changedsince 8)") tc.transactf("ok", "Uid Fetch 1:* (flags) (Changedsince 8)")
tc.xuntagged() tc.xuntagged()
// search if !uidonly {
tc.transactf("ok", "Search Modseq 8") // search
tc.xsearchmodseq(8, 1) tc.transactf("ok", "Search Modseq 8")
tc.transactf("ok", "Search Modseq 9") tc.xsearchmodseq(8, 1)
tc.xsearch() tc.transactf("ok", "Search Modseq 9")
tc.xsearch()
// esearch // esearch
tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq 8") tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq 8")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1, All: esearchall0("1"), ModSeq: 8}) tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1, All: esearchall0("1"), ModSeq: 8})
tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq 9") tc.transactf("ok", "Search Return (Min Max All) 1:* Modseq 9")
tc.xuntagged(imapclient.UntaggedEsearch{Tag: tc.client.LastTag}) tc.xuntagged(imapclient.UntaggedEsearch{Tag: tc.client.LastTag})
}
// store, cannot modify expunged messages. // store, cannot modify expunged messages.
tc.transactf("ok", `Uid Store 3,4 (Unchangedsince %d) +Flags (label2)`, clientModseq) tc.transactf("ok", `Uid Store 3,4 (Unchangedsince %d) +Flags (label2)`, clientModseq)
@ -350,18 +390,18 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
checkCondstoreEnabled := func(fn func(xtc *testconn)) { checkCondstoreEnabled := func(fn func(xtc *testconn)) {
t.Helper() t.Helper()
xtc := startNoSwitchboard(t) xtc := startNoSwitchboard(t, uidonly)
// We have modified modseq & createseq to 0 above for testing that case. Don't // We have modified modseq & createseq to 0 above for testing that case. Don't
// trigger the consistency checker. // trigger the consistency checker.
defer xtc.closeNoWait() defer xtc.closeNoWait()
xtc.client.Login("mjl@mox.example", password0) xtc.login("mjl@mox.example", password0)
fn(xtc) fn(xtc)
tagcount++ tagcount++
label := fmt.Sprintf("l%d", tagcount) label := fmt.Sprintf("l%d", tagcount)
tc.transactf("ok", "Store 4 Flags (%s)", label) tc.transactf("ok", "Uid Store 6 Flags (%s)", label)
clientModseq++ clientModseq++
xtc.transactf("ok", "Noop") xtc.transactf("ok", "Noop")
xtc.xuntagged(imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), imapclient.FetchFlags{label}, imapclient.FetchModSeq(clientModseq)}}) xtc.xuntagged(xtc.untaggedFetch(4, 6, imapclient.FetchFlags{label}, imapclient.FetchModSeq(clientModseq)))
} }
// SELECT/EXAMINE with CONDSTORE parameter, ../rfc/7162:373 // SELECT/EXAMINE with CONDSTORE parameter, ../rfc/7162:373
checkCondstoreEnabled(func(xtc *testconn) { checkCondstoreEnabled(func(xtc *testconn) {
@ -378,25 +418,25 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
checkCondstoreEnabled(func(xtc *testconn) { checkCondstoreEnabled(func(xtc *testconn) {
t.Helper() t.Helper()
xtc.transactf("ok", "Select inbox") xtc.transactf("ok", "Select inbox")
xtc.transactf("ok", "Fetch 4 (Modseq)") xtc.transactf("ok", "Uid Fetch 6 (Modseq)")
}) })
// SEARCH with MODSEQ ../rfc/7162:377 // SEARCH with MODSEQ ../rfc/7162:377
checkCondstoreEnabled(func(xtc *testconn) { checkCondstoreEnabled(func(xtc *testconn) {
t.Helper() t.Helper()
xtc.transactf("ok", "Select inbox") xtc.transactf("ok", "Select inbox")
xtc.transactf("ok", "Search 4 Modseq 1") xtc.transactf("ok", "Uid Search Uid 6 Modseq 1")
}) })
// FETCH with CHANGEDSINCE ../rfc/7162:380 // FETCH with CHANGEDSINCE ../rfc/7162:380
checkCondstoreEnabled(func(xtc *testconn) { checkCondstoreEnabled(func(xtc *testconn) {
t.Helper() t.Helper()
xtc.transactf("ok", "Select inbox") xtc.transactf("ok", "Select inbox")
xtc.transactf("ok", "Fetch 4 (Flags) (Changedsince %d)", clientModseq) xtc.transactf("ok", "Uid Fetch 6 (Flags) (Changedsince %d)", clientModseq)
}) })
// STORE with UNCHANGEDSINCE ../rfc/7162:382 // STORE with UNCHANGEDSINCE ../rfc/7162:382
checkCondstoreEnabled(func(xtc *testconn) { checkCondstoreEnabled(func(xtc *testconn) {
t.Helper() t.Helper()
xtc.transactf("ok", "Select inbox") xtc.transactf("ok", "Select inbox")
xtc.transactf("ok", "Store 4 (Unchangedsince 0) Flags ()") xtc.transactf("ok", "Uid Store 6 (Unchangedsince 0) Flags ()")
}) })
// ENABLE CONDSTORE ../rfc/7162:384 // ENABLE CONDSTORE ../rfc/7162:384
checkCondstoreEnabled(func(xtc *testconn) { checkCondstoreEnabled(func(xtc *testconn) {
@ -410,11 +450,12 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
xtc.transactf("ok", "Enable Qresync") xtc.transactf("ok", "Enable Qresync")
xtc.transactf("ok", "Select inbox") xtc.transactf("ok", "Select inbox")
}) })
tc.transactf("ok", "Store 4 Flags ()")
clientModseq++
if qresync { if qresync {
testQresync(t, tc, clientModseq) tc.transactf("ok", "Uid Store 6 Flags ()")
clientModseq++
testQresync(t, tc, uidonly, clientModseq)
} }
// Continue with some tests that further change the data. // Continue with some tests that further change the data.
@ -423,31 +464,31 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.transactf("ok", "Select otherbox") tc.transactf("ok", "Select otherbox")
tc2.transactf("ok", "Noop") tc2.transactf("ok", "Noop")
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
tc.transactf("ok", "Copy 1 inbox") tc.transactf("ok", "Uid Copy 1 inbox")
clientModseq++ clientModseq++
tc2.transactf("ok", "Noop") tc2.transactf("ok", "Noop")
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
tc2.xuntagged( tc2.xuntagged(
imapclient.UntaggedExists(5), imapclient.UntaggedExists(5),
imapclient.UntaggedFetch{Seq: 5, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(7), noflags}}, tc2.untaggedFetch(5, 7, noflags),
) )
tc3.xuntagged( tc3.xuntagged(
imapclient.UntaggedExists(5), imapclient.UntaggedExists(5),
imapclient.UntaggedFetch{Seq: 5, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(7), noflags, imapclient.FetchModSeq(clientModseq)}}, tc3.untaggedFetch(5, 7, noflags, imapclient.FetchModSeq(clientModseq)),
) )
// Then we move some messages, and check if we get expunged/vanished in original // Then we move some messages, and check if we get expunged/vanished in original
// and untagged fetch with modseq in destination mailbox. // and untagged fetch with modseq in destination mailbox.
// tc2o is a client without condstore, so no modseq responses. // tc2o is a client without condstore, so no modseq responses.
tc2o := startNoSwitchboard(t) tc2o := startNoSwitchboard(t, uidonly)
defer tc2o.closeNoWait() defer tc2o.closeNoWait()
tc2o.client.Login("mjl@mox.example", password0) tc2o.login("mjl@mox.example", password0)
tc2o.client.Select("otherbox") tc2o.client.Select("otherbox")
// tc3o is a client with condstore, so with modseq responses. // tc3o is a client with condstore, so with modseq responses.
tc3o := startNoSwitchboard(t) tc3o := startNoSwitchboard(t, uidonly)
defer tc3o.closeNoWait() defer tc3o.closeNoWait()
tc3o.client.Login("mjl@mox.example", password0) tc3o.login("mjl@mox.example", password0)
tc3o.client.Enable(capability) tc3o.client.Enable(capability)
tc3o.client.Select("otherbox") tc3o.client.Select("otherbox")
@ -457,14 +498,21 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
if qresync { if qresync {
tc.xuntaggedOpt(false, imapclient.UntaggedVanished{UIDs: xparseNumSet("2")}) tc.xuntaggedOpt(false, imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
tc.xcodeArg(imapclient.CodeHighestModSeq(clientModseq)) tc.xcodeArg(imapclient.CodeHighestModSeq(clientModseq))
} else if uidonly {
tc.xuntaggedOpt(false, imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
tc.xcode("")
} else { } else {
tc.xuntaggedOpt(false, imapclient.UntaggedExpunge(2)) tc.xuntaggedOpt(false, imapclient.UntaggedExpunge(2))
tc.xcode("") tc.xcode("")
} }
tc2.transactf("ok", "Noop") tc2.transactf("ok", "Noop")
tc2.xuntagged(imapclient.UntaggedExpunge(2)) if uidonly {
tc2.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
} else {
tc2.xuntagged(imapclient.UntaggedExpunge(2))
}
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
if qresync { if qresync || uidonly {
tc3.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("2")}) tc3.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
} else { } else {
tc3.xuntagged(imapclient.UntaggedExpunge(2)) tc3.xuntagged(imapclient.UntaggedExpunge(2))
@ -472,12 +520,12 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc2o.transactf("ok", "Noop") tc2o.transactf("ok", "Noop")
tc2o.xuntagged( tc2o.xuntagged(
imapclient.UntaggedExists(2), imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), noflags}}, tc2o.untaggedFetch(2, 2, noflags),
) )
tc3o.transactf("ok", "Noop") tc3o.transactf("ok", "Noop")
tc3o.xuntagged( tc3o.xuntagged(
imapclient.UntaggedExists(2), imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), noflags, imapclient.FetchModSeq(clientModseq)}}, tc2o.untaggedFetch(2, 2, noflags, imapclient.FetchModSeq(clientModseq)),
) )
tc2o.closeNoWait() tc2o.closeNoWait()
@ -491,12 +539,19 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.transactf("ok", "Rename inbox oldbox") tc.transactf("ok", "Rename inbox oldbox")
// todo spec: server doesn't respond with untagged responses, find rfc reference that says this is ok. // todo spec: server doesn't respond with untagged responses, find rfc reference that says this is ok.
tc2.transactf("ok", "Noop") tc2.transactf("ok", "Noop")
tc2.xuntagged( if uidonly {
imapclient.UntaggedList{Separator: '/', Mailbox: "oldbox"}, tc2.xuntagged(
imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1), imapclient.UntaggedList{Separator: '/', Mailbox: "oldbox"},
) imapclient.UntaggedVanished{UIDs: xparseNumSet("1,5:7")},
)
} else {
tc2.xuntagged(
imapclient.UntaggedList{Separator: '/', Mailbox: "oldbox"},
imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1),
)
}
tc3.transactf("ok", "Noop") tc3.transactf("ok", "Noop")
if qresync { if qresync || uidonly {
tc3.xuntagged( tc3.xuntagged(
imapclient.UntaggedList{Separator: '/', Mailbox: "oldbox"}, imapclient.UntaggedList{Separator: '/', Mailbox: "oldbox"},
imapclient.UntaggedVanished{UIDs: xparseNumSet("1,5:7")}, imapclient.UntaggedVanished{UIDs: xparseNumSet("1,5:7")},
@ -512,7 +567,7 @@ func testCondstoreQresync(t *testing.T, qresync bool) {
tc.transactf("ok", "Delete otherbox") tc.transactf("ok", "Delete otherbox")
} }
func testQresync(t *testing.T, tc *testconn, clientModseq int64) { func testQresync(t *testing.T, tc *testconn, uidonly bool, clientModseq int64) {
// Vanished on non-uid fetch is not allowed. ../rfc/7162:1693 // Vanished on non-uid fetch is not allowed. ../rfc/7162:1693
tc.transactf("bad", "fetch 1:* (Flags) (Changedsince 1 Vanished)") tc.transactf("bad", "fetch 1:* (Flags) (Changedsince 1 Vanished)")
@ -520,8 +575,8 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.transactf("bad", "Uid Fetch 1:* (Flags) (Vanished)") tc.transactf("bad", "Uid Fetch 1:* (Flags) (Vanished)")
// Vanished not allowed without first enabling qresync. ../rfc/7162:1697 // Vanished not allowed without first enabling qresync. ../rfc/7162:1697
xtc := startNoSwitchboard(t) xtc := startNoSwitchboard(t, uidonly)
xtc.client.Login("mjl@mox.example", password0) xtc.login("mjl@mox.example", password0)
xtc.transactf("ok", "Select inbox (Condstore)") xtc.transactf("ok", "Select inbox (Condstore)")
xtc.transactf("bad", "Uid Fetch 1:* (Flags) (Changedsince 1 Vanished)") xtc.transactf("bad", "Uid Fetch 1:* (Flags) (Changedsince 1 Vanished)")
xtc.closeNoWait() xtc.closeNoWait()
@ -532,17 +587,17 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
noflags := imapclient.FetchFlags(nil) noflags := imapclient.FetchFlags(nil)
tc.xuntagged( tc.xuntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(5)}}, tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(8)}}, tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
) )
// select/examine with qresync parameters, including the various optional fields. // select/examine with qresync parameters, including the various optional fields.
tc.transactf("ok", "Close") tc.transactf("ok", "Close")
// Must enable qresync explicitly before using. ../rfc/7162:1446 // Must enable qresync explicitly before using. ../rfc/7162:1446
xtc = startNoSwitchboard(t) xtc = startNoSwitchboard(t, uidonly)
xtc.client.Login("mjl@mox.example", password0) xtc.login("mjl@mox.example", password0)
xtc.transactf("bad", "Select inbox (Qresync 1 0)") xtc.transactf("bad", "Select inbox (Qresync 1 0)")
// Prevent triggering the consistency checker, we still have modseq/createseq at 0. // Prevent triggering the consistency checker, we still have modseq/createseq at 0.
xtc.closeNoWait() xtc.closeNoWait()
@ -568,11 +623,15 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"}, imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"},
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDNEXT", CodeArg: imapclient.CodeUint{Code: "UIDNEXT", Num: 7}, More: "x"}}, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDNEXT", CodeArg: imapclient.CodeUint{Code: "UIDNEXT", Num: 7}, More: "x"}},
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDVALIDITY", CodeArg: imapclient.CodeUint{Code: "UIDVALIDITY", Num: 1}, More: "x"}}, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDVALIDITY", CodeArg: imapclient.CodeUint{Code: "UIDVALIDITY", Num: 1}, More: "x"}},
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UNSEEN", CodeArg: imapclient.CodeUint{Code: "UNSEEN", Num: 1}, More: "x"}},
imapclient.UntaggedRecent(0), imapclient.UntaggedRecent(0),
imapclient.UntaggedExists(4), imapclient.UntaggedExists(4),
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(clientModseq), More: "x"}}, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(clientModseq), More: "x"}},
} }
if !uidonly {
baseUntagged = append(baseUntagged,
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UNSEEN", CodeArg: imapclient.CodeUint{Code: "UNSEEN", Num: 1}, More: "x"}},
)
}
makeUntagged := func(l ...imapclient.Untagged) []imapclient.Untagged { makeUntagged := func(l ...imapclient.Untagged) []imapclient.Untagged {
return slices.Concat(baseUntagged, l) return slices.Concat(baseUntagged, l)
@ -583,9 +642,9 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.xuntagged( tc.xuntagged(
makeUntagged( makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(5)}}, tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(8)}}, tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)..., )...,
) )
@ -600,9 +659,9 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.xuntagged( tc.xuntagged(
makeUntagged( makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(5)}}, tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(8)}}, tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)..., )...,
) )
@ -611,9 +670,9 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.transactf("ok", "Select inbox (Qresync (1 1 1,2,5:6))") tc.transactf("ok", "Select inbox (Qresync (1 1 1,2,5:6))")
tc.xuntagged( tc.xuntagged(
makeUntagged( makeUntagged(
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(5)}}, tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(8)}}, tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)..., )...,
) )
@ -622,7 +681,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.transactf("ok", "Select inbox (Qresync (1 1 5))") tc.transactf("ok", "Select inbox (Qresync (1 1 5))")
tc.xuntagged( tc.xuntagged(
makeUntagged( makeUntagged(
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(5)}}, tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
)..., )...,
) )
@ -644,19 +703,25 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
// know. But the seqs & uids must be of equal length. First try with a few combinations // know. But the seqs & uids must be of equal length. First try with a few combinations
// that aren't valid. ../rfc/7162:1579 // that aren't valid. ../rfc/7162:1579
tc.transactf("ok", "Close") tc.transactf("ok", "Close")
tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1 1,2)))") // Not same length. tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1 1,2)))") // Not same length.
tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1,2 1)))") // Not same length. tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1,2 1)))") // Not same length.
tc.transactf("no", "Select inbox (Qresync (1 1 1:6 (1,2 1,1)))") // Not ascending. if !uidonly {
tc.transactf("no", "Select inbox (Qresync (1 1 1:6 (1,2 1,1)))") // Not ascending.
}
tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1:* 1:4)))") // Star not allowed. tc.transactf("bad", "Select inbox (Qresync (1 1 1:6 (1:* 1:4)))") // Star not allowed.
if uidonly {
return
}
// With valid parameters, based on what a client would know at this stage. // With valid parameters, based on what a client would know at this stage.
tc.transactf("ok", "Select inbox (Qresync (1 1 1:6 (1,3,6 1,3,6)))") tc.transactf("ok", "Select inbox (Qresync (1 1 1:6 (1,3,6 1,3,6)))")
tc.xuntagged( tc.xuntagged(
makeUntagged( makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5), noflags, imapclient.FetchModSeq(5)}}, tc.untaggedFetch(3, 5, noflags, imapclient.FetchModSeq(5)),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(8)}}, tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)..., )...,
) )
@ -666,8 +731,8 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.xuntagged( tc.xuntagged(
makeUntagged( makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), noflags, imapclient.FetchModSeq(8)}}, tc.untaggedFetch(1, 1, noflags, imapclient.FetchModSeq(8)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)..., )...,
) )
@ -676,7 +741,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
tc.xuntagged( tc.xuntagged(
makeUntagged( makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)..., )...,
) )
@ -689,7 +754,7 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
makeUntagged( makeUntagged(
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."}}, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."}},
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)..., )...,
) )
@ -702,7 +767,128 @@ func testQresync(t *testing.T, tc *testconn, clientModseq int64) {
makeUntagged( makeUntagged(
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."}}, imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "ALERT", More: "Synchronization inconsistency in client detected. Client tried to sync with a UID that was removed at or after the MODSEQ it sent in the request. Sending all historic message removals for selected mailbox. Full synchronization recommended."}},
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")}, imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("3:4")},
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6), noflags, imapclient.FetchModSeq(clientModseq)}}, tc.untaggedFetch(4, 6, noflags, imapclient.FetchModSeq(clientModseq)),
)..., )...,
) )
} }
func TestQresyncHistory(t *testing.T) {
testQresyncHistory(t, false)
}
func TestQresyncHistoryUIDOnly(t *testing.T) {
testQresyncHistory(t, true)
}
func testQresyncHistory(t *testing.T, uidonly bool) {
defer mockUIDValidity()()
tc := start(t, uidonly)
defer tc.close()
tc.login("mjl@mox.example", password0)
tc.client.Enable("Qresync")
tc.transactf("ok", "Append inbox {1+}\r\nx")
tc.transactf("ok", "Append inbox {1+}\r\nx") // modseq 6
tc.transactf("ok", "Append inbox {1+}\r\nx")
tc.transactf("ok", "Select inbox")
tc.client.UIDStoreFlagsAdd("1,3", true, `\Deleted`) // modseq 8
tc.client.Expunge() // modseq 9
tc.client.UIDStoreFlagsAdd("2", true, `\Seen`) // modseq 10
// We have UID 2, no more UID 1 and 3.
flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent`, " ")
permflags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent \*`, " ")
uflags := imapclient.UntaggedFlags(flags)
upermflags := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "PERMANENTFLAGS", CodeArg: imapclient.CodeList{Code: "PERMANENTFLAGS", Args: permflags}, More: "x"}}
baseUntagged := []imapclient.Untagged{
uflags,
upermflags,
imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"},
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDNEXT", CodeArg: imapclient.CodeUint{Code: "UIDNEXT", Num: 4}, More: "x"}},
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDVALIDITY", CodeArg: imapclient.CodeUint{Code: "UIDVALIDITY", Num: 1}, More: "x"}},
imapclient.UntaggedRecent(0),
imapclient.UntaggedExists(1),
imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "HIGHESTMODSEQ", CodeArg: imapclient.CodeHighestModSeq(10), More: "x"}},
}
makeUntagged := func(l ...imapclient.Untagged) []imapclient.Untagged {
return slices.Concat(baseUntagged, l)
}
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox (Qresync (1 1))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("1,3")},
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
)...,
)
err := tc.account.DB.Write(ctxbg, func(tx *bstore.Tx) error {
syncState := store.SyncState{ID: 1}
err := tx.Get(&syncState)
tcheck(t, err, "get syncstate")
syncState.HighestDeletedModSeq = 9
err = tx.Update(&syncState)
tcheck(t, err, "update syncstate")
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{Expunged: true})
q.FilterLessEqual("ModSeq", syncState.HighestDeletedModSeq)
n, err := q.Delete()
tcheck(t, err, "delete history")
if n != 2 {
t.Fatalf("removed %d message history records, expected 2", n)
}
return nil
})
tcheck(t, err, "db write")
// We should still get VANISHED EARLIER for 1,3, even though we don't have history for it.
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox (Qresync (1 1))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("1,3")},
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
)...,
)
// Similar with explicit UIDs.
tc.transactf("ok", "Close")
tc.transactf("ok", "Select inbox (Qresync (1 1 1:3))")
tc.xuntagged(
makeUntagged(
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("1,3")},
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
)...,
)
// Fetch with changedsince also returns VANISHED EARLIER when we don't have history anymore.
tc.transactf("ok", "uid fetch 1:3 flags (Changedsince 10)")
tc.xuntagged() // We still have history, nothing changed.
tc.transactf("ok", "uid fetch 1:3 flags (Changedsince 9)")
tc.xuntagged(tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)))
// Missing history, but no vanished requested.
tc.transactf("ok", "uid fetch 1:4 flags (Changedsince 1)")
tc.xuntagged(
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
)
// Same, but with vanished requested.
tc.transactf("ok", "uid fetch 1:3 flags (Vanished Changedsince 10)")
tc.xuntagged() // We still have history, nothing changed.
tc.transactf("ok", "uid fetch 1:3 flags (Vanished Changedsince 9)")
tc.xuntagged(tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)))
// We return vanished for 1,3. Not for 4, since that is uidnext.
tc.transactf("ok", "uid fetch 1:4 flags (Vanished Changedsince 1)")
tc.xuntagged(
tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Seen`}, imapclient.FetchModSeq(10)),
imapclient.UntaggedVanished{Earlier: true, UIDs: xparseNumSet("1,3")},
)
}

View File

@ -7,17 +7,25 @@ import (
) )
func TestCopy(t *testing.T) { func TestCopy(t *testing.T) {
testCopy(t, false)
}
func TestCopyUIDOnly(t *testing.T) {
testCopy(t, true)
}
func testCopy(t *testing.T, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("Trash") tc2.client.Select("Trash")
tc.transactf("bad", "copy") // Missing params. tc.transactf("bad", "copy") // Missing params.
@ -27,48 +35,51 @@ func TestCopy(t *testing.T) {
// Seqs 1,2 and UIDs 3,4. // Seqs 1,2 and UIDs 3,4.
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.StoreFlagsSet("1:2", true, `\Deleted`) tc.transactf("ok", `Uid Store 1:2 +Flags.Silent (\Deleted)`)
tc.client.Expunge() tc.client.Expunge()
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.transactf("no", "copy 1 nonexistent") if uidonly {
tc.xcode("TRYCREATE") tc.transactf("ok", "uid copy 3:* Trash")
tc.transactf("no", "copy 1 expungebox") } else {
tc.xcode("TRYCREATE") 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. tc.transactf("no", "copy 1 inbox") // Cannot copy to same mailbox.
tc2.transactf("ok", "noop") // Drain. tc2.transactf("ok", "noop") // Drain.
tc.transactf("ok", "copy 1:* Trash") tc.transactf("ok", "copy 1:* Trash")
ptr := func(v uint32) *uint32 { return &v } tc.xcodeArg(imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 3, Last: uint32ptr(4)}}, To: []imapclient.NumRange{{First: 1, Last: uint32ptr(2)}}})
tc.xcodeArg(imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 3, Last: ptr(4)}}, To: []imapclient.NumRange{{First: 1, Last: ptr(2)}}}) }
tc2.transactf("ok", "noop") tc2.transactf("ok", "noop")
tc2.xuntagged( tc2.xuntagged(
imapclient.UntaggedExists(2), imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil)}}, tc2.untaggedFetch(1, 1, imapclient.FetchFlags(nil)),
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), imapclient.FetchFlags(nil)}}, tc2.untaggedFetch(2, 2, imapclient.FetchFlags(nil)),
) )
tc.transactf("no", "uid copy 1,2 Trash") // No match. tc.transactf("no", "uid copy 1,2 Trash") // No match.
tc.transactf("ok", "uid copy 4,3 Trash") tc.transactf("ok", "uid copy 4,3 Trash")
tc.xcodeArg(imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 3, Last: ptr(4)}}, To: []imapclient.NumRange{{First: 3, Last: ptr(4)}}}) tc.xcodeArg(imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 3, Last: uint32ptr(4)}}, To: []imapclient.NumRange{{First: 3, Last: uint32ptr(4)}}})
tc2.transactf("ok", "noop") tc2.transactf("ok", "noop")
tc2.xuntagged( tc2.xuntagged(
imapclient.UntaggedExists(4), imapclient.UntaggedExists(4),
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(3), imapclient.FetchFlags(nil)}}, tc2.untaggedFetch(3, 3, imapclient.FetchFlags(nil)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), imapclient.FetchFlags(nil)}}, tc2.untaggedFetch(4, 4, imapclient.FetchFlags(nil)),
) )
tclimit := startArgs(t, false, false, true, true, "limit") tclimit := startArgs(t, uidonly, false, false, true, true, "limit")
defer tclimit.close() defer tclimit.close()
tclimit.client.Login("limit@mox.example", password0) tclimit.login("limit@mox.example", password0)
tclimit.client.Select("inbox") tclimit.client.Select("inbox")
// First message of 1 byte is within limits. // First message of 1 byte is within limits.
tclimit.transactf("ok", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx") tclimit.transactf("ok", "append inbox (\\Seen Label1 $label2) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
tclimit.xuntagged(imapclient.UntaggedExists(1)) tclimit.xuntagged(imapclient.UntaggedExists(1))
// Second message would take account past limit. // Second message would take account past limit.
tclimit.transactf("no", "copy 1:* Trash") tclimit.transactf("no", "uid copy 1:* Trash")
tclimit.xcode("OVERQUOTA") tclimit.xcode("OVERQUOTA")
} }

View File

@ -7,14 +7,22 @@ import (
) )
func TestCreate(t *testing.T) { func TestCreate(t *testing.T) {
tc := start(t) testCreate(t, false)
}
func TestCreateUIDOnly(t *testing.T) {
testCreate(t, true)
}
func testCreate(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc.transactf("no", "create inbox") // Already exists and not allowed. ../rfc/9051:1913 tc.transactf("no", "create inbox") // Already exists and not allowed. ../rfc/9051:1913
tc.transactf("no", "create Inbox") // Idem. tc.transactf("no", "create Inbox") // Idem.

View File

@ -7,18 +7,26 @@ import (
) )
func TestDelete(t *testing.T) { func TestDelete(t *testing.T) {
tc := start(t) testDelete(t, false)
}
func TestDeleteUIDOnly(t *testing.T) {
testDelete(t, false)
}
func testDelete(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc3 := startNoSwitchboard(t) tc3 := startNoSwitchboard(t, uidonly)
defer tc3.closeNoWait() defer tc3.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc3.client.Login("mjl@mox.example", password0) tc3.login("mjl@mox.example", password0)
tc.transactf("bad", "delete") // Missing mailbox. tc.transactf("bad", "delete") // Missing mailbox.
tc.transactf("no", "delete inbox") // Cannot delete inbox. tc.transactf("no", "delete inbox") // Cannot delete inbox.

View File

@ -57,3 +57,9 @@ func xsyntaxErrorf(format string, args ...any) {
err := errors.New(errmsg) err := errors.New(errmsg)
panic(syntaxError{"", "", errmsg, err}) panic(syntaxError{"", "", errmsg, err})
} }
func xsyntaxCodeErrorf(code, format string, args ...any) {
errmsg := fmt.Sprintf(format, args...)
err := errors.New(errmsg)
panic(syntaxError{"", code, errmsg, err})
}

View File

@ -7,17 +7,25 @@ import (
) )
func TestExpunge(t *testing.T) { func TestExpunge(t *testing.T) {
testExpunge(t, false)
}
func TestExpungeUIDOnly(t *testing.T) {
testExpunge(t, true)
}
func testExpunge(t *testing.T, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc.transactf("bad", "expunge leftover") // Leftover data. tc.transactf("bad", "expunge leftover") // Leftover data.
@ -37,15 +45,23 @@ func TestExpunge(t *testing.T) {
tc.transactf("ok", "expunge") // Still nothing to remove. tc.transactf("ok", "expunge") // Still nothing to remove.
tc.xuntagged() tc.xuntagged()
tc.client.StoreFlagsAdd("1,3", true, `\Deleted`) tc.transactf("ok", `uid store 1,3 +flags.silent \Deleted`)
tc2.transactf("ok", "noop") // Drain. tc2.transactf("ok", "noop") // Drain.
tc.transactf("ok", "expunge") tc.transactf("ok", "expunge")
tc.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(2)) if uidonly {
tc.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("1,3")})
} else {
tc.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(2))
}
tc2.transactf("ok", "noop") // Drain. tc2.transactf("ok", "noop") // Drain.
tc2.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(2)) if uidonly {
tc2.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("1,3")})
} else {
tc2.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(2))
}
tc.transactf("ok", "expunge") // Nothing to remove anymore. tc.transactf("ok", "expunge") // Nothing to remove anymore.
tc.xuntagged() tc.xuntagged()
@ -59,7 +75,7 @@ func TestExpunge(t *testing.T) {
tc.transactf("bad", "uid expunge 1 leftover") // Leftover data. tc.transactf("bad", "uid expunge 1 leftover") // Leftover data.
tc.transactf("bad", "uid expunge 1 leftover") // Leftover data. tc.transactf("bad", "uid expunge 1 leftover") // Leftover data.
tc.client.StoreFlagsAdd("1,2,4", true, `\Deleted`) // Marks UID 2,4,6 as deleted. tc.transactf("ok", `uid store 2,4,6 +flags.silent \Deleted`)
tc.transactf("ok", "uid expunge 1") tc.transactf("ok", "uid expunge 1")
tc.xuntagged() // No match. tc.xuntagged() // No match.
@ -67,8 +83,16 @@ func TestExpunge(t *testing.T) {
tc2.transactf("ok", "noop") // Drain. tc2.transactf("ok", "noop") // Drain.
tc.transactf("ok", "uid expunge 4:6") // Removes UID 4,6 at seqs 2,4. tc.transactf("ok", "uid expunge 4:6") // Removes UID 4,6 at seqs 2,4.
tc.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3)) if uidonly {
tc.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("4,6")})
} else {
tc.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3))
}
tc2.transactf("ok", "noop") tc2.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3)) if uidonly {
tc2.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("4,6")})
} else {
tc2.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3))
}
} }

View File

@ -167,8 +167,9 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
c.xmailboxID(cmd.rtx, c.mailboxID) c.xmailboxID(cmd.rtx, c.mailboxID)
// With changedSince, the client is likely asking for a small set of changes. Use a // With changedSince, the client is likely asking for a small set of changes. Use a
// database query to trim down the uids we need to look at. // database query to trim down the uids we need to look at. We need to go through
// ../rfc/7162:871 // the database for "VANISHED (EARLIER)" anyway, to see UIDs that aren't in the
// session anymore. Vanished must be used with changedSince. ../rfc/7162:871
if changedSince > 0 { if changedSince > 0 {
q := bstore.QueryTx[store.Message](cmd.rtx) q := bstore.QueryTx[store.Message](cmd.rtx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID}) q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
@ -177,11 +178,16 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
} }
err := q.ForEach(func(m store.Message) error { err := q.ForEach(func(m store.Message) error {
if m.Expunged { if m.UID >= c.uidnext {
vanishedUIDs = append(vanishedUIDs, m.UID) return nil
} else if isUID { }
if nums.containsUID(m.UID, c.uids, c.searchResult) { if isUID {
uids = append(uids, m.UID) if nums.xcontainsKnownUID(m.UID, c.searchResult, func() store.UID { return c.uidnext - 1 }) {
if m.Expunged {
vanishedUIDs = append(vanishedUIDs, m.UID)
} else {
uids = append(uids, m.UID)
}
} }
} else { } else {
seq := c.sequence(m.UID) seq := c.sequence(m.UID)
@ -192,49 +198,52 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
return nil return nil
}) })
xcheckf(err, "looking up messages with changedsince") xcheckf(err, "looking up messages with changedsince")
} else {
uids = c.xnumSetUIDs(isUID, nums)
}
// Send vanished for all missing requested UIDs. ../rfc/7162:1718 // In case of vanished where we don't have the full history, we must send VANISHED
if !vanished { // for all uids matching nums. ../rfc/7162:1718
return delModSeq, err := c.account.HighestDeletedModSeq(cmd.rtx)
} xcheckf(err, "looking up highest deleted modseq")
if !vanished || changedSince >= delModSeq.Client() {
delModSeq, err := c.account.HighestDeletedModSeq(cmd.rtx) return
xcheckf(err, "looking up highest deleted modseq")
if changedSince >= delModSeq.Client() {
return
}
// First sort the uids we already found, for fast lookup.
slices.Sort(vanishedUIDs)
// We'll be gathering any more vanished uids in more.
more := map[store.UID]struct{}{}
checkVanished := func(uid store.UID) {
if uidSearch(c.uids, uid) <= 0 && uidSearch(vanishedUIDs, uid) <= 0 {
more[uid] = struct{}{}
} }
}
// Now look through the requested uids. We may have a searchResult, handle it // We'll iterate through all UIDs in the numset, and add anything that isn't
// separately from a numset with potential stars, over which we can more easily // already in uids and vanishedUIDs. First sort the uids we already found, for fast
// iterate. // lookup. We'll gather new UIDs in more, so we don't break the binary search.
if nums.searchResult { slices.Sort(vanishedUIDs)
for _, uid := range c.searchResult { slices.Sort(uids)
checkVanished(uid)
} more := map[store.UID]struct{}{} // We'll add them at the end.
} else { checkVanished := func(uid store.UID) {
iter := nums.interpretStar(c.uids).newIter() if uid < c.uidnext && uidSearch(uids, uid) <= 0 && uidSearch(vanishedUIDs, uid) <= 0 {
for { more[uid] = struct{}{}
num, ok := iter.Next()
if !ok {
break
} }
checkVanished(store.UID(num))
} }
// Now look through the requested uids. We may have a searchResult, handle it
// separately from a numset with potential stars, over which we can more easily
// iterate.
if nums.searchResult {
for _, uid := range c.searchResult {
checkVanished(uid)
}
} else {
xlastUID := c.newCachedLastUID(cmd.rtx, c.mailboxID, func(xerr error) { xuserErrorf("%s", xerr) })
iter := nums.xinterpretStar(xlastUID).newIter()
for {
num, ok := iter.Next()
if !ok {
break
}
checkVanished(store.UID(num))
}
}
vanishedUIDs = slices.AppendSeq(vanishedUIDs, maps.Keys(more))
slices.Sort(vanishedUIDs)
} else {
uids = c.xnumSetEval(cmd.rtx, isUID, nums)
} }
vanishedUIDs = slices.AppendSeq(vanishedUIDs, maps.Keys(more))
}) })
// We are continuing without a lock, working off our snapshot of uids to process. // We are continuing without a lock, working off our snapshot of uids to process.
@ -242,7 +251,6 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
if len(vanishedUIDs) > 0 { if len(vanishedUIDs) > 0 {
// Mention all vanished UIDs in compact numset form. // Mention all vanished UIDs in compact numset form.
// ../rfc/7162:1985 // ../rfc/7162:1985
slices.Sort(vanishedUIDs)
// No hard limit on response sizes, but clients are recommended to not send more // No hard limit on response sizes, but clients are recommended to not send more
// than 8k. We send a more conservative max 4k. // than 8k. We send a more conservative max 4k.
for _, s := range compactUIDSet(vanishedUIDs).Strings(4*1024 - 32) { for _, s := range compactUIDSet(vanishedUIDs).Strings(4*1024 - 32) {
@ -260,7 +268,12 @@ func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
xuserErrorf("processing fetch attribute: %v", err) xuserErrorf("processing fetch attribute: %v", err)
} }
fmt.Fprintf(cmd.conn.xbw, "* %d FETCH ", cmd.conn.xsequence(cmd.uid)) // UIDFETCH in case of uidonly. ../rfc/9586:181
if c.uidonly {
fmt.Fprintf(cmd.conn.xbw, "* %d UIDFETCH ", cmd.uid)
} else {
fmt.Fprintf(cmd.conn.xbw, "* %d FETCH ", cmd.conn.xsequence(cmd.uid))
}
data.xwriteTo(cmd.conn, cmd.conn.xbw) data.xwriteTo(cmd.conn, cmd.conn.xbw)
cmd.conn.xbw.Write([]byte("\r\n")) cmd.conn.xbw.Write([]byte("\r\n"))
@ -426,7 +439,10 @@ func (cmd *fetchCmd) process(atts []fetchAtt) (rdata listspace, rerr error) {
} }
}() }()
data := listspace{bare("UID"), number(cmd.uid)} var data listspace
if !cmd.conn.uidonly {
data = append(data, bare("UID"), number(cmd.uid))
}
cmd.markSeen = false cmd.markSeen = false
cmd.needFlags = false cmd.needFlags = false
@ -474,8 +490,11 @@ func (cmd *fetchCmd) process(atts []fetchAtt) (rdata listspace, rerr error) {
func (cmd *fetchCmd) xprocessAtt(a fetchAtt) []token { func (cmd *fetchCmd) xprocessAtt(a fetchAtt) []token {
switch a.field { switch a.field {
case "UID": case "UID":
// Always present. // Present by default without uidonly. For uidonly, we only add it when explicitly
return nil // requested. ../rfc/9586:184
if cmd.conn.uidonly {
return []token{bare("UID"), number(cmd.uid)}
}
case "ENVELOPE": case "ENVELOPE":
_, part := cmd.xensureParsed() _, part := cmd.xensureParsed()

View File

@ -12,10 +12,18 @@ import (
) )
func TestFetch(t *testing.T) { func TestFetch(t *testing.T) {
tc := start(t) testFetch(t, false)
}
func TestFetchUIDOnly(t *testing.T) {
testFetch(t, true)
}
func testFetch(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Enable("imap4rev2") tc.client.Enable("imap4rev2")
received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00") received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00")
tc.check(err, "parse time") tc.check(err, "parse time")
@ -82,156 +90,159 @@ func TestFetch(t *testing.T) {
flagsSeen := imapclient.FetchFlags{`\Seen`} flagsSeen := imapclient.FetchFlags{`\Seen`}
tc.transactf("ok", "fetch 1 all") if !uidonly {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, env1, noflags}}) tc.transactf("ok", "fetch 1 all")
tc.xuntagged(tc.untaggedFetch(1, 1, date1, rfcsize1, env1, noflags))
tc.transactf("ok", "fetch 1 fast") tc.transactf("ok", "fetch 1 fast")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, date1, rfcsize1, noflags))
tc.transactf("ok", "fetch 1 full") tc.transactf("ok", "fetch 1 full")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, env1, bodyxstructure1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, date1, rfcsize1, env1, bodyxstructure1, noflags))
tc.transactf("ok", "fetch 1 flags") tc.transactf("ok", "fetch 1 flags")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
tc.transactf("ok", "fetch 1 bodystructure") tc.transactf("ok", "fetch 1 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}) tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1))
// Should be returned unmodified, because there is no content-transfer-encoding. // Should be returned unmodified, because there is no content-transfer-encoding.
tc.transactf("ok", "fetch 1 binary[]") tc.transactf("ok", "fetch 1 binary[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, binary1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.transactf("ok", "fetch 1 binary[1]") tc.transactf("ok", "fetch 1 binary[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypart1}}) // Seen flag not changed. tc.xuntagged(tc.untaggedFetch(1, 1, binarypart1)) // Seen flag not changed.
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "uid fetch 1 binary[]<1.1>") tc.transactf("ok", "uid fetch 1 binary[]<1.1>")
tc.xuntagged( tc.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartial1, noflags}}, tc.untaggedFetch(1, 1, binarypartial1, noflags),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}, // For UID FETCH, we get the flags during the command. tc.untaggedFetch(1, 1, flagsSeen), // For UID FETCH, we get the flags during the command.
) )
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[1]<1.1>") tc.transactf("ok", "fetch 1 binary[1]<1.1>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartpartial1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, binarypartpartial1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[]<10000.10001>") tc.transactf("ok", "fetch 1 binary[]<10000.10001>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binaryend1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, binaryend1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 binary[1]<10000.10001>") tc.transactf("ok", "fetch 1 binary[1]<10000.10001>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartend1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, binarypartend1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.transactf("ok", "fetch 1 binary.size[]")
tc.transactf("ok", "fetch 1 binary.size[]") tc.xuntagged(tc.untaggedFetch(1, 1, binarysize1))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarysize1}})
tc.transactf("ok", "fetch 1 binary.size[1]") tc.transactf("ok", "fetch 1 binary.size[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarysizepart1}}) tc.xuntagged(tc.untaggedFetch(1, 1, binarysizepart1))
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[]") tc.transactf("ok", "fetch 1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, body1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.transactf("ok", "fetch 1 body[]<1.2>") tc.transactf("ok", "fetch 1 body[]<1.2>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyoff1}}) // Already seen. tc.xuntagged(tc.untaggedFetch(1, 1, bodyoff1)) // Already seen.
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged() // Already seen. tc.xuntagged() // Already seen.
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]") tc.transactf("ok", "fetch 1 body[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodypart1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, bodypart1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]<1.2>") tc.transactf("ok", "fetch 1 body[1]<1.2>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1off1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, body1off1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[1]<100000.100000>") tc.transactf("ok", "fetch 1 body[1]<100000.100000>")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyend1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, bodyend1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[header]") tc.transactf("ok", "fetch 1 body[header]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyheader1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, bodyheader1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body[text]") tc.transactf("ok", "fetch 1 body[text]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodytext1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, bodytext1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
// equivalent to body.peek[header], ../rfc/3501:3183 // equivalent to body.peek[header], ../rfc/3501:3183
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.transactf("ok", "fetch 1 rfc822.header")
tc.transactf("ok", "fetch 1 rfc822.header") tc.xuntagged(tc.untaggedFetch(1, 1, rfcheader1))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfcheader1}})
// equivalent to body[text], ../rfc/3501:3199 // equivalent to body[text], ../rfc/3501:3199
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 rfc822.text") tc.transactf("ok", "fetch 1 rfc822.text")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfctext1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, rfctext1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
// equivalent to body[], ../rfc/3501:3179 // equivalent to body[], ../rfc/3501:3179
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 rfc822") tc.transactf("ok", "fetch 1 rfc822")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfc1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, rfc1, noflags))
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
// With PEEK, we should not get the \Seen flag. // With PEEK, we should not get the \Seen flag.
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "fetch 1 body.peek[]") tc.transactf("ok", "fetch 1 body.peek[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}}) tc.xuntagged(tc.untaggedFetch(1, 1, body1))
tc.transactf("ok", "fetch 1 binary.peek[]") tc.transactf("ok", "fetch 1 binary.peek[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1}}) tc.xuntagged(tc.untaggedFetch(1, 1, binary1))
// HEADER.FIELDS and .NOT // HEADER.FIELDS and .NOT
tc.transactf("ok", "fetch 1 body.peek[header.fields (date)]") tc.transactf("ok", "fetch 1 body.peek[header.fields (date)]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, dateheader1}}) tc.xuntagged(tc.untaggedFetch(1, 1, dateheader1))
tc.transactf("ok", "fetch 1 body.peek[header.fields.not (date)]") tc.transactf("ok", "fetch 1 body.peek[header.fields.not (date)]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, nodateheader1}}) tc.xuntagged(tc.untaggedFetch(1, 1, nodateheader1))
// For non-multipart messages, 1 means the whole message, but since it's not of // For non-multipart messages, 1 means the whole message, but since it's not of
// type message/{rfc822,global} (a message), you can't get the message headers. // type message/{rfc822,global} (a message), you can't get the message headers.
// ../rfc/9051:4481 // ../rfc/9051:4481
tc.transactf("no", "fetch 1 body.peek[1.header]") tc.transactf("no", "fetch 1 body.peek[1.header]")
// MIME, part 1 for non-multipart messages is the message itself. ../rfc/9051:4481 // MIME, part 1 for non-multipart messages is the message itself. ../rfc/9051:4481
tc.transactf("ok", "fetch 1 body.peek[1.mime]") tc.transactf("ok", "fetch 1 body.peek[1.mime]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, mime1}}) tc.xuntagged(tc.untaggedFetch(1, 1, mime1))
// Missing sequence number. ../rfc/9051:7018 // Missing sequence number. ../rfc/9051:7018
tc.transactf("bad", "fetch 2 body[]") tc.transactf("bad", "fetch 2 body[]")
tc.transactf("ok", "fetch 1:1 body[]") tc.client.StoreFlagsClear("1", true, `\Seen`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, noflags}}) tc.transactf("ok", "fetch 1:1 body[]")
tc.transactf("ok", "noop") tc.xuntagged(tc.untaggedFetch(1, 1, body1, noflags))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}}) tc.transactf("ok", "noop")
tc.xuntagged(tc.untaggedFetch(1, 1, flagsSeen))
} else {
tc.client.UIDStoreFlagsAdd("1", true, `\Seen`)
tc.transactf("ok", "noop")
}
// UID fetch // UID fetch
tc.transactf("ok", "uid fetch 1 body[]") tc.transactf("ok", "uid fetch 1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}}) tc.xuntagged(tc.untaggedFetch(1, 1, body1))
// UID fetch
tc.transactf("ok", "uid fetch 2 body[]") tc.transactf("ok", "uid fetch 2 body[]")
tc.xuntagged() tc.xuntagged()
@ -254,9 +265,9 @@ func TestFetch(t *testing.T) {
return nil return nil
}) })
tc.check(err, "get savedate") tc.check(err, "get savedate")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchSaveDate{SaveDate: &saveDate}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchSaveDate{SaveDate: &saveDate}))
// Test some invalid syntax. // Test some invalid syntax. Also invalid for uidonly.
tc.transactf("bad", "fetch") tc.transactf("bad", "fetch")
tc.transactf("bad", "fetch ") tc.transactf("bad", "fetch ")
tc.transactf("bad", "fetch ") tc.transactf("bad", "fetch ")
@ -279,11 +290,12 @@ func TestFetch(t *testing.T) {
tc.transactf("bad", "fetch 1 body[header.fields.not ()]") // List must be non-empty. tc.transactf("bad", "fetch 1 body[header.fields.not ()]") // List must be non-empty.
tc.transactf("bad", "fetch 1 body[mime]") // MIME must be prefixed with a number. ../rfc/9051:4497 tc.transactf("bad", "fetch 1 body[mime]") // MIME must be prefixed with a number. ../rfc/9051:4497
tc.transactf("no", "fetch 1 body[2]") // No such part. if !uidonly {
tc.transactf("no", "fetch 1 body[2]") // No such part.
}
// Add more complex message. // Add more complex message.
uid2 := imapclient.FetchUID(2)
bodystructure2 := imapclient.FetchBodystructure{ bodystructure2 := imapclient.FetchBodystructure{
RespAttr: "BODYSTRUCTURE", RespAttr: "BODYSTRUCTURE",
Body: imapclient.BodyTypeMpart{ Body: imapclient.BodyTypeMpart{
@ -325,41 +337,42 @@ func TestFetch(t *testing.T) {
}, },
} }
tc.client.Append("inbox", makeAppendTime(nestedMessage, received)) tc.client.Append("inbox", makeAppendTime(nestedMessage, received))
tc.transactf("ok", "fetch 2 bodystructure") tc.transactf("ok", "uid fetch 2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.xuntagged(tc.untaggedFetch(2, 2, bodystructure2))
// Multiple responses. // Multiple responses.
tc.transactf("ok", "fetch 1:2 bodystructure") if !uidonly {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.transactf("ok", "fetch 1:2 bodystructure")
tc.transactf("ok", "fetch 1,2 bodystructure") tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.transactf("ok", "fetch 1,2 bodystructure")
tc.transactf("ok", "fetch 2:1 bodystructure") tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.transactf("ok", "fetch 2:1 bodystructure")
tc.transactf("ok", "fetch 1:* bodystructure") tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.transactf("ok", "fetch 1:* bodystructure")
tc.transactf("ok", "fetch *:1 bodystructure") tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.transactf("ok", "fetch *:1 bodystructure")
tc.transactf("ok", "fetch *:2 bodystructure") tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.transactf("ok", "fetch *:2 bodystructure")
tc.xuntagged(tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "fetch * bodystructure") // Highest msgseq. tc.transactf("ok", "fetch * bodystructure") // Highest msgseq.
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.xuntagged(tc.untaggedFetch(2, 2, bodystructure2))
}
tc.transactf("ok", "uid fetch 1:* bodystructure") tc.transactf("ok", "uid fetch 1:* bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "uid fetch 1:2 bodystructure") tc.transactf("ok", "uid fetch 1:2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "uid fetch 1,2 bodystructure") tc.transactf("ok", "uid fetch 1,2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.xuntagged(tc.untaggedFetch(1, 1, bodystructure1), tc.untaggedFetch(2, 2, bodystructure2))
tc.transactf("ok", "uid fetch 2:2 bodystructure") tc.transactf("ok", "uid fetch 2:2 bodystructure")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) tc.xuntagged(tc.untaggedFetch(2, 2, bodystructure2))
// todo: read the bodies/headers of the parts, and of the nested message. // todo: read the bodies/headers of the parts, and of the nested message.
tc.transactf("ok", "fetch 2 body.peek[]") tc.transactf("ok", "uid fetch 2 body.peek[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[]", Body: nestedMessage}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[]", Body: nestedMessage}))
part1 := tocrlf(` ... Some text appears here ... part1 := tocrlf(` ... Some text appears here ...
@ -369,22 +382,22 @@ func TestFetch(t *testing.T) {
It could have been done with explicit typing as in the It could have been done with explicit typing as in the
next part.] next part.]
`) `)
tc.transactf("ok", "fetch 2 body.peek[1]") tc.transactf("ok", "uid fetch 2 body.peek[1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[1]", Section: "1", Body: part1}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[1]", Section: "1", Body: part1}))
tc.transactf("no", "fetch 2 binary.peek[3]") // Only allowed on leaf parts, not multiparts. tc.transactf("no", "uid fetch 2 binary.peek[3]") // Only allowed on leaf parts, not multiparts.
tc.transactf("no", "fetch 2 binary.peek[5]") // Only allowed on leaf parts, not messages. tc.transactf("no", "uid fetch 2 binary.peek[5]") // Only allowed on leaf parts, not messages.
part31 := "aGVsbG8NCndvcmxkDQo=\r\n" part31 := "aGVsbG8NCndvcmxkDQo=\r\n"
part31dec := "hello\r\nworld\r\n" part31dec := "hello\r\nworld\r\n"
tc.transactf("ok", "fetch 2 binary.size[3.1]") tc.transactf("ok", "uid fetch 2 binary.size[3.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBinarySize{RespAttr: "BINARY.SIZE[3.1]", Parts: []uint32{3, 1}, Size: int64(len(part31dec))}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBinarySize{RespAttr: "BINARY.SIZE[3.1]", Parts: []uint32{3, 1}, Size: int64(len(part31dec))}))
tc.transactf("ok", "fetch 2 body.peek[3.1]") tc.transactf("ok", "uid fetch 2 body.peek[3.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[3.1]", Section: "3.1", Body: part31}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[3.1]", Section: "3.1", Body: part31}))
tc.transactf("ok", "fetch 2 binary.peek[3.1]") tc.transactf("ok", "uid fetch 2 binary.peek[3.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBinary{RespAttr: "BINARY[3.1]", Parts: []uint32{3, 1}, Data: part31dec}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBinary{RespAttr: "BINARY[3.1]", Parts: []uint32{3, 1}, Data: part31dec}))
part3 := tocrlf(`--unique-boundary-2 part3 := tocrlf(`--unique-boundary-2
Content-Type: audio/basic Content-Type: audio/basic
@ -401,12 +414,12 @@ Content-Disposition: inline; filename=image.jpg
--unique-boundary-2-- --unique-boundary-2--
`) `)
tc.transactf("ok", "fetch 2 body.peek[3]") tc.transactf("ok", "uid fetch 2 body.peek[3]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[3]", Section: "3", Body: part3}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[3]", Section: "3", Body: part3}))
part2mime := "Content-type: text/plain; charset=US-ASCII\r\n" part2mime := "Content-type: text/plain; charset=US-ASCII\r\n"
tc.transactf("ok", "fetch 2 body.peek[2.mime]") tc.transactf("ok", "uid fetch 2 body.peek[2.mime]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[2.MIME]", Section: "2.MIME", Body: part2mime}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[2.MIME]", Section: "2.MIME", Body: part2mime}))
part5 := tocrlf(`From: info@mox.example part5 := tocrlf(`From: info@mox.example
To: mox <info@mox.example> To: mox <info@mox.example>
@ -416,8 +429,8 @@ Content-Transfer-Encoding: Quoted-printable
... Additional text in ISO-8859-1 goes here ... ... Additional text in ISO-8859-1 goes here ...
`) `)
tc.transactf("ok", "fetch 2 body.peek[5]") tc.transactf("ok", "uid fetch 2 body.peek[5]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5]", Section: "5", Body: part5}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5]", Section: "5", Body: part5}))
part5header := tocrlf(`From: info@mox.example part5header := tocrlf(`From: info@mox.example
To: mox <info@mox.example> To: mox <info@mox.example>
@ -426,42 +439,42 @@ Content-Type: Text/plain; charset=ISO-8859-1
Content-Transfer-Encoding: Quoted-printable Content-Transfer-Encoding: Quoted-printable
`) `)
tc.transactf("ok", "fetch 2 body.peek[5.header]") tc.transactf("ok", "uid fetch 2 body.peek[5.header]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.HEADER]", Section: "5.HEADER", Body: part5header}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5.HEADER]", Section: "5.HEADER", Body: part5header}))
part5mime := tocrlf(`Content-Type: message/rfc822 part5mime := tocrlf(`Content-Type: message/rfc822
Content-MD5: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= Content-MD5: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
Content-Language: en,de Content-Language: en,de
Content-Location: http://localhost Content-Location: http://localhost
`) `)
tc.transactf("ok", "fetch 2 body.peek[5.mime]") tc.transactf("ok", "uid fetch 2 body.peek[5.mime]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.MIME]", Section: "5.MIME", Body: part5mime}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5.MIME]", Section: "5.MIME", Body: part5mime}))
part5text := " ... Additional text in ISO-8859-1 goes here ...\r\n" part5text := " ... Additional text in ISO-8859-1 goes here ...\r\n"
tc.transactf("ok", "fetch 2 body.peek[5.text]") tc.transactf("ok", "uid fetch 2 body.peek[5.text]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.TEXT]", Section: "5.TEXT", Body: part5text}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5.TEXT]", Section: "5.TEXT", Body: part5text}))
part5body := " ... Additional text in ISO-8859-1 goes here ...\r\n" part5body := " ... Additional text in ISO-8859-1 goes here ...\r\n"
tc.transactf("ok", "fetch 2 body.peek[5.1]") tc.transactf("ok", "uid fetch 2 body.peek[5.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.1]", Section: "5.1", Body: part5body}}}) tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchBody{RespAttr: "BODY[5.1]", Section: "5.1", Body: part5body}))
// 5.1 is the part that is the sub message, but not as message/rfc822, but as part, // 5.1 is the part that is the sub message, but not as message/rfc822, but as part,
// so we cannot request a header. // so we cannot request a header.
tc.transactf("no", "fetch 2 body.peek[5.1.header]") tc.transactf("no", "uid fetch 2 body.peek[5.1.header]")
// In case of EXAMINE instead of SELECT, we should not be seeing any changed \Seen flags for non-peek commands. // In case of EXAMINE instead of SELECT, we should not be seeing any changed \Seen flags for non-peek commands.
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.UIDStoreFlagsClear("1", true, `\Seen`)
tc.client.Unselect() tc.client.Unselect()
tc.client.Examine("inbox") tc.client.Examine("inbox")
// Preview // Preview
preview := "Hello Joe, do you think we can meet at 3:30 tomorrow?" preview := "Hello Joe, do you think we can meet at 3:30 tomorrow?"
tc.transactf("ok", "fetch 1 preview") tc.transactf("ok", "uid fetch 1 preview")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchPreview{Preview: &preview}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchPreview{Preview: &preview}))
tc.transactf("ok", "fetch 1 preview (lazy)") tc.transactf("ok", "uid fetch 1 preview (lazy)")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchPreview{Preview: &preview}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchPreview{Preview: &preview}))
// On-demand preview and saving on first request. // On-demand preview and saving on first request.
err = tc.account.DB.Write(ctxbg, func(tx *bstore.Tx) error { err = tc.account.DB.Write(ctxbg, func(tx *bstore.Tx) error {
@ -478,8 +491,8 @@ Content-Location: http://localhost
}) })
tcheck(t, err, "remove preview from database") tcheck(t, err, "remove preview from database")
tc.transactf("ok", "fetch 1 preview") tc.transactf("ok", "uid fetch 1 preview")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchPreview{Preview: &preview}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchPreview{Preview: &preview}))
m := store.Message{ID: 1} m := store.Message{ID: 1}
err = tc.account.DB.Get(ctxbg, &m) err = tc.account.DB.Get(ctxbg, &m)
tcheck(t, err, "get message") tcheck(t, err, "get message")
@ -489,29 +502,38 @@ Content-Location: http://localhost
t.Fatalf("got preview %q, expected %q", *m.Preview, preview+"\n") t.Fatalf("got preview %q, expected %q", *m.Preview, preview+"\n")
} }
tc.transactf("bad", "fetch 1 preview (bogus)") tc.transactf("bad", "uid fetch 1 preview (bogus)")
// Start a second session. Use it to remove the message. First session should still // Start a second session. Use it to remove the message. First session should still
// be able to access the messages. // be able to access the messages.
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc2.client.StoreFlagsSet("1", true, `\Deleted`) tc2.client.UIDStoreFlagsSet("1", true, `\Deleted`)
tc2.client.Expunge() tc2.client.Expunge()
tc2.client.Logout() tc2.client.Logout()
tc.transactf("ok", "fetch 1 binary[]") if uidonly {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1}}) tc.transactf("ok", "uid fetch 1 binary[]")
tc.xuntagged(
tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Deleted`}),
imapclient.UntaggedVanished{UIDs: xparseNumSet("1")},
)
// Message no longer available in session.
} else {
tc.transactf("ok", "fetch 1 binary[]")
tc.xuntagged(tc.untaggedFetch(1, 1, binary1))
tc.transactf("ok", "fetch 1 body[]") tc.transactf("ok", "fetch 1 body[]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}}) tc.xuntagged(tc.untaggedFetch(1, 1, body1))
tc.transactf("ok", "fetch 1 rfc822.text") tc.transactf("ok", "fetch 1 rfc822.text")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfctext1}}) tc.xuntagged(tc.untaggedFetch(1, 1, rfctext1))
tc.transactf("ok", "fetch 1 rfc822") tc.transactf("ok", "fetch 1 rfc822")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfc1}}) tc.xuntagged(tc.untaggedFetch(1, 1, rfc1))
}
tc.client.Logout() tc.client.Logout()
} }

View File

@ -9,14 +9,14 @@ import (
) )
func TestIdle(t *testing.T) { func TestIdle(t *testing.T) {
tc1 := start(t) tc1 := start(t, false)
defer tc1.close() defer tc1.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, false)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc1.client.Login("mjl@mox.example", password0) tc1.login("mjl@mox.example", password0)
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc1.transactf("ok", "select inbox") tc1.transactf("ok", "select inbox")
tc2.transactf("ok", "select inbox") tc2.transactf("ok", "select inbox")

View File

@ -8,10 +8,18 @@ import (
) )
func TestListBasic(t *testing.T) { func TestListBasic(t *testing.T) {
tc := start(t) testListBasic(t, false)
}
func TestListBasicUIDOnly(t *testing.T) {
testListBasic(t, true)
}
func testListBasic(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
ulist := func(name string, flags ...string) imapclient.UntaggedList { ulist := func(name string, flags ...string) imapclient.UntaggedList {
if len(flags) == 0 { if len(flags) == 0 {
@ -59,12 +67,20 @@ func TestListBasic(t *testing.T) {
} }
func TestListExtended(t *testing.T) { func TestListExtended(t *testing.T) {
testListExtended(t, false)
}
func TestListExtendedUIDOnly(t *testing.T) {
testListExtended(t, true)
}
func testListExtended(t *testing.T, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
ulist := func(name string, flags ...string) imapclient.UntaggedList { ulist := func(name string, flags ...string) imapclient.UntaggedList {
if len(flags) == 0 { if len(flags) == 0 {

View File

@ -7,10 +7,18 @@ import (
) )
func TestLsub(t *testing.T) { func TestLsub(t *testing.T) {
tc := start(t) testLsub(t, false)
}
func TestLsubUIDOnly(t *testing.T) {
testLsub(t, true)
}
func testLsub(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.transactf("bad", "lsub") // Missing params. tc.transactf("bad", "lsub") // Missing params.
tc.transactf("bad", `lsub ""`) // Missing param. tc.transactf("bad", `lsub ""`) // Missing param.

View File

@ -9,10 +9,18 @@ import (
) )
func TestMetadata(t *testing.T) { func TestMetadata(t *testing.T) {
tc := start(t) testMetadata(t, false)
}
func TestMetadataUIDOnly(t *testing.T) {
testMetadata(t, true)
}
func testMetadata(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.transactf("ok", `getmetadata "" /private/comment`) tc.transactf("ok", `getmetadata "" /private/comment`)
tc.xuntagged() tc.xuntagged()
@ -184,9 +192,9 @@ func TestMetadata(t *testing.T) {
}) })
// Broadcast should not happen when metadata capability is not enabled. // Broadcast should not happen when metadata capability is not enabled.
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc2.cmdf("", "idle") tc2.cmdf("", "idle")
@ -255,10 +263,10 @@ func TestMetadata(t *testing.T) {
} }
func TestMetadataLimit(t *testing.T) { func TestMetadataLimit(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
maxKeys, maxSize := metadataMaxKeys, metadataMaxSize maxKeys, maxSize := metadataMaxKeys, metadataMaxSize
defer func() { defer func() {

View File

@ -7,23 +7,31 @@ import (
) )
func TestMove(t *testing.T) { func TestMove(t *testing.T) {
testMove(t, false)
}
func TestMoveUIDOnly(t *testing.T) {
testMove(t, true)
}
func testMove(t *testing.T, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc3 := startNoSwitchboard(t) tc3 := startNoSwitchboard(t, uidonly)
defer tc3.closeNoWait() defer tc3.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("Trash") tc2.client.Select("Trash")
tc3.client.Login("mjl@mox.example", password0) tc3.login("mjl@mox.example", password0)
tc3.client.Select("inbox") tc3.client.Select("inbox")
tc.transactf("bad", "move") // Missing params. tc.transactf("bad", "move") // Missing params.
@ -33,43 +41,46 @@ func TestMove(t *testing.T) {
// Seqs 1,2 and UIDs 3,4. // Seqs 1,2 and UIDs 3,4.
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.StoreFlagsSet("1:2", true, `\Deleted`) tc.client.UIDStoreFlagsSet("1:2", true, `\Deleted`)
tc.client.Expunge() tc.client.Expunge()
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Unselect() if uidonly {
tc.client.Examine("inbox") tc.transactf("ok", "uid move 1:* Trash")
tc.transactf("no", "move 1 Trash") // Opened readonly. } else {
tc.client.Unselect() tc.client.Unselect()
tc.client.Select("inbox") tc.client.Examine("inbox")
tc.transactf("no", "move 1 Trash") // Opened readonly.
tc.client.Unselect()
tc.client.Select("inbox")
tc.transactf("no", "move 1 nonexistent") tc.transactf("no", "move 1 nonexistent")
tc.xcode("TRYCREATE") tc.xcode("TRYCREATE")
tc.transactf("no", "move 1 expungebox") tc.transactf("no", "move 1 expungebox")
tc.xcode("TRYCREATE") tc.xcode("TRYCREATE")
tc.transactf("no", "move 1 inbox") // Cannot move to same mailbox. tc.transactf("no", "move 1 inbox") // Cannot move to same mailbox.
tc2.transactf("ok", "noop") // Drain. tc2.transactf("ok", "noop") // Drain.
tc3.transactf("ok", "noop") // Drain. tc3.transactf("ok", "noop") // Drain.
tc.transactf("ok", "move 1:* Trash") tc.transactf("ok", "move 1:* Trash")
ptr := func(v uint32) *uint32 { return &v } tc.xuntagged(
tc.xuntagged( imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "COPYUID", CodeArg: imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 3, Last: uint32ptr(4)}}, To: []imapclient.NumRange{{First: 1, Last: uint32ptr(2)}}}, More: "moved"}},
imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "COPYUID", CodeArg: imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 3, Last: ptr(4)}}, To: []imapclient.NumRange{{First: 1, Last: ptr(2)}}}, More: "moved"}}, imapclient.UntaggedExpunge(1),
imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1),
imapclient.UntaggedExpunge(1), )
) tc2.transactf("ok", "noop")
tc2.transactf("ok", "noop") tc2.xuntagged(
tc2.xuntagged( imapclient.UntaggedExists(2),
imapclient.UntaggedExists(2), tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil)),
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil)}}, tc.untaggedFetch(2, 2, imapclient.FetchFlags(nil)),
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), imapclient.FetchFlags(nil)}}, )
) tc3.transactf("ok", "noop")
tc3.transactf("ok", "noop") tc3.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1))
tc3.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1)) }
// UIDs 5,6 // UIDs 5,6
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
@ -79,17 +90,28 @@ func TestMove(t *testing.T) {
tc.transactf("no", "uid move 1:4 Trash") // No match. tc.transactf("no", "uid move 1:4 Trash") // No match.
tc.transactf("ok", "uid move 6:5 Trash") tc.transactf("ok", "uid move 6:5 Trash")
tc.xuntagged( if uidonly {
imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "COPYUID", CodeArg: imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 5, Last: ptr(6)}}, To: []imapclient.NumRange{{First: 3, Last: ptr(4)}}}, More: "moved"}}, tc.xuntagged(
imapclient.UntaggedExpunge(1), imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "COPYUID", CodeArg: imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 5, Last: uint32ptr(6)}}, To: []imapclient.NumRange{{First: 3, Last: uint32ptr(4)}}}, More: "moved"}},
imapclient.UntaggedExpunge(1), imapclient.UntaggedVanished{UIDs: xparseNumSet("5:6")},
) )
} else {
tc.xuntagged(
imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "COPYUID", CodeArg: imapclient.CodeCopyUID{DestUIDValidity: 1, From: []imapclient.NumRange{{First: 5, Last: uint32ptr(6)}}, To: []imapclient.NumRange{{First: 3, Last: uint32ptr(4)}}}, More: "moved"}},
imapclient.UntaggedExpunge(1),
imapclient.UntaggedExpunge(1),
)
}
tc2.transactf("ok", "noop") tc2.transactf("ok", "noop")
tc2.xuntagged( tc2.xuntagged(
imapclient.UntaggedExists(4), imapclient.UntaggedExists(4),
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(3), imapclient.FetchFlags(nil)}}, tc2.untaggedFetch(3, 3, imapclient.FetchFlags(nil)),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), imapclient.FetchFlags(nil)}}, tc2.untaggedFetch(4, 4, imapclient.FetchFlags(nil)),
) )
tc3.transactf("ok", "noop") tc3.transactf("ok", "noop")
tc3.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1)) if uidonly {
tc3.xuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("5:6")})
} else {
tc3.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1))
}
} }

View File

@ -9,15 +9,19 @@ import (
"github.com/mjl-/mox/store" "github.com/mjl-/mox/store"
) )
func ptr[T any](v T) *T { func TestNotify(t *testing.T) {
return &v testNotify(t, false)
} }
func TestNotify(t *testing.T) { func TestNotifyUIDOnly(t *testing.T) {
testNotify(t, true)
}
func testNotify(t *testing.T, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
// Check for some invalid syntax. // Check for some invalid syntax.
@ -42,9 +46,9 @@ func TestNotify(t *testing.T) {
tc.transactf("no", "Notify Set Status (Personal (unknownEvent))") tc.transactf("no", "Notify Set Status (Personal (unknownEvent))")
tc.xcode("BADEVENT") tc.xcode("BADEVENT")
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
var modseq uint32 = 4 var modseq uint32 = 4
@ -60,15 +64,9 @@ func TestNotify(t *testing.T) {
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged( tc.xuntagged(
imapclient.UntaggedExists(1), imapclient.UntaggedExists(1),
imapclient.UntaggedFetch{ tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil)),
Seq: 1,
Attrs: []imapclient.FetchAttr{
imapclient.FetchUID(1),
imapclient.FetchFlags(nil),
},
},
) )
tc2.client.StoreFlagsAdd("1:*", true, `\Deleted`) tc2.client.UIDStoreFlagsAdd("1:*", true, `\Deleted`)
modseq++ modseq++
tc2.client.Expunge() tc2.client.Expunge()
modseq++ modseq++
@ -97,67 +95,56 @@ func TestNotify(t *testing.T) {
modseq++ modseq++
tc.readuntagged( tc.readuntagged(
imapclient.UntaggedExists(1), imapclient.UntaggedExists(1),
imapclient.UntaggedFetch{ tc.untaggedFetchUID(1, 2,
Seq: 1, imapclient.FetchBodystructure{
Attrs: []imapclient.FetchAttr{ RespAttr: "BODYSTRUCTURE",
imapclient.FetchUID(2), Body: imapclient.BodyTypeMpart{
imapclient.FetchBodystructure{ Bodies: []any{
RespAttr: "BODYSTRUCTURE", imapclient.BodyTypeText{
Body: imapclient.BodyTypeMpart{ MediaType: "TEXT",
Bodies: []any{ MediaSubtype: "PLAIN",
imapclient.BodyTypeText{ BodyFields: imapclient.BodyFields{
MediaType: "TEXT", Params: [][2]string{[...]string{"CHARSET", "utf-8"}},
MediaSubtype: "PLAIN", Octets: 21,
BodyFields: imapclient.BodyFields{
Params: [][2]string{[...]string{"CHARSET", "utf-8"}},
Octets: 21,
},
Lines: 1,
Ext: &imapclient.BodyExtension1Part{},
},
imapclient.BodyTypeText{
MediaType: "TEXT",
MediaSubtype: "HTML",
BodyFields: imapclient.BodyFields{
Params: [][2]string{[...]string{"CHARSET", "utf-8"}},
Octets: 15,
},
Lines: 1,
Ext: &imapclient.BodyExtension1Part{},
}, },
Lines: 1,
Ext: &imapclient.BodyExtension1Part{},
}, },
MediaSubtype: "ALTERNATIVE", imapclient.BodyTypeText{
Ext: &imapclient.BodyExtensionMpart{ MediaType: "TEXT",
Params: [][2]string{{"BOUNDARY", "x"}}, MediaSubtype: "HTML",
BodyFields: imapclient.BodyFields{
Params: [][2]string{[...]string{"CHARSET", "utf-8"}},
Octets: 15,
},
Lines: 1,
Ext: &imapclient.BodyExtension1Part{},
}, },
}, },
MediaSubtype: "ALTERNATIVE",
Ext: &imapclient.BodyExtensionMpart{
Params: [][2]string{{"BOUNDARY", "x"}},
},
}, },
imapclient.FetchPreview{Preview: ptr("this is plain text.")},
imapclient.FetchModSeq(modseq),
}, },
}, imapclient.FetchPreview{Preview: ptr("this is plain text.")},
imapclient.FetchModSeq(modseq),
),
) )
// Change flags. // Change flags.
tc2.client.StoreFlagsAdd("1:*", true, `\Deleted`) tc2.client.UIDStoreFlagsAdd("1:*", true, `\Deleted`)
modseq++ modseq++
tc.readuntagged( tc.readuntagged(tc.untaggedFetch(1, 2, imapclient.FetchFlags{`\Deleted`}, imapclient.FetchModSeq(modseq)))
imapclient.UntaggedFetch{
Seq: 1,
Attrs: []imapclient.FetchAttr{
imapclient.FetchUID(2),
imapclient.FetchFlags{`\Deleted`},
imapclient.FetchModSeq(modseq),
},
},
)
// Remove message. // Remove message.
tc2.client.Expunge() tc2.client.Expunge()
modseq++ modseq++
tc.readuntagged( if uidonly {
imapclient.UntaggedExpunge(1), tc.readuntagged(imapclient.UntaggedVanished{UIDs: xparseNumSet("2")})
) } else {
tc.readuntagged(imapclient.UntaggedExpunge(1))
}
// MailboxMetadataChange for mailbox annotation. // MailboxMetadataChange for mailbox annotation.
tc2.transactf("ok", `setmetadata Archive (/private/comment "test")`) tc2.transactf("ok", `setmetadata Archive (/private/comment "test")`)
@ -228,13 +215,7 @@ func TestNotify(t *testing.T) {
modseq++ modseq++
tc.readuntagged( tc.readuntagged(
imapclient.UntaggedExists(1), imapclient.UntaggedExists(1),
imapclient.UntaggedFetch{ tc.untaggedFetchUID(1, 3, imapclient.FetchModSeq(modseq)),
Seq: 1,
Attrs: []imapclient.FetchAttr{
imapclient.FetchUID(3),
imapclient.FetchModSeq(modseq),
},
},
) )
// Next round of events must be ignored. We shouldn't get anything until we add a // Next round of events must be ignored. We shouldn't get anything until we add a
@ -242,7 +223,7 @@ func TestNotify(t *testing.T) {
tc.transactf("ok", "Notify Set (Selected None) (mailboxes testbox (messageNew messageExpunge)) (personal None)") tc.transactf("ok", "Notify Set (Selected None) (mailboxes testbox (messageNew messageExpunge)) (personal None)")
tc2.client.Append("inbox", makeAppend(searchMsg)) // MessageNew tc2.client.Append("inbox", makeAppend(searchMsg)) // MessageNew
modseq++ modseq++
tc2.client.StoreFlagsAdd("1:*", true, `\Deleted`) // FlagChange tc2.client.UIDStoreFlagsAdd("1:*", true, `\Deleted`) // FlagChange
modseq++ modseq++
tc2.client.Expunge() // MessageExpunge tc2.client.Expunge() // MessageExpunge
modseq++ modseq++
@ -275,27 +256,27 @@ func TestNotify(t *testing.T) {
tc.client.Unsubscribe("other/a/b") tc.client.Unsubscribe("other/a/b")
// Inboxes // Inboxes
tc3 := startNoSwitchboard(t) tc3 := startNoSwitchboard(t, uidonly)
defer tc3.closeNoWait() defer tc3.closeNoWait()
tc3.client.Login("mjl@mox.example", password0) tc3.login("mjl@mox.example", password0)
tc3.transactf("ok", "Notify Set (Inboxes (messageNew messageExpunge))") tc3.transactf("ok", "Notify Set (Inboxes (messageNew messageExpunge))")
// Subscribed // Subscribed
tc4 := startNoSwitchboard(t) tc4 := startNoSwitchboard(t, uidonly)
defer tc4.closeNoWait() defer tc4.closeNoWait()
tc4.client.Login("mjl@mox.example", password0) tc4.login("mjl@mox.example", password0)
tc4.transactf("ok", "Notify Set (Subscribed (messageNew messageExpunge))") tc4.transactf("ok", "Notify Set (Subscribed (messageNew messageExpunge))")
// Subtree // Subtree
tc5 := startNoSwitchboard(t) tc5 := startNoSwitchboard(t, uidonly)
defer tc5.closeNoWait() defer tc5.closeNoWait()
tc5.client.Login("mjl@mox.example", password0) tc5.login("mjl@mox.example", password0)
tc5.transactf("ok", "Notify Set (Subtree (Nonexistent inbox) (messageNew messageExpunge))") tc5.transactf("ok", "Notify Set (Subtree (Nonexistent inbox) (messageNew messageExpunge))")
// Subtree-One // Subtree-One
tc6 := startNoSwitchboard(t) tc6 := startNoSwitchboard(t, uidonly)
defer tc6.closeNoWait() defer tc6.closeNoWait()
tc6.client.Login("mjl@mox.example", password0) tc6.login("mjl@mox.example", password0)
tc6.transactf("ok", "Notify Set (Subtree-One (Nonexistent Inbox/a other) (messageNew messageExpunge))") tc6.transactf("ok", "Notify Set (Subtree-One (Nonexistent Inbox/a other) (messageNew messageExpunge))")
// We append to other/a/b first. It would normally come first in the notifications, // We append to other/a/b first. It would normally come first in the notifications,
@ -336,7 +317,7 @@ func TestNotify(t *testing.T) {
tc.client.Select("statusbox") tc.client.Select("statusbox")
tc2.client.Append("inbox", makeAppend(searchMsg)) tc2.client.Append("inbox", makeAppend(searchMsg))
modseq++ modseq++
tc2.client.StoreFlagsSet("1", true, `\Seen`) tc2.client.UIDStoreFlagsSet("*", true, `\Seen`)
modseq++ modseq++
tc2.client.Append("statusbox", imapclient.Append{Flags: []string{"newflag"}, Size: int64(len(searchMsg)), Data: strings.NewReader(searchMsg)}) tc2.client.Append("statusbox", imapclient.Append{Flags: []string{"newflag"}, Size: int64(len(searchMsg)), Data: strings.NewReader(searchMsg)})
modseq++ modseq++
@ -350,40 +331,32 @@ func TestNotify(t *testing.T) {
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged( tc.xuntagged(
imapclient.UntaggedExists(2), imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{ tc.untaggedFetch(2, 2, imapclient.FetchFlags{"newflag"}, imapclient.FetchModSeq(modseq)),
Seq: 2,
Attrs: []imapclient.FetchAttr{
imapclient.FetchUID(2),
imapclient.FetchFlags{`newflag`},
imapclient.FetchModSeq(modseq),
},
},
imapclient.UntaggedFlags{`\Seen`, `\Answered`, `\Flagged`, `\Deleted`, `\Draft`, `$Forwarded`, `$Junk`, `$NotJunk`, `$Phishing`, `$MDNSent`, `newflag`}, imapclient.UntaggedFlags{`\Seen`, `\Answered`, `\Flagged`, `\Deleted`, `\Draft`, `$Forwarded`, `$Junk`, `$NotJunk`, `$Phishing`, `$MDNSent`, `newflag`},
) )
tc2.client.StoreFlagsSet("2", true, `\Deleted`) tc2.client.UIDStoreFlagsSet("2", true, `\Deleted`)
modseq++ modseq++
tc2.client.Expunge() tc2.client.Expunge()
modseq++ modseq++
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged( if uidonly {
imapclient.UntaggedFetch{ tc.xuntagged(
Seq: 2, tc.untaggedFetch(2, 2, imapclient.FetchFlags{`\Deleted`}, imapclient.FetchModSeq(modseq-1)),
Attrs: []imapclient.FetchAttr{ imapclient.UntaggedVanished{UIDs: xparseNumSet("2")},
imapclient.FetchUID(2), )
imapclient.FetchFlags{`\Deleted`}, } else {
imapclient.FetchModSeq(modseq - 1), tc.xuntagged(
}, tc.untaggedFetch(2, 2, imapclient.FetchFlags{`\Deleted`}, imapclient.FetchModSeq(modseq-1)),
}, imapclient.UntaggedExpunge(2),
imapclient.UntaggedExpunge(2), )
) }
// With Selected-Delayed, we should get events for selected mailboxes immediately when using IDLE. // With Selected-Delayed, we should get events for selected mailboxes immediately when using IDLE.
tc2.client.StoreFlagsSet("*", true, `\Answered`) tc2.client.UIDStoreFlagsSet("*", true, `\Answered`)
modseq++ modseq++
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc2.client.StoreFlagsClear("1", true, `\Seen`) tc2.client.UIDStoreFlagsClear("*", true, `\Seen`)
modseq++ modseq++
tc2.client.Select("statusbox") tc2.client.Select("statusbox")
@ -394,14 +367,7 @@ func TestNotify(t *testing.T) {
tc.conn.SetReadDeadline(time.Now().Add(3 * time.Second)) tc.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
tc.cmdf("", "idle") tc.cmdf("", "idle")
tc.readprefixline("+ ") tc.readprefixline("+ ")
tc.readuntagged(imapclient.UntaggedFetch{ tc.readuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Answered`}, imapclient.FetchModSeq(modseq-1)))
Seq: 1,
Attrs: []imapclient.FetchAttr{
imapclient.FetchUID(1),
imapclient.FetchFlags{`\Answered`},
imapclient.FetchModSeq(modseq - 1),
},
})
tc.writelinef("done") tc.writelinef("done")
tc.response("ok") tc.response("ok")
tc.conn.SetReadDeadline(time.Now().Add(30 * time.Second)) tc.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
@ -409,7 +375,7 @@ func TestNotify(t *testing.T) {
// If any event matches, we normally return it. But NONE prevents looking further. // If any event matches, we normally return it. But NONE prevents looking further.
tc.client.Unselect() tc.client.Unselect()
tc.transactf("ok", "notify set (mailboxes statusbox NONE) (personal (mailboxName))") tc.transactf("ok", "notify set (mailboxes statusbox NONE) (personal (mailboxName))")
tc2.client.StoreFlagsSet("*", true, `\Answered`) // Matches NONE, ignored. tc2.client.UIDStoreFlagsSet("*", true, `\Answered`) // Matches NONE, ignored.
//modseq++ //modseq++
tc2.client.Create("eventbox", nil) tc2.client.Create("eventbox", nil)
//modseq++ //modseq++
@ -425,23 +391,19 @@ func TestNotify(t *testing.T) {
offset := strings.Index(searchMsg, "\r\n\r\n") offset := strings.Index(searchMsg, "\r\n\r\n")
tc.readuntagged( tc.readuntagged(
imapclient.UntaggedExists(2), imapclient.UntaggedExists(2),
imapclient.UntaggedFetch{ tc.untaggedFetch(2, 3,
Seq: 2, imapclient.FetchBody{
Attrs: []imapclient.FetchAttr{ RespAttr: "BODY[HEADER]",
imapclient.FetchUID(3), Section: "HEADER",
imapclient.FetchBody{ Body: searchMsg[:offset+4],
RespAttr: "BODY[HEADER]",
Section: "HEADER",
Body: searchMsg[:offset+4],
},
imapclient.FetchBody{
RespAttr: "BODY[TEXT]",
Section: "TEXT",
Body: searchMsg[offset+4:],
},
imapclient.FetchFlags(nil),
}, },
}, imapclient.FetchBody{
RespAttr: "BODY[TEXT]",
Section: "TEXT",
Body: searchMsg[offset+4:],
},
imapclient.FetchFlags(nil),
),
) )
// If we encounter an error during fetch, an untagged NO is returned. // If we encounter an error during fetch, an untagged NO is returned.
@ -457,18 +419,21 @@ func TestNotify(t *testing.T) {
More: "generating notify fetch response: requested part does not exist", More: "generating notify fetch response: requested part does not exist",
}, },
}, },
imapclient.UntaggedFetch{ tc.untaggedFetchUID(3, 4),
Seq: 3,
Attrs: []imapclient.FetchAttr{
imapclient.FetchUID(4),
},
},
) )
// When adding new tests, uncomment modseq++ lines above. // When adding new tests, uncomment modseq++ lines above.
} }
func TestNotifyOverflow(t *testing.T) { func TestNotifyOverflow(t *testing.T) {
testNotifyOverflow(t, false)
}
func TestNotifyOverflowUIDOnly(t *testing.T) {
testNotifyOverflow(t, true)
}
func testNotifyOverflow(t *testing.T, uidonly bool) {
orig := store.CommPendingChangesMax orig := store.CommPendingChangesMax
store.CommPendingChangesMax = 3 store.CommPendingChangesMax = 3
defer func() { defer func() {
@ -476,15 +441,15 @@ func TestNotifyOverflow(t *testing.T) {
}() }()
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
// Generates 4 changes, crossing max 3. // Generates 4 changes, crossing max 3.
@ -507,26 +472,22 @@ func TestNotifyOverflow(t *testing.T) {
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged() tc.xuntagged()
// Enable notify again. We won't get a notification because the message isn't yet // Enable notify again. Without uidonly, we won't get a notification because the
// known in the session. // message isn't known in the session.
tc.transactf("ok", "notify set (selected (messageNew messageExpunge flagChange))") tc.transactf("ok", "notify set (selected (messageNew messageExpunge flagChange))")
tc2.client.StoreFlagsAdd("1", true, `\Seen`) tc2.client.UIDStoreFlagsAdd("1", true, `\Seen`)
tc.transactf("ok", "noop") if uidonly {
tc.xuntagged() tc.readuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Seen`}))
} else {
tc.transactf("ok", "noop")
tc.xuntagged()
}
// Reselect to get the message visible in the session. // Reselect to get the message visible in the session.
tc.client.Select("inbox") tc.client.Select("inbox")
tc2.client.StoreFlagsClear("1", true, `\Seen`) tc2.client.UIDStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged( tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil)))
imapclient.UntaggedFetch{
Seq: 1,
Attrs: []imapclient.FetchAttr{
imapclient.FetchUID(1),
imapclient.FetchFlags(nil),
},
},
)
// Trigger overflow for changes for "selected-delayed". // Trigger overflow for changes for "selected-delayed".
store.CommPendingChangesMax = 10 store.CommPendingChangesMax = 10
@ -536,8 +497,8 @@ func TestNotifyOverflow(t *testing.T) {
selectedDelayedChangesMax = delayedMax selectedDelayedChangesMax = delayedMax
}() }()
tc.transactf("ok", "notify set (selected-delayed (messageNew messageExpunge flagChange))") tc.transactf("ok", "notify set (selected-delayed (messageNew messageExpunge flagChange))")
tc2.client.StoreFlagsAdd("1", true, `\Seen`) tc2.client.UIDStoreFlagsAdd("1", true, `\Seen`)
tc2.client.StoreFlagsClear("1", true, `\Seen`) tc2.client.UIDStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged( tc.xuntagged(
imapclient.UntaggedResult{ imapclient.UntaggedResult{
@ -550,21 +511,13 @@ func TestNotifyOverflow(t *testing.T) {
) )
// Again, no new notifications until we select and enable again. // Again, no new notifications until we select and enable again.
tc2.client.StoreFlagsAdd("1", true, `\Seen`) tc2.client.UIDStoreFlagsAdd("1", true, `\Seen`)
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged() tc.xuntagged()
tc.client.Select("inbox") tc.client.Select("inbox")
tc.transactf("ok", "notify set (selected-delayed (messageNew messageExpunge flagChange))") tc.transactf("ok", "notify set (selected-delayed (messageNew messageExpunge flagChange))")
tc2.client.StoreFlagsClear("1", true, `\Seen`) tc2.client.UIDStoreFlagsClear("1", true, `\Seen`)
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.xuntagged( tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil)))
imapclient.UntaggedFetch{
Seq: 1,
Attrs: []imapclient.FetchAttr{
imapclient.FetchUID(1),
imapclient.FetchFlags(nil),
},
},
)
} }

View File

@ -946,42 +946,6 @@ func (p *parser) xsearchKey() *searchKey {
return sk return sk
} }
// hasModseq returns whether there is a modseq filter anywhere in the searchkey.
func (sk searchKey) hasModseq() bool {
if sk.clientModseq != nil {
return true
}
for _, e := range sk.searchKeys {
if e.hasModseq() {
return true
}
}
if sk.searchKey != nil && sk.searchKey.hasModseq() {
return true
}
if sk.searchKey2 != nil && sk.searchKey2.hasModseq() {
return true
}
return false
}
// Whether we need message sequence numbers to evaluate. If not, we cannot optimize
// when only MAX is requested through a reverse query.
func (sk searchKey) needSeq() bool {
for _, k := range sk.searchKeys {
if k.needSeq() {
return true
}
}
if sk.searchKey != nil && sk.searchKey.needSeq() {
return true
}
if sk.searchKey2 != nil && sk.searchKey2.needSeq() {
return true
}
return sk.seqSet != nil && !sk.seqSet.searchResult
}
// ../rfc/9051:6489 ../rfc/3501:4692 // ../rfc/9051:6489 ../rfc/3501:4692
func (p *parser) xdateDay() int { func (p *parser) xdateDay() int {
d := p.xdigit() d := p.xdigit()

View File

@ -35,7 +35,8 @@ func (ss numSet) containsSeq(seq msgseq, uids []store.UID, searchResult []store.
return ss.containsSeqCount(seq, uint32(len(uids))) return ss.containsSeqCount(seq, uint32(len(uids)))
} }
// containsSeqCount returns whether seq is contained in ss, which must not be a searchResult, assuming the message count. // containsSeqCount returns whether seq is contained in ss, which must not be a
// searchResult, assuming the message count.
func (ss numSet) containsSeqCount(seq msgseq, msgCount uint32) bool { func (ss numSet) containsSeqCount(seq msgseq, msgCount uint32) bool {
if msgCount == 0 { if msgCount == 0 {
return false return false
@ -64,45 +65,14 @@ func (ss numSet) containsSeqCount(seq msgseq, msgCount uint32) bool {
return false return false
} }
func (ss numSet) containsUID(uid store.UID, uids []store.UID, searchResult []store.UID) bool {
if len(uids) == 0 {
return false
}
if ss.searchResult {
return uidSearch(searchResult, uid) > 0 && uidSearch(uids, uid) > 0
}
for _, r := range ss.ranges {
first := store.UID(r.first.number)
if r.first.star || first > uids[len(uids)-1] {
first = uids[len(uids)-1]
}
last := first
// Num in <num>:* can be larger than last, but it still matches the last...
// Similar for *:<num>. ../rfc/9051:4814
if r.last != nil {
last = store.UID(r.last.number)
if r.last.star || last > uids[len(uids)-1] {
last = uids[len(uids)-1]
}
}
if first > last {
first, last = last, first
}
if uid >= first && uid <= last && uidSearch(uids, uid) > 0 {
return true
}
}
return false
}
// containsKnownUID returns whether uid, which is known to exist, matches the numSet. // containsKnownUID returns whether uid, which is known to exist, matches the numSet.
// highestUID must return the highest/last UID in the mailbox, or an error. A last UID must // highestUID must return the highest/last UID in the mailbox, or an error. A last UID must
// exist, otherwise this method wouldn't have been called with a known uid. // exist, otherwise this method wouldn't have been called with a known uid.
// highestUID is needed for interpreting UID sets like "<num>:*" where num is // highestUID is needed for interpreting UID sets like "<num>:*" where num is
// higher than the uid to check. // higher than the uid to check.
func (ss numSet) containsKnownUID(uid store.UID, searchResult []store.UID, highestUID func() (store.UID, error)) (bool, error) { func (ss numSet) xcontainsKnownUID(uid store.UID, searchResult []store.UID, xhighestUID func() store.UID) bool {
if ss.searchResult { if ss.searchResult {
return uidSearch(searchResult, uid) > 0, nil return uidSearch(searchResult, uid) > 0
} }
for _, r := range ss.ranges { for _, r := range ss.ranges {
@ -111,38 +81,61 @@ func (ss numSet) containsKnownUID(uid store.UID, searchResult []store.UID, highe
// Similar for *:<num>. ../rfc/9051:4814 // Similar for *:<num>. ../rfc/9051:4814
if r.first.star { if r.first.star {
if r.last != nil && uid >= store.UID(r.last.number) { if r.last != nil && uid >= store.UID(r.last.number) {
return true, nil return true
}
var err error
a, err = highestUID()
if err != nil {
return false, err
} }
a = xhighestUID()
} }
b := a b := a
if r.last != nil { if r.last != nil {
b = store.UID(r.last.number) b = store.UID(r.last.number)
if r.last.star { if r.last.star {
if uid >= a { if uid >= a {
return true, nil return true
}
var err error
b, err = highestUID()
if err != nil {
return false, err
} }
b = xhighestUID()
} }
} }
if a > b { if a > b {
a, b = b, a a, b = b, a
} }
if uid >= a && uid <= b { if uid >= a && uid <= b {
return true, nil return true
} }
} }
return false, nil return false
}
// xinterpretStar returns a numset that interprets stars in a uid set using
// xlastUID, returning a new uid set without stars, with increasing first/last, and
// without unneeded ranges (first.number != last.number).
// If there are no messages in the mailbox, xlastUID must return zero and the
// returned numSet will include 0.
func (s numSet) xinterpretStar(xlastUID func() store.UID) numSet {
var ns numSet
for _, r := range s.ranges {
first := r.first.number
if r.first.star {
first = uint32(xlastUID())
}
last := first
if r.last != nil {
if r.last.star {
last = uint32(xlastUID())
} else {
last = r.last.number
}
}
if first > last {
first, last = last, first
}
nr := numRange{first: setNumber{number: first}}
if first != last {
nr.last = &setNumber{number: last}
}
ns.ranges = append(ns.ranges, nr)
}
return ns
} }
// contains returns whether the numset contains the number. // contains returns whether the numset contains the number.
@ -216,38 +209,6 @@ func (ss numSet) String() string {
return l[0] return l[0]
} }
// interpretStar returns a numset that interprets stars in a numset, returning a new
// numset without stars with increasing first/last.
func (s numSet) interpretStar(uids []store.UID) numSet {
var ns numSet
if len(uids) == 0 {
return ns
}
for _, r := range s.ranges {
first := r.first.number
if r.first.star || first > uint32(uids[len(uids)-1]) {
first = uint32(uids[len(uids)-1])
}
last := first
if r.last != nil {
last = r.last.number
if r.last.star || last > uint32(uids[len(uids)-1]) {
last = uint32(uids[len(uids)-1])
}
}
if first > last {
first, last = last, first
}
nr := numRange{first: setNumber{number: first}}
if first != last {
nr.last = &setNumber{number: last}
}
ns.ranges = append(ns.ranges, nr)
}
return ns
}
// whether numSet only has numbers (no star/search), and is strictly increasing. // whether numSet only has numbers (no star/search), and is strictly increasing.
func (s *numSet) isBasicIncreasing() bool { func (s *numSet) isBasicIncreasing() bool {
if s.searchResult { if s.searchResult {
@ -385,6 +346,40 @@ type searchKey struct {
clientModseq *int64 clientModseq *int64
} }
// Whether we need message sequence numbers to evaluate. Sequence numbers are not
// allowed with UIDONLY. And if we need sequence numbers we cannot optimize
// searching for MAX with a query in reverse order.
func (sk *searchKey) hasSequenceNumbers() bool {
for _, k := range sk.searchKeys {
if k.hasSequenceNumbers() {
return true
}
}
if sk.searchKey != nil && sk.searchKey.hasSequenceNumbers() || sk.searchKey2 != nil && sk.searchKey2.hasSequenceNumbers() {
return true
}
return sk.seqSet != nil && !sk.seqSet.searchResult
}
// hasModseq returns whether there is a modseq filter anywhere in the searchkey.
func (sk *searchKey) hasModseq() bool {
if sk.clientModseq != nil {
return true
}
for _, e := range sk.searchKeys {
if e.hasModseq() {
return true
}
}
if sk.searchKey != nil && sk.searchKey.hasModseq() {
return true
}
if sk.searchKey2 != nil && sk.searchKey2.hasModseq() {
return true
}
return false
}
func compactUIDSet(l []store.UID) (r numSet) { func compactUIDSet(l []store.UID) (r numSet) {
for len(l) > 0 { for len(l) > 0 {
e := 1 e := 1

View File

@ -23,16 +23,10 @@ func TestNumSetContains(t *testing.T) {
check(ss0.containsSeq(1, []store.UID{2}, []store.UID{2})) check(ss0.containsSeq(1, []store.UID{2}, []store.UID{2}))
check(!ss0.containsSeq(1, []store.UID{2}, []store.UID{})) check(!ss0.containsSeq(1, []store.UID{2}, []store.UID{}))
check(ss0.containsUID(1, []store.UID{1}, []store.UID{1}))
check(ss0.containsUID(2, []store.UID{1, 2, 3}, []store.UID{2}))
check(!ss0.containsUID(2, []store.UID{1, 2, 3}, []store.UID{}))
check(!ss0.containsUID(2, []store.UID{}, []store.UID{2}))
ss1 := numSet{false, []numRange{{*num(1), nil}}} // Single number 1. ss1 := numSet{false, []numRange{{*num(1), nil}}} // Single number 1.
check(ss1.containsSeq(1, []store.UID{2}, nil)) check(ss1.containsSeq(1, []store.UID{2}, nil))
check(!ss1.containsSeq(2, []store.UID{1, 2}, nil)) check(!ss1.containsSeq(2, []store.UID{1, 2}, nil))
check(ss1.containsUID(1, []store.UID{1}, nil))
check(ss1.containsSeq(1, []store.UID{2}, nil)) check(ss1.containsSeq(1, []store.UID{2}, nil))
check(!ss1.containsSeq(2, []store.UID{1, 2}, nil)) check(!ss1.containsSeq(2, []store.UID{1, 2}, nil))
@ -44,15 +38,6 @@ func TestNumSetContains(t *testing.T) {
check(ss2.containsSeq(3, []store.UID{4, 5, 6}, nil)) check(ss2.containsSeq(3, []store.UID{4, 5, 6}, nil))
check(!ss2.containsSeq(4, []store.UID{4, 5, 6}, nil)) check(!ss2.containsSeq(4, []store.UID{4, 5, 6}, nil))
check(ss2.containsUID(2, []store.UID{2}, nil))
check(!ss2.containsUID(1, []store.UID{1, 2, 3}, nil))
check(ss2.containsUID(3, []store.UID{1, 2, 3}, nil))
check(!ss2.containsUID(2, []store.UID{4, 5}, nil))
check(!ss2.containsUID(2, []store.UID{1}, nil))
check(ss2.containsUID(2, []store.UID{2, 6}, nil))
check(ss2.containsUID(6, []store.UID{2, 6}, nil))
// *:2, same as 2:* // *:2, same as 2:*
ss3 := numSet{false, []numRange{{*star, num(2)}}} ss3 := numSet{false, []numRange{{*star, num(2)}}}
check(ss3.containsSeq(1, []store.UID{2}, nil)) check(ss3.containsSeq(1, []store.UID{2}, nil))
@ -60,15 +45,6 @@ func TestNumSetContains(t *testing.T) {
check(ss3.containsSeq(2, []store.UID{4, 5}, nil)) check(ss3.containsSeq(2, []store.UID{4, 5}, nil))
check(ss3.containsSeq(3, []store.UID{4, 5, 6}, nil)) check(ss3.containsSeq(3, []store.UID{4, 5, 6}, nil))
check(!ss3.containsSeq(4, []store.UID{4, 5, 6}, nil)) check(!ss3.containsSeq(4, []store.UID{4, 5, 6}, nil))
check(ss3.containsUID(2, []store.UID{2}, nil))
check(!ss3.containsUID(1, []store.UID{1, 2, 3}, nil))
check(ss3.containsUID(3, []store.UID{1, 2, 3}, nil))
check(!ss3.containsUID(2, []store.UID{4, 5}, nil))
check(!ss3.containsUID(2, []store.UID{1}, nil))
check(ss3.containsUID(2, []store.UID{2, 6}, nil))
check(ss3.containsUID(6, []store.UID{2, 6}, nil))
} }
func TestNumSetInterpret(t *testing.T) { func TestNumSetInterpret(t *testing.T) {
@ -77,38 +53,34 @@ func TestNumSetInterpret(t *testing.T) {
return p.xnumSet0(true, false) return p.xnumSet0(true, false)
} }
checkEqual := func(uids []store.UID, a, s string) { checkEqual := func(lastUID store.UID, a, s string) {
t.Helper() t.Helper()
n := parseNumSet(a).interpretStar(uids) n := parseNumSet(a).xinterpretStar(func() store.UID { return lastUID })
ns := n.String() ns := n.String()
if ns != s { if ns != s {
t.Fatalf("%s != %s", ns, s) t.Fatalf("%s != %s", ns, s)
} }
} }
checkEqual([]store.UID{}, "1:*", "") checkEqual(0, "1:*", "0:1")
checkEqual([]store.UID{1}, "1:*", "1") checkEqual(1, "1:*", "1")
checkEqual([]store.UID{1, 3}, "1:*", "1:3") checkEqual(3, "1:*", "1:3")
checkEqual([]store.UID{1, 3}, "4:*", "3") checkEqual(3, "4:*", "3:4")
checkEqual([]store.UID{1, 3}, "*:4", "3") checkEqual(3, "*:4", "3:4")
checkEqual([]store.UID{2, 3}, "*:4", "3") checkEqual(3, "*:4", "3:4")
checkEqual([]store.UID{2, 3}, "*:1", "1:3") checkEqual(3, "*:1", "1:3")
checkEqual([]store.UID{2, 3}, "1:*", "1:3") checkEqual(3, "1:*", "1:3")
checkEqual([]store.UID{1, 2, 3}, "1,2,3", "1,2,3") checkEqual(3, "1,2,3", "1,2,3")
checkEqual([]store.UID{}, "1,2,3", "") checkEqual(0, "1,2,3", "1,2,3")
checkEqual([]store.UID{}, "1:3", "") checkEqual(0, "1:3", "1:3")
checkEqual([]store.UID{}, "3:1", "") checkEqual(0, "3:1", "1:3")
iter := parseNumSet("1:3").interpretStar([]store.UID{}).newIter() iter := parseNumSet("3:1").xinterpretStar(func() store.UID { return 2 }).newIter()
if _, ok := iter.Next(); ok {
t.Fatalf("expected immediate end for empty iter")
}
iter = parseNumSet("3:1").interpretStar([]store.UID{1, 2}).newIter()
v0, _ := iter.Next() v0, _ := iter.Next()
v1, _ := iter.Next() v1, _ := iter.Next()
v2, _ := iter.Next()
_, ok := iter.Next() _, ok := iter.Next()
if v0 != 1 || v1 != 2 || ok { if v0 != 1 || v1 != 2 || v2 != 3 || ok {
t.Fatalf("got %v %v %v, expected 1, 2, false", v0, v1, ok) t.Fatalf("got %v %v %v %v, expected 1, 2, 3 false", v0, v1, v2, ok)
} }
} }

View File

@ -7,10 +7,10 @@ import (
) )
func TestQuota1(t *testing.T) { func TestQuota1(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
// We don't implement setquota. // We don't implement setquota.
tc.transactf("bad", `setquota "" (STORAGE 123)`) tc.transactf("bad", `setquota "" (STORAGE 123)`)
@ -35,10 +35,10 @@ func TestQuota1(t *testing.T) {
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusDeletedStorage: 0}}) tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusDeletedStorage: 0}})
// tclimit does have a limit. // tclimit does have a limit.
tclimit := startArgs(t, false, false, true, true, "limit") tclimit := startArgs(t, false, false, false, true, true, "limit")
defer tclimit.close() defer tclimit.close()
tclimit.client.Login("limit@mox.example", password0) tclimit.login("limit@mox.example", password0)
tclimit.transactf("ok", "getquotaroot inbox") tclimit.transactf("ok", "getquotaroot inbox")
tclimit.xuntagged( tclimit.xuntagged(

View File

@ -6,16 +6,24 @@ import (
"github.com/mjl-/mox/imapclient" "github.com/mjl-/mox/imapclient"
) )
// todo: check that UIDValidity is indeed updated properly.
func TestRename(t *testing.T) { func TestRename(t *testing.T) {
tc := start(t) testRename(t, false)
}
func TestRenameUIDOnly(t *testing.T) {
testRename(t, true)
}
// todo: check that UIDValidity is indeed updated properly.
func testRename(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc.transactf("bad", "rename") // Missing parameters. tc.transactf("bad", "rename") // Missing parameters.
tc.transactf("bad", "rename x") // Missing destination. tc.transactf("bad", "rename x") // Missing destination.
@ -104,7 +112,7 @@ func TestRename(t *testing.T) {
) )
tc.transactf("ok", `select x/minbox`) tc.transactf("ok", `select x/minbox`)
tc.transactf("ok", `uid fetch 1:* flags`) tc.transactf("ok", `uid fetch 1:* flags`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{"label1"}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{"label1"}))
// Renaming to new hiearchy that does not have any subscribes. // Renaming to new hiearchy that does not have any subscribes.
tc.transactf("ok", "rename x/minbox w/w") tc.transactf("ok", "rename x/minbox w/w")

View File

@ -82,32 +82,38 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
// Resolve "*" for UID or message sequence. // Resolve "*" for UID or message sequence.
if star { if star {
if len(c.uids) == 0 { if c.uidonly {
return func() { xuserErrorf("cannot use * on empty mailbox") } q := bstore.QueryTx[store.Message](tx)
} q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
if isUID { q.FilterEqual("Expunged", false)
num = uint32(c.uids[len(c.uids)-1]) q.FilterLess("UID", c.uidnext)
q.SortDesc("UID")
q.Limit(1)
m, err := q.Get()
if err == bstore.ErrAbsent {
return func() { xsyntaxErrorf("cannot use * on empty mailbox") }
}
xcheckf(err, "get last message in mailbox")
num = uint32(m.UID)
} else if c.exists == 0 {
return func() { xsyntaxErrorf("cannot use * on empty mailbox") }
} else if isUID {
num = uint32(c.uids[c.exists-1])
} else { } else {
num = uint32(len(c.uids)) num = uint32(c.exists)
} }
star = false star = false
} }
// Find or verify UID of message to replace. // Find or verify UID of message to replace.
var seq msgseq
if isUID { if isUID {
seq = c.sequence(store.UID(num)) uidOld = store.UID(num)
if seq <= 0 { } else if num > c.exists {
return func() { xuserErrorf("unknown uid %d", num) }
}
} else if num > uint32(len(c.uids)) {
return func() { xuserErrorf("invalid msgseq") } return func() { xuserErrorf("invalid msgseq") }
} else { } else {
seq = msgseq(num) uidOld = c.uids[int(num)-1]
} }
uidOld = c.uids[int(seq)-1]
// Check the message still exists in the database. If it doesn't, it may have been // 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, // 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 // when we are not possibly reading a sync literal and can respond with unsolicited
@ -115,6 +121,7 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
q := bstore.QueryTx[store.Message](tx) q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uidOld}) q.FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uidOld})
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
q.FilterLess("UID", c.uidnext)
_, err = q.Get() _, err = q.Get()
if err == bstore.ErrAbsent { if err == bstore.ErrAbsent {
return nil return nil
@ -336,7 +343,7 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
c.uidAppend(nm.UID) c.uidAppend(nm.UID)
// We send an untagged OK with APPENDUID, for sane bookkeeping in clients. ../rfc/8508:401 // We send an untagged OK with APPENDUID, for sane bookkeeping in clients. ../rfc/8508:401
c.xbwritelinef("* OK [APPENDUID %d %d] ", mbDst.UIDValidity, nm.UID) c.xbwritelinef("* OK [APPENDUID %d %d] ", mbDst.UIDValidity, nm.UID)
c.xbwritelinef("* %d EXISTS", len(c.uids)) c.xbwritelinef("* %d EXISTS", c.exists)
} }
// We must return vanished instead of expunge, and also highestmodseq, when qresync // We must return vanished instead of expunge, and also highestmodseq, when qresync
@ -345,13 +352,18 @@ func (c *conn) cmdxReplace(isUID bool, tag, cmd string, p *parser) {
// Now that we are in sync with msgseq, we can find our old msgseq and say it is // Now that we are in sync with msgseq, we can find our old msgseq and say it is
// expunged or vanished. ../rfc/7162:1900 // expunged or vanished. ../rfc/7162:1900
omsgseq := c.xsequence(om.UID) var oseq msgseq
c.sequenceRemove(omsgseq, om.UID) if c.uidonly {
if qresync { c.exists--
} else {
oseq = c.xsequence(om.UID)
c.sequenceRemove(oseq, om.UID)
}
if qresync || c.uidonly {
c.xbwritelinef("* VANISHED %d", om.UID) c.xbwritelinef("* VANISHED %d", om.UID)
// ../rfc/7162:1916 // ../rfc/7162:1916
} else { } else {
c.xbwritelinef("* %d EXPUNGE", omsgseq) c.xbwritelinef("* %d EXPUNGE", oseq)
} }
c.xwriteresultf("%s OK [HIGHESTMODSEQ %d] replaced", tag, nm.ModSeq.Client()) c.xwriteresultf("%s OK [HIGHESTMODSEQ %d] replaced", tag, nm.ModSeq.Client())
} }

View File

@ -7,45 +7,79 @@ import (
) )
func TestReplace(t *testing.T) { func TestReplace(t *testing.T) {
testReplace(t, false)
}
func TestReplaceUIDOnly(t *testing.T) {
testReplace(t, true)
}
func testReplace(t *testing.T, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
// Star not allowed on empty mailbox.
tc.transactf("bad", "uid replace * inbox {1}")
if !uidonly {
tc.transactf("bad", "replace * inbox {1}")
}
// Append 3 messages, remove first. Leaves msgseq 1,2 with uid 2,3. // 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.Append("inbox", makeAppend(exampleMsg), makeAppend(exampleMsg), makeAppend(exampleMsg))
tc.client.StoreFlagsSet("1", true, `\deleted`) tc.client.UIDStoreFlagsSet("1", true, `\deleted`)
tc.client.Expunge() tc.client.Expunge()
tc.transactf("no", "replace 2 expungebox {1}") // Mailbox no longer exists. tc.transactf("no", "uid replace 1 expungebox {1}") // Mailbox no longer exists.
tc.xcode("TRYCREATE") tc.xcode("TRYCREATE")
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
// Replace last message (msgseq 2, uid 3) in same mailbox. // Replace last message (msgseq 2, uid 3) in same mailbox.
tc.lastUntagged, tc.lastResult, tc.lastErr = tc.client.Replace("2", "INBOX", makeAppend(searchMsg)) if uidonly {
tc.lastUntagged, tc.lastResult, tc.lastErr = tc.client.UIDReplace("3", "INBOX", makeAppend(searchMsg))
} else {
tc.lastUntagged, tc.lastResult, tc.lastErr = tc.client.Replace("2", "INBOX", makeAppend(searchMsg))
}
tcheck(tc.t, tc.lastErr, "read imap response") tcheck(tc.t, tc.lastErr, "read imap response")
tc.xuntagged( if uidonly {
imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "APPENDUID", CodeArg: imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("4")}, More: ""}}, tc.xuntagged(
imapclient.UntaggedExists(3), imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "APPENDUID", CodeArg: imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("4")}, More: ""}},
imapclient.UntaggedExpunge(2), imapclient.UntaggedExists(3),
) imapclient.UntaggedVanished{UIDs: xparseNumSet("3")},
)
} else {
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(8)) tc.xcodeArg(imapclient.CodeHighestModSeq(8))
// Check that other client sees Exists and Expunge. // Check that other client sees Exists and Expunge.
tc2.transactf("ok", "noop") tc2.transactf("ok", "noop")
tc2.xuntagged( if uidonly {
imapclient.UntaggedExpunge(2), tc2.xuntagged(
imapclient.UntaggedExists(2), imapclient.UntaggedVanished{UIDs: xparseNumSet("3")},
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), imapclient.FetchFlags(nil)}}, imapclient.UntaggedExists(2),
) tc.untaggedFetch(2, 4, imapclient.FetchFlags(nil)),
)
} else {
tc2.xuntagged(
imapclient.UntaggedExpunge(2),
imapclient.UntaggedExists(2),
tc.untaggedFetch(2, 4, imapclient.FetchFlags(nil)),
)
}
// Enable qresync, replace uid 2 (msgseq 1) to different mailbox, see that we get vanished instead of expunged. // Enable qresync, replace uid 2 (msgseq 1) to different mailbox, see that we get vanished instead of expunged.
tc.transactf("ok", "enable qresync") tc.transactf("ok", "enable qresync")
@ -58,18 +92,38 @@ func TestReplace(t *testing.T) {
) )
tc.xcodeArg(imapclient.CodeHighestModSeq(9)) tc.xcodeArg(imapclient.CodeHighestModSeq(9))
// Use "*" for replacing.
tc.transactf("ok", "uid replace * inbox {1+}\r\nx")
tc.xuntagged(
imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "APPENDUID", CodeArg: imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("6")}, More: ""}},
imapclient.UntaggedExists(3),
imapclient.UntaggedVanished{UIDs: xparseNumSet("5")},
)
if !uidonly {
tc.transactf("ok", "replace * inbox {1+}\r\ny")
tc.xuntagged(
imapclient.UntaggedResult{Status: "OK", RespText: imapclient.RespText{Code: "APPENDUID", CodeArg: imapclient.CodeAppendUID{UIDValidity: 1, UIDs: xparseUIDRange("7")}, More: ""}},
imapclient.UntaggedExists(3),
imapclient.UntaggedVanished{UIDs: xparseNumSet("6")},
)
}
// Non-existent mailbox with non-synchronizing literal should consume the literal. // Non-existent mailbox with non-synchronizing literal should consume the literal.
tc.transactf("no", "replace 1 bogusbox {1+}\r\nx") if uidonly {
tc.transactf("no", "uid replace 1 bogusbox {1+}\r\nx")
} else {
tc.transactf("no", "replace 1 bogusbox {1+}\r\nx")
}
// Leftover data. // Leftover data.
tc.transactf("bad", "replace 1 inbox () {6+}\r\ntest\r\n ") tc.transactf("bad", "replace 1 inbox () {6+}\r\ntest\r\n ")
} }
func TestReplaceBigNonsyncLit(t *testing.T) { func TestReplaceBigNonsyncLit(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
// Adding a message >1mb with non-sync literal to non-existent mailbox should abort entire connection. // Adding a message >1mb with non-sync literal to non-existent mailbox should abort entire connection.
@ -81,20 +135,28 @@ func TestReplaceBigNonsyncLit(t *testing.T) {
} }
func TestReplaceQuota(t *testing.T) { func TestReplaceQuota(t *testing.T) {
testReplaceQuota(t, false)
}
func TestReplaceQuotaUIDOnly(t *testing.T) {
testReplaceQuota(t, true)
}
func testReplaceQuota(t *testing.T, uidonly bool) {
// with quota limit // with quota limit
tc := startArgs(t, true, false, true, true, "limit") tc := startArgs(t, uidonly, true, false, true, true, "limit")
defer tc.close() defer tc.close()
tc.client.Login("limit@mox.example", password0) tc.login("limit@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc.client.Append("inbox", makeAppend("x")) tc.client.Append("inbox", makeAppend("x"))
// Synchronizing literal, we get failure immediately. // Synchronizing literal, we get failure immediately.
tc.transactf("no", "replace 1 inbox {6}\r\n") tc.transactf("no", "uid replace 1 inbox {6}\r\n")
tc.xcode("OVERQUOTA") tc.xcode("OVERQUOTA")
// Synchronizing literal to non-existent mailbox, we get failure immediately. // Synchronizing literal to non-existent mailbox, we get failure immediately.
tc.transactf("no", "replace 1 badbox {6}\r\n") tc.transactf("no", "uid replace 1 badbox {6}\r\n")
tc.xcode("TRYCREATE") tc.xcode("TRYCREATE")
buf := make([]byte, 4000, 4002) buf := make([]byte, 4000, 4002)
@ -104,14 +166,14 @@ func TestReplaceQuota(t *testing.T) {
buf = append(buf, "\r\n"...) buf = append(buf, "\r\n"...)
// Non-synchronizing literal. We get to write our data. // Non-synchronizing literal. We get to write our data.
tc.client.Commandf("", "replace 1 inbox ~{4000+}") tc.client.Commandf("", "uid replace 1 inbox ~{4000+}")
_, err := tc.client.Write(buf) _, err := tc.client.Write(buf)
tc.check(err, "write replace message") tc.check(err, "write replace message")
tc.response("no") tc.response("no")
tc.xcode("OVERQUOTA") tc.xcode("OVERQUOTA")
// Non-synchronizing literal to bad mailbox. // Non-synchronizing literal to bad mailbox.
tc.client.Commandf("", "replace 1 badbox {4000+}") tc.client.Commandf("", "uid replace 1 badbox {4000+}")
_, err = tc.client.Write(buf) _, err = tc.client.Write(buf)
tc.check(err, "write replace message") tc.check(err, "write replace message")
tc.response("no") tc.response("no")
@ -119,22 +181,30 @@ func TestReplaceQuota(t *testing.T) {
} }
func TestReplaceExpunged(t *testing.T) { func TestReplaceExpunged(t *testing.T) {
tc := start(t) testReplaceExpunged(t, false)
}
func TestReplaceExpungedUIDOnly(t *testing.T) {
testReplaceExpunged(t, true)
}
func testReplaceExpunged(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
// We start the command, but don't write data yet. // We start the command, but don't write data yet.
tc.client.Commandf("", "replace 1 inbox {4000}") tc.client.Commandf("", "uid replace 1 inbox {4000}")
// Get in with second client and remove the message we are replacing. // Get in with second client and remove the message we are replacing.
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc2.client.StoreFlagsSet("1", true, `\Deleted`) tc2.client.UIDStoreFlagsSet("1", true, `\Deleted`)
tc2.client.Expunge() tc2.client.Expunge()
tc2.client.Unselect() tc2.client.Unselect()
tc2.client.Close() tc2.client.Close()
@ -149,8 +219,15 @@ func TestReplaceExpunged(t *testing.T) {
_, err := tc.client.Write(buf) _, err := tc.client.Write(buf)
tc.check(err, "write replace message") tc.check(err, "write replace message")
tc.response("no") tc.response("no")
tc.xuntagged( if uidonly {
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags{`\Deleted`}}}, tc.xuntagged(
imapclient.UntaggedExpunge(1), tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Deleted`}),
) imapclient.UntaggedVanished{UIDs: xparseNumSet("1")},
)
} else {
tc.xuntagged(
tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Deleted`}),
imapclient.UntaggedExpunge(1),
)
}
} }

View File

@ -107,6 +107,11 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
sk.searchKeys = append(sk.searchKeys, *p.xsearchKey()) sk.searchKeys = append(sk.searchKeys, *p.xsearchKey())
} }
// Sequence set search program must be rejected with UIDONLY enabled. ../rfc/9586:220
if c.uidonly && sk.hasSequenceNumbers() {
xsyntaxCodeErrorf("UIDREQUIRED", "cannot search message sequence numbers in search program with uidonly enabled")
}
// Even in case of error, we ensure search result is changed. // Even in case of error, we ensure search result is changed.
if save { if save {
c.searchResult = []store.UID{} c.searchResult = []store.UID{}
@ -340,7 +345,7 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
// Determine if search has a sequence set without search results. If so, we need // Determine if search has a sequence set without search results. If so, we need
// sequence numbers for matching, and we must always go through the messages in // sequence numbers for matching, and we must always go through the messages in
// forward order. No reverse search for MAX only. // forward order. No reverse search for MAX only.
needSeq := (len(mailboxes) > 1 || len(mailboxes) == 1 && mailboxes[0].ID != c.mailboxID) && sk.needSeq() needSeq := (len(mailboxes) > 1 || len(mailboxes) == 1 && mailboxes[0].ID != c.mailboxID) && sk.hasSequenceNumbers()
forward := eargs == nil || max1 == 0 || len(eargs) != 1 || needSeq forward := eargs == nil || max1 == 0 || len(eargs) != 1 || needSeq
reverse := max1 == 1 && (len(eargs) == 1 || min1+max1 == len(eargs)) && !needSeq reverse := max1 == 1 && (len(eargs) == 1 || min1+max1 == len(eargs)) && !needSeq
@ -352,8 +357,8 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
goal := "nil" goal := "nil"
var total uint32 var total uint32
for _, mb := range mailboxes { for _, mb := range mailboxes {
if mb.ID == c.mailboxID { if mb.ID == c.mailboxID && !c.uidonly {
total += uint32(len(c.uids)) total += c.exists
} else { } else {
total += uint32(mb.Total + mb.Deleted) total += uint32(mb.Total + mb.Deleted)
} }
@ -370,27 +375,34 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
result := Result{Mailbox: mb} result := Result{Mailbox: mb}
msgCount := uint32(mb.MailboxCounts.Total + mb.MailboxCounts.Deleted) msgCount := uint32(mb.MailboxCounts.Total + mb.MailboxCounts.Deleted)
if mb.ID == c.mailboxID { if mb.ID == c.mailboxID && !c.uidonly {
msgCount = uint32(len(c.uids)) msgCount = c.exists
} }
// Used for interpreting UID sets with a star, like "1:*" and "10:*". Only called // Used for interpreting UID sets with a star, like "1:*" and "10:*". Only called
// for UIDs that are higher than the number, since "10:*" evaluates to "10:5" if 5 // for UIDs that are higher than the number, since "10:*" evaluates to "10:5" if 5
// is the highest UID, and UID 5-10 would all match. // is the highest UID, and UID 5-10 would all match.
var cachedHighestUID store.UID var cachedHighestUID store.UID
highestUID := func() (store.UID, error) { xhighestUID := func() store.UID {
if cachedHighestUID > 0 { if cachedHighestUID > 0 {
return cachedHighestUID, nil return cachedHighestUID
} }
q := bstore.QueryTx[store.Message](tx) q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: mb.ID}) q.FilterNonzero(store.Message{MailboxID: mb.ID})
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
if mb.ID == c.mailboxID {
q.FilterLess("UID", c.uidnext)
}
q.SortDesc("UID") q.SortDesc("UID")
q.Limit(1) q.Limit(1)
m, err := q.Get() m, err := q.Get()
if err == bstore.ErrAbsent {
xuserErrorf("cannot use * on empty mailbox")
}
xcheckf(err, "get last uid")
cachedHighestUID = m.UID cachedHighestUID = m.UID
return cachedHighestUID, err return cachedHighestUID
} }
progressOrig := progress progressOrig := progress
@ -403,6 +415,9 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
q := bstore.QueryTx[store.Message](tx) q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: mb.ID}) q.FilterNonzero(store.Message{MailboxID: mb.ID})
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
if mb.ID == c.mailboxID {
q.FilterLess("UID", c.uidnext)
}
q.SortAsc("UID") q.SortAsc("UID")
for m, err := range q.All() { for m, err := range q.All() {
xcheckf(err, "list messages in mailbox") xcheckf(err, "list messages in mailbox")
@ -416,7 +431,7 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
} }
progress++ progress++
if c.searchMatch(tx, msgCount, seq, m, *sk, bodySearch, textSearch, highestUID) { if c.searchMatch(tx, msgCount, seq, m, *sk, bodySearch, textSearch, xhighestUID) {
result.UIDs = append(result.UIDs, m.UID) result.UIDs = append(result.UIDs, m.UID)
result.MaxModSeq = max(result.MaxModSeq, m.ModSeq) result.MaxModSeq = max(result.MaxModSeq, m.ModSeq)
if min1 == 1 && min1+max1 == len(eargs) { if min1 == 1 && min1+max1 == len(eargs) {
@ -443,6 +458,9 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
q.FilterNonzero(store.Message{MailboxID: mb.ID}) q.FilterNonzero(store.Message{MailboxID: mb.ID})
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
q.FilterGreater("UID", lastUID) q.FilterGreater("UID", lastUID)
if mb.ID == c.mailboxID {
q.FilterLess("UID", c.uidnext)
}
q.SortDesc("UID") q.SortDesc("UID")
for m, err := range q.All() { for m, err := range q.All() {
xcheckf(err, "list messages in mailbox") xcheckf(err, "list messages in mailbox")
@ -454,7 +472,7 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
progress++ progress++
var seq msgseq // Filled in by searchMatch for messages in selected mailbox. var seq msgseq // Filled in by searchMatch for messages in selected mailbox.
if c.searchMatch(tx, msgCount, seq, m, *sk, bodySearch, textSearch, highestUID) { if c.searchMatch(tx, msgCount, seq, m, *sk, bodySearch, textSearch, xhighestUID) {
result.UIDs = append(result.UIDs, m.UID) result.UIDs = append(result.UIDs, m.UID)
result.MaxModSeq = max(result.MaxModSeq, m.ModSeq) result.MaxModSeq = max(result.MaxModSeq, m.ModSeq)
break break
@ -483,10 +501,7 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
// Old-style SEARCH response. We must spell out each number. So we may be splitting // Old-style SEARCH response. We must spell out each number. So we may be splitting
// into multiple responses. ../rfc/9051:6809 ../rfc/3501:4833 // into multiple responses. ../rfc/9051:6809 ../rfc/3501:4833
for len(result.UIDs) > 0 { for len(result.UIDs) > 0 {
n := len(result.UIDs) n := min(100, len(result.UIDs))
if n > 100 {
n = 100
}
s := "" s := ""
for _, v := range result.UIDs[:n] { for _, v := range result.UIDs[:n] {
if !isUID { if !isUID {
@ -516,9 +531,7 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
if save { if save {
// ../rfc/9051:3784 ../rfc/5182:13 // ../rfc/9051:3784 ../rfc/5182:13
c.searchResult = results[0].UIDs c.searchResult = results[0].UIDs
if sanityChecks { c.checkUIDs(c.searchResult, false)
checkUIDs(c.searchResult)
}
} }
// No untagged ESEARCH response if nothing was requested. ../rfc/9051:4160 // No untagged ESEARCH response if nothing was requested. ../rfc/9051:4160
@ -584,27 +597,33 @@ func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
} }
type search struct { type search struct {
c *conn c *conn
tx *bstore.Tx tx *bstore.Tx
msgCount uint32 // Number of messages in mailbox (or session when selected). msgCount uint32 // Number of messages in mailbox (or session when selected).
seq msgseq // Can be 0, for other mailboxes than selected in case of MAX. seq msgseq // Can be 0, for other mailboxes than selected in case of MAX.
m store.Message m store.Message
mr *store.MsgReader mr *store.MsgReader
p *message.Part p *message.Part
highestUID func() (store.UID, error) xhighestUID func() store.UID
} }
func (c *conn) searchMatch(tx *bstore.Tx, msgCount uint32, seq msgseq, m store.Message, sk searchKey, bodySearch, textSearch *store.WordSearch, highestUID func() (store.UID, error)) bool { func (c *conn) searchMatch(tx *bstore.Tx, msgCount uint32, seq msgseq, m store.Message, sk searchKey, bodySearch, textSearch *store.WordSearch, xhighestUID func() store.UID) bool {
if m.MailboxID == c.mailboxID { if m.MailboxID == c.mailboxID {
seq = c.sequence(m.UID) // If session doesn't know about the message yet, don't return it.
if seq == 0 { if c.uidonly {
// Session has not yet seen this message, and is not expecting to get a result that if m.UID >= c.uidnext {
// includes it. return false
return false }
} else {
// Set seq for use in evaluations.
seq = c.sequence(m.UID)
if seq == 0 {
return false
}
} }
} }
s := search{c: c, tx: tx, msgCount: msgCount, seq: seq, m: m, highestUID: highestUID} s := search{c: c, tx: tx, msgCount: msgCount, seq: seq, m: m, xhighestUID: xhighestUID}
defer func() { defer func() {
if s.mr != nil { if s.mr != nil {
err := s.mr.Close() err := s.mr.Close()
@ -722,9 +741,7 @@ func (s *search) match0(sk searchKey) bool {
// is likely a mistake. No mention about it in the RFC. ../rfc/7377:257 // is likely a mistake. No mention about it in the RFC. ../rfc/7377:257
xuserErrorf("cannot use search result from another mailbox") xuserErrorf("cannot use search result from another mailbox")
} }
match, err := sk.uidSet.containsKnownUID(s.m.UID, c.searchResult, s.highestUID) return sk.uidSet.xcontainsKnownUID(s.m.UID, c.searchResult, s.xhighestUID)
xcheckf(err, "checking for presence in uid set")
return match
} }
// Parsed part. // Parsed part.

View File

@ -62,9 +62,17 @@ func (tc *testconn) xesearch(exp imapclient.UntaggedEsearch) {
} }
func TestSearch(t *testing.T) { func TestSearch(t *testing.T) {
tc := start(t) testSearch(t, false)
}
func TestSearchUIDOnly(t *testing.T) {
testSearch(t, true)
}
func testSearch(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
// Add 5 and delete first 4 messages. So UIDs start at 5. // Add 5 and delete first 4 messages. So UIDs start at 5.
@ -73,7 +81,7 @@ func TestSearch(t *testing.T) {
for range 5 { for range 5 {
tc.client.Append("inbox", makeAppendTime(exampleMsg, received)) tc.client.Append("inbox", makeAppendTime(exampleMsg, received))
} }
tc.client.StoreFlagsSet("1:4", true, `\Deleted`) tc.client.UIDStoreFlagsSet("1:4", true, `\Deleted`)
tc.client.Expunge() tc.client.Expunge()
received = time.Date(2022, time.January, 1, 9, 0, 0, 0, time.UTC) received = time.Date(2022, time.January, 1, 9, 0, 0, 0, time.UTC)
@ -98,298 +106,314 @@ func TestSearch(t *testing.T) {
// We now have sequence numbers 1,2,3 and UIDs 5,6,7. // We now have sequence numbers 1,2,3 and UIDs 5,6,7.
// We need to be selected. Not the case for ESEARCH command. if uidonly {
tc.client.Unselect() // We need to be selected. Not the case for ESEARCH command.
tc.transactf("no", "search all") tc.client.Unselect()
tc.client.Select("inbox") tc.transactf("no", "uid search all")
tc.client.Select("inbox")
} else {
// We need to be selected. Not the case for ESEARCH command.
tc.client.Unselect()
tc.transactf("no", "search all")
tc.client.Select("inbox")
tc.transactf("ok", "search all") tc.transactf("ok", "search all")
tc.xsearch(1, 2, 3) tc.xsearch(1, 2, 3)
}
tc.transactf("ok", "uid search all") tc.transactf("ok", "uid search all")
tc.xsearch(5, 6, 7) tc.xsearch(5, 6, 7)
tc.transactf("ok", "search answered")
tc.xsearch(3)
tc.transactf("ok", `search bcc "bcc@mox.example"`)
tc.xsearch(2, 3)
tc.transactf("ok", "search before 1-Jan-2038")
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search before 1-Jan-2020")
tc.xsearch() // Before is about received, not date header of message.
// WITHIN extension with OLDER & YOUNGER.
tc.transactf("ok", "search older 60")
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search younger 60")
tc.xsearch()
// SAVEDATE extension.
tc.transactf("ok", "search savedbefore %s", saveDate.Add(24*time.Hour).Format("2-Jan-2006"))
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search savedbefore %s", saveDate.Add(-24*time.Hour).Format("2-Jan-2006"))
tc.xsearch()
tc.transactf("ok", "search savedon %s", saveDate.Format("2-Jan-2006"))
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search savedon %s", saveDate.Add(-24*time.Hour).Format("2-Jan-2006"))
tc.xsearch()
tc.transactf("ok", "search savedsince %s", saveDate.Add(-24*time.Hour).Format("2-Jan-2006"))
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search savedsince %s", saveDate.Add(24*time.Hour).Format("2-Jan-2006"))
tc.xsearch()
tc.transactf("ok", `search body "Joe"`)
tc.xsearch(1)
tc.transactf("ok", `search body "Joe" body "bogus"`)
tc.xsearch()
tc.transactf("ok", `search body "Joe" text "Blurdybloop"`)
tc.xsearch(1)
tc.transactf("ok", `search body "Joe" not text "mox"`)
tc.xsearch(1)
tc.transactf("ok", `search body "Joe" not not body "Joe"`)
tc.xsearch(1)
tc.transactf("ok", `search body "this is plain text"`)
tc.xsearch(2, 3)
tc.transactf("ok", `search body "this is html"`)
tc.xsearch(2, 3)
tc.transactf("ok", `search cc "xcc@mox.example"`)
tc.xsearch(2, 3)
tc.transactf("ok", `search deleted`)
tc.xsearch(3)
tc.transactf("ok", `search flagged`)
tc.xsearch(3)
tc.transactf("ok", `search from "foobar@Blurdybloop.example"`)
tc.xsearch(1)
tc.transactf("ok", `search keyword $Forwarded`)
tc.xsearch(3)
tc.transactf("ok", `search keyword Custom1`)
tc.xsearch(3)
tc.transactf("ok", `search keyword custom2`)
tc.xsearch(3)
tc.transactf("ok", `search new`)
tc.xsearch() // New requires a message to be recent. We pretend all messages are not recent.
tc.transactf("ok", `search old`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search on 1-Jan-2022`)
tc.xsearch(2, 3)
tc.transactf("ok", `search recent`)
tc.xsearch()
tc.transactf("ok", `search seen`)
tc.xsearch(3)
tc.transactf("ok", `search since 1-Jan-2020`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search subject "afternoon"`)
tc.xsearch(1)
tc.transactf("ok", `search text "Joe"`)
tc.xsearch(1)
tc.transactf("ok", `search to "mooch@owatagu.siam.edu.example"`)
tc.xsearch(1)
tc.transactf("ok", `search unanswered`)
tc.xsearch(1, 2)
tc.transactf("ok", `search undeleted`)
tc.xsearch(1, 2)
tc.transactf("ok", `search unflagged`)
tc.xsearch(1, 2)
tc.transactf("ok", `search unkeyword $Junk`)
tc.xsearch(1, 2)
tc.transactf("ok", `search unkeyword custom1`)
tc.xsearch(1, 2)
tc.transactf("ok", `search unseen`)
tc.xsearch(1, 2)
tc.transactf("ok", `search draft`)
tc.xsearch(3)
tc.transactf("ok", `search header "subject" "afternoon"`)
tc.xsearch(1)
tc.transactf("ok", `search larger 1`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search not text "mox"`)
tc.xsearch(1)
tc.transactf("ok", `search or seen unseen`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search or unseen seen`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search sentbefore 8-Feb-1994`)
tc.xsearch(1)
tc.transactf("ok", `search senton 7-Feb-1994`)
tc.xsearch(1)
tc.transactf("ok", `search sentsince 6-Feb-1994`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search smaller 9999999`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search uid 1`)
tc.xsearch()
tc.transactf("ok", `search uid 5`)
tc.xsearch(1)
tc.transactf("ok", `search or larger 1000000 smaller 1`)
tc.xsearch()
tc.transactf("ok", `search undraft`)
tc.xsearch(1, 2)
tc.transactf("no", `search charset unknown text "mox"`)
tc.transactf("ok", `search charset us-ascii text "mox"`)
tc.xsearch(2, 3)
tc.transactf("ok", `search charset utf-8 text "mox"`)
tc.xsearch(2, 3)
// Check for properly formed INPROGRESS response code.
orig := inProgressPeriod
inProgressPeriod = 0
tc.cmdf("tag1", "search undraft")
tc.response("ok")
inprogress := func(cur, goal uint32) imapclient.UntaggedResult {
return imapclient.UntaggedResult{
Status: "OK",
RespText: imapclient.RespText{
Code: "INPROGRESS",
CodeArg: imapclient.CodeInProgress{Tag: "tag1", Current: &cur, Goal: &goal},
More: "still searching",
},
}
}
tc.xuntagged(
imapclient.UntaggedSearch([]uint32{1, 2}),
// Due to inProgressPeriod 0, we get an inprogress response for each message in the mailbox.
inprogress(0, 3),
inprogress(1, 3),
inprogress(2, 3),
)
inProgressPeriod = orig
esearchall := func(ss string) imapclient.UntaggedEsearch { esearchall := func(ss string) imapclient.UntaggedEsearch {
return imapclient.UntaggedEsearch{All: esearchall0(ss)} return imapclient.UntaggedEsearch{All: esearchall0(ss)}
} }
// Do new-style ESEARCH requests with RETURN. We should get an ESEARCH response. if !uidonly {
tc.transactf("ok", "search return () all") tc.transactf("ok", "search answered")
tc.xesearch(esearchall("1:3")) // Without any options, "ALL" is implicit. tc.xsearch(3)
tc.transactf("ok", "search return (min max count all) all") tc.transactf("ok", `search bcc "bcc@mox.example"`)
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3, Count: uint32ptr(3), All: esearchall0("1:3")}) tc.xsearch(2, 3)
tc.transactf("ok", "search before 1-Jan-2038")
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search before 1-Jan-2020")
tc.xsearch() // Before is about received, not date header of message.
// WITHIN extension with OLDER & YOUNGER.
tc.transactf("ok", "search older 60")
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search younger 60")
tc.xsearch()
// SAVEDATE extension.
tc.transactf("ok", "search savedbefore %s", saveDate.Add(24*time.Hour).Format("2-Jan-2006"))
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search savedbefore %s", saveDate.Add(-24*time.Hour).Format("2-Jan-2006"))
tc.xsearch()
tc.transactf("ok", "search savedon %s", saveDate.Format("2-Jan-2006"))
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search savedon %s", saveDate.Add(-24*time.Hour).Format("2-Jan-2006"))
tc.xsearch()
tc.transactf("ok", "search savedsince %s", saveDate.Add(-24*time.Hour).Format("2-Jan-2006"))
tc.xsearch(1, 2, 3)
tc.transactf("ok", "search savedsince %s", saveDate.Add(24*time.Hour).Format("2-Jan-2006"))
tc.xsearch()
tc.transactf("ok", `search body "Joe"`)
tc.xsearch(1)
tc.transactf("ok", `search body "Joe" body "bogus"`)
tc.xsearch()
tc.transactf("ok", `search body "Joe" text "Blurdybloop"`)
tc.xsearch(1)
tc.transactf("ok", `search body "Joe" not text "mox"`)
tc.xsearch(1)
tc.transactf("ok", `search body "Joe" not not body "Joe"`)
tc.xsearch(1)
tc.transactf("ok", `search body "this is plain text"`)
tc.xsearch(2, 3)
tc.transactf("ok", `search body "this is html"`)
tc.xsearch(2, 3)
tc.transactf("ok", `search cc "xcc@mox.example"`)
tc.xsearch(2, 3)
tc.transactf("ok", `search deleted`)
tc.xsearch(3)
tc.transactf("ok", `search flagged`)
tc.xsearch(3)
tc.transactf("ok", `search from "foobar@Blurdybloop.example"`)
tc.xsearch(1)
tc.transactf("ok", `search keyword $Forwarded`)
tc.xsearch(3)
tc.transactf("ok", `search keyword Custom1`)
tc.xsearch(3)
tc.transactf("ok", `search keyword custom2`)
tc.xsearch(3)
tc.transactf("ok", `search new`)
tc.xsearch() // New requires a message to be recent. We pretend all messages are not recent.
tc.transactf("ok", `search old`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search on 1-Jan-2022`)
tc.xsearch(2, 3)
tc.transactf("ok", `search recent`)
tc.xsearch()
tc.transactf("ok", `search seen`)
tc.xsearch(3)
tc.transactf("ok", `search since 1-Jan-2020`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search subject "afternoon"`)
tc.xsearch(1)
tc.transactf("ok", `search text "Joe"`)
tc.xsearch(1)
tc.transactf("ok", `search to "mooch@owatagu.siam.edu.example"`)
tc.xsearch(1)
tc.transactf("ok", `search unanswered`)
tc.xsearch(1, 2)
tc.transactf("ok", `search undeleted`)
tc.xsearch(1, 2)
tc.transactf("ok", `search unflagged`)
tc.xsearch(1, 2)
tc.transactf("ok", `search unkeyword $Junk`)
tc.xsearch(1, 2)
tc.transactf("ok", `search unkeyword custom1`)
tc.xsearch(1, 2)
tc.transactf("ok", `search unseen`)
tc.xsearch(1, 2)
tc.transactf("ok", `search draft`)
tc.xsearch(3)
tc.transactf("ok", `search header "subject" "afternoon"`)
tc.xsearch(1)
tc.transactf("ok", `search larger 1`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search not text "mox"`)
tc.xsearch(1)
tc.transactf("ok", `search or seen unseen`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search or unseen seen`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search sentbefore 8-Feb-1994`)
tc.xsearch(1)
tc.transactf("ok", `search senton 7-Feb-1994`)
tc.xsearch(1)
tc.transactf("ok", `search sentsince 6-Feb-1994`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search smaller 9999999`)
tc.xsearch(1, 2, 3)
tc.transactf("ok", `search uid 1`)
tc.xsearch()
tc.transactf("ok", `search uid 5`)
tc.xsearch(1)
tc.transactf("ok", `search or larger 1000000 smaller 1`)
tc.xsearch()
tc.transactf("ok", `search undraft`)
tc.xsearch(1, 2)
tc.transactf("no", `search charset unknown text "mox"`)
tc.transactf("ok", `search charset us-ascii text "mox"`)
tc.xsearch(2, 3)
tc.transactf("ok", `search charset utf-8 text "mox"`)
tc.xsearch(2, 3)
// Check for properly formed INPROGRESS response code.
orig := inProgressPeriod
inProgressPeriod = 0
tc.cmdf("tag1", "search undraft")
tc.response("ok")
inprogress := func(cur, goal uint32) imapclient.UntaggedResult {
return imapclient.UntaggedResult{
Status: "OK",
RespText: imapclient.RespText{
Code: "INPROGRESS",
CodeArg: imapclient.CodeInProgress{Tag: "tag1", Current: &cur, Goal: &goal},
More: "still searching",
},
}
}
tc.xuntagged(
imapclient.UntaggedSearch([]uint32{1, 2}),
// Due to inProgressPeriod 0, we get an inprogress response for each message in the mailbox.
inprogress(0, 3),
inprogress(1, 3),
inprogress(2, 3),
)
inProgressPeriod = orig
// Do new-style ESEARCH requests with RETURN. We should get an ESEARCH response.
tc.transactf("ok", "search return () all")
tc.xesearch(esearchall("1:3")) // Without any options, "ALL" is implicit.
tc.transactf("ok", "search return (min max count all) all")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3, Count: uint32ptr(3), All: esearchall0("1:3")})
tc.transactf("ok", "search return (min) all")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1})
tc.transactf("ok", "search return (min) 3")
tc.xesearch(imapclient.UntaggedEsearch{Min: 3})
tc.transactf("ok", "search return (min) NOT all")
tc.xesearch(imapclient.UntaggedEsearch{}) // Min not present if no match.
tc.transactf("ok", "search return (max) all")
tc.xesearch(imapclient.UntaggedEsearch{Max: 3})
tc.transactf("ok", "search return (max) 1")
tc.xesearch(imapclient.UntaggedEsearch{Max: 1})
tc.transactf("ok", "search return (max) not all")
tc.xesearch(imapclient.UntaggedEsearch{}) // Max not present if no match.
tc.transactf("ok", "search return (min max) all")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3})
tc.transactf("ok", "search return (min max) 1")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1})
tc.transactf("ok", "search return (min max) not all")
tc.xesearch(imapclient.UntaggedEsearch{})
tc.transactf("ok", "search return (all) not all")
tc.xesearch(imapclient.UntaggedEsearch{}) // All not present if no match.
tc.transactf("ok", "search return (min max all) not all")
tc.xesearch(imapclient.UntaggedEsearch{})
tc.transactf("ok", "search return (min max all count) not all")
tc.xesearch(imapclient.UntaggedEsearch{Count: uint32ptr(0)})
tc.transactf("ok", "search return (min max count all) 1,3")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3, Count: uint32ptr(2), All: esearchall0("1,3")})
tc.transactf("ok", "search return (min max count all) UID 5,7")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3, Count: uint32ptr(2), All: esearchall0("1,3")})
}
tc.transactf("ok", "UID search return (min max count all) all") tc.transactf("ok", "UID search return (min max count all) all")
tc.xesearch(imapclient.UntaggedEsearch{UID: true, Min: 5, Max: 7, Count: uint32ptr(3), All: esearchall0("5:7")}) tc.xesearch(imapclient.UntaggedEsearch{UID: true, Min: 5, Max: 7, Count: uint32ptr(3), All: esearchall0("5:7")})
tc.transactf("ok", "search return (min) all") if !uidonly {
tc.xesearch(imapclient.UntaggedEsearch{Min: 1}) tc.transactf("ok", "uid search return (min max count all) 1,3")
tc.xesearch(imapclient.UntaggedEsearch{UID: true, Min: 5, Max: 7, Count: uint32ptr(2), All: esearchall0("5,7")})
tc.transactf("ok", "search return (min) 3") }
tc.xesearch(imapclient.UntaggedEsearch{Min: 3})
tc.transactf("ok", "search return (min) NOT all")
tc.xesearch(imapclient.UntaggedEsearch{}) // Min not present if no match.
tc.transactf("ok", "search return (max) all")
tc.xesearch(imapclient.UntaggedEsearch{Max: 3})
tc.transactf("ok", "search return (max) 1")
tc.xesearch(imapclient.UntaggedEsearch{Max: 1})
tc.transactf("ok", "search return (max) not all")
tc.xesearch(imapclient.UntaggedEsearch{}) // Max not present if no match.
tc.transactf("ok", "search return (min max) all")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3})
tc.transactf("ok", "search return (min max) 1")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 1})
tc.transactf("ok", "search return (min max) not all")
tc.xesearch(imapclient.UntaggedEsearch{})
tc.transactf("ok", "search return (all) not all")
tc.xesearch(imapclient.UntaggedEsearch{}) // All not present if no match.
tc.transactf("ok", "search return (min max all) not all")
tc.xesearch(imapclient.UntaggedEsearch{})
tc.transactf("ok", "search return (min max all count) not all")
tc.xesearch(imapclient.UntaggedEsearch{Count: uint32ptr(0)})
tc.transactf("ok", "search return (min max count all) 1,3")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3, Count: uint32ptr(2), All: esearchall0("1,3")})
tc.transactf("ok", "search return (min max count all) UID 5,7")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3, Count: uint32ptr(2), All: esearchall0("1,3")})
tc.transactf("ok", "uid search return (min max count all) 1,3")
tc.xesearch(imapclient.UntaggedEsearch{UID: true, Min: 5, Max: 7, Count: uint32ptr(2), All: esearchall0("5,7")})
tc.transactf("ok", "uid search return (min max count all) UID 5,7") tc.transactf("ok", "uid search return (min max count all) UID 5,7")
tc.xesearch(imapclient.UntaggedEsearch{UID: true, Min: 5, Max: 7, Count: uint32ptr(2), All: esearchall0("5,7")}) tc.xesearch(imapclient.UntaggedEsearch{UID: true, Min: 5, Max: 7, Count: uint32ptr(2), All: esearchall0("5,7")})
tc.transactf("no", `search return () charset unknown text "mox"`) if !uidonly {
tc.transactf("ok", `search return () charset us-ascii text "mox"`) tc.transactf("no", `search return () charset unknown text "mox"`)
tc.xesearch(esearchall("2:3")) tc.transactf("ok", `search return () charset us-ascii text "mox"`)
tc.transactf("ok", `search return () charset utf-8 text "mox"`) tc.xesearch(esearchall("2:3"))
tc.xesearch(esearchall("2:3")) tc.transactf("ok", `search return () charset utf-8 text "mox"`)
tc.xesearch(esearchall("2:3"))
tc.transactf("bad", `search return (unknown) all`) tc.transactf("bad", `search return (unknown) all`)
tc.transactf("ok", "search return (save) 2") tc.transactf("ok", "search return (save) 2")
tc.xnountagged() // ../rfc/9051:3800 tc.xnountagged() // ../rfc/9051:3800
tc.transactf("ok", "fetch $ (uid)") tc.transactf("ok", "fetch $ (uid)")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6)}}) tc.xuntagged(tc.untaggedFetch(2, 6))
tc.transactf("ok", "search return (all) $") tc.transactf("ok", "search return (all) $")
tc.xesearch(esearchall("2")) tc.xesearch(esearchall("2"))
tc.transactf("ok", "search return (save) $") tc.transactf("ok", "search return (save) $")
tc.xnountagged() tc.xnountagged()
tc.transactf("ok", "search return (save all) all") tc.transactf("ok", "search return (save all) all")
tc.xesearch(esearchall("1:3")) tc.xesearch(esearchall("1:3"))
tc.transactf("ok", "search return (all save) all") tc.transactf("ok", "search return (all save) all")
tc.xesearch(esearchall("1:3")) tc.xesearch(esearchall("1:3"))
tc.transactf("ok", "search return (min save) all") tc.transactf("ok", "search return (min save) all")
tc.xesearch(imapclient.UntaggedEsearch{Min: 1}) tc.xesearch(imapclient.UntaggedEsearch{Min: 1})
tc.transactf("ok", "fetch $ (uid)") tc.transactf("ok", "fetch $ (uid)")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5)}}) tc.xuntagged(tc.untaggedFetch(1, 5))
}
// Do a seemingly old-style search command with IMAP4rev2 enabled. We'll still get ESEARCH responses. // Do a seemingly old-style search command with IMAP4rev2 enabled. We'll still get ESEARCH responses.
tc.client.Enable("IMAP4rev2") tc.client.Enable("IMAP4rev2")
tc.transactf("ok", `search undraft`)
tc.xesearch(esearchall("1:2")) if !uidonly {
tc.transactf("ok", `search undraft`)
tc.xesearch(esearchall("1:2"))
}
// Long commands should be rejected, not allocating too much memory. // Long commands should be rejected, not allocating too much memory.
lit := make([]byte, 100*1024+1) lit := make([]byte, 100*1024+1)
@ -470,21 +494,27 @@ func esearchall0(ss string) imapclient.NumSet {
return seqset return seqset
} }
// Test the MULTISEARCH extension. Where we don't need to have a mailbox selected, func TestSearchMultiUnselected(t *testing.T) {
// operating without messag sequence numbers, and return untagged esearch responses testSearchMulti(t, false, false)
// that include the mailbox and uidvalidity.
func TestSearchMulti(t *testing.T) {
testSearchMulti(t, false)
testSearchMulti(t, true)
} }
// Run multisearch tests with or without a mailbox selected. func TestSearchMultiSelected(t *testing.T) {
func testSearchMulti(t *testing.T, selected bool) { testSearchMulti(t, true, false)
}
func TestSearchMultiSelectedUIDOnly(t *testing.T) {
testSearchMulti(t, true, true)
}
// Test the MULTISEARCH extension, with and without selected mailbx. Operating
// without messag sequence numbers, and return untagged esearch responses that
// include the mailbox and uidvalidity.
func testSearchMulti(t *testing.T, selected, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
// Add 5 messages to Inbox and delete first 4 messages. So UIDs start at 5. // Add 5 messages to Inbox and delete first 4 messages. So UIDs start at 5.
@ -492,7 +522,7 @@ func testSearchMulti(t *testing.T, selected bool) {
for range 6 { for range 6 {
tc.client.Append("inbox", makeAppendTime(exampleMsg, received)) tc.client.Append("inbox", makeAppendTime(exampleMsg, received))
} }
tc.client.StoreFlagsSet("1:4", true, `\Deleted`) tc.client.UIDStoreFlagsSet("1:4", true, `\Deleted`)
tc.client.Expunge() tc.client.Expunge()
// Unselecting mailbox, esearch works in authenticated state. // Unselecting mailbox, esearch works in authenticated state.
@ -681,12 +711,15 @@ func testSearchMulti(t *testing.T, selected bool) {
) )
// Search with sequence set also for non-selected mailboxes(!). The min/max would // Search with sequence set also for non-selected mailboxes(!). The min/max would
// get the first and last message, but the message sequence set forces a scan. // get the first and last message, but the message sequence set forces a scan. Not
tc.cmdf("Tag1", `Esearch In (Mailboxes Inbox) Return (Min Max) 1:*`) // allowed with UIDONLY.
tc.response("ok") if !uidonly {
tc.xuntagged( tc.cmdf("Tag1", `Esearch In (Mailboxes Inbox) Return (Min Max) 1:*`)
imapclient.UntaggedEsearch{Tag: "Tag1", Mailbox: "Inbox", UIDValidity: 1, UID: true, Min: 5, Max: 7}, tc.response("ok")
) tc.xuntagged(
imapclient.UntaggedEsearch{Tag: "Tag1", Mailbox: "Inbox", UIDValidity: 1, UID: true, Min: 5, Max: 7},
)
}
// Search with uid set with "$highnum:*" forces getting highest uid. // Search with uid set with "$highnum:*" forces getting highest uid.
tc.cmdf("Tag1", `Esearch In (Mailboxes Inbox) Return (Min Max) Uid *:100`) tc.cmdf("Tag1", `Esearch In (Mailboxes Inbox) Return (Min Max) Uid *:100`)
@ -709,9 +742,9 @@ func testSearchMulti(t *testing.T, selected bool) {
// with Inbox selected will not return the new message since it isn't available in // with Inbox selected will not return the new message since it isn't available in
// the session yet. The message in Archive is returned, since there is no session // the session yet. The message in Archive is returned, since there is no session
// limitation. // limitation.
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, uidonly)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Append("inbox", makeAppendTime(searchMsg, received)) tc2.client.Append("inbox", makeAppendTime(searchMsg, received))
tc2.client.Append("Archive", makeAppendTime(searchMsg, received)) tc2.client.Append("Archive", makeAppendTime(searchMsg, received))
@ -722,7 +755,7 @@ func testSearchMulti(t *testing.T, selected bool) {
imapclient.UntaggedEsearch{Tag: "Tag1", Mailbox: "Inbox", UIDValidity: 1, UID: true, Count: uint32ptr(3)}, imapclient.UntaggedEsearch{Tag: "Tag1", Mailbox: "Inbox", UIDValidity: 1, UID: true, Count: uint32ptr(3)},
imapclient.UntaggedEsearch{Tag: "Tag1", Mailbox: "Archive", UIDValidity: 1, UID: true, Count: uint32ptr(2)}, imapclient.UntaggedEsearch{Tag: "Tag1", Mailbox: "Archive", UIDValidity: 1, UID: true, Count: uint32ptr(2)},
imapclient.UntaggedExists(4), imapclient.UntaggedExists(4),
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(8), imapclient.FetchFlags(nil)}}, tc.untaggedFetch(4, 8, imapclient.FetchFlags(nil)),
) )
} else { } else {
tc.xuntagged( tc.xuntagged(

View File

@ -8,20 +8,28 @@ import (
) )
func TestSelect(t *testing.T) { func TestSelect(t *testing.T) {
testSelectExamine(t, false) testSelectExamine(t, false, false)
} }
func TestExamine(t *testing.T) { func TestExamine(t *testing.T) {
testSelectExamine(t, true) testSelectExamine(t, true, false)
}
func TestSelectUIDOnly(t *testing.T) {
testSelectExamine(t, false, true)
}
func TestExamineUIDOnly(t *testing.T) {
testSelectExamine(t, true, true)
} }
// select and examine are pretty much the same. but examine opens readonly instead of readwrite. // select and examine are pretty much the same. but examine opens readonly instead of readwrite.
func testSelectExamine(t *testing.T, examine bool) { func testSelectExamine(t *testing.T, examine, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
cmd := "select" cmd := "select"
okcode := "READ-WRITE" okcode := "READ-WRITE"
@ -62,7 +70,11 @@ func testSelectExamine(t *testing.T, examine bool) {
// Append a message. It will be reported as UNSEEN. // Append a message. It will be reported as UNSEEN.
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.transactf("ok", "%s inbox", cmd) tc.transactf("ok", "%s inbox", cmd)
tc.xuntagged(uclosed, uflags, upermflags, urecent, uunseen, uexists1, uuidval1, uuidnext2, ulist) if uidonly {
tc.xuntagged(uclosed, uflags, upermflags, urecent, uexists1, uuidval1, uuidnext2, ulist)
} else {
tc.xuntagged(uclosed, uflags, upermflags, urecent, uunseen, uexists1, uuidval1, uuidnext2, ulist)
}
tc.xcode(okcode) tc.xcode(okcode)
// With imap4rev2, we no longer get untagged RECENT or untagged UNSEEN. // With imap4rev2, we no longer get untagged RECENT or untagged UNSEEN.

View File

@ -184,6 +184,7 @@ var serverCapabilities = strings.Join([]string{
"INPROGRESS", // ../rfc/9585:101 "INPROGRESS", // ../rfc/9585:101
"MULTISEARCH", // ../rfc/7377:187 "MULTISEARCH", // ../rfc/7377:187
"NOTIFY", // ../rfc/5465:195 "NOTIFY", // ../rfc/5465:195
"UIDONLY", // ../rfc/9586:127
// "COMPRESS=DEFLATE", // ../rfc/4978, disabled for interoperability issues: The flate reader (inflate) still blocks on partial flushes, preventing progress. // "COMPRESS=DEFLATE", // ../rfc/4978, disabled for interoperability issues: The flate reader (inflate) still blocks on partial flushes, preventing progress.
}, " ") }, " ")
@ -244,6 +245,9 @@ type conn struct {
mailboxID int64 // Only for StateSelected. mailboxID int64 // Only for StateSelected.
readonly bool // If opened mailbox is readonly. readonly bool // If opened mailbox is readonly.
uidonly bool // If uidonly is enabled, uids is empty and cannot be used.
uidnext store.UID // We don't return search/fetch/etc results for uids >= uidnext, which is updated when applying changes.
exists uint32 // Needed for uidonly, equal to len(uids) for non-uidonly sessions.
uids []store.UID // UIDs known in this session, sorted. todo future: store more space-efficiently, as ranges. uids []store.UID // UIDs known in this session, sorted. todo future: store more space-efficiently, as ranges.
} }
@ -258,6 +262,7 @@ const (
capCondstore capability = "CONDSTORE" capCondstore capability = "CONDSTORE"
capQresync capability = "QRESYNC" capQresync capability = "QRESYNC"
capMetadata capability = "METADATA" capMetadata capability = "METADATA"
capUIDOnly capability = "UIDONLY"
) )
type lineErr struct { type lineErr struct {
@ -288,6 +293,10 @@ var (
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", "esearch") 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", "esearch")
) )
// Commands that use sequence numbers. Cannot be used when UIDONLY is enabled.
// Commands like UID SEARCH have additional checks for some parameters.
var commandsSequence = stateCommands("search", "fetch", "store", "copy", "move", "replace")
var commands = map[string]func(c *conn, tag, cmd string, p *parser){ var commands = map[string]func(c *conn, tag, cmd string, p *parser){
// Any state. // Any state.
"capability": (*conn).cmdCapability, "capability": (*conn).cmdCapability,
@ -499,6 +508,8 @@ func (c *conn) unselect() {
c.state = stateAuthenticated c.state = stateAuthenticated
} }
c.mailboxID = 0 c.mailboxID = 0
c.uidnext = 0
c.exists = 0
c.uids = nil c.uids = nil
} }
@ -1343,6 +1354,11 @@ func (c *conn) command() {
xserverErrorf("unrecognized command") xserverErrorf("unrecognized command")
} }
// ../rfc/9586:172
if _, ok := commandsSequence[cmdlow]; ok && c.uidonly {
xsyntaxCodeErrorf("UIDREQUIRED", "cannot use message sequence numbers with uidonly")
}
fn(c, tag, cmd, p) fn(c, tag, cmd, p)
} }
@ -1414,6 +1430,9 @@ func xmailboxPatternMatcher(ref string, patterns []string) matchStringer {
} }
func (c *conn) sequence(uid store.UID) msgseq { func (c *conn) sequence(uid store.UID) msgseq {
if c.uidonly {
panic("sequence with uidonly")
}
return uidSearch(c.uids, uid) return uidSearch(c.uids, uid)
} }
@ -1435,6 +1454,9 @@ func uidSearch(uids []store.UID, uid store.UID) msgseq {
} }
func (c *conn) xsequence(uid store.UID) msgseq { func (c *conn) xsequence(uid store.UID) msgseq {
if c.uidonly {
panic("xsequence with uidonly")
}
seq := c.sequence(uid) seq := c.sequence(uid)
if seq <= 0 { if seq <= 0 {
xserverErrorf("unknown uid %d (%w)", uid, errProtocol) xserverErrorf("unknown uid %d (%w)", uid, errProtocol)
@ -1443,36 +1465,55 @@ func (c *conn) xsequence(uid store.UID) msgseq {
} }
func (c *conn) sequenceRemove(seq msgseq, uid store.UID) { func (c *conn) sequenceRemove(seq msgseq, uid store.UID) {
if c.uidonly {
panic("sequenceRemove with uidonly")
}
i := seq - 1 i := seq - 1
if c.uids[i] != uid { if c.uids[i] != uid {
xserverErrorf("got uid %d at msgseq %d, expected uid %d", uid, seq, c.uids[i]) xserverErrorf("got uid %d at msgseq %d, expected uid %d", uid, seq, c.uids[i])
} }
copy(c.uids[i:], c.uids[i+1:]) copy(c.uids[i:], c.uids[i+1:])
c.uids = c.uids[:len(c.uids)-1] c.uids = c.uids[:c.exists-1]
if sanityChecks { c.exists--
checkUIDs(c.uids) c.checkUIDs(c.uids, true)
}
} }
// add uid to the session. care must be taken that pending changes are fetched // add uid to session, through c.uidnext, and if uidonly isn't enabled to c.uids.
// while holding the account wlock, and applied before adding this uid, because // care must be taken that pending changes are fetched while holding the account
// those pending changes may contain another new uid that has to be added first. // wlock, and applied before adding this uid, because those pending changes may
func (c *conn) uidAppend(uid store.UID) msgseq { // contain another new uid that has to be added first.
func (c *conn) uidAppend(uid store.UID) {
if c.uidonly {
if uid < c.uidnext {
panic(fmt.Sprintf("new uid %d < uidnext %d", uid, c.uidnext))
}
c.exists++
c.uidnext = uid + 1
return
}
if uidSearch(c.uids, uid) > 0 { if uidSearch(c.uids, uid) > 0 {
xserverErrorf("uid already present (%w)", errProtocol) xserverErrorf("uid already present (%w)", errProtocol)
} }
if len(c.uids) > 0 && uid < c.uids[len(c.uids)-1] { if c.exists > 0 && uid < c.uids[c.exists-1] {
xserverErrorf("new uid %d is smaller than last uid %d (%w)", uid, c.uids[len(c.uids)-1], errProtocol) xserverErrorf("new uid %d is smaller than last uid %d (%w)", uid, c.uids[c.exists-1], errProtocol)
} }
c.exists++
c.uidnext = uid + 1
c.uids = append(c.uids, uid) c.uids = append(c.uids, uid)
if sanityChecks { c.checkUIDs(c.uids, true)
checkUIDs(c.uids)
}
return msgseq(len(c.uids))
} }
// sanity check that uids are in ascending order. // sanity check that uids are in ascending order.
func checkUIDs(uids []store.UID) { func (c *conn) checkUIDs(uids []store.UID, checkExists bool) {
if !sanityChecks {
return
}
if checkExists && uint32(len(uids)) != c.exists {
panic(fmt.Sprintf("exists %d does not match len(uids) %d", c.exists, len(c.uids)))
}
for i, uid := range uids { for i, uid := range uids {
if uid == 0 || i > 0 && uid <= uids[i-1] { if uid == 0 || i > 0 && uid <= uids[i-1] {
xserverErrorf("bad uids %v", uids) xserverErrorf("bad uids %v", uids)
@ -1480,75 +1521,121 @@ func checkUIDs(uids []store.UID) {
} }
} }
func (c *conn) xnumSetUIDs(isUID bool, nums numSet) []store.UID { func slicesAny[T any](l []T) []any {
_, uids := c.xnumSetConditionUIDs(false, true, isUID, nums) r := make([]any, len(l))
return uids for i, v := range l {
r[i] = v
}
return r
} }
func (c *conn) xnumSetCondition(isUID bool, nums numSet) []any { // newCachedLastUID returns a method that returns the highest uid for a mailbox,
uidargs, _ := c.xnumSetConditionUIDs(true, false, isUID, nums) // for interpretation of "*". If mailboxID is for the selected mailbox, the UIDs
return uidargs // visible in the session are taken into account. If there is no UID, 0 is
} // returned. If an error occurs, xerrfn is called, which should not return.
func (c *conn) newCachedLastUID(tx *bstore.Tx, mailboxID int64, xerrfn func(err error)) func() store.UID {
func (c *conn) xnumSetConditionUIDs(forDB, returnUIDs bool, isUID bool, nums numSet) ([]any, []store.UID) { var last store.UID
if nums.searchResult { var have bool
// Update previously stored UIDs. Some may have been deleted. return func() store.UID {
// Once deleted a UID will never come back, so we'll just remove those uids. if have {
o := 0 return last
for _, uid := range c.searchResult { }
if uidSearch(c.uids, uid) > 0 { if c.mailboxID == mailboxID {
c.searchResult[o] = uid if c.exists == 0 {
o++ return 0
}
if !c.uidonly {
return c.uids[c.exists-1]
} }
} }
c.searchResult = c.searchResult[:o] q := bstore.QueryTx[store.Message](tx)
uidargs := make([]any, len(c.searchResult)) q.FilterNonzero(store.Message{MailboxID: mailboxID})
for i, uid := range c.searchResult { q.FilterEqual("Expunged", false)
uidargs[i] = uid if c.mailboxID == mailboxID {
q.FilterLess("UID", c.uidnext)
} }
return uidargs, c.searchResult q.SortDesc("UID")
q.Limit(1)
m, err := q.Get()
if err == bstore.ErrAbsent {
have = true
return last
}
if err != nil {
xerrfn(err)
panic(err) // xerrfn should have called panic.
}
have = true
last = m.UID
return last
} }
}
var uidargs []any // xnumSetEval evaluates nums to uids given the current session state and messages
var uids []store.UID // in the selected mailbox. The returned UIDs are sorted, without duplicates.
func (c *conn) xnumSetEval(tx *bstore.Tx, isUID bool, nums numSet) []store.UID {
if nums.searchResult {
// UIDs that do not exist can be ignored.
if c.exists == 0 {
return nil
}
add := func(uid store.UID) { // Update previously stored UIDs. Some may have been deleted.
if forDB { // Once deleted a UID will never come back, so we'll just remove those uids.
uidargs = append(uidargs, uid) if c.uidonly {
} var uids []store.UID
if returnUIDs { if len(c.searchResult) > 0 {
uids = append(uids, uid) q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
q.FilterEqual("Expunged", false)
q.FilterEqual("UID", slicesAny(c.searchResult)...)
q.SortAsc("UID")
for m, err := range q.All() {
xcheckf(err, "looking up messages from search result")
uids = append(uids, m.UID)
}
}
c.searchResult = uids
} else {
o := 0
for _, uid := range c.searchResult {
if uidSearch(c.uids, uid) > 0 {
c.searchResult[o] = uid
o++
}
}
c.searchResult = c.searchResult[:o]
} }
return c.searchResult
} }
if !isUID { if !isUID {
uids := map[store.UID]struct{}{}
// Sequence numbers that don't exist, or * on an empty mailbox, should result in a BAD response. ../rfc/9051:7018 // Sequence numbers that don't exist, or * on an empty mailbox, should result in a BAD response. ../rfc/9051:7018
for _, r := range nums.ranges { for _, r := range nums.ranges {
var ia, ib int var ia, ib int
if r.first.star { if r.first.star {
if len(c.uids) == 0 { if c.exists == 0 {
xsyntaxErrorf("invalid seqset * on empty mailbox") xsyntaxErrorf("invalid seqset * on empty mailbox")
} }
ia = len(c.uids) - 1 ia = int(c.exists) - 1
} else { } else {
ia = int(r.first.number - 1) ia = int(r.first.number - 1)
if ia >= len(c.uids) { if ia >= int(c.exists) {
xsyntaxErrorf("msgseq %d not in mailbox", r.first.number) xsyntaxErrorf("msgseq %d not in mailbox", r.first.number)
} }
} }
if r.last == nil { if r.last == nil {
add(c.uids[ia]) uids[c.uids[ia]] = struct{}{}
continue continue
} }
if r.last.star { if r.last.star {
if len(c.uids) == 0 { ib = int(c.exists) - 1
xsyntaxErrorf("invalid seqset * on empty mailbox")
}
ib = len(c.uids) - 1
} else { } else {
ib = int(r.last.number - 1) ib = int(r.last.number - 1)
if ib >= len(c.uids) { if ib >= int(c.exists) {
xsyntaxErrorf("msgseq %d not in mailbox", r.last.number) xsyntaxErrorf("msgseq %d not in mailbox", r.last.number)
} }
} }
@ -1556,15 +1643,39 @@ func (c *conn) xnumSetConditionUIDs(forDB, returnUIDs bool, isUID bool, nums num
ia, ib = ib, ia ia, ib = ib, ia
} }
for _, uid := range c.uids[ia : ib+1] { for _, uid := range c.uids[ia : ib+1] {
add(uid) uids[uid] = struct{}{}
} }
} }
return uidargs, uids return slices.Sorted(maps.Keys(uids))
} }
// UIDs that do not exist can be ignored. // UIDs that do not exist can be ignored.
if len(c.uids) == 0 { if c.exists == 0 {
return nil, nil return nil
}
uids := map[store.UID]struct{}{}
if c.uidonly {
xlastUID := c.newCachedLastUID(tx, c.mailboxID, func(xerr error) { xuserErrorf("%s", xerr) })
for _, r := range nums.xinterpretStar(xlastUID).ranges {
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
q.FilterEqual("Expunged", false)
if r.last == nil {
q.FilterEqual("UID", r.first.number)
} else {
q.FilterGreaterEqual("UID", r.first.number)
q.FilterLessEqual("UID", r.last.number)
}
q.FilterLess("UID", c.uidnext)
q.SortAsc("UID")
for m, err := range q.All() {
xcheckf(err, "enumerating uids")
uids[m.UID] = struct{}{}
}
}
return slices.Sorted(maps.Keys(uids))
} }
for _, r := range nums.ranges { for _, r := range nums.ranges {
@ -1575,12 +1686,12 @@ func (c *conn) xnumSetConditionUIDs(forDB, returnUIDs bool, isUID bool, nums num
uida := store.UID(r.first.number) uida := store.UID(r.first.number)
if r.first.star { if r.first.star {
uida = c.uids[len(c.uids)-1] uida = c.uids[c.exists-1]
} }
uidb := store.UID(last.number) uidb := store.UID(last.number)
if last.star { if last.star {
uidb = c.uids[len(c.uids)-1] uidb = c.uids[c.exists-1]
} }
if uida > uidb { if uida > uidb {
@ -1589,7 +1700,7 @@ func (c *conn) xnumSetConditionUIDs(forDB, returnUIDs bool, isUID bool, nums num
// Binary search for uida. // Binary search for uida.
s := 0 s := 0
e := len(c.uids) e := int(c.exists)
for s < e { for s < e {
m := (s + e) / 2 m := (s + e) / 2
if uida < c.uids[m] { if uida < c.uids[m] {
@ -1603,14 +1714,13 @@ func (c *conn) xnumSetConditionUIDs(forDB, returnUIDs bool, isUID bool, nums num
for _, uid := range c.uids[s:] { for _, uid := range c.uids[s:] {
if uid >= uida && uid <= uidb { if uid >= uida && uid <= uidb {
add(uid) uids[uid] = struct{}{}
} else if uid > uidb { } else if uid > uidb {
break break
} }
} }
} }
return slices.Sorted(maps.Keys(uids))
return uidargs, uids
} }
func (c *conn) ok(tag, cmd string) { func (c *conn) ok(tag, cmd string) {
@ -1751,8 +1861,7 @@ func (c *conn) xapplyChanges(overflow bool, changes []store.Change, initial, sen
if !ok { if !ok {
break break
} }
seq := c.sequence(ch.UID) if initial && !c.uidonly && c.sequence(ch.UID) > 0 {
if seq > 0 && initial {
continue continue
} }
c.uidAppend(ch.UID) c.uidAppend(ch.UID)
@ -1765,15 +1874,19 @@ func (c *conn) xapplyChanges(overflow bool, changes []store.Change, initial, sen
// Write the exists, and the UID and flags as well. Hopefully the client waits for // Write the exists, and the UID and flags as well. Hopefully the client waits for
// long enough after the EXISTS to see these messages, and doesn't request them // long enough after the EXISTS to see these messages, and doesn't request them
// again with a FETCH. // again with a FETCH.
c.xbwritelinef("* %d EXISTS", len(c.uids)) c.xbwritelinef("* %d EXISTS", c.exists)
for _, add := range adds { for _, add := range adds {
seq := c.xsequence(add.UID)
var modseqStr string var modseqStr string
if condstore { if condstore {
modseqStr = fmt.Sprintf(" MODSEQ (%d)", add.ModSeq.Client()) modseqStr = fmt.Sprintf(" MODSEQ (%d)", add.ModSeq.Client())
} }
// UIDFETCH in case of uidonly. ../rfc/9586:228
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s%s)", seq, add.UID, flaglist(add.Flags, add.Keywords).pack(c), modseqStr) if c.uidonly {
c.xbwritelinef("* %d UIDFETCH (FLAGS %s%s)", add.UID, flaglist(add.Flags, add.Keywords).pack(c), modseqStr)
} else {
seq := c.xsequence(add.UID)
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s%s)", seq, add.UID, flaglist(add.Flags, add.Keywords).pack(c), modseqStr)
}
} }
continue continue
} }
@ -1785,6 +1898,15 @@ func (c *conn) xapplyChanges(overflow bool, changes []store.Change, initial, sen
case store.ChangeRemoveUIDs: case store.ChangeRemoveUIDs:
var vanishedUIDs numSet var vanishedUIDs numSet
for _, uid := range ch.UIDs { for _, uid := range ch.UIDs {
// With uidonly, we must always return VANISHED. ../rfc/9586:232
if c.uidonly {
if !initial {
c.exists--
vanishedUIDs.append(uint32(uid))
}
continue
}
var seq msgseq var seq msgseq
if initial { if initial {
seq = c.sequence(uid) seq = c.sequence(uid)
@ -1803,7 +1925,7 @@ func (c *conn) xapplyChanges(overflow bool, changes []store.Change, initial, sen
} }
} }
} }
if qresync { if !vanishedUIDs.empty() {
// VANISHED without EARLIER. ../rfc/7162:2004 // VANISHED without EARLIER. ../rfc/7162:2004
for _, s := range vanishedUIDs.Strings(4*1024 - 32) { for _, s := range vanishedUIDs.Strings(4*1024 - 32) {
c.xbwritelinef("* VANISHED %s", s) c.xbwritelinef("* VANISHED %s", s)
@ -1811,15 +1933,21 @@ func (c *conn) xapplyChanges(overflow bool, changes []store.Change, initial, sen
} }
case store.ChangeFlags: case store.ChangeFlags:
// The uid can be unknown if we just expunged it while another session marked it as deleted just before. if initial {
seq := c.sequence(ch.UID)
if seq <= 0 {
continue continue
} }
if !initial { var modseqStr string
var modseqStr string if condstore {
if condstore { modseqStr = fmt.Sprintf(" MODSEQ (%d)", ch.ModSeq.Client())
modseqStr = fmt.Sprintf(" MODSEQ (%d)", ch.ModSeq.Client()) }
// UIDFETCH in case of uidonly. ../rfc/9586:228
if c.uidonly {
c.xbwritelinef("* %d UIDFETCH (FLAGS %s%s)", ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr)
} else {
// The uid can be unknown if we just expunged it while another session marked it as deleted just before.
seq := c.sequence(ch.UID)
if seq <= 0 {
continue
} }
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s%s)", seq, ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr) c.xbwritelinef("* %d FETCH (UID %d FLAGS %s%s)", seq, ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr)
} }
@ -1969,10 +2097,10 @@ func (c *conn) xapplyChangesNotify(changes []store.Change, sendDelayed bool) {
continue continue
} }
seq := c.uidAppend(ch.UID) c.uidAppend(ch.UID)
// ../rfc/5465:515 // ../rfc/5465:515
c.xbwritelinef("* %d EXISTS", len(c.uids)) c.xbwritelinef("* %d EXISTS", c.exists)
// If client did not specify attributes, we'll send the defaults. // If client did not specify attributes, we'll send the defaults.
if len(ev.FetchAtt) == 0 { if len(ev.FetchAtt) == 0 {
@ -1982,7 +2110,12 @@ func (c *conn) xapplyChangesNotify(changes []store.Change, sendDelayed bool) {
} }
// NOTIFY does not specify the default fetch attributes to return, we send UID and // NOTIFY does not specify the default fetch attributes to return, we send UID and
// FLAGS. // FLAGS.
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s%s)", seq, ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr) // UIDFETCH in case of uidonly. ../rfc/9586:228
if c.uidonly {
c.xbwritelinef("* %d UIDFETCH (FLAGS %s%s)", ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr)
} else {
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s%s)", c.xsequence(ch.UID), ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr)
}
continue continue
} }
@ -1995,9 +2128,15 @@ func (c *conn) xapplyChangesNotify(changes []store.Change, sendDelayed bool) {
// developer sees the message. // developer sees the message.
c.log.Errorx("generating notify fetch response", err, slog.Int64("mailboxid", ch.MailboxID), slog.Any("uid", ch.UID)) c.log.Errorx("generating notify fetch response", err, slog.Int64("mailboxid", ch.MailboxID), slog.Any("uid", ch.UID))
c.xbwritelinef("* NO generating notify fetch response: %s", err.Error()) c.xbwritelinef("* NO generating notify fetch response: %s", err.Error())
// Always add UID, also for uidonly, to ensure a non-empty list.
data = listspace{bare("UID"), number(ch.UID)} data = listspace{bare("UID"), number(ch.UID)}
} }
fmt.Fprintf(cmd.conn.xbw, "* %d FETCH ", seq) // UIDFETCH in case of uidonly. ../rfc/9586:228
if c.uidonly {
fmt.Fprintf(cmd.conn.xbw, "* %d UIDFETCH ", ch.UID)
} else {
fmt.Fprintf(cmd.conn.xbw, "* %d FETCH ", c.xsequence(ch.UID))
}
func() { func() {
defer c.xtracewrite(mlog.LevelTracedata)() defer c.xtracewrite(mlog.LevelTracedata)()
data.xwriteTo(cmd.conn, cmd.conn.xbw) data.xwriteTo(cmd.conn, cmd.conn.xbw)
@ -2050,6 +2189,12 @@ func (c *conn) xapplyChangesNotify(changes []store.Change, sendDelayed bool) {
var vanishedUIDs numSet var vanishedUIDs numSet
for _, uid := range ch.UIDs { for _, uid := range ch.UIDs {
// With uidonly, we must always return VANISHED. ../rfc/9586:232
if c.uidonly {
c.exists--
vanishedUIDs.append(uint32(uid))
continue
}
seq := c.xsequence(uid) seq := c.xsequence(uid)
c.sequenceRemove(seq, uid) c.sequenceRemove(seq, uid)
@ -2059,7 +2204,7 @@ func (c *conn) xapplyChangesNotify(changes []store.Change, sendDelayed bool) {
c.xbwritelinef("* %d EXPUNGE", seq) c.xbwritelinef("* %d EXPUNGE", seq)
} }
} }
if qresync { if !vanishedUIDs.empty() {
// VANISHED without EARLIER. ../rfc/7162:2004 // VANISHED without EARLIER. ../rfc/7162:2004
for _, s := range vanishedUIDs.Strings(4*1024 - 32) { for _, s := range vanishedUIDs.Strings(4*1024 - 32) {
c.xbwritelinef("* VANISHED %s", s) c.xbwritelinef("* VANISHED %s", s)
@ -2092,9 +2237,12 @@ func (c *conn) xapplyChangesNotify(changes []store.Change, sendDelayed bool) {
} }
// The uid can be unknown if we just expunged it while another session marked it as deleted just before. // The uid can be unknown if we just expunged it while another session marked it as deleted just before.
seq := c.sequence(ch.UID) var seq msgseq
if seq <= 0 { if !c.uidonly {
continue seq = c.sequence(ch.UID)
if seq <= 0 {
continue
}
} }
var modseqStr string var modseqStr string
@ -2102,7 +2250,12 @@ func (c *conn) xapplyChangesNotify(changes []store.Change, sendDelayed bool) {
modseqStr = fmt.Sprintf(" MODSEQ (%d)", ch.ModSeq.Client()) modseqStr = fmt.Sprintf(" MODSEQ (%d)", ch.ModSeq.Client())
} }
// UID and FLAGS are required. ../rfc/5465:463 // UID and FLAGS are required. ../rfc/5465:463
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s%s)", seq, ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr) // UIDFETCH in case of uidonly. ../rfc/9586:228
if c.uidonly {
c.xbwritelinef("* %d UIDFETCH (FLAGS %s%s)", ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr)
} else {
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s%s)", seq, ch.UID, flaglist(ch.Flags, ch.Keywords).pack(c), modseqStr)
}
case store.ChangeThread: case store.ChangeThread:
continue continue
@ -2950,6 +3103,11 @@ func (c *conn) cmdEnable(tag, cmd string, p *parser) {
case capMetadata: case capMetadata:
c.enabled[cap] = true c.enabled[cap] = true
enabled += " " + s enabled += " " + s
case capUIDOnly:
c.enabled[cap] = true
enabled += " " + s
c.uidonly = true
c.uids = nil
} }
} }
// QRESYNC enabled CONDSTORE too ../rfc/7162:1391 // QRESYNC enabled CONDSTORE too ../rfc/7162:1391
@ -3075,6 +3233,11 @@ func (c *conn) cmdSelectExamine(isselect bool, tag, cmd string, p *parser) {
c.unselect() c.unselect()
} }
if c.uidonly && qrknownSeqSet != nil {
// ../rfc/9586:255
xsyntaxCodeErrorf("UIDREQUIRED", "cannot use message sequence match data with uidonly enabled")
}
name = xcheckmailboxname(name, true) name = xcheckmailboxname(name, true)
var highestModSeq store.ModSeq var highestModSeq store.ModSeq
@ -3085,24 +3248,27 @@ func (c *conn) cmdSelectExamine(isselect bool, tag, cmd string, p *parser) {
c.xdbread(func(tx *bstore.Tx) { c.xdbread(func(tx *bstore.Tx) {
mb = c.xmailbox(tx, name, "") mb = c.xmailbox(tx, name, "")
q := bstore.QueryTx[store.Message](tx) c.uidnext = mb.UIDNext
q.FilterNonzero(store.Message{MailboxID: mb.ID}) if c.uidonly {
q.FilterEqual("Expunged", false) c.exists = uint32(mb.MailboxCounts.Total + mb.MailboxCounts.Deleted)
q.SortAsc("UID") } else {
c.uids = []store.UID{} c.uids = []store.UID{}
var seq msgseq = 1
err := q.ForEach(func(m store.Message) error { q := bstore.QueryTx[store.Message](tx)
c.uids = append(c.uids, m.UID) q.FilterNonzero(store.Message{MailboxID: mb.ID})
if firstUnseen == 0 && !m.Seen { q.FilterEqual("Expunged", false)
firstUnseen = seq q.SortAsc("UID")
} err := q.ForEach(func(m store.Message) error {
seq++ c.uids = append(c.uids, m.UID)
return nil if firstUnseen == 0 && !m.Seen {
}) firstUnseen = msgseq(len(c.uids))
if sanityChecks { }
checkUIDs(c.uids) return nil
})
xcheckf(err, "fetching uids")
c.exists = uint32(len(c.uids))
} }
xcheckf(err, "fetching uids")
// Condstore extension, find the highest modseq. // Condstore extension, find the highest modseq.
if c.enabled[capCondstore] { if c.enabled[capCondstore] {
@ -3111,6 +3277,7 @@ func (c *conn) cmdSelectExamine(isselect bool, tag, cmd string, p *parser) {
// For QRESYNC, we need to know the highest modset of deleted expunged records to // For QRESYNC, we need to know the highest modset of deleted expunged records to
// maintain synchronization. // maintain synchronization.
if c.enabled[capQresync] { if c.enabled[capQresync] {
var err error
highDeletedModSeq, err = c.account.HighestDeletedModSeq(tx) highDeletedModSeq, err = c.account.HighestDeletedModSeq(tx)
xcheckf(err, "getting highest deleted modseq") xcheckf(err, "getting highest deleted modseq")
} }
@ -3129,7 +3296,7 @@ func (c *conn) cmdSelectExamine(isselect bool, tag, cmd string, p *parser) {
if !c.enabled[capIMAP4rev2] { if !c.enabled[capIMAP4rev2] {
c.xbwritelinef(`* 0 RECENT`) c.xbwritelinef(`* 0 RECENT`)
} }
c.xbwritelinef(`* %d EXISTS`, len(c.uids)) c.xbwritelinef(`* %d EXISTS`, c.exists)
if !c.enabled[capIMAP4rev2] && firstUnseen > 0 { if !c.enabled[capIMAP4rev2] && firstUnseen > 0 {
// ../rfc/9051:8051 ../rfc/3501:1774 // ../rfc/9051:8051 ../rfc/3501:1774
c.xbwritelinef(`* OK [UNSEEN %d] x`, firstUnseen) c.xbwritelinef(`* OK [UNSEEN %d] x`, firstUnseen)
@ -3173,7 +3340,8 @@ func (c *conn) cmdSelectExamine(isselect bool, tag, cmd string, p *parser) {
xsyntaxErrorf("invalid combination of known sequence set and uid set, must be of equal length") xsyntaxErrorf("invalid combination of known sequence set and uid set, must be of equal length")
} }
i := int(msgseq - 1) i := int(msgseq - 1)
if i < 0 || i >= len(c.uids) || c.uids[i] != store.UID(uid) { // Access to c.uids is safe, qrknownSeqSet and uidonly cannot both be set.
if i < 0 || i >= int(c.exists) || c.uids[i] != store.UID(uid) {
if uidSearch(c.uids, store.UID(uid)) <= 0 { if uidSearch(c.uids, store.UID(uid)) <= 0 {
// We will check this old client UID for consistency below. // We will check this old client UID for consistency below.
oldClientUID = store.UID(uid) oldClientUID = store.UID(uid)
@ -3223,6 +3391,7 @@ func (c *conn) cmdSelectExamine(isselect bool, tag, cmd string, p *parser) {
// Note: we don't filter by Expunged. // Note: we don't filter by Expunged.
q.FilterGreater("ModSeq", store.ModSeqFromClient(qrmodseq)) q.FilterGreater("ModSeq", store.ModSeqFromClient(qrmodseq))
q.FilterLessEqual("ModSeq", highestModSeq) q.FilterLessEqual("ModSeq", highestModSeq)
q.FilterLess("UID", c.uidnext)
q.SortAsc("ModSeq") q.SortAsc("ModSeq")
err := q.ForEach(func(m store.Message) error { err := q.ForEach(func(m store.Message) error {
if m.Expunged && m.UID < preVanished { if m.Expunged && m.UID < preVanished {
@ -3236,38 +3405,72 @@ func (c *conn) cmdSelectExamine(isselect bool, tag, cmd string, p *parser) {
vanishedUIDs[m.UID] = struct{}{} vanishedUIDs[m.UID] = struct{}{}
return nil return nil
} }
msgseq := c.sequence(m.UID) // UIDFETCH in case of uidonly. ../rfc/9586:228
if msgseq > 0 { if c.uidonly {
c.xbwritelinef("* %d UIDFETCH (FLAGS %s MODSEQ (%d))", m.UID, flaglist(m.Flags, m.Keywords).pack(c), m.ModSeq.Client())
} else if msgseq := c.sequence(m.UID); msgseq > 0 {
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s MODSEQ (%d))", msgseq, m.UID, flaglist(m.Flags, m.Keywords).pack(c), m.ModSeq.Client()) c.xbwritelinef("* %d FETCH (UID %d FLAGS %s MODSEQ (%d))", msgseq, m.UID, flaglist(m.Flags, m.Keywords).pack(c), m.ModSeq.Client())
} }
return nil return nil
}) })
xcheckf(err, "listing changed messages") xcheckf(err, "listing changed messages")
})
// Add UIDs from client's known UID set to vanished list if we don't have enough history. // If we don't have enough history, we go through all UIDs and look them up, and
if qrmodseq < highDeletedModSeq.Client() { // add them to the vanished list if they have disappeared.
// If no known uid set was in the request, we substitute 1:max or the empty set. if qrmodseq < highDeletedModSeq.Client() {
// ../rfc/7162:1524 // If no "known uid set" was in the request, we substitute 1:max or the empty set.
if qrknownUIDs == nil { // ../rfc/7162:1524
if len(c.uids) > 0 { if qrknownUIDs == nil {
qrknownUIDs = &numSet{ranges: []numRange{{first: setNumber{number: 1}, last: &setNumber{number: uint32(c.uids[len(c.uids)-1])}}}} qrknownUIDs = &numSet{ranges: []numRange{{first: setNumber{number: 1}, last: &setNumber{number: uint32(c.uidnext - 1)}}}}
}
if c.uidonly {
// note: qrknownUIDs will not contain "*".
for _, r := range qrknownUIDs.xinterpretStar(func() store.UID { return 0 }).ranges {
// Gather UIDs for this range.
var uids []store.UID
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: mb.ID})
q.FilterEqual("Expunged", false)
if r.last == nil {
q.FilterEqual("UID", r.first.number)
} else {
q.FilterGreaterEqual("UID", r.first.number)
q.FilterLessEqual("UID", r.last.number)
}
q.SortAsc("UID")
for m, err := range q.All() {
xcheckf(err, "enumerating uids")
uids = append(uids, m.UID)
}
// Find UIDs missing from the database.
iter := r.newIter()
for {
uid, ok := iter.Next()
if !ok {
break
}
if uidSearch(uids, store.UID(uid)) <= 0 {
vanishedUIDs[store.UID(uid)] = struct{}{}
}
}
}
} else { } else {
qrknownUIDs = &numSet{} // Ensure it is in ascending order, no needless first/last ranges. qrknownUIDs cannot contain a star.
iter := qrknownUIDs.newIter()
for {
v, ok := iter.Next()
if !ok {
break
}
if c.sequence(store.UID(v)) <= 0 {
vanishedUIDs[store.UID(v)] = struct{}{}
}
}
} }
} }
})
iter := qrknownUIDs.newIter()
for {
v, ok := iter.Next()
if !ok {
break
}
if c.sequence(store.UID(v)) <= 0 {
vanishedUIDs[store.UID(v)] = struct{}{}
}
}
}
// Now that we have all vanished UIDs, send them over compactly. // Now that we have all vanished UIDs, send them over compactly.
if len(vanishedUIDs) > 0 { if len(vanishedUIDs) > 0 {
@ -4044,7 +4247,7 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) {
c.uidAppend(a.m.UID) c.uidAppend(a.m.UID)
} }
// todo spec: with condstore/qresync, is there a mechanism to let the client know the modseq for the appended uid? in theory an untagged fetch with the modseq after the OK APPENDUID could make sense, but this probably isn't allowed. // todo spec: with condstore/qresync, is there a mechanism to let the client know the modseq for the appended uid? in theory an untagged fetch with the modseq after the OK APPENDUID could make sense, but this probably isn't allowed.
c.xbwritelinef("* %d EXISTS", len(c.uids)) c.xbwritelinef("* %d EXISTS", c.exists)
} }
// ../rfc/4315:289 ../rfc/3502:236 APPENDUID // ../rfc/4315:289 ../rfc/3502:236 APPENDUID
@ -4263,13 +4466,17 @@ func (c *conn) xexpunge(uidSet *numSet, missingMailboxOK bool) (expunged []store
} }
xcheckf(err, "get mailbox") xcheckf(err, "get mailbox")
xlastUID := c.newCachedLastUID(tx, c.mailboxID, func(err error) { xuserErrorf("%s", err) })
qm := bstore.QueryTx[store.Message](tx) qm := bstore.QueryTx[store.Message](tx)
qm.FilterNonzero(store.Message{MailboxID: c.mailboxID}) qm.FilterNonzero(store.Message{MailboxID: c.mailboxID})
qm.FilterEqual("Deleted", true) qm.FilterEqual("Deleted", true)
qm.FilterEqual("Expunged", false) qm.FilterEqual("Expunged", false)
qm.FilterLess("UID", c.uidnext)
qm.FilterFn(func(m store.Message) bool { qm.FilterFn(func(m store.Message) bool {
// Only remove if this session knows about the message and if present in optional uidSet. // Only remove if this session knows about the message and if present in optional
return uidSearch(c.uids, m.UID) > 0 && (uidSet == nil || uidSet.containsUID(m.UID, c.uids, c.searchResult)) // uidSet.
return uidSet == nil || uidSet.xcontainsKnownUID(m.UID, c.searchResult, xlastUID)
}) })
qm.SortAsc("UID") qm.SortAsc("UID")
expunged, err = qm.List() expunged, err = qm.List()
@ -4363,6 +4570,12 @@ func (c *conn) cmdxExpunge(tag, cmd string, uidSet *numSet) {
var vanishedUIDs numSet var vanishedUIDs numSet
qresync := c.enabled[capQresync] qresync := c.enabled[capQresync]
for _, m := range expunged { for _, m := range expunged {
// With uidonly, we must always return VANISHED. ../rfc/9586:210
if c.uidonly {
c.exists--
vanishedUIDs.append(uint32(m.UID))
continue
}
seq := c.xsequence(m.UID) seq := c.xsequence(m.UID)
c.sequenceRemove(seq, m.UID) c.sequenceRemove(seq, m.UID)
if qresync { if qresync {
@ -4445,20 +4658,14 @@ func (c *conn) cmdUIDReplace(tag, cmd string, p *parser) {
c.cmdxReplace(true, tag, cmd, p) c.cmdxReplace(true, tag, cmd, p)
} }
func (c *conn) gatherCopyMoveUIDs(isUID bool, nums numSet) ([]store.UID, []any) { func (c *conn) gatherCopyMoveUIDs(tx *bstore.Tx, isUID bool, nums numSet) []store.UID {
// Gather uids, then sort so we can return a consistently simple and hard to // 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 // misinterpret COPYUID/MOVEUID response. It seems safer to have UIDs in ascending
// order, because requested uid set of 12:10 is equal to 10:12, so if we would just // order, because requested uid set of 12:10 is equal to 10:12, so if we would just
// echo whatever the client sends us without reordering, the client can reorder our // echo whatever the client sends us without reordering, the client can reorder our
// response and interpret it differently than we intended. // response and interpret it differently than we intended.
// ../rfc/9051:5072 // ../rfc/9051:5072
uids := c.xnumSetUIDs(isUID, nums) return c.xnumSetEval(tx, isUID, nums)
slices.Sort(uids)
uidargs := make([]any, len(uids))
for i, uid := range uids {
uidargs[i] = uid
}
return uids, uidargs
} }
// Copy copies messages from the currently selected/active mailbox to another named // Copy copies messages from the currently selected/active mailbox to another named
@ -4477,8 +4684,6 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
name = xcheckmailboxname(name, true) name = xcheckmailboxname(name, true)
uids, uidargs := c.gatherCopyMoveUIDs(isUID, nums)
// Files that were created during the copy. Remove them if the operation fails. // Files that were created during the copy. Remove them if the operation fails.
var newIDs []int64 var newIDs []int64
defer func() { defer func() {
@ -4489,9 +4694,12 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
} }
}() }()
// UIDs to copy.
var uids []store.UID
var mbDst store.Mailbox var mbDst store.Mailbox
var nkeywords int var nkeywords int
var origUIDs, newUIDs []store.UID var newUIDs []store.UID
var flags []store.Flags var flags []store.Flags
var keywords [][]string var keywords [][]string
var modseq store.ModSeq // For messages in new mailbox, assigned when first message is copied. var modseq store.ModSeq // For messages in new mailbox, assigned when first message is copied.
@ -4500,12 +4708,15 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
c.xdbwrite(func(tx *bstore.Tx) { 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") mbDst = c.xmailbox(tx, name, "TRYCREATE")
if mbDst.ID == mbSrc.ID { if mbDst.ID == mbSrc.ID {
xuserErrorf("cannot copy to currently selected mailbox") xuserErrorf("cannot copy to currently selected mailbox")
} }
if len(uidargs) == 0 { uids = c.gatherCopyMoveUIDs(tx, isUID, nums)
if len(uids) == 0 {
xuserErrorf("no matching messages to copy") xuserErrorf("no matching messages to copy")
} }
@ -4522,17 +4733,17 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
// Reserve the uids in the destination mailbox. // Reserve the uids in the destination mailbox.
uidFirst := mbDst.UIDNext uidFirst := mbDst.UIDNext
mbDst.UIDNext += store.UID(len(uidargs)) mbDst.UIDNext += store.UID(len(uids))
// Fetch messages from database. // Fetch messages from database.
q := bstore.QueryTx[store.Message](tx) q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID}) q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
q.FilterEqual("UID", uidargs...) q.FilterEqual("UID", slicesAny(uids)...)
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
xmsgs, err := q.List() xmsgs, err := q.List()
xcheckf(err, "fetching messages") xcheckf(err, "fetching messages")
if len(xmsgs) != len(uidargs) { if len(xmsgs) != len(uids) {
xserverErrorf("uid and message mismatch") xserverErrorf("uid and message mismatch")
} }
@ -4588,7 +4799,6 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
xcheckf(err, "inserting message") xcheckf(err, "inserting message")
msgs[uid] = m msgs[uid] = m
nmsgs[i] = m nmsgs[i] = m
origUIDs = append(origUIDs, uid)
newUIDs = append(newUIDs, m.UID) newUIDs = append(newUIDs, m.UID)
newMsgIDs = append(newMsgIDs, m.ID) newMsgIDs = append(newMsgIDs, m.ID)
flags = append(flags, m.Flags) flags = append(flags, m.Flags)
@ -4666,7 +4876,7 @@ func (c *conn) cmdxCopy(isUID bool, tag, cmd string, p *parser) {
}) })
// ../rfc/9051:6881 ../rfc/4315:183 // ../rfc/9051:6881 ../rfc/4315:183
c.xwriteresultf("%s OK [COPYUID %d %s %s] copied", tag, mbDst.UIDValidity, compactUIDSet(origUIDs).String(), compactUIDSet(newUIDs).String()) c.xwriteresultf("%s OK [COPYUID %d %s %s] copied", tag, mbDst.UIDValidity, compactUIDSet(uids).String(), compactUIDSet(newUIDs).String())
} }
// Move moves messages from the currently selected/active mailbox to a named mailbox. // Move moves messages from the currently selected/active mailbox to a named mailbox.
@ -4688,7 +4898,8 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) {
xuserErrorf("mailbox open in read-only mode") xuserErrorf("mailbox open in read-only mode")
} }
uids, uidargs := c.gatherCopyMoveUIDs(isUID, nums) // UIDs to move.
var uids []store.UID
var mbDst store.Mailbox var mbDst store.Mailbox
var uidFirst store.UID var uidFirst store.UID
@ -4713,6 +4924,8 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) {
xuserErrorf("cannot move to currently selected mailbox") xuserErrorf("cannot move to currently selected mailbox")
} }
uids = c.gatherCopyMoveUIDs(tx, isUID, nums)
if len(uids) == 0 { if len(uids) == 0 {
xuserErrorf("no matching messages to move") xuserErrorf("no matching messages to move")
} }
@ -4727,11 +4940,11 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) {
// Make query selecting messages to move. // Make query selecting messages to move.
q := bstore.QueryTx[store.Message](tx) q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: mbSrc.ID}) q.FilterNonzero(store.Message{MailboxID: mbSrc.ID})
q.FilterEqual("UID", uidargs...) q.FilterEqual("UID", slicesAny(uids)...)
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
q.SortAsc("UID") q.SortAsc("UID")
newIDs, chl := c.xmoveMessages(tx, q, len(uidargs), modseq, &mbSrc, &mbDst) newIDs, chl := c.xmoveMessages(tx, q, len(uids), modseq, &mbSrc, &mbDst)
changes = append(changes, chl...) changes = append(changes, chl...)
cleanupIDs = newIDs cleanupIDs = newIDs
}) })
@ -4748,6 +4961,13 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) {
qresync := c.enabled[capQresync] qresync := c.enabled[capQresync]
var vanishedUIDs numSet var vanishedUIDs numSet
for i := range uids { for i := range uids {
// With uidonly, we must always return VANISHED. ../rfc/9586:232
if c.uidonly {
c.exists--
vanishedUIDs.append(uint32(uids[i]))
continue
}
seq := c.xsequence(uids[i]) seq := c.xsequence(uids[i])
c.sequenceRemove(seq, uids[i]) c.sequenceRemove(seq, uids[i])
if qresync { if qresync {
@ -4988,9 +5208,9 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) {
mb = c.xmailboxID(tx, c.mailboxID) // Validate. mb = c.xmailboxID(tx, c.mailboxID) // Validate.
origmb = mb origmb = mb
uidargs := c.xnumSetCondition(isUID, nums) uids := c.xnumSetEval(tx, isUID, nums)
if len(uidargs) == 0 { if len(uids) == 0 {
return return
} }
@ -5005,7 +5225,7 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) {
q := bstore.QueryTx[store.Message](tx) q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MailboxID: c.mailboxID}) q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
q.FilterEqual("UID", uidargs...) q.FilterEqual("UID", slicesAny(uids)...)
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
err := q.ForEach(func(m store.Message) error { err := q.ForEach(func(m store.Message) error {
// Client may specify a message multiple times, but we only process it once. ../rfc/7162:823 // Client may specify a message multiple times, but we only process it once. ../rfc/7162:823
@ -5111,16 +5331,25 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) {
// ../rfc/7162:549 // ../rfc/7162:549
if !silent || c.enabled[capCondstore] { if !silent || c.enabled[capCondstore] {
for _, m := range updated { for _, m := range updated {
var flags string var args []string
if !silent { if !silent {
flags = fmt.Sprintf(" FLAGS %s", flaglist(m.Flags, m.Keywords).pack(c)) args = append(args, fmt.Sprintf("FLAGS %s", flaglist(m.Flags, m.Keywords).pack(c)))
} }
var modseqStr string
if c.enabled[capCondstore] { if c.enabled[capCondstore] {
modseqStr = fmt.Sprintf(" MODSEQ (%d)", m.ModSeq.Client()) args = append(args, fmt.Sprintf("MODSEQ (%d)", m.ModSeq.Client()))
} }
// ../rfc/9051:6749 ../rfc/3501:4869 ../rfc/7162:2490 // ../rfc/9051:6749 ../rfc/3501:4869 ../rfc/7162:2490
c.xbwritelinef("* %d FETCH (UID %d%s%s)", c.xsequence(m.UID), m.UID, flags, modseqStr) // UIDFETCH in case of uidonly. ../rfc/9586:228
if c.uidonly {
// Ensure list is non-empty.
if len(args) == 0 {
args = append(args, fmt.Sprintf("UID %d", m.UID))
}
c.xbwritelinef("* %d UIDFETCH (%s)", m.UID, strings.Join(args, " "))
} else {
args = append([]string{fmt.Sprintf("UID %d", m.UID)}, args...)
c.xbwritelinef("* %d FETCH (%s)", c.xsequence(m.UID), strings.Join(args, " "))
}
} }
} }
@ -5138,7 +5367,12 @@ func (c *conn) cmdxStore(isUID bool, tag, cmd string, p *parser) {
// Also gather UIDs or sequences for the MODIFIED response below. ../rfc/7162:571 // Also gather UIDs or sequences for the MODIFIED response below. ../rfc/7162:571
var mnums []store.UID var mnums []store.UID
for _, m := range changed { for _, m := range changed {
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s MODSEQ (%d))", c.xsequence(m.UID), m.UID, flaglist(m.Flags, m.Keywords).pack(c), m.ModSeq.Client()) // UIDFETCH in case of uidonly. ../rfc/9586:228
if c.uidonly {
c.xbwritelinef("* %d UIDFETCH (FLAGS %s MODSEQ (%d))", m.UID, flaglist(m.Flags, m.Keywords).pack(c), m.ModSeq.Client())
} else {
c.xbwritelinef("* %d FETCH (UID %d FLAGS %s MODSEQ (%d))", c.xsequence(m.UID), m.UID, flaglist(m.Flags, m.Keywords).pack(c), m.ModSeq.Client())
}
if isUID { if isUID {
mnums = append(mnums, m.UID) mnums = append(mnums, m.UID)
} else { } else {

View File

@ -41,6 +41,10 @@ func init() {
mox.Context = ctxbg mox.Context = ctxbg
} }
func ptr[T any](v T) *T {
return &v
}
func tocrlf(s string) string { func tocrlf(s string) string {
return strings.ReplaceAll(s, "\n", "\r\n") return strings.ReplaceAll(s, "\n", "\r\n")
} }
@ -170,6 +174,7 @@ type testconn struct {
t *testing.T t *testing.T
conn net.Conn conn net.Conn
client *imapclient.Conn client *imapclient.Conn
uidonly bool
done chan struct{} done chan struct{}
serverConn net.Conn serverConn net.Conn
account *store.Account account *store.Account
@ -250,7 +255,7 @@ func tuntagged(t *testing.T, got imapclient.Untagged, dst any) {
gotv := reflect.ValueOf(got) gotv := reflect.ValueOf(got)
dstv := reflect.ValueOf(dst) dstv := reflect.ValueOf(dst)
if gotv.Type() != dstv.Type().Elem() { if gotv.Type() != dstv.Type().Elem() {
t.Fatalf("got %#v, expected %#v", gotv.Type(), dstv.Type().Elem()) t.Fatalf("got %#v, expected %#v", got, dstv.Elem().Interface())
} }
dstv.Elem().Set(gotv) dstv.Elem().Set(gotv)
} }
@ -327,6 +332,33 @@ func (tc *testconn) waitDone() {
} }
} }
func (tc *testconn) login(username, password string) {
tc.client.Login(username, password)
if tc.uidonly {
tc.transactf("ok", "enable uidonly")
}
}
// untaggedFetch returns an imapclient.UntaggedFetch or
// imapclient.UntaggedUIDFetch, depending on whether uidonly is enabled for the
// connection.
func (tc *testconn) untaggedFetch(seq, uid uint32, attrs ...imapclient.FetchAttr) any {
if tc.uidonly {
return imapclient.UntaggedUIDFetch{UID: uid, Attrs: attrs}
}
attrs = append([]imapclient.FetchAttr{imapclient.FetchUID(uid)}, attrs...)
return imapclient.UntaggedFetch{Seq: seq, Attrs: attrs}
}
// like untaggedFetch, but with explicit UID fetch attribute in case of uidonly.
func (tc *testconn) untaggedFetchUID(seq, uid uint32, attrs ...imapclient.FetchAttr) any {
attrs = append([]imapclient.FetchAttr{imapclient.FetchUID(uid)}, attrs...)
if tc.uidonly {
return imapclient.UntaggedUIDFetch{UID: uid, Attrs: attrs}
}
return imapclient.UntaggedFetch{Seq: seq, Attrs: attrs}
}
func (tc *testconn) close() { func (tc *testconn) close() {
tc.close0(true) tc.close0(true)
} }
@ -338,7 +370,7 @@ func (tc *testconn) closeNoWait() {
func (tc *testconn) close0(waitclose bool) { func (tc *testconn) close0(waitclose bool) {
defer func() { defer func() {
if unhandledPanics.Swap(0) > 0 { if unhandledPanics.Swap(0) > 0 {
tc.t.Fatalf("handled panic in server") tc.t.Fatalf("unhandled panic in server")
} }
}() }()
@ -388,19 +420,19 @@ func makeAppendTime(msg string, tm time.Time) imapclient.Append {
var connCounter int64 var connCounter int64
func start(t *testing.T) *testconn { func start(t *testing.T, uidonly bool) *testconn {
return startArgs(t, true, false, true, true, "mjl") return startArgs(t, uidonly, true, false, true, true, "mjl")
} }
func startNoSwitchboard(t *testing.T) *testconn { func startNoSwitchboard(t *testing.T, uidonly bool) *testconn {
return startArgs(t, false, false, true, false, "mjl") return startArgs(t, uidonly, false, false, true, false, "mjl")
} }
const password0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces. const password0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces.
const password1 = "tést " // PRECIS normalized, with NFC. const password1 = "tést " // PRECIS normalized, with NFC.
func startArgs(t *testing.T, first, immediateTLS bool, allowLoginWithoutTLS, setPassword bool, accname string) *testconn { func startArgs(t *testing.T, uidonly, first, immediateTLS bool, allowLoginWithoutTLS, setPassword bool, accname string) *testconn {
return startArgsMore(t, first, immediateTLS, nil, nil, allowLoginWithoutTLS, setPassword, accname, nil) return startArgsMore(t, uidonly, first, immediateTLS, nil, nil, allowLoginWithoutTLS, setPassword, accname, nil)
} }
// namedConn wraps a conn so it can return a RemoteAddr with a non-empty name. // namedConn wraps a conn so it can return a RemoteAddr with a non-empty name.
@ -415,7 +447,7 @@ func (c namedConn) RemoteAddr() net.Addr {
} }
// todo: the parameters and usage are too much now. change to scheme similar to smtpserver, with params in a struct, and a separate method for init and making a connection. // todo: the parameters and usage are too much now. change to scheme similar to smtpserver, with params in a struct, and a separate method for init and making a connection.
func startArgsMore(t *testing.T, first, immediateTLS bool, serverConfig, clientConfig *tls.Config, allowLoginWithoutTLS, setPassword bool, accname string, afterInit func() error) *testconn { func startArgsMore(t *testing.T, uidonly, first, immediateTLS bool, serverConfig, clientConfig *tls.Config, allowLoginWithoutTLS, setPassword bool, accname string, afterInit func() error) *testconn {
limitersInit() // Reset rate limiters. limitersInit() // Reset rate limiters.
switchStop := func() {} switchStop := func() {}
@ -506,7 +538,7 @@ func startArgsMore(t *testing.T, first, immediateTLS bool, serverConfig, clientC
}() }()
client, err := imapclient.New(connCounter, clientConn, true) client, err := imapclient.New(connCounter, clientConn, true)
tcheck(t, err, "new client") tcheck(t, err, "new client")
tc := &testconn{t: t, conn: clientConn, client: client, done: done, serverConn: serverConn, account: acc} tc := &testconn{t: t, conn: clientConn, client: client, uidonly: uidonly, done: done, serverConn: serverConn, account: acc}
if first { if first {
tc.switchStop = switchStop tc.switchStop = switchStop
} }
@ -542,7 +574,7 @@ func fakeCert(t *testing.T, randomkey bool) tls.Certificate {
} }
func TestLogin(t *testing.T) { func TestLogin(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.transactf("bad", "login too many args") tc.transactf("bad", "login too many args")
@ -556,11 +588,11 @@ func TestLogin(t *testing.T) {
tc.transactf("ok", `login mjl@mox.example "%s"`, password0) tc.transactf("ok", `login mjl@mox.example "%s"`, password0)
tc.close() tc.close()
tc = start(t) tc = start(t, false)
tc.transactf("ok", `login "mjl@mox.example" "%s"`, password0) tc.transactf("ok", `login "mjl@mox.example" "%s"`, password0)
tc.close() tc.close()
tc = start(t) tc = start(t, false)
tc.transactf("ok", `login "\"\"@mox.example" "%s"`, password0) tc.transactf("ok", `login "\"\"@mox.example" "%s"`, password0)
defer tc.close() defer tc.close()
@ -570,7 +602,7 @@ func TestLogin(t *testing.T) {
// Test that commands don't work in the states they are not supposed to. // Test that commands don't work in the states they are not supposed to.
func TestState(t *testing.T) { func TestState(t *testing.T) {
tc := start(t) tc := start(t, false)
notAuthenticated := []string{"starttls", "authenticate", "login"} notAuthenticated := []string{"starttls", "authenticate", "login"}
authenticatedOrSelected := []string{"enable", "select", "examine", "create", "delete", "rename", "subscribe", "unsubscribe", "list", "namespace", "status", "append", "idle", "lsub"} authenticatedOrSelected := []string{"enable", "select", "examine", "create", "delete", "rename", "subscribe", "unsubscribe", "list", "namespace", "status", "append", "idle", "lsub"}
@ -581,7 +613,7 @@ func TestState(t *testing.T) {
tc.transactf("ok", "noop") tc.transactf("ok", "noop")
tc.transactf("ok", "logout") tc.transactf("ok", "logout")
tc.close() tc.close()
tc = start(t) tc = start(t, false)
defer tc.close() defer tc.close()
// Not authenticated, lots of commands not allowed. // Not authenticated, lots of commands not allowed.
@ -599,7 +631,7 @@ func TestState(t *testing.T) {
} }
func TestNonIMAP(t *testing.T) { func TestNonIMAP(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
// imap greeting has already been read, we sidestep the imapclient. // imap greeting has already been read, we sidestep the imapclient.
@ -612,10 +644,10 @@ func TestNonIMAP(t *testing.T) {
} }
func TestLiterals(t *testing.T) { func TestLiterals(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Create("tmpbox", nil) tc.client.Create("tmpbox", nil)
tc.transactf("ok", "rename {6+}\r\ntmpbox {7+}\r\nntmpbox") tc.transactf("ok", "rename {6+}\r\ntmpbox {7+}\r\nntmpbox")
@ -636,9 +668,17 @@ func TestLiterals(t *testing.T) {
// Test longer scenario with login, lists, subscribes, status, selects, etc. // Test longer scenario with login, lists, subscribes, status, selects, etc.
func TestScenario(t *testing.T) { func TestScenario(t *testing.T) {
tc := start(t) testScenario(t, false)
}
func TestScenarioUIDOnly(t *testing.T) {
testScenario(t, true)
}
func testScenario(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.transactf("ok", `login mjl@mox.example "%s"`, password0) tc.login("mjl@mox.example", password0)
tc.transactf("bad", " missingcommand") tc.transactf("bad", " missingcommand")
@ -682,6 +722,44 @@ func TestScenario(t *testing.T) {
tc.check(err, "write message") tc.check(err, "write message")
tc.response("ok") tc.response("ok")
tc.transactf("ok", "uid fetch 1 all")
tc.transactf("ok", "uid fetch 1 body")
tc.transactf("ok", "uid fetch 1 binary[]")
tc.transactf("ok", `uid store 1 flags (\seen \answered)`)
tc.transactf("ok", `uid store 1 +flags ($junk)`) // should train as junk.
tc.transactf("ok", `uid store 1 -flags ($junk)`) // should retrain as non-junk.
tc.transactf("ok", `uid store 1 -flags (\seen)`) // should untrain completely.
tc.transactf("ok", `uid store 1 -flags (\answered)`)
tc.transactf("ok", `uid store 1 +flags (\answered)`)
tc.transactf("ok", `uid store 1 flags.silent (\seen \answered)`)
tc.transactf("ok", `uid store 1 -flags.silent (\answered)`)
tc.transactf("ok", `uid store 1 +flags.silent (\answered)`)
tc.transactf("bad", `uid store 1 flags (\badflag)`)
tc.transactf("ok", "noop")
tc.transactf("ok", "uid copy 1 Trash")
tc.transactf("ok", "uid copy 1 Trash")
tc.transactf("ok", "uid move 1 Trash")
tc.transactf("ok", "close")
tc.transactf("ok", "select Trash")
tc.transactf("ok", `uid store 1 flags (\deleted)`)
tc.transactf("ok", "expunge")
tc.transactf("ok", "noop")
tc.transactf("ok", `uid store 1 flags (\deleted)`)
tc.transactf("ok", "close")
tc.transactf("ok", "delete Trash")
if uidonly {
return
}
tc.transactf("ok", "create Trash")
tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(exampleMsg), exampleMsg)
tc.transactf("ok", "select inbox")
tc.transactf("ok", "fetch 1 all") tc.transactf("ok", "fetch 1 all")
tc.transactf("ok", "fetch 1 body") tc.transactf("ok", "fetch 1 body")
tc.transactf("ok", "fetch 1 binary[]") tc.transactf("ok", "fetch 1 binary[]")
@ -714,9 +792,9 @@ func TestScenario(t *testing.T) {
} }
func TestMailbox(t *testing.T) { func TestMailbox(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
invalid := []string{ invalid := []string{
"e\u0301", // é but as e + acute, not unicode-normalized "e\u0301", // é but as e + acute, not unicode-normalized
@ -736,14 +814,14 @@ func TestMailbox(t *testing.T) {
} }
func TestMailboxDeleted(t *testing.T) { func TestMailboxDeleted(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, false)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc.client.Create("testbox", nil) tc.client.Create("testbox", nil)
tc2.client.Select("testbox") tc2.client.Select("testbox")
@ -759,9 +837,9 @@ func TestMailboxDeleted(t *testing.T) {
tc2.transactf("no", "uid fetch 1 all") tc2.transactf("no", "uid fetch 1 all")
tc2.transactf("no", "store 1 flags ()") tc2.transactf("no", "store 1 flags ()")
tc2.transactf("no", "uid store 1 flags ()") tc2.transactf("no", "uid store 1 flags ()")
tc2.transactf("bad", "copy 1 inbox") // msgseq 1 not available. tc2.transactf("no", "copy 1 inbox")
tc2.transactf("no", "uid copy 1 inbox") tc2.transactf("no", "uid copy 1 inbox")
tc2.transactf("bad", "move 1 inbox") // msgseq 1 not available. tc2.transactf("no", "move 1 inbox")
tc2.transactf("no", "uid move 1 inbox") tc2.transactf("no", "uid move 1 inbox")
tc2.transactf("ok", "unselect") tc2.transactf("ok", "unselect")
@ -773,9 +851,9 @@ func TestMailboxDeleted(t *testing.T) {
} }
func TestID(t *testing.T) { func TestID(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.transactf("ok", "id nil") tc.transactf("ok", "id nil")
tc.xuntagged(imapclient.UntaggedID{"name": "mox", "version": moxvar.Version}) tc.xuntagged(imapclient.UntaggedID{"name": "mox", "version": moxvar.Version})
@ -787,54 +865,84 @@ func TestID(t *testing.T) {
} }
func TestSequence(t *testing.T) { func TestSequence(t *testing.T) {
tc := start(t) testSequence(t, false)
}
func TestSequenceUIDOnly(t *testing.T) {
testSequence(t, true)
}
func testSequence(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc.transactf("bad", "fetch * all") // ../rfc/9051:7018 tc.transactf("bad", "fetch * all") // ../rfc/9051:7018
tc.transactf("bad", "fetch 1:* all")
tc.transactf("bad", "fetch 1:2 all")
tc.transactf("bad", "fetch 1 all") // ../rfc/9051:7018 tc.transactf("bad", "fetch 1 all") // ../rfc/9051:7018
tc.transactf("ok", "uid fetch 1 all") // non-existing messages are OK for uids. tc.transactf("ok", "uid fetch 1 all") // non-existing messages are OK for uids.
tc.transactf("ok", "uid fetch * all") // * is like uidnext, a non-existing message. tc.transactf("ok", "uid fetch * all") // * is like uidnext, a non-existing message.
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.transactf("ok", "uid search return (save) all") // Empty result.
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.transactf("ok", "uid fetch $ uid")
tc.transactf("ok", "fetch 2:1,1 uid") // We reorder 2:1 to 1:2, but we don't deduplicate numbers. tc.xuntagged()
tc.xuntagged(
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1)}},
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2)}},
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1)}},
)
tc.transactf("ok", "uid fetch 3:* uid") // Because * is the last message, which is 2, the range becomes 3:2, which matches the last message. tc.client.Append("inbox", makeAppend(exampleMsg))
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2)}}) tc.client.Append("inbox", makeAppend(exampleMsg))
if !uidonly {
tc.transactf("ok", "fetch 2:1,1 uid") // We reorder 2:1 to 1:2, and we deduplicate numbers.
tc.xuntagged(
tc.untaggedFetch(1, 1),
tc.untaggedFetch(2, 2),
)
tc.transactf("bad", "fetch 1:3 all")
}
tc.transactf("ok", "uid fetch * flags")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchFlags(nil)))
tc.transactf("ok", "uid fetch 3:* flags") // Because * is the last message, which is 2, the range becomes 3:2, which matches the last message.
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchFlags(nil)))
tc.transactf("ok", "uid fetch *:3 flags")
tc.xuntagged(tc.untaggedFetch(2, 2, imapclient.FetchFlags(nil)))
tc.transactf("ok", "uid search return (save) all") // Empty result.
tc.transactf("ok", "uid fetch $ flags")
tc.xuntagged(
tc.untaggedFetch(1, 1, imapclient.FetchFlags(nil)),
tc.untaggedFetch(2, 2, imapclient.FetchFlags(nil)),
)
} }
// Test that a message that is expunged by another session can be read as long as a // Test that a message that is expunged by another session can be read as long as a
// reference is held by a session. New sessions do not see the expunged message. // reference is held by a session. New sessions do not see the expunged message.
// todo: possibly implement the additional reference counting. so far it hasn't been worth the trouble. // todo: possibly implement the additional reference counting. so far it hasn't been worth the trouble.
func DisabledTestReference(t *testing.T) { func DisabledTestReference(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, false)
defer tc2.close() defer tc2.close()
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc2.client.Select("inbox") tc2.client.Select("inbox")
tc.client.StoreFlagsSet("1", true, `\Deleted`) tc.client.StoreFlagsSet("1", true, `\Deleted`)
tc.client.Expunge() tc.client.Expunge()
tc3 := startNoSwitchboard(t) tc3 := startNoSwitchboard(t, false)
defer tc3.close() defer tc3.close()
tc3.client.Login("mjl@mox.example", password0) tc3.login("mjl@mox.example", password0)
tc3.transactf("ok", `list "" "inbox" return (status (messages))`) tc3.transactf("ok", `list "" "inbox" return (status (messages))`)
tc3.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"}, imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusMessages: 0}}) tc3.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"}, imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusMessages: 0}})
tc2.transactf("ok", "fetch 1 rfc822.size") tc2.transactf("ok", "fetch 1 rfc822.size")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchRFC822Size(len(exampleMsg))}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchRFC822Size(len(exampleMsg))))
} }

View File

@ -7,22 +7,22 @@ import (
) )
func TestStarttls(t *testing.T) { func TestStarttls(t *testing.T) {
tc := start(t) tc := start(t, false)
tc.client.Starttls(&tls.Config{InsecureSkipVerify: true}) tc.client.Starttls(&tls.Config{InsecureSkipVerify: true})
tc.transactf("bad", "starttls") // TLS already active. tc.transactf("bad", "starttls") // TLS already active.
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.close() tc.close()
tc = startArgs(t, true, true, false, true, "mjl") tc = startArgs(t, false, true, true, false, true, "mjl")
tc.transactf("bad", "starttls") // TLS already active. tc.transactf("bad", "starttls") // TLS already active.
tc.close() tc.close()
tc = startArgs(t, true, false, false, true, "mjl") tc = startArgs(t, false, true, false, false, true, "mjl")
tc.transactf("no", `login "mjl@mox.example" "%s"`, password0) tc.transactf("no", `login "mjl@mox.example" "%s"`, password0)
tc.xcode("PRIVACYREQUIRED") tc.xcode("PRIVACYREQUIRED")
tc.transactf("no", "authenticate PLAIN %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0))) tc.transactf("no", "authenticate PLAIN %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000"+password0)))
tc.xcode("PRIVACYREQUIRED") tc.xcode("PRIVACYREQUIRED")
tc.client.Starttls(&tls.Config{InsecureSkipVerify: true}) tc.client.Starttls(&tls.Config{InsecureSkipVerify: true})
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.close() tc.close()
} }

View File

@ -7,11 +7,19 @@ import (
) )
func TestStatus(t *testing.T) { func TestStatus(t *testing.T) {
testStatus(t, false)
}
func TestStatusUIDOnly(t *testing.T) {
testStatus(t, true)
}
func testStatus(t *testing.T, uidonly bool) {
defer mockUIDValidity()() defer mockUIDValidity()()
tc := start(t) tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.transactf("bad", "status") // Missing param. tc.transactf("bad", "status") // Missing param.
tc.transactf("bad", "status inbox") // Missing param. tc.transactf("bad", "status inbox") // Missing param.
@ -53,7 +61,7 @@ func TestStatus(t *testing.T) {
}) })
tc.client.Select("inbox") tc.client.Select("inbox")
tc.client.StoreFlagsSet("1", true, `\Deleted`) tc.client.UIDStoreFlagsSet("1", true, `\Deleted`)
tc.transactf("ok", "status inbox (messages uidnext uidvalidity unseen deleted size recent appendlimit)") tc.transactf("ok", "status inbox (messages uidnext uidvalidity unseen deleted size recent appendlimit)")
tc.xuntagged(imapclient.UntaggedStatus{ tc.xuntagged(imapclient.UntaggedStatus{
Mailbox: "Inbox", Mailbox: "Inbox",

View File

@ -8,63 +8,74 @@ import (
) )
func TestStore(t *testing.T) { func TestStore(t *testing.T) {
tc := start(t) testStore(t, false)
}
func TestStoreUIDOnly(t *testing.T) {
testStore(t, true)
}
func testStore(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Enable("imap4rev2") tc.client.Enable("imap4rev2")
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.Select("inbox") tc.client.Select("inbox")
uid1 := imapclient.FetchUID(1)
noflags := imapclient.FetchFlags(nil) noflags := imapclient.FetchFlags(nil)
tc.transactf("ok", "store 1 flags.silent ()") if !uidonly {
tc.transactf("ok", "store 1 flags.silent ()")
tc.xuntagged()
}
tc.transactf("ok", `uid store 1 flags ()`)
tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
tc.transactf("ok", `uid fetch 1 flags`)
tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
tc.transactf("ok", `uid store 1 flags.silent (\Seen)`)
tc.xuntagged() tc.xuntagged()
tc.transactf("ok", `uid fetch 1 flags`)
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Seen`}))
tc.transactf("ok", `store 1 flags ()`) tc.transactf("ok", `uid store 1 flags ($Junk)`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{`$Junk`}))
tc.transactf("ok", `fetch 1 flags`) tc.transactf("ok", `uid fetch 1 flags`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{`$Junk`}))
tc.transactf("ok", `store 1 flags.silent (\Seen)`) tc.transactf("ok", `uid store 1 +flags ()`)
tc.xuntagged() tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{`$Junk`}))
tc.transactf("ok", `fetch 1 flags`) tc.transactf("ok", `uid store 1 +flags (\Deleted)`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`\Seen`}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Deleted`, `$Junk`}))
tc.transactf("ok", `uid fetch 1 flags`)
tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{`\Deleted`, `$Junk`}))
tc.transactf("ok", `store 1 flags ($Junk)`) tc.transactf("ok", `uid store 1 -flags \Deleted $Junk`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`$Junk`}}}) tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
tc.transactf("ok", `fetch 1 flags`) tc.transactf("ok", `uid fetch 1 flags`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`$Junk`}}}) tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
tc.transactf("ok", `store 1 +flags ()`) if !uidonly {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`$Junk`}}}) tc.transactf("bad", "store 2 flags ()") // ../rfc/9051:7018
tc.transactf("ok", `store 1 +flags (\Deleted)`) }
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`\Deleted`, `$Junk`}}})
tc.transactf("ok", `fetch 1 flags`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`\Deleted`, `$Junk`}}})
tc.transactf("ok", `store 1 -flags \Deleted $Junk`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}})
tc.transactf("ok", `fetch 1 flags`)
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}})
tc.transactf("bad", "store 2 flags ()") // ../rfc/9051:7018
tc.transactf("ok", "uid store 1 flags ()") tc.transactf("ok", "uid store 1 flags ()")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}}) tc.xuntagged(tc.untaggedFetch(1, 1, noflags))
tc.transactf("ok", "store 1 flags (new)") // New flag. tc.transactf("ok", "uid store 1 flags (new)") // New flag.
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"new"}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{"new"}))
tc.transactf("ok", "store 1 flags (new new a b c)") // Duplicates are ignored. tc.transactf("ok", "uid store 1 flags (new new a b c)") // Duplicates are ignored.
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"a", "b", "c", "new"}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{"a", "b", "c", "new"}))
tc.transactf("ok", "store 1 +flags (new new c d e)") tc.transactf("ok", "uid store 1 +flags (new new c d e)")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"a", "b", "c", "d", "e", "new"}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{"a", "b", "c", "d", "e", "new"}))
tc.transactf("ok", "store 1 -flags (new new e a c)") tc.transactf("ok", "uid store 1 -flags (new new e a c)")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"b", "d"}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{"b", "d"}))
tc.transactf("ok", "store 1 flags ($Forwarded Different)") tc.transactf("ok", "uid store 1 flags ($Forwarded Different)")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{"$Forwarded", "different"}}}) tc.xuntagged(tc.untaggedFetch(1, 1, imapclient.FetchFlags{"$Forwarded", "different"}))
tc.transactf("bad", "store") // Need numset, flags and args. tc.transactf("bad", "store") // Need numset, flags and args.
tc.transactf("bad", "store 1") // Need flags. tc.transactf("bad", "store 1") // Need flags.
@ -80,5 +91,5 @@ func TestStore(t *testing.T) {
flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent a b c d different e new`, " ") flags := strings.Split(`\Seen \Answered \Flagged \Deleted \Draft $Forwarded $Junk $NotJunk $Phishing $MDNSent a b c d different e new`, " ")
tc.xuntaggedOpt(false, imapclient.UntaggedFlags(flags)) tc.xuntaggedOpt(false, imapclient.UntaggedFlags(flags))
tc.transactf("no", `store 1 flags ()`) // No permission to set flags. tc.transactf("no", `uid store 1 flags ()`) // No permission to set flags.
} }

View File

@ -7,14 +7,14 @@ import (
) )
func TestSubscribe(t *testing.T) { func TestSubscribe(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc2 := startNoSwitchboard(t) tc2 := startNoSwitchboard(t, false)
defer tc2.closeNoWait() defer tc2.closeNoWait()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc2.client.Login("mjl@mox.example", password0) tc2.login("mjl@mox.example", password0)
tc.transactf("bad", "subscribe") // Missing param. tc.transactf("bad", "subscribe") // Missing param.
tc.transactf("bad", "subscribe ") // Missing param. tc.transactf("bad", "subscribe ") // Missing param.

View File

@ -0,0 +1,39 @@
package imapserver
import (
"testing"
)
func TestUIDOnly(t *testing.T) {
tc := start(t, true)
defer tc.close()
tc.login("mjl@mox.example", password0)
tc.client.Select("inbox")
tc.transactf("bad", "Fetch 1")
tc.xcode("UIDREQUIRED")
tc.transactf("bad", "Fetch 1")
tc.xcode("UIDREQUIRED")
tc.transactf("bad", "Search 1")
tc.xcode("UIDREQUIRED")
tc.transactf("bad", "Store 1 Flags ()")
tc.xcode("UIDREQUIRED")
tc.transactf("bad", "Copy 1 Archive")
tc.xcode("UIDREQUIRED")
tc.transactf("bad", "Move 1 Archive")
tc.xcode("UIDREQUIRED")
// Sequence numbers in search program.
tc.transactf("bad", "Uid Search 1")
tc.xcode("UIDREQUIRED")
// Sequence number in last qresync parameter.
tc.transactf("ok", "Enable Qresync")
tc.transactf("bad", "Select inbox (Qresync (1 5 (1,3,6 1,3,6)))")
tc.xcode("UIDREQUIRED")
tc.client.Select("inbox") // Select again.
// Breaks connection.
tc.transactf("bad", "replace 1 inbox {1+}\r\nx")
tc.xcode("UIDREQUIRED")
}

View File

@ -7,10 +7,18 @@ import (
) )
func TestUnselect(t *testing.T) { func TestUnselect(t *testing.T) {
tc := start(t) testUnselect(t, false)
}
func TestUnselectUIDOnly(t *testing.T) {
testUnselect(t, true)
}
func testUnselect(t *testing.T, uidonly bool) {
tc := start(t, uidonly)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc.client.Select("inbox") tc.client.Select("inbox")
tc.transactf("bad", "unselect bogus") // Leftover data. tc.transactf("bad", "unselect bogus") // Leftover data.
@ -19,7 +27,7 @@ func TestUnselect(t *testing.T) {
tc.client.Select("inbox") tc.client.Select("inbox")
tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Append("inbox", makeAppend(exampleMsg))
tc.client.StoreFlagsAdd("1", true, `\Deleted`) tc.client.UIDStoreFlagsAdd("1", true, `\Deleted`)
tc.transactf("ok", "unselect") tc.transactf("ok", "unselect")
tc.transactf("ok", "status inbox (messages)") tc.transactf("ok", "status inbox (messages)")
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusMessages: 1}}) // Message not removed. tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[imapclient.StatusAttr]int64{imapclient.StatusMessages: 1}}) // Message not removed.

View File

@ -2,13 +2,19 @@ package imapserver
import ( import (
"testing" "testing"
"github.com/mjl-/mox/imapclient"
) )
func TestUnsubscribe(t *testing.T) { func TestUnsubscribe(t *testing.T) {
tc := start(t) tc := start(t, false)
defer tc.close() defer tc.close()
tc.client.Login("mjl@mox.example", password0) tc.login("mjl@mox.example", password0)
tc2 := startNoSwitchboard(t, false)
defer tc2.closeNoWait()
tc2.login("mjl@mox.example", password0)
tc.transactf("bad", "unsubscribe") // Missing param. tc.transactf("bad", "unsubscribe") // Missing param.
tc.transactf("bad", "unsubscribe ") // Missing param. tc.transactf("bad", "unsubscribe ") // Missing param.
@ -17,9 +23,16 @@ func TestUnsubscribe(t *testing.T) {
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("ok", "unsubscribe expungebox") // Does not exist anymore but is still subscribed.
tc.transactf("no", "unsubscribe expungebox") // Not subscribed. tc.transactf("no", "unsubscribe expungebox") // Not subscribed.
tc2.transactf("ok", "noop")
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\NonExistent`}, Separator: '/', Mailbox: "expungebox"})
tc.transactf("ok", "create a/b") tc.transactf("ok", "create a/b")
tc2.transactf("ok", "noop")
tc.transactf("ok", "unsubscribe a/b") tc.transactf("ok", "unsubscribe a/b")
tc.transactf("ok", "unsubscribe a/b") // Can unsubscribe even if there is no subscription. tc.transactf("ok", "unsubscribe a/b") // Can unsubscribe even if there is no subscription.
tc2.transactf("ok", "noop")
tc2.xuntagged(imapclient.UntaggedList{Flags: []string(nil), Separator: '/', Mailbox: "a/b"})
tc.transactf("ok", "subscribe a/b") tc.transactf("ok", "subscribe a/b")
tc.transactf("ok", "unsubscribe a/b") tc.transactf("ok", "unsubscribe a/b")
} }

View File

@ -242,7 +242,7 @@ https://www.iana.org/assignments/message-headers/message-headers.xhtml
9208 Partial - IMAP QUOTA Extension 9208 Partial - IMAP QUOTA Extension
9394 Roadmap - IMAP PARTIAL Extension for Paged SEARCH and FETCH 9394 Roadmap - IMAP PARTIAL Extension for Paged SEARCH and FETCH
9585 Yes - IMAP Response Code for Command Progress Notifications 9585 Yes - IMAP Response Code for Command Progress Notifications
9586 Roadmap - IMAP Extension for Using and Returning Unique Identifiers (UIDs) Only 9586 Yes - IMAP Extension for Using and Returning Unique Identifiers (UIDs) Only
9590 Yes - IMAP Extension for Returning Mailbox METADATA in Extended LIST 9590 Yes - IMAP Extension for Returning Mailbox METADATA in Extended LIST
9698 ? - The JMAPACCESS Extension for IMAP 9698 ? - The JMAPACCESS Extension for IMAP
9738 No - IMAP MESSAGELIMIT Extension 9738 No - IMAP MESSAGELIMIT Extension