This commit is contained in:
Mechiel Lukkien
2023-01-30 14:27:06 +01:00
commit cb229cb6cf
1256 changed files with 491723 additions and 0 deletions

77
imapserver/append_test.go Normal file
View 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}})
}

View 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
View 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
View 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
View 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
View 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...)})
}

View 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
View 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: &sectionSpec{
msgtext: &sectionMsgtext{s: "HEADER"},
},
}
respField, t := cmd.xbody(ba)
if respField == "" {
return nil
}
return []token{bare(a.field), t}
case "RFC822":
ba := fetchAtt{
field: "BODY",
section: &sectionSpec{},
}
respField, t := cmd.xbody(ba)
if respField == "" {
return nil
}
return []token{bare(a.field), t}
case "RFC822.TEXT":
ba := fetchAtt{
field: "BODY",
section: &sectionSpec{
msgtext: &sectionMsgtext{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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 = &sectionMsgtext{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 &sectionSpec{msgtext: p.xsectionMsgtext()}
}
defer p.context("part...")()
pt := &sectionPart{}
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 = &sectionText{mime: true}
break
}
pt.text = &sectionText{msgtext: p.xsectionMsgtext()}
break
}
return &sectionSpec{part: pt}
}
// ../rfc/9051:6985 ../rfc/3501:4975
func (p *parser) xsection() *sectionSpec {
defer p.context("parseSection")()
p.xtake("[")
if p.take("]") {
return &sectionSpec{}
}
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
View 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
View 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
}

View 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
View 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
View 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
View 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"))
}

View 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

File diff suppressed because it is too large Load Diff

646
imapserver/server_test.go Normal file
View 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))}})
}

View 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
View 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
View 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.
}

View 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"})
}

View 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.
}

View 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
View 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
View 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'
}