mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 18:24:35 +03:00
mox!
This commit is contained in:
77
imapserver/append_test.go
Normal file
77
imapserver/append_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestAppend(t *testing.T) {
|
||||
defer mockUIDValidity()()
|
||||
|
||||
tc := start(t) // note: with switchboard because this connection stays alive unlike tc2.
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t) // note: without switchboard because this connection will break during tests.
|
||||
defer tc2.close()
|
||||
|
||||
tc3 := startNoSwitchboard(t)
|
||||
defer tc3.close()
|
||||
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Select("inbox")
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Select("inbox")
|
||||
tc3.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc2.transactf("bad", "append") // Missing params.
|
||||
tc2.transactf("bad", `append inbox`) // Missing message.
|
||||
tc2.transactf("bad", `append inbox "test"`) // Message must be literal.
|
||||
|
||||
// Syntax error for line ending in literal causes connection abort.
|
||||
tc2.transactf("bad", "append inbox (\\Badflag) {1+}\r\nx") // Unknown flag.
|
||||
tc2 = startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
tc2.transactf("bad", "append inbox () \"bad time\" {1+}\r\nx") // Bad time.
|
||||
tc2 = startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
tc2.transactf("no", "append nobox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" {1}")
|
||||
tc2.xcode("TRYCREATE")
|
||||
|
||||
tc2.transactf("ok", "append inbox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" {1+}\r\nx")
|
||||
tc2.xuntagged(imapclient.UntaggedExists(1))
|
||||
tc2.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UID: 1})
|
||||
|
||||
tc.transactf("ok", "noop")
|
||||
uid1 := imapclient.FetchUID(1)
|
||||
flagsSeen := imapclient.FetchFlags{`\Seen`}
|
||||
tc.xuntagged(imapclient.UntaggedExists(1), imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, flagsSeen}})
|
||||
tc3.transactf("ok", "noop")
|
||||
tc3.xuntagged() // Inbox is not selected, nothing to report.
|
||||
|
||||
tc2.transactf("ok", "append inbox (\\Seen) \" 1-Jan-2022 10:10:00 +0100\" UTF8 ({34+}\r\ncontent-type: text/plain;;\r\n\r\ntest)")
|
||||
tc2.xuntagged(imapclient.UntaggedExists(2))
|
||||
tc2.xcodeArg(imapclient.CodeAppendUID{UIDValidity: 1, UID: 2})
|
||||
|
||||
// Messages that we cannot parse are marked as application/octet-stream. Perhaps
|
||||
// the imap client knows how to deal with them.
|
||||
tc2.transactf("ok", "uid fetch 2 body")
|
||||
uid2 := imapclient.FetchUID(2)
|
||||
xbs := imapclient.FetchBodystructure{
|
||||
RespAttr: "BODY",
|
||||
Body: imapclient.BodyTypeBasic{
|
||||
MediaType: "APPLICATION",
|
||||
MediaSubtype: "OCTET-STREAM",
|
||||
BodyFields: imapclient.BodyFields{
|
||||
Octets: 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
tc2.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, xbs}})
|
||||
}
|
110
imapserver/authenticate_test.go
Normal file
110
imapserver/authenticate_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/scram"
|
||||
)
|
||||
|
||||
func TestAuthenticatePlain(t *testing.T) {
|
||||
tc := start(t)
|
||||
|
||||
tc.transactf("no", "authenticate bogus ")
|
||||
tc.transactf("bad", "authenticate plain not base64...")
|
||||
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000baduser\u0000badpass")))
|
||||
tc.xcode("AUTHENTICATIONFAILED")
|
||||
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000badpass")))
|
||||
tc.xcode("AUTHENTICATIONFAILED")
|
||||
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl\u0000badpass"))) // Need email, not account.
|
||||
tc.xcode("AUTHENTICATIONFAILED")
|
||||
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000test")))
|
||||
tc.xcode("AUTHENTICATIONFAILED")
|
||||
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000testtesttest")))
|
||||
tc.xcode("AUTHENTICATIONFAILED")
|
||||
tc.transactf("bad", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000")))
|
||||
tc.xcode("")
|
||||
tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("other\u0000mjl@mox.example\u0000testtest")))
|
||||
tc.xcode("AUTHORIZATIONFAILED")
|
||||
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000testtest")))
|
||||
tc.close()
|
||||
|
||||
tc = start(t)
|
||||
tc.transactf("ok", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("mjl@mox.example\u0000mjl@mox.example\u0000testtest")))
|
||||
tc.close()
|
||||
|
||||
tc = start(t)
|
||||
tc.client.AuthenticatePlain("mjl@mox.example", "testtest")
|
||||
tc.close()
|
||||
|
||||
tc = start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.cmdf("", "authenticate plain")
|
||||
tc.readprefixline("+ ")
|
||||
tc.writelinef("*") // Aborts.
|
||||
tc.readstatus("bad")
|
||||
|
||||
tc.cmdf("", "authenticate plain")
|
||||
tc.readprefixline("+")
|
||||
tc.writelinef("%s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000testtest")))
|
||||
tc.readstatus("ok")
|
||||
}
|
||||
|
||||
func TestAuthenticateSCRAMSHA256(t *testing.T) {
|
||||
tc := start(t)
|
||||
tc.client.AuthenticateSCRAMSHA256("mjl@mox.example", "testtest")
|
||||
tc.close()
|
||||
|
||||
auth := func(status string, serverFinalError error, username, password string) {
|
||||
t.Helper()
|
||||
|
||||
sc := scram.NewClient(username, "")
|
||||
clientFirst, err := sc.ClientFirst()
|
||||
tc.check(err, "scram clientFirst")
|
||||
tc.client.LastTag = "x001"
|
||||
tc.writelinef("%s authenticate scram-sha-256 %s", tc.client.LastTag, base64.StdEncoding.EncodeToString([]byte(clientFirst)))
|
||||
|
||||
xreadContinuation := func() []byte {
|
||||
line, _, result, rerr := tc.client.ReadContinuation()
|
||||
tc.check(rerr, "read continuation")
|
||||
if result.Status != "" {
|
||||
tc.t.Fatalf("expected continuation")
|
||||
}
|
||||
buf, err := base64.StdEncoding.DecodeString(line)
|
||||
tc.check(err, "parsing base64 from remote")
|
||||
return buf
|
||||
}
|
||||
|
||||
serverFirst := xreadContinuation()
|
||||
clientFinal, err := sc.ServerFirst(serverFirst, password)
|
||||
tc.check(err, "scram clientFinal")
|
||||
tc.writelinef("%s", base64.StdEncoding.EncodeToString([]byte(clientFinal)))
|
||||
|
||||
serverFinal := xreadContinuation()
|
||||
err = sc.ServerFinal(serverFinal)
|
||||
if serverFinalError == nil {
|
||||
tc.check(err, "scram serverFinal")
|
||||
} else if err == nil || !errors.Is(err, serverFinalError) {
|
||||
t.Fatalf("server final, got err %#v, expected %#v", err, serverFinalError)
|
||||
}
|
||||
_, result, err := tc.client.Response()
|
||||
tc.check(err, "read response")
|
||||
if string(result.Status) != strings.ToUpper(status) {
|
||||
tc.t.Fatalf("got status %q, expected %q", result.Status, strings.ToUpper(status))
|
||||
}
|
||||
}
|
||||
|
||||
tc = start(t)
|
||||
auth("no", scram.ErrInvalidProof, "mjl@mox.example", "badpass")
|
||||
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.
|
||||
// auth("no", nil, "other@mox.example", "testtest")
|
||||
|
||||
tc.transactf("no", "authenticate bogus ")
|
||||
tc.transactf("bad", "authenticate scram-sha-256 not base64...")
|
||||
tc.transactf("bad", "authenticate scram-sha-256 %s", base64.StdEncoding.EncodeToString([]byte("bad data")))
|
||||
tc.close()
|
||||
}
|
53
imapserver/copy_test.go
Normal file
53
imapserver/copy_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestCopy(t *testing.T) {
|
||||
defer mockUIDValidity()()
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Select("inbox")
|
||||
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Select("Trash")
|
||||
|
||||
tc.transactf("bad", "copy") // Missing params.
|
||||
tc.transactf("bad", "copy 1") // Missing params.
|
||||
tc.transactf("bad", "copy 1 inbox ") // Leftover.
|
||||
|
||||
// Seqs 1,2 and UIDs 3,4.
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.StoreFlagsSet("1:2", true, `\Deleted`)
|
||||
tc.client.Expunge()
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
|
||||
tc.transactf("no", "copy 1 nonexistent")
|
||||
tc.xcode("TRYCREATE")
|
||||
|
||||
tc.transactf("no", "copy 1 inbox") // Cannot copy to same mailbox.
|
||||
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
|
||||
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: ptr(4)}}, To: []imapclient.NumRange{{First: 1, Last: ptr(2)}}})
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedExists(2), imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil)}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), imapclient.FetchFlags(nil)}})
|
||||
|
||||
tc.transactf("no", "uid copy 1,2 Trash") // No match.
|
||||
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)}}})
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedExists(4), imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(3), imapclient.FetchFlags(nil)}}, imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), imapclient.FetchFlags(nil)}})
|
||||
}
|
69
imapserver/create_test.go
Normal file
69
imapserver/create_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.transactf("no", "create inbox") // Already exists and not allowed. ../rfc/9051:1913
|
||||
tc.transactf("no", "create Inbox") // Idem.
|
||||
|
||||
// ../rfc/9051:1937
|
||||
tc.transactf("ok", "create inbox/a/c")
|
||||
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a"}, imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a/c"})
|
||||
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a"}, imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a/c"})
|
||||
|
||||
tc.transactf("no", "create inbox/a/c") // Exists.
|
||||
|
||||
tc.transactf("ok", "create inbox/a/x")
|
||||
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a/x"})
|
||||
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "Inbox/a/x"})
|
||||
|
||||
// ../rfc/9051:1934
|
||||
tc.transactf("ok", "create mailbox/")
|
||||
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "mailbox", OldName: "mailbox/"})
|
||||
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "mailbox"})
|
||||
|
||||
// If we are already subscribed, create should still work, and we still want to see the subscribed flag.
|
||||
tc.transactf("ok", "subscribe newbox")
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`, `\NonExistent`}, Separator: '/', Mailbox: "newbox"})
|
||||
|
||||
tc.transactf("ok", "create newbox")
|
||||
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "newbox"})
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "newbox"})
|
||||
|
||||
// todo: test create+delete+create of a name results in a higher uidvalidity.
|
||||
|
||||
tc.transactf("no", "create /bad/root")
|
||||
tc.transactf("no", "create bad//root") // Cannot have internal duplicate slashes.
|
||||
tc.transactf("no", `create ""`) // Refuse empty mailbox name.
|
||||
// We are not allowing special characters.
|
||||
tc.transactf("bad", `create "\n"`)
|
||||
tc.transactf("bad", `create "\x7f"`)
|
||||
tc.transactf("bad", `create "\x9f"`)
|
||||
tc.transactf("bad", `create "\u2028"`)
|
||||
tc.transactf("bad", `create "\u2029"`)
|
||||
tc.transactf("no", `create "%%"`)
|
||||
tc.transactf("no", `create "*"`)
|
||||
tc.transactf("no", `create "#"`)
|
||||
tc.transactf("no", `create "&"`)
|
||||
}
|
56
imapserver/delete_test.go
Normal file
56
imapserver/delete_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.transactf("bad", "delete") // Missing mailbox.
|
||||
tc.transactf("no", "delete inbox") // Cannot delete inbox.
|
||||
tc.transactf("no", "delete nonexistent") // Cannot delete mailbox that does not exist.
|
||||
tc.transactf("no", `delete "nonexistent"`) // Again, with quoted string syntax.
|
||||
|
||||
tc.client.Subscribe("x")
|
||||
tc.transactf("no", "delete x") // Subscription does not mean there is a mailbox that can be deleted.
|
||||
|
||||
tc.client.Create("a/b")
|
||||
tc2.transactf("ok", "noop") // Drain changes.
|
||||
|
||||
// ../rfc/9051:2000
|
||||
tc.transactf("no", "delete a") // Still has child.
|
||||
tc.xcode("HASCHILDREN")
|
||||
|
||||
tc.transactf("ok", "delete a/b")
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\NonExistent`}, Separator: '/', Mailbox: "a/b"})
|
||||
|
||||
tc.transactf("no", "delete a/b") // Already removed.
|
||||
tc.transactf("ok", "delete a") // Parent can now be removed.
|
||||
tc.transactf("ok", `list (subscribed) "" (a/b a) return (subscribed)`)
|
||||
// Subscriptions still exist.
|
||||
tc.xuntagged(
|
||||
imapclient.UntaggedList{Flags: []string{`\Subscribed`, `\NonExistent`}, Separator: '/', Mailbox: "a"},
|
||||
imapclient.UntaggedList{Flags: []string{`\Subscribed`, `\NonExistent`}, Separator: '/', Mailbox: "a/b"},
|
||||
)
|
||||
|
||||
// Let's try again with a message present.
|
||||
tc.client.Create("msgs")
|
||||
tc.client.Append("msgs", nil, nil, []byte(exampleMsg))
|
||||
tc.transactf("ok", "delete msgs")
|
||||
|
||||
// Delete for inbox/* is allowed.
|
||||
tc.client.Create("inbox/a")
|
||||
tc.transactf("ok", "delete inbox/a")
|
||||
|
||||
}
|
55
imapserver/error.go
Normal file
55
imapserver/error.go
Normal file
@ -0,0 +1,55 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func xcheckf(err error, format string, args ...any) {
|
||||
if err != nil {
|
||||
xserverErrorf("%s: %w", fmt.Sprintf(format, args...), err)
|
||||
}
|
||||
}
|
||||
|
||||
type userError struct {
|
||||
code string // Optional response code in brackets.
|
||||
err error
|
||||
}
|
||||
|
||||
func (e userError) Error() string { return e.err.Error() }
|
||||
func (e userError) Unwrap() error { return e.err }
|
||||
|
||||
func xuserErrorf(format string, args ...any) {
|
||||
panic(userError{err: fmt.Errorf(format, args...)})
|
||||
}
|
||||
|
||||
func xusercodeErrorf(code, format string, args ...any) {
|
||||
panic(userError{code: code, err: fmt.Errorf(format, args...)})
|
||||
}
|
||||
|
||||
type serverError struct{ err error }
|
||||
|
||||
func (e serverError) Error() string { return e.err.Error() }
|
||||
func (e serverError) Unwrap() error { return e.err }
|
||||
|
||||
func xserverErrorf(format string, args ...any) {
|
||||
panic(serverError{fmt.Errorf(format, args...)})
|
||||
}
|
||||
|
||||
type syntaxError struct {
|
||||
line string // Optional line to write before BAD result. For untagged response. CRLF will be added.
|
||||
code string // Optional result code (between []) to write in BAD result.
|
||||
err error // BAD response message.
|
||||
}
|
||||
|
||||
func (e syntaxError) Error() string {
|
||||
s := "bad syntax: " + e.err.Error()
|
||||
if e.code != "" {
|
||||
s += " [" + e.code + "]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
func (e syntaxError) Unwrap() error { return e.err }
|
||||
|
||||
func xsyntaxErrorf(format string, args ...any) {
|
||||
panic(syntaxError{"", "", fmt.Errorf(format, args...)})
|
||||
}
|
74
imapserver/expunge_test.go
Normal file
74
imapserver/expunge_test.go
Normal file
@ -0,0 +1,74 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestExpunge(t *testing.T) {
|
||||
defer mockUIDValidity()()
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Select("inbox")
|
||||
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
tc.transactf("bad", "expunge leftover") // Leftover data.
|
||||
tc.transactf("ok", "expunge") // Nothing to remove though.
|
||||
tc.xuntagged()
|
||||
|
||||
tc.client.Unselect()
|
||||
tc.client.Examine("inbox")
|
||||
tc.transactf("no", "expunge") // Read-only.
|
||||
tc.transactf("no", "uid expunge 1") // Read-only.
|
||||
|
||||
tc.client.Unselect()
|
||||
tc.client.Select("inbox")
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.transactf("ok", "expunge") // Still nothing to remove.
|
||||
tc.xuntagged()
|
||||
|
||||
tc.client.StoreFlagsAdd("1,3", true, `\Deleted`)
|
||||
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
|
||||
tc.transactf("ok", "expunge")
|
||||
tc.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(2))
|
||||
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
tc2.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(2))
|
||||
|
||||
tc.transactf("ok", "expunge") // Nothing to remove anymore.
|
||||
tc.xuntagged()
|
||||
|
||||
// Only UID 2 is still left. We'll add 3 more. Getting us to UIDs 2,4,5,6.
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
|
||||
tc.transactf("bad", "uid expunge") // Missing uid set.
|
||||
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 expunge 1")
|
||||
tc.xuntagged() // No match.
|
||||
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
|
||||
tc.transactf("ok", "uid expunge 4:6") // Removes UID 4,6 at seqs 2,4.
|
||||
tc.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3))
|
||||
|
||||
tc2.transactf("ok", "noop")
|
||||
tc.xuntagged(imapclient.UntaggedExpunge(2), imapclient.UntaggedExpunge(3))
|
||||
}
|
738
imapserver/fetch.go
Normal file
738
imapserver/fetch.go
Normal file
@ -0,0 +1,738 @@
|
||||
package imapserver
|
||||
|
||||
// todo: if fetch fails part-way through the command, we wouldn't be storing the messages that were parsed. should we try harder to get parsed form of messages stored in db?
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/textproto"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/message"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/moxio"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// functions to handle fetch attribute requests are defined on fetchCmd.
|
||||
type fetchCmd struct {
|
||||
conn *conn
|
||||
mailboxID int64
|
||||
uid store.UID
|
||||
tx *bstore.Tx // Writable tx, for storing message when first parsed as mime parts.
|
||||
changes []store.Change // For updated Seen flag.
|
||||
markSeen bool
|
||||
needFlags bool
|
||||
expungeIssued bool // Set if a message cannot be read. Can happen for expunged messages.
|
||||
|
||||
// Loaded when first needed, closed when message was processed.
|
||||
m *store.Message // Message currently being processed.
|
||||
msgr *store.MsgReader
|
||||
part *message.Part
|
||||
}
|
||||
|
||||
// error when processing an attribute. we typically just don't respond with requested attributes that encounter a failure.
|
||||
type attrError struct{ err error }
|
||||
|
||||
func (e attrError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
// raise error processing an attribute.
|
||||
func (cmd *fetchCmd) xerrorf(format string, args ...any) {
|
||||
panic(attrError{fmt.Errorf(format, args...)})
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xcheckf(err error, format string, args ...any) {
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
cmd.xerrorf("%s: %w", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch returns information about messages, be it email envelopes, headers,
|
||||
// bodies, full messages, flags.
|
||||
//
|
||||
// State: Selected
|
||||
func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
|
||||
// Command: ../rfc/9051:4330 ../rfc/3501:2992
|
||||
// Examples: ../rfc/9051:4463 ../rfc/9051:4520
|
||||
// Response syntax: ../rfc/9051:6742 ../rfc/3501:4864
|
||||
|
||||
// Request syntax: ../rfc/9051:6553 ../rfc/3501:4748
|
||||
p.xspace()
|
||||
nums := p.xnumSet()
|
||||
p.xspace()
|
||||
atts := p.xfetchAtts()
|
||||
p.xempty()
|
||||
|
||||
// We don't use c.account.WithRLock because we write to the client while reading messages.
|
||||
// We get the rlock, then we check the mailbox, release the lock and read the messages.
|
||||
// The db transaction still locks out any changes to the database...
|
||||
c.account.RLock()
|
||||
runlock := c.account.RUnlock
|
||||
// Note: we call runlock in a closure because we replace it below.
|
||||
defer func() {
|
||||
runlock()
|
||||
}()
|
||||
|
||||
cmd := &fetchCmd{conn: c, mailboxID: c.mailboxID}
|
||||
c.xdbwrite(func(tx *bstore.Tx) {
|
||||
cmd.tx = tx
|
||||
|
||||
// Ensure the mailbox still exists.
|
||||
c.xmailboxID(tx, c.mailboxID)
|
||||
|
||||
uids := c.xnumSetUIDs(isUID, nums)
|
||||
|
||||
// Release the account lock.
|
||||
runlock()
|
||||
runlock = func() {} // Prevent defer from unlocking again.
|
||||
|
||||
for _, uid := range uids {
|
||||
cmd.uid = uid
|
||||
cmd.process(atts)
|
||||
}
|
||||
})
|
||||
|
||||
if len(cmd.changes) > 0 {
|
||||
// Broadcast seen updates to other connections.
|
||||
c.broadcast(cmd.changes)
|
||||
}
|
||||
|
||||
if cmd.expungeIssued {
|
||||
// ../rfc/2180:343
|
||||
c.writeresultf("%s NO [EXPUNGEISSUED] at least one message was expunged", tag)
|
||||
} else {
|
||||
c.ok(tag, cmdstr)
|
||||
}
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xensureMessage() *store.Message {
|
||||
if cmd.m != nil {
|
||||
return cmd.m
|
||||
}
|
||||
|
||||
q := bstore.QueryTx[store.Message](cmd.tx)
|
||||
q.FilterNonzero(store.Message{MailboxID: cmd.mailboxID, UID: cmd.uid})
|
||||
m, err := q.Get()
|
||||
cmd.xcheckf(err, "get message for uid %d", cmd.uid)
|
||||
cmd.m = &m
|
||||
return cmd.m
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xensureParsed() (*store.MsgReader, *message.Part) {
|
||||
if cmd.msgr != nil {
|
||||
return cmd.msgr, cmd.part
|
||||
}
|
||||
|
||||
m := cmd.xensureMessage()
|
||||
|
||||
cmd.msgr = cmd.conn.account.MessageReader(*m)
|
||||
defer func() {
|
||||
if cmd.part == nil {
|
||||
err := cmd.msgr.Close()
|
||||
cmd.conn.xsanity(err, "closing messagereader")
|
||||
cmd.msgr = nil
|
||||
}
|
||||
}()
|
||||
|
||||
p, err := m.LoadPart(cmd.msgr)
|
||||
xcheckf(err, "load parsed message")
|
||||
cmd.part = &p
|
||||
return cmd.msgr, cmd.part
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) process(atts []fetchAtt) {
|
||||
defer func() {
|
||||
cmd.m = nil
|
||||
cmd.part = nil
|
||||
if cmd.msgr != nil {
|
||||
err := cmd.msgr.Close()
|
||||
cmd.conn.xsanity(err, "closing messagereader")
|
||||
cmd.msgr = nil
|
||||
}
|
||||
|
||||
x := recover()
|
||||
if x == nil {
|
||||
return
|
||||
}
|
||||
err, ok := x.(attrError)
|
||||
if !ok {
|
||||
panic(x)
|
||||
}
|
||||
if errors.Is(err, bstore.ErrAbsent) {
|
||||
cmd.expungeIssued = true
|
||||
return
|
||||
}
|
||||
cmd.conn.log.Infox("processing fetch attribute", err, mlog.Field("uid", cmd.uid))
|
||||
xuserErrorf("processing fetch attribute: %v", err)
|
||||
}()
|
||||
|
||||
data := listspace{bare("UID"), number(cmd.uid)}
|
||||
|
||||
cmd.markSeen = false
|
||||
cmd.needFlags = false
|
||||
|
||||
for _, a := range atts {
|
||||
data = append(data, cmd.xprocessAtt(a)...)
|
||||
}
|
||||
|
||||
if cmd.markSeen {
|
||||
m := cmd.xensureMessage()
|
||||
m.Seen = true
|
||||
err := cmd.tx.Update(m)
|
||||
xcheckf(err, "marking message as seen")
|
||||
|
||||
cmd.changes = append(cmd.changes, store.ChangeFlags{MailboxID: cmd.mailboxID, UID: cmd.uid, Mask: store.Flags{Seen: true}, Flags: m.Flags})
|
||||
}
|
||||
|
||||
if cmd.needFlags {
|
||||
m := cmd.xensureMessage()
|
||||
data = append(data, bare("FLAGS"), flaglist(m.Flags))
|
||||
}
|
||||
|
||||
// Write errors are turned into panics because we write through c.
|
||||
fmt.Fprintf(cmd.conn.bw, "* %d FETCH ", cmd.conn.xsequence(cmd.uid))
|
||||
data.writeTo(cmd.conn, cmd.conn.bw)
|
||||
cmd.conn.bw.Write([]byte("\r\n"))
|
||||
}
|
||||
|
||||
// result for one attribute. if processing fails, e.g. because data was requested
|
||||
// that doesn't exist and cannot be represented in imap, the attribute is simply
|
||||
// not returned to the user. in this case, the returned value is a nil list.
|
||||
func (cmd *fetchCmd) xprocessAtt(a fetchAtt) []token {
|
||||
switch a.field {
|
||||
case "UID":
|
||||
// Always present.
|
||||
return nil
|
||||
case "ENVELOPE":
|
||||
_, part := cmd.xensureParsed()
|
||||
envelope := xenvelope(part)
|
||||
return []token{bare("ENVELOPE"), envelope}
|
||||
|
||||
case "INTERNALDATE":
|
||||
// ../rfc/9051:6753 ../rfc/9051:6502
|
||||
m := cmd.xensureMessage()
|
||||
return []token{bare("INTERNALDATE"), dquote(m.Received.Format("_2-Jan-2006 15:04:05 -0700"))}
|
||||
|
||||
case "BODYSTRUCTURE":
|
||||
_, part := cmd.xensureParsed()
|
||||
bs := xbodystructure(part)
|
||||
return []token{bare("BODYSTRUCTURE"), bs}
|
||||
|
||||
case "BODY":
|
||||
respField, t := cmd.xbody(a)
|
||||
if respField == "" {
|
||||
return nil
|
||||
}
|
||||
return []token{bare(respField), t}
|
||||
|
||||
case "BINARY.SIZE":
|
||||
_, p := cmd.xensureParsed()
|
||||
if len(a.sectionBinary) == 0 {
|
||||
// Must return the size of the entire message but with decoded body.
|
||||
// todo: make this less expensive and/or cache the result?
|
||||
n, err := io.Copy(io.Discard, cmd.xbinaryMessageReader(p))
|
||||
cmd.xcheckf(err, "reading message as binary for its size")
|
||||
return []token{bare(cmd.sectionRespField(a)), number(uint32(n))}
|
||||
}
|
||||
p = cmd.xpartnumsDeref(a.sectionBinary, p)
|
||||
if len(p.Parts) > 0 || p.Message != nil {
|
||||
// ../rfc/9051:4385
|
||||
cmd.xerrorf("binary only allowed on leaf parts, not multipart/* or message/rfc822 or message/global")
|
||||
}
|
||||
return []token{bare(cmd.sectionRespField(a)), number(p.DecodedSize)}
|
||||
|
||||
case "BINARY":
|
||||
respField, t := cmd.xbinary(a)
|
||||
if respField == "" {
|
||||
return nil
|
||||
}
|
||||
return []token{bare(respField), t}
|
||||
|
||||
case "RFC822.SIZE":
|
||||
m := cmd.xensureMessage()
|
||||
return []token{bare("RFC822.SIZE"), number(m.Size)}
|
||||
|
||||
case "RFC822.HEADER":
|
||||
ba := fetchAtt{
|
||||
field: "BODY",
|
||||
peek: true,
|
||||
section: §ionSpec{
|
||||
msgtext: §ionMsgtext{s: "HEADER"},
|
||||
},
|
||||
}
|
||||
respField, t := cmd.xbody(ba)
|
||||
if respField == "" {
|
||||
return nil
|
||||
}
|
||||
return []token{bare(a.field), t}
|
||||
|
||||
case "RFC822":
|
||||
ba := fetchAtt{
|
||||
field: "BODY",
|
||||
section: §ionSpec{},
|
||||
}
|
||||
respField, t := cmd.xbody(ba)
|
||||
if respField == "" {
|
||||
return nil
|
||||
}
|
||||
return []token{bare(a.field), t}
|
||||
|
||||
case "RFC822.TEXT":
|
||||
ba := fetchAtt{
|
||||
field: "BODY",
|
||||
section: §ionSpec{
|
||||
msgtext: §ionMsgtext{s: "TEXT"},
|
||||
},
|
||||
}
|
||||
respField, t := cmd.xbody(ba)
|
||||
if respField == "" {
|
||||
return nil
|
||||
}
|
||||
return []token{bare(a.field), t}
|
||||
|
||||
case "FLAGS":
|
||||
cmd.needFlags = true
|
||||
|
||||
default:
|
||||
xserverErrorf("field %q not yet implemented", a.field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ../rfc/9051:6522
|
||||
func xenvelope(p *message.Part) token {
|
||||
var env message.Envelope
|
||||
if p.Envelope != nil {
|
||||
env = *p.Envelope
|
||||
}
|
||||
var date token = nilt
|
||||
if !env.Date.IsZero() {
|
||||
// ../rfc/5322:791
|
||||
date = string0(env.Date.Format("Mon, 2 Jan 2006 15:04:05 -0700"))
|
||||
}
|
||||
var subject token = nilt
|
||||
if env.Subject != "" {
|
||||
subject = string0(env.Subject)
|
||||
}
|
||||
var inReplyTo token = nilt
|
||||
if env.InReplyTo != "" {
|
||||
inReplyTo = string0(env.InReplyTo)
|
||||
}
|
||||
var messageID token = nilt
|
||||
if env.MessageID != "" {
|
||||
messageID = string0(env.MessageID)
|
||||
}
|
||||
|
||||
addresses := func(l []message.Address) token {
|
||||
if len(l) == 0 {
|
||||
return nilt
|
||||
}
|
||||
r := listspace{}
|
||||
for _, a := range l {
|
||||
var name token = nilt
|
||||
if a.Name != "" {
|
||||
name = string0(a.Name)
|
||||
}
|
||||
user := string0(a.User)
|
||||
var host token = nilt
|
||||
if a.Host != "" {
|
||||
host = string0(a.Host)
|
||||
}
|
||||
r = append(r, listspace{name, nilt, user, host})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Empty sender or reply-to result in fall-back to from. ../rfc/9051:6140
|
||||
sender := env.Sender
|
||||
if len(sender) == 0 {
|
||||
sender = env.From
|
||||
}
|
||||
replyTo := env.ReplyTo
|
||||
if len(replyTo) == 0 {
|
||||
replyTo = env.From
|
||||
}
|
||||
|
||||
return listspace{
|
||||
date,
|
||||
subject,
|
||||
addresses(env.From),
|
||||
addresses(sender),
|
||||
addresses(replyTo),
|
||||
addresses(env.To),
|
||||
addresses(env.CC),
|
||||
addresses(env.BCC),
|
||||
inReplyTo,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) peekOrSeen(peek bool) {
|
||||
if cmd.conn.readonly || peek {
|
||||
return
|
||||
}
|
||||
m := cmd.xensureMessage()
|
||||
if !m.Seen {
|
||||
cmd.markSeen = true
|
||||
cmd.needFlags = true
|
||||
}
|
||||
}
|
||||
|
||||
// reader that returns the message, but with header Content-Transfer-Encoding left out.
|
||||
func (cmd *fetchCmd) xbinaryMessageReader(p *message.Part) io.Reader {
|
||||
hr := cmd.xmodifiedHeader(p, []string{"Content-Transfer-Encoding"}, true)
|
||||
return io.MultiReader(hr, p.Reader())
|
||||
}
|
||||
|
||||
// return header with only fields, or with everything except fields if "not" is set.
|
||||
func (cmd *fetchCmd) xmodifiedHeader(p *message.Part, fields []string, not bool) io.Reader {
|
||||
h, err := io.ReadAll(p.HeaderReader())
|
||||
cmd.xcheckf(err, "reading header")
|
||||
|
||||
matchesFields := func(line []byte) bool {
|
||||
k := bytes.TrimRight(bytes.SplitN(line, []byte(":"), 2)[0], " \t")
|
||||
for _, f := range fields {
|
||||
if bytes.EqualFold(k, []byte(f)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var match bool
|
||||
hb := &bytes.Buffer{}
|
||||
for len(h) > 0 {
|
||||
line := h
|
||||
i := bytes.Index(line, []byte("\r\n"))
|
||||
if i >= 0 {
|
||||
line = line[:i+2]
|
||||
}
|
||||
h = h[len(line):]
|
||||
|
||||
match = matchesFields(line) || match && (bytes.HasPrefix(line, []byte(" ")) || bytes.HasPrefix(line, []byte("\t")))
|
||||
if match != not || len(line) == 2 {
|
||||
hb.Write(line)
|
||||
}
|
||||
}
|
||||
return hb
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xbinary(a fetchAtt) (string, token) {
|
||||
_, part := cmd.xensureParsed()
|
||||
|
||||
cmd.peekOrSeen(a.peek)
|
||||
if len(a.sectionBinary) == 0 {
|
||||
r := cmd.xbinaryMessageReader(part)
|
||||
if a.partial != nil {
|
||||
r = cmd.xpartialReader(a.partial, r)
|
||||
}
|
||||
return cmd.sectionRespField(a), readerSyncliteral{r}
|
||||
}
|
||||
|
||||
p := part
|
||||
if len(a.sectionBinary) > 0 {
|
||||
p = cmd.xpartnumsDeref(a.sectionBinary, p)
|
||||
}
|
||||
if len(p.Parts) != 0 || p.Message != nil {
|
||||
// ../rfc/9051:4385
|
||||
cmd.xerrorf("binary only allowed on leaf parts, not multipart/* or message/rfc822 or message/global")
|
||||
}
|
||||
|
||||
switch p.ContentTransferEncoding {
|
||||
case "", "7BIT", "8BIT", "BINARY", "BASE64", "QUOTED-PRINTABLE":
|
||||
default:
|
||||
// ../rfc/9051:5913
|
||||
xusercodeErrorf("UNKNOWN-CTE", "unknown Content-Transfer-Encoding %q", p.ContentTransferEncoding)
|
||||
}
|
||||
|
||||
r := p.Reader()
|
||||
if a.partial != nil {
|
||||
r = cmd.xpartialReader(a.partial, r)
|
||||
}
|
||||
return cmd.sectionRespField(a), readerSyncliteral{r}
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xpartialReader(partial *partial, r io.Reader) io.Reader {
|
||||
n, err := io.Copy(io.Discard, io.LimitReader(r, int64(partial.offset)))
|
||||
cmd.xcheckf(err, "skipping to offset for partial")
|
||||
if n != int64(partial.offset) {
|
||||
return strings.NewReader("") // ../rfc/3501:3143 ../rfc/9051:4418
|
||||
}
|
||||
return io.LimitReader(r, int64(partial.count))
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xbody(a fetchAtt) (string, token) {
|
||||
msgr, part := cmd.xensureParsed()
|
||||
|
||||
if a.section == nil {
|
||||
// Non-extensible form of BODYSTRUCTURE.
|
||||
return a.field, xbodystructure(part)
|
||||
}
|
||||
|
||||
cmd.peekOrSeen(a.peek)
|
||||
|
||||
respField := cmd.sectionRespField(a)
|
||||
|
||||
if a.section.msgtext == nil && a.section.part == nil {
|
||||
m := cmd.xensureMessage()
|
||||
var offset int64
|
||||
count := m.Size
|
||||
if a.partial != nil {
|
||||
offset = int64(a.partial.offset)
|
||||
if offset > m.Size {
|
||||
offset = m.Size
|
||||
}
|
||||
count = int64(a.partial.count)
|
||||
if offset+count > m.Size {
|
||||
count = m.Size - offset
|
||||
}
|
||||
}
|
||||
return respField, readerSizeSyncliteral{&moxio.AtReader{R: msgr, Offset: offset}, count}
|
||||
}
|
||||
|
||||
sr := cmd.xsection(a.section, part)
|
||||
|
||||
if a.partial != nil {
|
||||
n, err := io.Copy(io.Discard, io.LimitReader(sr, int64(a.partial.offset)))
|
||||
cmd.xcheckf(err, "skipping to offset for partial")
|
||||
if n != int64(a.partial.offset) {
|
||||
return respField, syncliteral("") // ../rfc/3501:3143 ../rfc/9051:4418
|
||||
}
|
||||
return respField, readerSyncliteral{io.LimitReader(sr, int64(a.partial.count))}
|
||||
}
|
||||
return respField, readerSyncliteral{sr}
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xpartnumsDeref(nums []uint32, p *message.Part) *message.Part {
|
||||
// ../rfc/9051:4481
|
||||
if (len(p.Parts) == 0 && p.Message == nil) && len(nums) == 1 && nums[0] == 1 {
|
||||
return p
|
||||
}
|
||||
|
||||
// ../rfc/9051:4485
|
||||
for i, num := range nums {
|
||||
index := int(num - 1)
|
||||
if p.Message != nil {
|
||||
err := p.SetMessageReaderAt()
|
||||
cmd.xcheckf(err, "preparing submessage")
|
||||
return cmd.xpartnumsDeref(nums[i:], p.Message)
|
||||
}
|
||||
if index < 0 || index >= len(p.Parts) {
|
||||
cmd.xerrorf("requested part does not exist")
|
||||
}
|
||||
p = &p.Parts[index]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader {
|
||||
if section.part == nil {
|
||||
return cmd.xsectionMsgtext(section.msgtext, p)
|
||||
}
|
||||
|
||||
p = cmd.xpartnumsDeref(section.part.part, p)
|
||||
|
||||
if section.part.text == nil {
|
||||
return p.RawReader()
|
||||
}
|
||||
|
||||
// ../rfc/9051:4535
|
||||
if p.Message != nil {
|
||||
err := p.SetMessageReaderAt()
|
||||
cmd.xcheckf(err, "preparing submessage")
|
||||
p = p.Message
|
||||
}
|
||||
|
||||
if !section.part.text.mime {
|
||||
return cmd.xsectionMsgtext(section.part.text.msgtext, p)
|
||||
}
|
||||
|
||||
// MIME header, see ../rfc/9051:4534 ../rfc/2045:1645
|
||||
h, err := io.ReadAll(p.HeaderReader())
|
||||
cmd.xcheckf(err, "reading header")
|
||||
|
||||
matchesFields := func(line []byte) bool {
|
||||
k := textproto.CanonicalMIMEHeaderKey(string(bytes.TrimRight(bytes.SplitN(line, []byte(":"), 2)[0], " \t")))
|
||||
// Only add MIME-Version and additional CRLF for messages, not other parts. ../rfc/2045:1645 ../rfc/2045:1652
|
||||
return (p.Envelope != nil && k == "Mime-Version") || strings.HasPrefix(k, "Content-")
|
||||
}
|
||||
|
||||
var match bool
|
||||
hb := &bytes.Buffer{}
|
||||
for len(h) > 0 {
|
||||
line := h
|
||||
i := bytes.Index(line, []byte("\r\n"))
|
||||
if i >= 0 {
|
||||
line = line[:i+2]
|
||||
}
|
||||
h = h[len(line):]
|
||||
|
||||
match = matchesFields(line) || match && (bytes.HasPrefix(line, []byte(" ")) || bytes.HasPrefix(line, []byte("\t")))
|
||||
if match || len(line) == 2 {
|
||||
hb.Write(line)
|
||||
}
|
||||
}
|
||||
return hb
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) xsectionMsgtext(smt *sectionMsgtext, p *message.Part) io.Reader {
|
||||
if smt.s == "HEADER" {
|
||||
return p.HeaderReader()
|
||||
}
|
||||
|
||||
switch smt.s {
|
||||
case "HEADER.FIELDS":
|
||||
return cmd.xmodifiedHeader(p, smt.headers, false)
|
||||
|
||||
case "HEADER.FIELDS.NOT":
|
||||
return cmd.xmodifiedHeader(p, smt.headers, true)
|
||||
|
||||
case "TEXT":
|
||||
// It appears imap clients expect to get the body of the message, not a "text body"
|
||||
// which sounds like it means a text/* part of a message. ../rfc/9051:4517
|
||||
return p.RawReader()
|
||||
}
|
||||
panic(serverError{fmt.Errorf("missing case")})
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) sectionRespField(a fetchAtt) string {
|
||||
s := a.field + "["
|
||||
if len(a.sectionBinary) > 0 {
|
||||
s += fmt.Sprintf("%d", a.sectionBinary[0])
|
||||
for _, v := range a.sectionBinary[1:] {
|
||||
s += "." + fmt.Sprintf("%d", v)
|
||||
}
|
||||
} else if a.section != nil {
|
||||
if a.section.part != nil {
|
||||
p := a.section.part
|
||||
s += fmt.Sprintf("%d", p.part[0])
|
||||
for _, v := range p.part[1:] {
|
||||
s += "." + fmt.Sprintf("%d", v)
|
||||
}
|
||||
if p.text != nil {
|
||||
if p.text.mime {
|
||||
s += ".MIME"
|
||||
} else {
|
||||
s += "." + cmd.sectionMsgtextName(p.text.msgtext)
|
||||
}
|
||||
}
|
||||
} else if a.section.msgtext != nil {
|
||||
s += cmd.sectionMsgtextName(a.section.msgtext)
|
||||
}
|
||||
}
|
||||
s += "]"
|
||||
// binary does not have partial in field, unlike BODY ../rfc/9051:6757
|
||||
if a.field != "BINARY" && a.partial != nil {
|
||||
s += fmt.Sprintf("<%d>", a.partial.offset)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (cmd *fetchCmd) sectionMsgtextName(smt *sectionMsgtext) string {
|
||||
s := smt.s
|
||||
if strings.HasPrefix(smt.s, "HEADER.FIELDS") {
|
||||
l := listspace{}
|
||||
for _, h := range smt.headers {
|
||||
l = append(l, astring(h))
|
||||
}
|
||||
s += " " + l.pack(cmd.conn)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func bodyFldParams(params map[string]string) token {
|
||||
if len(params) == 0 {
|
||||
return nilt
|
||||
}
|
||||
// Ensure same ordering, easier for testing.
|
||||
var keys []string
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
l := make(listspace, 2*len(keys))
|
||||
i := 0
|
||||
for _, k := range keys {
|
||||
l[i] = string0(strings.ToUpper(k))
|
||||
l[i+1] = string0(params[k])
|
||||
i += 2
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func bodyFldEnc(s string) token {
|
||||
up := strings.ToUpper(s)
|
||||
switch up {
|
||||
case "7BIT", "8BIT", "BINARY", "BASE64", "QUOTED-PRINTABLE":
|
||||
return dquote(up)
|
||||
}
|
||||
return string0(s)
|
||||
}
|
||||
|
||||
// xbodystructure returns a "body".
|
||||
// calls itself for multipart messages and message/{rfc822,global}.
|
||||
func xbodystructure(p *message.Part) token {
|
||||
if p.MediaType == "MULTIPART" {
|
||||
// Multipart, ../rfc/9051:6355 ../rfc/9051:6411
|
||||
var bodies concat
|
||||
for i := range p.Parts {
|
||||
bodies = append(bodies, xbodystructure(&p.Parts[i]))
|
||||
}
|
||||
return listspace{bodies, string0(p.MediaSubType)}
|
||||
}
|
||||
|
||||
// ../rfc/9051:6355
|
||||
if p.MediaType == "TEXT" {
|
||||
// ../rfc/9051:6404 ../rfc/9051:6418
|
||||
return listspace{
|
||||
dquote("TEXT"), string0(p.MediaSubType), // ../rfc/9051:6739
|
||||
// ../rfc/9051:6376
|
||||
bodyFldParams(p.ContentTypeParams), // ../rfc/9051:6401
|
||||
nilOrString(p.ContentID),
|
||||
nilOrString(p.ContentDescription),
|
||||
bodyFldEnc(p.ContentTransferEncoding),
|
||||
number(p.EndOffset - p.BodyOffset),
|
||||
number(p.RawLineCount),
|
||||
}
|
||||
} else if p.MediaType == "MESSAGE" && (p.MediaSubType == "RFC822" || p.MediaSubType == "GLOBAL") {
|
||||
// ../rfc/9051:6415
|
||||
// note: we don't have to prepare p.Message for reading, because we aren't going to read from it.
|
||||
return listspace{
|
||||
dquote("MESSAGE"), dquote(p.MediaSubType), // ../rfc/9051:6732
|
||||
// ../rfc/9051:6376
|
||||
bodyFldParams(p.ContentTypeParams), // ../rfc/9051:6401
|
||||
nilOrString(p.ContentID),
|
||||
nilOrString(p.ContentDescription),
|
||||
bodyFldEnc(p.ContentTransferEncoding),
|
||||
number(p.EndOffset - p.BodyOffset),
|
||||
xenvelope(p.Message),
|
||||
xbodystructure(p.Message),
|
||||
number(p.RawLineCount), // todo: or mp.RawLineCount?
|
||||
}
|
||||
}
|
||||
var media token
|
||||
switch p.MediaType {
|
||||
case "APPLICATION", "AUDIO", "IMAGE", "FONT", "MESSAGE", "MODEL", "VIDEO":
|
||||
media = dquote(p.MediaType)
|
||||
default:
|
||||
media = string0(p.MediaType)
|
||||
}
|
||||
// ../rfc/9051:6404 ../rfc/9051:6407
|
||||
return listspace{
|
||||
media, string0(p.MediaSubType), // ../rfc/9051:6723
|
||||
// ../rfc/9051:6376
|
||||
bodyFldParams(p.ContentTypeParams), // ../rfc/9051:6401
|
||||
nilOrString(p.ContentID),
|
||||
nilOrString(p.ContentDescription),
|
||||
bodyFldEnc(p.ContentTransferEncoding),
|
||||
number(p.EndOffset - p.BodyOffset),
|
||||
}
|
||||
}
|
403
imapserver/fetch_test.go
Normal file
403
imapserver/fetch_test.go
Normal file
@ -0,0 +1,403 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestFetch(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Enable("imap4rev2")
|
||||
received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00")
|
||||
tc.check(err, "parse time")
|
||||
tc.client.Append("inbox", nil, &received, []byte(exampleMsg))
|
||||
tc.client.Select("inbox")
|
||||
|
||||
uid1 := imapclient.FetchUID(1)
|
||||
date1 := imapclient.FetchInternalDate("16-Nov-2022 10:01:00 +0100")
|
||||
rfcsize1 := imapclient.FetchRFC822Size(len(exampleMsg))
|
||||
env1 := imapclient.FetchEnvelope{
|
||||
Date: "Mon, 7 Feb 1994 21:52:25 -0800",
|
||||
Subject: "afternoon meeting",
|
||||
From: []imapclient.Address{{Name: "Fred Foobar", Mailbox: "foobar", Host: "blurdybloop.example"}},
|
||||
Sender: []imapclient.Address{{Name: "Fred Foobar", Mailbox: "foobar", Host: "blurdybloop.example"}},
|
||||
ReplyTo: []imapclient.Address{{Name: "Fred Foobar", Mailbox: "foobar", Host: "blurdybloop.example"}},
|
||||
To: []imapclient.Address{{Mailbox: "mooch", Host: "owatagu.siam.edu.example"}},
|
||||
MessageID: "<B27397-0100000@Blurdybloop.example>",
|
||||
}
|
||||
noflags := imapclient.FetchFlags(nil)
|
||||
bodyxstructure1 := imapclient.FetchBodystructure{
|
||||
RespAttr: "BODY",
|
||||
Body: imapclient.BodyTypeText{
|
||||
MediaType: "TEXT",
|
||||
MediaSubtype: "PLAIN",
|
||||
BodyFields: imapclient.BodyFields{
|
||||
Params: [][2]string{[...]string{"CHARSET", "US-ASCII"}},
|
||||
Octets: 57,
|
||||
},
|
||||
Lines: 2,
|
||||
},
|
||||
}
|
||||
bodystructure1 := bodyxstructure1
|
||||
bodystructure1.RespAttr = "BODYSTRUCTURE"
|
||||
|
||||
split := strings.SplitN(exampleMsg, "\r\n\r\n", 2)
|
||||
exampleMsgHeader := split[0] + "\r\n\r\n"
|
||||
exampleMsgBody := split[1]
|
||||
|
||||
binary1 := imapclient.FetchBinary{RespAttr: "BINARY[]", Data: exampleMsg}
|
||||
binarypart1 := imapclient.FetchBinary{RespAttr: "BINARY[1]", Parts: []uint32{1}, Data: exampleMsgBody}
|
||||
binarypartial1 := imapclient.FetchBinary{RespAttr: "BINARY[]", Data: exampleMsg[1:2]}
|
||||
binarypartpartial1 := imapclient.FetchBinary{RespAttr: "BINARY[1]", Parts: []uint32{1}, Data: exampleMsgBody[1:2]}
|
||||
binaryend1 := imapclient.FetchBinary{RespAttr: "BINARY[]", Data: ""}
|
||||
binarypartend1 := imapclient.FetchBinary{RespAttr: "BINARY[1]", Parts: []uint32{1}, Data: ""}
|
||||
binarysize1 := imapclient.FetchBinarySize{RespAttr: "BINARY.SIZE[]", Size: int64(len(exampleMsg))}
|
||||
binarysizepart1 := imapclient.FetchBinarySize{RespAttr: "BINARY.SIZE[1]", Parts: []uint32{1}, Size: int64(len(exampleMsgBody))}
|
||||
bodyheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER]", Section: "HEADER", Body: exampleMsgHeader}
|
||||
bodytext1 := imapclient.FetchBody{RespAttr: "BODY[TEXT]", Section: "TEXT", Body: exampleMsgBody}
|
||||
body1 := imapclient.FetchBody{RespAttr: "BODY[]", Body: exampleMsg}
|
||||
bodypart1 := imapclient.FetchBody{RespAttr: "BODY[1]", Section: "1", Body: exampleMsgBody}
|
||||
bodyoff1 := imapclient.FetchBody{RespAttr: "BODY[]<1>", Section: "", Offset: 1, Body: exampleMsg[1:3]}
|
||||
body1off1 := imapclient.FetchBody{RespAttr: "BODY[1]<1>", Section: "1", Offset: 1, Body: exampleMsgBody[1:3]}
|
||||
bodyend1 := imapclient.FetchBody{RespAttr: "BODY[1]<100000>", Section: "1", Offset: 100000, Body: ""} // todo: should offset be what was requested, or the size of the message?
|
||||
rfcheader1 := imapclient.FetchRFC822Header(exampleMsgHeader)
|
||||
rfctext1 := imapclient.FetchRFC822Text(exampleMsgBody)
|
||||
rfc1 := imapclient.FetchRFC822(exampleMsg)
|
||||
headerSplit := strings.SplitN(exampleMsgHeader, "\r\n", 2)
|
||||
dateheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER.FIELDS (Date)]", Section: "HEADER.FIELDS (Date)", Body: headerSplit[0] + "\r\n\r\n"}
|
||||
nodateheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER.FIELDS.NOT (Date)]", Section: "HEADER.FIELDS.NOT (Date)", Body: headerSplit[1]}
|
||||
date1header1 := imapclient.FetchBody{RespAttr: "BODY[1.HEADER.FIELDS (Date)]", Section: "1.HEADER.FIELDS (Date)", Body: headerSplit[0] + "\r\n\r\n"}
|
||||
nodate1header1 := imapclient.FetchBody{RespAttr: "BODY[1.HEADER.FIELDS.NOT (Date)]", Section: "1.HEADER.FIELDS.NOT (Date)", Body: headerSplit[1]}
|
||||
mime1 := imapclient.FetchBody{RespAttr: "BODY[1.MIME]", Section: "1.MIME", Body: "MIME-Version: 1.0\r\nContent-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n\r\n"}
|
||||
|
||||
flagsSeen := imapclient.FetchFlags{`\Seen`}
|
||||
|
||||
tc.transactf("ok", "fetch 1 all")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, env1, noflags}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 fast")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, noflags}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 full")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1, rfcsize1, env1, bodyxstructure1, noflags}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 flags")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 bodystructure")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}})
|
||||
|
||||
// Should be returned unmodified, because there is no content-transfer-encoding.
|
||||
tc.transactf("ok", "fetch 1 binary[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1, flagsSeen}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 binary[1]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypart1}}) // Seen flag not changed.
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 binary[]<1.1>")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartial1, flagsSeen}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 binary[1]<1.1>")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartpartial1, flagsSeen}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 binary[]<10000.10001>")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binaryend1, flagsSeen}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 binary[1]<10000.10001>")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarypartend1, flagsSeen}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 binary.size[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarysize1}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 binary.size[1]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binarysizepart1}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 body[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, flagsSeen}})
|
||||
tc.transactf("ok", "fetch 1 body[]<1.2>")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyoff1}}) // Already seen.
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 body[1]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodypart1, flagsSeen}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 body[1]<1.2>")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1off1, flagsSeen}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 body[1]<100000.100000>")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyend1, flagsSeen}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 body[header]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodyheader1, flagsSeen}})
|
||||
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 body[text]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodytext1, flagsSeen}})
|
||||
|
||||
// equivalent to body.peek[header], ../rfc/3501:3183
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 rfc822.header")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfcheader1}})
|
||||
|
||||
// equivalent to body[text], ../rfc/3501:3199
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 rfc822.text")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfctext1, flagsSeen}})
|
||||
|
||||
// equivalent to body[], ../rfc/3501:3179
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 rfc822")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfc1, flagsSeen}})
|
||||
|
||||
// With PEEK, we should not get the \Seen flag.
|
||||
tc.client.StoreFlagsClear("1", true, `\Seen`)
|
||||
tc.transactf("ok", "fetch 1 body.peek[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 binary.peek[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1}})
|
||||
|
||||
// HEADER.FIELDS and .NOT
|
||||
tc.transactf("ok", "fetch 1 body.peek[header.fields (date)]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, dateheader1}})
|
||||
tc.transactf("ok", "fetch 1 body.peek[header.fields.not (date)]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, nodateheader1}})
|
||||
// For non-multipart messages, 1 means the whole message. ../rfc/9051:4481
|
||||
tc.transactf("ok", "fetch 1 body.peek[1.header.fields (date)]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1header1}})
|
||||
tc.transactf("ok", "fetch 1 body.peek[1.header.fields.not (date)]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, nodate1header1}})
|
||||
|
||||
// MIME, part 1 for non-multipart messages is the message itself. ../rfc/9051:4481
|
||||
tc.transactf("ok", "fetch 1 body.peek[1.mime]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, mime1}})
|
||||
|
||||
// Missing sequence number. ../rfc/9051:7018
|
||||
tc.transactf("bad", "fetch 2 body[]")
|
||||
|
||||
tc.transactf("ok", "fetch 1:1 body[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1, flagsSeen}})
|
||||
|
||||
// UID fetch
|
||||
tc.transactf("ok", "uid fetch 1 body[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}})
|
||||
|
||||
// UID fetch
|
||||
tc.transactf("ok", "uid fetch 2 body[]")
|
||||
tc.xuntagged()
|
||||
|
||||
// Test some invalid syntax.
|
||||
tc.transactf("bad", "fetch")
|
||||
tc.transactf("bad", "fetch ")
|
||||
tc.transactf("bad", "fetch ")
|
||||
tc.transactf("bad", "fetch 1") // At least one requested item required.
|
||||
tc.transactf("bad", "fetch 1 ()") // Empty list not allowed
|
||||
tc.transactf("bad", "fetch 1 unknown")
|
||||
tc.transactf("bad", "fetch 1 (unknown)")
|
||||
tc.transactf("bad", "fetch 1 (all)") // Macro's not allowed in list.
|
||||
tc.transactf("bad", "fetch 1 binary") // [] required
|
||||
tc.transactf("bad", "fetch 1 binary[text]") // Text/header etc only allowed for body[].
|
||||
tc.transactf("bad", "fetch 1 binary[]<1>") // Count required.
|
||||
tc.transactf("bad", "fetch 1 binary[]<1.0>") // Count must be > 0.
|
||||
tc.transactf("bad", "fetch 1 binary[]<1..1>") // Single dot.
|
||||
tc.transactf("bad", "fetch 1 body[]<1>") // Count required.
|
||||
tc.transactf("bad", "fetch 1 body[]<1.0>") // Count must be > 0.
|
||||
tc.transactf("bad", "fetch 1 body[]<1..1>") // Single dot.
|
||||
tc.transactf("bad", "fetch 1 body[header.fields]") // List of headers required.
|
||||
tc.transactf("bad", "fetch 1 body[header.fields ()]") // List must be non-empty.
|
||||
tc.transactf("bad", "fetch 1 body[header.fields.not]") // List of headers required.
|
||||
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("no", "fetch 1 body[2]") // No such part.
|
||||
|
||||
// Add more complex message.
|
||||
|
||||
uid2 := imapclient.FetchUID(2)
|
||||
bodystructure2 := imapclient.FetchBodystructure{
|
||||
RespAttr: "BODYSTRUCTURE",
|
||||
Body: imapclient.BodyTypeMpart{
|
||||
Bodies: []any{
|
||||
imapclient.BodyTypeBasic{BodyFields: imapclient.BodyFields{Octets: 275}},
|
||||
imapclient.BodyTypeText{MediaType: "TEXT", MediaSubtype: "PLAIN", BodyFields: imapclient.BodyFields{Params: [][2]string{{"CHARSET", "US-ASCII"}}, Octets: 114}, Lines: 3},
|
||||
imapclient.BodyTypeMpart{
|
||||
Bodies: []any{
|
||||
imapclient.BodyTypeBasic{MediaType: "AUDIO", MediaSubtype: "BASIC", BodyFields: imapclient.BodyFields{CTE: "BASE64", Octets: 22}},
|
||||
imapclient.BodyTypeBasic{MediaType: "IMAGE", MediaSubtype: "JPEG", BodyFields: imapclient.BodyFields{CTE: "BASE64"}},
|
||||
},
|
||||
MediaSubtype: "PARALLEL",
|
||||
},
|
||||
imapclient.BodyTypeText{MediaType: "TEXT", MediaSubtype: "ENRICHED", BodyFields: imapclient.BodyFields{Octets: 145}, Lines: 5},
|
||||
imapclient.BodyTypeMsg{
|
||||
MediaType: "MESSAGE",
|
||||
MediaSubtype: "RFC822",
|
||||
BodyFields: imapclient.BodyFields{Octets: 228},
|
||||
Envelope: imapclient.Envelope{
|
||||
Subject: "(subject in US-ASCII)",
|
||||
From: []imapclient.Address{{Name: "", Adl: "", Mailbox: "info", Host: "mox.example"}},
|
||||
Sender: []imapclient.Address{{Name: "", Adl: "", Mailbox: "info", Host: "mox.example"}},
|
||||
ReplyTo: []imapclient.Address{{Name: "", Adl: "", Mailbox: "info", Host: "mox.example"}},
|
||||
To: []imapclient.Address{{Name: "mox", Adl: "", Mailbox: "info", Host: "mox.example"}},
|
||||
},
|
||||
Bodystructure: imapclient.BodyTypeText{
|
||||
MediaType: "TEXT", MediaSubtype: "PLAIN", BodyFields: imapclient.BodyFields{Params: [][2]string{{"CHARSET", "ISO-8859-1"}}, CTE: "QUOTED-PRINTABLE", Octets: 51}, Lines: 1},
|
||||
Lines: 7,
|
||||
},
|
||||
},
|
||||
MediaSubtype: "MIXED",
|
||||
},
|
||||
}
|
||||
tc.client.Append("inbox", nil, &received, []byte(nestedMessage))
|
||||
tc.transactf("ok", "fetch 2 bodystructure")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
|
||||
|
||||
// Multiple responses.
|
||||
tc.transactf("ok", "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.transactf("ok", "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.transactf("ok", "fetch 2:1 bodystructure")
|
||||
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.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.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, bodystructure1}}, imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
|
||||
tc.transactf("ok", "fetch *:2 bodystructure")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
|
||||
|
||||
tc.transactf("ok", "fetch * bodystructure") // Highest msgseq.
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
|
||||
|
||||
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.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.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.transactf("ok", "uid fetch 2:2 bodystructure")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}})
|
||||
|
||||
// todo: read the bodies/headers of the parts, and of the nested message.
|
||||
tc.transactf("ok", "fetch 2 body.peek[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[]", Body: nestedMessage}}})
|
||||
|
||||
part1 := tocrlf(` ... Some text appears here ...
|
||||
|
||||
[Note that the blank between the boundary and the start
|
||||
of the text in this part means no header fields were
|
||||
given and this is text in the US-ASCII character set.
|
||||
It could have been done with explicit typing as in the
|
||||
next part.]
|
||||
`)
|
||||
tc.transactf("ok", "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.transactf("no", "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.
|
||||
|
||||
part31 := "aGVsbG8NCndvcmxkDQo=\r\n"
|
||||
part31dec := "hello\r\nworld\r\n"
|
||||
tc.transactf("ok", "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.transactf("ok", "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.transactf("ok", "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}}})
|
||||
|
||||
part3 := tocrlf(`--unique-boundary-2
|
||||
Content-Type: audio/basic
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
aGVsbG8NCndvcmxkDQo=
|
||||
|
||||
--unique-boundary-2
|
||||
Content-Type: image/jpeg
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
|
||||
--unique-boundary-2--
|
||||
|
||||
`)
|
||||
tc.transactf("ok", "fetch 2 body.peek[3]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[3]", Section: "3", Body: part3}}})
|
||||
|
||||
part2mime := tocrlf(`Content-type: text/plain; charset=US-ASCII
|
||||
|
||||
`)
|
||||
tc.transactf("ok", "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}}})
|
||||
|
||||
part5 := tocrlf(`From: info@mox.example
|
||||
To: mox <info@mox.example>
|
||||
Subject: (subject in US-ASCII)
|
||||
Content-Type: Text/plain; charset=ISO-8859-1
|
||||
Content-Transfer-Encoding: Quoted-printable
|
||||
|
||||
... Additional text in ISO-8859-1 goes here ...
|
||||
`)
|
||||
tc.transactf("ok", "fetch 2 body.peek[5]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5]", Section: "5", Body: part5}}})
|
||||
|
||||
part5header := tocrlf(`From: info@mox.example
|
||||
To: mox <info@mox.example>
|
||||
Subject: (subject in US-ASCII)
|
||||
Content-Type: Text/plain; charset=ISO-8859-1
|
||||
Content-Transfer-Encoding: Quoted-printable
|
||||
|
||||
`)
|
||||
tc.transactf("ok", "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}}})
|
||||
|
||||
part5mime := tocrlf(`Content-Type: Text/plain; charset=ISO-8859-1
|
||||
Content-Transfer-Encoding: Quoted-printable
|
||||
|
||||
`)
|
||||
tc.transactf("ok", "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}}})
|
||||
|
||||
part5text := " ... Additional text in ISO-8859-1 goes here ...\r\n"
|
||||
tc.transactf("ok", "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.transactf("ok", "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: part5text}}})
|
||||
|
||||
// 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.Unselect()
|
||||
tc.client.Examine("inbox")
|
||||
|
||||
tc.transactf("ok", "fetch 1 binary[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, binary1}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 body[]")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, body1}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 rfc822.text")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfctext1}})
|
||||
|
||||
tc.transactf("ok", "fetch 1 rfc822")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, rfc1}})
|
||||
|
||||
tc.client.Logout()
|
||||
}
|
140
imapserver/fuzz_test.go
Normal file
140
imapserver/fuzz_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// Fuzz the server. For each fuzz string, we set up servers in various connection states, and write the string as command.
|
||||
func FuzzServer(f *testing.F) {
|
||||
seed := []string{
|
||||
fmt.Sprintf("authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000testtest"))),
|
||||
"*",
|
||||
"capability",
|
||||
"noop",
|
||||
"logout",
|
||||
"select inbox",
|
||||
"examine inbox",
|
||||
"unselect",
|
||||
"close",
|
||||
"expunge",
|
||||
"subscribe inbox",
|
||||
"unsubscribe inbox",
|
||||
`lsub "" "*"`,
|
||||
`list "" ""`,
|
||||
`namespace`,
|
||||
"enable utf8=accept",
|
||||
"create inbox",
|
||||
"create tmpbox",
|
||||
"rename tmpbox ntmpbox",
|
||||
"delete ntmpbox",
|
||||
"status inbox (uidnext messages uidvalidity deleted size unseen recent)",
|
||||
"append inbox (\\seen) {2+}\r\nhi",
|
||||
"fetch 1 all",
|
||||
"fetch 1 body",
|
||||
"fetch 1 (bodystructure)",
|
||||
`store 1 flags (\seen \answered)`,
|
||||
`store 1 +flags ($junk)`,
|
||||
`store 1 -flags ($junk)`,
|
||||
"noop",
|
||||
"copy 1Trash",
|
||||
"copy 1 Trash",
|
||||
"move 1 Trash",
|
||||
"search 1 all",
|
||||
}
|
||||
for _, cmd := range seed {
|
||||
const tag = "x "
|
||||
f.Add(tag + cmd)
|
||||
}
|
||||
|
||||
mox.Context = context.Background()
|
||||
mox.ConfigStaticPath = "../testdata/imap/mox.conf"
|
||||
mox.MustLoadConfig()
|
||||
dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir)
|
||||
os.RemoveAll(dataDir)
|
||||
acc, err := store.OpenAccount("mjl")
|
||||
if err != nil {
|
||||
f.Fatalf("open account: %v", err)
|
||||
}
|
||||
defer acc.Close()
|
||||
err = acc.SetPassword("testtest")
|
||||
if err != nil {
|
||||
f.Fatalf("set password: %v", err)
|
||||
}
|
||||
done := store.Switchboard()
|
||||
defer close(done)
|
||||
|
||||
comm := store.RegisterComm(acc)
|
||||
defer comm.Unregister()
|
||||
|
||||
var cid int64 = 1
|
||||
|
||||
var fl *os.File
|
||||
if false {
|
||||
fl, err = os.Create("fuzz.log")
|
||||
if err != nil {
|
||||
f.Fatalf("fuzz log")
|
||||
}
|
||||
defer fl.Close()
|
||||
}
|
||||
flog := func(err error, msg string) {
|
||||
if fl != nil && err != nil {
|
||||
fmt.Fprintf(fl, "%s: %v\n", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
run := func(cmds []string) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer serverConn.Close()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
x := recover()
|
||||
// Protocol can become botched, when fuzzer sends literals.
|
||||
if x == nil {
|
||||
return
|
||||
}
|
||||
err, ok := x.(error)
|
||||
if !ok || !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
panic(x)
|
||||
}
|
||||
}()
|
||||
|
||||
defer clientConn.Close()
|
||||
|
||||
err := clientConn.SetDeadline(time.Now().Add(time.Second))
|
||||
flog(err, "set client deadline")
|
||||
client, _ := imapclient.New(clientConn, true)
|
||||
|
||||
for _, cmd := range cmds {
|
||||
client.Commandf("", "%s", cmd)
|
||||
client.Response()
|
||||
}
|
||||
client.Commandf("", "%s", s)
|
||||
client.Response()
|
||||
}()
|
||||
|
||||
err = serverConn.SetDeadline(time.Now().Add(time.Second))
|
||||
flog(err, "set server deadline")
|
||||
serve("test", cid, nil, serverConn, false, true)
|
||||
cid++
|
||||
}
|
||||
|
||||
run([]string{})
|
||||
run([]string{"login mjl@mox.example testtest"})
|
||||
run([]string{"login mjl@mox.example testtest", "select inbox"})
|
||||
xappend := fmt.Sprintf("append inbox () {%d+}\r\n%s", len(exampleMsg), exampleMsg)
|
||||
run([]string{"login mjl@mox.example testtest", "select inbox", xappend})
|
||||
})
|
||||
}
|
52
imapserver/idle_test.go
Normal file
52
imapserver/idle_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestIdle(t *testing.T) {
|
||||
tc1 := start(t)
|
||||
defer tc1.close()
|
||||
tc1.transactf("ok", "login mjl@mox.example testtest")
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
tc2.transactf("ok", "login mjl@mox.example testtest")
|
||||
|
||||
tc1.transactf("ok", "select inbox")
|
||||
tc2.transactf("ok", "select inbox")
|
||||
|
||||
// todo: test with delivery through smtp
|
||||
|
||||
tc2.cmdf("", "idle")
|
||||
tc2.readprefixline("+")
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
defer func() {
|
||||
x := recover()
|
||||
if x != nil {
|
||||
done <- fmt.Errorf("%v", x)
|
||||
}
|
||||
}()
|
||||
untagged, _ := tc2.client.ReadUntagged()
|
||||
var exists imapclient.UntaggedExists
|
||||
tuntagged(tc2.t, untagged, &exists)
|
||||
// todo: validate the data we got back.
|
||||
tc2.writelinef("done")
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
tc1.transactf("ok", "append inbox () {%d+}\r\n%s", len(exampleMsg), exampleMsg)
|
||||
timer := time.NewTimer(time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
tc1.check(err, "idle")
|
||||
case <-timer.C:
|
||||
t.Fatalf("idle did not finish")
|
||||
}
|
||||
}
|
228
imapserver/list.go
Normal file
228
imapserver/list.go
Normal file
@ -0,0 +1,228 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// LIST command, for listing mailboxes with various attributes, including about subscriptions and children.
|
||||
// We don't have flags Marked, Unmarked, NoSelect and NoInferiors and we don't have REMOTE mailboxes.
|
||||
//
|
||||
// State: Authenticated and selected.
|
||||
func (c *conn) cmdList(tag, cmd string, p *parser) {
|
||||
// Command: ../rfc/9051:2224 ../rfc/6154:144 ../rfc/5258:193 ../rfc/3501:2191
|
||||
// Examples: ../rfc/9051:2755 ../rfc/6154:347 ../rfc/5258:679 ../rfc/3501:2359
|
||||
|
||||
// Request syntax: ../rfc/9051:6600 ../rfc/6154:478 ../rfc/5258:1095 ../rfc/3501:4793
|
||||
p.xspace()
|
||||
var isExtended bool
|
||||
var listSubscribed bool
|
||||
var listRecursive bool
|
||||
if p.take("(") {
|
||||
// ../rfc/9051:6633
|
||||
isExtended = true
|
||||
selectOptions := map[string]bool{}
|
||||
var nbase int
|
||||
for !p.take(")") {
|
||||
if len(selectOptions) > 0 {
|
||||
p.xspace()
|
||||
}
|
||||
w := p.xatom()
|
||||
W := strings.ToUpper(w)
|
||||
switch W {
|
||||
case "REMOTE":
|
||||
case "RECURSIVEMATCH":
|
||||
listRecursive = true
|
||||
case "SUBSCRIBED":
|
||||
nbase++
|
||||
listSubscribed = true
|
||||
default:
|
||||
// ../rfc/9051:2398
|
||||
xsyntaxErrorf("bad list selection option %q", w)
|
||||
}
|
||||
// Duplicates must be accepted. ../rfc/9051:2399
|
||||
selectOptions[W] = true
|
||||
}
|
||||
if listRecursive && nbase == 0 {
|
||||
// ../rfc/9051:6640
|
||||
xsyntaxErrorf("cannot have RECURSIVEMATCH selection option without other (base) selection option")
|
||||
}
|
||||
p.xspace()
|
||||
}
|
||||
reference := p.xmailbox()
|
||||
p.xspace()
|
||||
patterns, isList := p.xmboxOrPat()
|
||||
isExtended = isExtended || isList
|
||||
var retSubscribed, retChildren, retSpecialUse bool
|
||||
var retStatusAttrs []string
|
||||
if p.take(" RETURN (") {
|
||||
isExtended = true
|
||||
// ../rfc/9051:6613 ../rfc/9051:6915 ../rfc/9051:7072 ../rfc/9051:6821 ../rfc/5819:95
|
||||
n := 0
|
||||
for !p.take(")") {
|
||||
if n > 0 {
|
||||
p.xspace()
|
||||
}
|
||||
n++
|
||||
w := p.xatom()
|
||||
W := strings.ToUpper(w)
|
||||
switch W {
|
||||
case "SUBSCRIBED":
|
||||
retSubscribed = true
|
||||
case "CHILDREN":
|
||||
// ../rfc/3348:44
|
||||
retChildren = true
|
||||
case "SPECIAL-USE":
|
||||
// ../rfc/6154:478
|
||||
retSpecialUse = true
|
||||
case "STATUS":
|
||||
// ../rfc/9051:7072 ../rfc/5819:181
|
||||
p.xspace()
|
||||
p.xtake("(")
|
||||
retStatusAttrs = []string{p.xstatusAtt()}
|
||||
for p.take(" ") {
|
||||
retStatusAttrs = append(retStatusAttrs, p.xstatusAtt())
|
||||
}
|
||||
p.xtake(")")
|
||||
default:
|
||||
// ../rfc/9051:2398
|
||||
xsyntaxErrorf("bad list return option %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.xempty()
|
||||
|
||||
if !isExtended && reference == "" && patterns[0] == "" {
|
||||
// ../rfc/9051:2277 ../rfc/3501:2221
|
||||
c.bwritelinef(`* LIST () "/" ""`)
|
||||
c.ok(tag, cmd)
|
||||
return
|
||||
}
|
||||
|
||||
if isExtended {
|
||||
// ../rfc/9051:2286
|
||||
n := make([]string, 0, len(patterns))
|
||||
for _, p := range patterns {
|
||||
if p != "" {
|
||||
n = append(n, p)
|
||||
}
|
||||
}
|
||||
patterns = n
|
||||
}
|
||||
re := xmailboxPatternMatcher(reference, patterns)
|
||||
var responseLines []string
|
||||
|
||||
c.account.WithRLock(func() {
|
||||
c.xdbread(func(tx *bstore.Tx) {
|
||||
type info struct {
|
||||
mailbox *store.Mailbox
|
||||
subscribed bool
|
||||
}
|
||||
names := map[string]info{}
|
||||
hasSubscribedChild := map[string]bool{}
|
||||
hasChild := map[string]bool{}
|
||||
var nameList []string
|
||||
|
||||
q := bstore.QueryTx[store.Mailbox](tx)
|
||||
err := q.ForEach(func(mb store.Mailbox) error {
|
||||
names[mb.Name] = info{mailbox: &mb}
|
||||
nameList = append(nameList, mb.Name)
|
||||
for p := filepath.Dir(mb.Name); p != "."; p = filepath.Dir(p) {
|
||||
hasChild[p] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
xcheckf(err, "listing mailboxes")
|
||||
|
||||
qs := bstore.QueryTx[store.Subscription](tx)
|
||||
err = qs.ForEach(func(sub store.Subscription) error {
|
||||
info, ok := names[sub.Name]
|
||||
info.subscribed = true
|
||||
names[sub.Name] = info
|
||||
if !ok {
|
||||
nameList = append(nameList, sub.Name)
|
||||
}
|
||||
for p := filepath.Dir(sub.Name); p != "."; p = filepath.Dir(p) {
|
||||
hasSubscribedChild[p] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
xcheckf(err, "listing subscriptions")
|
||||
|
||||
sort.Strings(nameList) // For predictable order in tests.
|
||||
|
||||
for _, name := range nameList {
|
||||
if !re.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
info := names[name]
|
||||
|
||||
var flags listspace
|
||||
var extended listspace
|
||||
if listRecursive && hasSubscribedChild[name] {
|
||||
extended = listspace{bare("CHILDINFO"), listspace{dquote("SUBSCRIBED")}}
|
||||
}
|
||||
if listSubscribed && info.subscribed {
|
||||
flags = append(flags, bare(`\Subscribed`))
|
||||
if info.mailbox == nil {
|
||||
flags = append(flags, bare(`\NonExistent`))
|
||||
}
|
||||
}
|
||||
if (info.mailbox == nil || listSubscribed) && flags == nil && extended == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if retChildren {
|
||||
var f string
|
||||
if hasChild[name] {
|
||||
f = `\HasChildren`
|
||||
} else {
|
||||
f = `\HasNoChildren`
|
||||
}
|
||||
flags = append(flags, bare(f))
|
||||
}
|
||||
if !listSubscribed && retSubscribed && info.subscribed {
|
||||
flags = append(flags, bare(`\Subscribed`))
|
||||
}
|
||||
if retSpecialUse && info.mailbox != nil {
|
||||
if info.mailbox.Archive {
|
||||
flags = append(flags, bare(`\Archive`))
|
||||
}
|
||||
if info.mailbox.Draft {
|
||||
flags = append(flags, bare(`\Draft`))
|
||||
}
|
||||
if info.mailbox.Junk {
|
||||
flags = append(flags, bare(`\Junk`))
|
||||
}
|
||||
if info.mailbox.Sent {
|
||||
flags = append(flags, bare(`\Sent`))
|
||||
}
|
||||
if info.mailbox.Trash {
|
||||
flags = append(flags, bare(`\Trash`))
|
||||
}
|
||||
}
|
||||
|
||||
var extStr string
|
||||
if extended != nil {
|
||||
extStr = " " + extended.pack(c)
|
||||
}
|
||||
line := fmt.Sprintf(`* LIST %s "/" %s%s`, flags.pack(c), astring(name).pack(c), extStr)
|
||||
responseLines = append(responseLines, line)
|
||||
|
||||
if retStatusAttrs != nil && info.mailbox != nil {
|
||||
responseLines = append(responseLines, c.xstatusLine(tx, *info.mailbox, retStatusAttrs))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
for _, line := range responseLines {
|
||||
c.bwritelinef("%s", line)
|
||||
}
|
||||
c.ok(tag, cmd)
|
||||
}
|
215
imapserver/list_test.go
Normal file
215
imapserver/list_test.go
Normal file
@ -0,0 +1,215 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
func TestListBasic(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
ulist := func(name string, flags ...string) imapclient.UntaggedList {
|
||||
if len(flags) == 0 {
|
||||
flags = nil
|
||||
}
|
||||
return imapclient.UntaggedList{Flags: flags, Separator: '/', Mailbox: name}
|
||||
}
|
||||
|
||||
tc.last(tc.client.List("INBOX"))
|
||||
tc.xuntagged(ulist("Inbox"))
|
||||
|
||||
tc.last(tc.client.List("Inbox"))
|
||||
tc.xuntagged(ulist("Inbox"))
|
||||
|
||||
tc.last(tc.client.List("%"))
|
||||
tc.xuntagged(ulist("Archive"), ulist("Drafts"), ulist("Inbox"), ulist("Junk"), ulist("Sent"), ulist("Trash"))
|
||||
|
||||
tc.last(tc.client.List("*"))
|
||||
tc.xuntagged(ulist("Archive"), ulist("Drafts"), ulist("Inbox"), ulist("Junk"), ulist("Sent"), ulist("Trash"))
|
||||
|
||||
tc.last(tc.client.List("A*"))
|
||||
tc.xuntagged(ulist("Archive"))
|
||||
|
||||
tc.client.Create("Inbox/todo")
|
||||
|
||||
tc.last(tc.client.List("Inbox*"))
|
||||
tc.xuntagged(ulist("Inbox"), ulist("Inbox/todo"))
|
||||
|
||||
tc.last(tc.client.List("Inbox/%"))
|
||||
tc.xuntagged(ulist("Inbox/todo"))
|
||||
|
||||
tc.last(tc.client.List("Inbox/*"))
|
||||
tc.xuntagged(ulist("Inbox/todo"))
|
||||
|
||||
// Leading full INBOX is turned into Inbox, so mailbox matches.
|
||||
tc.last(tc.client.List("INBOX/*"))
|
||||
tc.xuntagged(ulist("Inbox/todo"))
|
||||
|
||||
// No match because we are only touching various casings of the full "INBOX".
|
||||
tc.last(tc.client.List("INBO*"))
|
||||
tc.xuntagged()
|
||||
}
|
||||
|
||||
func TestListExtended(t *testing.T) {
|
||||
defer mockUIDValidity()()
|
||||
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
ulist := func(name string, flags ...string) imapclient.UntaggedList {
|
||||
if len(flags) == 0 {
|
||||
flags = nil
|
||||
}
|
||||
return imapclient.UntaggedList{Flags: flags, Separator: '/', Mailbox: name}
|
||||
}
|
||||
|
||||
uidvals := map[string]uint32{}
|
||||
for _, name := range store.InitialMailboxes {
|
||||
uidvals[name] = 1
|
||||
}
|
||||
var uidvalnext uint32 = 2
|
||||
uidval := func(name string) uint32 {
|
||||
v, ok := uidvals[name]
|
||||
if !ok {
|
||||
v = uidvalnext
|
||||
uidvals[name] = v
|
||||
uidvalnext++
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
ustatus := func(name string) imapclient.UntaggedStatus {
|
||||
attrs := map[string]int64{
|
||||
"MESSAGES": 0,
|
||||
"UIDNEXT": 1,
|
||||
"UIDVALIDITY": int64(uidval(name)),
|
||||
"UNSEEN": 0,
|
||||
"DELETED": 0,
|
||||
"SIZE": 0,
|
||||
"RECENT": 0,
|
||||
"APPENDLIMIT": 0,
|
||||
}
|
||||
return imapclient.UntaggedStatus{Mailbox: name, Attrs: attrs}
|
||||
}
|
||||
|
||||
const (
|
||||
Fsubscribed = `\Subscribed`
|
||||
Fhaschildren = `\HasChildren`
|
||||
Fhasnochildren = `\HasNoChildren`
|
||||
Fnonexistent = `\NonExistent`
|
||||
Farchive = `\Archive`
|
||||
Fdraft = `\Draft`
|
||||
Fjunk = `\Junk`
|
||||
Fsent = `\Sent`
|
||||
Ftrash = `\Trash`
|
||||
)
|
||||
|
||||
// untaggedlist with flags subscribed and hasnochildren
|
||||
xlist := func(name string, flags ...string) imapclient.UntaggedList {
|
||||
flags = append([]string{Fhasnochildren, Fsubscribed}, flags...)
|
||||
return ulist(name, flags...)
|
||||
}
|
||||
|
||||
xchildlist := func(name string, flags ...string) imapclient.UntaggedList {
|
||||
u := ulist(name, flags...)
|
||||
comp := imapclient.TaggedExtComp{String: "SUBSCRIBED"}
|
||||
u.Extended = []imapclient.MboxListExtendedItem{{Tag: "CHILDINFO", Val: imapclient.TaggedExtVal{Comp: &comp}}}
|
||||
return u
|
||||
}
|
||||
|
||||
tc.last(tc.client.ListFull(false, "INBOX"))
|
||||
tc.xuntagged(xlist("Inbox"), ustatus("Inbox"))
|
||||
|
||||
tc.last(tc.client.ListFull(false, "Inbox"))
|
||||
tc.xuntagged(xlist("Inbox"), ustatus("Inbox"))
|
||||
|
||||
tc.last(tc.client.ListFull(false, "%"))
|
||||
tc.xuntagged(xlist("Archive", Farchive), ustatus("Archive"), xlist("Drafts", Fdraft), ustatus("Drafts"), xlist("Inbox"), ustatus("Inbox"), xlist("Junk", Fjunk), ustatus("Junk"), xlist("Sent", Fsent), ustatus("Sent"), xlist("Trash", Ftrash), ustatus("Trash"))
|
||||
|
||||
tc.last(tc.client.ListFull(false, "*"))
|
||||
tc.xuntagged(xlist("Archive", Farchive), ustatus("Archive"), xlist("Drafts", Fdraft), ustatus("Drafts"), xlist("Inbox"), ustatus("Inbox"), xlist("Junk", Fjunk), ustatus("Junk"), xlist("Sent", Fsent), ustatus("Sent"), xlist("Trash", Ftrash), ustatus("Trash"))
|
||||
|
||||
tc.last(tc.client.ListFull(false, "A*"))
|
||||
tc.xuntagged(xlist("Archive", Farchive), ustatus("Archive"))
|
||||
|
||||
tc.last(tc.client.ListFull(false, "A*", "Junk"))
|
||||
tc.xuntagged(xlist("Archive", Farchive), ustatus("Archive"), xlist("Junk", Fjunk), ustatus("Junk"))
|
||||
|
||||
tc.client.Create("Inbox/todo")
|
||||
|
||||
tc.last(tc.client.ListFull(false, "Inbox*"))
|
||||
tc.xuntagged(ulist("Inbox", Fhaschildren, Fsubscribed), ustatus("Inbox"), xlist("Inbox/todo"), ustatus("Inbox/todo"))
|
||||
|
||||
tc.last(tc.client.ListFull(false, "Inbox/%"))
|
||||
tc.xuntagged(xlist("Inbox/todo"), ustatus("Inbox/todo"))
|
||||
|
||||
tc.last(tc.client.ListFull(false, "Inbox/*"))
|
||||
tc.xuntagged(xlist("Inbox/todo"), ustatus("Inbox/todo"))
|
||||
|
||||
// Leading full INBOX is turned into Inbox, so mailbox matches.
|
||||
tc.last(tc.client.ListFull(false, "INBOX/*"))
|
||||
tc.xuntagged(xlist("Inbox/todo"), ustatus("Inbox/todo"))
|
||||
|
||||
// No match because we are only touching various casings of the full "INBOX".
|
||||
tc.last(tc.client.ListFull(false, "INBO*"))
|
||||
tc.xuntagged()
|
||||
|
||||
tc.last(tc.client.ListFull(true, "Inbox"))
|
||||
tc.xuntagged(xchildlist("Inbox", Fsubscribed, Fhaschildren), ustatus("Inbox"))
|
||||
|
||||
tc.client.Unsubscribe("Inbox")
|
||||
tc.last(tc.client.ListFull(true, "Inbox"))
|
||||
tc.xuntagged(xchildlist("Inbox", Fhaschildren), ustatus("Inbox"))
|
||||
|
||||
tc.client.Delete("Inbox/todo") // Still subscribed.
|
||||
tc.last(tc.client.ListFull(true, "Inbox"))
|
||||
tc.xuntagged(xchildlist("Inbox", Fhasnochildren), ustatus("Inbox"))
|
||||
|
||||
// Simple extended list without RETURN options.
|
||||
tc.transactf("ok", `list "" ("inbox")`)
|
||||
tc.xuntagged(ulist("Inbox"))
|
||||
|
||||
tc.transactf("ok", `list () "" ("inbox") return ()`)
|
||||
tc.xuntagged(ulist("Inbox"))
|
||||
|
||||
tc.transactf("ok", `list "" ("inbox") return ()`)
|
||||
tc.xuntagged(ulist("Inbox"))
|
||||
|
||||
tc.transactf("ok", `list () "" ("inbox")`)
|
||||
tc.xuntagged(ulist("Inbox"))
|
||||
|
||||
tc.transactf("ok", `list (remote) "" ("inbox")`)
|
||||
tc.xuntagged(ulist("Inbox"))
|
||||
|
||||
tc.transactf("ok", `list (remote) "" "/inbox"`)
|
||||
tc.xuntagged()
|
||||
|
||||
tc.transactf("ok", `list (remote) "/inbox" ""`)
|
||||
tc.xuntagged()
|
||||
|
||||
tc.transactf("ok", `list (remote) "inbox" ""`)
|
||||
tc.xuntagged()
|
||||
|
||||
tc.transactf("ok", `list (remote) "inbox" "a"`)
|
||||
tc.xuntagged()
|
||||
|
||||
tc.client.Create("inbox/a")
|
||||
tc.transactf("ok", `list (remote) "inbox" "a"`)
|
||||
tc.xuntagged(ulist("Inbox/a"))
|
||||
|
||||
tc.client.Subscribe("x")
|
||||
tc.transactf("ok", `list (subscribed) "" x return (subscribed)`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`, `\NonExistent`}, Separator: '/', Mailbox: "x"})
|
||||
|
||||
tc.transactf("bad", `list (recursivematch) "" "*"`) // Cannot have recursivematch without a base selection option like subscribed.
|
||||
tc.transactf("bad", `list (recursivematch remote) "" "*"`) // "remote" is not a base selection option.
|
||||
tc.transactf("bad", `list (unknown) "" "*"`) // Unknown selection options must result in BAD.
|
||||
tc.transactf("bad", `list () "" "*" return (unknown)`) // Unknown return options must result in BAD.
|
||||
}
|
35
imapserver/lsub_test.go
Normal file
35
imapserver/lsub_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestLsub(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.transactf("bad", "lsub") // Missing params.
|
||||
tc.transactf("bad", `lsub ""`) // Missing param.
|
||||
tc.transactf("bad", `lsub "" x `) // Leftover data.
|
||||
|
||||
tc.transactf("ok", `lsub "" x*`)
|
||||
tc.xuntagged()
|
||||
|
||||
tc.transactf("ok", "create a/b/c")
|
||||
tc.transactf("ok", `lsub "" a/*`)
|
||||
tc.xuntagged(imapclient.UntaggedLsub{Separator: '/', Mailbox: "a/b"}, imapclient.UntaggedLsub{Separator: '/', Mailbox: "a/b/c"})
|
||||
|
||||
// ../rfc/3501:2394
|
||||
tc.transactf("ok", "unsubscribe a")
|
||||
tc.transactf("ok", "unsubscribe a/b")
|
||||
tc.transactf("ok", `lsub "" a/%%`)
|
||||
tc.xuntagged(imapclient.UntaggedLsub{Flags: []string{`\NoSelect`}, Separator: '/', Mailbox: "a/b"})
|
||||
|
||||
tc.transactf("ok", "unsubscribe a/b/c")
|
||||
tc.transactf("ok", `lsub "" a/%%`)
|
||||
tc.xuntagged()
|
||||
}
|
92
imapserver/move_test.go
Normal file
92
imapserver/move_test.go
Normal file
@ -0,0 +1,92 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestMove(t *testing.T) {
|
||||
defer mockUIDValidity()()
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
|
||||
tc3 := startNoSwitchboard(t)
|
||||
defer tc3.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Select("inbox")
|
||||
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Select("Trash")
|
||||
|
||||
tc3.client.Login("mjl@mox.example", "testtest")
|
||||
tc3.client.Select("inbox")
|
||||
|
||||
tc.transactf("bad", "move") // Missing params.
|
||||
tc.transactf("bad", "move 1") // Missing params.
|
||||
tc.transactf("bad", "move 1 inbox ") // Leftover.
|
||||
|
||||
// Seqs 1,2 and UIDs 3,4.
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.StoreFlagsSet("1:2", true, `\Deleted`)
|
||||
tc.client.Expunge()
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
|
||||
tc.client.Unselect()
|
||||
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.xcode("TRYCREATE")
|
||||
|
||||
tc.transactf("no", "move 1 inbox") // Cannot move to same mailbox.
|
||||
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
tc3.transactf("ok", "noop") // Drain.
|
||||
|
||||
tc.transactf("ok", "move 1:* Trash")
|
||||
ptr := func(v uint32) *uint32 { return &v }
|
||||
tc.xuntagged(
|
||||
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),
|
||||
)
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(
|
||||
imapclient.UntaggedExists(2),
|
||||
imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1), imapclient.FetchFlags(nil)}},
|
||||
imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2), imapclient.FetchFlags(nil)}},
|
||||
)
|
||||
tc3.transactf("ok", "noop")
|
||||
tc3.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1))
|
||||
|
||||
// UIDs 5,6
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
tc3.transactf("ok", "noop") // Drain.
|
||||
|
||||
tc.transactf("no", "uid move 1:4 Trash") // No match.
|
||||
tc.transactf("ok", "uid move 6:5 Trash")
|
||||
tc.xuntagged(
|
||||
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"}},
|
||||
imapclient.UntaggedExpunge(1),
|
||||
imapclient.UntaggedExpunge(1),
|
||||
)
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(
|
||||
imapclient.UntaggedExists(4),
|
||||
imapclient.UntaggedFetch{Seq: 3, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(3), imapclient.FetchFlags(nil)}},
|
||||
imapclient.UntaggedFetch{Seq: 4, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(4), imapclient.FetchFlags(nil)}},
|
||||
)
|
||||
tc3.transactf("ok", "noop")
|
||||
tc3.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1))
|
||||
}
|
213
imapserver/pack.go
Normal file
213
imapserver/pack.go
Normal file
@ -0,0 +1,213 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type token interface {
|
||||
pack(c *conn) string
|
||||
writeTo(c *conn, w io.Writer)
|
||||
}
|
||||
|
||||
type bare string
|
||||
|
||||
func (t bare) pack(c *conn) string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
func (t bare) writeTo(c *conn, w io.Writer) {
|
||||
w.Write([]byte(t.pack(c)))
|
||||
}
|
||||
|
||||
type niltoken struct{}
|
||||
|
||||
var nilt niltoken
|
||||
|
||||
func (t niltoken) pack(c *conn) string {
|
||||
return "NIL"
|
||||
}
|
||||
|
||||
func (t niltoken) writeTo(c *conn, w io.Writer) {
|
||||
w.Write([]byte(t.pack(c)))
|
||||
}
|
||||
|
||||
func nilOrString(s string) token {
|
||||
if s == "" {
|
||||
return nilt
|
||||
}
|
||||
return string0(s)
|
||||
}
|
||||
|
||||
type string0 string
|
||||
|
||||
// ../rfc/9051:7081
|
||||
// ../rfc/9051:6856 ../rfc/6855:153
|
||||
func (t string0) pack(c *conn) string {
|
||||
r := `"`
|
||||
for _, ch := range t {
|
||||
if ch == '\x00' || ch == '\r' || ch == '\n' || ch > 0x7f && !c.utf8strings() {
|
||||
return syncliteral(t).pack(c)
|
||||
}
|
||||
if ch == '\\' || ch == '"' {
|
||||
r += `\`
|
||||
}
|
||||
r += string(ch)
|
||||
}
|
||||
r += `"`
|
||||
return r
|
||||
}
|
||||
|
||||
func (t string0) writeTo(c *conn, w io.Writer) {
|
||||
w.Write([]byte(t.pack(c)))
|
||||
}
|
||||
|
||||
type dquote string
|
||||
|
||||
func (t dquote) pack(c *conn) string {
|
||||
r := `"`
|
||||
for _, c := range t {
|
||||
if c == '\\' || c == '"' {
|
||||
r += `\`
|
||||
}
|
||||
r += string(c)
|
||||
}
|
||||
r += `"`
|
||||
return r
|
||||
}
|
||||
|
||||
func (t dquote) writeTo(c *conn, w io.Writer) {
|
||||
w.Write([]byte(t.pack(c)))
|
||||
}
|
||||
|
||||
type syncliteral string
|
||||
|
||||
func (t syncliteral) pack(c *conn) string {
|
||||
return fmt.Sprintf("{%d}\r\n", len(t)) + string(t)
|
||||
}
|
||||
|
||||
func (t syncliteral) writeTo(c *conn, w io.Writer) {
|
||||
fmt.Fprintf(w, "{%d}\r\n", len(t))
|
||||
w.Write([]byte(t))
|
||||
}
|
||||
|
||||
// data from reader with known size.
|
||||
type readerSizeSyncliteral struct {
|
||||
r io.Reader
|
||||
size int64
|
||||
}
|
||||
|
||||
func (t readerSizeSyncliteral) pack(c *conn) string {
|
||||
buf, err := io.ReadAll(t.r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return fmt.Sprintf("{%d}\r\n", t.size) + string(buf)
|
||||
}
|
||||
|
||||
func (t readerSizeSyncliteral) writeTo(c *conn, w io.Writer) {
|
||||
fmt.Fprintf(w, "{%d}\r\n", t.size)
|
||||
if _, err := io.Copy(w, io.LimitReader(t.r, t.size)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// data from reader without known size.
|
||||
type readerSyncliteral struct {
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (t readerSyncliteral) pack(c *conn) string {
|
||||
buf, err := io.ReadAll(t.r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return fmt.Sprintf("{%d}\r\n", len(buf)) + string(buf)
|
||||
}
|
||||
|
||||
func (t readerSyncliteral) writeTo(c *conn, w io.Writer) {
|
||||
buf, err := io.ReadAll(t.r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Fprintf(w, "{%d}\r\n", len(buf))
|
||||
_, err = w.Write(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// list with tokens space-separated
|
||||
type listspace []token
|
||||
|
||||
func (t listspace) pack(c *conn) string {
|
||||
s := "("
|
||||
for i, e := range t {
|
||||
if i > 0 {
|
||||
s += " "
|
||||
}
|
||||
s += e.pack(c)
|
||||
}
|
||||
s += ")"
|
||||
return s
|
||||
}
|
||||
|
||||
func (t listspace) writeTo(c *conn, w io.Writer) {
|
||||
fmt.Fprint(w, "(")
|
||||
for i, e := range t {
|
||||
if i > 0 {
|
||||
fmt.Fprint(w, " ")
|
||||
}
|
||||
e.writeTo(c, w)
|
||||
}
|
||||
fmt.Fprint(w, ")")
|
||||
}
|
||||
|
||||
// Concatenated tokens, no spaces or list syntax.
|
||||
type concat []token
|
||||
|
||||
func (t concat) pack(c *conn) string {
|
||||
var s string
|
||||
for _, e := range t {
|
||||
s += e.pack(c)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (t concat) writeTo(c *conn, w io.Writer) {
|
||||
for _, e := range t {
|
||||
e.writeTo(c, w)
|
||||
}
|
||||
}
|
||||
|
||||
type astring string
|
||||
|
||||
func (t astring) pack(c *conn) string {
|
||||
if len(t) == 0 {
|
||||
return string0(t).pack(c)
|
||||
}
|
||||
next:
|
||||
for _, ch := range t {
|
||||
for _, x := range atomChar {
|
||||
if ch == x {
|
||||
continue next
|
||||
}
|
||||
}
|
||||
return string0(t).pack(c)
|
||||
}
|
||||
return string(t)
|
||||
}
|
||||
|
||||
func (t astring) writeTo(c *conn, w io.Writer) {
|
||||
w.Write([]byte(t.pack(c)))
|
||||
}
|
||||
|
||||
type number uint32
|
||||
|
||||
func (t number) pack(c *conn) string {
|
||||
return fmt.Sprintf("%d", t)
|
||||
}
|
||||
|
||||
func (t number) writeTo(c *conn, w io.Writer) {
|
||||
w.Write([]byte(t.pack(c)))
|
||||
}
|
942
imapserver/parse.go
Normal file
942
imapserver/parse.go
Normal file
@ -0,0 +1,942 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
listWildcards = "%*"
|
||||
char = charRange('\x01', '\x7f')
|
||||
ctl = charRange('\x01', '\x19')
|
||||
atomChar = charRemove(char, "(){ "+listWildcards+ctl)
|
||||
respSpecials = atomChar + "]"
|
||||
astringChar = atomChar + respSpecials
|
||||
)
|
||||
|
||||
func charRange(first, last rune) string {
|
||||
r := ""
|
||||
c := first
|
||||
r += string(c)
|
||||
for c < last {
|
||||
c++
|
||||
r += string(c)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func charRemove(s, remove string) string {
|
||||
r := ""
|
||||
next:
|
||||
for _, c := range s {
|
||||
for _, x := range remove {
|
||||
if c == x {
|
||||
continue next
|
||||
}
|
||||
}
|
||||
r += string(c)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
// Orig is the line in original casing, and upper in upper casing. We often match
|
||||
// against upper for easy case insensitive handling as IMAP requires, but sometimes
|
||||
// return from orig to keep the original case.
|
||||
orig string
|
||||
upper string
|
||||
o int // Current offset in parsing.
|
||||
contexts []string // What we're parsing, for error messages.
|
||||
conn *conn
|
||||
}
|
||||
|
||||
// toUpper upper cases bytes that are a-z. strings.ToUpper does too much. and
|
||||
// would replace invalid bytes with unicode replacement characters, which would
|
||||
// break our requirement that offsets into the original and upper case strings
|
||||
// point to the same character.
|
||||
func toUpper(s string) string {
|
||||
r := []byte(s)
|
||||
for i, c := range r {
|
||||
if c >= 'a' && c <= 'z' {
|
||||
r[i] = c - 0x20
|
||||
}
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
func newParser(s string, conn *conn) *parser {
|
||||
return &parser{s, toUpper(s), 0, nil, conn}
|
||||
}
|
||||
|
||||
func (p *parser) xerrorf(format string, args ...any) {
|
||||
var context string
|
||||
if len(p.contexts) > 0 {
|
||||
context = strings.Join(p.contexts, ",")
|
||||
}
|
||||
panic(syntaxError{"", "", fmt.Errorf("%s (%sremaining data %q)", fmt.Sprintf(format, args...), context, p.orig[p.o:])})
|
||||
}
|
||||
|
||||
func (p *parser) context(s string) func() {
|
||||
p.contexts = append(p.contexts, s)
|
||||
return func() {
|
||||
p.contexts = p.contexts[:len(p.contexts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) empty() bool {
|
||||
return p.o == len(p.upper)
|
||||
}
|
||||
|
||||
func (p *parser) xempty() {
|
||||
if !p.empty() {
|
||||
p.xerrorf("leftover data")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) hasPrefix(s string) bool {
|
||||
return strings.HasPrefix(p.upper[p.o:], s)
|
||||
}
|
||||
|
||||
func (p *parser) take(s string) bool {
|
||||
if !p.hasPrefix(s) {
|
||||
return false
|
||||
}
|
||||
p.o += len(s)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *parser) xtake(s string) {
|
||||
if !p.take(s) {
|
||||
p.xerrorf("expected %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) xnonempty() {
|
||||
if p.empty() {
|
||||
p.xerrorf("unexpected end")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) xtakeall() string {
|
||||
r := p.orig[p.o:]
|
||||
p.o = len(p.orig)
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) xtake1n(n int, what string) string {
|
||||
if n == 0 {
|
||||
p.xerrorf("expected chars from %s", what)
|
||||
}
|
||||
return p.xtaken(n)
|
||||
}
|
||||
|
||||
func (p *parser) xtake1fn(fn func(i int, c rune) bool) string {
|
||||
i := 0
|
||||
s := ""
|
||||
for _, c := range p.upper[p.o:] {
|
||||
if !fn(i, c) {
|
||||
break
|
||||
}
|
||||
s += string(c)
|
||||
i++
|
||||
}
|
||||
if s == "" {
|
||||
p.xerrorf("expected at least one character")
|
||||
}
|
||||
p.o += len(s)
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *parser) xtakechars(s string, what string) string {
|
||||
p.xnonempty()
|
||||
for i, c := range p.orig[p.o:] {
|
||||
if !contains(s, c) {
|
||||
return p.xtake1n(i, what)
|
||||
}
|
||||
}
|
||||
return p.xtakeall()
|
||||
}
|
||||
|
||||
func (p *parser) xtaken(n int) string {
|
||||
if p.o+n > len(p.orig) {
|
||||
p.xerrorf("not enough data")
|
||||
}
|
||||
r := p.orig[p.o : p.o+n]
|
||||
p.o += n
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) peekn(n int) (string, bool) {
|
||||
if len(p.upper[p.o:]) < n {
|
||||
return "", false
|
||||
}
|
||||
return p.upper[p.o : p.o+n], true
|
||||
}
|
||||
|
||||
func (p *parser) space() bool {
|
||||
return p.take(" ")
|
||||
}
|
||||
|
||||
func (p *parser) xspace() {
|
||||
if !p.space() {
|
||||
p.xerrorf("expected space")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) digits() string {
|
||||
var n int
|
||||
for _, c := range p.upper[p.o:] {
|
||||
if c >= '0' && c <= '9' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
s := p.upper[p.o : p.o+n]
|
||||
p.o += n
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *parser) nznumber() (uint32, bool) {
|
||||
o := p.o
|
||||
for o < len(p.upper) && p.upper[o] >= '0' && p.upper[o] <= '9' {
|
||||
o++
|
||||
}
|
||||
if o == p.o {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.ParseUint(p.upper[p.o:o], 10, 32); err != nil {
|
||||
return 0, false
|
||||
} else if n == 0 {
|
||||
return 0, false
|
||||
} else {
|
||||
p.o = o
|
||||
return uint32(n), true
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) xnznumber() uint32 {
|
||||
n, ok := p.nznumber()
|
||||
if !ok {
|
||||
p.xerrorf("expected non-zero number")
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (p *parser) number() (uint32, bool) {
|
||||
o := p.o
|
||||
for o < len(p.upper) && p.upper[o] >= '0' && p.upper[o] <= '9' {
|
||||
o++
|
||||
}
|
||||
if o == p.o {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseUint(p.upper[p.o:o], 10, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
p.o = o
|
||||
return uint32(n), true
|
||||
}
|
||||
|
||||
func (p *parser) xnumber() uint32 {
|
||||
n, ok := p.number()
|
||||
if !ok {
|
||||
p.xerrorf("expected number")
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (p *parser) xnumber64() int64 {
|
||||
s := p.digits()
|
||||
if s == "" {
|
||||
p.xerrorf("expected number64")
|
||||
}
|
||||
v, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
p.xerrorf("parsing number64 %q: %v", s, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// l should be a list of uppercase words, the first match is returned
|
||||
func (p *parser) takelist(l ...string) (string, bool) {
|
||||
for _, w := range l {
|
||||
if p.take(w) {
|
||||
return w, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (p *parser) xtakelist(l ...string) string {
|
||||
w, ok := p.takelist(l...)
|
||||
if !ok {
|
||||
p.xerrorf("expected one of %s", strings.Join(l, ","))
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (p *parser) xstring() (r string) {
|
||||
if p.take(`"`) {
|
||||
esc := false
|
||||
r := ""
|
||||
for i, c := range p.orig[p.o:] {
|
||||
if c == '\\' {
|
||||
esc = true
|
||||
} else if c == '\x00' || c == '\r' || c == '\n' {
|
||||
p.xerrorf("invalid nul, cr or lf in string")
|
||||
} else if esc {
|
||||
if c == '\\' || c == '"' {
|
||||
r += string(c)
|
||||
esc = false
|
||||
} else {
|
||||
p.xerrorf("invalid escape char %c", c)
|
||||
}
|
||||
} else if c == '"' {
|
||||
p.o += i + 1
|
||||
return r
|
||||
} else {
|
||||
r += string(c)
|
||||
}
|
||||
}
|
||||
p.xerrorf("missing closing dquote in string")
|
||||
}
|
||||
size, sync := p.xliteralSize(100*1024, false)
|
||||
s := p.conn.xreadliteral(size, sync)
|
||||
line := p.conn.readline(false)
|
||||
p.orig, p.upper, p.o = line, toUpper(line), 0
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *parser) xnil() {
|
||||
p.xtake("NIL")
|
||||
}
|
||||
|
||||
// Returns NIL as empty string.
|
||||
func (p *parser) xnilString() string {
|
||||
if p.take("NIL") {
|
||||
return ""
|
||||
}
|
||||
return p.xstring()
|
||||
}
|
||||
|
||||
func (p *parser) xastring() string {
|
||||
if p.hasPrefix(`"`) || p.hasPrefix("{") || p.hasPrefix("~{") {
|
||||
return p.xstring()
|
||||
}
|
||||
return p.xtakechars(astringChar, "astring")
|
||||
}
|
||||
|
||||
func contains(s string, c rune) bool {
|
||||
for _, x := range s {
|
||||
if x == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *parser) xtag() string {
|
||||
p.xnonempty()
|
||||
for i, c := range p.orig[p.o:] {
|
||||
if c == '+' || !contains(astringChar, c) {
|
||||
return p.xtake1n(i, "tag")
|
||||
}
|
||||
}
|
||||
return p.xtakeall()
|
||||
}
|
||||
|
||||
func (p *parser) xcommand() string {
|
||||
for i, c := range p.upper[p.o:] {
|
||||
if !(c >= 'A' && c <= 'Z' || c == ' ' && p.upper[p.o:p.o+i] == "UID") {
|
||||
return p.xtake1n(i, "command")
|
||||
}
|
||||
}
|
||||
return p.xtakeall()
|
||||
}
|
||||
|
||||
func (p *parser) remainder() string {
|
||||
return p.orig[p.o:]
|
||||
}
|
||||
|
||||
func (p *parser) xflag() string {
|
||||
return p.xtakelist(`\`, "$") + p.xatom()
|
||||
}
|
||||
|
||||
func (p *parser) xflagList() (l []string) {
|
||||
p.xtake("(")
|
||||
if !p.hasPrefix(")") {
|
||||
l = append(l, p.xflag())
|
||||
}
|
||||
for !p.take(")") {
|
||||
p.xspace()
|
||||
l = append(l, p.xflag())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *parser) xatom() string {
|
||||
return p.xtakechars(atomChar, "atom")
|
||||
}
|
||||
|
||||
func (p *parser) xmailbox() string {
|
||||
s := p.xastring()
|
||||
// UTF-7 is deprecated in IMAP4rev2. IMAP4rev1 does not fully forbid
|
||||
// UTF-8 returned in mailbox names. We'll do our best by attempting to
|
||||
// decode utf-7. But if that doesn't work, we'll just use the original
|
||||
// string.
|
||||
// ../rfc/3501:964
|
||||
if !p.conn.enabled[capIMAP4rev2] {
|
||||
ns, err := utf7decode(s)
|
||||
if err != nil {
|
||||
p.conn.log.Infox("decoding utf7 or mailbox name", err, mlog.Field("name", s))
|
||||
} else {
|
||||
s = ns
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ../rfc/9051:6605
|
||||
func (p *parser) xlistMailbox() string {
|
||||
if p.hasPrefix(`"`) || p.hasPrefix("{") {
|
||||
return p.xstring()
|
||||
}
|
||||
return p.xtakechars(atomChar+listWildcards+respSpecials, "list-char")
|
||||
}
|
||||
|
||||
// ../rfc/9051:6707 ../rfc/9051:6848 ../rfc/5258:1095 ../rfc/5258:1169 ../rfc/5258:1196
|
||||
func (p *parser) xmboxOrPat() ([]string, bool) {
|
||||
if !p.take("(") {
|
||||
return []string{p.xlistMailbox()}, false
|
||||
}
|
||||
l := []string{p.xlistMailbox()}
|
||||
for !p.take(")") {
|
||||
p.xspace()
|
||||
l = append(l, p.xlistMailbox())
|
||||
}
|
||||
return l, true
|
||||
}
|
||||
|
||||
// ../rfc/9051:7056
|
||||
// RECENT only in ../rfc/3501:5047
|
||||
// APPENDLIMIT is from ../rfc/7889:252
|
||||
func (p *parser) xstatusAtt() string {
|
||||
return p.xtakelist("MESSAGES", "UIDNEXT", "UIDVALIDITY", "UNSEEN", "DELETED", "SIZE", "RECENT", "APPENDLIMIT")
|
||||
}
|
||||
|
||||
// ../rfc/9051:7133 ../rfc/9051:7034
|
||||
func (p *parser) xnumSet() (r numSet) {
|
||||
defer p.context("numSet")()
|
||||
if p.take("$") {
|
||||
return numSet{searchResult: true}
|
||||
}
|
||||
r.ranges = append(r.ranges, p.xnumRange())
|
||||
for p.take(",") {
|
||||
r.ranges = append(r.ranges, p.xnumRange())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// parse numRange, which can be just a setNumber.
|
||||
func (p *parser) xnumRange() (r numRange) {
|
||||
if p.take("*") {
|
||||
r.first.star = true
|
||||
} else {
|
||||
r.first.number = p.xnznumber()
|
||||
}
|
||||
if p.take(":") {
|
||||
r.last = &setNumber{}
|
||||
if p.take("*") {
|
||||
r.last.star = true
|
||||
} else {
|
||||
r.last.number = p.xnznumber()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ../rfc/9051:6989 ../rfc/3501:4977
|
||||
func (p *parser) xsectionMsgtext() (r *sectionMsgtext) {
|
||||
defer p.context("sectionMsgtext")()
|
||||
msgtextWords := []string{"HEADER.FIELDS.NOT", "HEADER.FIELDS", "HEADER", "TEXT"}
|
||||
w := p.xtakelist(msgtextWords...)
|
||||
r = §ionMsgtext{s: w}
|
||||
if strings.HasPrefix(w, "HEADER.FIELDS") {
|
||||
p.xspace()
|
||||
p.xtake("(")
|
||||
r.headers = append(r.headers, textproto.CanonicalMIMEHeaderKey(p.xastring()))
|
||||
for {
|
||||
if p.take(")") {
|
||||
break
|
||||
}
|
||||
p.xspace()
|
||||
r.headers = append(r.headers, textproto.CanonicalMIMEHeaderKey(p.xastring()))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ../rfc/9051:6999 ../rfc/3501:4991
|
||||
func (p *parser) xsectionSpec() (r *sectionSpec) {
|
||||
defer p.context("parseSectionSpec")()
|
||||
|
||||
n, ok := p.nznumber()
|
||||
if !ok {
|
||||
return §ionSpec{msgtext: p.xsectionMsgtext()}
|
||||
}
|
||||
defer p.context("part...")()
|
||||
pt := §ionPart{}
|
||||
pt.part = append(pt.part, n)
|
||||
for {
|
||||
if !p.take(".") {
|
||||
break
|
||||
}
|
||||
if n, ok := p.nznumber(); ok {
|
||||
pt.part = append(pt.part, n)
|
||||
continue
|
||||
}
|
||||
if p.take("MIME") {
|
||||
pt.text = §ionText{mime: true}
|
||||
break
|
||||
}
|
||||
pt.text = §ionText{msgtext: p.xsectionMsgtext()}
|
||||
break
|
||||
}
|
||||
return §ionSpec{part: pt}
|
||||
}
|
||||
|
||||
// ../rfc/9051:6985 ../rfc/3501:4975
|
||||
func (p *parser) xsection() *sectionSpec {
|
||||
defer p.context("parseSection")()
|
||||
p.xtake("[")
|
||||
if p.take("]") {
|
||||
return §ionSpec{}
|
||||
}
|
||||
r := p.xsectionSpec()
|
||||
p.xtake("]")
|
||||
return r
|
||||
}
|
||||
|
||||
// ../rfc/9051:6841
|
||||
func (p *parser) xpartial() *partial {
|
||||
p.xtake("<")
|
||||
offset := p.xnumber()
|
||||
p.xtake(".")
|
||||
count := p.xnznumber()
|
||||
p.xtake(">")
|
||||
return &partial{offset, count}
|
||||
}
|
||||
|
||||
// ../rfc/9051:6987
|
||||
func (p *parser) xsectionBinary() (r []uint32) {
|
||||
p.xtake("[")
|
||||
if p.take("]") {
|
||||
return nil
|
||||
}
|
||||
r = append(r, p.xnznumber())
|
||||
for {
|
||||
if !p.take(".") {
|
||||
break
|
||||
}
|
||||
r = append(r, p.xnznumber())
|
||||
}
|
||||
p.xtake("]")
|
||||
return r
|
||||
}
|
||||
|
||||
// ../rfc/9051:6557 ../rfc/3501:4751
|
||||
func (p *parser) xfetchAtt() (r fetchAtt) {
|
||||
defer p.context("fetchAtt")()
|
||||
words := []string{
|
||||
"ENVELOPE", "FLAGS", "INTERNALDATE", "RFC822.SIZE", "BODYSTRUCTURE", "UID", "BODY.PEEK", "BODY", "BINARY.PEEK", "BINARY.SIZE", "BINARY",
|
||||
"RFC822.HEADER", "RFC822.TEXT", "RFC822", // older IMAP
|
||||
}
|
||||
f := p.xtakelist(words...)
|
||||
r.peek = strings.HasSuffix(f, ".PEEK")
|
||||
r.field = strings.TrimSuffix(f, ".PEEK")
|
||||
|
||||
switch r.field {
|
||||
case "BODY":
|
||||
if p.hasPrefix("[") {
|
||||
r.section = p.xsection()
|
||||
if p.hasPrefix("<") {
|
||||
r.partial = p.xpartial()
|
||||
}
|
||||
}
|
||||
case "BINARY":
|
||||
r.sectionBinary = p.xsectionBinary()
|
||||
if p.hasPrefix("<") {
|
||||
r.partial = p.xpartial()
|
||||
}
|
||||
case "BINARY.SIZE":
|
||||
r.sectionBinary = p.xsectionBinary()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ../rfc/9051:6553 ../rfc/3501:4748
|
||||
func (p *parser) xfetchAtts() []fetchAtt {
|
||||
defer p.context("fetchAtts")()
|
||||
|
||||
fields := func(l ...string) []fetchAtt {
|
||||
r := make([]fetchAtt, len(l))
|
||||
for i, s := range l {
|
||||
r[i] = fetchAtt{field: s}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if w, ok := p.takelist("ALL", "FAST", "FULL"); ok {
|
||||
switch w {
|
||||
case "ALL":
|
||||
return fields("FLAGS", "INTERNALDATE", "RFC822.SIZE", "ENVELOPE")
|
||||
case "FAST":
|
||||
return fields("FLAGS", "INTERNALDATE", "RFC822.SIZE")
|
||||
case "FULL":
|
||||
return fields("FLAGS", "INTERNALDATE", "RFC822.SIZE", "ENVELOPE", "BODY")
|
||||
}
|
||||
panic("missing case")
|
||||
}
|
||||
|
||||
if !p.hasPrefix("(") {
|
||||
return []fetchAtt{p.xfetchAtt()}
|
||||
}
|
||||
|
||||
l := []fetchAtt{}
|
||||
p.xtake("(")
|
||||
for {
|
||||
l = append(l, p.xfetchAtt())
|
||||
if !p.take(" ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
p.xtake(")")
|
||||
return l
|
||||
}
|
||||
|
||||
func xint(p *parser, s string) int {
|
||||
v, err := strconv.ParseInt(s, 10, 32)
|
||||
if err != nil {
|
||||
p.xerrorf("bad int %q: %v", s, err)
|
||||
}
|
||||
return int(v)
|
||||
}
|
||||
|
||||
func (p *parser) digit() (string, bool) {
|
||||
if p.empty() {
|
||||
return "", false
|
||||
}
|
||||
c := p.orig[p.o]
|
||||
if c < '0' || c > '9' {
|
||||
return "", false
|
||||
}
|
||||
s := p.orig[p.o : p.o+1]
|
||||
p.o++
|
||||
return s, true
|
||||
}
|
||||
|
||||
func (p *parser) xdigit() string {
|
||||
s, ok := p.digit()
|
||||
if !ok {
|
||||
p.xerrorf("expected digit")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ../rfc/9051:6492 ../rfc/3501:4695
|
||||
func (p *parser) xdateDayFixed() int {
|
||||
if p.take(" ") {
|
||||
return xint(p, p.xdigit())
|
||||
}
|
||||
return xint(p, p.xdigit()+p.xdigit())
|
||||
}
|
||||
|
||||
var months = []string{"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"}
|
||||
|
||||
// ../rfc/9051:6495 ../rfc/3501:4698
|
||||
func (p *parser) xdateMonth() time.Month {
|
||||
s := strings.ToLower(p.xtaken(3))
|
||||
for i, m := range months {
|
||||
if m == s {
|
||||
return time.Month(1 + i)
|
||||
}
|
||||
}
|
||||
p.xerrorf("unknown month %q", s)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ../rfc/9051:7120 ../rfc/3501:5067
|
||||
func (p *parser) xtime() (int, int, int) {
|
||||
h := xint(p, p.xtaken(2))
|
||||
p.xtake(":")
|
||||
m := xint(p, p.xtaken(2))
|
||||
p.xtake(":")
|
||||
s := xint(p, p.xtaken(2))
|
||||
return h, m, s
|
||||
}
|
||||
|
||||
// ../rfc/9051:7159 ../rfc/3501:5083
|
||||
func (p *parser) xzone() (string, int) {
|
||||
sign := p.xtakelist("+", "-")
|
||||
s := p.xtaken(4)
|
||||
v := xint(p, s)
|
||||
seconds := (v/100)*3600 + (v%100)*60
|
||||
if sign[0] == '-' {
|
||||
seconds = -seconds
|
||||
}
|
||||
return sign + s, seconds
|
||||
}
|
||||
|
||||
// ../rfc/9051:6502 ../rfc/3501:4713
|
||||
func (p *parser) xdateTime() time.Time {
|
||||
// DQUOTE date-day-fixed "-" date-month "-" date-year SP time SP zone DQUOTE
|
||||
p.xtake(`"`)
|
||||
day := p.xdateDayFixed()
|
||||
p.xtake("-")
|
||||
month := p.xdateMonth()
|
||||
p.xtake("-")
|
||||
year := xint(p, p.xtaken(4))
|
||||
p.xspace()
|
||||
hours, minutes, seconds := p.xtime()
|
||||
p.xspace()
|
||||
name, zoneSeconds := p.xzone()
|
||||
p.xtake(`"`)
|
||||
loc := time.FixedZone(name, zoneSeconds)
|
||||
return time.Date(year, month, day, hours, minutes, seconds, 0, loc)
|
||||
}
|
||||
|
||||
// ../rfc/9051:6655 ../rfc/7888:330 ../rfc/3501:4801
|
||||
func (p *parser) xliteralSize(maxSize int64, lit8 bool) (size int64, sync bool) {
|
||||
// todo: enforce that we get non-binary when ~ isn't present?
|
||||
if lit8 {
|
||||
p.take("~")
|
||||
}
|
||||
p.xtake("{")
|
||||
size = p.xnumber64()
|
||||
if maxSize > 0 && size > maxSize {
|
||||
// ../rfc/7888:249
|
||||
line := fmt.Sprintf("* BYE [ALERT] Max literal size %d is larger than allowed %d in this context", size, maxSize)
|
||||
panic(syntaxError{line, "TOOBIG", fmt.Errorf("literal too big")})
|
||||
}
|
||||
|
||||
sync = !p.take("+")
|
||||
p.xtake("}")
|
||||
p.xempty()
|
||||
return size, sync
|
||||
}
|
||||
|
||||
var searchKeyWords = []string{
|
||||
"ALL", "ANSWERED", "BCC",
|
||||
"BEFORE", "BODY",
|
||||
"CC", "DELETED", "FLAGGED",
|
||||
"FROM", "KEYWORD",
|
||||
"NEW", "OLD", "ON", "RECENT", "SEEN",
|
||||
"SINCE", "SUBJECT",
|
||||
"TEXT", "TO",
|
||||
"UNANSWERED", "UNDELETED", "UNFLAGGED",
|
||||
"UNKEYWORD", "UNSEEN",
|
||||
"DRAFT", "HEADER",
|
||||
"LARGER", "NOT",
|
||||
"OR",
|
||||
"SENTBEFORE", "SENTON",
|
||||
"SENTSINCE", "SMALLER",
|
||||
"UID", "UNDRAFT",
|
||||
}
|
||||
|
||||
// ../rfc/9051:6923 ../rfc/3501:4957
|
||||
// differences: rfc 9051 removes NEW, OLD, RECENT and makes SMALLER and LARGER number64 instead of number.
|
||||
func (p *parser) xsearchKey() *searchKey {
|
||||
if p.take("(") {
|
||||
sk := p.xsearchKey()
|
||||
l := []searchKey{*sk}
|
||||
for !p.take(")") {
|
||||
p.xspace()
|
||||
l = append(l, *p.xsearchKey())
|
||||
}
|
||||
return &searchKey{searchKeys: l}
|
||||
}
|
||||
|
||||
w, ok := p.takelist(searchKeyWords...)
|
||||
if !ok {
|
||||
seqs := p.xnumSet()
|
||||
return &searchKey{seqSet: &seqs}
|
||||
}
|
||||
|
||||
sk := &searchKey{op: w}
|
||||
switch sk.op {
|
||||
case "ALL":
|
||||
case "ANSWERED":
|
||||
case "BCC":
|
||||
p.xspace()
|
||||
sk.astring = p.xastring()
|
||||
case "BEFORE":
|
||||
p.xspace()
|
||||
sk.date = p.xdate()
|
||||
case "BODY":
|
||||
p.xspace()
|
||||
sk.astring = p.xastring()
|
||||
case "CC":
|
||||
p.xspace()
|
||||
sk.astring = p.xastring()
|
||||
case "DELETED":
|
||||
case "FLAGGED":
|
||||
case "FROM":
|
||||
p.xspace()
|
||||
sk.astring = p.xastring()
|
||||
case "KEYWORD":
|
||||
p.xspace()
|
||||
sk.atom = p.xatom()
|
||||
case "NEW":
|
||||
case "OLD":
|
||||
case "ON":
|
||||
p.xspace()
|
||||
sk.date = p.xdate()
|
||||
case "RECENT":
|
||||
case "SEEN":
|
||||
case "SINCE":
|
||||
p.xspace()
|
||||
sk.date = p.xdate()
|
||||
case "SUBJECT":
|
||||
p.xspace()
|
||||
sk.astring = p.xastring()
|
||||
case "TEXT":
|
||||
p.xspace()
|
||||
sk.astring = p.xastring()
|
||||
case "TO":
|
||||
p.xspace()
|
||||
sk.astring = p.xastring()
|
||||
case "UNANSWERED":
|
||||
case "UNDELETED":
|
||||
case "UNFLAGGED":
|
||||
case "UNKEYWORD":
|
||||
p.xspace()
|
||||
sk.atom = p.xatom()
|
||||
case "UNSEEN":
|
||||
case "DRAFT":
|
||||
case "HEADER":
|
||||
p.xspace()
|
||||
sk.headerField = p.xastring()
|
||||
p.xspace()
|
||||
sk.astring = p.xastring()
|
||||
case "LARGER":
|
||||
p.xspace()
|
||||
sk.number = p.xnumber64()
|
||||
case "NOT":
|
||||
p.xspace()
|
||||
sk.searchKey = p.xsearchKey()
|
||||
case "OR":
|
||||
p.xspace()
|
||||
sk.searchKey = p.xsearchKey()
|
||||
p.xspace()
|
||||
sk.searchKey2 = p.xsearchKey()
|
||||
case "SENTBEFORE":
|
||||
p.xspace()
|
||||
sk.date = p.xdate()
|
||||
case "SENTON":
|
||||
p.xspace()
|
||||
sk.date = p.xdate()
|
||||
case "SENTSINCE":
|
||||
p.xspace()
|
||||
sk.date = p.xdate()
|
||||
case "SMALLER":
|
||||
p.xspace()
|
||||
sk.number = p.xnumber64()
|
||||
case "UID":
|
||||
p.xspace()
|
||||
sk.uidSet = p.xnumSet()
|
||||
case "UNDRAFT":
|
||||
default:
|
||||
p.xerrorf("missing case for op %q", sk.op)
|
||||
}
|
||||
return sk
|
||||
}
|
||||
|
||||
// ../rfc/9051:6489 ../rfc/3501:4692
|
||||
func (p *parser) xdateDay() int {
|
||||
d := p.xdigit()
|
||||
if s, ok := p.digit(); ok {
|
||||
d += s
|
||||
}
|
||||
return xint(p, d)
|
||||
}
|
||||
|
||||
// ../rfc/9051:6487 ../rfc/3501:4690
|
||||
func (p *parser) xdate() time.Time {
|
||||
dquote := p.take(`"`)
|
||||
day := p.xdateDay()
|
||||
p.xtake("-")
|
||||
mon := p.xdateMonth()
|
||||
p.xtake("-")
|
||||
year := xint(p, p.xtaken(4))
|
||||
if dquote {
|
||||
p.take(`"`)
|
||||
}
|
||||
return time.Date(year, mon, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// ../rfc/9051:7090 ../rfc/4466:716
|
||||
func (p *parser) xtaggedExtLabel() string {
|
||||
return p.xtake1fn(func(i int, c rune) bool {
|
||||
return c >= 'A' && c <= 'Z' || c == '-' || c == '_' || c == '.' || i > 0 && (c >= '0' && c <= '9' || c == ':')
|
||||
})
|
||||
}
|
||||
|
||||
// no return value since we don't currently use the value.
|
||||
// ../rfc/9051:7111 ../rfc/4466:749
|
||||
func (p *parser) xtaggedExtVal() {
|
||||
if p.take("(") {
|
||||
if p.take(")") {
|
||||
return
|
||||
}
|
||||
p.xtaggedExtComp()
|
||||
p.xtake(")")
|
||||
} else {
|
||||
p.xtaggedExtSimple()
|
||||
}
|
||||
}
|
||||
|
||||
// ../rfc/9051:7109 ../rfc/4466:747
|
||||
func (p *parser) xtaggedExtSimple() {
|
||||
s := p.digits()
|
||||
if s == "" {
|
||||
p.xnumSet()
|
||||
}
|
||||
|
||||
// This can be a number64, or the start of a sequence-set. A sequence-set can also
|
||||
// start with a number, but only an uint32. After the number we'll try to continue
|
||||
// parsing as a sequence-set.
|
||||
_, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
p.xerrorf("parsing int: %v", err)
|
||||
}
|
||||
|
||||
if p.take(":") {
|
||||
if !p.take("*") {
|
||||
p.xnznumber()
|
||||
}
|
||||
}
|
||||
for p.take(",") {
|
||||
p.xnumRange()
|
||||
}
|
||||
}
|
||||
|
||||
// ../rfc/9051:7111 ../rfc/4466:735
|
||||
func (p *parser) xtaggedExtComp() {
|
||||
if p.take("(") {
|
||||
p.xtaggedExtComp()
|
||||
p.xtake(")")
|
||||
return
|
||||
}
|
||||
p.xastring()
|
||||
for p.space() {
|
||||
p.xtaggedExtComp()
|
||||
}
|
||||
}
|
28
imapserver/prefixconn.go
Normal file
28
imapserver/prefixconn.go
Normal file
@ -0,0 +1,28 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
// prefixConn is a net.Conn with a buffer from which the first reads are satisfied.
|
||||
// used for STARTTLS where already did a buffered read of initial TLS data.
|
||||
type prefixConn struct {
|
||||
prefix []byte
|
||||
net.Conn
|
||||
}
|
||||
|
||||
func (c *prefixConn) Read(buf []byte) (int, error) {
|
||||
if len(c.prefix) > 0 {
|
||||
n := len(buf)
|
||||
if n > len(c.prefix) {
|
||||
n = len(c.prefix)
|
||||
}
|
||||
copy(buf[:n], c.prefix[:n])
|
||||
c.prefix = c.prefix[n:]
|
||||
if len(c.prefix) == 0 {
|
||||
c.prefix = nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return c.Conn.Read(buf)
|
||||
}
|
186
imapserver/protocol.go
Normal file
186
imapserver/protocol.go
Normal file
@ -0,0 +1,186 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
type numSet struct {
|
||||
searchResult bool // "$"
|
||||
ranges []numRange
|
||||
}
|
||||
|
||||
// containsSeq returns whether seq is in the numSet, given uids and (saved) searchResult.
|
||||
// uids and searchResult must be sorted. searchResult can have uids that are no longer in uids.
|
||||
func (ss numSet) containsSeq(seq msgseq, uids []store.UID, searchResult []store.UID) bool {
|
||||
if len(uids) == 0 {
|
||||
return false
|
||||
}
|
||||
if ss.searchResult {
|
||||
uid := uids[int(seq)-1]
|
||||
return uidSearch(searchResult, uid) > 0 && uidSearch(uids, uid) > 0
|
||||
}
|
||||
for _, r := range ss.ranges {
|
||||
first := r.first.number
|
||||
if r.first.star {
|
||||
first = 1
|
||||
}
|
||||
last := first
|
||||
if r.last != nil {
|
||||
last = r.last.number
|
||||
if r.last.star {
|
||||
last = uint32(len(uids))
|
||||
}
|
||||
}
|
||||
if last > uint32(len(uids)) {
|
||||
last = uint32(len(uids))
|
||||
}
|
||||
if uint32(seq) >= first && uint32(seq) <= last {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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[0]
|
||||
}
|
||||
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]
|
||||
if last > first {
|
||||
first = last
|
||||
}
|
||||
} else if r.first.star && last < first {
|
||||
last = first
|
||||
}
|
||||
}
|
||||
if uid < first || uid > last {
|
||||
continue
|
||||
}
|
||||
if uidSearch(uids, uid) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ss numSet) String() string {
|
||||
if ss.searchResult {
|
||||
return "$"
|
||||
}
|
||||
s := ""
|
||||
for _, r := range ss.ranges {
|
||||
if s != "" {
|
||||
s += ","
|
||||
}
|
||||
if r.first.star {
|
||||
s += "*"
|
||||
} else {
|
||||
s += fmt.Sprintf("%d", r.first.number)
|
||||
}
|
||||
if r.last == nil {
|
||||
if r.first.star {
|
||||
panic("invalid numSet range first star without last")
|
||||
}
|
||||
continue
|
||||
}
|
||||
s += ":"
|
||||
if r.last.star {
|
||||
s += "*"
|
||||
} else {
|
||||
s += fmt.Sprintf("%d", r.last.number)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type setNumber struct {
|
||||
number uint32
|
||||
star bool
|
||||
}
|
||||
|
||||
type numRange struct {
|
||||
first setNumber
|
||||
last *setNumber // if nil, this numRange is just a setNumber in "first" and first.star will be false
|
||||
}
|
||||
|
||||
type partial struct {
|
||||
offset uint32
|
||||
count uint32
|
||||
}
|
||||
|
||||
type sectionPart struct {
|
||||
part []uint32
|
||||
text *sectionText
|
||||
}
|
||||
|
||||
type sectionText struct {
|
||||
mime bool // if "MIME"
|
||||
msgtext *sectionMsgtext
|
||||
}
|
||||
|
||||
// a non-nil *sectionSpec with nil msgtext & nil part means there were []'s, but nothing inside. e.g. "BODY[]".
|
||||
type sectionSpec struct {
|
||||
msgtext *sectionMsgtext
|
||||
part *sectionPart
|
||||
}
|
||||
|
||||
type sectionMsgtext struct {
|
||||
s string // "HEADER", "HEADER.FIELDS", "HEADER.FIELDS.NOT", "TEXT"
|
||||
headers []string // for "HEADER.FIELDS"*
|
||||
}
|
||||
|
||||
type fetchAtt struct {
|
||||
field string // uppercase, eg "ENVELOPE", "BODY". ".PEEK" is removed.
|
||||
peek bool
|
||||
section *sectionSpec
|
||||
sectionBinary []uint32
|
||||
partial *partial
|
||||
}
|
||||
|
||||
type searchKey struct {
|
||||
// Only one of searchKeys, seqSet and op can be non-nil/non-empty.
|
||||
searchKeys []searchKey // In case of nested/multiple keys. Also for the top-level command.
|
||||
seqSet *numSet // In case of bare sequence set. For op UID, field uidSet contains the parameter.
|
||||
op string // Determines which of the fields below are set.
|
||||
headerField string
|
||||
astring string
|
||||
date time.Time
|
||||
atom string
|
||||
number int64
|
||||
searchKey *searchKey
|
||||
searchKey2 *searchKey
|
||||
uidSet numSet
|
||||
}
|
||||
|
||||
func compactUIDSet(l []store.UID) (r numSet) {
|
||||
for len(l) > 0 {
|
||||
e := 1
|
||||
for ; e < len(l) && l[e] == l[e-1]+1; e++ {
|
||||
}
|
||||
first := setNumber{number: uint32(l[0])}
|
||||
var last *setNumber
|
||||
if e > 1 {
|
||||
last = &setNumber{number: uint32(l[e-1])}
|
||||
}
|
||||
r.ranges = append(r.ranges, numRange{first, last})
|
||||
l = l[e:]
|
||||
}
|
||||
return
|
||||
}
|
61
imapserver/protocol_test.go
Normal file
61
imapserver/protocol_test.go
Normal file
@ -0,0 +1,61 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
func TestNumSetContains(t *testing.T) {
|
||||
num := func(v uint32) *setNumber {
|
||||
return &setNumber{v, false}
|
||||
}
|
||||
star := &setNumber{star: true}
|
||||
|
||||
check := func(v bool) {
|
||||
t.Helper()
|
||||
if !v {
|
||||
t.Fatalf("bad")
|
||||
}
|
||||
}
|
||||
|
||||
ss0 := numSet{true, nil} // "$"
|
||||
check(ss0.containsSeq(1, []store.UID{2}, []store.UID{2}))
|
||||
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.
|
||||
check(ss1.containsSeq(1, []store.UID{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(2, []store.UID{1, 2}, nil))
|
||||
|
||||
// 2:*
|
||||
ss2 := numSet{false, []numRange{{*num(2), star}}}
|
||||
check(!ss2.containsSeq(1, []store.UID{2}, nil))
|
||||
check(ss2.containsSeq(2, []store.UID{4, 5}, nil))
|
||||
check(ss2.containsSeq(3, []store.UID{4, 5, 6}, nil))
|
||||
|
||||
check(ss2.containsUID(2, []store.UID{2}, nil))
|
||||
check(ss2.containsUID(3, []store.UID{1, 2, 3}, nil))
|
||||
check(ss2.containsUID(2, []store.UID{2}, nil))
|
||||
check(!ss2.containsUID(2, []store.UID{4, 5}, nil))
|
||||
check(!ss2.containsUID(2, []store.UID{1}, nil))
|
||||
|
||||
// *:2
|
||||
ss3 := numSet{false, []numRange{{*star, num(2)}}}
|
||||
check(ss3.containsSeq(1, []store.UID{2}, nil))
|
||||
check(ss3.containsSeq(2, []store.UID{4, 5}, nil))
|
||||
check(!ss3.containsSeq(3, []store.UID{1, 2, 3}, nil))
|
||||
|
||||
check(ss3.containsUID(1, []store.UID{1}, nil))
|
||||
check(ss3.containsUID(2, []store.UID{1, 2, 3}, nil))
|
||||
check(!ss3.containsUID(1, []store.UID{2, 3}, nil))
|
||||
check(!ss3.containsUID(3, []store.UID{1, 2, 3}, nil))
|
||||
}
|
81
imapserver/rename_test.go
Normal file
81
imapserver/rename_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
// todo: check that UIDValidity is indeed updated properly.
|
||||
func TestRename(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.transactf("bad", "rename") // Missing parameters.
|
||||
tc.transactf("bad", "rename x") // Missing destination.
|
||||
tc.transactf("bad", "rename x y ") // Leftover data.
|
||||
|
||||
tc.transactf("no", "rename doesnotexist newbox") // Does not exist.
|
||||
tc.xcode("NONEXISTENT") // ../rfc/9051:5140
|
||||
tc.transactf("no", `rename "Sent" "Trash"`) // Already exists.
|
||||
tc.xcode("ALREADYEXISTS")
|
||||
|
||||
tc.client.Create("x")
|
||||
tc.client.Subscribe("sub")
|
||||
tc.client.Create("a/b/c")
|
||||
tc.client.Subscribe("x/y/c") // For later rename, but not affected by rename of x.
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
|
||||
tc.transactf("ok", "rename x y")
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "y", OldName: "x"})
|
||||
|
||||
// Rename to a mailbox that only exists in database as subscribed.
|
||||
tc.transactf("ok", "rename y sub")
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "sub", OldName: "y"})
|
||||
|
||||
// Cannot rename a child to a parent. It already exists.
|
||||
tc.transactf("no", "rename a/b/c a/b")
|
||||
tc.xcode("ALREADYEXISTS")
|
||||
tc.transactf("no", "rename a/b a")
|
||||
tc.xcode("ALREADYEXISTS")
|
||||
|
||||
tc2.transactf("ok", "noop") // Drain.
|
||||
tc.transactf("ok", "rename a/b x/y") // This will cause new parent "x" to be created, and a/b and a/b/c to be renamed.
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "x"}, imapclient.UntaggedList{Separator: '/', Mailbox: "x/y", OldName: "a/b"}, imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "x/y/c", OldName: "a/b/c"})
|
||||
|
||||
tc.client.Create("k/l")
|
||||
tc.transactf("ok", "rename k/l k/l/m") // With "l" renamed, a new "k" will be created.
|
||||
tc.transactf("ok", `list "" "k*" return (subscribed)`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "k"}, imapclient.UntaggedList{Flags: []string{`\Subscribed`}, Separator: '/', Mailbox: "k/l"}, imapclient.UntaggedList{Separator: '/', Mailbox: "k/l/m"})
|
||||
|
||||
// Similar, but with missing parent not subscribed.
|
||||
tc.transactf("ok", "rename k/l/m k/ll")
|
||||
tc.transactf("ok", "delete k/l")
|
||||
tc.transactf("ok", "rename k/ll k/l") // Restored to previous mailboxes now.
|
||||
tc.client.Unsubscribe("k")
|
||||
tc.transactf("ok", "rename k/l k/l/m") // With "l" renamed, a new "k" will be created.
|
||||
tc.transactf("ok", `list "" "k*" return (subscribed)`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "k"}, imapclient.UntaggedList{Flags: []string{"\\Subscribed"}, Separator: '/', Mailbox: "k/l"}, imapclient.UntaggedList{Separator: '/', Mailbox: "k/l/m"})
|
||||
|
||||
// Renaming inbox keeps inbox in existence and does not rename children.
|
||||
tc.transactf("ok", "create inbox/a")
|
||||
tc.transactf("ok", "rename inbox minbox")
|
||||
tc.transactf("ok", `list "" (inbox inbox/a minbox)`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"}, imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox/a"}, imapclient.UntaggedList{Separator: '/', Mailbox: "minbox"})
|
||||
|
||||
// Renaming to new hiearchy that does not have any subscribes.
|
||||
tc.transactf("ok", "rename minbox w/w")
|
||||
tc.transactf("ok", `list "" "w*"`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "w"}, imapclient.UntaggedList{Separator: '/', Mailbox: "w/w"})
|
||||
|
||||
// todo: test create+delete+rename of/to a name results in a higher uidvalidity.
|
||||
}
|
463
imapserver/search.go
Normal file
463
imapserver/search.go
Normal file
@ -0,0 +1,463 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/message"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// Search returns messages matching criteria specified in parameters.
|
||||
//
|
||||
// State: Selected
|
||||
func (c *conn) cmdxSearch(isUID bool, tag, cmd string, p *parser) {
|
||||
// Command: ../rfc/9051:3716 ../rfc/4731:31 ../rfc/4466:354 ../rfc/3501:2723
|
||||
// Examples: ../rfc/9051:3986 ../rfc/4731:153 ../rfc/3501:2975
|
||||
// Syntax: ../rfc/9051:6918 ../rfc/4466:611 ../rfc/3501:4954
|
||||
|
||||
// We will respond with ESEARCH instead of SEARCH if "RETURN" is present or for IMAP4rev2.
|
||||
var eargs map[string]bool // Options except SAVE. Nil means old-style SEARCH response.
|
||||
var save bool // For SAVE option. Kept separately for easier handling of MIN/MAX later.
|
||||
|
||||
// IMAP4rev2 always returns ESEARCH, even with absent RETURN.
|
||||
if c.enabled[capIMAP4rev2] {
|
||||
eargs = map[string]bool{}
|
||||
}
|
||||
// ../rfc/9051:6967
|
||||
if p.take(" RETURN (") {
|
||||
eargs = map[string]bool{}
|
||||
|
||||
for !p.take(")") {
|
||||
if len(eargs) > 0 || save {
|
||||
p.xspace()
|
||||
}
|
||||
if w, ok := p.takelist("MIN", "MAX", "ALL", "COUNT", "SAVE"); ok {
|
||||
if w == "SAVE" {
|
||||
save = true
|
||||
} else {
|
||||
eargs[w] = true
|
||||
}
|
||||
} else {
|
||||
// ../rfc/4466:378 ../rfc/9051:3745
|
||||
xsyntaxErrorf("ESEARCH result option %q not supported", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ../rfc/4731:149 ../rfc/9051:3737
|
||||
if eargs != nil && len(eargs) == 0 && !save {
|
||||
eargs["ALL"] = true
|
||||
}
|
||||
|
||||
// If UTF8=ACCEPT is enabled, we should not accept any charset. We are a bit more
|
||||
// relaxed (reasonable?) and still allow US-ASCII and UTF-8. ../rfc/6855:198
|
||||
if p.take(" CHARSET ") {
|
||||
charset := strings.ToUpper(p.xastring())
|
||||
if charset != "US-ASCII" && charset != "UTF-8" {
|
||||
// ../rfc/3501:2771 ../rfc/9051:3836
|
||||
xusercodeErrorf("BADCHARSET", "only US-ASCII and UTF-8 supported")
|
||||
}
|
||||
}
|
||||
p.xspace()
|
||||
sk := &searchKey{
|
||||
searchKeys: []searchKey{*p.xsearchKey()},
|
||||
}
|
||||
for !p.empty() {
|
||||
p.xspace()
|
||||
sk.searchKeys = append(sk.searchKeys, *p.xsearchKey())
|
||||
}
|
||||
|
||||
// Even in case of error, we ensure search result is changed.
|
||||
if save {
|
||||
c.searchResult = []store.UID{}
|
||||
}
|
||||
|
||||
// Note: we only hold the account rlock for verifying the mailbox at the start.
|
||||
c.account.RLock()
|
||||
runlock := c.account.RUnlock
|
||||
// Note: in a defer because we replace it below.
|
||||
defer func() {
|
||||
runlock()
|
||||
}()
|
||||
|
||||
// If we only have a MIN and/or MAX, we can stop processing as soon as we
|
||||
// have those matches.
|
||||
var min, max int
|
||||
if eargs["MIN"] {
|
||||
min = 1
|
||||
}
|
||||
if eargs["MAX"] {
|
||||
max = 1
|
||||
}
|
||||
|
||||
var expungeIssued bool
|
||||
|
||||
var uids []store.UID
|
||||
c.xdbread(func(tx *bstore.Tx) {
|
||||
c.xmailboxID(tx, c.mailboxID) // Validate.
|
||||
runlock()
|
||||
runlock = func() {}
|
||||
|
||||
// Normal forward search when we don't have MAX only.
|
||||
var lastIndex = -1
|
||||
if eargs == nil || max == 0 || len(eargs) != 1 {
|
||||
for i, uid := range c.uids {
|
||||
lastIndex = i
|
||||
if c.searchMatch(tx, msgseq(i+1), uid, *sk, &expungeIssued) {
|
||||
uids = append(uids, uid)
|
||||
if min == 1 && min+max == len(eargs) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// And reverse search for MAX if we have only MAX or MAX combined with MIN.
|
||||
if max == 1 && (len(eargs) == 1 || min+max == len(eargs)) {
|
||||
for i := len(c.uids) - 1; i > lastIndex; i-- {
|
||||
if c.searchMatch(tx, msgseq(i+1), c.uids[i], *sk, &expungeIssued) {
|
||||
uids = append(uids, c.uids[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if eargs == nil {
|
||||
// Old-style SEARCH response. We must spell out each number. So we may be splitting
|
||||
// into multiple responses. ../rfc/9051:6809 ../rfc/3501:4833
|
||||
for len(uids) > 0 {
|
||||
n := len(uids)
|
||||
if n > 100 {
|
||||
n = 100
|
||||
}
|
||||
s := ""
|
||||
for _, v := range uids[:n] {
|
||||
if !isUID {
|
||||
v = store.UID(c.xsequence(v))
|
||||
}
|
||||
s += " " + fmt.Sprintf("%d", v)
|
||||
}
|
||||
uids = uids[n:]
|
||||
c.bwritelinef("* SEARCH%s", s)
|
||||
}
|
||||
} else {
|
||||
// New-style ESEARCH response. ../rfc/9051:6546 ../rfc/4466:522
|
||||
|
||||
if save {
|
||||
// ../rfc/9051:3784 ../rfc/5182:13
|
||||
c.searchResult = uids
|
||||
if sanityChecks {
|
||||
checkUIDs(c.searchResult)
|
||||
}
|
||||
}
|
||||
|
||||
// No untagged ESEARCH response if nothing was requested. ../rfc/9051:4160
|
||||
if len(eargs) > 0 {
|
||||
resp := fmt.Sprintf("* ESEARCH (TAG %s)", tag)
|
||||
if isUID {
|
||||
resp += " UID"
|
||||
}
|
||||
|
||||
// NOTE: we are converting UIDs to msgseq in the uids slice (if needed) while
|
||||
// keeping the "uids" name!
|
||||
if !isUID {
|
||||
// If searchResult is hanging on to the slice, we need to work on a copy.
|
||||
if save {
|
||||
nuids := make([]store.UID, len(uids))
|
||||
copy(nuids, uids)
|
||||
uids = nuids
|
||||
}
|
||||
for i, uid := range uids {
|
||||
uids[i] = store.UID(c.xsequence(uid))
|
||||
}
|
||||
}
|
||||
|
||||
// If no matches, then no MIN/MAX response. ../rfc/4731:98 ../rfc/9051:3758
|
||||
if eargs["MIN"] && len(uids) > 0 {
|
||||
resp += fmt.Sprintf(" MIN %d", uids[0])
|
||||
}
|
||||
if eargs["MAX"] && len(uids) > 0 {
|
||||
resp += fmt.Sprintf(" MAX %d", uids[len(uids)-1])
|
||||
}
|
||||
if eargs["COUNT"] {
|
||||
resp += fmt.Sprintf(" COUNT %d", len(uids))
|
||||
}
|
||||
if eargs["ALL"] && len(uids) > 0 {
|
||||
resp += fmt.Sprintf(" ALL %s", compactUIDSet(uids).String())
|
||||
}
|
||||
c.bwritelinef("%s", resp)
|
||||
}
|
||||
}
|
||||
if expungeIssued {
|
||||
// ../rfc/9051:5102
|
||||
c.writeresultf("%s OK [EXPUNGEISSUED] done", tag)
|
||||
} else {
|
||||
c.ok(tag, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
type search struct {
|
||||
c *conn
|
||||
tx *bstore.Tx
|
||||
seq msgseq
|
||||
uid store.UID
|
||||
mr *store.MsgReader
|
||||
m store.Message
|
||||
p *message.Part
|
||||
expungeIssued *bool
|
||||
}
|
||||
|
||||
func (c *conn) searchMatch(tx *bstore.Tx, seq msgseq, uid store.UID, sk searchKey, expungeIssued *bool) bool {
|
||||
s := search{c: c, tx: tx, seq: seq, uid: uid, expungeIssued: expungeIssued}
|
||||
defer func() {
|
||||
if s.mr != nil {
|
||||
err := s.mr.Close()
|
||||
c.xsanity(err, "closing messagereader")
|
||||
s.mr = nil
|
||||
}
|
||||
}()
|
||||
return s.match(sk)
|
||||
}
|
||||
|
||||
func (s *search) match(sk searchKey) bool {
|
||||
c := s.c
|
||||
|
||||
if sk.searchKeys != nil {
|
||||
for _, ssk := range sk.searchKeys {
|
||||
if !s.match(ssk) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
} else if sk.seqSet != nil {
|
||||
return sk.seqSet.containsSeq(s.seq, c.uids, c.searchResult)
|
||||
}
|
||||
|
||||
filterHeader := func(field, value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
h, err := s.p.Header()
|
||||
if err != nil {
|
||||
c.log.Debugx("parsing message header", err, mlog.Field("uid", s.uid))
|
||||
return false
|
||||
}
|
||||
for _, v := range h.Values(field) {
|
||||
if strings.Contains(strings.ToLower(v), lower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// We handle ops by groups that need increasing details about the message.
|
||||
|
||||
switch sk.op {
|
||||
case "ALL":
|
||||
return true
|
||||
case "NEW":
|
||||
// We do not implement the RECENT flag, so messages cannot be NEW.
|
||||
return false
|
||||
case "OLD":
|
||||
// We treat all messages as non-recent, so this means all messages.
|
||||
return true
|
||||
case "RECENT":
|
||||
// We do not implement the RECENT flag. All messages are not recent.
|
||||
return false
|
||||
case "NOT":
|
||||
return !s.match(*sk.searchKey)
|
||||
case "OR":
|
||||
return s.match(*sk.searchKey) || s.match(*sk.searchKey2)
|
||||
case "UID":
|
||||
return sk.uidSet.containsUID(s.uid, c.uids, c.searchResult)
|
||||
}
|
||||
|
||||
// Parsed message.
|
||||
if s.mr == nil {
|
||||
q := bstore.QueryTx[store.Message](s.tx)
|
||||
q.FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: s.uid})
|
||||
m, err := q.Get()
|
||||
if err == bstore.ErrAbsent {
|
||||
// ../rfc/2180:607
|
||||
*s.expungeIssued = true
|
||||
return false
|
||||
}
|
||||
xcheckf(err, "get message")
|
||||
s.m = m
|
||||
|
||||
// Closed by searchMatch after all (recursive) search.match calls are finished.
|
||||
s.mr = c.account.MessageReader(m)
|
||||
|
||||
if m.ParsedBuf == nil {
|
||||
c.log.Error("missing parsed message")
|
||||
} else {
|
||||
p, err := m.LoadPart(s.mr)
|
||||
xcheckf(err, "load parsed message")
|
||||
s.p = &p
|
||||
}
|
||||
}
|
||||
|
||||
// Parsed message, basic info.
|
||||
switch sk.op {
|
||||
case "ANSWERED":
|
||||
return s.m.Answered
|
||||
case "DELETED":
|
||||
return s.m.Deleted
|
||||
case "FLAGGED":
|
||||
return s.m.Flagged
|
||||
case "KEYWORD":
|
||||
switch sk.atom {
|
||||
case "$Forwarded":
|
||||
return s.m.Forwarded
|
||||
case "$Junk":
|
||||
return s.m.Junk
|
||||
case "$NotJunk":
|
||||
return s.m.Notjunk
|
||||
case "$Phishing":
|
||||
return s.m.Phishing
|
||||
case "$MDNSent":
|
||||
return s.m.MDNSent
|
||||
default:
|
||||
c.log.Info("search with unknown keyword", mlog.Field("keyword", sk.atom))
|
||||
return false
|
||||
}
|
||||
case "SEEN":
|
||||
return s.m.Seen
|
||||
case "UNANSWERED":
|
||||
return !s.m.Answered
|
||||
case "UNDELETED":
|
||||
return !s.m.Deleted
|
||||
case "UNFLAGGED":
|
||||
return !s.m.Flagged
|
||||
case "UNKEYWORD":
|
||||
switch sk.atom {
|
||||
case "$Forwarded":
|
||||
return !s.m.Forwarded
|
||||
case "$Junk":
|
||||
return !s.m.Junk
|
||||
case "$NotJunk":
|
||||
return !s.m.Notjunk
|
||||
case "$Phishing":
|
||||
return !s.m.Phishing
|
||||
case "$MDNSent":
|
||||
return !s.m.MDNSent
|
||||
default:
|
||||
c.log.Info("search with unknown keyword", mlog.Field("keyword", sk.atom))
|
||||
return false
|
||||
}
|
||||
case "UNSEEN":
|
||||
return !s.m.Seen
|
||||
case "DRAFT":
|
||||
return s.m.Draft
|
||||
case "UNDRAFT":
|
||||
return !s.m.Draft
|
||||
case "BEFORE", "ON", "SINCE":
|
||||
skdt := sk.date.Format("2006-01-02")
|
||||
rdt := s.m.Received.Format("2006-01-02")
|
||||
switch sk.op {
|
||||
case "BEFORE":
|
||||
return rdt < skdt
|
||||
case "ON":
|
||||
return rdt == skdt
|
||||
case "SINCE":
|
||||
return rdt >= skdt
|
||||
}
|
||||
panic("missing case")
|
||||
case "LARGER":
|
||||
return s.m.Size > sk.number
|
||||
case "SMALLER":
|
||||
return s.m.Size < sk.number
|
||||
}
|
||||
|
||||
if s.p == nil {
|
||||
c.log.Info("missing parsed message, not matching", mlog.Field("uid", s.uid))
|
||||
return false
|
||||
}
|
||||
|
||||
// Parsed message, more info.
|
||||
switch sk.op {
|
||||
case "BCC":
|
||||
return filterHeader("Bcc", sk.astring)
|
||||
case "BODY", "TEXT":
|
||||
headerToo := sk.op == "TEXT"
|
||||
lower := strings.ToLower(sk.astring)
|
||||
return mailContains(c, s.uid, s.p, lower, headerToo)
|
||||
case "CC":
|
||||
return filterHeader("Cc", sk.astring)
|
||||
case "FROM":
|
||||
return filterHeader("From", sk.astring)
|
||||
case "SUBJECT":
|
||||
return filterHeader("Subject", sk.astring)
|
||||
case "TO":
|
||||
return filterHeader("To", sk.astring)
|
||||
case "HEADER":
|
||||
// ../rfc/9051:3895
|
||||
lower := strings.ToLower(sk.astring)
|
||||
h, err := s.p.Header()
|
||||
if err != nil {
|
||||
c.log.Errorx("parsing header for search", err, mlog.Field("uid", s.uid))
|
||||
return false
|
||||
}
|
||||
k := textproto.CanonicalMIMEHeaderKey(sk.headerField)
|
||||
for _, v := range h.Values(k) {
|
||||
if lower == "" || strings.Contains(strings.ToLower(v), lower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "SENTBEFORE", "SENTON", "SENTSINCE":
|
||||
if s.p.Envelope == nil || s.p.Envelope.Date.IsZero() {
|
||||
return false
|
||||
}
|
||||
dt := s.p.Envelope.Date.Format("2006-01-02")
|
||||
skdt := sk.date.Format("2006-01-02")
|
||||
switch sk.op {
|
||||
case "SENTBEFORE":
|
||||
return dt < skdt
|
||||
case "SENTON":
|
||||
return dt == skdt
|
||||
case "SENTSINCE":
|
||||
return dt > skdt
|
||||
}
|
||||
panic("missing case")
|
||||
}
|
||||
panic(serverError{fmt.Errorf("missing case for search key op %q", sk.op)})
|
||||
}
|
||||
|
||||
// mailContains returns whether the mail message or part represented by p contains (case-insensitive) string lower.
|
||||
// The (decoded) text bodies are tested for a match.
|
||||
// If headerToo is set, the header part of the message is checked as well.
|
||||
func mailContains(c *conn, uid store.UID, p *message.Part, lower string, headerToo bool) bool {
|
||||
if headerToo && mailContainsReader(c, uid, p.HeaderReader(), lower) {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(p.Parts) == 0 {
|
||||
if p.MediaType != "TEXT" {
|
||||
// todo: for types we could try to find a library for parsing and search in there too
|
||||
return false
|
||||
}
|
||||
// todo: for html and perhaps other types, we could try to parse as text and filter on the text.
|
||||
return mailContainsReader(c, uid, p.Reader(), lower)
|
||||
}
|
||||
for _, pp := range p.Parts {
|
||||
headerToo = pp.MediaType == "MESSAGE" && (pp.MediaSubType == "RFC822" || pp.MediaSubType == "GLOBAL")
|
||||
if mailContains(c, uid, &pp, lower, headerToo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mailContainsReader(c *conn, uid store.UID, r io.Reader, lower string) bool {
|
||||
// todo: match as we read
|
||||
buf, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
c.log.Errorx("reading for search text match", err, mlog.Field("uid", uid))
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(buf)), lower)
|
||||
}
|
345
imapserver/search_test.go
Normal file
345
imapserver/search_test.go
Normal file
@ -0,0 +1,345 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
var searchMsg = strings.ReplaceAll(`Date: Mon, 1 Jan 2022 10:00:00 +0100 (CEST)
|
||||
From: mjl <mjl@mox.example>
|
||||
Subject: mox
|
||||
To: mox <mox@mox.example>
|
||||
Cc: <xcc@mox.example>
|
||||
Bcc: <bcc@mox.example>
|
||||
Reply-To: <noreply@mox.example>
|
||||
Message-Id: <123@mox.example>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary=x
|
||||
|
||||
--x
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
this is plain text.
|
||||
|
||||
--x
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
this is html.
|
||||
|
||||
--x--
|
||||
`, "\n", "\r\n")
|
||||
|
||||
func (tc *testconn) xsearch(nums ...uint32) {
|
||||
tc.t.Helper()
|
||||
|
||||
if len(nums) == 0 {
|
||||
tc.xnountagged()
|
||||
return
|
||||
}
|
||||
tc.xuntagged(imapclient.UntaggedSearch(nums))
|
||||
}
|
||||
|
||||
func (tc *testconn) xesearch(exp imapclient.UntaggedEsearch) {
|
||||
tc.t.Helper()
|
||||
|
||||
exp.Correlator = tc.client.LastTag
|
||||
tc.xuntagged(exp)
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Select("inbox")
|
||||
|
||||
// Add 5 and delete first 4 messages. So UIDs start at 5.
|
||||
received := time.Date(2020, time.January, 1, 10, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 5; i++ {
|
||||
tc.client.Append("inbox", nil, &received, []byte(exampleMsg))
|
||||
}
|
||||
tc.client.StoreFlagsSet("1:4", true, `\Deleted`)
|
||||
tc.client.Expunge()
|
||||
|
||||
received = time.Date(2022, time.January, 1, 9, 0, 0, 0, time.UTC)
|
||||
tc.client.Append("inbox", nil, &received, []byte(searchMsg))
|
||||
|
||||
received = time.Date(2022, time.January, 1, 9, 0, 0, 0, time.UTC)
|
||||
mostFlags := []string{
|
||||
`\Deleted`,
|
||||
`\Seen`,
|
||||
`\Answered`,
|
||||
`\Flagged`,
|
||||
`\Draft`,
|
||||
`$Forwarded`,
|
||||
`$Junk`,
|
||||
`$Notjunk`,
|
||||
`$Phishing`,
|
||||
`$MDNSent`,
|
||||
}
|
||||
tc.client.Append("inbox", mostFlags, &received, []byte(searchMsg))
|
||||
|
||||
// We now have sequence numbers 1,2,3 and UIDs 5,6,7.
|
||||
|
||||
tc.transactf("ok", "search all")
|
||||
tc.xsearch(1, 2, 3)
|
||||
|
||||
tc.transactf("ok", "uid search all")
|
||||
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.
|
||||
|
||||
tc.transactf("ok", `search 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 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 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 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)
|
||||
|
||||
// esearchall makes an UntaggedEsearch response with All set, for comparisons.
|
||||
esearchall0 := func(ss string) imapclient.NumSet {
|
||||
seqset := imapclient.NumSet{}
|
||||
for _, rs := range strings.Split(ss, ",") {
|
||||
t := strings.Split(rs, ":")
|
||||
if len(t) > 2 {
|
||||
panic("bad seqset")
|
||||
}
|
||||
var first uint32
|
||||
var last *uint32
|
||||
if t[0] != "*" {
|
||||
v, err := strconv.ParseUint(t[0], 10, 32)
|
||||
if err != nil {
|
||||
panic("parse first")
|
||||
}
|
||||
first = uint32(v)
|
||||
}
|
||||
if len(t) == 2 {
|
||||
if t[1] != "*" {
|
||||
v, err := strconv.ParseUint(t[1], 10, 32)
|
||||
if err != nil {
|
||||
panic("parse last")
|
||||
}
|
||||
u := uint32(v)
|
||||
last = &u
|
||||
}
|
||||
}
|
||||
seqset.Ranges = append(seqset.Ranges, imapclient.NumRange{First: first, Last: last})
|
||||
}
|
||||
return seqset
|
||||
}
|
||||
|
||||
esearchall := func(ss string) imapclient.UntaggedEsearch {
|
||||
return imapclient.UntaggedEsearch{All: esearchall0(ss)}
|
||||
}
|
||||
|
||||
uintptr := func(v uint32) *uint32 {
|
||||
return &v
|
||||
}
|
||||
|
||||
// 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: uintptr(3), All: esearchall0("1:3")})
|
||||
|
||||
tc.transactf("ok", "UID search return (min max count all) all")
|
||||
tc.xesearch(imapclient.UntaggedEsearch{UID: true, Min: 5, Max: 7, Count: uintptr(3), All: esearchall0("5:7")})
|
||||
|
||||
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: uintptr(0)})
|
||||
|
||||
tc.transactf("ok", "search return (min max count all) 1,3")
|
||||
tc.xesearch(imapclient.UntaggedEsearch{Min: 1, Max: 3, Count: uintptr(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: uintptr(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: uintptr(2), All: esearchall0("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: uintptr(2), All: esearchall0("5,7")})
|
||||
|
||||
tc.transactf("no", `search return () charset unknown text "mox"`)
|
||||
tc.transactf("ok", `search return () charset us-ascii text "mox"`)
|
||||
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("ok", "search return (save) 2")
|
||||
tc.xnountagged() // ../rfc/9051:3800
|
||||
tc.transactf("ok", "fetch $ (uid)")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(6)}})
|
||||
|
||||
tc.transactf("ok", "search return (all) $")
|
||||
tc.xesearch(esearchall("2"))
|
||||
|
||||
tc.transactf("ok", "search return (save) $")
|
||||
tc.xnountagged()
|
||||
|
||||
tc.transactf("ok", "search return (save all) all")
|
||||
tc.xesearch(esearchall("1:3"))
|
||||
|
||||
tc.transactf("ok", "search return (all save) all")
|
||||
tc.xesearch(esearchall("1:3"))
|
||||
|
||||
tc.transactf("ok", "search return (min save) all")
|
||||
tc.xesearch(imapclient.UntaggedEsearch{Min: 1})
|
||||
tc.transactf("ok", "fetch $ (uid)")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(5)}})
|
||||
|
||||
// Do a seemingly old-style search command with IMAP4rev2 enabled. We'll still get ESEARCH responses.
|
||||
tc.client.Enable("IMAP4rev2")
|
||||
tc.transactf("ok", `search undraft`)
|
||||
tc.xesearch(esearchall("1:2"))
|
||||
}
|
71
imapserver/selectexamine_test.go
Normal file
71
imapserver/selectexamine_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestSelect(t *testing.T) {
|
||||
testSelectExamine(t, false)
|
||||
}
|
||||
|
||||
func TestExamine(t *testing.T) {
|
||||
testSelectExamine(t, true)
|
||||
}
|
||||
|
||||
// select and examine are pretty much the same. but examine opens readonly instead of readwrite.
|
||||
func testSelectExamine(t *testing.T, examine bool) {
|
||||
defer mockUIDValidity()()
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
cmd := "select"
|
||||
okcode := "READ-WRITE"
|
||||
if examine {
|
||||
cmd = "examine"
|
||||
okcode = "READ-ONLY"
|
||||
}
|
||||
|
||||
uclosed := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "CLOSED", More: "x"}}
|
||||
flags := 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: flags}, More: "x"}}
|
||||
urecent := imapclient.UntaggedRecent(0)
|
||||
uexists0 := imapclient.UntaggedExists(0)
|
||||
uexists1 := imapclient.UntaggedExists(1)
|
||||
uuidval1 := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDVALIDITY", CodeArg: imapclient.CodeUint{Code: "UIDVALIDITY", Num: 1}, More: "x"}}
|
||||
uuidnext1 := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDNEXT", CodeArg: imapclient.CodeUint{Code: "UIDNEXT", Num: 1}, More: "x"}}
|
||||
ulist := imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"}
|
||||
uunseen := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UNSEEN", CodeArg: imapclient.CodeUint{Code: "UNSEEN", Num: 1}, More: "x"}}
|
||||
uuidnext2 := imapclient.UntaggedResult{Status: imapclient.OK, RespText: imapclient.RespText{Code: "UIDNEXT", CodeArg: imapclient.CodeUint{Code: "UIDNEXT", Num: 2}, More: "x"}}
|
||||
|
||||
// Parameter required.
|
||||
tc.transactf("bad", cmd)
|
||||
|
||||
// Mailbox does not exist.
|
||||
tc.transactf("no", cmd+" bogus")
|
||||
|
||||
tc.transactf("ok", cmd+" inbox")
|
||||
tc.xuntagged(uflags, upermflags, urecent, uexists0, uuidval1, uuidnext1, ulist)
|
||||
tc.xcode(okcode)
|
||||
|
||||
tc.transactf("ok", cmd+` "inbox"`)
|
||||
tc.xuntagged(uclosed, uflags, upermflags, urecent, uexists0, uuidval1, uuidnext1, ulist)
|
||||
tc.xcode(okcode)
|
||||
|
||||
// Append a message. It will be reported as UNSEEN.
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.transactf("ok", cmd+" inbox")
|
||||
tc.xuntagged(uclosed, uflags, upermflags, urecent, uunseen, uexists1, uuidval1, uuidnext2, ulist)
|
||||
tc.xcode(okcode)
|
||||
|
||||
// With imap4rev2, we no longer get untagged RECENT or untagged UNSEEN.
|
||||
tc.client.Enable("imap4rev2")
|
||||
tc.transactf("ok", cmd+" inbox")
|
||||
tc.xuntagged(uclosed, uflags, upermflags, uexists1, uuidval1, uuidnext2, ulist)
|
||||
tc.xcode(okcode)
|
||||
}
|
3012
imapserver/server.go
Normal file
3012
imapserver/server.go
Normal file
File diff suppressed because it is too large
Load Diff
646
imapserver/server_test.go
Normal file
646
imapserver/server_test.go
Normal file
@ -0,0 +1,646 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/moxvar"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
func init() {
|
||||
sanityChecks = true
|
||||
}
|
||||
|
||||
func tocrlf(s string) string {
|
||||
return strings.ReplaceAll(s, "\n", "\r\n")
|
||||
}
|
||||
|
||||
// From ../rfc/3501:2589
|
||||
var exampleMsg = tocrlf(`Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)
|
||||
From: Fred Foobar <foobar@Blurdybloop.example>
|
||||
Subject: afternoon meeting
|
||||
To: mooch@owatagu.siam.edu.example
|
||||
Message-Id: <B27397-0100000@Blurdybloop.example>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: TEXT/PLAIN; CHARSET=US-ASCII
|
||||
|
||||
Hello Joe, do you think we can meet at 3:30 tomorrow?
|
||||
|
||||
`)
|
||||
|
||||
/*
|
||||
From ../rfc/2049:801
|
||||
|
||||
Message structure:
|
||||
|
||||
Message - multipart/mixed
|
||||
Part 1 - no content-type
|
||||
Part 2 - text/plain
|
||||
Part 3 - multipart/parallel
|
||||
Part 3.1 - audio/basic (base64)
|
||||
Part 3.2 - image/jpeg (base64, empty)
|
||||
Part 4 - text/enriched
|
||||
Part 5 - message/rfc822
|
||||
Part 5.1 - text/plain (quoted-printable)
|
||||
*/
|
||||
var nestedMessage = tocrlf(`MIME-Version: 1.0
|
||||
From: Nathaniel Borenstein <nsb@nsb.fv.com>
|
||||
To: Ned Freed <ned@innosoft.com>
|
||||
Date: Fri, 07 Oct 1994 16:15:05 -0700 (PDT)
|
||||
Subject: A multipart example
|
||||
Content-Type: multipart/mixed;
|
||||
boundary=unique-boundary-1
|
||||
|
||||
This is the preamble area of a multipart message.
|
||||
Mail readers that understand multipart format
|
||||
should ignore this preamble.
|
||||
|
||||
If you are reading this text, you might want to
|
||||
consider changing to a mail reader that understands
|
||||
how to properly display multipart messages.
|
||||
|
||||
--unique-boundary-1
|
||||
|
||||
... Some text appears here ...
|
||||
|
||||
[Note that the blank between the boundary and the start
|
||||
of the text in this part means no header fields were
|
||||
given and this is text in the US-ASCII character set.
|
||||
It could have been done with explicit typing as in the
|
||||
next part.]
|
||||
|
||||
--unique-boundary-1
|
||||
Content-type: text/plain; charset=US-ASCII
|
||||
|
||||
This could have been part of the previous part, but
|
||||
illustrates explicit versus implicit typing of body
|
||||
parts.
|
||||
|
||||
--unique-boundary-1
|
||||
Content-Type: multipart/parallel; boundary=unique-boundary-2
|
||||
|
||||
--unique-boundary-2
|
||||
Content-Type: audio/basic
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
aGVsbG8NCndvcmxkDQo=
|
||||
|
||||
--unique-boundary-2
|
||||
Content-Type: image/jpeg
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
|
||||
--unique-boundary-2--
|
||||
|
||||
--unique-boundary-1
|
||||
Content-type: text/enriched
|
||||
|
||||
This is <bold><italic>enriched.</italic></bold>
|
||||
<smaller>as defined in RFC 1896</smaller>
|
||||
|
||||
Isn't it
|
||||
<bigger><bigger>cool?</bigger></bigger>
|
||||
|
||||
--unique-boundary-1
|
||||
Content-Type: message/rfc822
|
||||
|
||||
From: info@mox.example
|
||||
To: mox <info@mox.example>
|
||||
Subject: (subject in US-ASCII)
|
||||
Content-Type: Text/plain; charset=ISO-8859-1
|
||||
Content-Transfer-Encoding: Quoted-printable
|
||||
|
||||
... Additional text in ISO-8859-1 goes here ...
|
||||
|
||||
--unique-boundary-1--
|
||||
`)
|
||||
|
||||
func tcheck(t *testing.T, err error, msg string) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %s", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mockUIDValidity() func() {
|
||||
orig := store.InitialUIDValidity
|
||||
store.InitialUIDValidity = func() uint32 {
|
||||
return 1
|
||||
}
|
||||
return func() {
|
||||
store.InitialUIDValidity = orig
|
||||
}
|
||||
}
|
||||
|
||||
type testconn struct {
|
||||
t *testing.T
|
||||
conn net.Conn
|
||||
client *imapclient.Conn
|
||||
done chan struct{}
|
||||
serverConn net.Conn
|
||||
|
||||
// Result of last command.
|
||||
lastUntagged []imapclient.Untagged
|
||||
lastResult imapclient.Result
|
||||
lastErr error
|
||||
}
|
||||
|
||||
func (tc *testconn) check(err error, msg string) {
|
||||
tc.t.Helper()
|
||||
if err != nil {
|
||||
tc.t.Fatalf("%s: %s", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testconn) last(l []imapclient.Untagged, r imapclient.Result, err error) {
|
||||
tc.lastUntagged = l
|
||||
tc.lastResult = r
|
||||
tc.lastErr = err
|
||||
}
|
||||
|
||||
func (tc *testconn) xcode(s string) {
|
||||
tc.t.Helper()
|
||||
if tc.lastResult.Code != s {
|
||||
tc.t.Fatalf("got last code %q, expected %q", tc.lastResult.Code, s)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testconn) xcodeArg(v any) {
|
||||
tc.t.Helper()
|
||||
if !reflect.DeepEqual(tc.lastResult.CodeArg, v) {
|
||||
tc.t.Fatalf("got last code argument %v, expected %v", tc.lastResult.CodeArg, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testconn) xuntagged(exps ...any) {
|
||||
tc.t.Helper()
|
||||
last := append([]imapclient.Untagged{}, tc.lastUntagged...)
|
||||
next:
|
||||
for ei, exp := range exps {
|
||||
for i, l := range last {
|
||||
if reflect.TypeOf(l) != reflect.TypeOf(exp) {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(l, exp) {
|
||||
tc.t.Fatalf("untagged data mismatch, got:\n\t%T %#v\nexpected:\n\t%T %#v", l, l, exp, exp)
|
||||
}
|
||||
copy(last[i:], last[i+1:])
|
||||
last = last[:len(last)-1]
|
||||
continue next
|
||||
}
|
||||
var next string
|
||||
if len(tc.lastUntagged) > 0 {
|
||||
next = fmt.Sprintf(", next %#v", tc.lastUntagged[0])
|
||||
}
|
||||
tc.t.Fatalf("did not find untagged response %#v %T (%d) in %v%s", exp, exp, ei, tc.lastUntagged, next)
|
||||
}
|
||||
if len(last) > 0 {
|
||||
tc.t.Fatalf("leftover untagged responses %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func tuntagged(t *testing.T, got imapclient.Untagged, dst any) {
|
||||
t.Helper()
|
||||
gotv := reflect.ValueOf(got)
|
||||
dstv := reflect.ValueOf(dst)
|
||||
if gotv.Type() != dstv.Type().Elem() {
|
||||
t.Fatalf("got %v, expected %v", gotv.Type(), dstv.Type().Elem())
|
||||
}
|
||||
dstv.Elem().Set(gotv)
|
||||
}
|
||||
|
||||
func (tc *testconn) xnountagged() {
|
||||
tc.t.Helper()
|
||||
if len(tc.lastUntagged) != 0 {
|
||||
tc.t.Fatalf("got %v untagged, expected 0", tc.lastUntagged)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testconn) transactf(status, format string, args ...any) {
|
||||
tc.t.Helper()
|
||||
tc.cmdf("", format, args...)
|
||||
tc.response(status)
|
||||
}
|
||||
|
||||
func (tc *testconn) response(status string) {
|
||||
tc.t.Helper()
|
||||
tc.lastUntagged, tc.lastResult, tc.lastErr = tc.client.Response()
|
||||
tcheck(tc.t, tc.lastErr, "read imap response")
|
||||
if strings.ToUpper(status) != string(tc.lastResult.Status) {
|
||||
tc.t.Fatalf("got status %q, expected %q", tc.lastResult.Status, status)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testconn) cmdf(tag, format string, args ...any) {
|
||||
tc.t.Helper()
|
||||
err := tc.client.Commandf(tag, format, args...)
|
||||
tcheck(tc.t, err, "writing imap command")
|
||||
}
|
||||
|
||||
func (tc *testconn) readstatus(status string) {
|
||||
tc.t.Helper()
|
||||
tc.response(status)
|
||||
}
|
||||
|
||||
func (tc *testconn) readprefixline(pre string) {
|
||||
tc.t.Helper()
|
||||
line, err := tc.client.Readline()
|
||||
tcheck(tc.t, err, "read line")
|
||||
if !strings.HasPrefix(line, pre) {
|
||||
tc.t.Fatalf("expected prefix %q, got %q", pre, line)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testconn) writelinef(format string, args ...any) {
|
||||
tc.t.Helper()
|
||||
err := tc.client.Writelinef(format, args...)
|
||||
tcheck(tc.t, err, "write line")
|
||||
}
|
||||
|
||||
// wait at most 1 second for server to quit.
|
||||
func (tc *testconn) waitDone() {
|
||||
tc.t.Helper()
|
||||
t := time.NewTimer(time.Second)
|
||||
select {
|
||||
case <-tc.done:
|
||||
t.Stop()
|
||||
case <-t.C:
|
||||
tc.t.Fatalf("server not done within 1s")
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testconn) close() {
|
||||
tc.client.Close()
|
||||
tc.serverConn.Close()
|
||||
tc.waitDone()
|
||||
}
|
||||
|
||||
var connCounter int64
|
||||
|
||||
func start(t *testing.T) *testconn {
|
||||
return startArgs(t, true, false, true)
|
||||
}
|
||||
|
||||
func startNoSwitchboard(t *testing.T) *testconn {
|
||||
return startArgs(t, false, false, true)
|
||||
}
|
||||
|
||||
func startArgs(t *testing.T, first, isTLS, allowLoginWithoutTLS bool) *testconn {
|
||||
if first {
|
||||
os.RemoveAll("../testdata/imap/data")
|
||||
}
|
||||
mox.Context = context.Background()
|
||||
mox.ConfigStaticPath = "../testdata/imap/mox.conf"
|
||||
mox.MustLoadConfig()
|
||||
acc, err := store.OpenAccount("mjl")
|
||||
tcheck(t, err, "open account")
|
||||
if first {
|
||||
err = acc.SetPassword("testtest")
|
||||
tcheck(t, err, "set password")
|
||||
}
|
||||
err = acc.Close()
|
||||
tcheck(t, err, "close account")
|
||||
var switchDone chan struct{}
|
||||
if first {
|
||||
switchDone = store.Switchboard()
|
||||
} else {
|
||||
switchDone = make(chan struct{}) // Dummy, that can be closed.
|
||||
}
|
||||
|
||||
serverConn, clientConn := net.Pipe()
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{fakeCert(t)},
|
||||
}
|
||||
if isTLS {
|
||||
serverConn = tls.Server(serverConn, tlsConfig)
|
||||
clientConn = tls.Client(clientConn, &tls.Config{InsecureSkipVerify: true})
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
connCounter++
|
||||
cid := connCounter
|
||||
go func() {
|
||||
serve("test", cid, tlsConfig, serverConn, isTLS, allowLoginWithoutTLS)
|
||||
close(switchDone)
|
||||
close(done)
|
||||
}()
|
||||
client, err := imapclient.New(clientConn, true)
|
||||
tcheck(t, err, "new client")
|
||||
return &testconn{t: t, conn: clientConn, client: client, done: done, serverConn: serverConn}
|
||||
}
|
||||
|
||||
func fakeCert(t *testing.T) tls.Certificate {
|
||||
privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real!
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), // Required field...
|
||||
}
|
||||
localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
|
||||
if err != nil {
|
||||
t.Fatalf("making certificate: %s", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(localCertBuf)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing generated certificate: %s", err)
|
||||
}
|
||||
c := tls.Certificate{
|
||||
Certificate: [][]byte{localCertBuf},
|
||||
PrivateKey: privKey,
|
||||
Leaf: cert,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.transactf("bad", "login too many args")
|
||||
tc.transactf("bad", "login") // no args
|
||||
tc.transactf("no", "login mjl@mox.example badpass")
|
||||
tc.transactf("no", "login mjl testtest") // must use email, not account
|
||||
tc.transactf("no", "login mjl@mox.example test")
|
||||
tc.transactf("no", "login mjl@mox.example testtesttest")
|
||||
tc.transactf("no", `login "mjl@mox.example" "testtesttest"`)
|
||||
tc.transactf("no", "login \"m\xf8x@mox.example\" \"testtesttest\"")
|
||||
tc.transactf("ok", "login mjl@mox.example testtest")
|
||||
tc.close()
|
||||
|
||||
tc = start(t)
|
||||
defer tc.close()
|
||||
tc.transactf("ok", `login "mjl@mox.example" "testtest"`)
|
||||
|
||||
tc.transactf("bad", "logout badarg")
|
||||
tc.transactf("ok", "logout")
|
||||
}
|
||||
|
||||
// Test that commands don't work in the states they are not supposed to.
|
||||
func TestState(t *testing.T) {
|
||||
tc := start(t)
|
||||
|
||||
tc.transactf("bad", "boguscommand")
|
||||
|
||||
notAuthenticated := []string{"starttls", "authenticate", "login"}
|
||||
authenticatedOrSelected := []string{"enable", "select", "examine", "create", "delete", "rename", "subscribe", "unsubscribe", "list", "namespace", "status", "append", "idle", "lsub"}
|
||||
selected := []string{"close", "unselect", "expunge", "search", "fetch", "store", "copy", "move", "uid expunge"}
|
||||
|
||||
// Always allowed.
|
||||
tc.transactf("ok", "capability")
|
||||
tc.transactf("ok", "noop")
|
||||
tc.transactf("ok", "logout")
|
||||
tc.close()
|
||||
tc = start(t)
|
||||
defer tc.close()
|
||||
|
||||
// Not authenticated, lots of commands not allowed.
|
||||
for _, cmd := range append(append([]string{}, authenticatedOrSelected...), selected...) {
|
||||
tc.transactf("no", "%s", cmd)
|
||||
}
|
||||
|
||||
// Some commands not allowed when authenticated.
|
||||
tc.transactf("ok", "login mjl@mox.example testtest")
|
||||
for _, cmd := range append(append([]string{}, notAuthenticated...), selected...) {
|
||||
tc.transactf("no", "%s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiterals(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Create("tmpbox")
|
||||
|
||||
tc.transactf("ok", "rename {6+}\r\ntmpbox {7+}\r\nntmpbox")
|
||||
|
||||
from := "ntmpbox"
|
||||
to := "tmpbox"
|
||||
fmt.Fprint(tc.client, "xtag rename ")
|
||||
tc.client.WriteSyncLiteral(from)
|
||||
fmt.Fprint(tc.client, " ")
|
||||
tc.client.WriteSyncLiteral(to)
|
||||
fmt.Fprint(tc.client, "\r\n")
|
||||
tc.client.LastTag = "xtag"
|
||||
tc.last(tc.client.Response())
|
||||
if tc.lastResult.Status != "OK" {
|
||||
tc.t.Fatalf(`got %q, expected "OK"`, tc.lastResult.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Test longer scenario with login, lists, subscribes, status, selects, etc.
|
||||
func TestScenario(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
tc.transactf("ok", "login mjl@mox.example testtest")
|
||||
|
||||
tc.transactf("bad", " missingcommand")
|
||||
|
||||
tc.transactf("ok", "examine inbox")
|
||||
tc.transactf("ok", "unselect")
|
||||
|
||||
tc.transactf("ok", "examine inbox")
|
||||
tc.transactf("ok", "close")
|
||||
|
||||
tc.transactf("ok", "select inbox")
|
||||
tc.transactf("ok", "close")
|
||||
|
||||
tc.transactf("ok", "select inbox")
|
||||
tc.transactf("ok", "expunge")
|
||||
tc.transactf("ok", "check")
|
||||
|
||||
tc.transactf("ok", "subscribe inbox")
|
||||
tc.transactf("ok", "unsubscribe inbox")
|
||||
tc.transactf("ok", "subscribe inbox")
|
||||
|
||||
tc.transactf("ok", `lsub "" "*"`)
|
||||
|
||||
tc.transactf("ok", `list "" ""`)
|
||||
tc.transactf("ok", `namespace`)
|
||||
|
||||
tc.transactf("ok", "enable utf8=accept")
|
||||
tc.transactf("ok", "enable imap4rev2 utf8=accept")
|
||||
|
||||
tc.transactf("no", "create inbox")
|
||||
tc.transactf("ok", "create tmpbox")
|
||||
tc.transactf("ok", "rename tmpbox ntmpbox")
|
||||
tc.transactf("ok", "delete ntmpbox")
|
||||
|
||||
tc.transactf("ok", "status inbox (uidnext messages uidvalidity deleted size unseen recent)")
|
||||
|
||||
tc.transactf("ok", "append inbox (\\seen) {%d+}\r\n%s", len(exampleMsg), exampleMsg)
|
||||
tc.transactf("no", "append bogus () {%d}", len(exampleMsg))
|
||||
tc.cmdf("", "append inbox () {%d}", len(exampleMsg))
|
||||
tc.readprefixline("+")
|
||||
_, err := tc.conn.Write([]byte(exampleMsg + "\r\n"))
|
||||
tc.check(err, "write message")
|
||||
tc.response("ok")
|
||||
|
||||
tc.transactf("ok", "fetch 1 all")
|
||||
tc.transactf("ok", "fetch 1 body")
|
||||
tc.transactf("ok", "fetch 1 binary[]")
|
||||
|
||||
tc.transactf("ok", `store 1 flags (\seen \answered)`)
|
||||
tc.transactf("ok", `store 1 +flags ($junk)`) // should train as junk.
|
||||
tc.transactf("ok", `store 1 -flags ($junk)`) // should retrain as non-junk.
|
||||
tc.transactf("ok", `store 1 -flags (\seen)`) // should untrain completely.
|
||||
tc.transactf("ok", `store 1 -flags (\answered)`)
|
||||
tc.transactf("ok", `store 1 +flags (\answered)`)
|
||||
tc.transactf("ok", `store 1 flags.silent (\seen \answered)`)
|
||||
tc.transactf("ok", `store 1 -flags.silent (\answered)`)
|
||||
tc.transactf("ok", `store 1 +flags.silent (\answered)`)
|
||||
tc.transactf("no", `store 1 flags (\badflag)`)
|
||||
tc.transactf("ok", "noop")
|
||||
|
||||
tc.transactf("ok", "copy 1 Trash")
|
||||
tc.transactf("ok", "copy 1 Trash")
|
||||
tc.transactf("ok", "move 1 Trash")
|
||||
|
||||
tc.transactf("ok", "close")
|
||||
tc.transactf("ok", "select Trash")
|
||||
tc.transactf("ok", `store 1 flags (\deleted)`)
|
||||
tc.transactf("ok", "expunge")
|
||||
tc.transactf("ok", "noop")
|
||||
|
||||
tc.transactf("ok", `store 1 flags (\deleted)`)
|
||||
tc.transactf("ok", "close")
|
||||
tc.transactf("ok", "delete Trash")
|
||||
}
|
||||
|
||||
func TestMailbox(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
invalid := []string{
|
||||
"e\u0301", // é but as e + acute, not unicode-normalized
|
||||
"/leadingslash",
|
||||
"a//b",
|
||||
"Inbox/",
|
||||
"\x01",
|
||||
" ",
|
||||
"\x7f",
|
||||
"\x80",
|
||||
"\u2028",
|
||||
"\u2029",
|
||||
}
|
||||
for _, bad := range invalid {
|
||||
tc.transactf("no", "select {%d+}\r\n%s", len(bad), bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxDeleted(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.client.Create("testbox")
|
||||
tc2.client.Select("testbox")
|
||||
tc.client.Delete("testbox")
|
||||
|
||||
// Now try to operate on testbox while it has been removed.
|
||||
tc2.transactf("no", "check")
|
||||
tc2.transactf("no", "expunge")
|
||||
tc2.transactf("no", "uid expunge 1")
|
||||
tc2.transactf("no", "search all")
|
||||
tc2.transactf("no", "uid search all")
|
||||
tc2.transactf("no", "fetch 1:* all")
|
||||
tc2.transactf("no", "uid fetch 1 all")
|
||||
tc2.transactf("no", "store 1 flags ()")
|
||||
tc2.transactf("no", "uid store 1 flags ()")
|
||||
tc2.transactf("bad", "copy 1 inbox") // msgseq 1 not available.
|
||||
tc2.transactf("no", "uid copy 1 inbox")
|
||||
tc2.transactf("bad", "move 1 inbox") // msgseq 1 not available.
|
||||
tc2.transactf("no", "uid move 1 inbox")
|
||||
|
||||
tc2.transactf("ok", "unselect")
|
||||
|
||||
tc.client.Create("testbox")
|
||||
tc2.client.Select("testbox")
|
||||
tc.client.Delete("testbox")
|
||||
tc2.transactf("ok", "close")
|
||||
}
|
||||
|
||||
func TestID(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.transactf("ok", "id nil")
|
||||
tc.xuntagged(imapclient.UntaggedID{"name": "mox", "version": moxvar.Version})
|
||||
|
||||
tc.transactf("ok", `id ("name" "mox" "version" "1.2.3" "other" "test" "test" nil)`)
|
||||
tc.xuntagged(imapclient.UntaggedID{"name": "mox", "version": moxvar.Version})
|
||||
|
||||
tc.transactf("bad", `id ("name" "mox" "name" "mox")`) // Duplicate field.
|
||||
}
|
||||
|
||||
func TestSequence(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Select("inbox")
|
||||
|
||||
tc.transactf("bad", "fetch * 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 * all") // * is like uidnext, a non-existing message.
|
||||
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.transactf("ok", "fetch 2:1,1 uid") // We reorder 2:1 to 1:2, but we don't deduplicate numbers.
|
||||
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.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(2)}})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// todo: possibly implement the additional reference counting. so far it hasn't been worth the trouble.
|
||||
func disabledTestReference(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Select("inbox")
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Select("inbox")
|
||||
|
||||
tc.client.StoreFlagsSet("1", true, `\Deleted`)
|
||||
tc.client.Expunge()
|
||||
|
||||
tc3 := startNoSwitchboard(t)
|
||||
defer tc3.close()
|
||||
tc3.client.Login("mjl@mox.example", "testtest")
|
||||
tc3.transactf("ok", `list "" "inbox" return (status (messages))`)
|
||||
tc3.xuntagged(imapclient.UntaggedList{Separator: '/', Mailbox: "Inbox"}, imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"MESSAGES": 0}})
|
||||
|
||||
tc2.transactf("ok", "fetch 1 rfc822.size")
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchRFC822Size(len(exampleMsg))}})
|
||||
}
|
28
imapserver/starttls_test.go
Normal file
28
imapserver/starttls_test.go
Normal file
@ -0,0 +1,28 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStarttls(t *testing.T) {
|
||||
tc := start(t)
|
||||
tc.client.Starttls(&tls.Config{InsecureSkipVerify: true})
|
||||
tc.transactf("bad", "starttls") // TLS already active.
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.close()
|
||||
|
||||
tc = startArgs(t, true, true, false)
|
||||
tc.transactf("bad", "starttls") // TLS already active.
|
||||
tc.close()
|
||||
|
||||
tc = startArgs(t, true, false, false)
|
||||
tc.transactf("no", `login "mjl@mox.example" "testtest"`)
|
||||
tc.xcode("PRIVACYREQUIRED")
|
||||
tc.transactf("no", "authenticate PLAIN %s", base64.StdEncoding.EncodeToString([]byte("\u0000mjl@mox.example\u0000testtest")))
|
||||
tc.xcode("PRIVACYREQUIRED")
|
||||
tc.client.Starttls(&tls.Config{InsecureSkipVerify: true})
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.close()
|
||||
}
|
34
imapserver/status_test.go
Normal file
34
imapserver/status_test.go
Normal file
@ -0,0 +1,34 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestStatus(t *testing.T) {
|
||||
defer mockUIDValidity()()
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.transactf("bad", "status") // Missing param.
|
||||
tc.transactf("bad", "status inbox") // Missing param.
|
||||
tc.transactf("bad", "status inbox ()") // At least one attribute required.
|
||||
tc.transactf("bad", "status inbox (uidvalidity) ") // Leftover data.
|
||||
tc.transactf("bad", "status inbox (unknown)") // Unknown attribute.
|
||||
|
||||
tc.transactf("ok", "status inbox (messages uidnext uidvalidity unseen deleted size recent appendlimit)")
|
||||
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"MESSAGES": 0, "UIDVALIDITY": 1, "UIDNEXT": 1, "UNSEEN": 0, "DELETED": 0, "SIZE": 0, "RECENT": 0, "APPENDLIMIT": 0}})
|
||||
|
||||
// Again, now with a message in the mailbox.
|
||||
tc.transactf("ok", "append inbox {4+}\r\ntest")
|
||||
tc.transactf("ok", "status inbox (messages uidnext uidvalidity unseen deleted size recent appendlimit)")
|
||||
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"MESSAGES": 1, "UIDVALIDITY": 1, "UIDNEXT": 2, "UNSEEN": 1, "DELETED": 0, "SIZE": 4, "RECENT": 0, "APPENDLIMIT": 0}})
|
||||
|
||||
tc.client.Select("inbox")
|
||||
tc.client.StoreFlagsSet("1", true, `\Deleted`)
|
||||
tc.transactf("ok", "status inbox (messages uidnext uidvalidity unseen deleted size recent appendlimit)")
|
||||
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"MESSAGES": 1, "UIDVALIDITY": 1, "UIDNEXT": 2, "UNSEEN": 1, "DELETED": 1, "SIZE": 4, "RECENT": 0, "APPENDLIMIT": 0}})
|
||||
}
|
68
imapserver/store_test.go
Normal file
68
imapserver/store_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestStore(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Enable("imap4rev2")
|
||||
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.Select("inbox")
|
||||
|
||||
uid1 := imapclient.FetchUID(1)
|
||||
noflags := imapclient.FetchFlags(nil)
|
||||
|
||||
tc.transactf("ok", "store 1 flags.silent ()")
|
||||
tc.xuntagged()
|
||||
|
||||
tc.transactf("ok", `store 1 flags ()`)
|
||||
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("ok", `store 1 flags.silent (\Seen)`)
|
||||
tc.xuntagged()
|
||||
tc.transactf("ok", `fetch 1 flags`)
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`\Seen`}}})
|
||||
|
||||
tc.transactf("ok", `store 1 flags ($Junk)`)
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`$Junk`}}})
|
||||
tc.transactf("ok", `fetch 1 flags`)
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`$Junk`}}})
|
||||
|
||||
tc.transactf("ok", `store 1 +flags ()`)
|
||||
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, imapclient.FetchFlags{`$Junk`}}})
|
||||
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.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, noflags}})
|
||||
|
||||
tc.transactf("bad", "store") // Need numset, flags and args.
|
||||
tc.transactf("bad", "store 1") // Need flags.
|
||||
tc.transactf("bad", "store 1 +") // Need flags.
|
||||
tc.transactf("bad", "store 1 -") // Need flags.
|
||||
tc.transactf("bad", "store 1 flags ") // Need flags.
|
||||
tc.transactf("bad", "store 1 flags ") // Need flags.
|
||||
tc.transactf("bad", "store 1 flags (bogus)") // Unknown flag.
|
||||
|
||||
tc.client.Unselect()
|
||||
tc.client.Examine("inbox") // Open read-only.
|
||||
tc.transactf("no", `store 1 flags ()`) // No permission to set flags.
|
||||
}
|
32
imapserver/subscribe_test.go
Normal file
32
imapserver/subscribe_test.go
Normal file
@ -0,0 +1,32 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestSubscribe(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc2 := startNoSwitchboard(t)
|
||||
defer tc2.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc2.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.transactf("bad", "subscribe") // Missing param.
|
||||
tc.transactf("bad", "subscribe ") // Missing param.
|
||||
tc.transactf("bad", "subscribe fine ") // Leftover data.
|
||||
|
||||
tc.transactf("ok", "subscribe a/b")
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`, `\NonExistent`}, Separator: '/', Mailbox: "a/b"})
|
||||
tc.transactf("ok", "subscribe a/b") // Already subscribed, which is fine.
|
||||
tc2.transactf("ok", "noop")
|
||||
tc2.xuntagged() // But no new changes.
|
||||
|
||||
tc.transactf("ok", `list (subscribed) "" "a*" return (subscribed)`)
|
||||
tc.xuntagged(imapclient.UntaggedList{Flags: []string{`\Subscribed`, `\NonExistent`}, Separator: '/', Mailbox: "a/b"})
|
||||
}
|
26
imapserver/unselect_test.go
Normal file
26
imapserver/unselect_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/imapclient"
|
||||
)
|
||||
|
||||
func TestUnselect(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
tc.client.Select("inbox")
|
||||
|
||||
tc.transactf("bad", "unselect bogus") // Leftover data.
|
||||
tc.transactf("ok", "unselect")
|
||||
tc.transactf("no", "fetch 1 all") // Invalid when not selected.
|
||||
|
||||
tc.client.Select("inbox")
|
||||
tc.client.Append("inbox", nil, nil, []byte(exampleMsg))
|
||||
tc.client.StoreFlagsAdd("1", true, `\Deleted`)
|
||||
tc.transactf("ok", "unselect")
|
||||
tc.transactf("ok", "status inbox (messages)")
|
||||
tc.xuntagged(imapclient.UntaggedStatus{Mailbox: "Inbox", Attrs: map[string]int64{"MESSAGES": 1}}) // Message not removed.
|
||||
}
|
23
imapserver/unsubscribe_test.go
Normal file
23
imapserver/unsubscribe_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnsubscribe(t *testing.T) {
|
||||
tc := start(t)
|
||||
defer tc.close()
|
||||
|
||||
tc.client.Login("mjl@mox.example", "testtest")
|
||||
|
||||
tc.transactf("bad", "unsubscribe") // Missing param.
|
||||
tc.transactf("bad", "unsubscribe ") // Missing param.
|
||||
tc.transactf("bad", "unsubscribe fine ") // Leftover data.
|
||||
|
||||
tc.transactf("no", "unsubscribe a/b") // Does not exist and is not subscribed.
|
||||
tc.transactf("ok", "create a/b")
|
||||
tc.transactf("ok", "unsubscribe a/b")
|
||||
tc.transactf("ok", "unsubscribe a/b") // Can unsubscribe even if it does not exist.
|
||||
tc.transactf("ok", "subscribe a/b")
|
||||
tc.transactf("ok", "unsubscribe a/b")
|
||||
}
|
83
imapserver/utf7.go
Normal file
83
imapserver/utf7.go
Normal file
@ -0,0 +1,83 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const utf7chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,"
|
||||
|
||||
var utf7encoding = base64.NewEncoding(utf7chars).WithPadding(base64.NoPadding)
|
||||
|
||||
var (
|
||||
errUTF7SuperfluousShift = errors.New("utf7: superfluous unshift+shift")
|
||||
errUTF7Base64 = errors.New("utf7: bad base64")
|
||||
errUTF7OddSized = errors.New("utf7: odd-sized data")
|
||||
errUTF7UnneededShift = errors.New("utf7: unneeded shift")
|
||||
errUTF7UnfinishedShift = errors.New("utf7: unfinished shift")
|
||||
)
|
||||
|
||||
func utf7decode(s string) (string, error) {
|
||||
var r string
|
||||
var shifted bool
|
||||
var b string
|
||||
lastunshift := -2
|
||||
|
||||
for i, c := range s {
|
||||
if !shifted {
|
||||
if c == '&' {
|
||||
if lastunshift == i-1 {
|
||||
return "", errUTF7SuperfluousShift
|
||||
}
|
||||
shifted = true
|
||||
} else {
|
||||
r += string(c)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if c != '-' {
|
||||
b += string(c)
|
||||
continue
|
||||
}
|
||||
|
||||
shifted = false
|
||||
lastunshift = i
|
||||
if b == "" {
|
||||
r += "&"
|
||||
continue
|
||||
}
|
||||
buf, err := utf7encoding.DecodeString(b)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %q: %v", errUTF7Base64, b, err)
|
||||
}
|
||||
b = ""
|
||||
|
||||
if len(buf)%2 != 0 {
|
||||
return "", errUTF7OddSized
|
||||
}
|
||||
|
||||
x := make([]rune, len(buf)/2)
|
||||
j := 0
|
||||
for i := 0; i < len(buf); i += 2 {
|
||||
x[j] = rune(buf[i])<<8 | rune(buf[i+1])
|
||||
j++
|
||||
}
|
||||
|
||||
need := false
|
||||
for _, c := range x {
|
||||
if c < 0x20 || c > 0x7e || c == '&' {
|
||||
need = true
|
||||
}
|
||||
r += string(c)
|
||||
}
|
||||
if !need {
|
||||
return "", errUTF7UnneededShift
|
||||
}
|
||||
}
|
||||
if shifted {
|
||||
return "", errUTF7UnfinishedShift
|
||||
}
|
||||
return r, nil
|
||||
}
|
33
imapserver/utf7_test.go
Normal file
33
imapserver/utf7_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUTF7(t *testing.T) {
|
||||
check := func(input string, output string, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
r, err := utf7decode(input)
|
||||
if r != output {
|
||||
t.Fatalf("got %q, expected %q (err %v), for input %q", r, output, err, input)
|
||||
}
|
||||
if (expErr == nil) != (err == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("got err %v, expected %v", err, expErr)
|
||||
}
|
||||
}
|
||||
|
||||
check("plain", "plain", nil)
|
||||
check("&Jjo-", "☺", nil)
|
||||
check("test&Jjo-", "test☺", nil)
|
||||
check("&Jjo-test&Jjo-", "☺test☺", nil)
|
||||
check("&Jjo-test", "☺test", nil)
|
||||
check("&-", "&", nil)
|
||||
check("&-", "&", nil)
|
||||
check("&Jjo", "", errUTF7UnfinishedShift) // missing closing -
|
||||
check("&Jjo-&-", "", errUTF7SuperfluousShift) // shift just after unshift not allowed, should have been a single shift.
|
||||
check("&AGE-", "", errUTF7UnneededShift) // Just 'a', does not need utf7.
|
||||
check("&☺-", "", errUTF7Base64)
|
||||
check("&YQ-", "", errUTF7OddSized) // Just a single byte 'a'
|
||||
}
|
Reference in New Issue
Block a user