From 0e253ba422a65a9f6174cb3e7e86201ee5c71fec Mon Sep 17 00:00:00 2001 From: Anastasios Svolis Date: Thu, 1 May 2025 12:02:19 +0300 Subject: [PATCH] This is the v1 version, had the v2 before. --- LICENSE | 2 +- README.md | 181 ++- backend/appendlimit.go | 29 + backend/backend.go | 20 + backend/backendutil/backendutil.go | 2 + backend/backendutil/backendutil_test.go | 79 ++ backend/backendutil/body.go | 121 ++ backend/backendutil/body_test.go | 196 +++ backend/backendutil/bodystructure.go | 117 ++ backend/backendutil/bodystructure_test.go | 76 ++ backend/backendutil/envelope.go | 58 + backend/backendutil/envelope_test.go | 40 + backend/backendutil/flags.go | 73 + backend/backendutil/flags_test.go | 86 ++ backend/backendutil/search.go | 230 ++++ backend/backendutil/search_test.go | 432 ++++++ backend/mailbox.go | 78 ++ backend/memory/backend.go | 78 ++ backend/memory/mailbox.go | 243 ++++ backend/memory/message.go | 74 + backend/memory/user.go | 82 ++ backend/move.go | 19 + backend/updates.go | 98 ++ backend/user.go | 92 ++ client/client.go | 695 ++++++++++ client/client_test.go | 187 +++ client/cmd_any.go | 88 ++ client/cmd_any_test.go | 80 ++ client/cmd_auth.go | 380 ++++++ client/cmd_auth_test.go | 499 +++++++ client/cmd_noauth.go | 174 +++ client/cmd_noauth_test.go | 299 +++++ client/cmd_selected.go | 372 ++++++ client/cmd_selected_test.go | 816 ++++++++++++ client/example_test.go | 323 +++++ client/tag.go | 24 + command.go | 57 + command_test.go | 98 ++ commands/append.go | 93 ++ commands/authenticate.go | 124 ++ commands/capability.go | 18 + commands/check.go | 18 + commands/close.go | 18 + commands/commands.go | 2 + commands/copy.go | 47 + commands/create.go | 38 + commands/delete.go | 38 + commands/enable.go | 28 + commands/expunge.go | 16 + commands/fetch.go | 63 + commands/idle.go | 17 + commands/list.go | 60 + commands/login.go | 36 + commands/logout.go | 18 + commands/move.go | 48 + commands/noop.go | 18 + commands/rename.go | 51 + commands/search.go | 57 + commands/select.go | 45 + commands/starttls.go | 18 + commands/status.go | 58 + commands/store.go | 50 + commands/subscribe.go | 63 + commands/uid.go | 44 + commands/unselect.go | 17 + conn.go | 284 ++++ conn_test.go | 107 ++ date.go | 71 + date_test.go | 95 ++ go-imap-1.zip | Bin 0 -> 150867 bytes go.mod | 9 +- go.sum | 41 +- imap.go | 172 +-- internal/acl.go | 13 - internal/imapnum/numset.go | 306 ----- internal/imapwire/decoder.go | 654 --------- internal/imapwire/encoder.go | 341 ----- internal/imapwire/imapwire.go | 47 - internal/imapwire/num.go | 39 - internal/internal.go | 170 --- internal/sasl.go | 23 - internal/testcert.go | 37 + internal/testutil.go | 16 + internal/utf7/utf7.go | 13 - literal.go | 13 + logger.go | 8 + mailbox.go | 314 +++++ mailbox_test.go | 191 +++ message.go | 1186 +++++++++++++++++ message_test.go | 797 +++++++++++ read.go | 467 +++++++ read_test.go | 536 ++++++++ response.go | 240 +++- response_test.go | 252 ++++ responses/authenticate.go | 61 + responses/capability.go | 20 + responses/enabled.go | 33 + responses/expunge.go | 43 + responses/fetch.go | 70 + responses/idle.go | 38 + responses/list.go | 57 + responses/list_test.go | 65 + responses/responses.go | 35 + responses/search.go | 41 + responses/select.go | 142 ++ responses/status.go | 53 + search.go | 505 ++++--- search_test.go | 141 ++ seqset.go | 289 ++++ .../imapnum/numset_test.go => seqset_test.go | 210 +-- server/cmd_any.go | 52 + server/cmd_any_test.go | 129 ++ server/cmd_auth.go | 324 +++++ server/cmd_auth_test.go | 611 +++++++++ server/cmd_noauth.go | 132 ++ server/cmd_noauth_test.go | 225 ++++ server/cmd_selected.go | 346 +++++ server/cmd_selected_test.go | 584 ++++++++ server/conn.go | 421 ++++++ server/server.go | 419 ++++++ server/server_test.go | 49 + status.go | 157 ++- status_test.go | 94 ++ {internal/utf7 => utf7}/decoder.go | 97 +- {internal/utf7 => utf7}/decoder_test.go | 12 +- {internal/utf7 => utf7}/encoder.go | 55 +- {internal/utf7 => utf7}/encoder_test.go | 6 +- utf7/utf7.go | 34 + write.go | 255 ++++ write_test.go | 292 ++++ 130 files changed, 18061 insertions(+), 2179 deletions(-) create mode 100644 backend/appendlimit.go create mode 100644 backend/backend.go create mode 100644 backend/backendutil/backendutil.go create mode 100644 backend/backendutil/backendutil_test.go create mode 100644 backend/backendutil/body.go create mode 100644 backend/backendutil/body_test.go create mode 100644 backend/backendutil/bodystructure.go create mode 100644 backend/backendutil/bodystructure_test.go create mode 100644 backend/backendutil/envelope.go create mode 100644 backend/backendutil/envelope_test.go create mode 100644 backend/backendutil/flags.go create mode 100644 backend/backendutil/flags_test.go create mode 100644 backend/backendutil/search.go create mode 100644 backend/backendutil/search_test.go create mode 100644 backend/mailbox.go create mode 100644 backend/memory/backend.go create mode 100644 backend/memory/mailbox.go create mode 100644 backend/memory/message.go create mode 100644 backend/memory/user.go create mode 100644 backend/move.go create mode 100644 backend/updates.go create mode 100644 backend/user.go create mode 100644 client/client.go create mode 100644 client/client_test.go create mode 100644 client/cmd_any.go create mode 100644 client/cmd_any_test.go create mode 100644 client/cmd_auth.go create mode 100644 client/cmd_auth_test.go create mode 100644 client/cmd_noauth.go create mode 100644 client/cmd_noauth_test.go create mode 100644 client/cmd_selected.go create mode 100644 client/cmd_selected_test.go create mode 100644 client/example_test.go create mode 100644 client/tag.go create mode 100644 command.go create mode 100644 command_test.go create mode 100644 commands/append.go create mode 100644 commands/authenticate.go create mode 100644 commands/capability.go create mode 100644 commands/check.go create mode 100644 commands/close.go create mode 100644 commands/commands.go create mode 100644 commands/copy.go create mode 100644 commands/create.go create mode 100644 commands/delete.go create mode 100644 commands/enable.go create mode 100644 commands/expunge.go create mode 100644 commands/fetch.go create mode 100644 commands/idle.go create mode 100644 commands/list.go create mode 100644 commands/login.go create mode 100644 commands/logout.go create mode 100644 commands/move.go create mode 100644 commands/noop.go create mode 100644 commands/rename.go create mode 100644 commands/search.go create mode 100644 commands/select.go create mode 100644 commands/starttls.go create mode 100644 commands/status.go create mode 100644 commands/store.go create mode 100644 commands/subscribe.go create mode 100644 commands/uid.go create mode 100644 commands/unselect.go create mode 100644 conn.go create mode 100644 conn_test.go create mode 100644 date.go create mode 100644 date_test.go create mode 100644 go-imap-1.zip delete mode 100644 internal/acl.go delete mode 100644 internal/imapnum/numset.go delete mode 100644 internal/imapwire/decoder.go delete mode 100644 internal/imapwire/encoder.go delete mode 100644 internal/imapwire/imapwire.go delete mode 100644 internal/imapwire/num.go delete mode 100644 internal/internal.go delete mode 100644 internal/sasl.go create mode 100644 internal/testcert.go create mode 100644 internal/testutil.go delete mode 100644 internal/utf7/utf7.go create mode 100644 literal.go create mode 100644 logger.go create mode 100644 mailbox.go create mode 100644 mailbox_test.go create mode 100644 message.go create mode 100644 message_test.go create mode 100644 read.go create mode 100644 read_test.go create mode 100644 response_test.go create mode 100644 responses/authenticate.go create mode 100644 responses/capability.go create mode 100644 responses/enabled.go create mode 100644 responses/expunge.go create mode 100644 responses/fetch.go create mode 100644 responses/idle.go create mode 100644 responses/list.go create mode 100644 responses/list_test.go create mode 100644 responses/responses.go create mode 100644 responses/search.go create mode 100644 responses/select.go create mode 100644 responses/status.go create mode 100644 search_test.go create mode 100644 seqset.go rename internal/imapnum/numset_test.go => seqset_test.go (79%) create mode 100644 server/cmd_any.go create mode 100644 server/cmd_any_test.go create mode 100644 server/cmd_auth.go create mode 100644 server/cmd_auth_test.go create mode 100644 server/cmd_noauth.go create mode 100644 server/cmd_noauth_test.go create mode 100644 server/cmd_selected.go create mode 100644 server/cmd_selected_test.go create mode 100644 server/conn.go create mode 100644 server/server.go create mode 100644 server/server_test.go create mode 100644 status_test.go rename {internal/utf7 => utf7}/decoder.go (61%) rename {internal/utf7 => utf7}/decoder_test.go (95%) rename {internal/utf7 => utf7}/encoder.go (65%) rename {internal/utf7 => utf7}/encoder_test.go (97%) create mode 100644 utf7/utf7.go create mode 100644 write.go create mode 100644 write_test.go diff --git a/LICENSE b/LICENSE index d6718dc..f55742d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,8 +1,8 @@ The MIT License (MIT) Copyright (c) 2013 The Go-IMAP Authors +Copyright (c) 2016 emersion Copyright (c) 2016 Proton Technologies AG -Copyright (c) 2023 Simon Ser Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c84fdb9..abe9bc3 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,182 @@ # go-imap -[![Go Reference](https://pkg.go.dev/badge/github.com/emersion/go-imap/v2.svg)](https://pkg.go.dev/github.com/emersion/go-imap/v2) +[![godocs.io](https://godocs.io/github.com/emersion/go-imap?status.svg)](https://godocs.io/github.com/emersion/go-imap) +[![builds.sr.ht status](https://builds.sr.ht/~emersion/go-imap/commits/master.svg)](https://builds.sr.ht/~emersion/go-imap/commits/master?) -An [IMAP4rev2] library for Go. +An [IMAP4rev1](https://tools.ietf.org/html/rfc3501) library written in Go. It +can be used to build a client and/or a server. > **Note** -> This is the README for go-imap v2. This new major version is still in -> development. For go-imap v1, see the [v1 branch]. +> This is the README for go-imap v1. go-imap v2 is in development, see the +> [v2 branch](https://github.com/emersion/go-imap/tree/v2) for more details. ## Usage -To add go-imap to your project, run: +### Client [![godocs.io](https://godocs.io/github.com/emersion/go-imap/client?status.svg)](https://godocs.io/github.com/emersion/go-imap/client) - go get github.com/emersion/go-imap/v2 +```go +package main -Documentation and examples for the module are available here: +import ( + "log" -- [Client docs] -- [Server docs] + "github.com/emersion/go-imap/client" + "github.com/emersion/go-imap" +) + +func main() { + log.Println("Connecting to server...") + + // Connect to server + c, err := client.DialTLS("mail.example.org:993", nil) + if err != nil { + log.Fatal(err) + } + log.Println("Connected") + + // Don't forget to logout + defer c.Logout() + + // Login + if err := c.Login("username", "password"); err != nil { + log.Fatal(err) + } + log.Println("Logged in") + + // List mailboxes + mailboxes := make(chan *imap.MailboxInfo, 10) + done := make(chan error, 1) + go func () { + done <- c.List("", "*", mailboxes) + }() + + log.Println("Mailboxes:") + for m := range mailboxes { + log.Println("* " + m.Name) + } + + if err := <-done; err != nil { + log.Fatal(err) + } + + // Select INBOX + mbox, err := c.Select("INBOX", false) + if err != nil { + log.Fatal(err) + } + log.Println("Flags for INBOX:", mbox.Flags) + + // Get the last 4 messages + from := uint32(1) + to := mbox.Messages + if mbox.Messages > 3 { + // We're using unsigned integers here, only subtract if the result is > 0 + from = mbox.Messages - 3 + } + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + + messages := make(chan *imap.Message, 10) + done = make(chan error, 1) + go func() { + done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope}, messages) + }() + + log.Println("Last 4 messages:") + for msg := range messages { + log.Println("* " + msg.Envelope.Subject) + } + + if err := <-done; err != nil { + log.Fatal(err) + } + + log.Println("Done!") +} +``` + +### Server [![godocs.io](https://godocs.io/github.com/emersion/go-imap/server?status.svg)](https://godocs.io/github.com/emersion/go-imap/server) + +```go +package main + +import ( + "log" + + "github.com/emersion/go-imap/server" + "github.com/emersion/go-imap/backend/memory" +) + +func main() { + // Create a memory backend + be := memory.New() + + // Create a new server + s := server.New(be) + s.Addr = ":1143" + // Since we will use this server for testing only, we can allow plain text + // authentication over unencrypted connections + s.AllowInsecureAuth = true + + log.Println("Starting IMAP server at localhost:1143") + if err := s.ListenAndServe(); err != nil { + log.Fatal(err) + } +} +``` + +You can now use `telnet localhost 1143` to manually connect to the server. + +## Extensions + +Support for several IMAP extensions is included in go-imap itself. This +includes: + +* [APPENDLIMIT](https://tools.ietf.org/html/rfc7889) +* [CHILDREN](https://tools.ietf.org/html/rfc3348) +* [ENABLE](https://tools.ietf.org/html/rfc5161) +* [IDLE](https://tools.ietf.org/html/rfc2177) +* [IMPORTANT](https://tools.ietf.org/html/rfc8457) +* [LITERAL+](https://tools.ietf.org/html/rfc7888) +* [MOVE](https://tools.ietf.org/html/rfc6851) +* [SASL-IR](https://tools.ietf.org/html/rfc4959) +* [SPECIAL-USE](https://tools.ietf.org/html/rfc6154) +* [UNSELECT](https://tools.ietf.org/html/rfc3691) + +Support for other extensions is provided via separate packages. See below. + +## Extending go-imap + +### Extensions + +Commands defined in IMAP extensions are available in other packages. See [the +wiki](https://github.com/emersion/go-imap/wiki/Using-extensions#using-client-extensions) +to learn how to use them. + +* [COMPRESS](https://github.com/emersion/go-imap-compress) +* [ID](https://github.com/ProtonMail/go-imap-id) +* [METADATA](https://github.com/emersion/go-imap-metadata) +* [NAMESPACE](https://github.com/foxcpp/go-imap-namespace) +* [QUOTA](https://github.com/emersion/go-imap-quota) +* [SORT and THREAD](https://github.com/emersion/go-imap-sortthread) +* [UIDPLUS](https://github.com/emersion/go-imap-uidplus) + +### Server backends + +* [Memory](https://github.com/emersion/go-imap/tree/master/backend/memory) (for testing) +* [Multi](https://github.com/emersion/go-imap-multi) +* [PGP](https://github.com/emersion/go-imap-pgp) +* [Proxy](https://github.com/emersion/go-imap-proxy) +* [Notmuch](https://github.com/stbenjam/go-imap-notmuch) - Experimental gateway for [Notmuch](https://notmuchmail.org/) + +### Related projects + +* [go-message](https://github.com/emersion/go-message) - parsing and formatting MIME and mail messages +* [go-msgauth](https://github.com/emersion/go-msgauth) - handle DKIM, DMARC and Authentication-Results +* [go-pgpmail](https://github.com/emersion/go-pgpmail) - decrypting and encrypting mails with OpenPGP +* [go-sasl](https://github.com/emersion/go-sasl) - sending and receiving SASL authentications +* [go-smtp](https://github.com/emersion/go-smtp) - building SMTP clients and servers ## License MIT - -[IMAP4rev2]: https://www.rfc-editor.org/rfc/rfc9051.html -[v1 branch]: https://github.com/emersion/go-imap/tree/v1 -[Client docs]: https://pkg.go.dev/github.com/emersion/go-imap/v2/imapclient -[Server docs]: https://pkg.go.dev/github.com/emersion/go-imap/v2/imapserver diff --git a/backend/appendlimit.go b/backend/appendlimit.go new file mode 100644 index 0000000..2933116 --- /dev/null +++ b/backend/appendlimit.go @@ -0,0 +1,29 @@ +package backend + +import ( + "errors" +) + +// An error that should be returned by User.CreateMessage when the message size +// is too big. +var ErrTooBig = errors.New("Message size exceeding limit") + +// A backend that supports retrieving per-user message size limits. +type AppendLimitBackend interface { + Backend + + // Get the fixed maximum message size in octets that the backend will accept + // when creating a new message. If there is no limit, return nil. + CreateMessageLimit() *uint32 +} + +// A user that supports retrieving per-user message size limits. +type AppendLimitUser interface { + User + + // Get the fixed maximum message size in octets that the backend will accept + // when creating a new message. If there is no limit, return nil. + // + // This overrides the global backend limit. + CreateMessageLimit() *uint32 +} diff --git a/backend/backend.go b/backend/backend.go new file mode 100644 index 0000000..1ce7278 --- /dev/null +++ b/backend/backend.go @@ -0,0 +1,20 @@ +// Package backend defines an IMAP server backend interface. +package backend + +import ( + "errors" + + "github.com/emersion/go-imap" +) + +// ErrInvalidCredentials is returned by Backend.Login when a username or a +// password is incorrect. +var ErrInvalidCredentials = errors.New("Invalid credentials") + +// Backend is an IMAP server backend. A backend operation always deals with +// users. +type Backend interface { + // Login authenticates a user. If the username or the password is incorrect, + // it returns ErrInvalidCredentials. + Login(connInfo *imap.ConnInfo, username, password string) (User, error) +} diff --git a/backend/backendutil/backendutil.go b/backend/backendutil/backendutil.go new file mode 100644 index 0000000..a9b574a --- /dev/null +++ b/backend/backendutil/backendutil.go @@ -0,0 +1,2 @@ +// Package backendutil provides utility functions to implement IMAP backends. +package backendutil diff --git a/backend/backendutil/backendutil_test.go b/backend/backendutil/backendutil_test.go new file mode 100644 index 0000000..db48e48 --- /dev/null +++ b/backend/backendutil/backendutil_test.go @@ -0,0 +1,79 @@ +package backendutil + +import ( + "time" +) + +var testDate, _ = time.Parse(time.RFC1123Z, "Sat, 18 Jun 2016 12:00:00 +0900") + +const testHeaderString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + + "Date: Sat, 18 Jun 2016 12:00:00 +0900\r\n" + + "Date: Sat, 19 Jun 2016 12:00:00 +0900\r\n" + + "From: Mitsuha Miyamizu \r\n" + + "Reply-To: Mitsuha Miyamizu \r\n" + + "Message-Id: 42@example.org\r\n" + + "Subject: Your Name.\r\n" + + "To: Taki Tachibana \r\n" + + "\r\n" + +const testHeaderFromToString = "From: Mitsuha Miyamizu \r\n" + + "To: Taki Tachibana \r\n" + + "\r\n" + +const testHeaderDateString = "Date: Sat, 18 Jun 2016 12:00:00 +0900\r\n" + + "Date: Sat, 19 Jun 2016 12:00:00 +0900\r\n" + + "\r\n" + +const testHeaderNoFromToString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + + "Date: Sat, 18 Jun 2016 12:00:00 +0900\r\n" + + "Date: Sat, 19 Jun 2016 12:00:00 +0900\r\n" + + "Reply-To: Mitsuha Miyamizu \r\n" + + "Message-Id: 42@example.org\r\n" + + "Subject: Your Name.\r\n" + + "\r\n" + +const testAltHeaderString = "Content-Type: multipart/alternative; boundary=b2\r\n" + + "\r\n" + +const testTextHeaderString = "Content-Disposition: inline\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + +const testTextContentTypeString = "Content-Type: text/plain\r\n" + + "\r\n" + +const testTextNoContentTypeString = "Content-Disposition: inline\r\n" + + "\r\n" + +const testTextBodyString = "What's your name?" + +const testTextString = testTextHeaderString + testTextBodyString + +const testHTMLHeaderString = "Content-Disposition: inline\r\n" + + "Content-Type: text/html\r\n" + + "\r\n" + +const testHTMLBodyString = "
What's your\r\n name?
" + +const testHTMLString = testHTMLHeaderString + testHTMLBodyString + +const testAttachmentHeaderString = "Content-Disposition: attachment; filename=note.txt\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + +const testAttachmentBodyString = "My name is Mitsuha." + +const testAttachmentString = testAttachmentHeaderString + testAttachmentBodyString + +const testBodyString = "--message-boundary\r\n" + + testAltHeaderString + + "\r\n--b2\r\n" + + testTextString + + "\r\n--b2\r\n" + + testHTMLString + + "\r\n--b2--\r\n" + + "\r\n--message-boundary\r\n" + + testAttachmentString + + "\r\n--message-boundary--\r\n" + +const testMailString = testHeaderString + testBodyString diff --git a/backend/backendutil/body.go b/backend/backendutil/body.go new file mode 100644 index 0000000..502ee21 --- /dev/null +++ b/backend/backendutil/body.go @@ -0,0 +1,121 @@ +package backendutil + +import ( + "bytes" + "errors" + "io" + "mime" + nettextproto "net/textproto" + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message/textproto" +) + +var errNoSuchPart = errors.New("backendutil: no such message body part") + +func multipartReader(header textproto.Header, body io.Reader) *textproto.MultipartReader { + contentType := header.Get("Content-Type") + if !strings.HasPrefix(strings.ToLower(contentType), "multipart/") { + return nil + } + + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + return nil + } + + return textproto.NewMultipartReader(body, params["boundary"]) +} + +// FetchBodySection extracts a body section from a message. +func FetchBodySection(header textproto.Header, body io.Reader, section *imap.BodySectionName) (imap.Literal, error) { + // First, find the requested part using the provided path + for i := 0; i < len(section.Path); i++ { + n := section.Path[i] + + mr := multipartReader(header, body) + if mr == nil { + // First part of non-multipart message refers to the message itself. + // See RFC 3501, Page 55. + if len(section.Path) == 1 && section.Path[0] == 1 { + break + } + return nil, errNoSuchPart + } + + for j := 1; j <= n; j++ { + p, err := mr.NextPart() + if err == io.EOF { + return nil, errNoSuchPart + } else if err != nil { + return nil, err + } + + if j == n { + body = p + header = p.Header + + break + } + } + } + + // Then, write the requested data to a buffer + b := new(bytes.Buffer) + + resHeader := header + if section.Fields != nil { + // Copy header so we will not change value passed to us. + resHeader = header.Copy() + + if section.NotFields { + for _, fieldName := range section.Fields { + resHeader.Del(fieldName) + } + } else { + fieldsMap := make(map[string]struct{}, len(section.Fields)) + for _, field := range section.Fields { + fieldsMap[nettextproto.CanonicalMIMEHeaderKey(field)] = struct{}{} + } + + for field := resHeader.Fields(); field.Next(); { + if _, ok := fieldsMap[field.Key()]; !ok { + field.Del() + } + } + } + } + + // Write the header + err := textproto.WriteHeader(b, resHeader) + if err != nil { + return nil, err + } + + switch section.Specifier { + case imap.TextSpecifier: + // The header hasn't been requested. Discard it. + b.Reset() + case imap.EntireSpecifier: + if len(section.Path) > 0 { + // When selecting a specific part by index, IMAP servers + // return only the text, not the associated MIME header. + b.Reset() + } + } + + // Write the body, if requested + switch section.Specifier { + case imap.EntireSpecifier, imap.TextSpecifier: + if _, err := io.Copy(b, body); err != nil { + return nil, err + } + } + + var l imap.Literal = b + if section.Partial != nil { + l = bytes.NewReader(section.ExtractPartial(b.Bytes())) + } + return l, nil +} diff --git a/backend/backendutil/body_test.go b/backend/backendutil/body_test.go new file mode 100644 index 0000000..252a084 --- /dev/null +++ b/backend/backendutil/body_test.go @@ -0,0 +1,196 @@ +package backendutil + +import ( + "bufio" + "io/ioutil" + "strings" + "testing" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message/textproto" +) + +var bodyTests = []struct { + section string + body string +}{ + { + section: "BODY[]", + body: testMailString, + }, + { + section: "BODY[1.1]", + body: testTextBodyString, + }, + { + section: "BODY[1.2]", + body: testHTMLBodyString, + }, + { + section: "BODY[2]", + body: testAttachmentBodyString, + }, + { + section: "BODY[HEADER]", + body: testHeaderString, + }, + { + section: "BODY[HEADER.FIELDS (From To)]", + body: testHeaderFromToString, + }, + { + section: "BODY[HEADER.FIELDS (FROM to)]", + body: testHeaderFromToString, + }, + { + section: "BODY[HEADER.FIELDS.NOT (From To)]", + body: testHeaderNoFromToString, + }, + { + section: "BODY[HEADER.FIELDS (Date)]", + body: testHeaderDateString, + }, + { + section: "BODY[1.1.HEADER]", + body: testTextHeaderString, + }, + { + section: "BODY[1.1.HEADER.FIELDS (Content-Type)]", + body: testTextContentTypeString, + }, + { + section: "BODY[1.1.HEADER.FIELDS.NOT (Content-Type)]", + body: testTextNoContentTypeString, + }, + { + section: "BODY[2.HEADER]", + body: testAttachmentHeaderString, + }, + { + section: "BODY[2.MIME]", + body: testAttachmentHeaderString, + }, + { + section: "BODY[TEXT]", + body: testBodyString, + }, + { + section: "BODY[1.1.TEXT]", + body: testTextBodyString, + }, + { + section: "BODY[2.TEXT]", + body: testAttachmentBodyString, + }, + { + section: "BODY[2.1]", + body: "", + }, + { + section: "BODY[3]", + body: "", + }, + { + section: "BODY[2.TEXT]<0.9>", + body: testAttachmentBodyString[:9], + }, +} + +func TestFetchBodySection(t *testing.T) { + for _, test := range bodyTests { + test := test + t.Run(test.section, func(t *testing.T) { + bufferedBody := bufio.NewReader(strings.NewReader(testMailString)) + + header, err := textproto.ReadHeader(bufferedBody) + if err != nil { + t.Fatal("Expected no error while reading mail, got:", err) + } + + section, err := imap.ParseBodySectionName(imap.FetchItem(test.section)) + if err != nil { + t.Fatal("Expected no error while parsing body section name, got:", err) + } + + r, err := FetchBodySection(header, bufferedBody, section) + if test.body == "" { + if err == nil { + t.Error("Expected an error while extracting non-existing body section") + } + } else { + if err != nil { + t.Fatal("Expected no error while extracting body section, got:", err) + } + + b, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal("Expected no error while reading body section, got:", err) + } + + if s := string(b); s != test.body { + t.Errorf("Expected body section %q to be \n%s\n but got \n%s", test.section, test.body, s) + } + } + }) + } +} + +func TestFetchBodySection_NonMultipart(t *testing.T) { + // https://tools.ietf.org/html/rfc3501#page-55: + // Every message has at least one part number. Non-[MIME-IMB] + // messages, and non-multipart [MIME-IMB] messages with no + // encapsulated message, only have a part 1. + + testMsgHdr := "From: Mitsuha Miyamizu \r\n" + + "To: Taki Tachibana \r\n" + + "Subject: Your Name.\r\n" + + "Message-Id: 42@example.org\r\n" + + "\r\n" + testMsgBody := "That's not multipart message. Thought it should be possible to get this text using BODY[1]." + testMsg := testMsgHdr + testMsgBody + + tests := []struct { + section string + body string + }{ + { + section: "BODY[1.MIME]", + body: testMsgHdr, + }, + { + section: "BODY[1]", + body: testMsgBody, + }, + } + + for _, test := range tests { + test := test + t.Run(test.section, func(t *testing.T) { + bufferedBody := bufio.NewReader(strings.NewReader(testMsg)) + + header, err := textproto.ReadHeader(bufferedBody) + if err != nil { + t.Fatal("Expected no error while reading mail, got:", err) + } + + section, err := imap.ParseBodySectionName(imap.FetchItem(test.section)) + if err != nil { + t.Fatal("Expected no error while parsing body section name, got:", err) + } + + r, err := FetchBodySection(header, bufferedBody, section) + if err != nil { + t.Fatal("Expected no error while extracting body section, got:", err) + } + + b, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal("Expected no error while reading body section, got:", err) + } + + if s := string(b); s != test.body { + t.Errorf("Expected body section %q to be \n%s\n but got \n%s", test.section, test.body, s) + } + }) + } +} diff --git a/backend/backendutil/bodystructure.go b/backend/backendutil/bodystructure.go new file mode 100644 index 0000000..dbe139c --- /dev/null +++ b/backend/backendutil/bodystructure.go @@ -0,0 +1,117 @@ +package backendutil + +import ( + "bufio" + "bytes" + "io" + "io/ioutil" + "mime" + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message/textproto" +) + +type countReader struct { + r io.Reader + bytes uint32 + newlines uint32 + endsWithLF bool +} + +func (r *countReader) Read(b []byte) (int, error) { + n, err := r.r.Read(b) + r.bytes += uint32(n) + if n != 0 { + r.newlines += uint32(bytes.Count(b[:n], []byte{'\n'})) + r.endsWithLF = b[n-1] == '\n' + } + // If the stream does not end with a newline - count missing newline. + if err == io.EOF { + if !r.endsWithLF { + r.newlines++ + } + } + return n, err +} + +// FetchBodyStructure computes a message's body structure from its content. +func FetchBodyStructure(header textproto.Header, body io.Reader, extended bool) (*imap.BodyStructure, error) { + bs := new(imap.BodyStructure) + + mediaType, mediaParams, err := mime.ParseMediaType(header.Get("Content-Type")) + if err == nil { + typeParts := strings.SplitN(mediaType, "/", 2) + bs.MIMEType = typeParts[0] + if len(typeParts) == 2 { + bs.MIMESubType = typeParts[1] + } + bs.Params = mediaParams + } else { + bs.MIMEType = "text" + bs.MIMESubType = "plain" + } + + bs.Id = header.Get("Content-Id") + bs.Description = header.Get("Content-Description") + bs.Encoding = header.Get("Content-Transfer-Encoding") + + if mr := multipartReader(header, body); mr != nil { + var parts []*imap.BodyStructure + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + + pbs, err := FetchBodyStructure(p.Header, p, extended) + if err != nil { + return nil, err + } + parts = append(parts, pbs) + } + bs.Parts = parts + } else { + countedBody := countReader{r: body} + needLines := false + if bs.MIMEType == "message" && bs.MIMESubType == "rfc822" { + // This will result in double-buffering if body is already a + // bufio.Reader (most likely it is). :\ + bufBody := bufio.NewReader(&countedBody) + subMsgHdr, err := textproto.ReadHeader(bufBody) + if err != nil { + return nil, err + } + bs.Envelope, err = FetchEnvelope(subMsgHdr) + if err != nil { + return nil, err + } + bs.BodyStructure, err = FetchBodyStructure(subMsgHdr, bufBody, extended) + if err != nil { + return nil, err + } + needLines = true + } else if bs.MIMEType == "text" { + needLines = true + } + if _, err := io.Copy(ioutil.Discard, &countedBody); err != nil { + return nil, err + } + bs.Size = countedBody.bytes + if needLines { + bs.Lines = countedBody.newlines + } + } + + if extended { + bs.Extended = true + bs.Disposition, bs.DispositionParams, _ = mime.ParseMediaType(header.Get("Content-Disposition")) + + // TODO: bs.Language, bs.Location + // TODO: bs.MD5 + } + + return bs, nil +} diff --git a/backend/backendutil/bodystructure_test.go b/backend/backendutil/bodystructure_test.go new file mode 100644 index 0000000..ff3196e --- /dev/null +++ b/backend/backendutil/bodystructure_test.go @@ -0,0 +1,76 @@ +package backendutil + +import ( + "bufio" + "reflect" + "strings" + "testing" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message/textproto" +) + +var testBodyStructure = &imap.BodyStructure{ + MIMEType: "multipart", + MIMESubType: "mixed", + Params: map[string]string{"boundary": "message-boundary"}, + Parts: []*imap.BodyStructure{ + { + MIMEType: "multipart", + MIMESubType: "alternative", + Params: map[string]string{"boundary": "b2"}, + Extended: true, + Parts: []*imap.BodyStructure{ + { + MIMEType: "text", + MIMESubType: "plain", + Params: map[string]string{}, + Extended: true, + Disposition: "inline", + DispositionParams: map[string]string{}, + Lines: 1, + Size: 17, + }, + { + MIMEType: "text", + MIMESubType: "html", + Params: map[string]string{}, + Extended: true, + Disposition: "inline", + DispositionParams: map[string]string{}, + Lines: 2, + Size: 37, + }, + }, + }, + { + MIMEType: "text", + MIMESubType: "plain", + Params: map[string]string{}, + Extended: true, + Disposition: "attachment", + DispositionParams: map[string]string{"filename": "note.txt"}, + Lines: 1, + Size: 19, + }, + }, + Extended: true, +} + +func TestFetchBodyStructure(t *testing.T) { + bufferedBody := bufio.NewReader(strings.NewReader(testMailString)) + + header, err := textproto.ReadHeader(bufferedBody) + if err != nil { + t.Fatal("Expected no error while reading mail, got:", err) + } + + bs, err := FetchBodyStructure(header, bufferedBody, true) + if err != nil { + t.Fatal("Expected no error while fetching body structure, got:", err) + } + + if !reflect.DeepEqual(testBodyStructure, bs) { + t.Errorf("Expected body structure \n%+v\n but got \n%+v", testBodyStructure, bs) + } +} diff --git a/backend/backendutil/envelope.go b/backend/backendutil/envelope.go new file mode 100644 index 0000000..584620d --- /dev/null +++ b/backend/backendutil/envelope.go @@ -0,0 +1,58 @@ +package backendutil + +import ( + "net/mail" + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message/textproto" +) + +func headerAddressList(value string) ([]*imap.Address, error) { + addrs, err := mail.ParseAddressList(value) + if err != nil { + return []*imap.Address{}, err + } + + list := make([]*imap.Address, len(addrs)) + for i, a := range addrs { + parts := strings.SplitN(a.Address, "@", 2) + mailbox := parts[0] + var hostname string + if len(parts) == 2 { + hostname = parts[1] + } + + list[i] = &imap.Address{ + PersonalName: a.Name, + MailboxName: mailbox, + HostName: hostname, + } + } + + return list, err +} + +// FetchEnvelope returns a message's envelope from its header. +func FetchEnvelope(h textproto.Header) (*imap.Envelope, error) { + env := new(imap.Envelope) + + env.Date, _ = mail.ParseDate(h.Get("Date")) + env.Subject = h.Get("Subject") + env.From, _ = headerAddressList(h.Get("From")) + env.Sender, _ = headerAddressList(h.Get("Sender")) + if len(env.Sender) == 0 { + env.Sender = env.From + } + env.ReplyTo, _ = headerAddressList(h.Get("Reply-To")) + if len(env.ReplyTo) == 0 { + env.ReplyTo = env.From + } + env.To, _ = headerAddressList(h.Get("To")) + env.Cc, _ = headerAddressList(h.Get("Cc")) + env.Bcc, _ = headerAddressList(h.Get("Bcc")) + env.InReplyTo = h.Get("In-Reply-To") + env.MessageId = h.Get("Message-Id") + + return env, nil +} diff --git a/backend/backendutil/envelope_test.go b/backend/backendutil/envelope_test.go new file mode 100644 index 0000000..a708c06 --- /dev/null +++ b/backend/backendutil/envelope_test.go @@ -0,0 +1,40 @@ +package backendutil + +import ( + "bufio" + "reflect" + "strings" + "testing" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message/textproto" +) + +var testEnvelope = &imap.Envelope{ + Date: testDate, + Subject: "Your Name.", + From: []*imap.Address{{PersonalName: "Mitsuha Miyamizu", MailboxName: "mitsuha.miyamizu", HostName: "example.org"}}, + Sender: []*imap.Address{{PersonalName: "Mitsuha Miyamizu", MailboxName: "mitsuha.miyamizu", HostName: "example.org"}}, + ReplyTo: []*imap.Address{{PersonalName: "Mitsuha Miyamizu", MailboxName: "mitsuha.miyamizu+replyto", HostName: "example.org"}}, + To: []*imap.Address{{PersonalName: "Taki Tachibana", MailboxName: "taki.tachibana", HostName: "example.org"}}, + Cc: []*imap.Address{}, + Bcc: []*imap.Address{}, + InReplyTo: "", + MessageId: "42@example.org", +} + +func TestFetchEnvelope(t *testing.T) { + hdr, err := textproto.ReadHeader(bufio.NewReader(strings.NewReader(testMailString))) + if err != nil { + t.Fatal("Expected no error while reading mail, got:", err) + } + + env, err := FetchEnvelope(hdr) + if err != nil { + t.Fatal("Expected no error while fetching envelope, got:", err) + } + + if !reflect.DeepEqual(env, testEnvelope) { + t.Errorf("Expected envelope \n%+v\n but got \n%+v", testEnvelope, env) + } +} diff --git a/backend/backendutil/flags.go b/backend/backendutil/flags.go new file mode 100644 index 0000000..6da759e --- /dev/null +++ b/backend/backendutil/flags.go @@ -0,0 +1,73 @@ +package backendutil + +import ( + "github.com/emersion/go-imap" +) + +// UpdateFlags executes a flag operation on the flag set current. +func UpdateFlags(current []string, op imap.FlagsOp, flags []string) []string { + // Don't modify contents of 'flags' slice. Only modify 'current'. + // See https://github.com/golang/go/wiki/SliceTricks + + // Re-use current's backing store + newFlags := current[:0] + switch op { + case imap.SetFlags: + hasRecent := false + // keep recent flag + for _, flag := range current { + if flag == imap.RecentFlag { + newFlags = append(newFlags, imap.RecentFlag) + hasRecent = true + break + } + } + // append new flags + for _, flag := range flags { + if flag == imap.RecentFlag { + // Make sure we don't add the recent flag multiple times. + if hasRecent { + // Already have the recent flag, skip. + continue + } + hasRecent = true + } + // append new flag + newFlags = append(newFlags, flag) + } + case imap.AddFlags: + // keep current flags + newFlags = current + // Only add new flag if it isn't already in current list. + for _, addFlag := range flags { + found := false + for _, flag := range current { + if addFlag == flag { + found = true + break + } + } + // new flag not found, add it. + if !found { + newFlags = append(newFlags, addFlag) + } + } + case imap.RemoveFlags: + // Filter current flags + for _, flag := range current { + remove := false + for _, removeFlag := range flags { + if removeFlag == flag { + remove = true + } + } + if !remove { + newFlags = append(newFlags, flag) + } + } + default: + // Unknown operation, return current flags unchanged + newFlags = current + } + return newFlags +} diff --git a/backend/backendutil/flags_test.go b/backend/backendutil/flags_test.go new file mode 100644 index 0000000..7b1faff --- /dev/null +++ b/backend/backendutil/flags_test.go @@ -0,0 +1,86 @@ +package backendutil + +import ( + "reflect" + "testing" + + "github.com/emersion/go-imap" +) + +var updateFlagsTests = []struct { + op imap.FlagsOp + flags []string + res []string +}{ + { + op: imap.AddFlags, + flags: []string{"d", "e"}, + res: []string{"a", "b", "c", "d", "e"}, + }, + { + op: imap.AddFlags, + flags: []string{"a", "d", "b"}, + res: []string{"a", "b", "c", "d"}, + }, + { + op: imap.RemoveFlags, + flags: []string{"b", "v", "e", "a"}, + res: []string{"c"}, + }, + { + op: imap.SetFlags, + flags: []string{"a", "d", "e"}, + res: []string{"a", "d", "e"}, + }, + // Test unknown op for code coverage. + { + op: imap.FlagsOp("TestUnknownOp"), + flags: []string{"a", "d", "e"}, + res: []string{"a", "b", "c"}, + }, +} + +func TestUpdateFlags(t *testing.T) { + flagsList := []string{"a", "b", "c"} + for _, test := range updateFlagsTests { + // Make a backup copy of 'test.flags' + origFlags := append(test.flags[:0:0], test.flags...) + // Copy flags + current := append(flagsList[:0:0], flagsList...) + got := UpdateFlags(current, test.op, test.flags) + + if !reflect.DeepEqual(got, test.res) { + t.Errorf("Expected result to be \n%v\n but got \n%v", test.res, got) + } + // Verify that 'test.flags' wasn't modified + if !reflect.DeepEqual(origFlags, test.flags) { + t.Errorf("Unexpected change to operation flags list changed \nbefore %v\n after \n%v", + origFlags, test.flags) + } + } +} + +func TestUpdateFlags_Recent(t *testing.T) { + current := []string{} + + current = UpdateFlags(current, imap.SetFlags, []string{imap.RecentFlag}) + + res := []string{imap.RecentFlag} + if !reflect.DeepEqual(current, res) { + t.Errorf("Expected result to be \n%v\n but got \n%v", res, current) + } + + current = UpdateFlags(current, imap.SetFlags, []string{"something"}) + + res = []string{imap.RecentFlag, "something"} + if !reflect.DeepEqual(current, res) { + t.Errorf("Expected result to be \n%v\n but got \n%v", res, current) + } + + current = UpdateFlags(current, imap.SetFlags, []string{"another", imap.RecentFlag}) + + res = []string{imap.RecentFlag, "another"} + if !reflect.DeepEqual(current, res) { + t.Errorf("Expected result to be \n%v\n but got \n%v", res, current) + } +} diff --git a/backend/backendutil/search.go b/backend/backendutil/search.go new file mode 100644 index 0000000..50327ac --- /dev/null +++ b/backend/backendutil/search.go @@ -0,0 +1,230 @@ +package backendutil + +import ( + "bytes" + "fmt" + "io" + "strings" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" + "github.com/emersion/go-message/mail" + "github.com/emersion/go-message/textproto" +) + +func matchString(s, substr string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} + +func bufferBody(e *message.Entity) (*bytes.Buffer, error) { + b := new(bytes.Buffer) + if _, err := io.Copy(b, e.Body); err != nil { + return nil, err + } + e.Body = b + return b, nil +} + +func matchBody(e *message.Entity, substr string) (bool, error) { + if s, ok := e.Body.(fmt.Stringer); ok { + return matchString(s.String(), substr), nil + } + + b, err := bufferBody(e) + if err != nil { + return false, err + } + return matchString(b.String(), substr), nil +} + +type lengther interface { + Len() int +} + +type countWriter struct { + N int +} + +func (w *countWriter) Write(b []byte) (int, error) { + w.N += len(b) + return len(b), nil +} + +func bodyLen(e *message.Entity) (int, error) { + headerSize := countWriter{} + textproto.WriteHeader(&headerSize, e.Header.Header) + + if l, ok := e.Body.(lengther); ok { + return l.Len() + headerSize.N, nil + } + + b, err := bufferBody(e) + if err != nil { + return 0, err + } + return b.Len() + headerSize.N, nil +} + +// Match returns true if a message and its metadata matches the provided +// criteria. +func Match(e *message.Entity, seqNum, uid uint32, date time.Time, flags []string, c *imap.SearchCriteria) (bool, error) { + // TODO: support encoded header fields for Bcc, Cc, From, To + // TODO: add header size for Larger and Smaller + + h := mail.Header{Header: e.Header} + + if !c.SentBefore.IsZero() || !c.SentSince.IsZero() { + t, err := h.Date() + if err != nil { + return false, err + } + t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + + if !c.SentBefore.IsZero() && !t.Before(c.SentBefore) { + return false, nil + } + if !c.SentSince.IsZero() && t.Before(c.SentSince) { + return false, nil + } + } + + for key, wantValues := range c.Header { + ok := e.Header.Has(key) + for _, wantValue := range wantValues { + if wantValue == "" && !ok { + return false, nil + } + if wantValue != "" { + ok := false + values := e.Header.FieldsByKey(key) + for values.Next() { + decoded, _ := values.Text() + if matchString(decoded, wantValue) { + ok = true + break + } + } + if !ok { + return false, nil + } + } + } + } + for _, body := range c.Body { + if ok, err := matchBody(e, body); err != nil || !ok { + return false, err + } + } + for _, text := range c.Text { + headerMatch := false + for f := e.Header.Fields(); f.Next(); { + decoded, err := f.Text() + if err != nil { + continue + } + if strings.Contains(f.Key()+": "+decoded, text) { + headerMatch = true + } + } + if ok, err := matchBody(e, text); err != nil || !ok && !headerMatch { + return false, err + } + } + + if c.Larger > 0 || c.Smaller > 0 { + n, err := bodyLen(e) + if err != nil { + return false, err + } + + if c.Larger > 0 && uint32(n) <= c.Larger { + return false, nil + } + if c.Smaller > 0 && uint32(n) >= c.Smaller { + return false, nil + } + } + + if !c.Since.IsZero() || !c.Before.IsZero() { + if !matchDate(date, c) { + return false, nil + } + } + + if c.WithFlags != nil || c.WithoutFlags != nil { + if !matchFlags(flags, c) { + return false, nil + } + } + + if c.SeqNum != nil || c.Uid != nil { + if !matchSeqNumAndUid(seqNum, uid, c) { + return false, nil + } + } + + for _, not := range c.Not { + ok, err := Match(e, seqNum, uid, date, flags, not) + if err != nil || ok { + return false, err + } + } + for _, or := range c.Or { + ok1, err := Match(e, seqNum, uid, date, flags, or[0]) + if err != nil { + return ok1, err + } + + ok2, err := Match(e, seqNum, uid, date, flags, or[1]) + if err != nil || (!ok1 && !ok2) { + return false, err + } + } + + return true, nil +} + +func matchFlags(flags []string, c *imap.SearchCriteria) bool { + flagsMap := make(map[string]bool) + for _, f := range flags { + flagsMap[f] = true + } + + for _, f := range c.WithFlags { + if !flagsMap[f] { + return false + } + } + for _, f := range c.WithoutFlags { + if flagsMap[f] { + return false + } + } + + return true +} + +func matchSeqNumAndUid(seqNum uint32, uid uint32, c *imap.SearchCriteria) bool { + if c.SeqNum != nil && !c.SeqNum.Contains(seqNum) { + return false + } + if c.Uid != nil && !c.Uid.Contains(uid) { + return false + } + return true +} + +func matchDate(date time.Time, c *imap.SearchCriteria) bool { + // We discard time zone information by setting it to UTC. + // RFC 3501 explicitly requires zone unaware date comparison. + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC) + + if !c.Since.IsZero() && !date.After(c.Since) { + return false + } + if !c.Before.IsZero() && !date.Before(c.Before) { + return false + } + return true +} diff --git a/backend/backendutil/search_test.go b/backend/backendutil/search_test.go new file mode 100644 index 0000000..94001c5 --- /dev/null +++ b/backend/backendutil/search_test.go @@ -0,0 +1,432 @@ +package backendutil + +import ( + "net/textproto" + "strings" + "testing" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" +) + +var testInternalDate = time.Unix(1483997966, 0) + +var matchTests = []struct { + criteria *imap.SearchCriteria + seqNum uint32 + uid uint32 + date time.Time + flags []string + res bool +}{ + { + criteria: &imap.SearchCriteria{ + Header: textproto.MIMEHeader{"From": {"Mitsuha"}}, + }, + res: true, + }, + { + criteria: &imap.SearchCriteria{ + Header: textproto.MIMEHeader{"To": {"Mitsuha"}}, + }, + res: false, + }, + { + criteria: &imap.SearchCriteria{SentBefore: testDate.Add(48 * time.Hour)}, + res: true, + }, + { + criteria: &imap.SearchCriteria{ + Not: []*imap.SearchCriteria{{SentSince: testDate.Add(48 * time.Hour)}}, + }, + res: true, + }, + { + criteria: &imap.SearchCriteria{ + Not: []*imap.SearchCriteria{{Body: []string{"name"}}}, + }, + res: false, + }, + { + criteria: &imap.SearchCriteria{ + Text: []string{"name"}, + }, + res: true, + }, + { + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + {Text: []string{"i'm not in the text"}}, + {Body: []string{"i'm not in the body"}}, + }}, + }, + res: false, + }, + { + criteria: &imap.SearchCriteria{ + Header: textproto.MIMEHeader{"Message-Id": {"42@example.org"}}, + }, + res: true, + }, + { + criteria: &imap.SearchCriteria{ + Header: textproto.MIMEHeader{"Message-Id": {"43@example.org"}}, + }, + res: false, + }, + { + criteria: &imap.SearchCriteria{ + Header: textproto.MIMEHeader{"Message-Id": {""}}, + }, + res: true, + }, + { + criteria: &imap.SearchCriteria{ + Header: textproto.MIMEHeader{"Totally-Not-Reply-To": {""}}, + }, + res: false, + }, + { + criteria: &imap.SearchCriteria{ + Larger: 10, + }, + res: true, + }, + { + criteria: &imap.SearchCriteria{ + Smaller: 10, + }, + res: false, + }, + { + criteria: &imap.SearchCriteria{ + Header: textproto.MIMEHeader{"Subject": {"your"}}, + }, + res: true, + }, + { + criteria: &imap.SearchCriteria{ + Header: textproto.MIMEHeader{"Subject": {"Taki"}}, + }, + res: false, + }, + { + flags: []string{imap.SeenFlag}, + criteria: &imap.SearchCriteria{ + WithFlags: []string{imap.SeenFlag}, + WithoutFlags: []string{imap.FlaggedFlag}, + }, + res: true, + }, + { + flags: []string{imap.SeenFlag}, + criteria: &imap.SearchCriteria{ + WithFlags: []string{imap.DraftFlag}, + WithoutFlags: []string{imap.FlaggedFlag}, + }, + res: false, + }, + { + flags: []string{imap.SeenFlag, imap.FlaggedFlag}, + criteria: &imap.SearchCriteria{ + WithFlags: []string{imap.SeenFlag}, + WithoutFlags: []string{imap.FlaggedFlag}, + }, + res: false, + }, + { + flags: []string{imap.SeenFlag, imap.FlaggedFlag}, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + {WithFlags: []string{imap.DraftFlag}}, + {WithoutFlags: []string{imap.SeenFlag}}, + }}, + }, + res: false, + }, + { + flags: []string{imap.SeenFlag, imap.FlaggedFlag}, + criteria: &imap.SearchCriteria{ + Not: []*imap.SearchCriteria{ + {WithFlags: []string{imap.SeenFlag}}, + }, + }, + res: false, + }, + { + seqNum: 42, + uid: 69, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Uid: new(imap.SeqSet), + Not: []*imap.SearchCriteria{{SeqNum: new(imap.SeqSet)}}, + }, + { + SeqNum: new(imap.SeqSet), + }, + }}, + }, + res: false, + }, + { + seqNum: 42, + uid: 69, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Uid: &imap.SeqSet{Set: []imap.Seq{{69, 69}}}, + Not: []*imap.SearchCriteria{{SeqNum: new(imap.SeqSet)}}, + }, + { + SeqNum: new(imap.SeqSet), + }, + }}, + }, + res: true, + }, + { + seqNum: 42, + uid: 69, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Uid: &imap.SeqSet{Set: []imap.Seq{{69, 69}}}, + Not: []*imap.SearchCriteria{{ + SeqNum: &imap.SeqSet{Set: []imap.Seq{imap.Seq{42, 42}}}, + }}, + }, + { + SeqNum: new(imap.SeqSet), + }, + }}, + }, + res: false, + }, + { + seqNum: 42, + uid: 69, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Uid: &imap.SeqSet{Set: []imap.Seq{{69, 69}}}, + Not: []*imap.SearchCriteria{{ + SeqNum: &imap.SeqSet{Set: []imap.Seq{{42, 42}}}, + }}, + }, + { + SeqNum: &imap.SeqSet{Set: []imap.Seq{{42, 42}}}, + }, + }}, + }, + res: true, + }, + { + date: testInternalDate, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Since: testInternalDate.Add(48 * time.Hour), + Not: []*imap.SearchCriteria{{ + Since: testInternalDate.Add(48 * time.Hour), + }}, + }, + { + Before: testInternalDate.Add(-48 * time.Hour), + }, + }}, + }, + res: false, + }, + { + date: testInternalDate, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Since: testInternalDate.Add(-48 * time.Hour), + Not: []*imap.SearchCriteria{{ + Since: testInternalDate.Add(48 * time.Hour), + }}, + }, + { + Before: testInternalDate.Add(-48 * time.Hour), + }, + }}, + }, + res: true, + }, + { + date: testInternalDate, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Since: testInternalDate.Add(-48 * time.Hour), + Not: []*imap.SearchCriteria{{ + Since: testInternalDate.Add(-48 * time.Hour), + }}, + }, + { + Before: testInternalDate.Add(-48 * time.Hour), + }, + }}, + }, + res: false, + }, + { + date: testInternalDate, + criteria: &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Since: testInternalDate.Add(-48 * time.Hour), + Not: []*imap.SearchCriteria{{ + Since: testInternalDate.Add(-48 * time.Hour), + }}, + }, + { + Before: testInternalDate.Add(48 * time.Hour), + }, + }}, + }, + res: true, + }, +} + +func TestMatch(t *testing.T) { + for i, test := range matchTests { + e, err := message.Read(strings.NewReader(testMailString)) + if err != nil { + t.Fatal("Expected no error while reading entity, got:", err) + } + + ok, err := Match(e, test.seqNum, test.uid, test.date, test.flags, test.criteria) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + + if test.res && !ok { + t.Errorf("Expected #%v to match search criteria", i+1) + } + if !test.res && ok { + t.Errorf("Expected #%v not to match search criteria", i+1) + } + } +} + +func TestMatchEncoded(t *testing.T) { + encodedTestMsg := `From: "fox.cpp" +To: "fox.cpp" +Subject: =?utf-8?B?0J/RgNC+0LLQtdGA0LrQsCE=?= +Date: Sun, 09 Jun 2019 00:06:43 +0300 +MIME-Version: 1.0 +Message-ID: +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: quoted-printable + +=D0=AD=D1=82=D0=BE=D1=82 =D1=82=D0=B5=D0=BA=D1=81=D1=82 =D0=B4=D0=BE=D0=BB= +=D0=B6=D0=B5=D0=BD =D0=B1=D1=8B=D1=82=D1=8C =D0=B7=D0=B0=D0=BA=D0=BE=D0=B4= +=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD =D0=B2 base64 =D0=B8=D0=BB=D0=B8 quote= +d-encoding.` + e, err := message.Read(strings.NewReader(encodedTestMsg)) + if err != nil { + t.Fatal("Expected no error while reading entity, got:", err) + } + + // Check encoded header. + crit := imap.SearchCriteria{ + Header: textproto.MIMEHeader{"Subject": []string{"Проверка!"}}, + } + + ok, err := Match(e, 0, 0, time.Now(), []string{}, &crit) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + + if !ok { + t.Error("Expected match for encoded header") + } + + // Encoded body. + crit = imap.SearchCriteria{ + Body: []string{"или"}, + } + + ok, err = Match(e, 0, 0, time.Now(), []string{}, &crit) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + + if !ok { + t.Error("Expected match for encoded body") + } +} + +func TestMatchIssue298Regression(t *testing.T) { + raw1 := "Subject: 1\r\n\r\n1" + raw2 := "Subject: 2\r\n\r\n22" + raw3 := "Subject: 3\r\n\r\n333" + e1, err := message.Read(strings.NewReader(raw1)) + if err != nil { + t.Fatal("Expected no error while reading entity, got:", err) + } + e2, err := message.Read(strings.NewReader(raw2)) + if err != nil { + t.Fatal("Expected no error while reading entity, got:", err) + } + e3, err := message.Read(strings.NewReader(raw3)) + if err != nil { + t.Fatal("Expected no error while reading entity, got:", err) + } + + // Search for body size > 15 ("LARGER 15"), which should match messages #2 and #3 + criteria := &imap.SearchCriteria{ + Larger: 15, + } + ok1, err := Match(e1, 1, 101, time.Now(), nil, criteria) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + if ok1 { + t.Errorf("Expected message #1 to not match search criteria") + } + ok2, err := Match(e2, 2, 102, time.Now(), nil, criteria) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + if !ok2 { + t.Errorf("Expected message #2 to match search criteria") + } + ok3, err := Match(e3, 3, 103, time.Now(), nil, criteria) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + if !ok3 { + t.Errorf("Expected message #3 to match search criteria") + } + + // Search for body size < 17 ("SMALLER 17"), which should match messages #1 and #2 + criteria = &imap.SearchCriteria{ + Smaller: 17, + } + ok1, err = Match(e1, 1, 101, time.Now(), nil, criteria) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + if !ok1 { + t.Errorf("Expected message #1 to match search criteria") + } + ok2, err = Match(e2, 2, 102, time.Now(), nil, criteria) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + if !ok2 { + t.Errorf("Expected message #2 to match search criteria") + } + ok3, err = Match(e3, 3, 103, time.Now(), nil, criteria) + if err != nil { + t.Fatal("Expected no error while matching entity, got:", err) + } + if ok3 { + t.Errorf("Expected message #3 to not match search criteria") + } +} diff --git a/backend/mailbox.go b/backend/mailbox.go new file mode 100644 index 0000000..0976240 --- /dev/null +++ b/backend/mailbox.go @@ -0,0 +1,78 @@ +package backend + +import ( + "time" + + "github.com/emersion/go-imap" +) + +// Mailbox represents a mailbox belonging to a user in the mail storage system. +// A mailbox operation always deals with messages. +type Mailbox interface { + // Name returns this mailbox name. + Name() string + + // Info returns this mailbox info. + Info() (*imap.MailboxInfo, error) + + // Status returns this mailbox status. The fields Name, Flags, PermanentFlags + // and UnseenSeqNum in the returned MailboxStatus must be always populated. + // This function does not affect the state of any messages in the mailbox. See + // RFC 3501 section 6.3.10 for a list of items that can be requested. + Status(items []imap.StatusItem) (*imap.MailboxStatus, error) + + // SetSubscribed adds or removes the mailbox to the server's set of "active" + // or "subscribed" mailboxes. + SetSubscribed(subscribed bool) error + + // Check requests a checkpoint of the currently selected mailbox. A checkpoint + // refers to any implementation-dependent housekeeping associated with the + // mailbox (e.g., resolving the server's in-memory state of the mailbox with + // the state on its disk). A checkpoint MAY take a non-instantaneous amount of + // real time to complete. If a server implementation has no such housekeeping + // considerations, CHECK is equivalent to NOOP. + Check() error + + // ListMessages returns a list of messages. seqset must be interpreted as UIDs + // if uid is set to true and as message sequence numbers otherwise. See RFC + // 3501 section 6.4.5 for a list of items that can be requested. + // + // Messages must be sent to ch. When the function returns, ch must be closed. + ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error + + // SearchMessages searches messages. The returned list must contain UIDs if + // uid is set to true, or sequence numbers otherwise. + SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) + + // CreateMessage appends a new message to this mailbox. The \Recent flag will + // be added no matter flags is empty or not. If date is nil, the current time + // will be used. + // + // If the Backend implements Updater, it must notify the client immediately + // via a mailbox update. + CreateMessage(flags []string, date time.Time, body imap.Literal) error + + // UpdateMessagesFlags alters flags for the specified message(s). + // + // If the Backend implements Updater, it must notify the client immediately + // via a message update. + UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, operation imap.FlagsOp, flags []string) error + + // CopyMessages copies the specified message(s) to the end of the specified + // destination mailbox. The flags and internal date of the message(s) SHOULD + // be preserved, and the Recent flag SHOULD be set, in the copy. + // + // If the destination mailbox does not exist, a server SHOULD return an error. + // It SHOULD NOT automatically create the mailbox. + // + // If the Backend implements Updater, it must notify the client immediately + // via a mailbox update. + CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error + + // Expunge permanently removes all messages that have the \Deleted flag set + // from the currently selected mailbox. + // + // If the Backend implements Updater, it must notify the client immediately + // via an expunge update. + Expunge() error +} diff --git a/backend/memory/backend.go b/backend/memory/backend.go new file mode 100644 index 0000000..ed77132 --- /dev/null +++ b/backend/memory/backend.go @@ -0,0 +1,78 @@ +// A memory backend. +package memory + +import ( + "errors" + "fmt" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" +) + +type Backend struct { + users map[string]*User +} + +func (be *Backend) Login(_ *imap.ConnInfo, username, password string) (backend.User, error) { + user, ok := be.users[username] + if ok && user.password == password { + return user, nil + } + + return nil, errors.New("Bad username or password") +} + +func New() *Backend { + user := &User{username: "username", password: "password"} + + body := "From: contact@example.org\r\n" + + "To: contact@example.org\r\n" + + "Subject: A little message, just for you\r\n" + + "Date: Wed, 11 May 2016 14:31:59 +0000\r\n" + + "Message-ID: <0000000@localhost/>\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "Hi there :)" + + user.mailboxes = map[string]*Mailbox{ + "INBOX": { + name: "INBOX", + user: user, + Messages: []*Message{ + { + Uid: 6, + Date: time.Now(), + Flags: []string{"\\Seen"}, + Size: uint32(len(body)), + Body: []byte(body), + }, + }, + }, + } + + return &Backend{ + users: map[string]*User{user.username: user}, + } +} + +// NewUser adds a user to the backend. +func (be *Backend) NewUser(username, password string) (*User, error) { + _, ok := be.users[username] + if ok { + return nil, fmt.Errorf("user %s is already defined.", username) + } + u := &User{username: username, password: password, mailboxes: make(map[string]*Mailbox)} + be.users[username] = u + return u, nil +} + +// DeleteUser removes a user from the backend. +func (be *Backend) DeleteUser(username string) error { + _, ok := be.users[username] + if !ok { + return fmt.Errorf("user %s is not defined.", username) + } + delete(be.users, username) + return nil +} diff --git a/backend/memory/mailbox.go b/backend/memory/mailbox.go new file mode 100644 index 0000000..2d0fa9f --- /dev/null +++ b/backend/memory/mailbox.go @@ -0,0 +1,243 @@ +package memory + +import ( + "io/ioutil" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/backend/backendutil" +) + +var Delimiter = "/" + +type Mailbox struct { + Subscribed bool + Messages []*Message + + name string + user *User +} + +func (mbox *Mailbox) Name() string { + return mbox.name +} + +func (mbox *Mailbox) Info() (*imap.MailboxInfo, error) { + info := &imap.MailboxInfo{ + Delimiter: Delimiter, + Name: mbox.name, + } + return info, nil +} + +func (mbox *Mailbox) uidNext() uint32 { + var uid uint32 + for _, msg := range mbox.Messages { + if msg.Uid > uid { + uid = msg.Uid + } + } + uid++ + return uid +} + +func (mbox *Mailbox) flags() []string { + flagsMap := make(map[string]bool) + for _, msg := range mbox.Messages { + for _, f := range msg.Flags { + if !flagsMap[f] { + flagsMap[f] = true + } + } + } + + var flags []string + for f := range flagsMap { + flags = append(flags, f) + } + return flags +} + +func (mbox *Mailbox) unseenSeqNum() uint32 { + for i, msg := range mbox.Messages { + seqNum := uint32(i + 1) + + seen := false + for _, flag := range msg.Flags { + if flag == imap.SeenFlag { + seen = true + break + } + } + + if !seen { + return seqNum + } + } + return 0 +} + +func (mbox *Mailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { + status := imap.NewMailboxStatus(mbox.name, items) + status.Flags = mbox.flags() + status.PermanentFlags = []string{"\\*"} + status.UnseenSeqNum = mbox.unseenSeqNum() + + for _, name := range items { + switch name { + case imap.StatusMessages: + status.Messages = uint32(len(mbox.Messages)) + case imap.StatusUidNext: + status.UidNext = mbox.uidNext() + case imap.StatusUidValidity: + status.UidValidity = 1 + case imap.StatusRecent: + status.Recent = 0 // TODO + case imap.StatusUnseen: + status.Unseen = 0 // TODO + } + } + + return status, nil +} + +func (mbox *Mailbox) SetSubscribed(subscribed bool) error { + mbox.Subscribed = subscribed + return nil +} + +func (mbox *Mailbox) Check() error { + return nil +} + +func (mbox *Mailbox) ListMessages(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { + defer close(ch) + + for i, msg := range mbox.Messages { + seqNum := uint32(i + 1) + + var id uint32 + if uid { + id = msg.Uid + } else { + id = seqNum + } + if !seqSet.Contains(id) { + continue + } + + m, err := msg.Fetch(seqNum, items) + if err != nil { + continue + } + + ch <- m + } + + return nil +} + +func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) { + var ids []uint32 + for i, msg := range mbox.Messages { + seqNum := uint32(i + 1) + + ok, err := msg.Match(seqNum, criteria) + if err != nil || !ok { + continue + } + + var id uint32 + if uid { + id = msg.Uid + } else { + id = seqNum + } + ids = append(ids, id) + } + return ids, nil +} + +func (mbox *Mailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { + if date.IsZero() { + date = time.Now() + } + + b, err := ioutil.ReadAll(body) + if err != nil { + return err + } + + mbox.Messages = append(mbox.Messages, &Message{ + Uid: mbox.uidNext(), + Date: date, + Size: uint32(len(b)), + Flags: flags, + Body: b, + }) + return nil +} + +func (mbox *Mailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) error { + for i, msg := range mbox.Messages { + var id uint32 + if uid { + id = msg.Uid + } else { + id = uint32(i + 1) + } + if !seqset.Contains(id) { + continue + } + + msg.Flags = backendutil.UpdateFlags(msg.Flags, op, flags) + } + + return nil +} + +func (mbox *Mailbox) CopyMessages(uid bool, seqset *imap.SeqSet, destName string) error { + dest, ok := mbox.user.mailboxes[destName] + if !ok { + return backend.ErrNoSuchMailbox + } + + for i, msg := range mbox.Messages { + var id uint32 + if uid { + id = msg.Uid + } else { + id = uint32(i + 1) + } + if !seqset.Contains(id) { + continue + } + + msgCopy := *msg + msgCopy.Uid = dest.uidNext() + dest.Messages = append(dest.Messages, &msgCopy) + } + + return nil +} + +func (mbox *Mailbox) Expunge() error { + for i := len(mbox.Messages) - 1; i >= 0; i-- { + msg := mbox.Messages[i] + + deleted := false + for _, flag := range msg.Flags { + if flag == imap.DeletedFlag { + deleted = true + break + } + } + + if deleted { + mbox.Messages = append(mbox.Messages[:i], mbox.Messages[i+1:]...) + } + } + + return nil +} diff --git a/backend/memory/message.go b/backend/memory/message.go new file mode 100644 index 0000000..5829058 --- /dev/null +++ b/backend/memory/message.go @@ -0,0 +1,74 @@ +package memory + +import ( + "bufio" + "bytes" + "io" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend/backendutil" + "github.com/emersion/go-message" + "github.com/emersion/go-message/textproto" +) + +type Message struct { + Uid uint32 + Date time.Time + Size uint32 + Flags []string + Body []byte +} + +func (m *Message) entity() (*message.Entity, error) { + return message.Read(bytes.NewReader(m.Body)) +} + +func (m *Message) headerAndBody() (textproto.Header, io.Reader, error) { + body := bufio.NewReader(bytes.NewReader(m.Body)) + hdr, err := textproto.ReadHeader(body) + return hdr, body, err +} + +func (m *Message) Fetch(seqNum uint32, items []imap.FetchItem) (*imap.Message, error) { + fetched := imap.NewMessage(seqNum, items) + for _, item := range items { + switch item { + case imap.FetchEnvelope: + hdr, _, _ := m.headerAndBody() + fetched.Envelope, _ = backendutil.FetchEnvelope(hdr) + case imap.FetchBody, imap.FetchBodyStructure: + hdr, body, _ := m.headerAndBody() + fetched.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure) + case imap.FetchFlags: + fetched.Flags = m.Flags + case imap.FetchInternalDate: + fetched.InternalDate = m.Date + case imap.FetchRFC822Size: + fetched.Size = m.Size + case imap.FetchUid: + fetched.Uid = m.Uid + default: + section, err := imap.ParseBodySectionName(item) + if err != nil { + break + } + + body := bufio.NewReader(bytes.NewReader(m.Body)) + hdr, err := textproto.ReadHeader(body) + if err != nil { + return nil, err + } + + l, _ := backendutil.FetchBodySection(hdr, body, section) + fetched.Body[section] = l + } + } + + return fetched, nil +} + +func (m *Message) Match(seqNum uint32, c *imap.SearchCriteria) (bool, error) { + e, _ := m.entity() + return backendutil.Match(e, seqNum, m.Uid, m.Date, m.Flags, c) +} diff --git a/backend/memory/user.go b/backend/memory/user.go new file mode 100644 index 0000000..5a4d376 --- /dev/null +++ b/backend/memory/user.go @@ -0,0 +1,82 @@ +package memory + +import ( + "errors" + + "github.com/emersion/go-imap/backend" +) + +type User struct { + username string + password string + mailboxes map[string]*Mailbox +} + +func (u *User) Username() string { + return u.username +} + +func (u *User) ListMailboxes(subscribed bool) (mailboxes []backend.Mailbox, err error) { + for _, mailbox := range u.mailboxes { + if subscribed && !mailbox.Subscribed { + continue + } + + mailboxes = append(mailboxes, mailbox) + } + return +} + +func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) { + mailbox, ok := u.mailboxes[name] + if !ok { + err = errors.New("No such mailbox") + } + return +} + +func (u *User) CreateMailbox(name string) error { + if _, ok := u.mailboxes[name]; ok { + return errors.New("Mailbox already exists") + } + + u.mailboxes[name] = &Mailbox{name: name, user: u} + return nil +} + +func (u *User) DeleteMailbox(name string) error { + if name == "INBOX" { + return errors.New("Cannot delete INBOX") + } + if _, ok := u.mailboxes[name]; !ok { + return errors.New("No such mailbox") + } + + delete(u.mailboxes, name) + return nil +} + +func (u *User) RenameMailbox(existingName, newName string) error { + mbox, ok := u.mailboxes[existingName] + if !ok { + return errors.New("No such mailbox") + } + + u.mailboxes[newName] = &Mailbox{ + name: newName, + Messages: mbox.Messages, + user: u, + } + + mbox.Messages = nil + + if existingName != "INBOX" { + delete(u.mailboxes, existingName) + } + + return nil +} + +func (u *User) Logout() error { + return nil +} diff --git a/backend/move.go b/backend/move.go new file mode 100644 index 0000000..a7b5968 --- /dev/null +++ b/backend/move.go @@ -0,0 +1,19 @@ +package backend + +import ( + "github.com/emersion/go-imap" +) + +// MoveMailbox is a mailbox that supports moving messages. +type MoveMailbox interface { + Mailbox + + // Move the specified message(s) to the end of the specified destination + // mailbox. This means that a new message is created in the target mailbox + // with a new UID, the original message is removed from the source mailbox, + // and it appears to the client as a single action. + // + // If the destination mailbox does not exist, a server SHOULD return an error. + // It SHOULD NOT automatically create the mailbox. + MoveMessages(uid bool, seqset *imap.SeqSet, dest string) error +} diff --git a/backend/updates.go b/backend/updates.go new file mode 100644 index 0000000..39a93db --- /dev/null +++ b/backend/updates.go @@ -0,0 +1,98 @@ +package backend + +import ( + "github.com/emersion/go-imap" +) + +// Update contains user and mailbox information about an unilateral backend +// update. +type Update interface { + // The user targeted by this update. If empty, all connected users will + // be notified. + Username() string + // The mailbox targeted by this update. If empty, the update targets all + // mailboxes. + Mailbox() string + // Done returns a channel that is closed when the update has been broadcast to + // all clients. + Done() chan struct{} +} + +// NewUpdate creates a new update. +func NewUpdate(username, mailbox string) Update { + return &update{ + username: username, + mailbox: mailbox, + } +} + +type update struct { + username string + mailbox string + done chan struct{} +} + +func (u *update) Username() string { + return u.username +} + +func (u *update) Mailbox() string { + return u.mailbox +} + +func (u *update) Done() chan struct{} { + if u.done == nil { + u.done = make(chan struct{}) + } + return u.done +} + +// StatusUpdate is a status update. See RFC 3501 section 7.1 for a list of +// status responses. +type StatusUpdate struct { + Update + *imap.StatusResp +} + +// MailboxUpdate is a mailbox update. +type MailboxUpdate struct { + Update + *imap.MailboxStatus +} + +// MailboxInfoUpdate is a maiblox info update. +type MailboxInfoUpdate struct { + Update + *imap.MailboxInfo +} + +// MessageUpdate is a message update. +type MessageUpdate struct { + Update + *imap.Message +} + +// ExpungeUpdate is an expunge update. +type ExpungeUpdate struct { + Update + SeqNum uint32 +} + +// BackendUpdater is a Backend that implements Updater is able to send +// unilateral backend updates. Backends not implementing this interface don't +// correctly send unilateral updates, for instance if a user logs in from two +// connections and deletes a message from one of them, the over is not aware +// that such a mesage has been deleted. More importantly, backends implementing +// Updater can notify the user for external updates such as new message +// notifications. +type BackendUpdater interface { + // Updates returns a set of channels where updates are sent to. + Updates() <-chan Update +} + +// MailboxPoller is a Mailbox that is able to poll updates for new messages or +// message status updates during a period of inactivity. +type MailboxPoller interface { + // Poll requests mailbox updates. + Poll() error +} diff --git a/backend/user.go b/backend/user.go new file mode 100644 index 0000000..afcd014 --- /dev/null +++ b/backend/user.go @@ -0,0 +1,92 @@ +package backend + +import "errors" + +var ( + // ErrNoSuchMailbox is returned by User.GetMailbox, User.DeleteMailbox and + // User.RenameMailbox when retrieving, deleting or renaming a mailbox that + // doesn't exist. + ErrNoSuchMailbox = errors.New("No such mailbox") + // ErrMailboxAlreadyExists is returned by User.CreateMailbox and + // User.RenameMailbox when creating or renaming mailbox that already exists. + ErrMailboxAlreadyExists = errors.New("Mailbox already exists") +) + +// User represents a user in the mail storage system. A user operation always +// deals with mailboxes. +type User interface { + // Username returns this user's username. + Username() string + + // ListMailboxes returns a list of mailboxes belonging to this user. If + // subscribed is set to true, only returns subscribed mailboxes. + ListMailboxes(subscribed bool) ([]Mailbox, error) + + // GetMailbox returns a mailbox. If it doesn't exist, it returns + // ErrNoSuchMailbox. + GetMailbox(name string) (Mailbox, error) + + // CreateMailbox creates a new mailbox. + // + // If the mailbox already exists, an error must be returned. If the mailbox + // name is suffixed with the server's hierarchy separator character, this is a + // declaration that the client intends to create mailbox names under this name + // in the hierarchy. + // + // If the server's hierarchy separator character appears elsewhere in the + // name, the server SHOULD create any superior hierarchical names that are + // needed for the CREATE command to be successfully completed. In other + // words, an attempt to create "foo/bar/zap" on a server in which "/" is the + // hierarchy separator character SHOULD create foo/ and foo/bar/ if they do + // not already exist. + // + // If a new mailbox is created with the same name as a mailbox which was + // deleted, its unique identifiers MUST be greater than any unique identifiers + // used in the previous incarnation of the mailbox UNLESS the new incarnation + // has a different unique identifier validity value. + CreateMailbox(name string) error + + // DeleteMailbox permanently remove the mailbox with the given name. It is an + // error to // attempt to delete INBOX or a mailbox name that does not exist. + // + // The DELETE command MUST NOT remove inferior hierarchical names. For + // example, if a mailbox "foo" has an inferior "foo.bar" (assuming "." is the + // hierarchy delimiter character), removing "foo" MUST NOT remove "foo.bar". + // + // The value of the highest-used unique identifier of the deleted mailbox MUST + // be preserved so that a new mailbox created with the same name will not + // reuse the identifiers of the former incarnation, UNLESS the new incarnation + // has a different unique identifier validity value. + DeleteMailbox(name string) error + + // RenameMailbox changes the name of a mailbox. It is an error to attempt to + // rename from a mailbox name that does not exist or to a mailbox name that + // already exists. + // + // If the name has inferior hierarchical names, then the inferior hierarchical + // names MUST also be renamed. For example, a rename of "foo" to "zap" will + // rename "foo/bar" (assuming "/" is the hierarchy delimiter character) to + // "zap/bar". + // + // If the server's hierarchy separator character appears in the name, the + // server SHOULD create any superior hierarchical names that are needed for + // the RENAME command to complete successfully. In other words, an attempt to + // rename "foo/bar/zap" to baz/rag/zowie on a server in which "/" is the + // hierarchy separator character SHOULD create baz/ and baz/rag/ if they do + // not already exist. + // + // The value of the highest-used unique identifier of the old mailbox name + // MUST be preserved so that a new mailbox created with the same name will not + // reuse the identifiers of the former incarnation, UNLESS the new incarnation + // has a different unique identifier validity value. + // + // Renaming INBOX is permitted, and has special behavior. It moves all + // messages in INBOX to a new mailbox with the given name, leaving INBOX + // empty. If the server implementation supports inferior hierarchical names + // of INBOX, these are unaffected by a rename of INBOX. + RenameMailbox(existingName, newName string) error + + // Logout is called when this User will no longer be used, likely because the + // client closed the connection. + Logout() error +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..d93c4cb --- /dev/null +++ b/client/client.go @@ -0,0 +1,695 @@ +// Package client provides an IMAP client. +// +// It is not safe to use the same Client from multiple goroutines. In general, +// the IMAP protocol doesn't make it possible to send multiple independent +// IMAP commands on the same connection. +package client + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net" + "os" + "sync" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" +) + +// errClosed is used when a connection is closed while waiting for a command +// response. +var errClosed = fmt.Errorf("imap: connection closed") + +// errUnregisterHandler is returned by a response handler to unregister itself. +var errUnregisterHandler = fmt.Errorf("imap: unregister handler") + +// Update is an unilateral server update. +type Update interface { + update() +} + +// StatusUpdate is delivered when a status update is received. +type StatusUpdate struct { + Status *imap.StatusResp +} + +func (u *StatusUpdate) update() {} + +// MailboxUpdate is delivered when a mailbox status changes. +type MailboxUpdate struct { + Mailbox *imap.MailboxStatus +} + +func (u *MailboxUpdate) update() {} + +// ExpungeUpdate is delivered when a message is deleted. +type ExpungeUpdate struct { + SeqNum uint32 +} + +func (u *ExpungeUpdate) update() {} + +// MessageUpdate is delivered when a message attribute changes. +type MessageUpdate struct { + Message *imap.Message +} + +func (u *MessageUpdate) update() {} + +// Client is an IMAP client. +type Client struct { + conn *imap.Conn + isTLS bool + serverName string + + loggedOut chan struct{} + continues chan<- bool + upgrading bool + + handlers []responses.Handler + handlersLocker sync.Mutex + + // The current connection state. + state imap.ConnState + // The selected mailbox, if there is one. + mailbox *imap.MailboxStatus + // The cached server capabilities. + caps map[string]bool + // state, mailbox and caps may be accessed in different goroutines. Protect + // access. + locker sync.Mutex + + // This flag is set when the first search query fails with a BADCHARSET + // error. Subsequent queries will be performed with the US-ASCII + // charset. According to RFC 3501, SEARCH must only support US-ASCII; + // other charsets are optional. + utf8SearchUnsupported bool + + // A channel to which unilateral updates from the server will be sent. An + // update can be one of: *StatusUpdate, *MailboxUpdate, *MessageUpdate, + // *ExpungeUpdate. Note that blocking this channel blocks the whole client, + // so it's recommended to use a separate goroutine and a buffered channel to + // prevent deadlocks. + Updates chan<- Update + + // ErrorLog specifies an optional logger for errors accepting connections and + // unexpected behavior from handlers. By default, logging goes to os.Stderr + // via the log package's standard logger. The logger must be safe to use + // simultaneously from multiple goroutines. + ErrorLog imap.Logger + + // Timeout specifies a maximum amount of time to wait on a command. + // + // A Timeout of zero means no timeout. This is the default. + Timeout time.Duration +} + +func (c *Client) registerHandler(h responses.Handler) { + if h == nil { + return + } + + c.handlersLocker.Lock() + c.handlers = append(c.handlers, h) + c.handlersLocker.Unlock() +} + +func (c *Client) handle(resp imap.Resp) error { + c.handlersLocker.Lock() + for i := len(c.handlers) - 1; i >= 0; i-- { + if err := c.handlers[i].Handle(resp); err != responses.ErrUnhandled { + if err == errUnregisterHandler { + c.handlers = append(c.handlers[:i], c.handlers[i+1:]...) + err = nil + } + c.handlersLocker.Unlock() + return err + } + } + c.handlersLocker.Unlock() + return responses.ErrUnhandled +} + +func (c *Client) reader() { + defer close(c.loggedOut) + // Loop while connected. + for { + connected, err := c.readOnce() + if err != nil { + c.ErrorLog.Println("error reading response:", err) + } + if !connected { + return + } + } +} + +func (c *Client) readOnce() (bool, error) { + if c.State() == imap.LogoutState { + return false, nil + } + + resp, err := imap.ReadResp(c.conn.Reader) + if err == io.EOF || c.State() == imap.LogoutState { + return false, nil + } else if err != nil { + if imap.IsParseError(err) { + return true, err + } else { + return false, err + } + } + + if err := c.handle(resp); err != nil && err != responses.ErrUnhandled { + c.ErrorLog.Println("cannot handle response ", resp, err) + } + return true, nil +} + +func (c *Client) writeReply(reply []byte) error { + if _, err := c.conn.Writer.Write(reply); err != nil { + return err + } + // Flush reply + return c.conn.Writer.Flush() +} + +type handleResult struct { + status *imap.StatusResp + err error +} + +func (c *Client) execute(cmdr imap.Commander, h responses.Handler) (*imap.StatusResp, error) { + cmd := cmdr.Command() + cmd.Tag = generateTag() + + var replies <-chan []byte + if replier, ok := h.(responses.Replier); ok { + replies = replier.Replies() + } + + if c.Timeout > 0 { + err := c.conn.SetDeadline(time.Now().Add(c.Timeout)) + if err != nil { + return nil, err + } + } else { + // It's possible the client had a timeout set from a previous command, but no + // longer does. Ensure we respect that. The zero time means no deadline. + if err := c.conn.SetDeadline(time.Time{}); err != nil { + return nil, err + } + } + + // Check if we are upgrading. + upgrading := c.upgrading + + // Add handler before sending command, to be sure to get the response in time + // (in tests, the response is sent right after our command is received, so + // sometimes the response was received before the setup of this handler) + doneHandle := make(chan handleResult, 1) + unregister := make(chan struct{}) + c.registerHandler(responses.HandlerFunc(func(resp imap.Resp) error { + select { + case <-unregister: + // If an error occured while sending the command, abort + return errUnregisterHandler + default: + } + + if s, ok := resp.(*imap.StatusResp); ok && s.Tag == cmd.Tag { + // This is the command's status response, we're done + doneHandle <- handleResult{s, nil} + // Special handling of connection upgrading. + if upgrading { + c.upgrading = false + // Wait for upgrade to finish. + c.conn.Wait() + } + // Cancel any pending literal write + select { + case c.continues <- false: + default: + } + return errUnregisterHandler + } + + if h != nil { + // Pass the response to the response handler + if err := h.Handle(resp); err != nil && err != responses.ErrUnhandled { + // If the response handler returns an error, abort + doneHandle <- handleResult{nil, err} + return errUnregisterHandler + } else { + return err + } + } + return responses.ErrUnhandled + })) + + // Send the command to the server + if err := cmd.WriteTo(c.conn.Writer); err != nil { + // Error while sending the command + close(unregister) + + if err, ok := err.(imap.LiteralLengthErr); ok { + // Expected > Actual + // The server is waiting for us to write + // more bytes, we don't have them. Run. + // Expected < Actual + // We are about to send a potentially truncated message, we don't + // want this (ths terminating CRLF is not sent at this point). + c.conn.Close() + return nil, err + } + + return nil, err + } + // Flush writer if we are upgrading + if upgrading { + if err := c.conn.Writer.Flush(); err != nil { + // Error while sending the command + close(unregister) + return nil, err + } + } + + for { + select { + case reply := <-replies: + // Response handler needs to send a reply (Used for AUTHENTICATE) + if err := c.writeReply(reply); err != nil { + close(unregister) + return nil, err + } + case <-c.loggedOut: + // If the connection is closed (such as from an I/O error), ensure we + // realize this and don't block waiting on a response that will never + // come. loggedOut is a channel that closes when the reader goroutine + // ends. + close(unregister) + return nil, errClosed + case result := <-doneHandle: + return result.status, result.err + } + } +} + +// State returns the current connection state. +func (c *Client) State() imap.ConnState { + c.locker.Lock() + state := c.state + c.locker.Unlock() + return state +} + +// Mailbox returns the selected mailbox. It returns nil if there isn't one. +func (c *Client) Mailbox() *imap.MailboxStatus { + // c.Mailbox fields are not supposed to change, so we can return the pointer. + c.locker.Lock() + mbox := c.mailbox + c.locker.Unlock() + return mbox +} + +// SetState sets this connection's internal state. +// +// This function should not be called directly, it must only be used by +// libraries implementing extensions of the IMAP protocol. +func (c *Client) SetState(state imap.ConnState, mailbox *imap.MailboxStatus) { + c.locker.Lock() + c.state = state + c.mailbox = mailbox + c.locker.Unlock() +} + +// Execute executes a generic command. cmdr is a value that can be converted to +// a raw command and h is a response handler. The function returns when the +// command has completed or failed, in this case err is nil. A non-nil err value +// indicates a network error. +// +// This function should not be called directly, it must only be used by +// libraries implementing extensions of the IMAP protocol. +func (c *Client) Execute(cmdr imap.Commander, h responses.Handler) (*imap.StatusResp, error) { + return c.execute(cmdr, h) +} + +func (c *Client) handleContinuationReqs() { + c.registerHandler(responses.HandlerFunc(func(resp imap.Resp) error { + if _, ok := resp.(*imap.ContinuationReq); ok { + go func() { + c.continues <- true + }() + return nil + } + return responses.ErrUnhandled + })) +} + +func (c *Client) gotStatusCaps(args []interface{}) { + c.locker.Lock() + + c.caps = make(map[string]bool) + for _, cap := range args { + if cap, ok := cap.(string); ok { + c.caps[cap] = true + } + } + + c.locker.Unlock() +} + +// The server can send unilateral data. This function handles it. +func (c *Client) handleUnilateral() { + c.registerHandler(responses.HandlerFunc(func(resp imap.Resp) error { + switch resp := resp.(type) { + case *imap.StatusResp: + if resp.Tag != "*" { + return responses.ErrUnhandled + } + + switch resp.Type { + case imap.StatusRespOk, imap.StatusRespNo, imap.StatusRespBad: + if c.Updates != nil { + c.Updates <- &StatusUpdate{resp} + } + case imap.StatusRespBye: + c.locker.Lock() + c.state = imap.LogoutState + c.mailbox = nil + c.locker.Unlock() + + c.conn.Close() + + if c.Updates != nil { + c.Updates <- &StatusUpdate{resp} + } + default: + return responses.ErrUnhandled + } + case *imap.DataResp: + name, fields, ok := imap.ParseNamedResp(resp) + if !ok { + return responses.ErrUnhandled + } + + switch name { + case "CAPABILITY": + c.gotStatusCaps(fields) + case "EXISTS": + if c.Mailbox() == nil { + break + } + + if messages, err := imap.ParseNumber(fields[0]); err == nil { + c.locker.Lock() + c.mailbox.Messages = messages + c.locker.Unlock() + + c.mailbox.ItemsLocker.Lock() + c.mailbox.Items[imap.StatusMessages] = nil + c.mailbox.ItemsLocker.Unlock() + } + + if c.Updates != nil { + c.Updates <- &MailboxUpdate{c.Mailbox()} + } + case "RECENT": + if c.Mailbox() == nil { + break + } + + if recent, err := imap.ParseNumber(fields[0]); err == nil { + c.locker.Lock() + c.mailbox.Recent = recent + c.locker.Unlock() + + c.mailbox.ItemsLocker.Lock() + c.mailbox.Items[imap.StatusRecent] = nil + c.mailbox.ItemsLocker.Unlock() + } + + if c.Updates != nil { + c.Updates <- &MailboxUpdate{c.Mailbox()} + } + case "EXPUNGE": + seqNum, _ := imap.ParseNumber(fields[0]) + + if c.Updates != nil { + c.Updates <- &ExpungeUpdate{seqNum} + } + case "FETCH": + seqNum, _ := imap.ParseNumber(fields[0]) + fields, _ := fields[1].([]interface{}) + + msg := &imap.Message{SeqNum: seqNum} + if err := msg.Parse(fields); err != nil { + break + } + + if c.Updates != nil { + c.Updates <- &MessageUpdate{msg} + } + default: + return responses.ErrUnhandled + } + default: + return responses.ErrUnhandled + } + return nil + })) +} + +func (c *Client) handleGreetAndStartReading() error { + var greetErr error + gotGreet := false + + c.registerHandler(responses.HandlerFunc(func(resp imap.Resp) error { + status, ok := resp.(*imap.StatusResp) + if !ok { + greetErr = fmt.Errorf("invalid greeting received from server: not a status response") + return errUnregisterHandler + } + + c.locker.Lock() + switch status.Type { + case imap.StatusRespPreauth: + c.state = imap.AuthenticatedState + case imap.StatusRespBye: + c.state = imap.LogoutState + case imap.StatusRespOk: + c.state = imap.NotAuthenticatedState + default: + c.state = imap.LogoutState + c.locker.Unlock() + greetErr = fmt.Errorf("invalid greeting received from server: %v", status.Type) + return errUnregisterHandler + } + c.locker.Unlock() + + if status.Code == imap.CodeCapability { + c.gotStatusCaps(status.Arguments) + } + + gotGreet = true + return errUnregisterHandler + })) + + // call `readOnce` until we get the greeting or an error + for !gotGreet { + connected, err := c.readOnce() + // Check for read errors + if err != nil { + // return read errors + return err + } + // Check for invalid greet + if greetErr != nil { + // return read errors + return greetErr + } + // Check if connection was closed. + if !connected { + // connection closed. + return io.EOF + } + } + + // We got the greeting, now start the reader goroutine. + go c.reader() + + return nil +} + +// Upgrade a connection, e.g. wrap an unencrypted connection with an encrypted +// tunnel. +// +// This function should not be called directly, it must only be used by +// libraries implementing extensions of the IMAP protocol. +func (c *Client) Upgrade(upgrader imap.ConnUpgrader) error { + return c.conn.Upgrade(upgrader) +} + +// Writer returns the imap.Writer for this client's connection. +// +// This function should not be called directly, it must only be used by +// libraries implementing extensions of the IMAP protocol. +func (c *Client) Writer() *imap.Writer { + return c.conn.Writer +} + +// IsTLS checks if this client's connection has TLS enabled. +func (c *Client) IsTLS() bool { + return c.isTLS +} + +// LoggedOut returns a channel which is closed when the connection to the server +// is closed. +func (c *Client) LoggedOut() <-chan struct{} { + return c.loggedOut +} + +// SetDebug defines an io.Writer to which all network activity will be logged. +// If nil is provided, network activity will not be logged. +func (c *Client) SetDebug(w io.Writer) { + // Need to send a command to unblock the reader goroutine. + cmd := new(commands.Noop) + err := c.Upgrade(func(conn net.Conn) (net.Conn, error) { + // Flag connection as in upgrading + c.upgrading = true + if status, err := c.execute(cmd, nil); err != nil { + return nil, err + } else if err := status.Err(); err != nil { + return nil, err + } + + // Wait for reader to block. + c.conn.WaitReady() + + c.conn.SetDebug(w) + return conn, nil + }) + if err != nil { + log.Println("SetDebug:", err) + } + +} + +// New creates a new client from an existing connection. +func New(conn net.Conn) (*Client, error) { + continues := make(chan bool) + w := imap.NewClientWriter(nil, continues) + r := imap.NewReader(nil) + + c := &Client{ + conn: imap.NewConn(conn, r, w), + loggedOut: make(chan struct{}), + continues: continues, + state: imap.ConnectingState, + ErrorLog: log.New(os.Stderr, "imap/client: ", log.LstdFlags), + } + + c.handleContinuationReqs() + c.handleUnilateral() + if err := c.handleGreetAndStartReading(); err != nil { + return c, err + } + + plusOk, _ := c.Support("LITERAL+") + minusOk, _ := c.Support("LITERAL-") + // We don't use non-sync literal if it is bigger than 4096 bytes, so + // LITERAL- is fine too. + c.conn.AllowAsyncLiterals = plusOk || minusOk + + return c, nil +} + +// Dial connects to an IMAP server using an unencrypted connection. +func Dial(addr string) (*Client, error) { + return DialWithDialer(new(net.Dialer), addr) +} + +type Dialer interface { + // Dial connects to the given address. + Dial(network, addr string) (net.Conn, error) +} + +// DialWithDialer connects to an IMAP server using an unencrypted connection +// using dialer.Dial. +// +// Among other uses, this allows to apply a dial timeout. +func DialWithDialer(dialer Dialer, addr string) (*Client, error) { + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return nil, err + } + + // We don't return to the caller until we try to receive a greeting. As such, + // there is no way to set the client's Timeout for that action. As a + // workaround, if the dialer has a timeout set, use that for the connection's + // deadline. + if netDialer, ok := dialer.(*net.Dialer); ok && netDialer.Timeout > 0 { + err := conn.SetDeadline(time.Now().Add(netDialer.Timeout)) + if err != nil { + return nil, err + } + } + + c, err := New(conn) + if err != nil { + return nil, err + } + + c.serverName, _, _ = net.SplitHostPort(addr) + return c, nil +} + +// DialTLS connects to an IMAP server using an encrypted connection. +func DialTLS(addr string, tlsConfig *tls.Config) (*Client, error) { + return DialWithDialerTLS(new(net.Dialer), addr, tlsConfig) +} + +// DialWithDialerTLS connects to an IMAP server using an encrypted connection +// using dialer.Dial. +// +// Among other uses, this allows to apply a dial timeout. +func DialWithDialerTLS(dialer Dialer, addr string, tlsConfig *tls.Config) (*Client, error) { + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return nil, err + } + + serverName, _, _ := net.SplitHostPort(addr) + if tlsConfig == nil { + tlsConfig = &tls.Config{} + } + if tlsConfig.ServerName == "" { + tlsConfig = tlsConfig.Clone() + tlsConfig.ServerName = serverName + } + tlsConn := tls.Client(conn, tlsConfig) + + // We don't return to the caller until we try to receive a greeting. As such, + // there is no way to set the client's Timeout for that action. As a + // workaround, if the dialer has a timeout set, use that for the connection's + // deadline. + if netDialer, ok := dialer.(*net.Dialer); ok && netDialer.Timeout > 0 { + err := tlsConn.SetDeadline(time.Now().Add(netDialer.Timeout)) + if err != nil { + return nil, err + } + } + + c, err := New(tlsConn) + if err != nil { + return nil, err + } + + c.isTLS = true + c.serverName = serverName + return c, nil +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..99b40a2 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,187 @@ +package client + +import ( + "bufio" + "bytes" + "io" + "net" + "strings" + "testing" + + "github.com/emersion/go-imap" +) + +type cmdScanner struct { + scanner *bufio.Scanner +} + +func (s *cmdScanner) ScanLine() string { + s.scanner.Scan() + return s.scanner.Text() +} + +func (s *cmdScanner) ScanCmd() (tag string, cmd string) { + parts := strings.SplitN(s.ScanLine(), " ", 2) + return parts[0], parts[1] +} + +func newCmdScanner(r io.Reader) *cmdScanner { + return &cmdScanner{ + scanner: bufio.NewScanner(r), + } +} + +type serverConn struct { + *cmdScanner + net.Conn + net.Listener +} + +func (c *serverConn) Close() error { + if err := c.Conn.Close(); err != nil { + return err + } + return c.Listener.Close() +} + +func (c *serverConn) WriteString(s string) (n int, err error) { + return io.WriteString(c.Conn, s) +} + +func newTestClient(t *testing.T) (c *Client, s *serverConn) { + return newTestClientWithGreeting(t, "* OK [CAPABILITY IMAP4rev1 STARTTLS AUTH=PLAIN UNSELECT] Server ready.\r\n") +} + +func newTestClientWithGreeting(t *testing.T, greeting string) (c *Client, s *serverConn) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + done := make(chan struct{}) + go func() { + conn, err := l.Accept() + if err != nil { + panic(err) + } + + if _, err := io.WriteString(conn, greeting); err != nil { + panic(err) + } + + s = &serverConn{newCmdScanner(conn), conn, l} + close(done) + }() + + c, err = Dial(l.Addr().String()) + if err != nil { + t.Fatal(err) + } + + <-done + return +} + +func setClientState(c *Client, state imap.ConnState, mailbox *imap.MailboxStatus) { + c.locker.Lock() + c.state = state + c.mailbox = mailbox + c.locker.Unlock() +} + +func TestClient(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + if ok, err := c.Support("IMAP4rev1"); err != nil { + t.Fatal("c.Support(IMAP4rev1) =", err) + } else if !ok { + t.Fatal("c.Support(IMAP4rev1) = false, want true") + } +} + +func TestClient_SetDebug(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + var b bytes.Buffer + done := make(chan error) + + go func() { + c.SetDebug(&b) + done <- nil + }() + if tag, cmd := s.ScanCmd(); cmd != "NOOP" { + t.Fatal("Bad command:", cmd) + } else { + s.WriteString(tag + " OK NOOP completed.\r\n") + } + // wait for SetDebug to finish. + <-done + + go func() { + _, err := c.Capability() + done <- err + }() + + tag, cmd := s.ScanCmd() + if cmd != "CAPABILITY" { + t.Fatal("Bad command:", cmd) + } + + s.WriteString("* CAPABILITY IMAP4rev1\r\n") + s.WriteString(tag + " OK CAPABILITY completed.\r\n") + + if err := <-done; err != nil { + t.Fatal("c.Capability() =", err) + } + + if b.Len() == 0 { + t.Error("empty debug buffer") + } +} + +func TestClient_unilateral(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, imap.NewMailboxStatus("INBOX", nil)) + + updates := make(chan Update, 1) + c.Updates = updates + + s.WriteString("* 42 EXISTS\r\n") + if update, ok := (<-updates).(*MailboxUpdate); !ok || update.Mailbox.Messages != 42 { + t.Errorf("Invalid messages count: expected %v but got %v", 42, update.Mailbox.Messages) + } + + s.WriteString("* 587 RECENT\r\n") + if update, ok := (<-updates).(*MailboxUpdate); !ok || update.Mailbox.Recent != 587 { + t.Errorf("Invalid recent count: expected %v but got %v", 587, update.Mailbox.Recent) + } + + s.WriteString("* 65535 EXPUNGE\r\n") + if update, ok := (<-updates).(*ExpungeUpdate); !ok || update.SeqNum != 65535 { + t.Errorf("Invalid expunged sequence number: expected %v but got %v", 65535, update.SeqNum) + } + + s.WriteString("* 431 FETCH (FLAGS (\\Seen))\r\n") + if update, ok := (<-updates).(*MessageUpdate); !ok || update.Message.SeqNum != 431 { + t.Errorf("Invalid expunged sequence number: expected %v but got %v", 431, update.Message.SeqNum) + } + + s.WriteString("* OK Reticulating splines...\r\n") + if update, ok := (<-updates).(*StatusUpdate); !ok || update.Status.Info != "Reticulating splines..." { + t.Errorf("Invalid info: got %v", update.Status.Info) + } + + s.WriteString("* NO Kansai band competition is in 30 seconds !\r\n") + if update, ok := (<-updates).(*StatusUpdate); !ok || update.Status.Info != "Kansai band competition is in 30 seconds !" { + t.Errorf("Invalid warning: got %v", update.Status.Info) + } + + s.WriteString("* BAD Battery level too low, shutting down.\r\n") + if update, ok := (<-updates).(*StatusUpdate); !ok || update.Status.Info != "Battery level too low, shutting down." { + t.Errorf("Invalid error: got %v", update.Status.Info) + } +} diff --git a/client/cmd_any.go b/client/cmd_any.go new file mode 100644 index 0000000..cb0d38a --- /dev/null +++ b/client/cmd_any.go @@ -0,0 +1,88 @@ +package client + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/commands" +) + +// ErrAlreadyLoggedOut is returned if Logout is called when the client is +// already logged out. +var ErrAlreadyLoggedOut = errors.New("Already logged out") + +// Capability requests a listing of capabilities that the server supports. +// Capabilities are often returned by the server with the greeting or with the +// STARTTLS and LOGIN responses, so usually explicitly requesting capabilities +// isn't needed. +// +// Most of the time, Support should be used instead. +func (c *Client) Capability() (map[string]bool, error) { + cmd := &commands.Capability{} + + if status, err := c.execute(cmd, nil); err != nil { + return nil, err + } else if err := status.Err(); err != nil { + return nil, err + } + + c.locker.Lock() + caps := c.caps + c.locker.Unlock() + return caps, nil +} + +// Support checks if cap is a capability supported by the server. If the server +// hasn't sent its capabilities yet, Support requests them. +func (c *Client) Support(cap string) (bool, error) { + c.locker.Lock() + ok := c.caps != nil + c.locker.Unlock() + + // If capabilities are not cached, request them + if !ok { + if _, err := c.Capability(); err != nil { + return false, err + } + } + + c.locker.Lock() + supported := c.caps[cap] + c.locker.Unlock() + + return supported, nil +} + +// Noop always succeeds and does nothing. +// +// It can be used as a periodic poll for new messages or message status updates +// during a period of inactivity. It can also be used to reset any inactivity +// autologout timer on the server. +func (c *Client) Noop() error { + cmd := new(commands.Noop) + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + return status.Err() +} + +// Logout gracefully closes the connection. +func (c *Client) Logout() error { + if c.State() == imap.LogoutState { + return ErrAlreadyLoggedOut + } + + cmd := new(commands.Logout) + + if status, err := c.execute(cmd, nil); err == errClosed { + // Server closed connection, that's what we want anyway + return nil + } else if err != nil { + return err + } else if status != nil { + return status.Err() + } + return nil +} diff --git a/client/cmd_any_test.go b/client/cmd_any_test.go new file mode 100644 index 0000000..5491221 --- /dev/null +++ b/client/cmd_any_test.go @@ -0,0 +1,80 @@ +package client + +import ( + "testing" + + "github.com/emersion/go-imap" +) + +func TestClient_Capability(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + var caps map[string]bool + done := make(chan error, 1) + go func() { + var err error + caps, err = c.Capability() + done <- err + }() + + tag, cmd := s.ScanCmd() + if cmd != "CAPABILITY" { + t.Fatalf("client sent command %v, want CAPABILITY", cmd) + } + s.WriteString("* CAPABILITY IMAP4rev1 XTEST\r\n") + s.WriteString(tag + " OK CAPABILITY completed.\r\n") + + if err := <-done; err != nil { + t.Error("c.Capability() = ", err) + } + + if !caps["XTEST"] { + t.Error("XTEST capability missing") + } +} + +func TestClient_Noop(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + done := make(chan error, 1) + go func() { + done <- c.Noop() + }() + + tag, cmd := s.ScanCmd() + if cmd != "NOOP" { + t.Fatalf("client sent command %v, want NOOP", cmd) + } + s.WriteString(tag + " OK NOOP completed\r\n") + + if err := <-done; err != nil { + t.Error("c.Noop() = ", err) + } +} + +func TestClient_Logout(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + done := make(chan error, 1) + go func() { + done <- c.Logout() + }() + + tag, cmd := s.ScanCmd() + if cmd != "LOGOUT" { + t.Fatalf("client sent command %v, want LOGOUT", cmd) + } + s.WriteString("* BYE Client asked to close the connection.\r\n") + s.WriteString(tag + " OK LOGOUT completed\r\n") + + if err := <-done; err != nil { + t.Error("c.Logout() =", err) + } + + if state := c.State(); state != imap.LogoutState { + t.Errorf("c.State() = %v, want %v", state, imap.LogoutState) + } +} diff --git a/client/cmd_auth.go b/client/cmd_auth.go new file mode 100644 index 0000000..a280017 --- /dev/null +++ b/client/cmd_auth.go @@ -0,0 +1,380 @@ +package client + +import ( + "errors" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" +) + +// ErrNotLoggedIn is returned if a function that requires the client to be +// logged in is called then the client isn't. +var ErrNotLoggedIn = errors.New("Not logged in") + +func (c *Client) ensureAuthenticated() error { + state := c.State() + if state != imap.AuthenticatedState && state != imap.SelectedState { + return ErrNotLoggedIn + } + return nil +} + +// Select selects a mailbox so that messages in the mailbox can be accessed. Any +// currently selected mailbox is deselected before attempting the new selection. +// Even if the readOnly parameter is set to false, the server can decide to open +// the mailbox in read-only mode. +func (c *Client) Select(name string, readOnly bool) (*imap.MailboxStatus, error) { + if err := c.ensureAuthenticated(); err != nil { + return nil, err + } + + cmd := &commands.Select{ + Mailbox: name, + ReadOnly: readOnly, + } + + mbox := &imap.MailboxStatus{Name: name, Items: make(map[imap.StatusItem]interface{})} + res := &responses.Select{ + Mailbox: mbox, + } + c.locker.Lock() + c.mailbox = mbox + c.locker.Unlock() + + status, err := c.execute(cmd, res) + if err != nil { + c.locker.Lock() + c.mailbox = nil + c.locker.Unlock() + return nil, err + } + if err := status.Err(); err != nil { + c.locker.Lock() + c.mailbox = nil + c.locker.Unlock() + return nil, err + } + + c.locker.Lock() + mbox.ReadOnly = (status.Code == imap.CodeReadOnly) + c.state = imap.SelectedState + c.locker.Unlock() + return mbox, nil +} + +// Create creates a mailbox with the given name. +func (c *Client) Create(name string) error { + if err := c.ensureAuthenticated(); err != nil { + return err + } + + cmd := &commands.Create{ + Mailbox: name, + } + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + return status.Err() +} + +// Delete permanently removes the mailbox with the given name. +func (c *Client) Delete(name string) error { + if err := c.ensureAuthenticated(); err != nil { + return err + } + + cmd := &commands.Delete{ + Mailbox: name, + } + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + return status.Err() +} + +// Rename changes the name of a mailbox. +func (c *Client) Rename(existingName, newName string) error { + if err := c.ensureAuthenticated(); err != nil { + return err + } + + cmd := &commands.Rename{ + Existing: existingName, + New: newName, + } + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + return status.Err() +} + +// Subscribe adds the specified mailbox name to the server's set of "active" or +// "subscribed" mailboxes. +func (c *Client) Subscribe(name string) error { + if err := c.ensureAuthenticated(); err != nil { + return err + } + + cmd := &commands.Subscribe{ + Mailbox: name, + } + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + return status.Err() +} + +// Unsubscribe removes the specified mailbox name from the server's set of +// "active" or "subscribed" mailboxes. +func (c *Client) Unsubscribe(name string) error { + if err := c.ensureAuthenticated(); err != nil { + return err + } + + cmd := &commands.Unsubscribe{ + Mailbox: name, + } + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + return status.Err() +} + +// List returns a subset of names from the complete set of all names available +// to the client. +// +// An empty name argument is a special request to return the hierarchy delimiter +// and the root name of the name given in the reference. The character "*" is a +// wildcard, and matches zero or more characters at this position. The +// character "%" is similar to "*", but it does not match a hierarchy delimiter. +func (c *Client) List(ref, name string, ch chan *imap.MailboxInfo) error { + defer close(ch) + + if err := c.ensureAuthenticated(); err != nil { + return err + } + + cmd := &commands.List{ + Reference: ref, + Mailbox: name, + } + res := &responses.List{Mailboxes: ch} + + status, err := c.execute(cmd, res) + if err != nil { + return err + } + return status.Err() +} + +// Lsub returns a subset of names from the set of names that the user has +// declared as being "active" or "subscribed". +func (c *Client) Lsub(ref, name string, ch chan *imap.MailboxInfo) error { + defer close(ch) + + if err := c.ensureAuthenticated(); err != nil { + return err + } + + cmd := &commands.List{ + Reference: ref, + Mailbox: name, + Subscribed: true, + } + res := &responses.List{ + Mailboxes: ch, + Subscribed: true, + } + + status, err := c.execute(cmd, res) + if err != nil { + return err + } + return status.Err() +} + +// Status requests the status of the indicated mailbox. It does not change the +// currently selected mailbox, nor does it affect the state of any messages in +// the queried mailbox. +// +// See RFC 3501 section 6.3.10 for a list of items that can be requested. +func (c *Client) Status(name string, items []imap.StatusItem) (*imap.MailboxStatus, error) { + if err := c.ensureAuthenticated(); err != nil { + return nil, err + } + + cmd := &commands.Status{ + Mailbox: name, + Items: items, + } + res := &responses.Status{ + Mailbox: new(imap.MailboxStatus), + } + + status, err := c.execute(cmd, res) + if err != nil { + return nil, err + } + return res.Mailbox, status.Err() +} + +// Append appends the literal argument as a new message to the end of the +// specified destination mailbox. This argument SHOULD be in the format of an +// RFC 2822 message. flags and date are optional arguments and can be set to +// nil and the empty struct. +func (c *Client) Append(mbox string, flags []string, date time.Time, msg imap.Literal) error { + if err := c.ensureAuthenticated(); err != nil { + return err + } + + cmd := &commands.Append{ + Mailbox: mbox, + Flags: flags, + Date: date, + Message: msg, + } + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + return status.Err() +} + +// Enable requests the server to enable the named extensions. The extensions +// which were successfully enabled are returned. +// +// See RFC 5161 section 3.1. +func (c *Client) Enable(caps []string) ([]string, error) { + if ok, err := c.Support("ENABLE"); !ok || err != nil { + return nil, ErrExtensionUnsupported + } + + // ENABLE is invalid if a mailbox has been selected. + if c.State() != imap.AuthenticatedState { + return nil, ErrNotLoggedIn + } + + cmd := &commands.Enable{Caps: caps} + res := &responses.Enabled{} + + if status, err := c.Execute(cmd, res); err != nil { + return nil, err + } else { + return res.Caps, status.Err() + } +} + +func (c *Client) idle(stop <-chan struct{}) error { + cmd := &commands.Idle{} + + res := &responses.Idle{ + Stop: stop, + RepliesCh: make(chan []byte, 10), + } + + if status, err := c.Execute(cmd, res); err != nil { + return err + } else { + return status.Err() + } +} + +// IdleOptions holds options for Client.Idle. +type IdleOptions struct { + // LogoutTimeout is used to avoid being logged out by the server when + // idling. Each LogoutTimeout, the IDLE command is restarted. If set to + // zero, a default is used. If negative, this behavior is disabled. + LogoutTimeout time.Duration + // Poll interval when the server doesn't support IDLE. If zero, a default + // is used. If negative, polling is always disabled. + PollInterval time.Duration +} + +// Idle indicates to the server that the client is ready to receive unsolicited +// mailbox update messages. When the client wants to send commands again, it +// must first close stop. +// +// If the server doesn't support IDLE, go-imap falls back to polling. +func (c *Client) Idle(stop <-chan struct{}, opts *IdleOptions) error { + if ok, err := c.Support("IDLE"); err != nil { + return err + } else if !ok { + return c.idleFallback(stop, opts) + } + + logoutTimeout := 25 * time.Minute + if opts != nil { + if opts.LogoutTimeout > 0 { + logoutTimeout = opts.LogoutTimeout + } else if opts.LogoutTimeout < 0 { + return c.idle(stop) + } + } + + t := time.NewTicker(logoutTimeout) + defer t.Stop() + + for { + stopOrRestart := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- c.idle(stopOrRestart) + }() + + select { + case <-t.C: + close(stopOrRestart) + if err := <-done; err != nil { + return err + } + case <-stop: + close(stopOrRestart) + return <-done + case err := <-done: + close(stopOrRestart) + if err != nil { + return err + } + } + } +} + +func (c *Client) idleFallback(stop <-chan struct{}, opts *IdleOptions) error { + pollInterval := time.Minute + if opts != nil { + if opts.PollInterval > 0 { + pollInterval = opts.PollInterval + } else if opts.PollInterval < 0 { + return ErrExtensionUnsupported + } + } + + t := time.NewTicker(pollInterval) + defer t.Stop() + + for { + select { + case <-t.C: + if err := c.Noop(); err != nil { + return err + } + case <-stop: + return nil + case <-c.LoggedOut(): + return errors.New("disconnected while idling") + } + } +} diff --git a/client/cmd_auth_test.go b/client/cmd_auth_test.go new file mode 100644 index 0000000..68b6f97 --- /dev/null +++ b/client/cmd_auth_test.go @@ -0,0 +1,499 @@ +package client + +import ( + "bytes" + "io" + "reflect" + "testing" + "time" + + "github.com/emersion/go-imap" +) + +func TestClient_Select(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + var mbox *imap.MailboxStatus + done := make(chan error, 1) + go func() { + var err error + mbox, err = c.Select("INBOX", false) + done <- err + }() + + tag, cmd := s.ScanCmd() + if cmd != "SELECT INBOX" { + t.Fatalf("client sent command %v, want SELECT \"INBOX\"", cmd) + } + + s.WriteString("* 172 EXISTS\r\n") + s.WriteString("* 1 RECENT\r\n") + s.WriteString("* OK [UNSEEN 12] Message 12 is first unseen\r\n") + s.WriteString("* OK [UIDVALIDITY 3857529045] UIDs valid\r\n") + s.WriteString("* OK [UIDNEXT 4392] Predicted next UID\r\n") + s.WriteString("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n") + s.WriteString("* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited\r\n") + s.WriteString(tag + " OK SELECT completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Select() = %v", err) + } + + want := &imap.MailboxStatus{ + Name: "INBOX", + ReadOnly: false, + Flags: []string{imap.AnsweredFlag, imap.FlaggedFlag, imap.DeletedFlag, imap.SeenFlag, imap.DraftFlag}, + PermanentFlags: []string{imap.DeletedFlag, imap.SeenFlag, "\\*"}, + UnseenSeqNum: 12, + Messages: 172, + Recent: 1, + UidNext: 4392, + UidValidity: 3857529045, + } + mbox.Items = nil + if !reflect.DeepEqual(mbox, want) { + t.Errorf("c.Select() = \n%+v\n want \n%+v", mbox, want) + } +} + +func TestClient_Select_ReadOnly(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + var mbox *imap.MailboxStatus + done := make(chan error, 1) + go func() { + var err error + mbox, err = c.Select("INBOX", true) + done <- err + }() + + tag, cmd := s.ScanCmd() + if cmd != "EXAMINE INBOX" { + t.Fatalf("client sent command %v, want EXAMINE \"INBOX\"", cmd) + } + + s.WriteString(tag + " OK [READ-ONLY] EXAMINE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Select() = %v", err) + } + + if !mbox.ReadOnly { + t.Errorf("c.Select().ReadOnly = false, want true") + } +} + +func TestClient_Create(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + done := make(chan error, 1) + go func() { + done <- c.Create("New Mailbox") + }() + + tag, cmd := s.ScanCmd() + if cmd != "CREATE \"New Mailbox\"" { + t.Fatalf("client sent command %v, want %v", cmd, "CREATE \"New Mailbox\"") + } + + s.WriteString(tag + " OK CREATE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Create() = %v", err) + } +} + +func TestClient_Delete(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + done := make(chan error, 1) + go func() { + done <- c.Delete("Old Mailbox") + }() + + tag, cmd := s.ScanCmd() + if cmd != "DELETE \"Old Mailbox\"" { + t.Fatalf("client sent command %v, want %v", cmd, "DELETE \"Old Mailbox\"") + } + + s.WriteString(tag + " OK DELETE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Delete() = %v", err) + } +} + +func TestClient_Rename(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + done := make(chan error, 1) + go func() { + done <- c.Rename("Old Mailbox", "New Mailbox") + }() + + tag, cmd := s.ScanCmd() + if cmd != "RENAME \"Old Mailbox\" \"New Mailbox\"" { + t.Fatalf("client sent command %v, want %v", cmd, "RENAME \"Old Mailbox\" \"New Mailbox\"") + } + + s.WriteString(tag + " OK RENAME completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Rename() = %v", err) + } +} + +func TestClient_Subscribe(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + done := make(chan error, 1) + go func() { + done <- c.Subscribe("Mailbox") + }() + + tag, cmd := s.ScanCmd() + if cmd != "SUBSCRIBE \"Mailbox\"" { + t.Fatalf("client sent command %v, want %v", cmd, "SUBSCRIBE \"Mailbox\"") + } + + s.WriteString(tag + " OK SUBSCRIBE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Subscribe() = %v", err) + } +} + +func TestClient_Unsubscribe(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + done := make(chan error, 1) + go func() { + done <- c.Unsubscribe("Mailbox") + }() + + tag, cmd := s.ScanCmd() + if cmd != "UNSUBSCRIBE \"Mailbox\"" { + t.Fatalf("client sent command %v, want %v", cmd, "UNSUBSCRIBE \"Mailbox\"") + } + + s.WriteString(tag + " OK UNSUBSCRIBE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Unsubscribe() = %v", err) + } +} + +func TestClient_List(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + done := make(chan error, 1) + mailboxes := make(chan *imap.MailboxInfo, 3) + go func() { + done <- c.List("", "%", mailboxes) + }() + + tag, cmd := s.ScanCmd() + if cmd != "LIST \"\" \"%\"" { + t.Fatalf("client sent command %v, want %v", cmd, "LIST \"\" \"%\"") + } + + s.WriteString("* LIST (flag1) \"/\" INBOX\r\n") + s.WriteString("* LIST (flag2 flag3) \"/\" Drafts\r\n") + s.WriteString("* LIST () \"/\" Sent\r\n") + s.WriteString(tag + " OK LIST completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.List() = %v", err) + } + + want := []struct { + name string + attributes []string + }{ + {"INBOX", []string{"flag1"}}, + {"Drafts", []string{"flag2", "flag3"}}, + {"Sent", []string{}}, + } + + i := 0 + for mbox := range mailboxes { + if mbox.Name != want[i].name { + t.Errorf("Bad mailbox name for %v: %v, want %v", i, mbox.Name, want[i].name) + } + + if !reflect.DeepEqual(mbox.Attributes, want[i].attributes) { + t.Errorf("Bad mailbox attributes for %v: %v, want %v", i, mbox.Attributes, want[i].attributes) + } + + i++ + } +} + +func TestClient_Lsub(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + done := make(chan error, 1) + mailboxes := make(chan *imap.MailboxInfo, 1) + go func() { + done <- c.Lsub("", "%", mailboxes) + }() + + tag, cmd := s.ScanCmd() + if cmd != "LSUB \"\" \"%\"" { + t.Fatalf("client sent command %v, want %v", cmd, "LSUB \"\" \"%\"") + } + + s.WriteString("* LSUB () \"/\" INBOX\r\n") + s.WriteString(tag + " OK LSUB completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Lsub() = %v", err) + } + + mbox := <-mailboxes + if mbox.Name != "INBOX" { + t.Errorf("Bad mailbox name: %v", mbox.Name) + } + if len(mbox.Attributes) != 0 { + t.Errorf("Bad mailbox flags: %v", mbox.Attributes) + } +} + +func TestClient_Status(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + done := make(chan error, 1) + var mbox *imap.MailboxStatus + go func() { + var err error + mbox, err = c.Status("INBOX", []imap.StatusItem{imap.StatusMessages, imap.StatusRecent}) + done <- err + }() + + tag, cmd := s.ScanCmd() + if cmd != "STATUS INBOX (MESSAGES RECENT)" { + t.Fatalf("client sent command %v, want %v", cmd, "STATUS \"INBOX\" (MESSAGES RECENT)") + } + + s.WriteString("* STATUS INBOX (MESSAGES 42 RECENT 1)\r\n") + s.WriteString(tag + " OK STATUS completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Status() = %v", err) + } + + if mbox.Messages != 42 { + t.Errorf("Bad mailbox messages: %v", mbox.Messages) + } + if mbox.Recent != 1 { + t.Errorf("Bad mailbox recent: %v", mbox.Recent) + } +} + +type literalWrap struct { + io.Reader + L int +} + +func (lw literalWrap) Len() int { + return lw.L +} + +func TestClient_Append_SmallerLiteral(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + msg := "Hello World!\r\nHello Gophers!\r\n" + date := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + flags := []string{imap.SeenFlag, imap.DraftFlag} + + r := bytes.NewBufferString(msg) + + done := make(chan error, 1) + go func() { + done <- c.Append("INBOX", flags, date, literalWrap{r, 35}) + + // The buffer is not flushed on error, force it so io.ReadFull can + // continue. + c.conn.Flush() + }() + + tag, _ := s.ScanCmd() + s.WriteString("+ send literal\r\n") + + b := make([]byte, 30) + // The client will close connection. + if _, err := io.ReadFull(s, b); err != io.EOF { + t.Error("Expected EOF, got", err) + } + + s.WriteString(tag + " OK APPEND completed\r\n") + + err, ok := (<-done).(imap.LiteralLengthErr) + if !ok { + t.Fatalf("c.Append() = %v", err) + } + if err.Expected != 35 { + t.Fatalf("err.Expected = %v", err.Expected) + } + if err.Actual != 30 { + t.Fatalf("err.Actual = %v", err.Actual) + } +} + +func TestClient_Append_BiggerLiteral(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + msg := "Hello World!\r\nHello Gophers!\r\n" + date := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + flags := []string{imap.SeenFlag, imap.DraftFlag} + + r := bytes.NewBufferString(msg) + + done := make(chan error, 1) + go func() { + done <- c.Append("INBOX", flags, date, literalWrap{r, 25}) + + // The buffer is not flushed on error, force it so io.ReadFull can + // continue. + c.conn.Flush() + }() + + tag, _ := s.ScanCmd() + s.WriteString("+ send literal\r\n") + + // The client will close connection. + b := make([]byte, 25) + if _, err := io.ReadFull(s, b); err != io.EOF { + t.Error("Expected EOF, got", err) + } + + s.WriteString(tag + " OK APPEND completed\r\n") + + err, ok := (<-done).(imap.LiteralLengthErr) + if !ok { + t.Fatalf("c.Append() = %v", err) + } + if err.Expected != 25 { + t.Fatalf("err.Expected = %v", err.Expected) + } + if err.Actual != 30 { + t.Fatalf("err.Actual = %v", err.Actual) + } +} + +func TestClient_Append(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + msg := "Hello World!\r\nHello Gophers!\r\n" + date := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + flags := []string{imap.SeenFlag, imap.DraftFlag} + + done := make(chan error, 1) + go func() { + done <- c.Append("INBOX", flags, date, bytes.NewBufferString(msg)) + }() + + tag, cmd := s.ScanCmd() + if cmd != "APPEND INBOX (\\Seen \\Draft) \"10-Nov-2009 23:00:00 +0000\" {30}" { + t.Fatalf("client sent command %v, want %v", cmd, "APPEND \"INBOX\" (\\Seen \\Draft) \"10-Nov-2009 23:00:00 +0000\" {30}") + } + + s.WriteString("+ send literal\r\n") + + b := make([]byte, 30) + if _, err := io.ReadFull(s, b); err != nil { + t.Fatal(err) + } else if string(b) != msg { + t.Fatal("Bad literal:", string(b)) + } + + s.WriteString(tag + " OK APPEND completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Append() = %v", err) + } +} + +func TestClient_Append_failed(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + // First the server refuses + + msg := "First try" + done := make(chan error, 1) + go func() { + done <- c.Append("INBOX", nil, time.Time{}, bytes.NewBufferString(msg)) + }() + + tag, _ := s.ScanCmd() + s.WriteString(tag + " BAD APPEND failed\r\n") + + if err := <-done; err == nil { + t.Fatal("c.Append() = nil, want an error from the server") + } + + // Try a second time, the server accepts + + msg = "Second try" + go func() { + done <- c.Append("INBOX", nil, time.Time{}, bytes.NewBufferString(msg)) + }() + + tag, _ = s.ScanCmd() + s.WriteString("+ send literal\r\n") + + b := make([]byte, len(msg)) + if _, err := io.ReadFull(s, b); err != nil { + t.Fatal(err) + } else if string(b) != msg { + t.Fatal("Bad literal:", string(b)) + } + + s.WriteString(tag + " OK APPEND completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Append() = %v", err) + } +} diff --git a/client/cmd_noauth.go b/client/cmd_noauth.go new file mode 100644 index 0000000..f9b34d3 --- /dev/null +++ b/client/cmd_noauth.go @@ -0,0 +1,174 @@ +package client + +import ( + "crypto/tls" + "errors" + "net" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" + "github.com/emersion/go-sasl" +) + +var ( + // ErrAlreadyLoggedIn is returned if Login or Authenticate is called when the + // client is already logged in. + ErrAlreadyLoggedIn = errors.New("Already logged in") + // ErrTLSAlreadyEnabled is returned if StartTLS is called when TLS is already + // enabled. + ErrTLSAlreadyEnabled = errors.New("TLS is already enabled") + // ErrLoginDisabled is returned if Login or Authenticate is called when the + // server has disabled authentication. Most of the time, calling enabling TLS + // solves the problem. + ErrLoginDisabled = errors.New("Login is disabled in current state") +) + +// SupportStartTLS checks if the server supports STARTTLS. +func (c *Client) SupportStartTLS() (bool, error) { + return c.Support("STARTTLS") +} + +// StartTLS starts TLS negotiation. +func (c *Client) StartTLS(tlsConfig *tls.Config) error { + if c.isTLS { + return ErrTLSAlreadyEnabled + } + + if tlsConfig == nil { + tlsConfig = new(tls.Config) + } + if tlsConfig.ServerName == "" { + tlsConfig = tlsConfig.Clone() + tlsConfig.ServerName = c.serverName + } + + cmd := new(commands.StartTLS) + + err := c.Upgrade(func(conn net.Conn) (net.Conn, error) { + // Flag connection as in upgrading + c.upgrading = true + if status, err := c.execute(cmd, nil); err != nil { + return nil, err + } else if err := status.Err(); err != nil { + return nil, err + } + + // Wait for reader to block. + c.conn.WaitReady() + tlsConn := tls.Client(conn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return nil, err + } + + // Capabilities change when TLS is enabled + c.locker.Lock() + c.caps = nil + c.locker.Unlock() + + return tlsConn, nil + }) + if err != nil { + return err + } + + c.isTLS = true + return nil +} + +// SupportAuth checks if the server supports a given authentication mechanism. +func (c *Client) SupportAuth(mech string) (bool, error) { + return c.Support("AUTH=" + mech) +} + +// Authenticate indicates a SASL authentication mechanism to the server. If the +// server supports the requested authentication mechanism, it performs an +// authentication protocol exchange to authenticate and identify the client. +func (c *Client) Authenticate(auth sasl.Client) error { + if c.State() != imap.NotAuthenticatedState { + return ErrAlreadyLoggedIn + } + + mech, ir, err := auth.Start() + if err != nil { + return err + } + + cmd := &commands.Authenticate{ + Mechanism: mech, + } + + irOk, err := c.Support("SASL-IR") + if err != nil { + return err + } + if irOk { + cmd.InitialResponse = ir + } + + res := &responses.Authenticate{ + Mechanism: auth, + InitialResponse: ir, + RepliesCh: make(chan []byte, 10), + } + if irOk { + res.InitialResponse = nil + } + + status, err := c.execute(cmd, res) + if err != nil { + return err + } + if err = status.Err(); err != nil { + return err + } + + c.locker.Lock() + c.state = imap.AuthenticatedState + c.caps = nil // Capabilities change when user is logged in + c.locker.Unlock() + + if status.Code == "CAPABILITY" { + c.gotStatusCaps(status.Arguments) + } + + return nil +} + +// Login identifies the client to the server and carries the plaintext password +// authenticating this user. +func (c *Client) Login(username, password string) error { + if state := c.State(); state == imap.AuthenticatedState || state == imap.SelectedState { + return ErrAlreadyLoggedIn + } + + c.locker.Lock() + loginDisabled := c.caps != nil && c.caps["LOGINDISABLED"] + c.locker.Unlock() + if loginDisabled { + return ErrLoginDisabled + } + + cmd := &commands.Login{ + Username: username, + Password: password, + } + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + if err = status.Err(); err != nil { + return err + } + + c.locker.Lock() + c.state = imap.AuthenticatedState + c.caps = nil // Capabilities change when user is logged in + c.locker.Unlock() + + if status.Code == "CAPABILITY" { + c.gotStatusCaps(status.Arguments) + } + return nil +} diff --git a/client/cmd_noauth_test.go b/client/cmd_noauth_test.go new file mode 100644 index 0000000..cada41e --- /dev/null +++ b/client/cmd_noauth_test.go @@ -0,0 +1,299 @@ +package client + +import ( + "crypto/tls" + "io" + "testing" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/internal" + "github.com/emersion/go-sasl" +) + +func TestClient_StartTLS(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + cert, err := tls.X509KeyPair(internal.LocalhostCert, internal.LocalhostKey) + if err != nil { + t.Fatal("cannot load test certificate:", err) + } + + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{cert}, + } + + if c.IsTLS() { + t.Fatal("Client has TLS enabled before STARTTLS") + } + + if ok, err := c.SupportStartTLS(); err != nil { + t.Fatalf("c.SupportStartTLS() = %v", err) + } else if !ok { + t.Fatalf("c.SupportStartTLS() = %v, want true", ok) + } + + done := make(chan error, 1) + go func() { + done <- c.StartTLS(tlsConfig) + }() + + tag, cmd := s.ScanCmd() + if cmd != "STARTTLS" { + t.Fatalf("client sent command %v, want STARTTLS", cmd) + } + s.WriteString(tag + " OK Begin TLS negotiation now\r\n") + + ss := tls.Server(s.Conn, tlsConfig) + if err := ss.Handshake(); err != nil { + t.Fatal("cannot perform TLS handshake:", err) + } + + if err := <-done; err != nil { + t.Error("c.StartTLS() =", err) + } + + if !c.IsTLS() { + t.Errorf("Client has not TLS enabled after STARTTLS") + } + + go func() { + _, err := c.Capability() + done <- err + }() + + tag, cmd = newCmdScanner(ss).ScanCmd() + if cmd != "CAPABILITY" { + t.Fatalf("client sent command %v, want CAPABILITY", cmd) + } + io.WriteString(ss, "* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n") + io.WriteString(ss, tag+" OK CAPABILITY completed.\r\n") +} + +func TestClient_Authenticate(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + if ok, err := c.SupportAuth(sasl.Plain); err != nil { + t.Fatalf("c.SupportAuth(sasl.Plain) = %v", err) + } else if !ok { + t.Fatalf("c.SupportAuth(sasl.Plain) = %v, want true", ok) + } + + sasl := sasl.NewPlainClient("", "username", "password") + + done := make(chan error, 1) + go func() { + done <- c.Authenticate(sasl) + }() + + tag, cmd := s.ScanCmd() + if cmd != "AUTHENTICATE PLAIN" { + t.Fatalf("client sent command %v, want AUTHENTICATE PLAIN", cmd) + } + + s.WriteString("+ \r\n") + + wantLine := "AHVzZXJuYW1lAHBhc3N3b3Jk" + if line := s.ScanLine(); line != wantLine { + t.Fatalf("client sent auth %v, want %v", line, wantLine) + } + + s.WriteString(tag + " OK AUTHENTICATE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Authenticate() = %v", err) + } + + if state := c.State(); state != imap.AuthenticatedState { + t.Errorf("c.State() = %v, want %v", state, imap.AuthenticatedState) + } +} + +func TestClient_Authenticate_InitialResponse(t *testing.T) { + c, s := newTestClientWithGreeting(t, "* OK [CAPABILITY IMAP4rev1 SASL-IR STARTTLS AUTH=PLAIN] Server ready.\r\n") + defer s.Close() + + if ok, err := c.SupportAuth(sasl.Plain); err != nil { + t.Fatalf("c.SupportAuth(sasl.Plain) = %v", err) + } else if !ok { + t.Fatalf("c.SupportAuth(sasl.Plain) = %v, want true", ok) + } + + sasl := sasl.NewPlainClient("", "username", "password") + + done := make(chan error, 1) + go func() { + done <- c.Authenticate(sasl) + }() + + tag, cmd := s.ScanCmd() + if cmd != "AUTHENTICATE PLAIN AHVzZXJuYW1lAHBhc3N3b3Jk" { + t.Fatalf("client sent command %v, want AUTHENTICATE PLAIN AHVzZXJuYW1lAHBhc3N3b3Jk", cmd) + } + + s.WriteString(tag + " OK AUTHENTICATE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Authenticate() = %v", err) + } + + if state := c.State(); state != imap.AuthenticatedState { + t.Errorf("c.State() = %v, want %v", state, imap.AuthenticatedState) + } +} + +func TestClient_Login_Success(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + done := make(chan error, 1) + go func() { + done <- c.Login("username", "password") + }() + + tag, cmd := s.ScanCmd() + if cmd != "LOGIN \"username\" \"password\"" { + t.Fatalf("client sent command %v, want LOGIN username password", cmd) + } + s.WriteString(tag + " OK LOGIN completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Login() = %v", err) + } + + if state := c.State(); state != imap.AuthenticatedState { + t.Errorf("c.State() = %v, want %v", state, imap.AuthenticatedState) + } +} + +func TestClient_Login_8bitSync(t *testing.T) { + c, s := newTestClientWithGreeting(t, "* OK [CAPABILITY IMAP4rev1 SASL-IR STARTTLS AUTH=PLAIN] Server ready.\r\n") + defer s.Close() + + // Use of UTF-8 will force go-imap to send password in literal. + done := make(chan error, 1) + go func() { + done <- c.Login("username", "пароль") + }() + + tag, cmd := s.ScanCmd() + if cmd != "LOGIN \"username\" {12}" { + t.Fatalf("client sent command %v, want LOGIN \"username\" {12}", cmd) + } + s.WriteString("+ send literal\r\n") + pass := s.ScanLine() + if pass != "пароль" { + t.Fatalf("client sent %v, want {12}'пароль' literal", pass) + } + s.WriteString(tag + " OK LOGIN completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Login() = %v", err) + } + + if state := c.State(); state != imap.AuthenticatedState { + t.Errorf("c.State() = %v, want %v", state, imap.AuthenticatedState) + } +} + +func TestClient_Login_8bitNonSync(t *testing.T) { + c, s := newTestClientWithGreeting(t, "* OK [CAPABILITY IMAP4rev1 LITERAL- SASL-IR STARTTLS AUTH=PLAIN] Server ready.\r\n") + defer s.Close() + + // Use of UTF-8 will force go-imap to send password in literal. + done := make(chan error, 1) + go func() { + done <- c.Login("username", "пароль") + }() + + tag, cmd := s.ScanCmd() + if cmd != "LOGIN \"username\" {12+}" { + t.Fatalf("client sent command %v, want LOGIN \"username\" {12+}", cmd) + } + pass := s.ScanLine() + if pass != "пароль" { + t.Fatalf("client sent %v, want {12+}'пароль' literal", pass) + } + s.WriteString(tag + " OK LOGIN completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Login() = %v", err) + } + + if state := c.State(); state != imap.AuthenticatedState { + t.Errorf("c.State() = %v, want %v", state, imap.AuthenticatedState) + } +} + +func TestClient_Login_Error(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + done := make(chan error, 1) + go func() { + done <- c.Login("username", "password") + }() + + tag, cmd := s.ScanCmd() + if cmd != "LOGIN \"username\" \"password\"" { + t.Fatalf("client sent command %v, want LOGIN username password", cmd) + } + s.WriteString(tag + " NO LOGIN incorrect\r\n") + + if err := <-done; err == nil { + t.Fatal("c.Login() = nil, want LOGIN incorrect") + } + + if state := c.State(); state != imap.NotAuthenticatedState { + t.Errorf("c.State() = %v, want %v", state, imap.NotAuthenticatedState) + } +} + +func TestClient_Login_State_Allowed(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + done := make(chan error, 1) + go func() { + done <- c.Login("username", "password") + }() + + tag, cmd := s.ScanCmd() + if cmd != "LOGIN \"username\" \"password\"" { + t.Fatalf("client sent command %v, want LOGIN \"username\" \"password\"", cmd) + } + s.WriteString(tag + " OK LOGIN completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Login() = %v", err) + } + + if state := c.State(); state != imap.AuthenticatedState { + t.Errorf("c.State() = %v, want %v", state, imap.AuthenticatedState) + } + + go func() { + done <- c.Login("username", "password") + }() + if err := <-done; err != ErrAlreadyLoggedIn { + t.Fatalf("c.Login() = %v, want %v", err, ErrAlreadyLoggedIn) + } + + go func() { + done <- c.Logout() + }() + + s.ScanCmd() + s.WriteString("* BYE Client asked to close the connection.\r\n") + s.WriteString(tag + " OK LOGOUT completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Logout() = %v", err) + } + + if err := c.Login("username", "password"); err == ErrAlreadyLoggedIn { + t.Errorf("Client is logout, login must not give %v", ErrAlreadyLoggedIn) + } +} diff --git a/client/cmd_selected.go b/client/cmd_selected.go new file mode 100644 index 0000000..40fc513 --- /dev/null +++ b/client/cmd_selected.go @@ -0,0 +1,372 @@ +package client + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" +) + +var ( + // ErrNoMailboxSelected is returned if a command that requires a mailbox to be + // selected is called when there isn't. + ErrNoMailboxSelected = errors.New("No mailbox selected") + + // ErrExtensionUnsupported is returned if a command uses a extension that + // is not supported by the server. + ErrExtensionUnsupported = errors.New("The required extension is not supported by the server") +) + +// Check requests a checkpoint of the currently selected mailbox. A checkpoint +// refers to any implementation-dependent housekeeping associated with the +// mailbox that is not normally executed as part of each command. +func (c *Client) Check() error { + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + cmd := new(commands.Check) + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + + return status.Err() +} + +// Close permanently removes all messages that have the \Deleted flag set from +// the currently selected mailbox, and returns to the authenticated state from +// the selected state. +func (c *Client) Close() error { + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + cmd := new(commands.Close) + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } else if err := status.Err(); err != nil { + return err + } + + c.locker.Lock() + c.state = imap.AuthenticatedState + c.mailbox = nil + c.locker.Unlock() + return nil +} + +// Terminate closes the tcp connection +func (c *Client) Terminate() error { + return c.conn.Close() +} + +// Expunge permanently removes all messages that have the \Deleted flag set from +// the currently selected mailbox. If ch is not nil, sends sequence IDs of each +// deleted message to this channel. +func (c *Client) Expunge(ch chan uint32) error { + if ch != nil { + defer close(ch) + } + + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + cmd := new(commands.Expunge) + + var h responses.Handler + if ch != nil { + h = &responses.Expunge{SeqNums: ch} + } + + status, err := c.execute(cmd, h) + if err != nil { + return err + } + return status.Err() +} + +func (c *Client) executeSearch(uid bool, criteria *imap.SearchCriteria, charset string) (ids []uint32, status *imap.StatusResp, err error) { + if c.State() != imap.SelectedState { + err = ErrNoMailboxSelected + return + } + + var cmd imap.Commander = &commands.Search{ + Charset: charset, + Criteria: criteria, + } + if uid { + cmd = &commands.Uid{Cmd: cmd} + } + + res := new(responses.Search) + + status, err = c.execute(cmd, res) + if err != nil { + return + } + + err, ids = status.Err(), res.Ids + return +} + +func (c *Client) search(uid bool, criteria *imap.SearchCriteria) (ids []uint32, err error) { + charset := "UTF-8" + if c.utf8SearchUnsupported { + charset = "US-ASCII" + } + ids, status, err := c.executeSearch(uid, criteria, charset) + if status != nil && status.Code == imap.CodeBadCharset { + // Some servers don't support UTF-8 + ids, _, err = c.executeSearch(uid, criteria, "US-ASCII") + c.utf8SearchUnsupported = true + } + return +} + +// Search searches the mailbox for messages that match the given searching +// criteria. Searching criteria consist of one or more search keys. The response +// contains a list of message sequence IDs corresponding to those messages that +// match the searching criteria. When multiple keys are specified, the result is +// the intersection (AND function) of all the messages that match those keys. +// Criteria must be UTF-8 encoded. See RFC 3501 section 6.4.4 for a list of +// searching criteria. When no criteria has been set, all messages in the mailbox +// will be searched using ALL criteria. +func (c *Client) Search(criteria *imap.SearchCriteria) (seqNums []uint32, err error) { + return c.search(false, criteria) +} + +// UidSearch is identical to Search, but UIDs are returned instead of message +// sequence numbers. +func (c *Client) UidSearch(criteria *imap.SearchCriteria) (uids []uint32, err error) { + return c.search(true, criteria) +} + +func (c *Client) fetch(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan *imap.Message) error { + defer close(ch) + + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + var cmd imap.Commander = &commands.Fetch{ + SeqSet: seqset, + Items: items, + } + if uid { + cmd = &commands.Uid{Cmd: cmd} + } + + res := &responses.Fetch{Messages: ch, SeqSet: seqset, Uid: uid} + + status, err := c.execute(cmd, res) + if err != nil { + return err + } + return status.Err() +} + +// Fetch retrieves data associated with a message in the mailbox. See RFC 3501 +// section 6.4.5 for a list of items that can be requested. +func (c *Client) Fetch(seqset *imap.SeqSet, items []imap.FetchItem, ch chan *imap.Message) error { + return c.fetch(false, seqset, items, ch) +} + +// UidFetch is identical to Fetch, but seqset is interpreted as containing +// unique identifiers instead of message sequence numbers. +func (c *Client) UidFetch(seqset *imap.SeqSet, items []imap.FetchItem, ch chan *imap.Message) error { + return c.fetch(true, seqset, items, ch) +} + +func (c *Client) store(uid bool, seqset *imap.SeqSet, item imap.StoreItem, value interface{}, ch chan *imap.Message) error { + if ch != nil { + defer close(ch) + } + + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + // TODO: this could break extensions (this only works when item is FLAGS) + if fields, ok := value.([]interface{}); ok { + for i, field := range fields { + if s, ok := field.(string); ok { + fields[i] = imap.RawString(s) + } + } + } + + // If ch is nil, the updated values are data which will be lost, so don't + // retrieve it. + if ch == nil { + op, _, err := imap.ParseFlagsOp(item) + if err == nil { + item = imap.FormatFlagsOp(op, true) + } + } + + var cmd imap.Commander = &commands.Store{ + SeqSet: seqset, + Item: item, + Value: value, + } + if uid { + cmd = &commands.Uid{Cmd: cmd} + } + + var h responses.Handler + if ch != nil { + h = &responses.Fetch{Messages: ch, SeqSet: seqset, Uid: uid} + } + + status, err := c.execute(cmd, h) + if err != nil { + return err + } + return status.Err() +} + +// Store alters data associated with a message in the mailbox. If ch is not nil, +// the updated value of the data will be sent to this channel. See RFC 3501 +// section 6.4.6 for a list of items that can be updated. +func (c *Client) Store(seqset *imap.SeqSet, item imap.StoreItem, value interface{}, ch chan *imap.Message) error { + return c.store(false, seqset, item, value, ch) +} + +// UidStore is identical to Store, but seqset is interpreted as containing +// unique identifiers instead of message sequence numbers. +func (c *Client) UidStore(seqset *imap.SeqSet, item imap.StoreItem, value interface{}, ch chan *imap.Message) error { + return c.store(true, seqset, item, value, ch) +} + +func (c *Client) copy(uid bool, seqset *imap.SeqSet, dest string) error { + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + var cmd imap.Commander = &commands.Copy{ + SeqSet: seqset, + Mailbox: dest, + } + if uid { + cmd = &commands.Uid{Cmd: cmd} + } + + status, err := c.execute(cmd, nil) + if err != nil { + return err + } + return status.Err() +} + +// Copy copies the specified message(s) to the end of the specified destination +// mailbox. +func (c *Client) Copy(seqset *imap.SeqSet, dest string) error { + return c.copy(false, seqset, dest) +} + +// UidCopy is identical to Copy, but seqset is interpreted as containing unique +// identifiers instead of message sequence numbers. +func (c *Client) UidCopy(seqset *imap.SeqSet, dest string) error { + return c.copy(true, seqset, dest) +} + +func (c *Client) move(uid bool, seqset *imap.SeqSet, dest string) error { + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + if ok, err := c.Support("MOVE"); err != nil { + return err + } else if !ok { + return c.moveFallback(uid, seqset, dest) + } + + var cmd imap.Commander = &commands.Move{ + SeqSet: seqset, + Mailbox: dest, + } + if uid { + cmd = &commands.Uid{Cmd: cmd} + } + + if status, err := c.Execute(cmd, nil); err != nil { + return err + } else { + return status.Err() + } +} + +// moveFallback uses COPY, STORE and EXPUNGE for servers which don't support +// MOVE. +func (c *Client) moveFallback(uid bool, seqset *imap.SeqSet, dest string) error { + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.DeletedFlag} + if uid { + if err := c.UidCopy(seqset, dest); err != nil { + return err + } + + if err := c.UidStore(seqset, item, flags, nil); err != nil { + return err + } + } else { + if err := c.Copy(seqset, dest); err != nil { + return err + } + + if err := c.Store(seqset, item, flags, nil); err != nil { + return err + } + } + + return c.Expunge(nil) +} + +// Move moves the specified message(s) to the end of the specified destination +// mailbox. +// +// If the server doesn't support the MOVE extension defined in RFC 6851, +// go-imap will fallback to copy, store and expunge. +func (c *Client) Move(seqset *imap.SeqSet, dest string) error { + return c.move(false, seqset, dest) +} + +// UidMove is identical to Move, but seqset is interpreted as containing unique +// identifiers instead of message sequence numbers. +func (c *Client) UidMove(seqset *imap.SeqSet, dest string) error { + return c.move(true, seqset, dest) +} + +// Unselect frees server's resources associated with the selected mailbox and +// returns the server to the authenticated state. This command performs the same +// actions as Close, except that no messages are permanently removed from the +// currently selected mailbox. +// +// If client does not support the UNSELECT extension, ErrExtensionUnsupported +// is returned. +func (c *Client) Unselect() error { + if ok, err := c.Support("UNSELECT"); !ok || err != nil { + return ErrExtensionUnsupported + } + + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + cmd := &commands.Unselect{} + if status, err := c.Execute(cmd, nil); err != nil { + return err + } else if err := status.Err(); err != nil { + return err + } + + c.SetState(imap.AuthenticatedState, nil) + return nil +} diff --git a/client/cmd_selected_test.go b/client/cmd_selected_test.go new file mode 100644 index 0000000..6b6b7e1 --- /dev/null +++ b/client/cmd_selected_test.go @@ -0,0 +1,816 @@ +package client + +import ( + "io/ioutil" + "net/textproto" + "reflect" + "testing" + "time" + + "github.com/emersion/go-imap" +) + +func TestClient_Check(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + done := make(chan error, 1) + go func() { + done <- c.Check() + }() + + tag, cmd := s.ScanCmd() + if cmd != "CHECK" { + t.Fatalf("client sent command %v, want %v", cmd, "CHECK") + } + + s.WriteString(tag + " OK CHECK completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Check() = %v", err) + } +} + +func TestClient_Close(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, &imap.MailboxStatus{Name: "INBOX"}) + + done := make(chan error, 1) + go func() { + done <- c.Close() + }() + + tag, cmd := s.ScanCmd() + if cmd != "CLOSE" { + t.Fatalf("client sent command %v, want %v", cmd, "CLOSE") + } + + s.WriteString(tag + " OK CLOSE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Check() = %v", err) + } + + if state := c.State(); state != imap.AuthenticatedState { + t.Errorf("Bad state: %v", state) + } + if mailbox := c.Mailbox(); mailbox != nil { + t.Errorf("Client selected mailbox is not nil: %v", mailbox) + } +} + +func TestClient_Expunge(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + done := make(chan error, 1) + expunged := make(chan uint32, 4) + go func() { + done <- c.Expunge(expunged) + }() + + tag, cmd := s.ScanCmd() + if cmd != "EXPUNGE" { + t.Fatalf("client sent command %v, want %v", cmd, "EXPUNGE") + } + + s.WriteString("* 3 EXPUNGE\r\n") + s.WriteString("* 3 EXPUNGE\r\n") + s.WriteString("* 5 EXPUNGE\r\n") + s.WriteString("* 8 EXPUNGE\r\n") + s.WriteString(tag + " OK EXPUNGE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Expunge() = %v", err) + } + + expected := []uint32{3, 3, 5, 8} + + i := 0 + for id := range expunged { + if id != expected[i] { + t.Errorf("Bad expunged sequence number: got %v instead of %v", id, expected[i]) + } + i++ + } +} + +func TestClient_Search(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + date, _ := time.Parse(imap.DateLayout, "1-Feb-1994") + criteria := &imap.SearchCriteria{ + WithFlags: []string{imap.DeletedFlag}, + Header: textproto.MIMEHeader{"From": {"Smith"}}, + Since: date, + Not: []*imap.SearchCriteria{{ + Header: textproto.MIMEHeader{"To": {"Pauline"}}, + }}, + } + + done := make(chan error, 1) + var results []uint32 + go func() { + var err error + results, err = c.Search(criteria) + done <- err + }() + + wantCmd := `SEARCH CHARSET UTF-8 SINCE "1-Feb-1994" FROM "Smith" DELETED NOT (TO "Pauline")` + tag, cmd := s.ScanCmd() + if cmd != wantCmd { + t.Fatalf("client sent command %v, want %v", cmd, wantCmd) + } + + s.WriteString("* SEARCH 2 84 882\r\n") + s.WriteString(tag + " OK SEARCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Search() = %v", err) + } + + want := []uint32{2, 84, 882} + if !reflect.DeepEqual(results, want) { + t.Errorf("c.Search() = %v, want %v", results, want) + } +} + +func TestClient_Search_Badcharset(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + date, _ := time.Parse(imap.DateLayout, "1-Feb-1994") + criteria := &imap.SearchCriteria{ + WithFlags: []string{imap.DeletedFlag}, + Header: textproto.MIMEHeader{"From": {"Smith"}}, + Since: date, + Not: []*imap.SearchCriteria{{ + Header: textproto.MIMEHeader{"To": {"Pauline"}}, + }}, + } + + done := make(chan error, 1) + var results []uint32 + go func() { + var err error + results, err = c.Search(criteria) + done <- err + }() + + // first search call with default UTF-8 charset (assume server does not support UTF-8) + wantCmd := `SEARCH CHARSET UTF-8 SINCE "1-Feb-1994" FROM "Smith" DELETED NOT (TO "Pauline")` + tag, cmd := s.ScanCmd() + if cmd != wantCmd { + t.Fatalf("client sent command %v, want %v", cmd, wantCmd) + } + + s.WriteString(tag + " NO [BADCHARSET (US-ASCII)]\r\n") + + // internal fall-back to US-ASCII which sets utf8SearchUnsupported = true + wantCmd = `SEARCH CHARSET US-ASCII SINCE "1-Feb-1994" FROM "Smith" DELETED NOT (TO "Pauline")` + tag, cmd = s.ScanCmd() + if cmd != wantCmd { + t.Fatalf("client sent command %v, want %v", cmd, wantCmd) + } + + s.WriteString("* SEARCH 2 84 882\r\n") + s.WriteString(tag + " OK SEARCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("err: %v", err) + } + + want := []uint32{2, 84, 882} + if !reflect.DeepEqual(results, want) { + t.Errorf("c.Search() = %v, want %v", results, want) + } + + if !c.utf8SearchUnsupported { + t.Fatal("client should have utf8SearchUnsupported set to true") + } + + // second call to search (with utf8SearchUnsupported=true) + go func() { + var err error + results, err = c.Search(criteria) + done <- err + }() + + tag, cmd = s.ScanCmd() + if cmd != wantCmd { + t.Fatalf("client sent command %v, want %v", cmd, wantCmd) + } + + s.WriteString("* SEARCH 2 84 882\r\n") + s.WriteString(tag + " OK SEARCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Search() = %v", err) + } + + if !reflect.DeepEqual(results, want) { + t.Errorf("c.Search() = %v, want %v", results, want) + } +} + +func TestClient_Search_Uid(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + criteria := &imap.SearchCriteria{ + WithoutFlags: []string{imap.DeletedFlag}, + } + + done := make(chan error, 1) + var results []uint32 + go func() { + var err error + results, err = c.UidSearch(criteria) + done <- err + }() + + wantCmd := "UID SEARCH CHARSET UTF-8 UNDELETED" + tag, cmd := s.ScanCmd() + if cmd != wantCmd { + t.Fatalf("client sent command %v, want %v", cmd, wantCmd) + } + + s.WriteString("* SEARCH 1 78 2010\r\n") + s.WriteString(tag + " OK UID SEARCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Search() = %v", err) + } + + want := []uint32{1, 78, 2010} + if !reflect.DeepEqual(results, want) { + t.Errorf("c.Search() = %v, want %v", results, want) + } +} + +func TestClient_Search_Uid_Badcharset(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + criteria := &imap.SearchCriteria{ + WithoutFlags: []string{imap.DeletedFlag}, + } + + done := make(chan error, 1) + var results []uint32 + go func() { + var err error + results, err = c.UidSearch(criteria) + done <- err + }() + + // first search call with default UTF-8 charset (assume server does not support UTF-8) + wantCmd := "UID SEARCH CHARSET UTF-8 UNDELETED" + tag, cmd := s.ScanCmd() + if cmd != wantCmd { + t.Fatalf("client sent command %v, want %v", cmd, wantCmd) + } + + s.WriteString(tag + " NO [BADCHARSET (US-ASCII)]\r\n") + + // internal fall-back to US-ASCII which sets utf8SearchUnsupported = true + wantCmd = "UID SEARCH CHARSET US-ASCII UNDELETED" + tag, cmd = s.ScanCmd() + if cmd != wantCmd { + t.Fatalf("client sent command %v, want %v", cmd, wantCmd) + } + + s.WriteString("* SEARCH 1 78 2010\r\n") + s.WriteString(tag + " OK UID SEARCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.UidSearch() = %v", err) + } + + want := []uint32{1, 78, 2010} + if !reflect.DeepEqual(results, want) { + t.Errorf("c.UidSearch() = %v, want %v", results, want) + } + + if !c.utf8SearchUnsupported { + t.Fatal("client should have utf8SearchUnsupported set to true") + } + + // second call to search (with utf8SearchUnsupported=true) + go func() { + var err error + results, err = c.UidSearch(criteria) + done <- err + }() + + tag, cmd = s.ScanCmd() + if cmd != wantCmd { + t.Fatalf("client sent command %v, want %v", cmd, wantCmd) + } + + s.WriteString("* SEARCH 1 78 2010\r\n") + s.WriteString(tag + " OK UID SEARCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.UidSearch() = %v", err) + } + + if !reflect.DeepEqual(results, want) { + t.Errorf("c.UidSearch() = %v, want %v", results, want) + } + +} + +func TestClient_Fetch(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("2:3") + fields := []imap.FetchItem{imap.FetchUid, imap.FetchItem("BODY[]")} + + done := make(chan error, 1) + messages := make(chan *imap.Message, 2) + go func() { + done <- c.Fetch(seqset, fields, messages) + }() + + tag, cmd := s.ScanCmd() + if cmd != "FETCH 2:3 (UID BODY[])" { + t.Fatalf("client sent command %v, want %v", cmd, "FETCH 2:3 (UID BODY[])") + } + + s.WriteString("* 2 FETCH (UID 42 BODY[] {16}\r\n") + s.WriteString("I love potatoes.") + s.WriteString(")\r\n") + + s.WriteString("* 3 FETCH (UID 28 BODY[] {12}\r\n") + s.WriteString("Hello World!") + s.WriteString(")\r\n") + + s.WriteString(tag + " OK FETCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Fetch() = %v", err) + } + + section, _ := imap.ParseBodySectionName("BODY[]") + + msg := <-messages + if msg.SeqNum != 2 { + t.Errorf("First message has bad sequence number: %v", msg.SeqNum) + } + if msg.Uid != 42 { + t.Errorf("First message has bad UID: %v", msg.Uid) + } + if body, _ := ioutil.ReadAll(msg.GetBody(section)); string(body) != "I love potatoes." { + t.Errorf("First message has bad body: %q", body) + } + + msg = <-messages + if msg.SeqNum != 3 { + t.Errorf("First message has bad sequence number: %v", msg.SeqNum) + } + if msg.Uid != 28 { + t.Errorf("Second message has bad UID: %v", msg.Uid) + } + if body, _ := ioutil.ReadAll(msg.GetBody(section)); string(body) != "Hello World!" { + t.Errorf("Second message has bad body: %q", body) + } +} + +func TestClient_Fetch_ClosedState(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.AuthenticatedState, nil) + + seqset, _ := imap.ParseSeqSet("2:3") + fields := []imap.FetchItem{imap.FetchUid, imap.FetchItem("BODY[]")} + + done := make(chan error, 1) + messages := make(chan *imap.Message, 2) + go func() { + done <- c.Fetch(seqset, fields, messages) + }() + + _, more := <-messages + + if more { + t.Fatalf("Messages channel has more messages, but it must be closed with no messages sent") + } + + err := <-done + + if err != ErrNoMailboxSelected { + t.Fatalf("Expected error to be IMAP Client ErrNoMailboxSelected") + } +} + +func TestClient_Fetch_Partial(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("1") + fields := []imap.FetchItem{imap.FetchItem("BODY.PEEK[]<0.10>")} + + done := make(chan error, 1) + messages := make(chan *imap.Message, 1) + go func() { + done <- c.Fetch(seqset, fields, messages) + }() + + tag, cmd := s.ScanCmd() + if cmd != "FETCH 1 (BODY.PEEK[]<0.10>)" { + t.Fatalf("client sent command %v, want %v", cmd, "FETCH 1 (BODY.PEEK[]<0.10>)") + } + + s.WriteString("* 1 FETCH (BODY[]<0> {10}\r\n") + s.WriteString("I love pot") + s.WriteString(")\r\n") + + s.WriteString(tag + " OK FETCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Fetch() = %v", err) + } + + section, _ := imap.ParseBodySectionName("BODY.PEEK[]<0.10>") + + msg := <-messages + if body, _ := ioutil.ReadAll(msg.GetBody(section)); string(body) != "I love pot" { + t.Errorf("Message has bad body: %q", body) + } +} + +func TestClient_Fetch_part(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("1") + fields := []imap.FetchItem{imap.FetchItem("BODY.PEEK[1]")} + + done := make(chan error, 1) + messages := make(chan *imap.Message, 1) + go func() { + done <- c.Fetch(seqset, fields, messages) + }() + + tag, cmd := s.ScanCmd() + if cmd != "FETCH 1 (BODY.PEEK[1])" { + t.Fatalf("client sent command %v, want %v", cmd, "FETCH 1 (BODY.PEEK[1])") + } + + s.WriteString("* 1 FETCH (BODY[1] {3}\r\n") + s.WriteString("Hey") + s.WriteString(")\r\n") + + s.WriteString(tag + " OK FETCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Fetch() = %v", err) + } + + <-messages +} + +func TestClient_Fetch_Uid(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("1:867") + fields := []imap.FetchItem{imap.FetchFlags} + + done := make(chan error, 1) + messages := make(chan *imap.Message, 1) + go func() { + done <- c.UidFetch(seqset, fields, messages) + }() + + tag, cmd := s.ScanCmd() + if cmd != "UID FETCH 1:867 (FLAGS)" { + t.Fatalf("client sent command %v, want %v", cmd, "UID FETCH 1:867 (FLAGS)") + } + + s.WriteString("* 23 FETCH (UID 42 FLAGS (\\Seen))\r\n") + s.WriteString(tag + " OK UID FETCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.UidFetch() = %v", err) + } + + msg := <-messages + if msg.SeqNum != 23 { + t.Errorf("First message has bad sequence number: %v", msg.SeqNum) + } + if msg.Uid != 42 { + t.Errorf("Message has bad UID: %v", msg.Uid) + } + if len(msg.Flags) != 1 || msg.Flags[0] != "\\Seen" { + t.Errorf("Message has bad flags: %v", msg.Flags) + } +} + +func TestClient_Fetch_Unilateral(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("1:4") + fields := []imap.FetchItem{imap.FetchFlags} + + done := make(chan error, 1) + messages := make(chan *imap.Message, 3) + go func() { + done <- c.Fetch(seqset, fields, messages) + }() + + tag, cmd := s.ScanCmd() + if cmd != "FETCH 1:4 (FLAGS)" { + t.Fatalf("client sent command %v, want %v", cmd, "FETCH 1:4 (FLAGS)") + } + + s.WriteString("* 2 FETCH (FLAGS (\\Seen))\r\n") + s.WriteString("* 123 FETCH (FLAGS (\\Deleted))\r\n") + s.WriteString("* 4 FETCH (FLAGS (\\Seen))\r\n") + s.WriteString(tag + " OK FETCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Fetch() = %v", err) + } + + msg := <-messages + if msg.SeqNum != 2 { + t.Errorf("First message has bad sequence number: %v", msg.SeqNum) + } + msg = <-messages + if msg.SeqNum != 4 { + t.Errorf("Second message has bad sequence number: %v", msg.SeqNum) + } + + _, ok := <-messages + if ok { + t.Errorf("More than two messages") + } +} + +func TestClient_Fetch_Unilateral_Uid(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("1:4") + fields := []imap.FetchItem{imap.FetchFlags} + + done := make(chan error, 1) + messages := make(chan *imap.Message, 3) + go func() { + done <- c.UidFetch(seqset, fields, messages) + }() + + tag, cmd := s.ScanCmd() + if cmd != "UID FETCH 1:4 (FLAGS)" { + t.Fatalf("client sent command %v, want %v", cmd, "UID FETCH 1:4 (FLAGS)") + } + + s.WriteString("* 23 FETCH (UID 2 FLAGS (\\Seen))\r\n") + s.WriteString("* 123 FETCH (FLAGS (\\Deleted))\r\n") + s.WriteString("* 49 FETCH (UID 4 FLAGS (\\Seen))\r\n") + s.WriteString(tag + " OK FETCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Fetch() = %v", err) + } + + msg := <-messages + if msg.Uid != 2 { + t.Errorf("First message has bad UID: %v", msg.Uid) + } + msg = <-messages + if msg.Uid != 4 { + t.Errorf("Second message has bad UID: %v", msg.Uid) + } + + _, ok := <-messages + if ok { + t.Errorf("More than two messages") + } +} + +func TestClient_Fetch_Uid_Dynamic(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("4:*") + fields := []imap.FetchItem{imap.FetchFlags} + + done := make(chan error, 1) + messages := make(chan *imap.Message, 1) + go func() { + done <- c.UidFetch(seqset, fields, messages) + }() + + tag, cmd := s.ScanCmd() + if cmd != "UID FETCH 4:* (FLAGS)" { + t.Fatalf("client sent command %v, want %v", cmd, "UID FETCH 4:* (FLAGS)") + } + + s.WriteString("* 23 FETCH (UID 2 FLAGS (\\Seen))\r\n") + s.WriteString(tag + " OK FETCH completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Fetch() = %v", err) + } + + msg, ok := <-messages + if !ok { + t.Errorf("No message supplied") + } else if msg.Uid != 2 { + t.Errorf("First message has bad UID: %v", msg.Uid) + } +} + +func TestClient_Store(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("2") + + done := make(chan error, 1) + updates := make(chan *imap.Message, 1) + go func() { + done <- c.Store(seqset, imap.AddFlags, []interface{}{imap.SeenFlag, "foobar"}, updates) + }() + + tag, cmd := s.ScanCmd() + if cmd != "STORE 2 +FLAGS (\\Seen foobar)" { + t.Fatalf("client sent command %v, want %v", cmd, "STORE 2 +FLAGS (\\Seen foobar)") + } + + s.WriteString("* 2 FETCH (FLAGS (\\Seen foobar))\r\n") + s.WriteString(tag + " OK STORE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Store() = %v", err) + } + + msg := <-updates + if len(msg.Flags) != 2 || msg.Flags[0] != "\\Seen" || msg.Flags[1] != "foobar" { + t.Errorf("Bad message flags: %v", msg.Flags) + } +} + +func TestClient_Store_Silent(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("2:3") + + done := make(chan error, 1) + go func() { + done <- c.Store(seqset, imap.AddFlags, []interface{}{imap.SeenFlag, "foobar"}, nil) + }() + + tag, cmd := s.ScanCmd() + if cmd != "STORE 2:3 +FLAGS.SILENT (\\Seen foobar)" { + t.Fatalf("client sent command %v, want %v", cmd, "STORE 2:3 +FLAGS.SILENT (\\Seen foobar)") + } + + s.WriteString(tag + " OK STORE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Store() = %v", err) + } +} + +func TestClient_Store_Uid(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("27:901") + + done := make(chan error, 1) + go func() { + done <- c.UidStore(seqset, imap.AddFlags, []interface{}{imap.DeletedFlag, "foobar"}, nil) + }() + + tag, cmd := s.ScanCmd() + if cmd != "UID STORE 27:901 +FLAGS.SILENT (\\Deleted foobar)" { + t.Fatalf("client sent command %v, want %v", cmd, "UID STORE 27:901 +FLAGS.SILENT (\\Deleted foobar)") + } + + s.WriteString(tag + " OK STORE completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.UidStore() = %v", err) + } +} + +func TestClient_Copy(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("2:4") + + done := make(chan error, 1) + go func() { + done <- c.Copy(seqset, "Sent") + }() + + tag, cmd := s.ScanCmd() + if cmd != "COPY 2:4 \"Sent\"" { + t.Fatalf("client sent command %v, want %v", cmd, "COPY 2:4 \"Sent\"") + } + + s.WriteString(tag + " OK COPY completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Copy() = %v", err) + } +} + +func TestClient_Copy_Uid(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + seqset, _ := imap.ParseSeqSet("78:102") + + done := make(chan error, 1) + go func() { + done <- c.UidCopy(seqset, "Drafts") + }() + + tag, cmd := s.ScanCmd() + if cmd != "UID COPY 78:102 \"Drafts\"" { + t.Fatalf("client sent command %v, want %v", cmd, "UID COPY 78:102 \"Drafts\"") + } + + s.WriteString(tag + " OK UID COPY completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.UidCopy() = %v", err) + } +} + +func TestClient_Unselect(t *testing.T) { + c, s := newTestClient(t) + defer s.Close() + + setClientState(c, imap.SelectedState, nil) + + done := make(chan error, 1) + go func() { + done <- c.Unselect() + }() + + tag, cmd := s.ScanCmd() + if cmd != "UNSELECT" { + t.Fatalf("client sent command %v, want %v", cmd, "UNSELECT") + } + + s.WriteString(tag + " OK UNSELECT completed\r\n") + + if err := <-done; err != nil { + t.Fatalf("c.Unselect() = %v", err) + } + + if c.State() != imap.AuthenticatedState { + t.Fatal("Client is not Authenticated after UNSELECT") + } +} diff --git a/client/example_test.go b/client/example_test.go new file mode 100644 index 0000000..28f041a --- /dev/null +++ b/client/example_test.go @@ -0,0 +1,323 @@ +package client_test + +import ( + "bytes" + "crypto/tls" + "io/ioutil" + "log" + "net/mail" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" +) + +func ExampleClient() { + log.Println("Connecting to server...") + + // Connect to server + c, err := client.DialTLS("mail.example.org:993", nil) + if err != nil { + log.Fatal(err) + } + log.Println("Connected") + + // Don't forget to logout + defer c.Logout() + + // Login + if err := c.Login("username", "password"); err != nil { + log.Fatal(err) + } + log.Println("Logged in") + + // List mailboxes + mailboxes := make(chan *imap.MailboxInfo, 10) + done := make(chan error, 1) + go func() { + done <- c.List("", "*", mailboxes) + }() + + log.Println("Mailboxes:") + for m := range mailboxes { + log.Println("* " + m.Name) + } + + if err := <-done; err != nil { + log.Fatal(err) + } + + // Select INBOX + mbox, err := c.Select("INBOX", false) + if err != nil { + log.Fatal(err) + } + log.Println("Flags for INBOX:", mbox.Flags) + + // Get the last 4 messages + from := uint32(1) + to := mbox.Messages + if mbox.Messages > 3 { + // We're using unsigned integers here, only substract if the result is > 0 + from = mbox.Messages - 3 + } + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + items := []imap.FetchItem{imap.FetchEnvelope} + + messages := make(chan *imap.Message, 10) + done = make(chan error, 1) + go func() { + done <- c.Fetch(seqset, items, messages) + }() + + log.Println("Last 4 messages:") + for msg := range messages { + log.Println("* " + msg.Envelope.Subject) + } + + if err := <-done; err != nil { + log.Fatal(err) + } + + log.Println("Done!") +} + +func ExampleClient_Fetch() { + // Let's assume c is a client + var c *client.Client + + // Select INBOX + mbox, err := c.Select("INBOX", false) + if err != nil { + log.Fatal(err) + } + + // Get the last message + if mbox.Messages == 0 { + log.Fatal("No message in mailbox") + } + seqset := new(imap.SeqSet) + seqset.AddRange(mbox.Messages, mbox.Messages) + + // Get the whole message body + section := &imap.BodySectionName{} + items := []imap.FetchItem{section.FetchItem()} + + messages := make(chan *imap.Message, 1) + done := make(chan error, 1) + go func() { + done <- c.Fetch(seqset, items, messages) + }() + + log.Println("Last message:") + msg := <-messages + r := msg.GetBody(section) + if r == nil { + log.Fatal("Server didn't returned message body") + } + + if err := <-done; err != nil { + log.Fatal(err) + } + + m, err := mail.ReadMessage(r) + if err != nil { + log.Fatal(err) + } + + header := m.Header + log.Println("Date:", header.Get("Date")) + log.Println("From:", header.Get("From")) + log.Println("To:", header.Get("To")) + log.Println("Subject:", header.Get("Subject")) + + body, err := ioutil.ReadAll(m.Body) + if err != nil { + log.Fatal(err) + } + log.Println(body) +} + +func ExampleClient_Append() { + // Let's assume c is a client + var c *client.Client + + // Write the message to a buffer + var b bytes.Buffer + b.WriteString("From: \r\n") + b.WriteString("To: \r\n") + b.WriteString("Subject: Hey there\r\n") + b.WriteString("\r\n") + b.WriteString("Hey <3") + + // Append it to INBOX, with two flags + flags := []string{imap.FlaggedFlag, "foobar"} + if err := c.Append("INBOX", flags, time.Now(), &b); err != nil { + log.Fatal(err) + } +} + +func ExampleClient_Expunge() { + // Let's assume c is a client + var c *client.Client + + // Select INBOX + mbox, err := c.Select("INBOX", false) + if err != nil { + log.Fatal(err) + } + + // We will delete the last message + if mbox.Messages == 0 { + log.Fatal("No message in mailbox") + } + seqset := new(imap.SeqSet) + seqset.AddNum(mbox.Messages) + + // First mark the message as deleted + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.DeletedFlag} + if err := c.Store(seqset, item, flags, nil); err != nil { + log.Fatal(err) + } + + // Then delete it + if err := c.Expunge(nil); err != nil { + log.Fatal(err) + } + + log.Println("Last message has been deleted") +} + +func ExampleClient_StartTLS() { + log.Println("Connecting to server...") + + // Connect to server + c, err := client.Dial("mail.example.org:143") + if err != nil { + log.Fatal(err) + } + log.Println("Connected") + + // Don't forget to logout + defer c.Logout() + + // Start a TLS session + tlsConfig := &tls.Config{ServerName: "mail.example.org"} + if err := c.StartTLS(tlsConfig); err != nil { + log.Fatal(err) + } + log.Println("TLS started") + + // Now we can login + if err := c.Login("username", "password"); err != nil { + log.Fatal(err) + } + log.Println("Logged in") +} + +func ExampleClient_Store() { + // Let's assume c is a client + var c *client.Client + + // Select INBOX + _, err := c.Select("INBOX", false) + if err != nil { + log.Fatal(err) + } + + // Mark message 42 as seen + seqSet := new(imap.SeqSet) + seqSet.AddNum(42) + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.SeenFlag} + err = c.Store(seqSet, item, flags, nil) + if err != nil { + log.Fatal(err) + } + + log.Println("Message has been marked as seen") +} + +func ExampleClient_Search() { + // Let's assume c is a client + var c *client.Client + + // Select INBOX + _, err := c.Select("INBOX", false) + if err != nil { + log.Fatal(err) + } + + // Set search criteria + criteria := imap.NewSearchCriteria() + criteria.WithoutFlags = []string{imap.SeenFlag} + ids, err := c.Search(criteria) + if err != nil { + log.Fatal(err) + } + log.Println("IDs found:", ids) + + if len(ids) > 0 { + seqset := new(imap.SeqSet) + seqset.AddNum(ids...) + + messages := make(chan *imap.Message, 10) + done := make(chan error, 1) + go func() { + done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope}, messages) + }() + + log.Println("Unseen messages:") + for msg := range messages { + log.Println("* " + msg.Envelope.Subject) + } + + if err := <-done; err != nil { + log.Fatal(err) + } + } + + log.Println("Done!") +} + +func ExampleClient_Idle() { + // Let's assume c is a client + var c *client.Client + + // Select a mailbox + if _, err := c.Select("INBOX", false); err != nil { + log.Fatal(err) + } + + // Create a channel to receive mailbox updates + updates := make(chan client.Update) + c.Updates = updates + + // Start idling + stopped := false + stop := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- c.Idle(stop, nil) + }() + + // Listen for updates + for { + select { + case update := <-updates: + log.Println("New update:", update) + if !stopped { + close(stop) + stopped = true + } + case err := <-done: + if err != nil { + log.Fatal(err) + } + log.Println("Not idling anymore") + return + } + } +} diff --git a/client/tag.go b/client/tag.go new file mode 100644 index 0000000..01526ab --- /dev/null +++ b/client/tag.go @@ -0,0 +1,24 @@ +package client + +import ( + "crypto/rand" + "encoding/base64" +) + +func randomString(n int) (string, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func generateTag() string { + tag, err := randomString(4) + if err != nil { + panic(err) + } + return tag +} diff --git a/command.go b/command.go new file mode 100644 index 0000000..dac2696 --- /dev/null +++ b/command.go @@ -0,0 +1,57 @@ +package imap + +import ( + "errors" + "strings" +) + +// A value that can be converted to a command. +type Commander interface { + Command() *Command +} + +// A command. +type Command struct { + // The command tag. It acts as a unique identifier for this command. If empty, + // the command is untagged. + Tag string + // The command name. + Name string + // The command arguments. + Arguments []interface{} +} + +// Implements the Commander interface. +func (cmd *Command) Command() *Command { + return cmd +} + +func (cmd *Command) WriteTo(w *Writer) error { + tag := cmd.Tag + if tag == "" { + tag = "*" + } + + fields := []interface{}{RawString(tag), RawString(cmd.Name)} + fields = append(fields, cmd.Arguments...) + return w.writeLine(fields...) +} + +// Parse a command from fields. +func (cmd *Command) Parse(fields []interface{}) error { + if len(fields) < 2 { + return errors.New("imap: cannot parse command: no enough fields") + } + + var ok bool + if cmd.Tag, ok = fields[0].(string); !ok { + return errors.New("imap: cannot parse command: invalid tag") + } + if cmd.Name, ok = fields[1].(string); !ok { + return errors.New("imap: cannot parse command: invalid name") + } + cmd.Name = strings.ToUpper(cmd.Name) // Command names are case-insensitive + + cmd.Arguments = fields[2:] + return nil +} diff --git a/command_test.go b/command_test.go new file mode 100644 index 0000000..dafa9fa --- /dev/null +++ b/command_test.go @@ -0,0 +1,98 @@ +package imap_test + +import ( + "bytes" + "testing" + + "github.com/emersion/go-imap" +) + +func TestCommand_Command(t *testing.T) { + cmd := &imap.Command{ + Tag: "A001", + Name: "NOOP", + } + + if cmd.Command() != cmd { + t.Error("Command should return itself") + } +} + +func TestCommand_WriteTo_NoArgs(t *testing.T) { + var b bytes.Buffer + w := imap.NewWriter(&b) + + cmd := &imap.Command{ + Tag: "A001", + Name: "NOOP", + } + + if err := cmd.WriteTo(w); err != nil { + t.Fatal(err) + } + if b.String() != "A001 NOOP\r\n" { + t.Fatal("Not the expected command: ", b.String()) + } +} + +func TestCommand_WriteTo_WithArgs(t *testing.T) { + var b bytes.Buffer + w := imap.NewWriter(&b) + + cmd := &imap.Command{ + Tag: "A002", + Name: "LOGIN", + Arguments: []interface{}{"username", "password"}, + } + + if err := cmd.WriteTo(w); err != nil { + t.Fatal(err) + } + if b.String() != "A002 LOGIN \"username\" \"password\"\r\n" { + t.Fatal("Not the expected command: ", b.String()) + } +} + +func TestCommand_Parse_NoArgs(t *testing.T) { + fields := []interface{}{"a", "NOOP"} + + cmd := &imap.Command{} + + if err := cmd.Parse(fields); err != nil { + t.Fatal(err) + } + if cmd.Tag != "a" { + t.Error("Invalid tag:", cmd.Tag) + } + if cmd.Name != "NOOP" { + t.Error("Invalid name:", cmd.Name) + } + if len(cmd.Arguments) != 0 { + t.Error("Invalid arguments:", cmd.Arguments) + } +} + +func TestCommand_Parse_WithArgs(t *testing.T) { + fields := []interface{}{"a", "LOGIN", "username", "password"} + + cmd := &imap.Command{} + + if err := cmd.Parse(fields); err != nil { + t.Fatal(err) + } + if cmd.Tag != "a" { + t.Error("Invalid tag:", cmd.Tag) + } + if cmd.Name != "LOGIN" { + t.Error("Invalid name:", cmd.Name) + } + if len(cmd.Arguments) != 2 { + t.Error("Invalid arguments:", cmd.Arguments) + } + if username, ok := cmd.Arguments[0].(string); !ok || username != "username" { + t.Error("Invalid first argument:", cmd.Arguments[0]) + } + if password, ok := cmd.Arguments[1].(string); !ok || password != "password" { + t.Error("Invalid second argument:", cmd.Arguments[1]) + } +} diff --git a/commands/append.go b/commands/append.go new file mode 100644 index 0000000..d70b584 --- /dev/null +++ b/commands/append.go @@ -0,0 +1,93 @@ +package commands + +import ( + "errors" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// Append is an APPEND command, as defined in RFC 3501 section 6.3.11. +type Append struct { + Mailbox string + Flags []string + Date time.Time + Message imap.Literal +} + +func (cmd *Append) Command() *imap.Command { + var args []interface{} + + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + args = append(args, imap.FormatMailboxName(mailbox)) + + if cmd.Flags != nil { + flags := make([]interface{}, len(cmd.Flags)) + for i, flag := range cmd.Flags { + flags[i] = imap.RawString(flag) + } + args = append(args, flags) + } + + if !cmd.Date.IsZero() { + args = append(args, cmd.Date) + } + + args = append(args, cmd.Message) + + return &imap.Command{ + Name: "APPEND", + Arguments: args, + } +} + +func (cmd *Append) Parse(fields []interface{}) (err error) { + if len(fields) < 2 { + return errors.New("No enough arguments") + } + + // Parse mailbox name + if mailbox, err := imap.ParseString(fields[0]); err != nil { + return err + } else if mailbox, err = utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } else { + cmd.Mailbox = imap.CanonicalMailboxName(mailbox) + } + + // Parse message literal + litIndex := len(fields) - 1 + var ok bool + if cmd.Message, ok = fields[litIndex].(imap.Literal); !ok { + return errors.New("Message must be a literal") + } + + // Remaining fields a optional + fields = fields[1:litIndex] + if len(fields) > 0 { + // Parse flags list + if flags, ok := fields[0].([]interface{}); ok { + if cmd.Flags, err = imap.ParseStringList(flags); err != nil { + return err + } + + for i, flag := range cmd.Flags { + cmd.Flags[i] = imap.CanonicalFlag(flag) + } + + fields = fields[1:] + } + + // Parse date + if len(fields) > 0 { + if date, ok := fields[0].(string); !ok { + return errors.New("Date must be a string") + } else if cmd.Date, err = time.Parse(imap.DateTimeLayout, date); err != nil { + return err + } + } + } + + return +} diff --git a/commands/authenticate.go b/commands/authenticate.go new file mode 100644 index 0000000..b66f21f --- /dev/null +++ b/commands/authenticate.go @@ -0,0 +1,124 @@ +package commands + +import ( + "bufio" + "encoding/base64" + "errors" + "io" + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-sasl" +) + +// AuthenticateConn is a connection that supports IMAP authentication. +type AuthenticateConn interface { + io.Reader + + // WriteResp writes an IMAP response to this connection. + WriteResp(res imap.WriterTo) error +} + +// Authenticate is an AUTHENTICATE command, as defined in RFC 3501 section +// 6.2.2. +type Authenticate struct { + Mechanism string + InitialResponse []byte +} + +func (cmd *Authenticate) Command() *imap.Command { + args := []interface{}{imap.RawString(cmd.Mechanism)} + if cmd.InitialResponse != nil { + var encodedResponse string + if len(cmd.InitialResponse) == 0 { + // Empty initial response should be encoded as "=", not empty + // string. + encodedResponse = "=" + } else { + encodedResponse = base64.StdEncoding.EncodeToString(cmd.InitialResponse) + } + + args = append(args, imap.RawString(encodedResponse)) + } + return &imap.Command{ + Name: "AUTHENTICATE", + Arguments: args, + } +} + +func (cmd *Authenticate) Parse(fields []interface{}) error { + if len(fields) < 1 { + return errors.New("Not enough arguments") + } + + var ok bool + if cmd.Mechanism, ok = fields[0].(string); !ok { + return errors.New("Mechanism must be a string") + } + cmd.Mechanism = strings.ToUpper(cmd.Mechanism) + + if len(fields) != 2 { + return nil + } + + encodedResponse, ok := fields[1].(string) + if !ok { + return errors.New("Initial response must be a string") + } + if encodedResponse == "=" { + cmd.InitialResponse = []byte{} + return nil + } + + var err error + cmd.InitialResponse, err = base64.StdEncoding.DecodeString(encodedResponse) + if err != nil { + return err + } + + return nil +} + +func (cmd *Authenticate) Handle(mechanisms map[string]sasl.Server, conn AuthenticateConn) error { + sasl, ok := mechanisms[cmd.Mechanism] + if !ok { + return errors.New("Unsupported mechanism") + } + + scanner := bufio.NewScanner(conn) + + response := cmd.InitialResponse + for { + challenge, done, err := sasl.Next(response) + if err != nil || done { + return err + } + + encoded := base64.StdEncoding.EncodeToString(challenge) + cont := &imap.ContinuationReq{Info: encoded} + if err := conn.WriteResp(cont); err != nil { + return err + } + + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return err + } + return errors.New("unexpected EOF") + } + + encoded = scanner.Text() + if encoded != "" { + if encoded == "*" { + return &imap.ErrStatusResp{Resp: &imap.StatusResp{ + Type: imap.StatusRespBad, + Info: "negotiation cancelled", + }} + } + response, err = base64.StdEncoding.DecodeString(encoded) + if err != nil { + return err + } + } + } +} diff --git a/commands/capability.go b/commands/capability.go new file mode 100644 index 0000000..3359c0a --- /dev/null +++ b/commands/capability.go @@ -0,0 +1,18 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// Capability is a CAPABILITY command, as defined in RFC 3501 section 6.1.1. +type Capability struct{} + +func (c *Capability) Command() *imap.Command { + return &imap.Command{ + Name: "CAPABILITY", + } +} + +func (c *Capability) Parse(fields []interface{}) error { + return nil +} diff --git a/commands/check.go b/commands/check.go new file mode 100644 index 0000000..b90df7c --- /dev/null +++ b/commands/check.go @@ -0,0 +1,18 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// Check is a CHECK command, as defined in RFC 3501 section 6.4.1. +type Check struct{} + +func (cmd *Check) Command() *imap.Command { + return &imap.Command{ + Name: "CHECK", + } +} + +func (cmd *Check) Parse(fields []interface{}) error { + return nil +} diff --git a/commands/close.go b/commands/close.go new file mode 100644 index 0000000..cc60658 --- /dev/null +++ b/commands/close.go @@ -0,0 +1,18 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// Close is a CLOSE command, as defined in RFC 3501 section 6.4.2. +type Close struct{} + +func (cmd *Close) Command() *imap.Command { + return &imap.Command{ + Name: "CLOSE", + } +} + +func (cmd *Close) Parse(fields []interface{}) error { + return nil +} diff --git a/commands/commands.go b/commands/commands.go new file mode 100644 index 0000000..a62b248 --- /dev/null +++ b/commands/commands.go @@ -0,0 +1,2 @@ +// Package commands implements IMAP commands defined in RFC 3501. +package commands diff --git a/commands/copy.go b/commands/copy.go new file mode 100644 index 0000000..5258f35 --- /dev/null +++ b/commands/copy.go @@ -0,0 +1,47 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// Copy is a COPY command, as defined in RFC 3501 section 6.4.7. +type Copy struct { + SeqSet *imap.SeqSet + Mailbox string +} + +func (cmd *Copy) Command() *imap.Command { + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + + return &imap.Command{ + Name: "COPY", + Arguments: []interface{}{cmd.SeqSet, imap.FormatMailboxName(mailbox)}, + } +} + +func (cmd *Copy) Parse(fields []interface{}) error { + if len(fields) < 2 { + return errors.New("No enough arguments") + } + + if seqSet, ok := fields[0].(string); !ok { + return errors.New("Invalid sequence set") + } else if seqSet, err := imap.ParseSeqSet(seqSet); err != nil { + return err + } else { + cmd.SeqSet = seqSet + } + + if mailbox, err := imap.ParseString(fields[1]); err != nil { + return err + } else if mailbox, err := utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } else { + cmd.Mailbox = imap.CanonicalMailboxName(mailbox) + } + + return nil +} diff --git a/commands/create.go b/commands/create.go new file mode 100644 index 0000000..a1e6fe2 --- /dev/null +++ b/commands/create.go @@ -0,0 +1,38 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// Create is a CREATE command, as defined in RFC 3501 section 6.3.3. +type Create struct { + Mailbox string +} + +func (cmd *Create) Command() *imap.Command { + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + + return &imap.Command{ + Name: "CREATE", + Arguments: []interface{}{mailbox}, + } +} + +func (cmd *Create) Parse(fields []interface{}) error { + if len(fields) < 1 { + return errors.New("No enough arguments") + } + + if mailbox, err := imap.ParseString(fields[0]); err != nil { + return err + } else if mailbox, err := utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } else { + cmd.Mailbox = imap.CanonicalMailboxName(mailbox) + } + + return nil +} diff --git a/commands/delete.go b/commands/delete.go new file mode 100644 index 0000000..60f4da8 --- /dev/null +++ b/commands/delete.go @@ -0,0 +1,38 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// Delete is a DELETE command, as defined in RFC 3501 section 6.3.3. +type Delete struct { + Mailbox string +} + +func (cmd *Delete) Command() *imap.Command { + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + + return &imap.Command{ + Name: "DELETE", + Arguments: []interface{}{imap.FormatMailboxName(mailbox)}, + } +} + +func (cmd *Delete) Parse(fields []interface{}) error { + if len(fields) < 1 { + return errors.New("No enough arguments") + } + + if mailbox, err := imap.ParseString(fields[0]); err != nil { + return err + } else if mailbox, err := utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } else { + cmd.Mailbox = imap.CanonicalMailboxName(mailbox) + } + + return nil +} diff --git a/commands/enable.go b/commands/enable.go new file mode 100644 index 0000000..d0e25d1 --- /dev/null +++ b/commands/enable.go @@ -0,0 +1,28 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// An ENABLE command, defined in RFC 5161 section 3.1. +type Enable struct { + Caps []string +} + +func (cmd *Enable) Command() *imap.Command { + args := make([]interface{}, len(cmd.Caps)) + for i, c := range cmd.Caps { + args[i] = imap.RawString(c) + } + + return &imap.Command{ + Name: "ENABLE", + Arguments: args, + } +} + +func (cmd *Enable) Parse(fields []interface{}) error { + var err error + cmd.Caps, err = imap.ParseStringList(fields) + return err +} diff --git a/commands/expunge.go b/commands/expunge.go new file mode 100644 index 0000000..af550a4 --- /dev/null +++ b/commands/expunge.go @@ -0,0 +1,16 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// Expunge is an EXPUNGE command, as defined in RFC 3501 section 6.4.3. +type Expunge struct{} + +func (cmd *Expunge) Command() *imap.Command { + return &imap.Command{Name: "EXPUNGE"} +} + +func (cmd *Expunge) Parse(fields []interface{}) error { + return nil +} diff --git a/commands/fetch.go b/commands/fetch.go new file mode 100644 index 0000000..4eb3ab9 --- /dev/null +++ b/commands/fetch.go @@ -0,0 +1,63 @@ +package commands + +import ( + "errors" + "strings" + + "github.com/emersion/go-imap" +) + +// Fetch is a FETCH command, as defined in RFC 3501 section 6.4.5. +type Fetch struct { + SeqSet *imap.SeqSet + Items []imap.FetchItem +} + +func (cmd *Fetch) Command() *imap.Command { + // Handle FETCH macros separately as they should not be serialized within parentheses + if len(cmd.Items) == 1 && (cmd.Items[0] == imap.FetchAll || cmd.Items[0] == imap.FetchFast || cmd.Items[0] == imap.FetchFull) { + return &imap.Command{ + Name: "FETCH", + Arguments: []interface{}{cmd.SeqSet, imap.RawString(cmd.Items[0])}, + } + } else { + items := make([]interface{}, len(cmd.Items)) + for i, item := range cmd.Items { + items[i] = imap.RawString(item) + } + + return &imap.Command{ + Name: "FETCH", + Arguments: []interface{}{cmd.SeqSet, items}, + } + } +} + +func (cmd *Fetch) Parse(fields []interface{}) error { + if len(fields) < 2 { + return errors.New("No enough arguments") + } + + var err error + if seqset, ok := fields[0].(string); !ok { + return errors.New("Sequence set must be an atom") + } else if cmd.SeqSet, err = imap.ParseSeqSet(seqset); err != nil { + return err + } + + switch items := fields[1].(type) { + case string: // A macro or a single item + cmd.Items = imap.FetchItem(strings.ToUpper(items)).Expand() + case []interface{}: // A list of items + cmd.Items = make([]imap.FetchItem, 0, len(items)) + for _, v := range items { + itemStr, _ := v.(string) + item := imap.FetchItem(strings.ToUpper(itemStr)) + cmd.Items = append(cmd.Items, item.Expand()...) + } + default: + return errors.New("Items must be either a string or a list") + } + + return nil +} diff --git a/commands/idle.go b/commands/idle.go new file mode 100644 index 0000000..4d9669f --- /dev/null +++ b/commands/idle.go @@ -0,0 +1,17 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// An IDLE command. +// Se RFC 2177 section 3. +type Idle struct{} + +func (cmd *Idle) Command() *imap.Command { + return &imap.Command{Name: "IDLE"} +} + +func (cmd *Idle) Parse(fields []interface{}) error { + return nil +} diff --git a/commands/list.go b/commands/list.go new file mode 100644 index 0000000..52686e9 --- /dev/null +++ b/commands/list.go @@ -0,0 +1,60 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// List is a LIST command, as defined in RFC 3501 section 6.3.8. If Subscribed +// is set to true, LSUB will be used instead. +type List struct { + Reference string + Mailbox string + + Subscribed bool +} + +func (cmd *List) Command() *imap.Command { + name := "LIST" + if cmd.Subscribed { + name = "LSUB" + } + + enc := utf7.Encoding.NewEncoder() + ref, _ := enc.String(cmd.Reference) + mailbox, _ := enc.String(cmd.Mailbox) + + return &imap.Command{ + Name: name, + Arguments: []interface{}{ref, mailbox}, + } +} + +func (cmd *List) Parse(fields []interface{}) error { + if len(fields) < 2 { + return errors.New("No enough arguments") + } + + dec := utf7.Encoding.NewDecoder() + + if mailbox, err := imap.ParseString(fields[0]); err != nil { + return err + } else if mailbox, err := dec.String(mailbox); err != nil { + return err + } else { + // TODO: canonical mailbox path + cmd.Reference = imap.CanonicalMailboxName(mailbox) + } + + if mailbox, err := imap.ParseString(fields[1]); err != nil { + return err + } else if mailbox, err := dec.String(mailbox); err != nil { + return err + } else { + cmd.Mailbox = imap.CanonicalMailboxName(mailbox) + } + + return nil +} diff --git a/commands/login.go b/commands/login.go new file mode 100644 index 0000000..d0af0b5 --- /dev/null +++ b/commands/login.go @@ -0,0 +1,36 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" +) + +// Login is a LOGIN command, as defined in RFC 3501 section 6.2.2. +type Login struct { + Username string + Password string +} + +func (cmd *Login) Command() *imap.Command { + return &imap.Command{ + Name: "LOGIN", + Arguments: []interface{}{cmd.Username, cmd.Password}, + } +} + +func (cmd *Login) Parse(fields []interface{}) error { + if len(fields) < 2 { + return errors.New("Not enough arguments") + } + + var err error + if cmd.Username, err = imap.ParseString(fields[0]); err != nil { + return err + } + if cmd.Password, err = imap.ParseString(fields[1]); err != nil { + return err + } + + return nil +} diff --git a/commands/logout.go b/commands/logout.go new file mode 100644 index 0000000..e826719 --- /dev/null +++ b/commands/logout.go @@ -0,0 +1,18 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// Logout is a LOGOUT command, as defined in RFC 3501 section 6.1.3. +type Logout struct{} + +func (c *Logout) Command() *imap.Command { + return &imap.Command{ + Name: "LOGOUT", + } +} + +func (c *Logout) Parse(fields []interface{}) error { + return nil +} diff --git a/commands/move.go b/commands/move.go new file mode 100644 index 0000000..613a870 --- /dev/null +++ b/commands/move.go @@ -0,0 +1,48 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// A MOVE command. +// See RFC 6851 section 3.1. +type Move struct { + SeqSet *imap.SeqSet + Mailbox string +} + +func (cmd *Move) Command() *imap.Command { + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + + return &imap.Command{ + Name: "MOVE", + Arguments: []interface{}{cmd.SeqSet, mailbox}, + } +} + +func (cmd *Move) Parse(fields []interface{}) (err error) { + if len(fields) < 2 { + return errors.New("No enough arguments") + } + + seqset, ok := fields[0].(string) + if !ok { + return errors.New("Invalid sequence set") + } + if cmd.SeqSet, err = imap.ParseSeqSet(seqset); err != nil { + return err + } + + mailbox, ok := fields[1].(string) + if !ok { + return errors.New("Mailbox name must be a string") + } + if cmd.Mailbox, err = utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } + + return +} diff --git a/commands/noop.go b/commands/noop.go new file mode 100644 index 0000000..da6a1c2 --- /dev/null +++ b/commands/noop.go @@ -0,0 +1,18 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// Noop is a NOOP command, as defined in RFC 3501 section 6.1.2. +type Noop struct{} + +func (c *Noop) Command() *imap.Command { + return &imap.Command{ + Name: "NOOP", + } +} + +func (c *Noop) Parse(fields []interface{}) error { + return nil +} diff --git a/commands/rename.go b/commands/rename.go new file mode 100644 index 0000000..37a5fa7 --- /dev/null +++ b/commands/rename.go @@ -0,0 +1,51 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// Rename is a RENAME command, as defined in RFC 3501 section 6.3.5. +type Rename struct { + Existing string + New string +} + +func (cmd *Rename) Command() *imap.Command { + enc := utf7.Encoding.NewEncoder() + existingName, _ := enc.String(cmd.Existing) + newName, _ := enc.String(cmd.New) + + return &imap.Command{ + Name: "RENAME", + Arguments: []interface{}{imap.FormatMailboxName(existingName), imap.FormatMailboxName(newName)}, + } +} + +func (cmd *Rename) Parse(fields []interface{}) error { + if len(fields) < 2 { + return errors.New("No enough arguments") + } + + dec := utf7.Encoding.NewDecoder() + + if existingName, err := imap.ParseString(fields[0]); err != nil { + return err + } else if existingName, err := dec.String(existingName); err != nil { + return err + } else { + cmd.Existing = imap.CanonicalMailboxName(existingName) + } + + if newName, err := imap.ParseString(fields[1]); err != nil { + return err + } else if newName, err := dec.String(newName); err != nil { + return err + } else { + cmd.New = imap.CanonicalMailboxName(newName) + } + + return nil +} diff --git a/commands/search.go b/commands/search.go new file mode 100644 index 0000000..72f026c --- /dev/null +++ b/commands/search.go @@ -0,0 +1,57 @@ +package commands + +import ( + "errors" + "io" + "strings" + + "github.com/emersion/go-imap" +) + +// Search is a SEARCH command, as defined in RFC 3501 section 6.4.4. +type Search struct { + Charset string + Criteria *imap.SearchCriteria +} + +func (cmd *Search) Command() *imap.Command { + var args []interface{} + if cmd.Charset != "" { + args = append(args, imap.RawString("CHARSET"), imap.RawString(cmd.Charset)) + } + args = append(args, cmd.Criteria.Format()...) + + return &imap.Command{ + Name: "SEARCH", + Arguments: args, + } +} + +func (cmd *Search) Parse(fields []interface{}) error { + if len(fields) == 0 { + return errors.New("Missing search criteria") + } + + // Parse charset + if f, ok := fields[0].(string); ok && strings.EqualFold(f, "CHARSET") { + if len(fields) < 2 { + return errors.New("Missing CHARSET value") + } + if cmd.Charset, ok = fields[1].(string); !ok { + return errors.New("Charset must be a string") + } + fields = fields[2:] + } + + var charsetReader func(io.Reader) io.Reader + charset := strings.ToLower(cmd.Charset) + if charset != "utf-8" && charset != "us-ascii" && charset != "" { + charsetReader = func(r io.Reader) io.Reader { + r, _ = imap.CharsetReader(charset, r) + return r + } + } + + cmd.Criteria = new(imap.SearchCriteria) + return cmd.Criteria.ParseWithCharset(fields, charsetReader) +} diff --git a/commands/select.go b/commands/select.go new file mode 100644 index 0000000..e881eff --- /dev/null +++ b/commands/select.go @@ -0,0 +1,45 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// Select is a SELECT command, as defined in RFC 3501 section 6.3.1. If ReadOnly +// is set to true, the EXAMINE command will be used instead. +type Select struct { + Mailbox string + ReadOnly bool +} + +func (cmd *Select) Command() *imap.Command { + name := "SELECT" + if cmd.ReadOnly { + name = "EXAMINE" + } + + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + + return &imap.Command{ + Name: name, + Arguments: []interface{}{imap.FormatMailboxName(mailbox)}, + } +} + +func (cmd *Select) Parse(fields []interface{}) error { + if len(fields) < 1 { + return errors.New("No enough arguments") + } + + if mailbox, err := imap.ParseString(fields[0]); err != nil { + return err + } else if mailbox, err := utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } else { + cmd.Mailbox = imap.CanonicalMailboxName(mailbox) + } + + return nil +} diff --git a/commands/starttls.go b/commands/starttls.go new file mode 100644 index 0000000..d900e5e --- /dev/null +++ b/commands/starttls.go @@ -0,0 +1,18 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// StartTLS is a STARTTLS command, as defined in RFC 3501 section 6.2.1. +type StartTLS struct{} + +func (cmd *StartTLS) Command() *imap.Command { + return &imap.Command{ + Name: "STARTTLS", + } +} + +func (cmd *StartTLS) Parse(fields []interface{}) error { + return nil +} diff --git a/commands/status.go b/commands/status.go new file mode 100644 index 0000000..672dce5 --- /dev/null +++ b/commands/status.go @@ -0,0 +1,58 @@ +package commands + +import ( + "errors" + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// Status is a STATUS command, as defined in RFC 3501 section 6.3.10. +type Status struct { + Mailbox string + Items []imap.StatusItem +} + +func (cmd *Status) Command() *imap.Command { + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + + items := make([]interface{}, len(cmd.Items)) + for i, item := range cmd.Items { + items[i] = imap.RawString(item) + } + + return &imap.Command{ + Name: "STATUS", + Arguments: []interface{}{imap.FormatMailboxName(mailbox), items}, + } +} + +func (cmd *Status) Parse(fields []interface{}) error { + if len(fields) < 2 { + return errors.New("No enough arguments") + } + + if mailbox, err := imap.ParseString(fields[0]); err != nil { + return err + } else if mailbox, err := utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } else { + cmd.Mailbox = imap.CanonicalMailboxName(mailbox) + } + + items, ok := fields[1].([]interface{}) + if !ok { + return errors.New("STATUS command parameter is not a list") + } + cmd.Items = make([]imap.StatusItem, len(items)) + for i, f := range items { + if s, ok := f.(string); !ok { + return errors.New("Got a non-string field in a STATUS command parameter") + } else { + cmd.Items[i] = imap.StatusItem(strings.ToUpper(s)) + } + } + + return nil +} diff --git a/commands/store.go b/commands/store.go new file mode 100644 index 0000000..aeee3e6 --- /dev/null +++ b/commands/store.go @@ -0,0 +1,50 @@ +package commands + +import ( + "errors" + "strings" + + "github.com/emersion/go-imap" +) + +// Store is a STORE command, as defined in RFC 3501 section 6.4.6. +type Store struct { + SeqSet *imap.SeqSet + Item imap.StoreItem + Value interface{} +} + +func (cmd *Store) Command() *imap.Command { + return &imap.Command{ + Name: "STORE", + Arguments: []interface{}{cmd.SeqSet, imap.RawString(cmd.Item), cmd.Value}, + } +} + +func (cmd *Store) Parse(fields []interface{}) error { + if len(fields) < 3 { + return errors.New("No enough arguments") + } + + seqset, ok := fields[0].(string) + if !ok { + return errors.New("Invalid sequence set") + } + var err error + if cmd.SeqSet, err = imap.ParseSeqSet(seqset); err != nil { + return err + } + + if item, ok := fields[1].(string); !ok { + return errors.New("Item name must be a string") + } else { + cmd.Item = imap.StoreItem(strings.ToUpper(item)) + } + + if len(fields[2:]) == 1 { + cmd.Value = fields[2] + } else { + cmd.Value = fields[2:] + } + return nil +} diff --git a/commands/subscribe.go b/commands/subscribe.go new file mode 100644 index 0000000..ef06427 --- /dev/null +++ b/commands/subscribe.go @@ -0,0 +1,63 @@ +package commands + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +// Subscribe is a SUBSCRIBE command, as defined in RFC 3501 section 6.3.6. +type Subscribe struct { + Mailbox string +} + +func (cmd *Subscribe) Command() *imap.Command { + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + + return &imap.Command{ + Name: "SUBSCRIBE", + Arguments: []interface{}{imap.FormatMailboxName(mailbox)}, + } +} + +func (cmd *Subscribe) Parse(fields []interface{}) error { + if len(fields) < 1 { + return errors.New("No enough arguments") + } + + if mailbox, err := imap.ParseString(fields[0]); err != nil { + return err + } else if cmd.Mailbox, err = utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } + return nil +} + +// An UNSUBSCRIBE command. +// See RFC 3501 section 6.3.7 +type Unsubscribe struct { + Mailbox string +} + +func (cmd *Unsubscribe) Command() *imap.Command { + mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) + + return &imap.Command{ + Name: "UNSUBSCRIBE", + Arguments: []interface{}{imap.FormatMailboxName(mailbox)}, + } +} + +func (cmd *Unsubscribe) Parse(fields []interface{}) error { + if len(fields) < 1 { + return errors.New("No enogh arguments") + } + + if mailbox, err := imap.ParseString(fields[0]); err != nil { + return err + } else if cmd.Mailbox, err = utf7.Encoding.NewDecoder().String(mailbox); err != nil { + return err + } + return nil +} diff --git a/commands/uid.go b/commands/uid.go new file mode 100644 index 0000000..59bbe2f --- /dev/null +++ b/commands/uid.go @@ -0,0 +1,44 @@ +package commands + +import ( + "errors" + "strings" + + "github.com/emersion/go-imap" +) + +// Uid is a UID command, as defined in RFC 3501 section 6.4.8. It wraps another +// command (e.g. wrapping a Fetch command will result in a UID FETCH). +type Uid struct { + Cmd imap.Commander +} + +func (cmd *Uid) Command() *imap.Command { + inner := cmd.Cmd.Command() + + args := []interface{}{imap.RawString(inner.Name)} + args = append(args, inner.Arguments...) + + return &imap.Command{ + Name: "UID", + Arguments: args, + } +} + +func (cmd *Uid) Parse(fields []interface{}) error { + if len(fields) < 1 { + return errors.New("No command name specified") + } + + name, ok := fields[0].(string) + if !ok { + return errors.New("Command name must be a string") + } + + cmd.Cmd = &imap.Command{ + Name: strings.ToUpper(name), // Command names are case-insensitive + Arguments: fields[1:], + } + + return nil +} diff --git a/commands/unselect.go b/commands/unselect.go new file mode 100644 index 0000000..da5c63d --- /dev/null +++ b/commands/unselect.go @@ -0,0 +1,17 @@ +package commands + +import ( + "github.com/emersion/go-imap" +) + +// An UNSELECT command. +// See RFC 3691 section 2. +type Unselect struct{} + +func (cmd *Unselect) Command() *imap.Command { + return &imap.Command{Name: "UNSELECT"} +} + +func (cmd *Unselect) Parse(fields []interface{}) error { + return nil +} diff --git a/conn.go b/conn.go new file mode 100644 index 0000000..09ce633 --- /dev/null +++ b/conn.go @@ -0,0 +1,284 @@ +package imap + +import ( + "bufio" + "crypto/tls" + "io" + "net" + "sync" +) + +// A connection state. +// See RFC 3501 section 3. +type ConnState int + +const ( + // In the connecting state, the server has not yet sent a greeting and no + // command can be issued. + ConnectingState = 0 + + // In the not authenticated state, the client MUST supply + // authentication credentials before most commands will be + // permitted. This state is entered when a connection starts + // unless the connection has been pre-authenticated. + NotAuthenticatedState ConnState = 1 << 0 + + // In the authenticated state, the client is authenticated and MUST + // select a mailbox to access before commands that affect messages + // will be permitted. This state is entered when a + // pre-authenticated connection starts, when acceptable + // authentication credentials have been provided, after an error in + // selecting a mailbox, or after a successful CLOSE command. + AuthenticatedState = 1 << 1 + + // In a selected state, a mailbox has been selected to access. + // This state is entered when a mailbox has been successfully + // selected. + SelectedState = AuthenticatedState + 1<<2 + + // In the logout state, the connection is being terminated. This + // state can be entered as a result of a client request (via the + // LOGOUT command) or by unilateral action on the part of either + // the client or server. + LogoutState = 1 << 3 + + // ConnectedState is either NotAuthenticatedState, AuthenticatedState or + // SelectedState. + ConnectedState = NotAuthenticatedState | AuthenticatedState | SelectedState +) + +// A function that upgrades a connection. +// +// This should only be used by libraries implementing an IMAP extension (e.g. +// COMPRESS). +type ConnUpgrader func(conn net.Conn) (net.Conn, error) + +type Waiter struct { + start sync.WaitGroup + end sync.WaitGroup + finished bool +} + +func NewWaiter() *Waiter { + w := &Waiter{finished: false} + w.start.Add(1) + w.end.Add(1) + return w +} + +func (w *Waiter) Wait() { + if !w.finished { + // Signal that we are ready for upgrade to continue. + w.start.Done() + // Wait for upgrade to finish. + w.end.Wait() + w.finished = true + } +} + +func (w *Waiter) WaitReady() { + if !w.finished { + // Wait for reader/writer goroutine to be ready for upgrade. + w.start.Wait() + } +} + +func (w *Waiter) Close() { + if !w.finished { + // Upgrade is finished, close chanel to release reader/writer + w.end.Done() + } +} + +type LockedWriter struct { + lock sync.Mutex + writer io.Writer +} + +// NewLockedWriter - goroutine safe writer. +func NewLockedWriter(w io.Writer) io.Writer { + return &LockedWriter{writer: w} +} + +func (w *LockedWriter) Write(b []byte) (int, error) { + w.lock.Lock() + defer w.lock.Unlock() + return w.writer.Write(b) +} + +type debugWriter struct { + io.Writer + + local io.Writer + remote io.Writer +} + +// NewDebugWriter creates a new io.Writer that will write local network activity +// to local and remote network activity to remote. +func NewDebugWriter(local, remote io.Writer) io.Writer { + return &debugWriter{Writer: local, local: local, remote: remote} +} + +type multiFlusher struct { + flushers []flusher +} + +func (mf *multiFlusher) Flush() error { + for _, f := range mf.flushers { + if err := f.Flush(); err != nil { + return err + } + } + return nil +} + +func newMultiFlusher(flushers ...flusher) flusher { + return &multiFlusher{flushers} +} + +// Underlying connection state information. +type ConnInfo struct { + RemoteAddr net.Addr + LocalAddr net.Addr + + // nil if connection is not using TLS. + TLS *tls.ConnectionState +} + +// An IMAP connection. +type Conn struct { + net.Conn + *Reader + *Writer + + br *bufio.Reader + bw *bufio.Writer + + waiter *Waiter + + // Print all commands and responses to this io.Writer. + debug io.Writer +} + +// NewConn creates a new IMAP connection. +func NewConn(conn net.Conn, r *Reader, w *Writer) *Conn { + c := &Conn{Conn: conn, Reader: r, Writer: w} + + c.init() + return c +} + +func (c *Conn) createWaiter() *Waiter { + // create new waiter each time. + w := NewWaiter() + c.waiter = w + return w +} + +func (c *Conn) init() { + r := io.Reader(c.Conn) + w := io.Writer(c.Conn) + + if c.debug != nil { + localDebug, remoteDebug := c.debug, c.debug + if debug, ok := c.debug.(*debugWriter); ok { + localDebug, remoteDebug = debug.local, debug.remote + } + // If local and remote are the same, then we need a LockedWriter. + if localDebug == remoteDebug { + localDebug = NewLockedWriter(localDebug) + remoteDebug = localDebug + } + + if localDebug != nil { + w = io.MultiWriter(c.Conn, localDebug) + } + if remoteDebug != nil { + r = io.TeeReader(c.Conn, remoteDebug) + } + } + + if c.br == nil { + c.br = bufio.NewReader(r) + c.Reader.reader = c.br + } else { + c.br.Reset(r) + } + + if c.bw == nil { + c.bw = bufio.NewWriter(w) + c.Writer.Writer = c.bw + } else { + c.bw.Reset(w) + } + + if f, ok := c.Conn.(flusher); ok { + c.Writer.Writer = struct { + io.Writer + flusher + }{ + c.bw, + newMultiFlusher(c.bw, f), + } + } +} + +func (c *Conn) Info() *ConnInfo { + info := &ConnInfo{ + RemoteAddr: c.RemoteAddr(), + LocalAddr: c.LocalAddr(), + } + + tlsConn, ok := c.Conn.(*tls.Conn) + if ok { + state := tlsConn.ConnectionState() + info.TLS = &state + } + + return info +} + +// Write implements io.Writer. +func (c *Conn) Write(b []byte) (n int, err error) { + return c.Writer.Write(b) +} + +// Flush writes any buffered data to the underlying connection. +func (c *Conn) Flush() error { + return c.Writer.Flush() +} + +// Upgrade a connection, e.g. wrap an unencrypted connection with an encrypted +// tunnel. +func (c *Conn) Upgrade(upgrader ConnUpgrader) error { + // Block reads and writes during the upgrading process + w := c.createWaiter() + defer w.Close() + + upgraded, err := upgrader(c.Conn) + if err != nil { + return err + } + + c.Conn = upgraded + c.init() + return nil +} + +// Called by reader/writer goroutines to wait for Upgrade to finish +func (c *Conn) Wait() { + c.waiter.Wait() +} + +// Called by Upgrader to wait for reader/writer goroutines to be ready for +// upgrade. +func (c *Conn) WaitReady() { + c.waiter.WaitReady() +} + +// SetDebug defines an io.Writer to which all network activity will be logged. +// If nil is provided, network activity will not be logged. +func (c *Conn) SetDebug(w io.Writer) { + c.debug = w + c.init() +} diff --git a/conn_test.go b/conn_test.go new file mode 100644 index 0000000..135226b --- /dev/null +++ b/conn_test.go @@ -0,0 +1,107 @@ +package imap_test + +import ( + "bytes" + "io" + "net" + "testing" + + "github.com/emersion/go-imap" +) + +func TestNewConn(t *testing.T) { + b := &bytes.Buffer{} + c, s := net.Pipe() + + done := make(chan error) + go (func() { + _, err := io.Copy(b, s) + done <- err + })() + + r := imap.NewReader(nil) + w := imap.NewWriter(nil) + + ic := imap.NewConn(c, r, w) + + sent := []byte("hi") + ic.Write(sent) + ic.Flush() + ic.Close() + + if err := <-done; err != nil { + t.Fatal(err) + } + + s.Close() + + received := b.Bytes() + if string(sent) != string(received) { + t.Errorf("Sent %v but received %v", sent, received) + } +} + +func transform(b []byte) []byte { + bb := make([]byte, len(b)) + + for i, c := range b { + if rune(c) == 'c' { + bb[i] = byte('d') + } else { + bb[i] = c + } + } + + return bb +} + +type upgraded struct { + net.Conn +} + +func (c *upgraded) Write(b []byte) (int, error) { + return c.Conn.Write(transform(b)) +} + +func TestConn_Upgrade(t *testing.T) { + b := &bytes.Buffer{} + c, s := net.Pipe() + + done := make(chan error) + go (func() { + _, err := io.Copy(b, s) + done <- err + })() + + r := imap.NewReader(nil) + w := imap.NewWriter(nil) + + ic := imap.NewConn(c, r, w) + + began := make(chan struct{}) + go ic.Upgrade(func(conn net.Conn) (net.Conn, error) { + began <- struct{}{} + ic.WaitReady() + return &upgraded{conn}, nil + }) + <-began + + ic.Wait() + + sent := []byte("abcd") + expected := transform(sent) + ic.Write(sent) + ic.Flush() + ic.Close() + + if err := <-done; err != nil { + t.Fatal(err) + } + + s.Close() + + received := b.Bytes() + if string(expected) != string(received) { + t.Errorf("Expected %v but received %v", expected, received) + } +} diff --git a/date.go b/date.go new file mode 100644 index 0000000..bf99647 --- /dev/null +++ b/date.go @@ -0,0 +1,71 @@ +package imap + +import ( + "fmt" + "regexp" + "time" +) + +// Date and time layouts. +// Dovecot adds a leading zero to dates: +// https://github.com/dovecot/core/blob/4fbd5c5e113078e72f29465ccc96d44955ceadc2/src/lib-imap/imap-date.c#L166 +// Cyrus adds a leading space to dates: +// https://github.com/cyrusimap/cyrus-imapd/blob/1cb805a3bffbdf829df0964f3b802cdc917e76db/lib/times.c#L543 +// GMail doesn't support leading spaces in dates used in SEARCH commands. +const ( + // Defined in RFC 3501 as date-text on page 83. + DateLayout = "_2-Jan-2006" + // Defined in RFC 3501 as date-time on page 83. + DateTimeLayout = "_2-Jan-2006 15:04:05 -0700" + // Defined in RFC 5322 section 3.3, mentioned as env-date in RFC 3501 page 84. + envelopeDateTimeLayout = "Mon, 02 Jan 2006 15:04:05 -0700" + // Use as an example in RFC 3501 page 54. + searchDateLayout = "2-Jan-2006" +) + +// time.Time with a specific layout. +type ( + Date time.Time + DateTime time.Time + envelopeDateTime time.Time + searchDate time.Time +) + +// Permutations of the layouts defined in RFC 5322, section 3.3. +var envelopeDateTimeLayouts = [...]string{ + envelopeDateTimeLayout, // popular, try it first + "_2 Jan 2006 15:04:05 -0700", + "_2 Jan 2006 15:04:05 MST", + "_2 Jan 2006 15:04 -0700", + "_2 Jan 2006 15:04 MST", + "_2 Jan 06 15:04:05 -0700", + "_2 Jan 06 15:04:05 MST", + "_2 Jan 06 15:04 -0700", + "_2 Jan 06 15:04 MST", + "Mon, _2 Jan 2006 15:04:05 -0700", + "Mon, _2 Jan 2006 15:04:05 MST", + "Mon, _2 Jan 2006 15:04 -0700", + "Mon, _2 Jan 2006 15:04 MST", + "Mon, _2 Jan 06 15:04:05 -0700", + "Mon, _2 Jan 06 15:04:05 MST", + "Mon, _2 Jan 06 15:04 -0700", + "Mon, _2 Jan 06 15:04 MST", +} + +// TODO: this is a blunt way to strip any trailing CFWS (comment). A sharper +// one would strip multiple CFWS, and only if really valid according to +// RFC5322. +var commentRE = regexp.MustCompile(`[ \t]+\(.*\)$`) + +// Try parsing the date based on the layouts defined in RFC 5322, section 3.3. +// Inspired by https://github.com/golang/go/blob/master/src/net/mail/message.go +func parseMessageDateTime(maybeDate string) (time.Time, error) { + maybeDate = commentRE.ReplaceAllString(maybeDate, "") + for _, layout := range envelopeDateTimeLayouts { + parsed, err := time.Parse(layout, maybeDate) + if err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("date %s could not be parsed", maybeDate) +} diff --git a/date_test.go b/date_test.go new file mode 100644 index 0000000..678f44b --- /dev/null +++ b/date_test.go @@ -0,0 +1,95 @@ +package imap + +import ( + "testing" + "time" +) + +var expectedDateTime = time.Date(2009, time.November, 2, 23, 0, 0, 0, time.FixedZone("", -6*60*60)) +var expectedDate = time.Date(2009, time.November, 2, 0, 0, 0, 0, time.FixedZone("", 0)) + +func TestParseMessageDateTime(t *testing.T) { + tests := []struct { + in string + out time.Time + ok bool + }{ + // some permutations + {"2 Nov 2009 23:00 -0600", expectedDateTime, true}, + {"Tue, 2 Nov 2009 23:00:00 -0600", expectedDateTime, true}, + {"Tue, 2 Nov 2009 23:00:00 -0600 (MST)", expectedDateTime, true}, + + // whitespace + {" 2 Nov 2009 23:00 -0600", expectedDateTime, true}, + {"Tue, 2 Nov 2009 23:00:00 -0600", expectedDateTime, true}, + {"Tue, 2 Nov 2009 23:00:00 -0600 (MST)", expectedDateTime, true}, + + // invalid + {"abc10 Nov 2009 23:00 -0600123", expectedDateTime, false}, + {"10.Nov.2009 11:00:00 -9900", expectedDateTime, false}, + } + for _, test := range tests { + out, err := parseMessageDateTime(test.in) + if !test.ok { + if err == nil { + t.Errorf("ParseMessageDateTime(%q) expected error; got %q", test.in, out) + } + } else if err != nil { + t.Errorf("ParseMessageDateTime(%q) expected %q; got %v", test.in, test.out, err) + } else if !out.Equal(test.out) { + t.Errorf("ParseMessageDateTime(%q) expected %q; got %q", test.in, test.out, out) + } + } +} + +func TestParseDateTime(t *testing.T) { + tests := []struct { + in string + out time.Time + ok bool + }{ + {"2-Nov-2009 23:00:00 -0600", expectedDateTime, true}, + + // whitespace + {" 2-Nov-2009 23:00:00 -0600", expectedDateTime, true}, + + // invalid or incorrect + {"10-Nov-2009", time.Time{}, false}, + {"abc10-Nov-2009 23:00:00 -0600123", time.Time{}, false}, + } + for _, test := range tests { + out, err := time.Parse(DateTimeLayout, test.in) + if !test.ok { + if err == nil { + t.Errorf("ParseDateTime(%q) expected error; got %q", test.in, out) + } + } else if err != nil { + t.Errorf("ParseDateTime(%q) expected %q; got %v", test.in, test.out, err) + } else if !out.Equal(test.out) { + t.Errorf("ParseDateTime(%q) expected %q; got %q", test.in, test.out, out) + } + } +} + +func TestParseDate(t *testing.T) { + tests := []struct { + in string + out time.Time + ok bool + }{ + {"2-Nov-2009", expectedDate, true}, + {" 2-Nov-2009", expectedDate, true}, + } + for _, test := range tests { + out, err := time.Parse(DateLayout, test.in) + if !test.ok { + if err == nil { + t.Errorf("ParseDate(%q) expected error; got %q", test.in, out) + } + } else if err != nil { + t.Errorf("ParseDate(%q) expected %q; got %v", test.in, test.out, err) + } else if !out.Equal(test.out) { + t.Errorf("ParseDate(%q) expected %q; got %q", test.in, test.out, out) + } + } +} diff --git a/go-imap-1.zip b/go-imap-1.zip new file mode 100644 index 0000000000000000000000000000000000000000..2fa486ae64df4d0530dbc726178c365793de9991 GIT binary patch literal 150867 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 literal 0 HcmV?d00001 diff --git a/go.mod b/go.mod index 4700a30..9b7f79b 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ -module github.com/emersion/go-imap/v2 +module github.com/emersion/go-imap -go 1.18 +go 1.13 require ( - github.com/emersion/go-message v0.18.1 - github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 + github.com/emersion/go-message v0.15.0 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + golang.org/x/text v0.3.7 ) diff --git a/go.sum b/go.sum index 1a05948..7acaab2 100644 --- a/go.sum +++ b/go.sum @@ -1,35 +1,10 @@ -github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= -github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= -github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= -github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/imap.go b/imap.go index 7b43357..704c1f3 100644 --- a/imap.go +++ b/imap.go @@ -1,105 +1,111 @@ -// Package imap implements IMAP4rev2. -// -// IMAP4rev2 is defined in RFC 9051. -// -// This package contains types and functions common to both the client and -// server. See the imapclient and imapserver sub-packages. +// Package imap implements IMAP4rev1 (RFC 3501). package imap import ( - "fmt" + "errors" "io" + "strings" ) -// ConnState describes the connection state. -// -// See RFC 9051 section 3. -type ConnState int +// A StatusItem is a mailbox status data item that can be retrieved with a +// STATUS command. See RFC 3501 section 6.3.10. +type StatusItem string const ( - ConnStateNone ConnState = iota - ConnStateNotAuthenticated - ConnStateAuthenticated - ConnStateSelected - ConnStateLogout + StatusMessages StatusItem = "MESSAGES" + StatusRecent StatusItem = "RECENT" + StatusUidNext StatusItem = "UIDNEXT" + StatusUidValidity StatusItem = "UIDVALIDITY" + StatusUnseen StatusItem = "UNSEEN" + + StatusAppendLimit StatusItem = "APPENDLIMIT" ) -// String implements fmt.Stringer. -func (state ConnState) String() string { - switch state { - case ConnStateNone: - return "none" - case ConnStateNotAuthenticated: - return "not authenticated" - case ConnStateAuthenticated: - return "authenticated" - case ConnStateSelected: - return "selected" - case ConnStateLogout: - return "logout" +// A FetchItem is a message data item that can be fetched. +type FetchItem string + +// List of items that can be fetched. See RFC 3501 section 6.4.5. +// +// Warning: FetchBody will not return the raw message body, instead it will +// return a subset of FetchBodyStructure. +const ( + // Macros + FetchAll FetchItem = "ALL" + FetchFast FetchItem = "FAST" + FetchFull FetchItem = "FULL" + + // Items + FetchBody FetchItem = "BODY" + FetchBodyStructure FetchItem = "BODYSTRUCTURE" + FetchEnvelope FetchItem = "ENVELOPE" + FetchFlags FetchItem = "FLAGS" + FetchInternalDate FetchItem = "INTERNALDATE" + FetchRFC822 FetchItem = "RFC822" + FetchRFC822Header FetchItem = "RFC822.HEADER" + FetchRFC822Size FetchItem = "RFC822.SIZE" + FetchRFC822Text FetchItem = "RFC822.TEXT" + FetchUid FetchItem = "UID" +) + +// Expand expands the item if it's a macro. +func (item FetchItem) Expand() []FetchItem { + switch item { + case FetchAll: + return []FetchItem{FetchFlags, FetchInternalDate, FetchRFC822Size, FetchEnvelope} + case FetchFast: + return []FetchItem{FetchFlags, FetchInternalDate, FetchRFC822Size} + case FetchFull: + return []FetchItem{FetchFlags, FetchInternalDate, FetchRFC822Size, FetchEnvelope, FetchBody} default: - panic(fmt.Errorf("imap: unknown connection state %v", int(state))) + return []FetchItem{item} } } -// MailboxAttr is a mailbox attribute. -// -// Mailbox attributes are defined in RFC 9051 section 7.3.1. -type MailboxAttr string +// FlagsOp is an operation that will be applied on message flags. +type FlagsOp string const ( - // Base attributes - MailboxAttrNonExistent MailboxAttr = "\\NonExistent" - MailboxAttrNoInferiors MailboxAttr = "\\Noinferiors" - MailboxAttrNoSelect MailboxAttr = "\\Noselect" - MailboxAttrHasChildren MailboxAttr = "\\HasChildren" - MailboxAttrHasNoChildren MailboxAttr = "\\HasNoChildren" - MailboxAttrMarked MailboxAttr = "\\Marked" - MailboxAttrUnmarked MailboxAttr = "\\Unmarked" - MailboxAttrSubscribed MailboxAttr = "\\Subscribed" - MailboxAttrRemote MailboxAttr = "\\Remote" - - // Role (aka. "special-use") attributes - MailboxAttrAll MailboxAttr = "\\All" - MailboxAttrArchive MailboxAttr = "\\Archive" - MailboxAttrDrafts MailboxAttr = "\\Drafts" - MailboxAttrFlagged MailboxAttr = "\\Flagged" - MailboxAttrJunk MailboxAttr = "\\Junk" - MailboxAttrSent MailboxAttr = "\\Sent" - MailboxAttrTrash MailboxAttr = "\\Trash" - MailboxAttrImportant MailboxAttr = "\\Important" // RFC 8457 + // SetFlags replaces existing flags by new ones. + SetFlags FlagsOp = "FLAGS" + // AddFlags adds new flags. + AddFlags = "+FLAGS" + // RemoveFlags removes existing flags. + RemoveFlags = "-FLAGS" ) -// Flag is a message flag. -// -// Message flags are defined in RFC 9051 section 2.3.2. -type Flag string +// silentOp can be appended to a FlagsOp to prevent the operation from +// triggering unilateral message updates. +const silentOp = ".SILENT" -const ( - // System flags - FlagSeen Flag = "\\Seen" - FlagAnswered Flag = "\\Answered" - FlagFlagged Flag = "\\Flagged" - FlagDeleted Flag = "\\Deleted" - FlagDraft Flag = "\\Draft" +// A StoreItem is a message data item that can be updated. +type StoreItem string - // Widely used flags - FlagForwarded Flag = "$Forwarded" - FlagMDNSent Flag = "$MDNSent" // Message Disposition Notification sent - FlagJunk Flag = "$Junk" - FlagNotJunk Flag = "$NotJunk" - FlagPhishing Flag = "$Phishing" - FlagImportant Flag = "$Important" // RFC 8457 - - // Permanent flags - FlagWildcard Flag = "\\*" -) - -// LiteralReader is a reader for IMAP literals. -type LiteralReader interface { - io.Reader - Size() int64 +// FormatFlagsOp returns the StoreItem that executes the flags operation op. +func FormatFlagsOp(op FlagsOp, silent bool) StoreItem { + s := string(op) + if silent { + s += silentOp + } + return StoreItem(s) } -// UID is a message unique identifier. -type UID uint32 +// ParseFlagsOp parses a flags operation from StoreItem. +func ParseFlagsOp(item StoreItem) (op FlagsOp, silent bool, err error) { + itemStr := string(item) + silent = strings.HasSuffix(itemStr, silentOp) + if silent { + itemStr = strings.TrimSuffix(itemStr, silentOp) + } + op = FlagsOp(itemStr) + + if op != SetFlags && op != AddFlags && op != RemoveFlags { + err = errors.New("Unsupported STORE operation") + } + return +} + +// CharsetReader, if non-nil, defines a function to generate charset-conversion +// readers, converting from the provided charset into UTF-8. Charsets are always +// lower-case. utf-8 and us-ascii charsets are handled by default. One of the +// the CharsetReader's result values must be non-nil. +var CharsetReader func(charset string, r io.Reader) (io.Reader, error) diff --git a/internal/acl.go b/internal/acl.go deleted file mode 100644 index 43c0787..0000000 --- a/internal/acl.go +++ /dev/null @@ -1,13 +0,0 @@ -package internal - -import ( - "github.com/emersion/go-imap/v2" -) - -func FormatRights(rm imap.RightModification, rs imap.RightSet) string { - s := "" - if rm != imap.RightModificationReplace { - s = string(rm) - } - return s + string(rs) -} diff --git a/internal/imapnum/numset.go b/internal/imapnum/numset.go deleted file mode 100644 index 25a4f29..0000000 --- a/internal/imapnum/numset.go +++ /dev/null @@ -1,306 +0,0 @@ -package imapnum - -import ( - "fmt" - "strconv" - "strings" -) - -// Range represents a single seq-number or seq-range value (RFC 3501 ABNF). Values -// may be static (e.g. "1", "2:4") or dynamic (e.g. "*", "1:*"). A seq-number is -// represented by setting Start = Stop. Zero is used to represent "*", which is -// safe because seq-number uses nz-number rule. The order of values is always -// Start <= Stop, except when representing "n:*", where Start = n and Stop = 0. -type Range struct { - Start, Stop uint32 -} - -// Contains returns true if the seq-number q is contained in range value s. -// The dynamic value "*" contains only other "*" values, the dynamic range "n:*" -// contains "*" and all numbers >= n. -func (s Range) Contains(q uint32) bool { - if q == 0 { - return s.Stop == 0 // "*" is contained only in "*" and "n:*" - } - return s.Start != 0 && s.Start <= q && (q <= s.Stop || s.Stop == 0) -} - -// Less returns true if s precedes and does not contain seq-number q. -func (s Range) Less(q uint32) bool { - return (s.Stop < q || q == 0) && s.Stop != 0 -} - -// Merge combines range values s and t into a single union if the two -// intersect or one is a superset of the other. The order of s and t does not -// matter. If the values cannot be merged, s is returned unmodified and ok is -// set to false. -func (s Range) Merge(t Range) (union Range, ok bool) { - union = s - if s == t { - return s, true - } - if s.Start != 0 && t.Start != 0 { - // s and t are any combination of "n", "n:m", or "n:*" - if s.Start > t.Start { - s, t = t, s - } - // s starts at or before t, check where it ends - if (s.Stop >= t.Stop && t.Stop != 0) || s.Stop == 0 { - return s, true // s is a superset of t - } - // s is "n" or "n:m", if m == ^uint32(0) then t is "n:*" - if s.Stop+1 >= t.Start || s.Stop == ^uint32(0) { - return Range{s.Start, t.Stop}, true // s intersects or touches t - } - return union, false - } - // exactly one of s and t is "*" - if s.Start == 0 { - if t.Stop == 0 { - return t, true // s is "*", t is "n:*" - } - } else if s.Stop == 0 { - return s, true // s is "n:*", t is "*" - } - return union, false -} - -// String returns range value s as a seq-number or seq-range string. -func (s Range) String() string { - if s.Start == s.Stop { - if s.Start == 0 { - return "*" - } - return strconv.FormatUint(uint64(s.Start), 10) - } - b := strconv.AppendUint(make([]byte, 0, 24), uint64(s.Start), 10) - if s.Stop == 0 { - return string(append(b, ':', '*')) - } - return string(strconv.AppendUint(append(b, ':'), uint64(s.Stop), 10)) -} - -func (s Range) append(nums []uint32) (out []uint32, ok bool) { - if s.Start == 0 || s.Stop == 0 { - return nil, false - } - for n := s.Start; n <= s.Stop; n++ { - nums = append(nums, n) - } - return nums, true -} - -// Set is used to represent a set of message sequence numbers or UIDs (see -// sequence-set ABNF rule). The zero value is an empty set. -type Set []Range - -// AddNum inserts new numbers into the set. The value 0 represents "*". -func (s *Set) AddNum(q ...uint32) { - for _, v := range q { - s.insert(Range{v, v}) - } -} - -// AddRange inserts a new range into the set. -func (s *Set) AddRange(start, stop uint32) { - if (stop < start && stop != 0) || start == 0 { - s.insert(Range{stop, start}) - } else { - s.insert(Range{start, stop}) - } -} - -// AddSet inserts all values from t into s. -func (s *Set) AddSet(t Set) { - for _, v := range t { - s.insert(v) - } -} - -// Dynamic returns true if the set contains "*" or "n:*" values. -func (s Set) Dynamic() bool { - return len(s) > 0 && s[len(s)-1].Stop == 0 -} - -// Contains returns true if the non-zero sequence number or UID q is contained -// in the set. The dynamic range "n:*" contains all q >= n. It is the caller's -// responsibility to handle the special case where q is the maximum UID in the -// mailbox and q < n (i.e. the set cannot match UIDs against "*:n" or "*" since -// it doesn't know what the maximum value is). -func (s Set) Contains(q uint32) bool { - if _, ok := s.search(q); ok { - return q != 0 - } - return false -} - -// Nums returns a slice of all numbers contained in the set. -func (s Set) Nums() (nums []uint32, ok bool) { - for _, v := range s { - nums, ok = v.append(nums) - if !ok { - return nil, false - } - } - return nums, true -} - -// String returns a sorted representation of all contained number values. -func (s Set) String() string { - if len(s) == 0 { - return "" - } - b := make([]byte, 0, 64) - for _, v := range s { - b = append(b, ',') - if v.Start == 0 { - b = append(b, '*') - continue - } - b = strconv.AppendUint(b, uint64(v.Start), 10) - if v.Start != v.Stop { - if v.Stop == 0 { - b = append(b, ':', '*') - continue - } - b = strconv.AppendUint(append(b, ':'), uint64(v.Stop), 10) - } - } - return string(b[1:]) -} - -// insert adds range value v to the set. -func (ptr *Set) insert(v Range) { - s := *ptr - defer func() { - *ptr = s - }() - - i, _ := s.search(v.Start) - merged := false - if i > 0 { - // try merging with the preceding entry (e.g. "1,4".insert(2), i == 1) - s[i-1], merged = s[i-1].Merge(v) - } - if i == len(s) { - // v was either merged with the last entry or needs to be appended - if !merged { - s.insertAt(i, v) - } - return - } else if merged { - i-- - } else if s[i], merged = s[i].Merge(v); !merged { - s.insertAt(i, v) // insert in the middle (e.g. "1,5".insert(3), i == 1) - return - } - // v was merged with s[i], continue trying to merge until the end - for j := i + 1; j < len(s); j++ { - if s[i], merged = s[i].Merge(s[j]); !merged { - if j > i+1 { - // cut out all entries between i and j that were merged - s = append(s[:i+1], s[j:]...) - } - return - } - } - // everything after s[i] was merged - s = s[:i+1] -} - -// insertAt inserts a new range value v at index i, resizing s.Set as needed. -func (ptr *Set) insertAt(i int, v Range) { - s := *ptr - defer func() { - *ptr = s - }() - - if n := len(s); i == n { - // insert at the end - s = append(s, v) - return - } else if n < cap(s) { - // enough space, shift everything at and after i to the right - s = s[:n+1] - copy(s[i+1:], s[i:]) - } else { - // allocate new slice and copy everything, n is at least 1 - set := make([]Range, n+1, n*2) - copy(set, s[:i]) - copy(set[i+1:], s[i:]) - s = set - } - s[i] = v -} - -// search attempts to find the index of the range set value that contains q. -// If no values contain q, the returned index is the position where q should be -// inserted and ok is set to false. -func (s Set) search(q uint32) (i int, ok bool) { - min, max := 0, len(s)-1 - for min < max { - if mid := (min + max) >> 1; s[mid].Less(q) { - min = mid + 1 - } else { - max = mid - } - } - if max < 0 || s[min].Less(q) { - return len(s), false // q is the new largest value - } - return min, s[min].Contains(q) -} - -// errBadNumSet is used to report problems with the format of a number set -// value. -type errBadNumSet string - -func (err errBadNumSet) Error() string { - return fmt.Sprintf("imap: bad number set value %q", string(err)) -} - -// parseNum parses a single seq-number value (non-zero uint32 or "*"). -func parseNum(v string) (uint32, error) { - if n, err := strconv.ParseUint(v, 10, 32); err == nil && v[0] != '0' { - return uint32(n), nil - } else if v == "*" { - return 0, nil - } - return 0, errBadNumSet(v) -} - -// parseNumRange creates a new seq instance by parsing strings in the format -// "n" or "n:m", where n and/or m may be "*". An error is returned for invalid -// values. -func parseNumRange(v string) (Range, error) { - var ( - r Range - err error - ) - if sep := strings.IndexRune(v, ':'); sep < 0 { - r.Start, err = parseNum(v) - r.Stop = r.Start - return r, err - } else if r.Start, err = parseNum(v[:sep]); err == nil { - if r.Stop, err = parseNum(v[sep+1:]); err == nil { - if (r.Stop < r.Start && r.Stop != 0) || r.Start == 0 { - r.Start, r.Stop = r.Stop, r.Start - } - return r, nil - } - } - return r, errBadNumSet(v) -} - -// ParseSet returns a new Set after parsing the set string. -func ParseSet(set string) (Set, error) { - var s Set - for _, sv := range strings.Split(set, ",") { - r, err := parseNumRange(sv) - if err != nil { - return s, err - } - s.AddRange(r.Start, r.Stop) - } - return s, nil -} diff --git a/internal/imapwire/decoder.go b/internal/imapwire/decoder.go deleted file mode 100644 index cfd2995..0000000 --- a/internal/imapwire/decoder.go +++ /dev/null @@ -1,654 +0,0 @@ -package imapwire - -import ( - "bufio" - "fmt" - "io" - "strconv" - "strings" - "unicode" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapnum" - "github.com/emersion/go-imap/v2/internal/utf7" -) - -// This limits the max list nesting depth to prevent stack overflow. -const maxListDepth = 1000 - -// IsAtomChar returns true if ch is an ATOM-CHAR. -func IsAtomChar(ch byte) bool { - switch ch { - case '(', ')', '{', ' ', '%', '*', '"', '\\', ']': - return false - default: - return !unicode.IsControl(rune(ch)) - } -} - -// Is non-empty char -func isAStringChar(ch byte) bool { - return IsAtomChar(ch) || ch == ']' -} - -// DecoderExpectError is an error due to the Decoder.Expect family of methods. -type DecoderExpectError struct { - Message string -} - -func (err *DecoderExpectError) Error() string { - return fmt.Sprintf("imapwire: %v", err.Message) -} - -// A Decoder reads IMAP data. -// -// There are multiple families of methods: -// -// - Methods directly named after IMAP grammar elements attempt to decode -// said element, and return false if it's another element. -// - "Expect" methods do the same, but set the decoder error (see Err) on -// failure. -type Decoder struct { - // CheckBufferedLiteralFunc is called when a literal is about to be decoded - // and needs to be fully buffered in memory. - CheckBufferedLiteralFunc func(size int64, nonSync bool) error - // MaxSize defines a maximum number of bytes to be read from the input. - // Literals are ignored. - MaxSize int64 - - r *bufio.Reader - side ConnSide - err error - literal bool - crlf bool - listDepth int - readBytes int64 -} - -// NewDecoder creates a new decoder. -func NewDecoder(r *bufio.Reader, side ConnSide) *Decoder { - return &Decoder{r: r, side: side} -} - -func (dec *Decoder) mustUnreadByte() { - if err := dec.r.UnreadByte(); err != nil { - panic(fmt.Errorf("imapwire: failed to unread byte: %v", err)) - } - dec.readBytes-- -} - -// Err returns the decoder error, if any. -func (dec *Decoder) Err() error { - return dec.err -} - -func (dec *Decoder) returnErr(err error) bool { - if err == nil { - return true - } - if dec.err == nil { - dec.err = err - } - return false -} - -func (dec *Decoder) readByte() (byte, bool) { - if dec.MaxSize > 0 && dec.readBytes > dec.MaxSize { - return 0, dec.returnErr(fmt.Errorf("imapwire: max size exceeded")) - } - dec.crlf = false - if dec.literal { - return 0, dec.returnErr(fmt.Errorf("imapwire: cannot decode while a literal is open")) - } - b, err := dec.r.ReadByte() - if err != nil { - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - return b, dec.returnErr(err) - } - dec.readBytes++ - return b, true -} - -func (dec *Decoder) acceptByte(want byte) bool { - got, ok := dec.readByte() - if !ok { - return false - } else if got != want { - dec.mustUnreadByte() - return false - } - return true -} - -// EOF returns true if end-of-file is reached. -func (dec *Decoder) EOF() bool { - _, err := dec.r.ReadByte() - if err == io.EOF { - return true - } else if err != nil { - return dec.returnErr(err) - } - dec.mustUnreadByte() - return false -} - -// Expect sets the decoder error if ok is false. -func (dec *Decoder) Expect(ok bool, name string) bool { - if !ok { - msg := fmt.Sprintf("expected %v", name) - if dec.r.Buffered() > 0 { - b, _ := dec.r.Peek(1) - msg += fmt.Sprintf(", got %q", b) - } - return dec.returnErr(&DecoderExpectError{Message: msg}) - } - return true -} - -func (dec *Decoder) SP() bool { - if dec.acceptByte(' ') { - // https://github.com/emersion/go-imap/issues/571 - b, ok := dec.readByte() - if !ok { - return false - } - dec.mustUnreadByte() - return b != '\r' && b != '\n' - } - - // Special case: SP is optional if the next field is a parenthesized list - b, ok := dec.readByte() - if !ok { - return false - } - dec.mustUnreadByte() - return b == '(' -} - -func (dec *Decoder) ExpectSP() bool { - return dec.Expect(dec.SP(), "SP") -} - -func (dec *Decoder) CRLF() bool { - dec.acceptByte(' ') // https://github.com/emersion/go-imap/issues/540 - dec.acceptByte('\r') // be liberal in what we receive and accept lone LF - if !dec.acceptByte('\n') { - return false - } - dec.crlf = true - return true -} - -func (dec *Decoder) ExpectCRLF() bool { - return dec.Expect(dec.CRLF(), "CRLF") -} - -func (dec *Decoder) Func(ptr *string, valid func(ch byte) bool) bool { - var sb strings.Builder - for { - b, ok := dec.readByte() - if !ok { - return false - } - - if !valid(b) { - dec.mustUnreadByte() - break - } - - sb.WriteByte(b) - } - if sb.Len() == 0 { - return false - } - *ptr = sb.String() - return true -} - -func (dec *Decoder) Atom(ptr *string) bool { - return dec.Func(ptr, IsAtomChar) -} - -func (dec *Decoder) ExpectAtom(ptr *string) bool { - return dec.Expect(dec.Atom(ptr), "atom") -} - -func (dec *Decoder) ExpectNIL() bool { - var s string - return dec.ExpectAtom(&s) && dec.Expect(s == "NIL", "NIL") -} - -func (dec *Decoder) Special(b byte) bool { - return dec.acceptByte(b) -} - -func (dec *Decoder) ExpectSpecial(b byte) bool { - return dec.Expect(dec.Special(b), fmt.Sprintf("'%v'", string(b))) -} - -func (dec *Decoder) Text(ptr *string) bool { - var sb strings.Builder - for { - b, ok := dec.readByte() - if !ok { - return false - } else if b == '\r' || b == '\n' { - dec.mustUnreadByte() - break - } - sb.WriteByte(b) - } - if sb.Len() == 0 { - return false - } - *ptr = sb.String() - return true -} - -func (dec *Decoder) ExpectText(ptr *string) bool { - return dec.Expect(dec.Text(ptr), "text") -} - -func (dec *Decoder) DiscardUntilByte(untilCh byte) { - for { - ch, ok := dec.readByte() - if !ok { - return - } else if ch == untilCh { - dec.mustUnreadByte() - return - } - } -} - -func (dec *Decoder) DiscardLine() { - if dec.crlf { - return - } - var text string - dec.Text(&text) - dec.CRLF() -} - -func (dec *Decoder) DiscardValue() bool { - var s string - if dec.String(&s) { - return true - } - - isList, err := dec.List(func() error { - if !dec.DiscardValue() { - return dec.Err() - } - return nil - }) - if err != nil { - return false - } else if isList { - return true - } - - if dec.Atom(&s) { - return true - } - - dec.Expect(false, "value") - return false -} - -func (dec *Decoder) numberStr() (s string, ok bool) { - var sb strings.Builder - for { - ch, ok := dec.readByte() - if !ok { - return "", false - } else if ch < '0' || ch > '9' { - dec.mustUnreadByte() - break - } - sb.WriteByte(ch) - } - if sb.Len() == 0 { - return "", false - } - return sb.String(), true -} - -func (dec *Decoder) Number(ptr *uint32) bool { - s, ok := dec.numberStr() - if !ok { - return false - } - v64, err := strconv.ParseUint(s, 10, 32) - if err != nil { - return false // can happen on overflow - } - *ptr = uint32(v64) - return true -} - -func (dec *Decoder) ExpectNumber(ptr *uint32) bool { - return dec.Expect(dec.Number(ptr), "number") -} - -func (dec *Decoder) ExpectBodyFldOctets(ptr *uint32) bool { - // Workaround: some servers incorrectly return "-1" for the body structure - // size. See: - // https://github.com/emersion/go-imap/issues/534 - if dec.acceptByte('-') { - *ptr = 0 - return dec.Expect(dec.acceptByte('1'), "-1 (body-fld-octets workaround)") - } - return dec.ExpectNumber(ptr) -} - -func (dec *Decoder) Number64(ptr *int64) bool { - s, ok := dec.numberStr() - if !ok { - return false - } - v, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return false // can happen on overflow - } - *ptr = v - return true -} - -func (dec *Decoder) ExpectNumber64(ptr *int64) bool { - return dec.Expect(dec.Number64(ptr), "number64") -} - -func (dec *Decoder) ModSeq(ptr *uint64) bool { - s, ok := dec.numberStr() - if !ok { - return false - } - v, err := strconv.ParseUint(s, 10, 64) - if err != nil { - return false // can happen on overflow - } - *ptr = v - return true -} - -func (dec *Decoder) ExpectModSeq(ptr *uint64) bool { - return dec.Expect(dec.ModSeq(ptr), "mod-sequence-value") -} - -func (dec *Decoder) Quoted(ptr *string) bool { - if !dec.Special('"') { - return false - } - var sb strings.Builder - for { - ch, ok := dec.readByte() - if !ok { - return false - } - - if ch == '"' { - break - } - - if ch == '\\' { - ch, ok = dec.readByte() - if !ok { - return false - } - } - - sb.WriteByte(ch) - } - *ptr = sb.String() - return true -} - -func (dec *Decoder) ExpectAString(ptr *string) bool { - if dec.Quoted(ptr) { - return true - } - if dec.Literal(ptr) { - return true - } - // We cannot do dec.Atom(ptr) here because sometimes mailbox names are unquoted, - // and they can contain special characters like `]`. - return dec.Expect(dec.Func(ptr, isAStringChar), "ASTRING-CHAR") -} - -func (dec *Decoder) String(ptr *string) bool { - return dec.Quoted(ptr) || dec.Literal(ptr) -} - -func (dec *Decoder) ExpectString(ptr *string) bool { - return dec.Expect(dec.String(ptr), "string") -} - -func (dec *Decoder) ExpectNString(ptr *string) bool { - var s string - if dec.Atom(&s) { - if !dec.Expect(s == "NIL", "nstring") { - return false - } - *ptr = "" - return true - } - return dec.ExpectString(ptr) -} - -func (dec *Decoder) ExpectNStringReader() (lit *LiteralReader, nonSync, ok bool) { - var s string - if dec.Atom(&s) { - if !dec.Expect(s == "NIL", "nstring") { - return nil, false, false - } - return nil, true, true - } - // TODO: read quoted string as a string instead of buffering - if dec.Quoted(&s) { - return newLiteralReaderFromString(s), true, true - } - if lit, nonSync, ok = dec.LiteralReader(); ok { - return lit, nonSync, true - } else { - return nil, false, dec.Expect(false, "nstring") - } -} - -func (dec *Decoder) List(f func() error) (isList bool, err error) { - if !dec.Special('(') { - return false, nil - } - if dec.Special(')') { - return true, nil - } - - dec.listDepth++ - defer func() { - dec.listDepth-- - }() - - if dec.listDepth >= maxListDepth { - return false, fmt.Errorf("imapwire: exceeded max depth") - } - - for { - if err := f(); err != nil { - return true, err - } - - if dec.Special(')') { - return true, nil - } else if !dec.ExpectSP() { - return true, dec.Err() - } - } -} - -func (dec *Decoder) ExpectList(f func() error) error { - isList, err := dec.List(f) - if err != nil { - return err - } else if !dec.Expect(isList, "(") { - return dec.Err() - } - return nil -} - -func (dec *Decoder) ExpectNList(f func() error) error { - var s string - if dec.Atom(&s) { - if !dec.Expect(s == "NIL", "NIL") { - return dec.Err() - } - return nil - } - return dec.ExpectList(f) -} - -func (dec *Decoder) ExpectMailbox(ptr *string) bool { - var name string - if !dec.ExpectAString(&name) { - return false - } - if strings.EqualFold(name, "INBOX") { - *ptr = "INBOX" - return true - } - name, err := utf7.Decode(name) - if err == nil { - *ptr = name - } - return dec.returnErr(err) -} - -func (dec *Decoder) ExpectUID(ptr *imap.UID) bool { - var num uint32 - if !dec.ExpectNumber(&num) { - return false - } - *ptr = imap.UID(num) - return true -} - -func (dec *Decoder) ExpectNumSet(kind NumKind, ptr *imap.NumSet) bool { - if dec.Special('$') { - *ptr = imap.SearchRes() - return true - } - - var s string - if !dec.Expect(dec.Func(&s, isNumSetChar), "sequence-set") { - return false - } - numSet, err := imapnum.ParseSet(s) - if err != nil { - return dec.returnErr(err) - } - - switch kind { - case NumKindSeq: - *ptr = seqSetFromNumSet(numSet) - case NumKindUID: - *ptr = uidSetFromNumSet(numSet) - } - return true -} - -func (dec *Decoder) ExpectUIDSet(ptr *imap.UIDSet) bool { - var numSet imap.NumSet - ok := dec.ExpectNumSet(NumKindUID, &numSet) - if ok { - *ptr = numSet.(imap.UIDSet) - } - return ok -} - -func isNumSetChar(ch byte) bool { - return ch == '*' || IsAtomChar(ch) -} - -func (dec *Decoder) Literal(ptr *string) bool { - lit, nonSync, ok := dec.LiteralReader() - if !ok { - return false - } - if dec.CheckBufferedLiteralFunc != nil { - if err := dec.CheckBufferedLiteralFunc(lit.Size(), nonSync); err != nil { - lit.cancel() - return false - } - } - var sb strings.Builder - _, err := io.Copy(&sb, lit) - if err == nil { - *ptr = sb.String() - } - return dec.returnErr(err) -} - -func (dec *Decoder) LiteralReader() (lit *LiteralReader, nonSync, ok bool) { - if !dec.Special('{') { - return nil, false, false - } - var size int64 - if !dec.ExpectNumber64(&size) { - return nil, false, false - } - if dec.side == ConnSideServer { - nonSync = dec.acceptByte('+') - } - if !dec.ExpectSpecial('}') || !dec.ExpectCRLF() { - return nil, false, false - } - dec.literal = true - lit = &LiteralReader{ - dec: dec, - size: size, - r: io.LimitReader(dec.r, size), - } - return lit, nonSync, true -} - -func (dec *Decoder) ExpectLiteralReader() (lit *LiteralReader, nonSync bool, err error) { - lit, nonSync, ok := dec.LiteralReader() - if !dec.Expect(ok, "literal") { - return nil, false, dec.Err() - } - return lit, nonSync, nil -} - -type LiteralReader struct { - dec *Decoder - size int64 - r io.Reader -} - -func newLiteralReaderFromString(s string) *LiteralReader { - return &LiteralReader{ - size: int64(len(s)), - r: strings.NewReader(s), - } -} - -func (lit *LiteralReader) Size() int64 { - return lit.size -} - -func (lit *LiteralReader) Read(b []byte) (int, error) { - n, err := lit.r.Read(b) - if err == io.EOF { - lit.cancel() - } - return n, err -} - -func (lit *LiteralReader) cancel() { - if lit.dec == nil { - return - } - lit.dec.literal = false - lit.dec = nil -} diff --git a/internal/imapwire/encoder.go b/internal/imapwire/encoder.go deleted file mode 100644 index b27589a..0000000 --- a/internal/imapwire/encoder.go +++ /dev/null @@ -1,341 +0,0 @@ -package imapwire - -import ( - "bufio" - "fmt" - "io" - "strconv" - "strings" - "unicode" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/utf7" -) - -// An Encoder writes IMAP data. -// -// Most methods don't return an error, instead they defer error handling until -// CRLF is called. These methods return the Encoder so that calls can be -// chained. -type Encoder struct { - // QuotedUTF8 allows raw UTF-8 in quoted strings. This requires IMAP4rev2 - // to be available, or UTF8=ACCEPT to be enabled. - QuotedUTF8 bool - // LiteralMinus enables non-synchronizing literals for short payloads. - // This requires IMAP4rev2 or LITERAL-. This is only meaningful for - // clients. - LiteralMinus bool - // LiteralPlus enables non-synchronizing literals for all payloads. This - // requires LITERAL+. This is only meaningful for clients. - LiteralPlus bool - // NewContinuationRequest creates a new continuation request. This is only - // meaningful for clients. - NewContinuationRequest func() *ContinuationRequest - - w *bufio.Writer - side ConnSide - err error - literal bool -} - -// NewEncoder creates a new encoder. -func NewEncoder(w *bufio.Writer, side ConnSide) *Encoder { - return &Encoder{w: w, side: side} -} - -func (enc *Encoder) setErr(err error) { - if enc.err == nil { - enc.err = err - } -} - -func (enc *Encoder) writeString(s string) *Encoder { - if enc.err != nil { - return enc - } - if enc.literal { - enc.err = fmt.Errorf("imapwire: cannot encode while a literal is open") - return enc - } - if _, err := enc.w.WriteString(s); err != nil { - enc.err = err - } - return enc -} - -// CRLF writes a "\r\n" sequence and flushes the buffered writer. -func (enc *Encoder) CRLF() error { - enc.writeString("\r\n") - if enc.err != nil { - return enc.err - } - return enc.w.Flush() -} - -func (enc *Encoder) Atom(s string) *Encoder { - return enc.writeString(s) -} - -func (enc *Encoder) SP() *Encoder { - return enc.writeString(" ") -} - -func (enc *Encoder) Special(ch byte) *Encoder { - return enc.writeString(string(ch)) -} - -func (enc *Encoder) Quoted(s string) *Encoder { - var sb strings.Builder - sb.Grow(2 + len(s)) - sb.WriteByte('"') - for i := 0; i < len(s); i++ { - ch := s[i] - if ch == '"' || ch == '\\' { - sb.WriteByte('\\') - } - sb.WriteByte(ch) - } - sb.WriteByte('"') - return enc.writeString(sb.String()) -} - -func (enc *Encoder) String(s string) *Encoder { - if !enc.validQuoted(s) { - enc.stringLiteral(s) - return enc - } - return enc.Quoted(s) -} - -func (enc *Encoder) validQuoted(s string) bool { - if len(s) > 4096 { - return false - } - - for i := 0; i < len(s); i++ { - ch := s[i] - - // NUL, CR and LF are never valid - switch ch { - case 0, '\r', '\n': - return false - } - - if !enc.QuotedUTF8 && ch > unicode.MaxASCII { - return false - } - } - return true -} - -func (enc *Encoder) stringLiteral(s string) { - var sync *ContinuationRequest - if enc.side == ConnSideClient && (!enc.LiteralMinus || len(s) > 4096) && !enc.LiteralPlus { - if enc.NewContinuationRequest != nil { - sync = enc.NewContinuationRequest() - } - if sync == nil { - enc.setErr(fmt.Errorf("imapwire: cannot send synchronizing literal")) - return - } - } - wc := enc.Literal(int64(len(s)), sync) - _, writeErr := io.WriteString(wc, s) - closeErr := wc.Close() - if writeErr != nil { - enc.setErr(writeErr) - } else if closeErr != nil { - enc.setErr(closeErr) - } -} - -func (enc *Encoder) Mailbox(name string) *Encoder { - if strings.EqualFold(name, "INBOX") { - return enc.Atom("INBOX") - } else { - if enc.QuotedUTF8 { - name = utf7.Escape(name) - } else { - name = utf7.Encode(name) - } - return enc.String(name) - } -} - -func (enc *Encoder) NumSet(numSet imap.NumSet) *Encoder { - s := numSet.String() - if s == "" { - enc.setErr(fmt.Errorf("imapwire: cannot encode empty sequence set")) - return enc - } - return enc.writeString(s) -} - -func (enc *Encoder) Flag(flag imap.Flag) *Encoder { - if flag != "\\*" && !isValidFlag(string(flag)) { - enc.setErr(fmt.Errorf("imapwire: invalid flag %q", flag)) - return enc - } - return enc.writeString(string(flag)) -} - -func (enc *Encoder) MailboxAttr(attr imap.MailboxAttr) *Encoder { - if !strings.HasPrefix(string(attr), "\\") || !isValidFlag(string(attr)) { - enc.setErr(fmt.Errorf("imapwire: invalid mailbox attribute %q", attr)) - return enc - } - return enc.writeString(string(attr)) -} - -// isValidFlag checks whether the provided string satisfies -// flag-keyword / flag-extension. -func isValidFlag(s string) bool { - for i := 0; i < len(s); i++ { - ch := s[i] - if ch == '\\' { - if i != 0 { - return false - } - } else { - if !IsAtomChar(ch) { - return false - } - } - } - return len(s) > 0 -} - -func (enc *Encoder) Number(v uint32) *Encoder { - return enc.writeString(strconv.FormatUint(uint64(v), 10)) -} - -func (enc *Encoder) Number64(v int64) *Encoder { - // TODO: disallow negative values - return enc.writeString(strconv.FormatInt(v, 10)) -} - -func (enc *Encoder) ModSeq(v uint64) *Encoder { - // TODO: disallow zero values - return enc.writeString(strconv.FormatUint(v, 10)) -} - -// List writes a parenthesized list. -func (enc *Encoder) List(n int, f func(i int)) *Encoder { - enc.Special('(') - for i := 0; i < n; i++ { - if i > 0 { - enc.SP() - } - f(i) - } - enc.Special(')') - return enc -} - -func (enc *Encoder) BeginList() *ListEncoder { - enc.Special('(') - return &ListEncoder{enc: enc} -} - -func (enc *Encoder) NIL() *Encoder { - return enc.Atom("NIL") -} - -func (enc *Encoder) Text(s string) *Encoder { - return enc.writeString(s) -} - -func (enc *Encoder) UID(uid imap.UID) *Encoder { - return enc.Number(uint32(uid)) -} - -// Literal writes a literal. -// -// The caller must write exactly size bytes to the returned writer. -// -// If sync is non-nil, the literal is synchronizing: the encoder will wait for -// nil to be sent to the channel before writing the literal data. If an error -// is sent to the channel, the literal will be cancelled. -func (enc *Encoder) Literal(size int64, sync *ContinuationRequest) io.WriteCloser { - if sync != nil && enc.side == ConnSideServer { - panic("imapwire: sync must be nil on a server-side Encoder.Literal") - } - - // TODO: literal8 - enc.writeString("{") - enc.Number64(size) - if sync == nil && enc.side == ConnSideClient { - enc.writeString("+") - } - enc.writeString("}") - - if sync == nil { - enc.writeString("\r\n") - } else { - if err := enc.CRLF(); err != nil { - return errorWriter{err} - } - if _, err := sync.Wait(); err != nil { - enc.setErr(err) - return errorWriter{err} - } - } - - enc.literal = true - return &literalWriter{ - enc: enc, - n: size, - } -} - -type errorWriter struct { - err error -} - -func (ew errorWriter) Write(b []byte) (int, error) { - return 0, ew.err -} - -func (ew errorWriter) Close() error { - return ew.err -} - -type literalWriter struct { - enc *Encoder - n int64 -} - -func (lw *literalWriter) Write(b []byte) (int, error) { - if lw.n-int64(len(b)) < 0 { - return 0, fmt.Errorf("wrote too many bytes in literal") - } - n, err := lw.enc.w.Write(b) - lw.n -= int64(n) - return n, err -} - -func (lw *literalWriter) Close() error { - lw.enc.literal = false - if lw.n != 0 { - return fmt.Errorf("wrote too few bytes in literal (%v remaining)", lw.n) - } - return nil -} - -type ListEncoder struct { - enc *Encoder - n int -} - -func (le *ListEncoder) Item() *Encoder { - if le.n > 0 { - le.enc.SP() - } - le.n++ - return le.enc -} - -func (le *ListEncoder) End() { - le.enc.Special(')') - le.enc = nil -} diff --git a/internal/imapwire/imapwire.go b/internal/imapwire/imapwire.go deleted file mode 100644 index 716d1c2..0000000 --- a/internal/imapwire/imapwire.go +++ /dev/null @@ -1,47 +0,0 @@ -// Package imapwire implements the IMAP wire protocol. -// -// The IMAP wire protocol is defined in RFC 9051 section 4. -package imapwire - -import ( - "fmt" -) - -// ConnSide describes the side of a connection: client or server. -type ConnSide int - -const ( - ConnSideClient ConnSide = 1 + iota - ConnSideServer -) - -// ContinuationRequest is a continuation request. -// -// The sender must call either Done or Cancel. The receiver must call Wait. -type ContinuationRequest struct { - done chan struct{} - err error - text string -} - -func NewContinuationRequest() *ContinuationRequest { - return &ContinuationRequest{done: make(chan struct{})} -} - -func (cont *ContinuationRequest) Cancel(err error) { - if err == nil { - err = fmt.Errorf("imapwire: continuation request cancelled") - } - cont.err = err - close(cont.done) -} - -func (cont *ContinuationRequest) Done(text string) { - cont.text = text - close(cont.done) -} - -func (cont *ContinuationRequest) Wait() (string, error) { - <-cont.done - return cont.text, cont.err -} diff --git a/internal/imapwire/num.go b/internal/imapwire/num.go deleted file mode 100644 index 270afe1..0000000 --- a/internal/imapwire/num.go +++ /dev/null @@ -1,39 +0,0 @@ -package imapwire - -import ( - "unsafe" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapnum" -) - -type NumKind int - -const ( - NumKindSeq NumKind = iota + 1 - NumKindUID -) - -func seqSetFromNumSet(s imapnum.Set) imap.SeqSet { - return *(*imap.SeqSet)(unsafe.Pointer(&s)) -} - -func uidSetFromNumSet(s imapnum.Set) imap.UIDSet { - return *(*imap.UIDSet)(unsafe.Pointer(&s)) -} - -func NumSetKind(numSet imap.NumSet) NumKind { - switch numSet.(type) { - case imap.SeqSet: - return NumKindSeq - case imap.UIDSet: - return NumKindUID - default: - panic("imap: invalid NumSet type") - } -} - -func ParseSeqSet(s string) (imap.SeqSet, error) { - numSet, err := imapnum.ParseSet(s) - return seqSetFromNumSet(numSet), err -} diff --git a/internal/internal.go b/internal/internal.go deleted file mode 100644 index 7053d83..0000000 --- a/internal/internal.go +++ /dev/null @@ -1,170 +0,0 @@ -package internal - -import ( - "fmt" - "strings" - "sync" - "time" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -const ( - DateTimeLayout = "_2-Jan-2006 15:04:05 -0700" - DateLayout = "2-Jan-2006" -) - -const FlagRecent imap.Flag = "\\Recent" // removed in IMAP4rev2 - -func DecodeDateTime(dec *imapwire.Decoder) (time.Time, error) { - var s string - if !dec.Quoted(&s) { - return time.Time{}, nil - } - t, err := time.Parse(DateTimeLayout, s) - if err != nil { - return time.Time{}, fmt.Errorf("in date-time: %v", err) // TODO: use imapwire.DecodeExpectError? - } - return t, err -} - -func ExpectDateTime(dec *imapwire.Decoder) (time.Time, error) { - t, err := DecodeDateTime(dec) - if err != nil { - return t, err - } - if !dec.Expect(!t.IsZero(), "date-time") { - return t, dec.Err() - } - return t, nil -} - -func ExpectDate(dec *imapwire.Decoder) (time.Time, error) { - var s string - if !dec.ExpectAString(&s) { - return time.Time{}, dec.Err() - } - t, err := time.Parse(DateLayout, s) - if err != nil { - return time.Time{}, fmt.Errorf("in date: %v", err) // use imapwire.DecodeExpectError? - } - return t, nil -} - -func ExpectFlagList(dec *imapwire.Decoder) ([]imap.Flag, error) { - var flags []imap.Flag - err := dec.ExpectList(func() error { - // Some servers start the list with a space, so we need to skip it - // https://github.com/emersion/go-imap/pull/633 - dec.SP() - - flag, err := ExpectFlag(dec) - if err != nil { - return err - } - flags = append(flags, flag) - return nil - }) - return flags, err -} - -func ExpectFlag(dec *imapwire.Decoder) (imap.Flag, error) { - isSystem := dec.Special('\\') - if isSystem && dec.Special('*') { - return imap.FlagWildcard, nil // flag-perm - } - var name string - if !dec.ExpectAtom(&name) { - return "", fmt.Errorf("in flag: %w", dec.Err()) - } - if isSystem { - name = "\\" + name - } - return canonicalFlag(name), nil -} - -func ExpectMailboxAttrList(dec *imapwire.Decoder) ([]imap.MailboxAttr, error) { - var attrs []imap.MailboxAttr - err := dec.ExpectList(func() error { - attr, err := ExpectMailboxAttr(dec) - if err != nil { - return err - } - attrs = append(attrs, attr) - return nil - }) - return attrs, err -} - -func ExpectMailboxAttr(dec *imapwire.Decoder) (imap.MailboxAttr, error) { - flag, err := ExpectFlag(dec) - return canonicalMailboxAttr(string(flag)), err -} - -var ( - canonOnce sync.Once - canonFlag map[string]imap.Flag - canonMailboxAttr map[string]imap.MailboxAttr -) - -func canonInit() { - flags := []imap.Flag{ - imap.FlagSeen, - imap.FlagAnswered, - imap.FlagFlagged, - imap.FlagDeleted, - imap.FlagDraft, - imap.FlagForwarded, - imap.FlagMDNSent, - imap.FlagJunk, - imap.FlagNotJunk, - imap.FlagPhishing, - imap.FlagImportant, - } - mailboxAttrs := []imap.MailboxAttr{ - imap.MailboxAttrNonExistent, - imap.MailboxAttrNoInferiors, - imap.MailboxAttrNoSelect, - imap.MailboxAttrHasChildren, - imap.MailboxAttrHasNoChildren, - imap.MailboxAttrMarked, - imap.MailboxAttrUnmarked, - imap.MailboxAttrSubscribed, - imap.MailboxAttrRemote, - imap.MailboxAttrAll, - imap.MailboxAttrArchive, - imap.MailboxAttrDrafts, - imap.MailboxAttrFlagged, - imap.MailboxAttrJunk, - imap.MailboxAttrSent, - imap.MailboxAttrTrash, - imap.MailboxAttrImportant, - } - - canonFlag = make(map[string]imap.Flag) - for _, flag := range flags { - canonFlag[strings.ToLower(string(flag))] = flag - } - - canonMailboxAttr = make(map[string]imap.MailboxAttr) - for _, attr := range mailboxAttrs { - canonMailboxAttr[strings.ToLower(string(attr))] = attr - } -} - -func canonicalFlag(s string) imap.Flag { - canonOnce.Do(canonInit) - if flag, ok := canonFlag[strings.ToLower(s)]; ok { - return flag - } - return imap.Flag(s) -} - -func canonicalMailboxAttr(s string) imap.MailboxAttr { - canonOnce.Do(canonInit) - if attr, ok := canonMailboxAttr[strings.ToLower(s)]; ok { - return attr - } - return imap.MailboxAttr(s) -} diff --git a/internal/sasl.go b/internal/sasl.go deleted file mode 100644 index 85d9f3d..0000000 --- a/internal/sasl.go +++ /dev/null @@ -1,23 +0,0 @@ -package internal - -import ( - "encoding/base64" -) - -func EncodeSASL(b []byte) string { - if len(b) == 0 { - return "=" - } else { - return base64.StdEncoding.EncodeToString(b) - } -} - -func DecodeSASL(s string) ([]byte, error) { - if s == "=" { - // go-sasl treats nil as no challenge/response, so return a non-nil - // empty byte slice - return []byte{}, nil - } else { - return base64.StdEncoding.DecodeString(s) - } -} diff --git a/internal/testcert.go b/internal/testcert.go new file mode 100644 index 0000000..9757659 --- /dev/null +++ b/internal/testcert.go @@ -0,0 +1,37 @@ +package internal + +// LocalhostCert is a PEM-encoded TLS cert with SAN IPs +// "127.0.0.1" and "[::1]", expiring at Jan 29 16:00:00 2084 GMT. +// generated from src/crypto/tls: +// go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example.com --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h +var LocalhostCert = []byte(`-----BEGIN CERTIFICATE----- +MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zANBgkqhkiG9w0BAQsFADAS +MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw +MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB +iQKBgQDuLnQAI3mDgey3VBzWnB2L39JUU4txjeVE6myuDqkM/uGlfjb9SjY1bIw4 +iA5sBBZzHi3z0h1YV8QPuxEbi4nW91IJm2gsvvZhIrCHS3l6afab4pZBl2+XsDul +rKBxKKtD1rGxlG4LjncdabFn9gvLZad2bSysqz/qTAUStTvqJQIDAQABo2gwZjAO +BgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUw +AwEB/zAuBgNVHREEJzAlggtleGFtcGxlLmNvbYcEfwAAAYcQAAAAAAAAAAAAAAAA +AAAAATANBgkqhkiG9w0BAQsFAAOBgQCEcetwO59EWk7WiJsG4x8SY+UIAA+flUI9 +tyC4lNhbcF2Idq9greZwbYCqTTTr2XiRNSMLCOjKyI7ukPoPjo16ocHj+P3vZGfs +h1fIw3cSS2OolhloGw/XM6RWPWtPAlGykKLciQrBru5NAPvCMsb/I1DAceTiotQM +fblo6RBxUQ== +-----END CERTIFICATE-----`) + +// LocalhostKey is the private key for localhostCert. +var LocalhostKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQDuLnQAI3mDgey3VBzWnB2L39JUU4txjeVE6myuDqkM/uGlfjb9 +SjY1bIw4iA5sBBZzHi3z0h1YV8QPuxEbi4nW91IJm2gsvvZhIrCHS3l6afab4pZB +l2+XsDulrKBxKKtD1rGxlG4LjncdabFn9gvLZad2bSysqz/qTAUStTvqJQIDAQAB +AoGAGRzwwir7XvBOAy5tM/uV6e+Zf6anZzus1s1Y1ClbjbE6HXbnWWF/wbZGOpet +3Zm4vD6MXc7jpTLryzTQIvVdfQbRc6+MUVeLKwZatTXtdZrhu+Jk7hx0nTPy8Jcb +uJqFk541aEw+mMogY/xEcfbWd6IOkp+4xqjlFLBEDytgbIECQQDvH/E6nk+hgN4H +qzzVtxxr397vWrjrIgPbJpQvBsafG7b0dA4AFjwVbFLmQcj2PprIMmPcQrooz8vp +jy4SHEg1AkEA/v13/5M47K9vCxmb8QeD/asydfsgS5TeuNi8DoUBEmiSJwma7FXY +fFUtxuvL7XvjwjN5B30pNEbc6Iuyt7y4MQJBAIt21su4b3sjXNueLKH85Q+phy2U +fQtuUE9txblTu14q3N7gHRZB4ZMhFYyDy8CKrN2cPg/Fvyt0Xlp/DoCzjA0CQQDU +y2ptGsuSmgUtWj3NM9xuwYPm+Z/F84K6+ARYiZ6PYj013sovGKUFfYAqVXVlxtIX +qyUBnu3X9ps8ZfjLZO7BAkEAlT4R5Yl6cGhaJQYZHOde3JEMhNRcVFMO8dJDaFeo +f9Oeos0UUothgiDktdQHxdNEwLjQf7lJJBzV+5OtwswCWA== +-----END RSA PRIVATE KEY-----`) diff --git a/internal/testutil.go b/internal/testutil.go new file mode 100644 index 0000000..56e355d --- /dev/null +++ b/internal/testutil.go @@ -0,0 +1,16 @@ +package internal + +type MapListSorter []interface{} + +func (s MapListSorter) Len() int { + return len(s) / 2 +} + +func (s MapListSorter) Less(i, j int) bool { + return s[i*2].(string) < s[j*2].(string) +} + +func (s MapListSorter) Swap(i, j int) { + s[i*2], s[j*2] = s[j*2], s[i*2] + s[i*2+1], s[j*2+1] = s[j*2+1], s[i*2+1] +} diff --git a/internal/utf7/utf7.go b/internal/utf7/utf7.go deleted file mode 100644 index 3ff09a9..0000000 --- a/internal/utf7/utf7.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package utf7 implements modified UTF-7 encoding defined in RFC 3501 section 5.1.3 -package utf7 - -import ( - "encoding/base64" -) - -const ( - min = 0x20 // Minimum self-representing UTF-7 value - max = 0x7E // Maximum self-representing UTF-7 value -) - -var b64Enc = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,") diff --git a/literal.go b/literal.go new file mode 100644 index 0000000..b5b7f55 --- /dev/null +++ b/literal.go @@ -0,0 +1,13 @@ +package imap + +import ( + "io" +) + +// A literal, as defined in RFC 3501 section 4.3. +type Literal interface { + io.Reader + + // Len returns the number of bytes of the literal. + Len() int +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..fa96cdc --- /dev/null +++ b/logger.go @@ -0,0 +1,8 @@ +package imap + +// Logger is the behaviour used by server/client to +// report errors for accepting connections and unexpected behavior from handlers. +type Logger interface { + Printf(format string, v ...interface{}) + Println(v ...interface{}) +} diff --git a/mailbox.go b/mailbox.go new file mode 100644 index 0000000..8f12d4d --- /dev/null +++ b/mailbox.go @@ -0,0 +1,314 @@ +package imap + +import ( + "errors" + "fmt" + "strings" + "sync" + + "github.com/emersion/go-imap/utf7" +) + +// The primary mailbox, as defined in RFC 3501 section 5.1. +const InboxName = "INBOX" + +// CanonicalMailboxName returns the canonical form of a mailbox name. Mailbox names can be +// case-sensitive or case-insensitive depending on the backend implementation. +// The special INBOX mailbox is case-insensitive. +func CanonicalMailboxName(name string) string { + if strings.EqualFold(name, InboxName) { + return InboxName + } + return name +} + +// Mailbox attributes definied in RFC 3501 section 7.2.2. +const ( + // It is not possible for any child levels of hierarchy to exist under this\ + // name; no child levels exist now and none can be created in the future. + NoInferiorsAttr = "\\Noinferiors" + // It is not possible to use this name as a selectable mailbox. + NoSelectAttr = "\\Noselect" + // The mailbox has been marked "interesting" by the server; the mailbox + // probably contains messages that have been added since the last time the + // mailbox was selected. + MarkedAttr = "\\Marked" + // The mailbox does not contain any additional messages since the last time + // the mailbox was selected. + UnmarkedAttr = "\\Unmarked" +) + +// Mailbox attributes defined in RFC 6154 section 2 (SPECIAL-USE extension). +const ( + // This mailbox presents all messages in the user's message store. + AllAttr = "\\All" + // This mailbox is used to archive messages. + ArchiveAttr = "\\Archive" + // This mailbox is used to hold draft messages -- typically, messages that are + // being composed but have not yet been sent. + DraftsAttr = "\\Drafts" + // This mailbox presents all messages marked in some way as "important". + FlaggedAttr = "\\Flagged" + // This mailbox is where messages deemed to be junk mail are held. + JunkAttr = "\\Junk" + // This mailbox is used to hold copies of messages that have been sent. + SentAttr = "\\Sent" + // This mailbox is used to hold messages that have been deleted or marked for + // deletion. + TrashAttr = "\\Trash" +) + +// Mailbox attributes defined in RFC 3348 (CHILDREN extension) +const ( + // The presence of this attribute indicates that the mailbox has child + // mailboxes. + HasChildrenAttr = "\\HasChildren" + // The presence of this attribute indicates that the mailbox has no child + // mailboxes. + HasNoChildrenAttr = "\\HasNoChildren" +) + +// This mailbox attribute is a signal that the mailbox contains messages that +// are likely important to the user. This attribute is defined in RFC 8457 +// section 3. +const ImportantAttr = "\\Important" + +// Basic mailbox info. +type MailboxInfo struct { + // The mailbox attributes. + Attributes []string + // The server's path separator. + Delimiter string + // The mailbox name. + Name string +} + +// Parse mailbox info from fields. +func (info *MailboxInfo) Parse(fields []interface{}) error { + if len(fields) < 3 { + return errors.New("Mailbox info needs at least 3 fields") + } + + var err error + if info.Attributes, err = ParseStringList(fields[0]); err != nil { + return err + } + + var ok bool + if info.Delimiter, ok = fields[1].(string); !ok { + // The delimiter may be specified as NIL, which gets converted to a nil interface. + if fields[1] != nil { + return errors.New("Mailbox delimiter must be a string") + } + info.Delimiter = "" + } + + if name, err := ParseString(fields[2]); err != nil { + return err + } else if name, err := utf7.Encoding.NewDecoder().String(name); err != nil { + return err + } else { + info.Name = CanonicalMailboxName(name) + } + + return nil +} + +// Format mailbox info to fields. +func (info *MailboxInfo) Format() []interface{} { + name, _ := utf7.Encoding.NewEncoder().String(info.Name) + attrs := make([]interface{}, len(info.Attributes)) + for i, attr := range info.Attributes { + attrs[i] = RawString(attr) + } + + // If the delimiter is NIL, we need to treat it specially by inserting + // a nil field (so that it's later converted to an unquoted NIL atom). + var del interface{} + + if info.Delimiter != "" { + del = info.Delimiter + } + + // Thunderbird doesn't understand delimiters if not quoted + return []interface{}{attrs, del, FormatMailboxName(name)} +} + +// TODO: optimize this +func (info *MailboxInfo) match(name, pattern string) bool { + i := strings.IndexAny(pattern, "*%") + if i == -1 { + // No more wildcards + return name == pattern + } + + // Get parts before and after wildcard + chunk, wildcard, rest := pattern[0:i], pattern[i], pattern[i+1:] + + // Check that name begins with chunk + if len(chunk) > 0 && !strings.HasPrefix(name, chunk) { + return false + } + name = strings.TrimPrefix(name, chunk) + + // Expand wildcard + var j int + for j = 0; j < len(name); j++ { + if wildcard == '%' && string(name[j]) == info.Delimiter { + break // Stop on delimiter if wildcard is % + } + // Try to match the rest from here + if info.match(name[j:], rest) { + return true + } + } + + return info.match(name[j:], rest) +} + +// Match checks if a reference and a pattern matches this mailbox name, as +// defined in RFC 3501 section 6.3.8. +func (info *MailboxInfo) Match(reference, pattern string) bool { + name := info.Name + + if info.Delimiter != "" && strings.HasPrefix(pattern, info.Delimiter) { + reference = "" + pattern = strings.TrimPrefix(pattern, info.Delimiter) + } + if reference != "" { + if info.Delimiter != "" && !strings.HasSuffix(reference, info.Delimiter) { + reference += info.Delimiter + } + if !strings.HasPrefix(name, reference) { + return false + } + name = strings.TrimPrefix(name, reference) + } + + return info.match(name, pattern) +} + +// A mailbox status. +type MailboxStatus struct { + // The mailbox name. + Name string + // True if the mailbox is open in read-only mode. + ReadOnly bool + // The mailbox items that are currently filled in. This map's values + // should not be used directly, they must only be used by libraries + // implementing extensions of the IMAP protocol. + Items map[StatusItem]interface{} + + // The Items map may be accessed in different goroutines. Protect + // concurrent writes. + ItemsLocker sync.Mutex + + // The mailbox flags. + Flags []string + // The mailbox permanent flags. + PermanentFlags []string + // The sequence number of the first unseen message in the mailbox. + UnseenSeqNum uint32 + + // The number of messages in this mailbox. + Messages uint32 + // The number of messages not seen since the last time the mailbox was opened. + Recent uint32 + // The number of unread messages. + Unseen uint32 + // The next UID. + UidNext uint32 + // Together with a UID, it is a unique identifier for a message. + // Must be greater than or equal to 1. + UidValidity uint32 + + // Per-mailbox limit of message size. Set only if server supports the + // APPENDLIMIT extension. + AppendLimit uint32 +} + +// Create a new mailbox status that will contain the specified items. +func NewMailboxStatus(name string, items []StatusItem) *MailboxStatus { + status := &MailboxStatus{ + Name: name, + Items: make(map[StatusItem]interface{}), + } + + for _, k := range items { + status.Items[k] = nil + } + + return status +} + +func (status *MailboxStatus) Parse(fields []interface{}) error { + status.Items = make(map[StatusItem]interface{}) + + var k StatusItem + for i, f := range fields { + if i%2 == 0 { + if kstr, ok := f.(string); !ok { + return fmt.Errorf("cannot parse mailbox status: key is not a string, but a %T", f) + } else { + k = StatusItem(strings.ToUpper(kstr)) + } + } else { + status.Items[k] = nil + + var err error + switch k { + case StatusMessages: + status.Messages, err = ParseNumber(f) + case StatusRecent: + status.Recent, err = ParseNumber(f) + case StatusUnseen: + status.Unseen, err = ParseNumber(f) + case StatusUidNext: + status.UidNext, err = ParseNumber(f) + case StatusUidValidity: + status.UidValidity, err = ParseNumber(f) + case StatusAppendLimit: + status.AppendLimit, err = ParseNumber(f) + default: + status.Items[k] = f + } + + if err != nil { + return err + } + } + } + + return nil +} + +func (status *MailboxStatus) Format() []interface{} { + var fields []interface{} + for k, v := range status.Items { + switch k { + case StatusMessages: + v = status.Messages + case StatusRecent: + v = status.Recent + case StatusUnseen: + v = status.Unseen + case StatusUidNext: + v = status.UidNext + case StatusUidValidity: + v = status.UidValidity + case StatusAppendLimit: + v = status.AppendLimit + } + + fields = append(fields, RawString(k), v) + } + return fields +} + +func FormatMailboxName(name string) interface{} { + // Some e-mails servers don't handle quoted INBOX names correctly so we special-case it. + if strings.EqualFold(name, "INBOX") { + return RawString(name) + } + return name +} diff --git a/mailbox_test.go b/mailbox_test.go new file mode 100644 index 0000000..5d5eb70 --- /dev/null +++ b/mailbox_test.go @@ -0,0 +1,191 @@ +package imap_test + +import ( + "fmt" + "reflect" + "sort" + "testing" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/internal" +) + +func TestCanonicalMailboxName(t *testing.T) { + if got := imap.CanonicalMailboxName("Inbox"); got != imap.InboxName { + t.Errorf("Invalid canonical mailbox name: expected %q but got %q", imap.InboxName, got) + } + if got := imap.CanonicalMailboxName("Drafts"); got != "Drafts" { + t.Errorf("Invalid canonical mailbox name: expected %q but got %q", "Drafts", got) + } +} + +var mailboxInfoTests = []struct { + fields []interface{} + info *imap.MailboxInfo +}{ + { + fields: []interface{}{ + []interface{}{"\\Noselect", "\\Recent", "\\Unseen"}, + "/", + "INBOX", + }, + info: &imap.MailboxInfo{ + Attributes: []string{"\\Noselect", "\\Recent", "\\Unseen"}, + Delimiter: "/", + Name: "INBOX", + }, + }, +} + +func TestMailboxInfo_Parse(t *testing.T) { + for _, test := range mailboxInfoTests { + info := &imap.MailboxInfo{} + if err := info.Parse(test.fields); err != nil { + t.Fatal(err) + } + + if fmt.Sprint(info.Attributes) != fmt.Sprint(test.info.Attributes) { + t.Fatal("Invalid flags:", info.Attributes) + } + if info.Delimiter != test.info.Delimiter { + t.Fatal("Invalid delimiter:", info.Delimiter) + } + if info.Name != test.info.Name { + t.Fatal("Invalid name:", info.Name) + } + } +} + +func TestMailboxInfo_Format(t *testing.T) { + for _, test := range mailboxInfoTests { + fields := test.info.Format() + + if fmt.Sprint(fields) != fmt.Sprint(test.fields) { + t.Fatal("Invalid fields:", fields) + } + } +} + +var mailboxInfoMatchTests = []struct { + name, ref, pattern string + result bool +}{ + {name: "INBOX", pattern: "INBOX", result: true}, + {name: "INBOX", pattern: "Asuka", result: false}, + {name: "INBOX", pattern: "*", result: true}, + {name: "INBOX", pattern: "%", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "*", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "%", result: false}, + {name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/*", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/%", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "Neo* Evangelion/Misato", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "Neo% Evangelion/Misato", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "*Eva*/Misato", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "%Eva%/Misato", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "*X*/Misato", result: false}, + {name: "Neon Genesis Evangelion/Misato", pattern: "%X%/Misato", result: false}, + {name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/Mi%o", result: true}, + {name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/Mi%too", result: false}, + {name: "Misato/Misato", pattern: "Mis*to/Misato", result: true}, + {name: "Misato/Misato", pattern: "Mis*to", result: true}, + {name: "Misato/Misato/Misato", pattern: "Mis*to/Mis%to", result: true}, + {name: "Misato/Misato", pattern: "Mis**to/Misato", result: true}, + {name: "Misato/Misato", pattern: "Misat%/Misato", result: true}, + {name: "Misato/Misato", pattern: "Misat%Misato", result: false}, + {name: "Misato/Misato", ref: "Misato", pattern: "Misato", result: true}, + {name: "Misato/Misato", ref: "Misato/", pattern: "Misato", result: true}, + {name: "Misato/Misato", ref: "Shinji", pattern: "/Misato/*", result: true}, + {name: "Misato/Misato", ref: "Misato", pattern: "/Misato", result: false}, + {name: "Misato/Misato", ref: "Misato", pattern: "Shinji", result: false}, + {name: "Misato/Misato", ref: "Shinji", pattern: "Misato", result: false}, +} + +func TestMailboxInfo_Match(t *testing.T) { + for _, test := range mailboxInfoMatchTests { + info := &imap.MailboxInfo{Name: test.name, Delimiter: "/"} + result := info.Match(test.ref, test.pattern) + if result != test.result { + t.Errorf("Matching name %q with pattern %q and reference %q returns %v, but expected %v", test.name, test.pattern, test.ref, result, test.result) + } + } +} + +func TestNewMailboxStatus(t *testing.T) { + status := imap.NewMailboxStatus("INBOX", []imap.StatusItem{imap.StatusMessages, imap.StatusUnseen}) + + expected := &imap.MailboxStatus{ + Name: "INBOX", + Items: map[imap.StatusItem]interface{}{imap.StatusMessages: nil, imap.StatusUnseen: nil}, + } + + if !reflect.DeepEqual(expected, status) { + t.Errorf("Invalid mailbox status: expected \n%+v\n but got \n%+v", expected, status) + } +} + +var mailboxStatusTests = [...]struct { + fields []interface{} + status *imap.MailboxStatus +}{ + { + fields: []interface{}{ + "MESSAGES", uint32(42), + "RECENT", uint32(1), + "UNSEEN", uint32(6), + "UIDNEXT", uint32(65536), + "UIDVALIDITY", uint32(4242), + }, + status: &imap.MailboxStatus{ + Items: map[imap.StatusItem]interface{}{ + imap.StatusMessages: nil, + imap.StatusRecent: nil, + imap.StatusUnseen: nil, + imap.StatusUidNext: nil, + imap.StatusUidValidity: nil, + }, + Messages: 42, + Recent: 1, + Unseen: 6, + UidNext: 65536, + UidValidity: 4242, + }, + }, +} + +func TestMailboxStatus_Parse(t *testing.T) { + for i, test := range mailboxStatusTests { + status := &imap.MailboxStatus{} + if err := status.Parse(test.fields); err != nil { + t.Errorf("Expected no error while parsing mailbox status #%v, got: %v", i, err) + continue + } + + if !reflect.DeepEqual(status, test.status) { + t.Errorf("Invalid parsed mailbox status for #%v: got \n%+v\n but expected \n%+v", i, status, test.status) + } + } +} + +func TestMailboxStatus_Format(t *testing.T) { + for i, test := range mailboxStatusTests { + fields := test.status.Format() + + // MapListSorter does not know about RawString and will panic. + stringFields := make([]interface{}, 0, len(fields)) + for _, field := range fields { + if s, ok := field.(imap.RawString); ok { + stringFields = append(stringFields, string(s)) + } else { + stringFields = append(stringFields, field) + } + } + + sort.Sort(internal.MapListSorter(stringFields)) + + sort.Sort(internal.MapListSorter(test.fields)) + + if !reflect.DeepEqual(stringFields, test.fields) { + t.Errorf("Invalid mailbox status fields for #%v: got \n%+v\n but expected \n%+v", i, fields, test.fields) + } + } +} diff --git a/message.go b/message.go new file mode 100644 index 0000000..bd28325 --- /dev/null +++ b/message.go @@ -0,0 +1,1186 @@ +package imap + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime" + "strconv" + "strings" + "time" +) + +// System message flags, defined in RFC 3501 section 2.3.2. +const ( + SeenFlag = "\\Seen" + AnsweredFlag = "\\Answered" + FlaggedFlag = "\\Flagged" + DeletedFlag = "\\Deleted" + DraftFlag = "\\Draft" + RecentFlag = "\\Recent" +) + +// ImportantFlag is a message flag to signal that a message is likely important +// to the user. This flag is defined in RFC 8457 section 2. +const ImportantFlag = "$Important" + +// TryCreateFlag is a special flag in MailboxStatus.PermanentFlags indicating +// that it is possible to create new keywords by attempting to store those +// flags in the mailbox. +const TryCreateFlag = "\\*" + +var flags = []string{ + SeenFlag, + AnsweredFlag, + FlaggedFlag, + DeletedFlag, + DraftFlag, + RecentFlag, +} + +// A PartSpecifier specifies which parts of the MIME entity should be returned. +type PartSpecifier string + +// Part specifiers described in RFC 3501 page 55. +const ( + // Refers to the entire part, including headers. + EntireSpecifier PartSpecifier = "" + // Refers to the header of the part. Must include the final CRLF delimiting + // the header and the body. + HeaderSpecifier = "HEADER" + // Refers to the text body of the part, omitting the header. + TextSpecifier = "TEXT" + // Refers to the MIME Internet Message Body header. Must include the final + // CRLF delimiting the header and the body. + MIMESpecifier = "MIME" +) + +// CanonicalFlag returns the canonical form of a flag. Flags are case-insensitive. +// +// If the flag is defined in RFC 3501, it returns the flag with the case of the +// RFC. Otherwise, it returns the lowercase version of the flag. +func CanonicalFlag(flag string) string { + for _, f := range flags { + if strings.EqualFold(f, flag) { + return f + } + } + return strings.ToLower(flag) +} + +func ParseParamList(fields []interface{}) (map[string]string, error) { + params := make(map[string]string) + + var k string + for i, f := range fields { + p, err := ParseString(f) + if err != nil { + return nil, errors.New("Parameter list contains a non-string: " + err.Error()) + } + + if i%2 == 0 { + k = p + } else { + params[k] = p + k = "" + } + } + + if k != "" { + return nil, errors.New("Parameter list contains a key without a value") + } + return params, nil +} + +func FormatParamList(params map[string]string) []interface{} { + var fields []interface{} + for key, value := range params { + fields = append(fields, key, value) + } + return fields +} + +var wordDecoder = &mime.WordDecoder{ + CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { + if CharsetReader != nil { + return CharsetReader(charset, input) + } + return nil, fmt.Errorf("imap: unhandled charset %q", charset) + }, +} + +func decodeHeader(s string) (string, error) { + dec, err := wordDecoder.DecodeHeader(s) + if err != nil { + return s, err + } + return dec, nil +} + +func encodeHeader(s string) string { + return mime.QEncoding.Encode("utf-8", s) +} + +func stringLowered(i interface{}) (string, bool) { + s, ok := i.(string) + return strings.ToLower(s), ok +} + +func parseHeaderParamList(fields []interface{}) (map[string]string, error) { + params, err := ParseParamList(fields) + if err != nil { + return nil, err + } + + for k, v := range params { + if lower := strings.ToLower(k); lower != k { + delete(params, k) + k = lower + } + + params[k], _ = decodeHeader(v) + } + + return params, nil +} + +func formatHeaderParamList(params map[string]string) []interface{} { + encoded := make(map[string]string) + for k, v := range params { + encoded[k] = encodeHeader(v) + } + return FormatParamList(encoded) +} + +// A message. +type Message struct { + // The message sequence number. It must be greater than or equal to 1. + SeqNum uint32 + // The mailbox items that are currently filled in. This map's values + // should not be used directly, they must only be used by libraries + // implementing extensions of the IMAP protocol. + Items map[FetchItem]interface{} + + // The message envelope. + Envelope *Envelope + // The message body structure (either BODYSTRUCTURE or BODY). + BodyStructure *BodyStructure + // The message flags. + Flags []string + // The date the message was received by the server. + InternalDate time.Time + // The message size. + Size uint32 + // The message unique identifier. It must be greater than or equal to 1. + Uid uint32 + // The message body sections. + Body map[*BodySectionName]Literal + + // The order in which items were requested. This order must be preserved + // because some bad IMAP clients (looking at you, Outlook!) refuse responses + // containing items in a different order. + itemsOrder []FetchItem +} + +// Create a new empty message that will contain the specified items. +func NewMessage(seqNum uint32, items []FetchItem) *Message { + msg := &Message{ + SeqNum: seqNum, + Items: make(map[FetchItem]interface{}), + Body: make(map[*BodySectionName]Literal), + itemsOrder: items, + } + + for _, k := range items { + msg.Items[k] = nil + } + + return msg +} + +// Parse a message from fields. +func (m *Message) Parse(fields []interface{}) error { + m.Items = make(map[FetchItem]interface{}) + m.Body = map[*BodySectionName]Literal{} + m.itemsOrder = nil + + var k FetchItem + for i, f := range fields { + if i%2 == 0 { // It's a key + switch f := f.(type) { + case string: + k = FetchItem(strings.ToUpper(f)) + case RawString: + k = FetchItem(strings.ToUpper(string(f))) + default: + return fmt.Errorf("cannot parse message: key is not a string, but a %T", f) + } + } else { // It's a value + m.Items[k] = nil + m.itemsOrder = append(m.itemsOrder, k) + + switch k { + case FetchBody, FetchBodyStructure: + bs, ok := f.([]interface{}) + if !ok { + return fmt.Errorf("cannot parse message: BODYSTRUCTURE is not a list, but a %T", f) + } + + m.BodyStructure = &BodyStructure{Extended: k == FetchBodyStructure} + if err := m.BodyStructure.Parse(bs); err != nil { + return err + } + case FetchEnvelope: + env, ok := f.([]interface{}) + if !ok { + return fmt.Errorf("cannot parse message: ENVELOPE is not a list, but a %T", f) + } + + m.Envelope = &Envelope{} + if err := m.Envelope.Parse(env); err != nil { + return err + } + case FetchFlags: + flags, ok := f.([]interface{}) + if !ok { + return fmt.Errorf("cannot parse message: FLAGS is not a list, but a %T", f) + } + + m.Flags = make([]string, len(flags)) + for i, flag := range flags { + s, _ := ParseString(flag) + m.Flags[i] = CanonicalFlag(s) + } + case FetchInternalDate: + date, _ := f.(string) + m.InternalDate, _ = time.Parse(DateTimeLayout, date) + case FetchRFC822Size: + m.Size, _ = ParseNumber(f) + case FetchUid: + m.Uid, _ = ParseNumber(f) + default: + // Likely to be a section of the body + // First check that the section name is correct + if section, err := ParseBodySectionName(k); err != nil { + // Not a section name, maybe an attribute defined in an IMAP extension + m.Items[k] = f + } else { + m.Body[section], _ = f.(Literal) + } + } + } + } + + return nil +} + +func (m *Message) formatItem(k FetchItem) []interface{} { + v := m.Items[k] + var kk interface{} = RawString(k) + + switch k { + case FetchBody, FetchBodyStructure: + // Extension data is only returned with the BODYSTRUCTURE fetch + m.BodyStructure.Extended = k == FetchBodyStructure + v = m.BodyStructure.Format() + case FetchEnvelope: + v = m.Envelope.Format() + case FetchFlags: + flags := make([]interface{}, len(m.Flags)) + for i, flag := range m.Flags { + flags[i] = RawString(flag) + } + v = flags + case FetchInternalDate: + v = m.InternalDate + case FetchRFC822Size: + v = m.Size + case FetchUid: + v = m.Uid + default: + for section, literal := range m.Body { + if section.value == k { + // This can contain spaces, so we can't pass it as a string directly + kk = section.resp() + v = literal + break + } + } + } + + return []interface{}{kk, v} +} + +func (m *Message) Format() []interface{} { + var fields []interface{} + + // First send ordered items + processed := make(map[FetchItem]bool) + for _, k := range m.itemsOrder { + if _, ok := m.Items[k]; ok { + fields = append(fields, m.formatItem(k)...) + processed[k] = true + } + } + + // Then send other remaining items + for k := range m.Items { + if !processed[k] { + fields = append(fields, m.formatItem(k)...) + } + } + + return fields +} + +// GetBody gets the body section with the specified name. Returns nil if it's not found. +func (m *Message) GetBody(section *BodySectionName) Literal { + section = section.resp() + + for s, body := range m.Body { + if section.Equal(s) { + if body == nil { + // Server can return nil, we need to treat as empty string per RFC 3501 + body = bytes.NewReader(nil) + } + return body + } + } + return nil +} + +// A body section name. +// See RFC 3501 page 55. +type BodySectionName struct { + BodyPartName + + // If set to true, do not implicitly set the \Seen flag. + Peek bool + // The substring of the section requested. The first value is the position of + // the first desired octet and the second value is the maximum number of + // octets desired. + Partial []int + + value FetchItem +} + +func (section *BodySectionName) parse(s string) error { + section.value = FetchItem(s) + + if s == "RFC822" { + s = "BODY[]" + } + if s == "RFC822.HEADER" { + s = "BODY.PEEK[HEADER]" + } + if s == "RFC822.TEXT" { + s = "BODY[TEXT]" + } + + partStart := strings.Index(s, "[") + if partStart == -1 { + return errors.New("Invalid body section name: must contain an open bracket") + } + + partEnd := strings.LastIndex(s, "]") + if partEnd == -1 { + return errors.New("Invalid body section name: must contain a close bracket") + } + + name := s[:partStart] + part := s[partStart+1 : partEnd] + partial := s[partEnd+1:] + + if name == "BODY.PEEK" { + section.Peek = true + } else if name != "BODY" { + return errors.New("Invalid body section name") + } + + b := bytes.NewBufferString(part + string(cr) + string(lf)) + r := NewReader(b) + fields, err := r.ReadFields() + if err != nil { + return err + } + + if err := section.BodyPartName.parse(fields); err != nil { + return err + } + + if len(partial) > 0 { + if !strings.HasPrefix(partial, "<") || !strings.HasSuffix(partial, ">") { + return errors.New("Invalid body section name: invalid partial") + } + partial = partial[1 : len(partial)-1] + + partialParts := strings.SplitN(partial, ".", 2) + + var from, length int + if from, err = strconv.Atoi(partialParts[0]); err != nil { + return errors.New("Invalid body section name: invalid partial: invalid from: " + err.Error()) + } + section.Partial = []int{from} + + if len(partialParts) == 2 { + if length, err = strconv.Atoi(partialParts[1]); err != nil { + return errors.New("Invalid body section name: invalid partial: invalid length: " + err.Error()) + } + section.Partial = append(section.Partial, length) + } + } + + return nil +} + +func (section *BodySectionName) FetchItem() FetchItem { + if section.value != "" { + return section.value + } + + s := "BODY" + if section.Peek { + s += ".PEEK" + } + + s += "[" + section.BodyPartName.string() + "]" + + if len(section.Partial) > 0 { + s += "<" + s += strconv.Itoa(section.Partial[0]) + + if len(section.Partial) > 1 { + s += "." + s += strconv.Itoa(section.Partial[1]) + } + + s += ">" + } + + return FetchItem(s) +} + +// Equal checks whether two sections are equal. +func (section *BodySectionName) Equal(other *BodySectionName) bool { + if section.Peek != other.Peek { + return false + } + if len(section.Partial) != len(other.Partial) { + return false + } + if len(section.Partial) > 0 && section.Partial[0] != other.Partial[0] { + return false + } + if len(section.Partial) > 1 && section.Partial[1] != other.Partial[1] { + return false + } + return section.BodyPartName.Equal(&other.BodyPartName) +} + +func (section *BodySectionName) resp() *BodySectionName { + resp := *section // Copy section + if resp.Peek { + resp.Peek = false + } + if len(resp.Partial) == 2 { + resp.Partial = []int{resp.Partial[0]} + } + if !strings.HasPrefix(string(resp.value), string(FetchRFC822)) { + resp.value = "" + } + return &resp +} + +// ExtractPartial returns a subset of the specified bytes matching the partial requested in the +// section name. +func (section *BodySectionName) ExtractPartial(b []byte) []byte { + if len(section.Partial) != 2 { + return b + } + + from := section.Partial[0] + length := section.Partial[1] + to := from + length + if from > len(b) { + return nil + } + if to > len(b) { + to = len(b) + } + return b[from:to] +} + +// ParseBodySectionName parses a body section name. +func ParseBodySectionName(s FetchItem) (*BodySectionName, error) { + section := new(BodySectionName) + err := section.parse(string(s)) + return section, err +} + +// A body part name. +type BodyPartName struct { + // The specifier of the requested part. + Specifier PartSpecifier + // The part path. Parts indexes start at 1. + Path []int + // If Specifier is HEADER, contains header fields that will/won't be returned, + // depending of the value of NotFields. + Fields []string + // If set to true, Fields is a blacklist of fields instead of a whitelist. + NotFields bool +} + +func (part *BodyPartName) parse(fields []interface{}) error { + if len(fields) == 0 { + return nil + } + + name, ok := fields[0].(string) + if !ok { + return errors.New("Invalid body section name: part name must be a string") + } + + args := fields[1:] + + path := strings.Split(strings.ToUpper(name), ".") + + end := 0 +loop: + for i, node := range path { + switch PartSpecifier(node) { + case EntireSpecifier, HeaderSpecifier, MIMESpecifier, TextSpecifier: + part.Specifier = PartSpecifier(node) + end = i + 1 + break loop + } + + index, err := strconv.Atoi(node) + if err != nil { + return errors.New("Invalid body part name: " + err.Error()) + } + if index <= 0 { + return errors.New("Invalid body part name: index <= 0") + } + + part.Path = append(part.Path, index) + } + + if part.Specifier == HeaderSpecifier && len(path) > end && path[end] == "FIELDS" && len(args) > 0 { + end++ + if len(path) > end && path[end] == "NOT" { + part.NotFields = true + } + + names, ok := args[0].([]interface{}) + if !ok { + return errors.New("Invalid body part name: HEADER.FIELDS must have a list argument") + } + + for _, namei := range names { + if name, ok := namei.(string); ok { + part.Fields = append(part.Fields, name) + } + } + } + + return nil +} + +func (part *BodyPartName) string() string { + path := make([]string, len(part.Path)) + for i, index := range part.Path { + path[i] = strconv.Itoa(index) + } + + if part.Specifier != EntireSpecifier { + path = append(path, string(part.Specifier)) + } + + if part.Specifier == HeaderSpecifier && len(part.Fields) > 0 { + path = append(path, "FIELDS") + + if part.NotFields { + path = append(path, "NOT") + } + } + + s := strings.Join(path, ".") + + if len(part.Fields) > 0 { + s += " (" + strings.Join(part.Fields, " ") + ")" + } + + return s +} + +// Equal checks whether two body part names are equal. +func (part *BodyPartName) Equal(other *BodyPartName) bool { + if part.Specifier != other.Specifier { + return false + } + if part.NotFields != other.NotFields { + return false + } + if len(part.Path) != len(other.Path) { + return false + } + for i, node := range part.Path { + if node != other.Path[i] { + return false + } + } + if len(part.Fields) != len(other.Fields) { + return false + } + for _, field := range part.Fields { + found := false + for _, f := range other.Fields { + if strings.EqualFold(field, f) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// An address. +type Address struct { + // The personal name. + PersonalName string + // The SMTP at-domain-list (source route). + AtDomainList string + // The mailbox name. + MailboxName string + // The host name. + HostName string +} + +// Address returns the mailbox address (e.g. "foo@example.org"). +func (addr *Address) Address() string { + return addr.MailboxName + "@" + addr.HostName +} + +// Parse an address from fields. +func (addr *Address) Parse(fields []interface{}) error { + if len(fields) < 4 { + return errors.New("Address doesn't contain 4 fields") + } + + if s, err := ParseString(fields[0]); err == nil { + addr.PersonalName, _ = decodeHeader(s) + } + if s, err := ParseString(fields[1]); err == nil { + addr.AtDomainList, _ = decodeHeader(s) + } + + s, err := ParseString(fields[2]) + if err != nil { + return errors.New("Mailbox name could not be parsed") + } + addr.MailboxName, _ = decodeHeader(s) + + s, err = ParseString(fields[3]) + if err != nil { + return errors.New("Host name could not be parsed") + } + addr.HostName, _ = decodeHeader(s) + + return nil +} + +// Format an address to fields. +func (addr *Address) Format() []interface{} { + fields := make([]interface{}, 4) + + if addr.PersonalName != "" { + fields[0] = encodeHeader(addr.PersonalName) + } + if addr.AtDomainList != "" { + fields[1] = addr.AtDomainList + } + if addr.MailboxName != "" { + fields[2] = addr.MailboxName + } + if addr.HostName != "" { + fields[3] = addr.HostName + } + + return fields +} + +// Parse an address list from fields. +func ParseAddressList(fields []interface{}) (addrs []*Address) { + for _, f := range fields { + if addrFields, ok := f.([]interface{}); ok { + addr := &Address{} + if err := addr.Parse(addrFields); err == nil { + addrs = append(addrs, addr) + } + } + } + + return +} + +// Format an address list to fields. +func FormatAddressList(addrs []*Address) interface{} { + if len(addrs) == 0 { + return nil + } + + fields := make([]interface{}, len(addrs)) + + for i, addr := range addrs { + fields[i] = addr.Format() + } + + return fields +} + +// A message envelope, ie. message metadata from its headers. +// See RFC 3501 page 77. +type Envelope struct { + // The message date. + Date time.Time + // The message subject. + Subject string + // The From header addresses. + From []*Address + // The message senders. + Sender []*Address + // The Reply-To header addresses. + ReplyTo []*Address + // The To header addresses. + To []*Address + // The Cc header addresses. + Cc []*Address + // The Bcc header addresses. + Bcc []*Address + // The In-Reply-To header. Contains the parent Message-Id. + InReplyTo string + // The Message-Id header. + MessageId string +} + +// Parse an envelope from fields. +func (e *Envelope) Parse(fields []interface{}) error { + if len(fields) < 10 { + return errors.New("ENVELOPE doesn't contain 10 fields") + } + + if date, ok := fields[0].(string); ok { + e.Date, _ = parseMessageDateTime(date) + } + if subject, err := ParseString(fields[1]); err == nil { + e.Subject, _ = decodeHeader(subject) + } + if from, ok := fields[2].([]interface{}); ok { + e.From = ParseAddressList(from) + } + if sender, ok := fields[3].([]interface{}); ok { + e.Sender = ParseAddressList(sender) + } + if replyTo, ok := fields[4].([]interface{}); ok { + e.ReplyTo = ParseAddressList(replyTo) + } + if to, ok := fields[5].([]interface{}); ok { + e.To = ParseAddressList(to) + } + if cc, ok := fields[6].([]interface{}); ok { + e.Cc = ParseAddressList(cc) + } + if bcc, ok := fields[7].([]interface{}); ok { + e.Bcc = ParseAddressList(bcc) + } + if inReplyTo, ok := fields[8].(string); ok { + e.InReplyTo = inReplyTo + } + if msgId, ok := fields[9].(string); ok { + e.MessageId = msgId + } + + return nil +} + +// Format an envelope to fields. +func (e *Envelope) Format() (fields []interface{}) { + fields = make([]interface{}, 0, 10) + fields = append(fields, envelopeDateTime(e.Date)) + if e.Subject != "" { + fields = append(fields, encodeHeader(e.Subject)) + } else { + fields = append(fields, nil) + } + fields = append(fields, + FormatAddressList(e.From), + FormatAddressList(e.Sender), + FormatAddressList(e.ReplyTo), + FormatAddressList(e.To), + FormatAddressList(e.Cc), + FormatAddressList(e.Bcc), + ) + if e.InReplyTo != "" { + fields = append(fields, e.InReplyTo) + } else { + fields = append(fields, nil) + } + if e.MessageId != "" { + fields = append(fields, e.MessageId) + } else { + fields = append(fields, nil) + } + return fields +} + +// A body structure. +// See RFC 3501 page 74. +type BodyStructure struct { + // Basic fields + + // The MIME type (e.g. "text", "image") + MIMEType string + // The MIME subtype (e.g. "plain", "png") + MIMESubType string + // The MIME parameters. + Params map[string]string + + // The Content-Id header. + Id string + // The Content-Description header. + Description string + // The Content-Encoding header. + Encoding string + // The Content-Length header. + Size uint32 + + // Type-specific fields + + // The children parts, if multipart. + Parts []*BodyStructure + // The envelope, if message/rfc822. + Envelope *Envelope + // The body structure, if message/rfc822. + BodyStructure *BodyStructure + // The number of lines, if text or message/rfc822. + Lines uint32 + + // Extension data + + // True if the body structure contains extension data. + Extended bool + + // The Content-Disposition header field value. + Disposition string + // The Content-Disposition header field parameters. + DispositionParams map[string]string + // The Content-Language header field, if multipart. + Language []string + // The content URI, if multipart. + Location []string + + // The MD5 checksum. + MD5 string +} + +func (bs *BodyStructure) Parse(fields []interface{}) error { + if len(fields) == 0 { + return nil + } + + // Initialize params map + bs.Params = make(map[string]string) + + switch fields[0].(type) { + case []interface{}: // A multipart body part + bs.MIMEType = "multipart" + + end := 0 + for i, fi := range fields { + switch f := fi.(type) { + case []interface{}: // A part + part := new(BodyStructure) + if err := part.Parse(f); err != nil { + return err + } + bs.Parts = append(bs.Parts, part) + case string: + end = i + } + + if end > 0 { + break + } + } + + bs.MIMESubType, _ = fields[end].(string) + end++ + + // GMail seems to return only 3 extension data fields. Parse as many fields + // as we can. + if len(fields) > end { + bs.Extended = true // Contains extension data + + params, _ := fields[end].([]interface{}) + bs.Params, _ = parseHeaderParamList(params) + end++ + } + if len(fields) > end { + if disp, ok := fields[end].([]interface{}); ok && len(disp) >= 2 { + if s, ok := disp[0].(string); ok { + bs.Disposition, _ = decodeHeader(s) + bs.Disposition = strings.ToLower(bs.Disposition) + } + if params, ok := disp[1].([]interface{}); ok { + bs.DispositionParams, _ = parseHeaderParamList(params) + } + } + end++ + } + if len(fields) > end { + switch langs := fields[end].(type) { + case string: + bs.Language = []string{langs} + case []interface{}: + bs.Language, _ = ParseStringList(langs) + default: + bs.Language = nil + } + end++ + } + if len(fields) > end { + location, _ := fields[end].([]interface{}) + bs.Location, _ = ParseStringList(location) + end++ + } + case string: // A non-multipart body part + if len(fields) < 7 { + return errors.New("Non-multipart body part doesn't have 7 fields") + } + + bs.MIMEType, _ = stringLowered(fields[0]) + bs.MIMESubType, _ = stringLowered(fields[1]) + + params, _ := fields[2].([]interface{}) + bs.Params, _ = parseHeaderParamList(params) + + bs.Id, _ = fields[3].(string) + if desc, err := ParseString(fields[4]); err == nil { + bs.Description, _ = decodeHeader(desc) + } + bs.Encoding, _ = stringLowered(fields[5]) + bs.Size, _ = ParseNumber(fields[6]) + + end := 7 + + // Type-specific fields + if strings.EqualFold(bs.MIMEType, "message") && strings.EqualFold(bs.MIMESubType, "rfc822") { + if len(fields)-end < 3 { + return errors.New("Missing type-specific fields for message/rfc822") + } + + envelope, _ := fields[end].([]interface{}) + bs.Envelope = new(Envelope) + bs.Envelope.Parse(envelope) + + structure, _ := fields[end+1].([]interface{}) + bs.BodyStructure = new(BodyStructure) + bs.BodyStructure.Parse(structure) + + bs.Lines, _ = ParseNumber(fields[end+2]) + + end += 3 + } + if strings.EqualFold(bs.MIMEType, "text") { + if len(fields)-end < 1 { + return errors.New("Missing type-specific fields for text/*") + } + + bs.Lines, _ = ParseNumber(fields[end]) + end++ + } + + // GMail seems to return only 3 extension data fields. Parse as many fields + // as we can. + if len(fields) > end { + bs.Extended = true // Contains extension data + + bs.MD5, _ = fields[end].(string) + end++ + } + if len(fields) > end { + if disp, ok := fields[end].([]interface{}); ok && len(disp) >= 2 { + if s, ok := disp[0].(string); ok { + bs.Disposition, _ = decodeHeader(s) + bs.Disposition = strings.ToLower(bs.Disposition) + } + if params, ok := disp[1].([]interface{}); ok { + bs.DispositionParams, _ = parseHeaderParamList(params) + } + } + end++ + } + if len(fields) > end { + switch langs := fields[end].(type) { + case string: + bs.Language = []string{langs} + case []interface{}: + bs.Language, _ = ParseStringList(langs) + default: + bs.Language = nil + } + end++ + } + if len(fields) > end { + location, _ := fields[end].([]interface{}) + bs.Location, _ = ParseStringList(location) + end++ + } + } + + return nil +} + +func (bs *BodyStructure) Format() (fields []interface{}) { + if strings.EqualFold(bs.MIMEType, "multipart") { + for _, part := range bs.Parts { + fields = append(fields, part.Format()) + } + + fields = append(fields, bs.MIMESubType) + + if bs.Extended { + extended := make([]interface{}, 4) + + if bs.Params != nil { + extended[0] = formatHeaderParamList(bs.Params) + } + if bs.Disposition != "" { + extended[1] = []interface{}{ + encodeHeader(bs.Disposition), + formatHeaderParamList(bs.DispositionParams), + } + } + if bs.Language != nil { + extended[2] = FormatStringList(bs.Language) + } + if bs.Location != nil { + extended[3] = FormatStringList(bs.Location) + } + + fields = append(fields, extended...) + } + } else { + fields = make([]interface{}, 7) + fields[0] = bs.MIMEType + fields[1] = bs.MIMESubType + fields[2] = formatHeaderParamList(bs.Params) + + if bs.Id != "" { + fields[3] = bs.Id + } + if bs.Description != "" { + fields[4] = encodeHeader(bs.Description) + } + if bs.Encoding != "" { + fields[5] = bs.Encoding + } + + fields[6] = bs.Size + + // Type-specific fields + if strings.EqualFold(bs.MIMEType, "message") && strings.EqualFold(bs.MIMESubType, "rfc822") { + var env interface{} + if bs.Envelope != nil { + env = bs.Envelope.Format() + } + + var bsbs interface{} + if bs.BodyStructure != nil { + bsbs = bs.BodyStructure.Format() + } + + fields = append(fields, env, bsbs, bs.Lines) + } + if strings.EqualFold(bs.MIMEType, "text") { + fields = append(fields, bs.Lines) + } + + // Extension data + if bs.Extended { + extended := make([]interface{}, 4) + + if bs.MD5 != "" { + extended[0] = bs.MD5 + } + if bs.Disposition != "" { + extended[1] = []interface{}{ + encodeHeader(bs.Disposition), + formatHeaderParamList(bs.DispositionParams), + } + } + if bs.Language != nil { + extended[2] = FormatStringList(bs.Language) + } + if bs.Location != nil { + extended[3] = FormatStringList(bs.Location) + } + + fields = append(fields, extended...) + } + } + + return +} + +// Filename parses the body structure's filename, if it's an attachment. An +// empty string is returned if the filename isn't specified. An error is +// returned if and only if a charset error occurs, in which case the undecoded +// filename is returned too. +func (bs *BodyStructure) Filename() (string, error) { + raw, ok := bs.DispositionParams["filename"] + if !ok { + // Using "name" in Content-Type is discouraged + raw = bs.Params["name"] + } + return decodeHeader(raw) +} + +// BodyStructureWalkFunc is the type of the function called for each body +// structure visited by BodyStructure.Walk. The path argument contains the IMAP +// part path (see BodyPartName). +// +// The function should return true to visit all of the part's children or false +// to skip them. +type BodyStructureWalkFunc func(path []int, part *BodyStructure) (walkChildren bool) + +// Walk walks the body structure tree, calling f for each part in the tree, +// including bs itself. The parts are visited in DFS pre-order. +func (bs *BodyStructure) Walk(f BodyStructureWalkFunc) { + // Non-multipart messages only have part 1 + if len(bs.Parts) == 0 { + f([]int{1}, bs) + return + } + + bs.walk(f, nil) +} + +func (bs *BodyStructure) walk(f BodyStructureWalkFunc, path []int) { + if !f(path, bs) { + return + } + + for i, part := range bs.Parts { + num := i + 1 + + partPath := append([]int(nil), path...) + partPath = append(partPath, num) + + part.walk(f, partPath) + } +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..5270d95 --- /dev/null +++ b/message_test.go @@ -0,0 +1,797 @@ +package imap + +import ( + "bytes" + "fmt" + "reflect" + "testing" + "time" +) + +func TestCanonicalFlag(t *testing.T) { + if got := CanonicalFlag("\\SEEN"); got != SeenFlag { + t.Errorf("Invalid canonical flag: expected %q but got %q", SeenFlag, got) + } + + if got := CanonicalFlag("Junk"); got != "junk" { + t.Errorf("Invalid canonical flag: expected %q but got %q", "junk", got) + } +} + +func TestNewMessage(t *testing.T) { + msg := NewMessage(42, []FetchItem{FetchBodyStructure, FetchFlags}) + + expected := &Message{ + SeqNum: 42, + Items: map[FetchItem]interface{}{FetchBodyStructure: nil, FetchFlags: nil}, + Body: make(map[*BodySectionName]Literal), + itemsOrder: []FetchItem{FetchBodyStructure, FetchFlags}, + } + + if !reflect.DeepEqual(expected, msg) { + t.Errorf("Invalid message: expected \n%+v\n but got \n%+v", expected, msg) + } +} + +func formatFields(fields []interface{}) (string, error) { + b := &bytes.Buffer{} + w := NewWriter(b) + + if err := w.writeList(fields); err != nil { + return "", fmt.Errorf("Cannot format \n%+v\n got error: \n%v", fields, err) + } + + return b.String(), nil +} + +var messageTests = []struct { + message *Message + fields []interface{} +}{ + { + message: &Message{ + Items: map[FetchItem]interface{}{ + FetchEnvelope: nil, + FetchBody: nil, + FetchFlags: nil, + FetchRFC822Size: nil, + FetchUid: nil, + }, + Body: map[*BodySectionName]Literal{}, + Envelope: envelopeTests[0].envelope, + BodyStructure: bodyStructureTests[0].bodyStructure, + Flags: []string{SeenFlag, AnsweredFlag}, + Size: 4242, + Uid: 2424, + itemsOrder: []FetchItem{FetchEnvelope, FetchBody, FetchFlags, FetchRFC822Size, FetchUid}, + }, + fields: []interface{}{ + RawString("ENVELOPE"), envelopeTests[0].fields, + RawString("BODY"), bodyStructureTests[0].fields, + RawString("FLAGS"), []interface{}{RawString(SeenFlag), RawString(AnsweredFlag)}, + RawString("RFC822.SIZE"), RawString("4242"), + RawString("UID"), RawString("2424"), + }, + }, +} + +func TestMessage_Parse(t *testing.T) { + for i, test := range messageTests { + m := &Message{} + if err := m.Parse(test.fields); err != nil { + t.Errorf("Cannot parse message for #%v: %v", i, err) + } else if !reflect.DeepEqual(m, test.message) { + t.Errorf("Invalid parsed message for #%v: got \n%+v\n but expected \n%+v", i, m, test.message) + } + } +} + +func TestMessage_Format(t *testing.T) { + for i, test := range messageTests { + fields := test.message.Format() + + got, err := formatFields(fields) + if err != nil { + t.Error(err) + continue + } + + expected, _ := formatFields(test.fields) + + if got != expected { + t.Errorf("Invalid message fields for #%v: got \n%v\n but expected \n%v", i, got, expected) + } + } +} + +var bodySectionNameTests = []struct { + raw string + parsed *BodySectionName + formatted string +}{ + { + raw: "BODY[]", + parsed: &BodySectionName{BodyPartName: BodyPartName{}}, + }, + { + raw: "RFC822", + parsed: &BodySectionName{BodyPartName: BodyPartName{}}, + formatted: "BODY[]", + }, + { + raw: "BODY[HEADER]", + parsed: &BodySectionName{BodyPartName: BodyPartName{Specifier: HeaderSpecifier}}, + }, + { + raw: "BODY.PEEK[]", + parsed: &BodySectionName{BodyPartName: BodyPartName{}, Peek: true}, + }, + { + raw: "BODY[TEXT]", + parsed: &BodySectionName{BodyPartName: BodyPartName{Specifier: TextSpecifier}}, + }, + { + raw: "RFC822.TEXT", + parsed: &BodySectionName{BodyPartName: BodyPartName{Specifier: TextSpecifier}}, + formatted: "BODY[TEXT]", + }, + { + raw: "RFC822.HEADER", + parsed: &BodySectionName{BodyPartName: BodyPartName{Specifier: HeaderSpecifier}, Peek: true}, + formatted: "BODY.PEEK[HEADER]", + }, + { + raw: "BODY[]<0.512>", + parsed: &BodySectionName{BodyPartName: BodyPartName{}, Partial: []int{0, 512}}, + }, + { + raw: "BODY[]<512>", + parsed: &BodySectionName{BodyPartName: BodyPartName{}, Partial: []int{512}}, + }, + { + raw: "BODY[1.2.3]", + parsed: &BodySectionName{BodyPartName: BodyPartName{Path: []int{1, 2, 3}}}, + }, + { + raw: "BODY[1.2.3.HEADER]", + parsed: &BodySectionName{BodyPartName: BodyPartName{Specifier: HeaderSpecifier, Path: []int{1, 2, 3}}}, + }, + { + raw: "BODY[5.MIME]", + parsed: &BodySectionName{BodyPartName: BodyPartName{Specifier: MIMESpecifier, Path: []int{5}}}, + }, + { + raw: "BODY[HEADER.FIELDS (From To)]", + parsed: &BodySectionName{BodyPartName: BodyPartName{Specifier: HeaderSpecifier, Fields: []string{"From", "To"}}}, + }, + { + raw: "BODY[HEADER.FIELDS.NOT (Content-Id)]", + parsed: &BodySectionName{BodyPartName: BodyPartName{Specifier: HeaderSpecifier, Fields: []string{"Content-Id"}, NotFields: true}}, + }, +} + +func TestNewBodySectionName(t *testing.T) { + for i, test := range bodySectionNameTests { + bsn, err := ParseBodySectionName(FetchItem(test.raw)) + if err != nil { + t.Errorf("Cannot parse #%v: %v", i, err) + continue + } + + if !reflect.DeepEqual(bsn.BodyPartName, test.parsed.BodyPartName) { + t.Errorf("Invalid body part name for #%v: %#+v", i, bsn.BodyPartName) + } else if bsn.Peek != test.parsed.Peek { + t.Errorf("Invalid peek value for #%v: %#+v", i, bsn.Peek) + } else if !reflect.DeepEqual(bsn.Partial, test.parsed.Partial) { + t.Errorf("Invalid partial for #%v: %#+v", i, bsn.Partial) + } + } +} + +func TestBodySectionName_String(t *testing.T) { + for i, test := range bodySectionNameTests { + s := string(test.parsed.FetchItem()) + + expected := test.formatted + if expected == "" { + expected = test.raw + } + + if expected != s { + t.Errorf("Invalid body section name for #%v: got %v but expected %v", i, s, expected) + } + } +} + +func TestBodySectionName_ExtractPartial(t *testing.T) { + tests := []struct { + bsn string + whole string + partial string + }{ + { + bsn: "BODY[]", + whole: "Hello World!", + partial: "Hello World!", + }, + { + bsn: "BODY[]<6.5>", + whole: "Hello World!", + partial: "World", + }, + { + bsn: "BODY[]<6.1000>", + whole: "Hello World!", + partial: "World!", + }, + { + bsn: "BODY[]<0.1>", + whole: "Hello World!", + partial: "H", + }, + { + bsn: "BODY[]<1000.2000>", + whole: "Hello World!", + partial: "", + }, + } + + for i, test := range tests { + bsn, err := ParseBodySectionName(FetchItem(test.bsn)) + if err != nil { + t.Errorf("Cannot parse body section name #%v: %v", i, err) + continue + } + + partial := string(bsn.ExtractPartial([]byte(test.whole))) + if partial != test.partial { + t.Errorf("Invalid partial for #%v: got %v but expected %v", i, partial, test.partial) + } + } +} + +var t = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("", -6*60*60)) + +var envelopeTests = []struct { + envelope *Envelope + fields []interface{} +}{ + { + envelope: &Envelope{ + Date: t, + Subject: "Hello World!", + From: []*Address{addrTests[0].addr}, + Sender: nil, + ReplyTo: nil, + To: nil, + Cc: nil, + Bcc: nil, + InReplyTo: "42@example.org", + MessageId: "43@example.org", + }, + fields: []interface{}{ + "Tue, 10 Nov 2009 23:00:00 -0600", + "Hello World!", + []interface{}{addrTests[0].fields}, + nil, + nil, + nil, + nil, + nil, + "42@example.org", + "43@example.org", + }, + }, +} + +func TestEnvelope_Parse(t *testing.T) { + for i, test := range envelopeTests { + e := &Envelope{} + if err := e.Parse(test.fields); err != nil { + t.Error("Error parsing envelope:", err) + } else if !reflect.DeepEqual(e, test.envelope) { + t.Errorf("Invalid envelope for #%v: got %v but expected %v", i, e, test.envelope) + } + } +} + +func TestEnvelope_Parse_literal(t *testing.T) { + subject := "Hello World!" + l := bytes.NewBufferString(subject) + fields := []interface{}{ + "Tue, 10 Nov 2009 23:00:00 -0600", + l, + nil, + nil, + nil, + nil, + nil, + nil, + "42@example.org", + "43@example.org", + } + + e := &Envelope{} + if err := e.Parse(fields); err != nil { + t.Error("Error parsing envelope:", err) + } else if e.Subject != subject { + t.Errorf("Invalid envelope subject: got %v but expected %v", e.Subject, subject) + } +} + +func TestEnvelope_Format(t *testing.T) { + for i, test := range envelopeTests { + fields := test.envelope.Format() + + got, err := formatFields(fields) + if err != nil { + t.Error(err) + continue + } + + expected, _ := formatFields(test.fields) + + if got != expected { + t.Errorf("Invalid envelope fields for #%v: got %v but expected %v", i, got, expected) + } + } +} + +var addrTests = []struct { + fields []interface{} + addr *Address +}{ + { + fields: []interface{}{"The NSA", nil, "root", "nsa.gov"}, + addr: &Address{ + PersonalName: "The NSA", + MailboxName: "root", + HostName: "nsa.gov", + }, + }, +} + +func TestAddress_Parse(t *testing.T) { + for i, test := range addrTests { + addr := &Address{} + + if err := addr.Parse(test.fields); err != nil { + t.Error("Error parsing address:", err) + } else if !reflect.DeepEqual(addr, test.addr) { + t.Errorf("Invalid address for #%v: got %v but expected %v", i, addr, test.addr) + } + } +} + +func TestAddress_Format(t *testing.T) { + for i, test := range addrTests { + fields := test.addr.Format() + if !reflect.DeepEqual(fields, test.fields) { + t.Errorf("Invalid address fields for #%v: got %v but expected %v", i, fields, test.fields) + } + } +} + +func TestEmptyAddress(t *testing.T) { + fields := []interface{}{nil, nil, nil, nil} + addr := &Address{} + err := addr.Parse(fields) + if err == nil { + t.Error("A nil address did not return an error") + } +} + +func TestEmptyGroupAddress(t *testing.T) { + fields := []interface{}{nil, nil, "undisclosed-recipients", nil} + addr := &Address{} + err := addr.Parse(fields) + if err == nil { + t.Error("An empty group did not return an error when parsed as address") + } +} + +func TestAddressList(t *testing.T) { + fields := make([]interface{}, len(addrTests)) + addrs := make([]*Address, len(addrTests)) + for i, test := range addrTests { + fields[i] = test.fields + addrs[i] = test.addr + } + + gotAddrs := ParseAddressList(fields) + if !reflect.DeepEqual(gotAddrs, addrs) { + t.Error("Invalid address list: got", gotAddrs, "but expected", addrs) + } + + gotFields := FormatAddressList(addrs) + if !reflect.DeepEqual(gotFields, fields) { + t.Error("Invalid address list fields: got", gotFields, "but expected", fields) + } +} + +func TestEmptyAddressList(t *testing.T) { + addrs := make([]*Address, 0) + + gotFields := FormatAddressList(addrs) + if !reflect.DeepEqual(gotFields, nil) { + t.Error("Invalid address list fields: got", gotFields, "but expected nil") + } +} + +var paramsListTest = []struct { + fields []interface{} + params map[string]string +}{ + { + fields: nil, + params: map[string]string{}, + }, + { + fields: []interface{}{"a", "b"}, + params: map[string]string{"a": "b"}, + }, +} + +func TestParseParamList(t *testing.T) { + for i, test := range paramsListTest { + if params, err := ParseParamList(test.fields); err != nil { + t.Errorf("Cannot parse params fields for #%v: %v", i, err) + } else if !reflect.DeepEqual(params, test.params) { + t.Errorf("Invalid params for #%v: got %v but expected %v", i, params, test.params) + } + } + + // Malformed params lists + + fields := []interface{}{"cc", []interface{}{"dille"}} + if params, err := ParseParamList(fields); err == nil { + t.Error("Parsed invalid params list:", params) + } + + fields = []interface{}{"cc"} + if params, err := ParseParamList(fields); err == nil { + t.Error("Parsed invalid params list:", params) + } +} + +func TestFormatParamList(t *testing.T) { + for i, test := range paramsListTest { + fields := FormatParamList(test.params) + + if !reflect.DeepEqual(fields, test.fields) { + t.Errorf("Invalid params fields for #%v: got %v but expected %v", i, fields, test.fields) + } + } +} + +var bodyStructureTests = []struct { + fields []interface{} + bodyStructure *BodyStructure +}{ + { + fields: []interface{}{"image", "jpeg", []interface{}{}, "", "A picture of cat", "base64", RawString("4242")}, + bodyStructure: &BodyStructure{ + MIMEType: "image", + MIMESubType: "jpeg", + Params: map[string]string{}, + Id: "", + Description: "A picture of cat", + Encoding: "base64", + Size: 4242, + }, + }, + { + fields: []interface{}{"text", "plain", []interface{}{"charset", "utf-8"}, nil, nil, "us-ascii", RawString("42"), RawString("2")}, + bodyStructure: &BodyStructure{ + MIMEType: "text", + MIMESubType: "plain", + Params: map[string]string{"charset": "utf-8"}, + Encoding: "us-ascii", + Size: 42, + Lines: 2, + }, + }, + { + fields: []interface{}{ + "message", "rfc822", []interface{}{}, nil, nil, "us-ascii", RawString("42"), + (&Envelope{}).Format(), + (&BodyStructure{}).Format(), + RawString("67"), + }, + bodyStructure: &BodyStructure{ + MIMEType: "message", + MIMESubType: "rfc822", + Params: map[string]string{}, + Encoding: "us-ascii", + Size: 42, + Lines: 67, + Envelope: &Envelope{ + From: nil, + Sender: nil, + ReplyTo: nil, + To: nil, + Cc: nil, + Bcc: nil, + }, + BodyStructure: &BodyStructure{ + Params: map[string]string{}, + }, + }, + }, + { + fields: []interface{}{ + "application", "pdf", []interface{}{}, nil, nil, "base64", RawString("4242"), + "e0323a9039add2978bf5b49550572c7c", + []interface{}{"attachment", []interface{}{"filename", "document.pdf"}}, + []interface{}{"en-US"}, []interface{}{}, + }, + bodyStructure: &BodyStructure{ + MIMEType: "application", + MIMESubType: "pdf", + Params: map[string]string{}, + Encoding: "base64", + Size: 4242, + Extended: true, + MD5: "e0323a9039add2978bf5b49550572c7c", + Disposition: "attachment", + DispositionParams: map[string]string{"filename": "document.pdf"}, + Language: []string{"en-US"}, + Location: []string{}, + }, + }, + { + fields: []interface{}{ + []interface{}{"text", "plain", []interface{}{}, nil, nil, "us-ascii", RawString("87"), RawString("22")}, + []interface{}{"text", "html", []interface{}{}, nil, nil, "us-ascii", RawString("106"), RawString("36")}, + "alternative", + }, + bodyStructure: &BodyStructure{ + MIMEType: "multipart", + MIMESubType: "alternative", + Params: map[string]string{}, + Parts: []*BodyStructure{ + { + MIMEType: "text", + MIMESubType: "plain", + Params: map[string]string{}, + Encoding: "us-ascii", + Size: 87, + Lines: 22, + }, + { + MIMEType: "text", + MIMESubType: "html", + Params: map[string]string{}, + Encoding: "us-ascii", + Size: 106, + Lines: 36, + }, + }, + }, + }, + { + fields: []interface{}{ + []interface{}{"text", "plain", []interface{}{}, nil, nil, "us-ascii", RawString("87"), RawString("22")}, + "alternative", []interface{}{"hello", "world"}, + []interface{}{"inline", []interface{}{}}, + []interface{}{"en-US"}, []interface{}{}, + }, + bodyStructure: &BodyStructure{ + MIMEType: "multipart", + MIMESubType: "alternative", + Params: map[string]string{"hello": "world"}, + Parts: []*BodyStructure{ + { + MIMEType: "text", + MIMESubType: "plain", + Params: map[string]string{}, + Encoding: "us-ascii", + Size: 87, + Lines: 22, + }, + }, + Extended: true, + Disposition: "inline", + DispositionParams: map[string]string{}, + Language: []string{"en-US"}, + Location: []string{}, + }, + }, +} + +func TestBodyStructure_Parse(t *testing.T) { + for i, test := range bodyStructureTests { + bs := &BodyStructure{} + + if err := bs.Parse(test.fields); err != nil { + t.Errorf("Cannot parse #%v: %v", i, err) + } else if !reflect.DeepEqual(bs, test.bodyStructure) { + t.Errorf("Invalid body structure for #%v: got \n%+v\n but expected \n%+v", i, bs, test.bodyStructure) + } + } +} + +func TestBodyStructure_Parse_uppercase(t *testing.T) { + fields := []interface{}{ + "APPLICATION", "PDF", []interface{}{"NAME", "Document.pdf"}, nil, nil, + "BASE64", RawString("4242"), nil, + []interface{}{"ATTACHMENT", []interface{}{"FILENAME", "Document.pdf"}}, + nil, nil, + } + + expected := &BodyStructure{ + MIMEType: "application", + MIMESubType: "pdf", + Params: map[string]string{"name": "Document.pdf"}, + Encoding: "base64", + Size: 4242, + Extended: true, + MD5: "", + Disposition: "attachment", + DispositionParams: map[string]string{"filename": "Document.pdf"}, + Language: nil, + Location: []string{}, + } + + bs := &BodyStructure{} + if err := bs.Parse(fields); err != nil { + t.Errorf("Cannot parse: %v", err) + } else if !reflect.DeepEqual(bs, expected) { + t.Errorf("Invalid body structure: got \n%+v\n but expected \n%+v", bs, expected) + } +} + +func TestBodyStructure_Format(t *testing.T) { + for i, test := range bodyStructureTests { + fields := test.bodyStructure.Format() + got, err := formatFields(fields) + if err != nil { + t.Error(err) + continue + } + + expected, _ := formatFields(test.fields) + + if got != expected { + t.Errorf("Invalid body structure fields for #%v: has \n%v\n but expected \n%v", i, got, expected) + } + } +} + +func TestBodyStructureFilename(t *testing.T) { + tests := []struct { + bs BodyStructure + filename string + }{ + { + bs: BodyStructure{ + DispositionParams: map[string]string{"filename": "cat.png"}, + }, + filename: "cat.png", + }, + { + bs: BodyStructure{ + Params: map[string]string{"name": "cat.png"}, + }, + filename: "cat.png", + }, + { + bs: BodyStructure{}, + filename: "", + }, + { + bs: BodyStructure{ + DispositionParams: map[string]string{"filename": "=?UTF-8?Q?Opis_przedmiotu_zam=c3=b3wienia_-_za=c5=82=c4=85cznik_nr_1?= =?UTF-8?Q?=2epdf?="}, + }, + filename: "Opis przedmiotu zamówienia - załącznik nr 1.pdf", + }, + } + + for i, test := range tests { + got, err := test.bs.Filename() + if err != nil { + t.Errorf("Invalid body structure filename for #%v: error: %v", i, err) + continue + } + + if got != test.filename { + t.Errorf("Invalid body structure filename for #%v: got '%v', want '%v'", i, got, test.filename) + } + } +} + +func TestBodyStructureWalk(t *testing.T) { + textPlain := &BodyStructure{ + MIMEType: "text", + MIMESubType: "plain", + } + + textHTML := &BodyStructure{ + MIMEType: "text", + MIMESubType: "plain", + } + + multipartAlternative := &BodyStructure{ + MIMEType: "multipart", + MIMESubType: "alternative", + Parts: []*BodyStructure{textPlain, textHTML}, + } + + imagePNG := &BodyStructure{ + MIMEType: "image", + MIMESubType: "png", + } + + multipartMixed := &BodyStructure{ + MIMEType: "multipart", + MIMESubType: "mixed", + Parts: []*BodyStructure{multipartAlternative, imagePNG}, + } + + type testNode struct { + path []int + part *BodyStructure + } + + tests := []struct { + bs *BodyStructure + nodes []testNode + walkChildren bool + }{ + { + bs: textPlain, + nodes: []testNode{ + {path: []int{1}, part: textPlain}, + }, + }, + { + bs: multipartAlternative, + nodes: []testNode{ + {path: nil, part: multipartAlternative}, + {path: []int{1}, part: textPlain}, + {path: []int{2}, part: textHTML}, + }, + walkChildren: true, + }, + { + bs: multipartMixed, + nodes: []testNode{ + {path: nil, part: multipartMixed}, + {path: []int{1}, part: multipartAlternative}, + {path: []int{1, 1}, part: textPlain}, + {path: []int{1, 2}, part: textHTML}, + {path: []int{2}, part: imagePNG}, + }, + walkChildren: true, + }, + { + bs: multipartMixed, + nodes: []testNode{ + {path: nil, part: multipartMixed}, + }, + walkChildren: false, + }, + } + + for i, test := range tests { + j := 0 + test.bs.Walk(func(path []int, part *BodyStructure) bool { + if j >= len(test.nodes) { + t.Errorf("Test #%v: invalid node count: got > %v, want %v", i, j, len(test.nodes)) + return false + } + n := &test.nodes[j] + if !reflect.DeepEqual(path, n.path) { + t.Errorf("Test #%v: node #%v: invalid path: got %v, want %v", i, j, path, n.path) + } + if part != n.part { + t.Errorf("Test #%v: node #%v: invalid part: got %v, want %v", i, j, part, n.part) + } + j++ + return test.walkChildren + }) + if j != len(test.nodes) { + t.Errorf("Test #%v: invalid node count: got %v, want %v", i, j, len(test.nodes)) + } + } +} diff --git a/read.go b/read.go new file mode 100644 index 0000000..112ee28 --- /dev/null +++ b/read.go @@ -0,0 +1,467 @@ +package imap + +import ( + "bytes" + "errors" + "io" + "strconv" + "strings" +) + +const ( + sp = ' ' + cr = '\r' + lf = '\n' + dquote = '"' + literalStart = '{' + literalEnd = '}' + listStart = '(' + listEnd = ')' + respCodeStart = '[' + respCodeEnd = ']' +) + +const ( + crlf = "\r\n" + nilAtom = "NIL" +) + +// TODO: add CTL to atomSpecials +var ( + quotedSpecials = string([]rune{dquote, '\\'}) + respSpecials = string([]rune{respCodeEnd}) + atomSpecials = string([]rune{listStart, listEnd, literalStart, sp, '%', '*'}) + quotedSpecials + respSpecials +) + +type parseError struct { + error +} + +func newParseError(text string) error { + return &parseError{errors.New(text)} +} + +// IsParseError returns true if the provided error is a parse error produced by +// Reader. +func IsParseError(err error) bool { + _, ok := err.(*parseError) + return ok +} + +// A string reader. +type StringReader interface { + // ReadString reads until the first occurrence of delim in the input, + // returning a string containing the data up to and including the delimiter. + // See https://golang.org/pkg/bufio/#Reader.ReadString + ReadString(delim byte) (line string, err error) +} + +type reader interface { + io.Reader + io.RuneScanner + StringReader +} + +// ParseNumber parses a number. +func ParseNumber(f interface{}) (uint32, error) { + // Useful for tests + if n, ok := f.(uint32); ok { + return n, nil + } + + var s string + switch f := f.(type) { + case RawString: + s = string(f) + case string: + s = f + default: + return 0, newParseError("expected a number, got a non-atom") + } + + nbr, err := strconv.ParseUint(string(s), 10, 32) + if err != nil { + return 0, &parseError{err} + } + + return uint32(nbr), nil +} + +// ParseString parses a string, which is either a literal, a quoted string or an +// atom. +func ParseString(f interface{}) (string, error) { + if s, ok := f.(string); ok { + return s, nil + } + + // Useful for tests + if a, ok := f.(RawString); ok { + return string(a), nil + } + + if l, ok := f.(Literal); ok { + b := make([]byte, l.Len()) + if _, err := io.ReadFull(l, b); err != nil { + return "", err + } + return string(b), nil + } + + return "", newParseError("expected a string") +} + +// Convert a field list to a string list. +func ParseStringList(f interface{}) ([]string, error) { + fields, ok := f.([]interface{}) + if !ok { + return nil, newParseError("expected a string list, got a non-list") + } + + list := make([]string, len(fields)) + for i, f := range fields { + var err error + if list[i], err = ParseString(f); err != nil { + return nil, newParseError("cannot parse string in string list: " + err.Error()) + } + } + return list, nil +} + +func trimSuffix(str string, suffix rune) string { + return str[:len(str)-1] +} + +// An IMAP reader. +type Reader struct { + MaxLiteralSize uint32 // The maximum literal size. + + reader + + continues chan<- bool + + brackets int + inRespCode bool +} + +func (r *Reader) ReadSp() error { + char, _, err := r.ReadRune() + if err != nil { + return err + } + if char != sp { + return newParseError("expected a space") + } + return nil +} + +func (r *Reader) ReadCrlf() (err error) { + var char rune + + if char, _, err = r.ReadRune(); err != nil { + return + } + if char == lf { + return + } + if char != cr { + err = newParseError("line doesn't end with a CR") + return + } + + if char, _, err = r.ReadRune(); err != nil { + return + } + if char != lf { + err = newParseError("line doesn't end with a LF") + } + + return +} + +func (r *Reader) ReadAtom() (interface{}, error) { + r.brackets = 0 + + var atom string + for { + char, _, err := r.ReadRune() + if err != nil { + return nil, err + } + + // TODO: list-wildcards and \ + if r.brackets == 0 && (char == listStart || char == literalStart || char == dquote) { + return nil, newParseError("atom contains forbidden char: " + string(char)) + } + if char == cr || char == lf { + break + } + if r.brackets == 0 && (char == sp || char == listEnd) { + break + } + if char == respCodeEnd { + if r.brackets == 0 { + if r.inRespCode { + break + } else { + return nil, newParseError("atom contains bad brackets nesting") + } + } + r.brackets-- + } + if char == respCodeStart { + r.brackets++ + } + + atom += string(char) + } + + r.UnreadRune() + + if atom == nilAtom { + return nil, nil + } + + return atom, nil +} + +func (r *Reader) ReadLiteral() (Literal, error) { + char, _, err := r.ReadRune() + if err != nil { + return nil, err + } else if char != literalStart { + return nil, newParseError("literal string doesn't start with an open brace") + } + + lstr, err := r.ReadString(byte(literalEnd)) + if err != nil { + return nil, err + } + lstr = trimSuffix(lstr, literalEnd) + + nonSync := strings.HasSuffix(lstr, "+") + if nonSync { + lstr = trimSuffix(lstr, '+') + } + + n, err := strconv.ParseUint(lstr, 10, 32) + if err != nil { + return nil, newParseError("cannot parse literal length: " + err.Error()) + } + if r.MaxLiteralSize > 0 && uint32(n) > r.MaxLiteralSize { + return nil, newParseError("literal exceeding maximum size") + } + + if err := r.ReadCrlf(); err != nil { + return nil, err + } + + // Send continuation request if necessary + if r.continues != nil && !nonSync { + r.continues <- true + } + + // Read literal + b := make([]byte, n) + if _, err := io.ReadFull(r, b); err != nil { + return nil, err + } + return bytes.NewBuffer(b), nil +} + +func (r *Reader) ReadQuotedString() (string, error) { + if char, _, err := r.ReadRune(); err != nil { + return "", err + } else if char != dquote { + return "", newParseError("quoted string doesn't start with a double quote") + } + + var buf bytes.Buffer + var escaped bool + for { + char, _, err := r.ReadRune() + if err != nil { + return "", err + } + + if char == '\\' && !escaped { + escaped = true + } else { + if char == cr || char == lf { + r.UnreadRune() + return "", newParseError("CR or LF not allowed in quoted string") + } + if char == dquote && !escaped { + break + } + + if !strings.ContainsRune(quotedSpecials, char) && escaped { + return "", newParseError("quoted string cannot contain backslash followed by a non-quoted-specials char") + } + + buf.WriteRune(char) + escaped = false + } + } + + return buf.String(), nil +} + +func (r *Reader) ReadFields() (fields []interface{}, err error) { + var char rune + for { + if char, _, err = r.ReadRune(); err != nil { + return + } + if err = r.UnreadRune(); err != nil { + return + } + + var field interface{} + ok := true + switch char { + case literalStart: + field, err = r.ReadLiteral() + case dquote: + field, err = r.ReadQuotedString() + case listStart: + field, err = r.ReadList() + case listEnd: + ok = false + case cr: + return + default: + field, err = r.ReadAtom() + } + + if err != nil { + return + } + if ok { + fields = append(fields, field) + } + + if char, _, err = r.ReadRune(); err != nil { + return + } + if char == cr || char == lf || char == listEnd || char == respCodeEnd { + if char == cr || char == lf { + r.UnreadRune() + } + return + } + if char == listStart { + r.UnreadRune() + continue + } + if char != sp { + err = newParseError("fields are not separated by a space") + return + } + } +} + +func (r *Reader) ReadList() (fields []interface{}, err error) { + char, _, err := r.ReadRune() + if err != nil { + return + } + if char != listStart { + err = newParseError("list doesn't start with an open parenthesis") + return + } + + fields, err = r.ReadFields() + if err != nil { + return + } + + r.UnreadRune() + if char, _, err = r.ReadRune(); err != nil { + return + } + if char != listEnd { + err = newParseError("list doesn't end with a close parenthesis") + } + return +} + +func (r *Reader) ReadLine() (fields []interface{}, err error) { + fields, err = r.ReadFields() + if err != nil { + return + } + + r.UnreadRune() + err = r.ReadCrlf() + return +} + +func (r *Reader) ReadRespCode() (code StatusRespCode, fields []interface{}, err error) { + char, _, err := r.ReadRune() + if err != nil { + return + } + if char != respCodeStart { + err = newParseError("response code doesn't start with an open bracket") + return + } + + r.inRespCode = true + fields, err = r.ReadFields() + r.inRespCode = false + if err != nil { + return + } + + if len(fields) == 0 { + err = newParseError("response code doesn't contain any field") + return + } + + codeStr, ok := fields[0].(string) + if !ok { + err = newParseError("response code doesn't start with a string atom") + return + } + if codeStr == "" { + err = newParseError("response code is empty") + return + } + code = StatusRespCode(strings.ToUpper(codeStr)) + + fields = fields[1:] + + r.UnreadRune() + char, _, err = r.ReadRune() + if err != nil { + return + } + if char != respCodeEnd { + err = newParseError("response code doesn't end with a close bracket") + } + return +} + +func (r *Reader) ReadInfo() (info string, err error) { + info, err = r.ReadString(byte(lf)) + if err != nil { + return + } + info = strings.TrimSuffix(info, string(lf)) + info = strings.TrimSuffix(info, string(cr)) + info = strings.TrimLeft(info, " ") + + return +} + +func NewReader(r reader) *Reader { + return &Reader{reader: r} +} + +func NewServerReader(r reader, continues chan<- bool) *Reader { + return &Reader{reader: r, continues: continues} +} + +type Parser interface { + Parse(fields []interface{}) error +} diff --git a/read_test.go b/read_test.go new file mode 100644 index 0000000..0d9211c --- /dev/null +++ b/read_test.go @@ -0,0 +1,536 @@ +package imap_test + +import ( + "bytes" + "io" + "io/ioutil" + "reflect" + "testing" + + "github.com/emersion/go-imap" +) + +func TestParseNumber(t *testing.T) { + tests := []struct { + f interface{} + n uint32 + err bool + }{ + {f: "42", n: 42}, + {f: "0", n: 0}, + {f: "-1", err: true}, + {f: "1.2", err: true}, + {f: nil, err: true}, + {f: bytes.NewBufferString("cc"), err: true}, + } + + for _, test := range tests { + n, err := imap.ParseNumber(test.f) + if err != nil { + if !test.err { + t.Errorf("Cannot parse number %v", test.f) + } + } else { + if test.err { + t.Errorf("Parsed invalid number %v", test.f) + } else if n != test.n { + t.Errorf("Invalid parsed number: got %v but expected %v", n, test.n) + } + } + } +} + +func TestParseStringList(t *testing.T) { + tests := []struct { + field interface{} + list []string + }{ + { + field: []interface{}{"a", "b", "c", "d"}, + list: []string{"a", "b", "c", "d"}, + }, + { + field: []interface{}{"a"}, + list: []string{"a"}, + }, + { + field: []interface{}{}, + list: []string{}, + }, + { + field: []interface{}{"a", 42, "c", "d"}, + list: nil, + }, + { + field: []interface{}{"a", nil, "c", "d"}, + list: nil, + }, + { + field: "Asuka FTW", + list: nil, + }, + } + + for _, test := range tests { + list, err := imap.ParseStringList(test.field) + if err != nil { + if test.list != nil { + t.Errorf("Cannot parse string list: %v", err) + } + } else if !reflect.DeepEqual(list, test.list) { + t.Errorf("Invalid parsed string list: got \n%+v\n but expected \n%+v", list, test.list) + } + } +} + +func newReader(s string) (b *bytes.Buffer, r *imap.Reader) { + b = bytes.NewBuffer([]byte(s)) + r = imap.NewReader(b) + return +} + +func TestReader_ReadSp(t *testing.T) { + b, r := newReader(" ") + if err := r.ReadSp(); err != nil { + t.Error(err) + } + if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + + _, r = newReader("") + if err := r.ReadSp(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadCrlf(t *testing.T) { + b, r := newReader("\r\n") + if err := r.ReadCrlf(); err != nil { + t.Error(err) + } + if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + + _, r = newReader("") + if err := r.ReadCrlf(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("\n") + if err := r.ReadCrlf(); err != nil { + t.Error(err) + } + + _, r = newReader("\r") + if err := r.ReadCrlf(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("\r42") + if err := r.ReadCrlf(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadAtom(t *testing.T) { + b, r := newReader("NIL\r\n") + if atom, err := r.ReadAtom(); err != nil { + t.Error(err) + } else if atom != nil { + t.Error("NIL atom is not nil:", atom) + } else { + if err := r.ReadCrlf(); err != nil && err != io.EOF { + t.Error("Cannot read CRLF after atom:", err) + } + if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + } + + b, r = newReader("atom\r\n") + if atom, err := r.ReadAtom(); err != nil { + t.Error(err) + } else if s, ok := atom.(string); !ok || s != "atom" { + t.Error("String atom has not the expected value:", atom) + } else { + if err := r.ReadCrlf(); err != nil && err != io.EOF { + t.Error("Cannot read CRLF after atom:", err) + } + if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + } + + _, r = newReader("") + if _, err := r.ReadAtom(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("(hi there)\r\n") + if _, err := r.ReadAtom(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("{42}\r\n") + if _, err := r.ReadAtom(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("\"\r\n") + if _, err := r.ReadAtom(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("abc]") + if _, err := r.ReadAtom(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("[abc]def]ghi") + if _, err := r.ReadAtom(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadLiteral_NonSync(t *testing.T) { + // For synchronizing literal we should send continuation request. + b := bytes.NewBuffer([]byte("{7}\r\nabcdefg")) + cont := make(chan bool, 5) + r := imap.NewServerReader(b, cont) + if litr, err := r.ReadLiteral(); err != nil { + t.Error(err) + } else if litr.Len() != 7 { + t.Error("Invalid literal length") + } else { + if len(cont) != 1 { + t.Error("Missing continuation rejqest") + } + <-cont + } + + b = bytes.NewBuffer([]byte("{7+}\r\nabcdefg")) + r = imap.NewServerReader(b, cont) + if litr, err := r.ReadLiteral(); err != nil { + t.Error(err) + } else if litr.Len() != 7 { + t.Error("Invalid literal length") + } else { + if len(cont) != 0 { + t.Error("Unexpected continuation rejqest") + } + if contents, err := ioutil.ReadAll(litr); err != nil { + t.Error(err) + } else if string(contents) != "abcdefg" { + t.Error("Literal has not the expected value:", string(contents)) + } else if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + } +} + +func TestReader_ReadLiteral(t *testing.T) { + b, r := newReader("{7}\r\nabcdefg") + if literal, err := r.ReadLiteral(); err != nil { + t.Error(err) + } else if literal.Len() != 7 { + t.Error("Invalid literal length:", literal.Len()) + } else { + if contents, err := ioutil.ReadAll(literal); err != nil { + t.Error(err) + } else if string(contents) != "abcdefg" { + t.Error("Literal has not the expected value:", string(contents)) + } else if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + } + + _, r = newReader("") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("[7}\r\nabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("{7]\r\nabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("{7.4}\r\nabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("{7}abcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("{7}\rabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("{7}\nabcdefg") + if _, err := r.ReadLiteral(); err != nil { + t.Error(err) + } + + _, r = newReader("{7}\r\nabcd") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadQuotedString(t *testing.T) { + b, r := newReader("\"hello gopher\"\r\n") + if s, err := r.ReadQuotedString(); err != nil { + t.Error(err) + } else if s != "hello gopher" { + t.Error("Quoted string has not the expected value:", s) + } else { + if err := r.ReadCrlf(); err != nil && err != io.EOF { + t.Error("Cannot read CRLF after quoted string:", err) + } + if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + } + + _, r = newReader("\"here's a backslash: \\\\, and here's a double quote: \\\" !\"\r\n") + if s, err := r.ReadQuotedString(); err != nil { + t.Error(err) + } else if s != "here's a backslash: \\, and here's a double quote: \" !" { + t.Error("Quoted string has not the expected value:", s) + } + + _, r = newReader("") + if _, err := r.ReadQuotedString(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("hello gopher\"\r\n") + if _, err := r.ReadQuotedString(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("\"hello gopher\r\n") + if _, err := r.ReadQuotedString(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("\"hello \\gopher\"\r\n") + if _, err := r.ReadQuotedString(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadFields(t *testing.T) { + b, r := newReader("field1 \"field2\"\r\n") + if fields, err := r.ReadFields(); err != nil { + t.Error(err) + } else if len(fields) != 2 { + t.Error("Expected 2 fields, but got", len(fields)) + } else if s, ok := fields[0].(string); !ok || s != "field1" { + t.Error("Field 1 has not the expected value:", fields[0]) + } else if s, ok := fields[1].(string); !ok || s != "field2" { + t.Error("Field 2 has not the expected value:", fields[1]) + } else { + if err := r.ReadCrlf(); err != nil && err != io.EOF { + t.Error("Cannot read CRLF after fields:", err) + } + if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + } + + _, r = newReader("") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("fi\"eld1 \"field2\"\r\n") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("field1 ") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("field1 (") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("field1\"field2\"\r\n") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("\"field1\"\"field2\"\r\n") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadList(t *testing.T) { + b, r := newReader("(field1 \"field2\" {6}\r\nfield3 field4)") + if fields, err := r.ReadList(); err != nil { + t.Error(err) + } else if len(fields) != 4 { + t.Error("Expected 2 fields, but got", len(fields)) + } else if s, ok := fields[0].(string); !ok || s != "field1" { + t.Error("Field 1 has not the expected value:", fields[0]) + } else if s, ok := fields[1].(string); !ok || s != "field2" { + t.Error("Field 2 has not the expected value:", fields[1]) + } else if literal, ok := fields[2].(imap.Literal); !ok { + t.Error("Field 3 has not the expected value:", fields[2]) + } else if contents, err := ioutil.ReadAll(literal); err != nil { + t.Error(err) + } else if string(contents) != "field3" { + t.Error("Field 3 has not the expected value:", string(contents)) + } else if s, ok := fields[3].(string); !ok || s != "field4" { + t.Error("Field 4 has not the expected value:", fields[3]) + } else if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + + b, r = newReader("()") + if fields, err := r.ReadList(); err != nil { + t.Error(err) + } else if len(fields) != 0 { + t.Error("Expected 0 fields, but got", len(fields)) + } else if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + + _, r = newReader("") + if _, err := r.ReadList(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("[field1 field2 field3)") + if _, err := r.ReadList(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("(field1 fie\"ld2 field3)") + if _, err := r.ReadList(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("(field1 field2 field3\r\n") + if _, err := r.ReadList(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadLine(t *testing.T) { + b, r := newReader("field1 field2\r\n") + if fields, err := r.ReadLine(); err != nil { + t.Error(err) + } else if len(fields) != 2 { + t.Error("Expected 2 fields, but got", len(fields)) + } else if s, ok := fields[0].(string); !ok || s != "field1" { + t.Error("Field 1 has not the expected value:", fields[0]) + } else if s, ok := fields[1].(string); !ok || s != "field2" { + t.Error("Field 2 has not the expected value:", fields[1]) + } else if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + + _, r = newReader("") + if _, err := r.ReadLine(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("field1 field2\rabc") + if _, err := r.ReadLine(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadRespCode(t *testing.T) { + b, r := newReader("[CAPABILITY NOOP STARTTLS]") + if code, fields, err := r.ReadRespCode(); err != nil { + t.Error(err) + } else if code != imap.CodeCapability { + t.Error("Response code has not the expected value:", code) + } else if len(fields) != 2 { + t.Error("Expected 2 fields, but got", len(fields)) + } else if s, ok := fields[0].(string); !ok || s != "NOOP" { + t.Error("Field 1 has not the expected value:", fields[0]) + } else if s, ok := fields[1].(string); !ok || s != "STARTTLS" { + t.Error("Field 2 has not the expected value:", fields[1]) + } else if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + + _, r = newReader("") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("{CAPABILITY NOOP STARTTLS]") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("[CAPABILITY NO\"OP STARTTLS]") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("[]") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("[{3}\r\nabc]") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("[CAPABILITY NOOP STARTTLS\r\n") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } +} + +func TestReader_ReadInfo(t *testing.T) { + b, r := newReader("I love potatoes.\r\n") + if info, err := r.ReadInfo(); err != nil { + t.Error(err) + } else if info != "I love potatoes." { + t.Error("Info has not the expected value:", info) + } else if b.Len() > 0 { + t.Error("Buffer is not empty after read") + } + + _, r = newReader("I love potatoes.") + if _, err := r.ReadInfo(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("I love potatoes.\r") + if _, err := r.ReadInfo(); err == nil { + t.Error("Invalid read didn't fail") + } + + _, r = newReader("I love potatoes.\n") + if _, err := r.ReadInfo(); err != nil { + t.Error(err) + } + + _, r = newReader("I love potatoes.\rabc") + if _, err := r.ReadInfo(); err == nil { + t.Error("Invalid read didn't fail") + } +} diff --git a/response.go b/response.go index 0ce54cf..611d03e 100644 --- a/response.go +++ b/response.go @@ -1,81 +1,181 @@ package imap import ( - "fmt" "strings" ) -// StatusResponseType is a generic status response type. -type StatusResponseType string - -const ( - StatusResponseTypeOK StatusResponseType = "OK" - StatusResponseTypeNo StatusResponseType = "NO" - StatusResponseTypeBad StatusResponseType = "BAD" - StatusResponseTypePreAuth StatusResponseType = "PREAUTH" - StatusResponseTypeBye StatusResponseType = "BYE" -) - -// ResponseCode is a response code. -type ResponseCode string - -const ( - ResponseCodeAlert ResponseCode = "ALERT" - ResponseCodeAlreadyExists ResponseCode = "ALREADYEXISTS" - ResponseCodeAuthenticationFailed ResponseCode = "AUTHENTICATIONFAILED" - ResponseCodeAuthorizationFailed ResponseCode = "AUTHORIZATIONFAILED" - ResponseCodeBadCharset ResponseCode = "BADCHARSET" - ResponseCodeCannot ResponseCode = "CANNOT" - ResponseCodeClientBug ResponseCode = "CLIENTBUG" - ResponseCodeContactAdmin ResponseCode = "CONTACTADMIN" - ResponseCodeCorruption ResponseCode = "CORRUPTION" - ResponseCodeExpired ResponseCode = "EXPIRED" - ResponseCodeHasChildren ResponseCode = "HASCHILDREN" - ResponseCodeInUse ResponseCode = "INUSE" - ResponseCodeLimit ResponseCode = "LIMIT" - ResponseCodeNonExistent ResponseCode = "NONEXISTENT" - ResponseCodeNoPerm ResponseCode = "NOPERM" - ResponseCodeOverQuota ResponseCode = "OVERQUOTA" - ResponseCodeParse ResponseCode = "PARSE" - ResponseCodePrivacyRequired ResponseCode = "PRIVACYREQUIRED" - ResponseCodeServerBug ResponseCode = "SERVERBUG" - ResponseCodeTryCreate ResponseCode = "TRYCREATE" - ResponseCodeUnavailable ResponseCode = "UNAVAILABLE" - ResponseCodeUnknownCTE ResponseCode = "UNKNOWN-CTE" - - // METADATA - ResponseCodeTooMany ResponseCode = "TOOMANY" - ResponseCodeNoPrivate ResponseCode = "NOPRIVATE" - - // APPENDLIMIT - ResponseCodeTooBig ResponseCode = "TOOBIG" -) - -// StatusResponse is a generic status response. -// -// See RFC 9051 section 7.1. -type StatusResponse struct { - Type StatusResponseType - Code ResponseCode - Text string +// Resp is an IMAP response. It is either a *DataResp, a +// *ContinuationReq or a *StatusResp. +type Resp interface { + resp() } -// Error is an IMAP error caused by a status response. -type Error StatusResponse - -var _ error = (*Error)(nil) - -// Error implements the error interface. -func (err *Error) Error() string { - var sb strings.Builder - fmt.Fprintf(&sb, "imap: %v", err.Type) - if err.Code != "" { - fmt.Fprintf(&sb, " [%v]", err.Code) +// ReadResp reads a single response from a Reader. +func ReadResp(r *Reader) (Resp, error) { + atom, err := r.ReadAtom() + if err != nil { + return nil, err } - text := err.Text - if text == "" { - text = "" + tag, ok := atom.(string) + if !ok { + return nil, newParseError("response tag is not an atom") } - fmt.Fprintf(&sb, " %v", text) - return sb.String() + + if tag == "+" { + if err := r.ReadSp(); err != nil { + r.UnreadRune() + } + + resp := &ContinuationReq{} + resp.Info, err = r.ReadInfo() + if err != nil { + return nil, err + } + + return resp, nil + } + + if err := r.ReadSp(); err != nil { + return nil, err + } + + // Can be either data or status + // Try to parse a status + var fields []interface{} + if atom, err := r.ReadAtom(); err == nil { + fields = append(fields, atom) + + if err := r.ReadSp(); err == nil { + if name, ok := atom.(string); ok { + status := StatusRespType(name) + switch status { + case StatusRespOk, StatusRespNo, StatusRespBad, StatusRespPreauth, StatusRespBye: + resp := &StatusResp{ + Tag: tag, + Type: status, + } + + char, _, err := r.ReadRune() + if err != nil { + return nil, err + } + r.UnreadRune() + + if char == '[' { + // Contains code & arguments + resp.Code, resp.Arguments, err = r.ReadRespCode() + if err != nil { + return nil, err + } + } + + resp.Info, err = r.ReadInfo() + if err != nil { + return nil, err + } + + return resp, nil + } + } + } else { + r.UnreadRune() + } + } else { + r.UnreadRune() + } + + // Not a status so it's data + resp := &DataResp{Tag: tag} + + var remaining []interface{} + remaining, err = r.ReadLine() + if err != nil { + return nil, err + } + + resp.Fields = append(fields, remaining...) + return resp, nil +} + +// DataResp is an IMAP response containing data. +type DataResp struct { + // The response tag. Can be either "" for untagged responses, "+" for continuation + // requests or a previous command's tag. + Tag string + // The parsed response fields. + Fields []interface{} +} + +// NewUntaggedResp creates a new untagged response. +func NewUntaggedResp(fields []interface{}) *DataResp { + return &DataResp{ + Tag: "*", + Fields: fields, + } +} + +func (r *DataResp) resp() {} + +func (r *DataResp) WriteTo(w *Writer) error { + tag := RawString(r.Tag) + if tag == "" { + tag = RawString("*") + } + + fields := []interface{}{RawString(tag)} + fields = append(fields, r.Fields...) + return w.writeLine(fields...) +} + +// ContinuationReq is a continuation request response. +type ContinuationReq struct { + // The info message sent with the continuation request. + Info string +} + +func (r *ContinuationReq) resp() {} + +func (r *ContinuationReq) WriteTo(w *Writer) error { + if err := w.writeString("+"); err != nil { + return err + } + + if r.Info != "" { + if err := w.writeString(string(sp) + r.Info); err != nil { + return err + } + } + + return w.writeCrlf() +} + +// ParseNamedResp attempts to parse a named data response. +func ParseNamedResp(resp Resp) (name string, fields []interface{}, ok bool) { + data, ok := resp.(*DataResp) + if !ok || len(data.Fields) == 0 { + return + } + + // Some responses (namely EXISTS and RECENT) are formatted like so: + // [num] [name] [...] + // Which is fucking stupid. But we handle that here by checking if the + // response name is a number and then rearranging it. + if len(data.Fields) > 1 { + name, ok := data.Fields[1].(string) + if ok { + if _, err := ParseNumber(data.Fields[0]); err == nil { + fields := []interface{}{data.Fields[0]} + fields = append(fields, data.Fields[2:]...) + return strings.ToUpper(name), fields, true + } + } + } + + // IMAP commands are formatted like this: + // [name] [...] + name, ok = data.Fields[0].(string) + if !ok { + return + } + return strings.ToUpper(name), data.Fields[1:], true } diff --git a/response_test.go b/response_test.go new file mode 100644 index 0000000..f9f16ab --- /dev/null +++ b/response_test.go @@ -0,0 +1,252 @@ +package imap_test + +import ( + "bytes" + "reflect" + "testing" + + "github.com/emersion/go-imap" +) + +func TestResp_WriteTo(t *testing.T) { + var b bytes.Buffer + w := imap.NewWriter(&b) + + resp := imap.NewUntaggedResp([]interface{}{imap.RawString("76"), imap.RawString("FETCH"), []interface{}{imap.RawString("UID"), 783}}) + if err := resp.WriteTo(w); err != nil { + t.Fatal(err) + } + + if b.String() != "* 76 FETCH (UID 783)\r\n" { + t.Error("Invalid response:", b.String()) + } +} + +func TestContinuationReq_WriteTo(t *testing.T) { + var b bytes.Buffer + w := imap.NewWriter(&b) + + resp := &imap.ContinuationReq{} + + if err := resp.WriteTo(w); err != nil { + t.Fatal(err) + } + + if b.String() != "+\r\n" { + t.Error("Invalid continuation:", b.String()) + } +} + +func TestContinuationReq_WriteTo_WithInfo(t *testing.T) { + var b bytes.Buffer + w := imap.NewWriter(&b) + + resp := &imap.ContinuationReq{Info: "send literal"} + + if err := resp.WriteTo(w); err != nil { + t.Fatal(err) + } + + if b.String() != "+ send literal\r\n" { + t.Error("Invalid continuation:", b.String()) + } +} + +func TestReadResp_ContinuationReq(t *testing.T) { + b := bytes.NewBufferString("+ send literal\r\n") + r := imap.NewReader(b) + + resp, err := imap.ReadResp(r) + if err != nil { + t.Fatal(err) + } + + cont, ok := resp.(*imap.ContinuationReq) + if !ok { + t.Fatal("Response is not a continuation request") + } + + if cont.Info != "send literal" { + t.Error("Invalid info:", cont.Info) + } +} + +func TestReadResp_ContinuationReq_NoInfo(t *testing.T) { + b := bytes.NewBufferString("+\r\n") + r := imap.NewReader(b) + + resp, err := imap.ReadResp(r) + if err != nil { + t.Fatal(err) + } + + cont, ok := resp.(*imap.ContinuationReq) + if !ok { + t.Fatal("Response is not a continuation request") + } + + if cont.Info != "" { + t.Error("Invalid info:", cont.Info) + } +} + +func TestReadResp_Resp(t *testing.T) { + b := bytes.NewBufferString("* 1 EXISTS\r\n") + r := imap.NewReader(b) + + resp, err := imap.ReadResp(r) + if err != nil { + t.Fatal(err) + } + + data, ok := resp.(*imap.DataResp) + if !ok { + t.Fatal("Invalid response type") + } + + if data.Tag != "*" { + t.Error("Invalid tag:", data.Tag) + } + if len(data.Fields) != 2 { + t.Error("Invalid fields:", data.Fields) + } +} + +func TestReadResp_Resp_NoArgs(t *testing.T) { + b := bytes.NewBufferString("* SEARCH\r\n") + r := imap.NewReader(b) + + resp, err := imap.ReadResp(r) + if err != nil { + t.Fatal(err) + } + + data, ok := resp.(*imap.DataResp) + if !ok { + t.Fatal("Invalid response type") + } + + if data.Tag != "*" { + t.Error("Invalid tag:", data.Tag) + } + if len(data.Fields) != 1 || data.Fields[0] != "SEARCH" { + t.Error("Invalid fields:", data.Fields) + } +} + +func TestReadResp_StatusResp(t *testing.T) { + tests := []struct { + input string + expected *imap.StatusResp + }{ + { + input: "* OK IMAP4rev1 Service Ready\r\n", + expected: &imap.StatusResp{ + Tag: "*", + Type: imap.StatusRespOk, + Info: "IMAP4rev1 Service Ready", + }, + }, + { + input: "* PREAUTH Welcome Pauline!\r\n", + expected: &imap.StatusResp{ + Tag: "*", + Type: imap.StatusRespPreauth, + Info: "Welcome Pauline!", + }, + }, + { + input: "a001 OK NOOP completed\r\n", + expected: &imap.StatusResp{ + Tag: "a001", + Type: imap.StatusRespOk, + Info: "NOOP completed", + }, + }, + { + input: "a001 OK [READ-ONLY] SELECT completed\r\n", + expected: &imap.StatusResp{ + Tag: "a001", + Type: imap.StatusRespOk, + Code: "READ-ONLY", + Info: "SELECT completed", + }, + }, + { + input: "a001 OK [CAPABILITY IMAP4rev1 UIDPLUS] LOGIN completed\r\n", + expected: &imap.StatusResp{ + Tag: "a001", + Type: imap.StatusRespOk, + Code: "CAPABILITY", + Arguments: []interface{}{"IMAP4rev1", "UIDPLUS"}, + Info: "LOGIN completed", + }, + }, + } + + for _, test := range tests { + b := bytes.NewBufferString(test.input) + r := imap.NewReader(b) + + resp, err := imap.ReadResp(r) + if err != nil { + t.Fatal(err) + } + + status, ok := resp.(*imap.StatusResp) + if !ok { + t.Fatal("Response is not a status:", resp) + } + + if status.Tag != test.expected.Tag { + t.Errorf("Invalid tag: expected %v but got %v", status.Tag, test.expected.Tag) + } + if status.Type != test.expected.Type { + t.Errorf("Invalid type: expected %v but got %v", status.Type, test.expected.Type) + } + if status.Code != test.expected.Code { + t.Errorf("Invalid code: expected %v but got %v", status.Code, test.expected.Code) + } + if len(status.Arguments) != len(test.expected.Arguments) { + t.Errorf("Invalid arguments: expected %v but got %v", status.Arguments, test.expected.Arguments) + } + if status.Info != test.expected.Info { + t.Errorf("Invalid info: expected %v but got %v", status.Info, test.expected.Info) + } + } +} + +func TestParseNamedResp(t *testing.T) { + tests := []struct { + resp *imap.DataResp + name string + fields []interface{} + }{ + { + resp: &imap.DataResp{Fields: []interface{}{"CAPABILITY", "IMAP4rev1"}}, + name: "CAPABILITY", + fields: []interface{}{"IMAP4rev1"}, + }, + { + resp: &imap.DataResp{Fields: []interface{}{"42", "EXISTS"}}, + name: "EXISTS", + fields: []interface{}{"42"}, + }, + { + resp: &imap.DataResp{Fields: []interface{}{"42", "FETCH", "blah"}}, + name: "FETCH", + fields: []interface{}{"42", "blah"}, + }, + } + + for _, test := range tests { + name, fields, ok := imap.ParseNamedResp(test.resp) + if !ok { + t.Errorf("ParseNamedResp(%v)[2] = false, want true", test.resp) + } else if name != test.name { + t.Errorf("ParseNamedResp(%v)[0] = %v, want %v", test.resp, name, test.name) + } else if !reflect.DeepEqual(fields, test.fields) { + t.Errorf("ParseNamedResp(%v)[1] = %v, want %v", test.resp, fields, test.fields) + } + } +} diff --git a/responses/authenticate.go b/responses/authenticate.go new file mode 100644 index 0000000..8e134cb --- /dev/null +++ b/responses/authenticate.go @@ -0,0 +1,61 @@ +package responses + +import ( + "encoding/base64" + + "github.com/emersion/go-imap" + "github.com/emersion/go-sasl" +) + +// An AUTHENTICATE response. +type Authenticate struct { + Mechanism sasl.Client + InitialResponse []byte + RepliesCh chan []byte +} + +// Implements +func (r *Authenticate) Replies() <-chan []byte { + return r.RepliesCh +} + +func (r *Authenticate) writeLine(l string) error { + r.RepliesCh <- []byte(l + "\r\n") + return nil +} + +func (r *Authenticate) cancel() error { + return r.writeLine("*") +} + +func (r *Authenticate) Handle(resp imap.Resp) error { + cont, ok := resp.(*imap.ContinuationReq) + if !ok { + return ErrUnhandled + } + + // Empty challenge, send initial response as stated in RFC 2222 section 5.1 + if cont.Info == "" && r.InitialResponse != nil { + encoded := base64.StdEncoding.EncodeToString(r.InitialResponse) + if err := r.writeLine(encoded); err != nil { + return err + } + r.InitialResponse = nil + return nil + } + + challenge, err := base64.StdEncoding.DecodeString(cont.Info) + if err != nil { + r.cancel() + return err + } + + reply, err := r.Mechanism.Next(challenge) + if err != nil { + r.cancel() + return err + } + + encoded := base64.StdEncoding.EncodeToString(reply) + return r.writeLine(encoded) +} diff --git a/responses/capability.go b/responses/capability.go new file mode 100644 index 0000000..483cb2e --- /dev/null +++ b/responses/capability.go @@ -0,0 +1,20 @@ +package responses + +import ( + "github.com/emersion/go-imap" +) + +// A CAPABILITY response. +// See RFC 3501 section 7.2.1 +type Capability struct { + Caps []string +} + +func (r *Capability) WriteTo(w *imap.Writer) error { + fields := []interface{}{imap.RawString("CAPABILITY")} + for _, cap := range r.Caps { + fields = append(fields, imap.RawString(cap)) + } + + return imap.NewUntaggedResp(fields).WriteTo(w) +} diff --git a/responses/enabled.go b/responses/enabled.go new file mode 100644 index 0000000..fc4e27b --- /dev/null +++ b/responses/enabled.go @@ -0,0 +1,33 @@ +package responses + +import ( + "github.com/emersion/go-imap" +) + +// An ENABLED response, defined in RFC 5161 section 3.2. +type Enabled struct { + Caps []string +} + +func (r *Enabled) Handle(resp imap.Resp) error { + name, fields, ok := imap.ParseNamedResp(resp) + if !ok || name != "ENABLED" { + return ErrUnhandled + } + + if caps, err := imap.ParseStringList(fields); err != nil { + return err + } else { + r.Caps = append(r.Caps, caps...) + } + + return nil +} + +func (r *Enabled) WriteTo(w *imap.Writer) error { + fields := []interface{}{imap.RawString("ENABLED")} + for _, cap := range r.Caps { + fields = append(fields, imap.RawString(cap)) + } + return imap.NewUntaggedResp(fields).WriteTo(w) +} diff --git a/responses/expunge.go b/responses/expunge.go new file mode 100644 index 0000000..bce3bf1 --- /dev/null +++ b/responses/expunge.go @@ -0,0 +1,43 @@ +package responses + +import ( + "github.com/emersion/go-imap" +) + +const expungeName = "EXPUNGE" + +// An EXPUNGE response. +// See RFC 3501 section 7.4.1 +type Expunge struct { + SeqNums chan uint32 +} + +func (r *Expunge) Handle(resp imap.Resp) error { + name, fields, ok := imap.ParseNamedResp(resp) + if !ok || name != expungeName { + return ErrUnhandled + } + + if len(fields) == 0 { + return errNotEnoughFields + } + + seqNum, err := imap.ParseNumber(fields[0]) + if err != nil { + return err + } + + r.SeqNums <- seqNum + return nil +} + +func (r *Expunge) WriteTo(w *imap.Writer) error { + for seqNum := range r.SeqNums { + resp := imap.NewUntaggedResp([]interface{}{seqNum, imap.RawString(expungeName)}) + if err := resp.WriteTo(w); err != nil { + return err + } + } + + return nil +} diff --git a/responses/fetch.go b/responses/fetch.go new file mode 100644 index 0000000..691ebcb --- /dev/null +++ b/responses/fetch.go @@ -0,0 +1,70 @@ +package responses + +import ( + "github.com/emersion/go-imap" +) + +const fetchName = "FETCH" + +// A FETCH response. +// See RFC 3501 section 7.4.2 +type Fetch struct { + Messages chan *imap.Message + SeqSet *imap.SeqSet + Uid bool +} + +func (r *Fetch) Handle(resp imap.Resp) error { + name, fields, ok := imap.ParseNamedResp(resp) + if !ok || name != fetchName { + return ErrUnhandled + } else if len(fields) < 1 { + return errNotEnoughFields + } + + seqNum, err := imap.ParseNumber(fields[0]) + if err != nil { + return err + } + + msgFields, _ := fields[1].([]interface{}) + msg := &imap.Message{SeqNum: seqNum} + if err := msg.Parse(msgFields); err != nil { + return err + } + + if r.Uid && msg.Uid == 0 { + // we requested UIDs and got a message without one --> unilateral update --> ignore + return ErrUnhandled + } + + var num uint32 + if r.Uid { + num = msg.Uid + } else { + num = seqNum + } + + // Check whether we obtained a result we requested with our SeqSet + // If the result is not contained in our SeqSet we have to handle an additional special case: + // In case we requested UIDs with a dynamic sequence (i.e. * or n:*) and the maximum UID of the mailbox + // is less then our n, the server will supply us with the max UID (cf. RFC 3501 §6.4.8 and §9 `seq-range`). + // Thus, such a result is correct and has to be returned by us. + if !r.SeqSet.Contains(num) && (!r.Uid || !r.SeqSet.Dynamic()) { + return ErrUnhandled + } + + r.Messages <- msg + return nil +} + +func (r *Fetch) WriteTo(w *imap.Writer) error { + var err error + for msg := range r.Messages { + resp := imap.NewUntaggedResp([]interface{}{msg.SeqNum, imap.RawString(fetchName), msg.Format()}) + if err == nil { + err = resp.WriteTo(w) + } + } + return err +} diff --git a/responses/idle.go b/responses/idle.go new file mode 100644 index 0000000..b5efcac --- /dev/null +++ b/responses/idle.go @@ -0,0 +1,38 @@ +package responses + +import ( + "github.com/emersion/go-imap" +) + +// An IDLE response. +type Idle struct { + RepliesCh chan []byte + Stop <-chan struct{} + + gotContinuationReq bool +} + +func (r *Idle) Replies() <-chan []byte { + return r.RepliesCh +} + +func (r *Idle) stop() { + r.RepliesCh <- []byte("DONE\r\n") +} + +func (r *Idle) Handle(resp imap.Resp) error { + // Wait for a continuation request + if _, ok := resp.(*imap.ContinuationReq); ok && !r.gotContinuationReq { + r.gotContinuationReq = true + + // We got a continuation request, wait for r.Stop to be closed + go func() { + <-r.Stop + r.stop() + }() + + return nil + } + + return ErrUnhandled +} diff --git a/responses/list.go b/responses/list.go new file mode 100644 index 0000000..e080fc1 --- /dev/null +++ b/responses/list.go @@ -0,0 +1,57 @@ +package responses + +import ( + "github.com/emersion/go-imap" +) + +const ( + listName = "LIST" + lsubName = "LSUB" +) + +// A LIST response. +// If Subscribed is set to true, LSUB will be used instead. +// See RFC 3501 section 7.2.2 +type List struct { + Mailboxes chan *imap.MailboxInfo + Subscribed bool +} + +func (r *List) Name() string { + if r.Subscribed { + return lsubName + } else { + return listName + } +} + +func (r *List) Handle(resp imap.Resp) error { + name, fields, ok := imap.ParseNamedResp(resp) + if !ok || name != r.Name() { + return ErrUnhandled + } + + mbox := &imap.MailboxInfo{} + if err := mbox.Parse(fields); err != nil { + return err + } + + r.Mailboxes <- mbox + return nil +} + +func (r *List) WriteTo(w *imap.Writer) error { + respName := r.Name() + + for mbox := range r.Mailboxes { + fields := []interface{}{imap.RawString(respName)} + fields = append(fields, mbox.Format()...) + + resp := imap.NewUntaggedResp(fields) + if err := resp.WriteTo(w); err != nil { + return err + } + } + + return nil +} diff --git a/responses/list_test.go b/responses/list_test.go new file mode 100644 index 0000000..040d555 --- /dev/null +++ b/responses/list_test.go @@ -0,0 +1,65 @@ +package responses + +import ( + "bytes" + "testing" + + "github.com/emersion/go-imap" +) + +func TestListSlashDelimiter(t *testing.T) { + mbox := &imap.MailboxInfo{} + + if err := mbox.Parse([]interface{}{ + []interface{}{"\\Unseen"}, + "/", + "INBOX", + }); err != nil { + t.Error(err) + t.FailNow() + } + + if response := getListResponse(t, mbox); response != `* LIST (\Unseen) "/" INBOX`+"\r\n" { + t.Error("Unexpected response:", response) + } +} + +func TestListNILDelimiter(t *testing.T) { + mbox := &imap.MailboxInfo{} + + if err := mbox.Parse([]interface{}{ + []interface{}{"\\Unseen"}, + nil, + "INBOX", + }); err != nil { + t.Error(err) + t.FailNow() + } + + if response := getListResponse(t, mbox); response != `* LIST (\Unseen) NIL INBOX`+"\r\n" { + t.Error("Unexpected response:", response) + } +} + +func newListResponse(mbox *imap.MailboxInfo) (l *List) { + l = &List{Mailboxes: make(chan *imap.MailboxInfo)} + + go func() { + l.Mailboxes <- mbox + close(l.Mailboxes) + }() + + return +} + +func getListResponse(t *testing.T, mbox *imap.MailboxInfo) string { + b := &bytes.Buffer{} + w := imap.NewWriter(b) + + if err := newListResponse(mbox).WriteTo(w); err != nil { + t.Error(err) + t.FailNow() + } + + return b.String() +} diff --git a/responses/responses.go b/responses/responses.go new file mode 100644 index 0000000..4d035ee --- /dev/null +++ b/responses/responses.go @@ -0,0 +1,35 @@ +// IMAP responses defined in RFC 3501. +package responses + +import ( + "errors" + + "github.com/emersion/go-imap" +) + +// ErrUnhandled is used when a response hasn't been handled. +var ErrUnhandled = errors.New("imap: unhandled response") + +var errNotEnoughFields = errors.New("imap: not enough fields in response") + +// Handler handles responses. +type Handler interface { + // Handle processes a response. If the response cannot be processed, + // ErrUnhandledResp must be returned. + Handle(resp imap.Resp) error +} + +// HandlerFunc is a function that handles responses. +type HandlerFunc func(resp imap.Resp) error + +// Handle implements Handler. +func (f HandlerFunc) Handle(resp imap.Resp) error { + return f(resp) +} + +// Replier is a Handler that needs to send raw data (for instance +// AUTHENTICATE). +type Replier interface { + Handler + Replies() <-chan []byte +} diff --git a/responses/search.go b/responses/search.go new file mode 100644 index 0000000..028dbc7 --- /dev/null +++ b/responses/search.go @@ -0,0 +1,41 @@ +package responses + +import ( + "github.com/emersion/go-imap" +) + +const searchName = "SEARCH" + +// A SEARCH response. +// See RFC 3501 section 7.2.5 +type Search struct { + Ids []uint32 +} + +func (r *Search) Handle(resp imap.Resp) error { + name, fields, ok := imap.ParseNamedResp(resp) + if !ok || name != searchName { + return ErrUnhandled + } + + r.Ids = make([]uint32, len(fields)) + for i, f := range fields { + if id, err := imap.ParseNumber(f); err != nil { + return err + } else { + r.Ids[i] = id + } + } + + return nil +} + +func (r *Search) WriteTo(w *imap.Writer) (err error) { + fields := []interface{}{imap.RawString(searchName)} + for _, id := range r.Ids { + fields = append(fields, id) + } + + resp := imap.NewUntaggedResp(fields) + return resp.WriteTo(w) +} diff --git a/responses/select.go b/responses/select.go new file mode 100644 index 0000000..e450963 --- /dev/null +++ b/responses/select.go @@ -0,0 +1,142 @@ +package responses + +import ( + "fmt" + + "github.com/emersion/go-imap" +) + +// A SELECT response. +type Select struct { + Mailbox *imap.MailboxStatus +} + +func (r *Select) Handle(resp imap.Resp) error { + if r.Mailbox == nil { + r.Mailbox = &imap.MailboxStatus{Items: make(map[imap.StatusItem]interface{})} + } + mbox := r.Mailbox + + switch resp := resp.(type) { + case *imap.DataResp: + name, fields, ok := imap.ParseNamedResp(resp) + if !ok || name != "FLAGS" { + return ErrUnhandled + } else if len(fields) < 1 { + return errNotEnoughFields + } + + flags, _ := fields[0].([]interface{}) + mbox.Flags, _ = imap.ParseStringList(flags) + case *imap.StatusResp: + if len(resp.Arguments) < 1 { + return ErrUnhandled + } + + var item imap.StatusItem + switch resp.Code { + case "UNSEEN": + mbox.UnseenSeqNum, _ = imap.ParseNumber(resp.Arguments[0]) + case "PERMANENTFLAGS": + flags, _ := resp.Arguments[0].([]interface{}) + mbox.PermanentFlags, _ = imap.ParseStringList(flags) + case "UIDNEXT": + mbox.UidNext, _ = imap.ParseNumber(resp.Arguments[0]) + item = imap.StatusUidNext + case "UIDVALIDITY": + mbox.UidValidity, _ = imap.ParseNumber(resp.Arguments[0]) + item = imap.StatusUidValidity + default: + return ErrUnhandled + } + + if item != "" { + mbox.ItemsLocker.Lock() + mbox.Items[item] = nil + mbox.ItemsLocker.Unlock() + } + default: + return ErrUnhandled + } + return nil +} + +func (r *Select) WriteTo(w *imap.Writer) error { + mbox := r.Mailbox + + if mbox.Flags != nil { + flags := make([]interface{}, len(mbox.Flags)) + for i, f := range mbox.Flags { + flags[i] = imap.RawString(f) + } + res := imap.NewUntaggedResp([]interface{}{imap.RawString("FLAGS"), flags}) + if err := res.WriteTo(w); err != nil { + return err + } + } + + if mbox.PermanentFlags != nil { + flags := make([]interface{}, len(mbox.PermanentFlags)) + for i, f := range mbox.PermanentFlags { + flags[i] = imap.RawString(f) + } + statusRes := &imap.StatusResp{ + Type: imap.StatusRespOk, + Code: imap.CodePermanentFlags, + Arguments: []interface{}{flags}, + Info: "Flags permitted.", + } + if err := statusRes.WriteTo(w); err != nil { + return err + } + } + + if mbox.UnseenSeqNum > 0 { + statusRes := &imap.StatusResp{ + Type: imap.StatusRespOk, + Code: imap.CodeUnseen, + Arguments: []interface{}{mbox.UnseenSeqNum}, + Info: fmt.Sprintf("Message %d is first unseen", mbox.UnseenSeqNum), + } + if err := statusRes.WriteTo(w); err != nil { + return err + } + } + + for k := range r.Mailbox.Items { + switch k { + case imap.StatusMessages: + res := imap.NewUntaggedResp([]interface{}{mbox.Messages, imap.RawString("EXISTS")}) + if err := res.WriteTo(w); err != nil { + return err + } + case imap.StatusRecent: + res := imap.NewUntaggedResp([]interface{}{mbox.Recent, imap.RawString("RECENT")}) + if err := res.WriteTo(w); err != nil { + return err + } + case imap.StatusUidNext: + statusRes := &imap.StatusResp{ + Type: imap.StatusRespOk, + Code: imap.CodeUidNext, + Arguments: []interface{}{mbox.UidNext}, + Info: "Predicted next UID", + } + if err := statusRes.WriteTo(w); err != nil { + return err + } + case imap.StatusUidValidity: + statusRes := &imap.StatusResp{ + Type: imap.StatusRespOk, + Code: imap.CodeUidValidity, + Arguments: []interface{}{mbox.UidValidity}, + Info: "UIDs valid", + } + if err := statusRes.WriteTo(w); err != nil { + return err + } + } + } + + return nil +} diff --git a/responses/status.go b/responses/status.go new file mode 100644 index 0000000..6a8570c --- /dev/null +++ b/responses/status.go @@ -0,0 +1,53 @@ +package responses + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/utf7" +) + +const statusName = "STATUS" + +// A STATUS response. +// See RFC 3501 section 7.2.4 +type Status struct { + Mailbox *imap.MailboxStatus +} + +func (r *Status) Handle(resp imap.Resp) error { + if r.Mailbox == nil { + r.Mailbox = &imap.MailboxStatus{} + } + mbox := r.Mailbox + + name, fields, ok := imap.ParseNamedResp(resp) + if !ok || name != statusName { + return ErrUnhandled + } else if len(fields) < 2 { + return errNotEnoughFields + } + + if name, err := imap.ParseString(fields[0]); err != nil { + return err + } else if name, err := utf7.Encoding.NewDecoder().String(name); err != nil { + return err + } else { + mbox.Name = imap.CanonicalMailboxName(name) + } + + var items []interface{} + if items, ok = fields[1].([]interface{}); !ok { + return errors.New("STATUS response expects a list as second argument") + } + + mbox.Items = nil + return mbox.Parse(items) +} + +func (r *Status) WriteTo(w *imap.Writer) error { + mbox := r.Mailbox + name, _ := utf7.Encoding.NewEncoder().String(mbox.Name) + fields := []interface{}{imap.RawString(statusName), imap.FormatMailboxName(name), mbox.Format()} + return imap.NewUntaggedResp(fields).WriteTo(w) +} diff --git a/search.go b/search.go index e5b7720..0ecb24d 100644 --- a/search.go +++ b/search.go @@ -1,202 +1,371 @@ package imap import ( - "reflect" + "errors" + "fmt" + "io" + "net/textproto" + "strings" "time" ) -// SearchOptions contains options for the SEARCH command. -type SearchOptions struct { - // Requires IMAP4rev2 or ESEARCH - ReturnMin bool - ReturnMax bool - ReturnAll bool - ReturnCount bool - // Requires IMAP4rev2 or SEARCHRES - ReturnSave bool +func maybeString(mystery interface{}) string { + if s, ok := mystery.(string); ok { + return s + } + return "" } -// SearchCriteria is a criteria for the SEARCH command. -// -// When multiple fields are populated, the result is the intersection ("and" -// function) of all messages that match the fields. -// -// And, Not and Or can be used to combine multiple criteria together. For -// instance, the following criteria matches messages not containing "hello": -// -// SearchCriteria{Not: []SearchCriteria{{ -// Body: []string{"hello"}, -// }}} -// -// The following criteria matches messages containing either "hello" or -// "world": -// -// SearchCriteria{Or: [][2]SearchCriteria{{ -// {Body: []string{"hello"}}, -// {Body: []string{"world"}}, -// }}} +func convertField(f interface{}, charsetReader func(io.Reader) io.Reader) string { + // An IMAP string contains only 7-bit data, no need to decode it + if s, ok := f.(string); ok { + return s + } + + // If no charset is provided, getting directly the string is faster + if charsetReader == nil { + if stringer, ok := f.(fmt.Stringer); ok { + return stringer.String() + } + } + + // Not a string, it must be a literal + l, ok := f.(Literal) + if !ok { + return "" + } + + var r io.Reader = l + if charsetReader != nil { + if dec := charsetReader(r); dec != nil { + r = dec + } + } + + b := make([]byte, l.Len()) + if _, err := io.ReadFull(r, b); err != nil { + return "" + } + return string(b) +} + +func popSearchField(fields []interface{}) (interface{}, []interface{}, error) { + if len(fields) == 0 { + return nil, nil, errors.New("imap: no enough fields for search key") + } + return fields[0], fields[1:], nil +} + +// SearchCriteria is a search criteria. A message matches the criteria if and +// only if it matches each one of its fields. type SearchCriteria struct { - SeqNum []SeqSet - UID []UIDSet + SeqNum *SeqSet // Sequence number is in sequence set + Uid *SeqSet // UID is in sequence set - // Only the date is used, the time and timezone are ignored - Since time.Time - Before time.Time - SentSince time.Time - SentBefore time.Time + // Time and timezone are ignored + Since time.Time // Internal date is since this date + Before time.Time // Internal date is before this date + SentSince time.Time // Date header field is since this date + SentBefore time.Time // Date header field is before this date - Header []SearchCriteriaHeaderField - Body []string - Text []string + Header textproto.MIMEHeader // Each header field value is present + Body []string // Each string is in the body + Text []string // Each string is in the text (header + body) - Flag []Flag - NotFlag []Flag + WithFlags []string // Each flag is present + WithoutFlags []string // Each flag is not present - Larger int64 - Smaller int64 + Larger uint32 // Size is larger than this number + Smaller uint32 // Size is smaller than this number - Not []SearchCriteria - Or [][2]SearchCriteria - - ModSeq *SearchCriteriaModSeq // requires CONDSTORE + Not []*SearchCriteria // Each criteria doesn't match + Or [][2]*SearchCriteria // Each criteria pair has at least one match of two } -// And intersects two search criteria. -func (criteria *SearchCriteria) And(other *SearchCriteria) { - criteria.SeqNum = append(criteria.SeqNum, other.SeqNum...) - criteria.UID = append(criteria.UID, other.UID...) +// NewSearchCriteria creates a new search criteria. +func NewSearchCriteria() *SearchCriteria { + return &SearchCriteria{Header: make(textproto.MIMEHeader)} +} - criteria.Since = intersectSince(criteria.Since, other.Since) - criteria.Before = intersectBefore(criteria.Before, other.Before) - criteria.SentSince = intersectSince(criteria.SentSince, other.SentSince) - criteria.SentBefore = intersectBefore(criteria.SentBefore, other.SentBefore) - - criteria.Header = append(criteria.Header, other.Header...) - criteria.Body = append(criteria.Body, other.Body...) - criteria.Text = append(criteria.Text, other.Text...) - - criteria.Flag = append(criteria.Flag, other.Flag...) - criteria.NotFlag = append(criteria.NotFlag, other.NotFlag...) - - if criteria.Larger == 0 || other.Larger > criteria.Larger { - criteria.Larger = other.Larger - } - if criteria.Smaller == 0 || other.Smaller < criteria.Smaller { - criteria.Smaller = other.Smaller +func (c *SearchCriteria) parseField(fields []interface{}, charsetReader func(io.Reader) io.Reader) ([]interface{}, error) { + if len(fields) == 0 { + return nil, nil } - criteria.Not = append(criteria.Not, other.Not...) - criteria.Or = append(criteria.Or, other.Or...) -} + f := fields[0] + fields = fields[1:] -func intersectSince(t1, t2 time.Time) time.Time { - switch { - case t1.IsZero(): - return t2 - case t2.IsZero(): - return t1 - case t1.After(t2): - return t1 - default: - return t2 + if subfields, ok := f.([]interface{}); ok { + return fields, c.ParseWithCharset(subfields, charsetReader) } -} -func intersectBefore(t1, t2 time.Time) time.Time { - switch { - case t1.IsZero(): - return t2 - case t2.IsZero(): - return t1 - case t1.Before(t2): - return t1 - default: - return t2 - } -} - -type SearchCriteriaHeaderField struct { - Key, Value string -} - -type SearchCriteriaModSeq struct { - ModSeq uint64 - MetadataName string - MetadataType SearchCriteriaMetadataType -} - -type SearchCriteriaMetadataType string - -const ( - SearchCriteriaMetadataAll SearchCriteriaMetadataType = "all" - SearchCriteriaMetadataPrivate SearchCriteriaMetadataType = "priv" - SearchCriteriaMetadataShared SearchCriteriaMetadataType = "shared" -) - -// SearchData is the data returned by a SEARCH command. -type SearchData struct { - All NumSet - - // requires IMAP4rev2 or ESEARCH - UID bool - Min uint32 - Max uint32 - Count uint32 - - // requires CONDSTORE - ModSeq uint64 -} - -// AllSeqNums returns All as a slice of sequence numbers. -func (data *SearchData) AllSeqNums() []uint32 { - seqSet, ok := data.All.(SeqSet) + key, ok := f.(string) if !ok { - return nil + return nil, fmt.Errorf("imap: invalid search criteria field type: %T", f) + } + key = strings.ToUpper(key) + + var err error + switch key { + case "ALL": + // Nothing to do + case "ANSWERED", "DELETED", "DRAFT", "FLAGGED", "RECENT", "SEEN": + c.WithFlags = append(c.WithFlags, CanonicalFlag("\\"+key)) + case "BCC", "CC", "FROM", "SUBJECT", "TO": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } + if c.Header == nil { + c.Header = make(textproto.MIMEHeader) + } + c.Header.Add(key, convertField(f, charsetReader)) + case "BEFORE": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if t, err := time.Parse(DateLayout, maybeString(f)); err != nil { + return nil, err + } else if c.Before.IsZero() || t.Before(c.Before) { + c.Before = t + } + case "BODY": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else { + c.Body = append(c.Body, convertField(f, charsetReader)) + } + case "HEADER": + var f1, f2 interface{} + if f1, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if f2, fields, err = popSearchField(fields); err != nil { + return nil, err + } else { + if c.Header == nil { + c.Header = make(textproto.MIMEHeader) + } + c.Header.Add(maybeString(f1), convertField(f2, charsetReader)) + } + case "KEYWORD": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else { + c.WithFlags = append(c.WithFlags, CanonicalFlag(maybeString(f))) + } + case "LARGER": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if n, err := ParseNumber(f); err != nil { + return nil, err + } else if c.Larger == 0 || n > c.Larger { + c.Larger = n + } + case "NEW": + c.WithFlags = append(c.WithFlags, RecentFlag) + c.WithoutFlags = append(c.WithoutFlags, SeenFlag) + case "NOT": + not := new(SearchCriteria) + if fields, err = not.parseField(fields, charsetReader); err != nil { + return nil, err + } + c.Not = append(c.Not, not) + case "OLD": + c.WithoutFlags = append(c.WithoutFlags, RecentFlag) + case "ON": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if t, err := time.Parse(DateLayout, maybeString(f)); err != nil { + return nil, err + } else { + c.Since = t + c.Before = t.Add(24 * time.Hour) + } + case "OR": + c1, c2 := new(SearchCriteria), new(SearchCriteria) + if fields, err = c1.parseField(fields, charsetReader); err != nil { + return nil, err + } else if fields, err = c2.parseField(fields, charsetReader); err != nil { + return nil, err + } + c.Or = append(c.Or, [2]*SearchCriteria{c1, c2}) + case "SENTBEFORE": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if t, err := time.Parse(DateLayout, maybeString(f)); err != nil { + return nil, err + } else if c.SentBefore.IsZero() || t.Before(c.SentBefore) { + c.SentBefore = t + } + case "SENTON": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if t, err := time.Parse(DateLayout, maybeString(f)); err != nil { + return nil, err + } else { + c.SentSince = t + c.SentBefore = t.Add(24 * time.Hour) + } + case "SENTSINCE": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if t, err := time.Parse(DateLayout, maybeString(f)); err != nil { + return nil, err + } else if c.SentSince.IsZero() || t.After(c.SentSince) { + c.SentSince = t + } + case "SINCE": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if t, err := time.Parse(DateLayout, maybeString(f)); err != nil { + return nil, err + } else if c.Since.IsZero() || t.After(c.Since) { + c.Since = t + } + case "SMALLER": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if n, err := ParseNumber(f); err != nil { + return nil, err + } else if c.Smaller == 0 || n < c.Smaller { + c.Smaller = n + } + case "TEXT": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else { + c.Text = append(c.Text, convertField(f, charsetReader)) + } + case "UID": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else if c.Uid, err = ParseSeqSet(maybeString(f)); err != nil { + return nil, err + } + case "UNANSWERED", "UNDELETED", "UNDRAFT", "UNFLAGGED", "UNSEEN": + unflag := strings.TrimPrefix(key, "UN") + c.WithoutFlags = append(c.WithoutFlags, CanonicalFlag("\\"+unflag)) + case "UNKEYWORD": + if f, fields, err = popSearchField(fields); err != nil { + return nil, err + } else { + c.WithoutFlags = append(c.WithoutFlags, CanonicalFlag(maybeString(f))) + } + default: // Try to parse a sequence set + if c.SeqNum, err = ParseSeqSet(key); err != nil { + return nil, err + } } - // Note: a dynamic sequence set would be a server bug - nums, ok := seqSet.Nums() - if !ok { - panic("imap: SearchData.All is a dynamic number set") - } - return nums + return fields, nil } -// AllUIDs returns All as a slice of UIDs. -func (data *SearchData) AllUIDs() []UID { - uidSet, ok := data.All.(UIDSet) - if !ok { - return nil +// ParseWithCharset parses a search criteria from the provided fields. +// charsetReader is an optional function that converts from the fields charset +// to UTF-8. +func (c *SearchCriteria) ParseWithCharset(fields []interface{}, charsetReader func(io.Reader) io.Reader) error { + for len(fields) > 0 { + var err error + if fields, err = c.parseField(fields, charsetReader); err != nil { + return err + } + } + return nil +} + +// Format formats search criteria to fields. UTF-8 is used. +func (c *SearchCriteria) Format() []interface{} { + var fields []interface{} + + if c.SeqNum != nil { + fields = append(fields, c.SeqNum) + } + if c.Uid != nil { + fields = append(fields, RawString("UID"), c.Uid) } - // Note: a dynamic sequence set would be a server bug - uids, ok := uidSet.Nums() - if !ok { - panic("imap: SearchData.All is a dynamic number set") + if !c.Since.IsZero() && !c.Before.IsZero() && c.Before.Sub(c.Since) == 24*time.Hour { + fields = append(fields, RawString("ON"), searchDate(c.Since)) + } else { + if !c.Since.IsZero() { + fields = append(fields, RawString("SINCE"), searchDate(c.Since)) + } + if !c.Before.IsZero() { + fields = append(fields, RawString("BEFORE"), searchDate(c.Before)) + } + } + if !c.SentSince.IsZero() && !c.SentBefore.IsZero() && c.SentBefore.Sub(c.SentSince) == 24*time.Hour { + fields = append(fields, RawString("SENTON"), searchDate(c.SentSince)) + } else { + if !c.SentSince.IsZero() { + fields = append(fields, RawString("SENTSINCE"), searchDate(c.SentSince)) + } + if !c.SentBefore.IsZero() { + fields = append(fields, RawString("SENTBEFORE"), searchDate(c.SentBefore)) + } } - return uids -} -// searchRes is a special empty UIDSet which can be used as a marker. It has -// a non-zero cap so that its data pointer is non-nil and can be compared. -// -// It's a UIDSet rather than a SeqSet so that it can be passed to the -// UID EXPUNGE command. -var ( - searchRes = make(UIDSet, 0, 1) - searchResAddr = reflect.ValueOf(searchRes).Pointer() -) + for key, values := range c.Header { + var prefields []interface{} + switch key { + case "Bcc", "Cc", "From", "Subject", "To": + prefields = []interface{}{RawString(strings.ToUpper(key))} + default: + prefields = []interface{}{RawString("HEADER"), key} + } + for _, value := range values { + fields = append(fields, prefields...) + fields = append(fields, value) + } + } -// SearchRes returns a special marker which can be used instead of a UIDSet to -// reference the last SEARCH result. On the wire, it's encoded as '$'. -// -// It requires IMAP4rev2 or the SEARCHRES extension. -func SearchRes() UIDSet { - return searchRes -} + for _, value := range c.Body { + fields = append(fields, RawString("BODY"), value) + } + for _, value := range c.Text { + fields = append(fields, RawString("TEXT"), value) + } -// IsSearchRes checks whether a sequence set is a reference to the last SEARCH -// result. See SearchRes. -func IsSearchRes(numSet NumSet) bool { - return reflect.ValueOf(numSet).Pointer() == searchResAddr + for _, flag := range c.WithFlags { + var subfields []interface{} + switch flag { + case AnsweredFlag, DeletedFlag, DraftFlag, FlaggedFlag, RecentFlag, SeenFlag: + subfields = []interface{}{RawString(strings.ToUpper(strings.TrimPrefix(flag, "\\")))} + default: + subfields = []interface{}{RawString("KEYWORD"), RawString(flag)} + } + fields = append(fields, subfields...) + } + for _, flag := range c.WithoutFlags { + var subfields []interface{} + switch flag { + case AnsweredFlag, DeletedFlag, DraftFlag, FlaggedFlag, SeenFlag: + subfields = []interface{}{RawString("UN" + strings.ToUpper(strings.TrimPrefix(flag, "\\")))} + case RecentFlag: + subfields = []interface{}{RawString("OLD")} + default: + subfields = []interface{}{RawString("UNKEYWORD"), RawString(flag)} + } + fields = append(fields, subfields...) + } + + if c.Larger > 0 { + fields = append(fields, RawString("LARGER"), c.Larger) + } + if c.Smaller > 0 { + fields = append(fields, RawString("SMALLER"), c.Smaller) + } + + for _, not := range c.Not { + fields = append(fields, RawString("NOT"), not.Format()) + } + + for _, or := range c.Or { + fields = append(fields, RawString("OR"), or[0].Format(), or[1].Format()) + } + + // Not a single criteria given, add ALL criteria as fallback + if len(fields) == 0 { + fields = append(fields, RawString("ALL")) + } + + return fields } diff --git a/search_test.go b/search_test.go new file mode 100644 index 0000000..81c3e5e --- /dev/null +++ b/search_test.go @@ -0,0 +1,141 @@ +package imap + +import ( + "bytes" + "io" + "net/textproto" + "reflect" + "strings" + "testing" + "time" +) + +// Note to myself: writing these boring tests actually fixed 2 bugs :P + +var searchSeqSet1, _ = ParseSeqSet("1:42") +var searchSeqSet2, _ = ParseSeqSet("743:938") +var searchDate1 = time.Date(1997, 11, 21, 0, 0, 0, 0, time.UTC) +var searchDate2 = time.Date(1984, 11, 5, 0, 0, 0, 0, time.UTC) + +var searchCriteriaTests = []struct { + expected string + criteria *SearchCriteria +}{ + { + expected: `(1:42 UID 743:938 ` + + `SINCE "5-Nov-1984" BEFORE "21-Nov-1997" SENTSINCE "5-Nov-1984" SENTBEFORE "21-Nov-1997" ` + + `FROM "root@protonmail.com" BODY "hey there" TEXT "DILLE" ` + + `ANSWERED DELETED KEYWORD cc UNKEYWORD microsoft ` + + `LARGER 4242 SMALLER 4342 ` + + `NOT (SENTON "21-Nov-1997" HEADER "Content-Type" "text/csv") ` + + `OR (ON "5-Nov-1984" DRAFT FLAGGED UNANSWERED UNDELETED OLD) (UNDRAFT UNFLAGGED UNSEEN))`, + criteria: &SearchCriteria{ + SeqNum: searchSeqSet1, + Uid: searchSeqSet2, + Since: searchDate2, + Before: searchDate1, + SentSince: searchDate2, + SentBefore: searchDate1, + Header: textproto.MIMEHeader{ + "From": {"root@protonmail.com"}, + }, + Body: []string{"hey there"}, + Text: []string{"DILLE"}, + WithFlags: []string{AnsweredFlag, DeletedFlag, "cc"}, + WithoutFlags: []string{"microsoft"}, + Larger: 4242, + Smaller: 4342, + Not: []*SearchCriteria{{ + SentSince: searchDate1, + SentBefore: searchDate1.Add(24 * time.Hour), + Header: textproto.MIMEHeader{ + "Content-Type": {"text/csv"}, + }, + }}, + Or: [][2]*SearchCriteria{{ + { + Since: searchDate2, + Before: searchDate2.Add(24 * time.Hour), + WithFlags: []string{DraftFlag, FlaggedFlag}, + WithoutFlags: []string{AnsweredFlag, DeletedFlag, RecentFlag}, + }, + { + WithoutFlags: []string{DraftFlag, FlaggedFlag, SeenFlag}, + }, + }}, + }, + }, + { + expected: "(ALL)", + criteria: &SearchCriteria{}, + }, +} + +func TestSearchCriteria_Format(t *testing.T) { + for i, test := range searchCriteriaTests { + fields := test.criteria.Format() + + got, err := formatFields(fields) + if err != nil { + t.Fatal("Unexpected no error while formatting fields, got:", err) + } + + if got != test.expected { + t.Errorf("Invalid search criteria fields for #%v: got \n%v\n instead of \n%v", i+1, got, test.expected) + } + } +} + +func TestSearchCriteria_Parse(t *testing.T) { + for i, test := range searchCriteriaTests { + criteria := new(SearchCriteria) + + b := bytes.NewBuffer([]byte(test.expected)) + r := NewReader(b) + fields, _ := r.ReadFields() + + if err := criteria.ParseWithCharset(fields[0].([]interface{}), nil); err != nil { + t.Errorf("Cannot parse search criteria for #%v: %v", i+1, err) + } else if !reflect.DeepEqual(criteria, test.criteria) { + t.Errorf("Invalid search criteria for #%v: got \n%+v\n instead of \n%+v", i+1, criteria, test.criteria) + } + } +} + +var searchCriteriaParseTests = []struct { + fields []interface{} + criteria *SearchCriteria + charset func(io.Reader) io.Reader +}{ + { + fields: []interface{}{"ALL"}, + criteria: &SearchCriteria{}, + }, + { + fields: []interface{}{"NEW"}, + criteria: &SearchCriteria{ + WithFlags: []string{RecentFlag}, + WithoutFlags: []string{SeenFlag}, + }, + }, + { + fields: []interface{}{"SUBJECT", strings.NewReader("café")}, + criteria: &SearchCriteria{ + Header: textproto.MIMEHeader{"Subject": {"café"}}, + }, + charset: func(r io.Reader) io.Reader { + return r + }, + }, +} + +func TestSearchCriteria_Parse_others(t *testing.T) { + for i, test := range searchCriteriaParseTests { + criteria := new(SearchCriteria) + if err := criteria.ParseWithCharset(test.fields, test.charset); err != nil { + t.Errorf("Cannot parse search criteria for #%v: %v", i+1, err) + } else if !reflect.DeepEqual(criteria, test.criteria) { + t.Errorf("Invalid search criteria for #%v: got \n%+v\n instead of \n%+v", i+1, criteria, test.criteria) + } + } +} diff --git a/seqset.go b/seqset.go new file mode 100644 index 0000000..abe6afc --- /dev/null +++ b/seqset.go @@ -0,0 +1,289 @@ +package imap + +import ( + "fmt" + "strconv" + "strings" +) + +// ErrBadSeqSet is used to report problems with the format of a sequence set +// value. +type ErrBadSeqSet string + +func (err ErrBadSeqSet) Error() string { + return fmt.Sprintf("imap: bad sequence set value %q", string(err)) +} + +// Seq represents a single seq-number or seq-range value (RFC 3501 ABNF). Values +// may be static (e.g. "1", "2:4") or dynamic (e.g. "*", "1:*"). A seq-number is +// represented by setting Start = Stop. Zero is used to represent "*", which is +// safe because seq-number uses nz-number rule. The order of values is always +// Start <= Stop, except when representing "n:*", where Start = n and Stop = 0. +type Seq struct { + Start, Stop uint32 +} + +// parseSeqNumber parses a single seq-number value (non-zero uint32 or "*"). +func parseSeqNumber(v string) (uint32, error) { + if n, err := strconv.ParseUint(v, 10, 32); err == nil && v[0] != '0' { + return uint32(n), nil + } else if v == "*" { + return 0, nil + } + return 0, ErrBadSeqSet(v) +} + +// parseSeq creates a new seq instance by parsing strings in the format "n" or +// "n:m", where n and/or m may be "*". An error is returned for invalid values. +func parseSeq(v string) (s Seq, err error) { + if sep := strings.IndexRune(v, ':'); sep < 0 { + s.Start, err = parseSeqNumber(v) + s.Stop = s.Start + return + } else if s.Start, err = parseSeqNumber(v[:sep]); err == nil { + if s.Stop, err = parseSeqNumber(v[sep+1:]); err == nil { + if (s.Stop < s.Start && s.Stop != 0) || s.Start == 0 { + s.Start, s.Stop = s.Stop, s.Start + } + return + } + } + return s, ErrBadSeqSet(v) +} + +// Contains returns true if the seq-number q is contained in sequence value s. +// The dynamic value "*" contains only other "*" values, the dynamic range "n:*" +// contains "*" and all numbers >= n. +func (s Seq) Contains(q uint32) bool { + if q == 0 { + return s.Stop == 0 // "*" is contained only in "*" and "n:*" + } + return s.Start != 0 && s.Start <= q && (q <= s.Stop || s.Stop == 0) +} + +// Less returns true if s precedes and does not contain seq-number q. +func (s Seq) Less(q uint32) bool { + return (s.Stop < q || q == 0) && s.Stop != 0 +} + +// Merge combines sequence values s and t into a single union if the two +// intersect or one is a superset of the other. The order of s and t does not +// matter. If the values cannot be merged, s is returned unmodified and ok is +// set to false. +func (s Seq) Merge(t Seq) (union Seq, ok bool) { + if union = s; s == t { + ok = true + return + } + if s.Start != 0 && t.Start != 0 { + // s and t are any combination of "n", "n:m", or "n:*" + if s.Start > t.Start { + s, t = t, s + } + // s starts at or before t, check where it ends + if (s.Stop >= t.Stop && t.Stop != 0) || s.Stop == 0 { + return s, true // s is a superset of t + } + // s is "n" or "n:m", if m == ^uint32(0) then t is "n:*" + if s.Stop+1 >= t.Start || s.Stop == ^uint32(0) { + return Seq{s.Start, t.Stop}, true // s intersects or touches t + } + return + } + // exactly one of s and t is "*" + if s.Start == 0 { + if t.Stop == 0 { + return t, true // s is "*", t is "n:*" + } + } else if s.Stop == 0 { + return s, true // s is "n:*", t is "*" + } + return +} + +// String returns sequence value s as a seq-number or seq-range string. +func (s Seq) String() string { + if s.Start == s.Stop { + if s.Start == 0 { + return "*" + } + return strconv.FormatUint(uint64(s.Start), 10) + } + b := strconv.AppendUint(make([]byte, 0, 24), uint64(s.Start), 10) + if s.Stop == 0 { + return string(append(b, ':', '*')) + } + return string(strconv.AppendUint(append(b, ':'), uint64(s.Stop), 10)) +} + +// SeqSet is used to represent a set of message sequence numbers or UIDs (see +// sequence-set ABNF rule). The zero value is an empty set. +type SeqSet struct { + Set []Seq +} + +// ParseSeqSet returns a new SeqSet instance after parsing the set string. +func ParseSeqSet(set string) (s *SeqSet, err error) { + s = new(SeqSet) + return s, s.Add(set) +} + +// Add inserts new sequence values into the set. The string format is described +// by RFC 3501 sequence-set ABNF rule. If an error is encountered, all values +// inserted successfully prior to the error remain in the set. +func (s *SeqSet) Add(set string) error { + for _, sv := range strings.Split(set, ",") { + v, err := parseSeq(sv) + if err != nil { + return err + } + s.insert(v) + } + return nil +} + +// AddNum inserts new sequence numbers into the set. The value 0 represents "*". +func (s *SeqSet) AddNum(q ...uint32) { + for _, v := range q { + s.insert(Seq{v, v}) + } +} + +// AddRange inserts a new sequence range into the set. +func (s *SeqSet) AddRange(Start, Stop uint32) { + if (Stop < Start && Stop != 0) || Start == 0 { + s.insert(Seq{Stop, Start}) + } else { + s.insert(Seq{Start, Stop}) + } +} + +// AddSet inserts all values from t into s. +func (s *SeqSet) AddSet(t *SeqSet) { + for _, v := range t.Set { + s.insert(v) + } +} + +// Clear removes all values from the set. +func (s *SeqSet) Clear() { + s.Set = s.Set[:0] +} + +// Empty returns true if the sequence set does not contain any values. +func (s SeqSet) Empty() bool { + return len(s.Set) == 0 +} + +// Dynamic returns true if the set contains "*" or "n:*" values. +func (s SeqSet) Dynamic() bool { + return len(s.Set) > 0 && s.Set[len(s.Set)-1].Stop == 0 +} + +// Contains returns true if the non-zero sequence number or UID q is contained +// in the set. The dynamic range "n:*" contains all q >= n. It is the caller's +// responsibility to handle the special case where q is the maximum UID in the +// mailbox and q < n (i.e. the set cannot match UIDs against "*:n" or "*" since +// it doesn't know what the maximum value is). +func (s SeqSet) Contains(q uint32) bool { + if _, ok := s.search(q); ok { + return q != 0 + } + return false +} + +// String returns a sorted representation of all contained sequence values. +func (s SeqSet) String() string { + if len(s.Set) == 0 { + return "" + } + b := make([]byte, 0, 64) + for _, v := range s.Set { + b = append(b, ',') + if v.Start == 0 { + b = append(b, '*') + continue + } + b = strconv.AppendUint(b, uint64(v.Start), 10) + if v.Start != v.Stop { + if v.Stop == 0 { + b = append(b, ':', '*') + continue + } + b = strconv.AppendUint(append(b, ':'), uint64(v.Stop), 10) + } + } + return string(b[1:]) +} + +// insert adds sequence value v to the set. +func (s *SeqSet) insert(v Seq) { + i, _ := s.search(v.Start) + merged := false + if i > 0 { + // try merging with the preceding entry (e.g. "1,4".insert(2), i == 1) + s.Set[i-1], merged = s.Set[i-1].Merge(v) + } + if i == len(s.Set) { + // v was either merged with the last entry or needs to be appended + if !merged { + s.insertAt(i, v) + } + return + } else if merged { + i-- + } else if s.Set[i], merged = s.Set[i].Merge(v); !merged { + s.insertAt(i, v) // insert in the middle (e.g. "1,5".insert(3), i == 1) + return + } + // v was merged with s.Set[i], continue trying to merge until the end + for j := i + 1; j < len(s.Set); j++ { + if s.Set[i], merged = s.Set[i].Merge(s.Set[j]); !merged { + if j > i+1 { + // cut out all entries between i and j that were merged + s.Set = append(s.Set[:i+1], s.Set[j:]...) + } + return + } + } + // everything after s.Set[i] was merged + s.Set = s.Set[:i+1] +} + +// insertAt inserts a new sequence value v at index i, resizing s.Set as needed. +func (s *SeqSet) insertAt(i int, v Seq) { + if n := len(s.Set); i == n { + // insert at the end + s.Set = append(s.Set, v) + return + } else if n < cap(s.Set) { + // enough space, shift everything at and after i to the right + s.Set = s.Set[:n+1] + copy(s.Set[i+1:], s.Set[i:]) + } else { + // allocate new slice and copy everything, n is at least 1 + set := make([]Seq, n+1, n*2) + copy(set, s.Set[:i]) + copy(set[i+1:], s.Set[i:]) + s.Set = set + } + s.Set[i] = v +} + +// search attempts to find the index of the sequence set value that contains q. +// If no values contain q, the returned index is the position where q should be +// inserted and ok is set to false. +func (s SeqSet) search(q uint32) (i int, ok bool) { + min, max := 0, len(s.Set)-1 + for min < max { + if mid := (min + max) >> 1; s.Set[mid].Less(q) { + min = mid + 1 + } else { + max = mid + } + } + if max < 0 || s.Set[min].Less(q) { + return len(s.Set), false // q is the new largest value + } + return min, s.Set[min].Contains(q) +} diff --git a/internal/imapnum/numset_test.go b/seqset_test.go similarity index 79% rename from internal/imapnum/numset_test.go rename to seqset_test.go index 440abbb..4762bb3 100644 --- a/internal/imapnum/numset_test.go +++ b/seqset_test.go @@ -1,4 +1,4 @@ -package imapnum +package imap import ( "math/rand" @@ -8,79 +8,79 @@ import ( const max = ^uint32(0) -func TestParseNumRange(t *testing.T) { +func TestSeqParser(t *testing.T) { tests := []struct { in string - out Range + out Seq ok bool }{ // Invalid number - {"", Range{}, false}, - {" ", Range{}, false}, - {"A", Range{}, false}, - {"0", Range{}, false}, - {" 1", Range{}, false}, - {"1 ", Range{}, false}, - {"*1", Range{}, false}, - {"1*", Range{}, false}, - {"-1", Range{}, false}, - {"01", Range{}, false}, - {"0x1", Range{}, false}, - {"1 2", Range{}, false}, - {"1,2", Range{}, false}, - {"1.2", Range{}, false}, - {"4294967296", Range{}, false}, + {"", Seq{}, false}, + {" ", Seq{}, false}, + {"A", Seq{}, false}, + {"0", Seq{}, false}, + {" 1", Seq{}, false}, + {"1 ", Seq{}, false}, + {"*1", Seq{}, false}, + {"1*", Seq{}, false}, + {"-1", Seq{}, false}, + {"01", Seq{}, false}, + {"0x1", Seq{}, false}, + {"1 2", Seq{}, false}, + {"1,2", Seq{}, false}, + {"1.2", Seq{}, false}, + {"4294967296", Seq{}, false}, // Valid number - {"*", Range{0, 0}, true}, - {"1", Range{1, 1}, true}, - {"42", Range{42, 42}, true}, - {"1000", Range{1000, 1000}, true}, - {"4294967295", Range{max, max}, true}, + {"*", Seq{0, 0}, true}, + {"1", Seq{1, 1}, true}, + {"42", Seq{42, 42}, true}, + {"1000", Seq{1000, 1000}, true}, + {"4294967295", Seq{max, max}, true}, // Invalid range - {":", Range{}, false}, - {"*:", Range{}, false}, - {":*", Range{}, false}, - {"1:", Range{}, false}, - {":1", Range{}, false}, - {"0:0", Range{}, false}, - {"0:*", Range{}, false}, - {"0:1", Range{}, false}, - {"1:0", Range{}, false}, - {"1:2 ", Range{}, false}, - {"1: 2", Range{}, false}, - {"1:2:", Range{}, false}, - {"1:2,", Range{}, false}, - {"1:2:3", Range{}, false}, - {"1:2,3", Range{}, false}, - {"*:4294967296", Range{}, false}, - {"0:4294967295", Range{}, false}, - {"1:4294967296", Range{}, false}, - {"4294967296:*", Range{}, false}, - {"4294967295:0", Range{}, false}, - {"4294967296:1", Range{}, false}, - {"4294967295:4294967296", Range{}, false}, + {":", Seq{}, false}, + {"*:", Seq{}, false}, + {":*", Seq{}, false}, + {"1:", Seq{}, false}, + {":1", Seq{}, false}, + {"0:0", Seq{}, false}, + {"0:*", Seq{}, false}, + {"0:1", Seq{}, false}, + {"1:0", Seq{}, false}, + {"1:2 ", Seq{}, false}, + {"1: 2", Seq{}, false}, + {"1:2:", Seq{}, false}, + {"1:2,", Seq{}, false}, + {"1:2:3", Seq{}, false}, + {"1:2,3", Seq{}, false}, + {"*:4294967296", Seq{}, false}, + {"0:4294967295", Seq{}, false}, + {"1:4294967296", Seq{}, false}, + {"4294967296:*", Seq{}, false}, + {"4294967295:0", Seq{}, false}, + {"4294967296:1", Seq{}, false}, + {"4294967295:4294967296", Seq{}, false}, // Valid range - {"*:*", Range{0, 0}, true}, - {"1:*", Range{1, 0}, true}, - {"*:1", Range{1, 0}, true}, - {"2:2", Range{2, 2}, true}, - {"2:42", Range{2, 42}, true}, - {"42:2", Range{2, 42}, true}, - {"*:4294967294", Range{max - 1, 0}, true}, - {"*:4294967295", Range{max, 0}, true}, - {"4294967294:*", Range{max - 1, 0}, true}, - {"4294967295:*", Range{max, 0}, true}, - {"1:4294967294", Range{1, max - 1}, true}, - {"1:4294967295", Range{1, max}, true}, - {"4294967295:1000", Range{1000, max}, true}, - {"4294967294:4294967295", Range{max - 1, max}, true}, - {"4294967295:4294967295", Range{max, max}, true}, + {"*:*", Seq{0, 0}, true}, + {"1:*", Seq{1, 0}, true}, + {"*:1", Seq{1, 0}, true}, + {"2:2", Seq{2, 2}, true}, + {"2:42", Seq{2, 42}, true}, + {"42:2", Seq{2, 42}, true}, + {"*:4294967294", Seq{max - 1, 0}, true}, + {"*:4294967295", Seq{max, 0}, true}, + {"4294967294:*", Seq{max - 1, 0}, true}, + {"4294967295:*", Seq{max, 0}, true}, + {"1:4294967294", Seq{1, max - 1}, true}, + {"1:4294967295", Seq{1, max}, true}, + {"4294967295:1000", Seq{1000, max}, true}, + {"4294967294:4294967295", Seq{max - 1, max}, true}, + {"4294967295:4294967295", Seq{max, max}, true}, } for _, test := range tests { - out, err := parseNumRange(test.in) + out, err := parseSeq(test.in) if !test.ok { if err == nil { t.Errorf("parseSeq(%q) expected error; got %q", test.in, out) @@ -93,7 +93,7 @@ func TestParseNumRange(t *testing.T) { } } -func TestNumRangeContainsLess(t *testing.T) { +func TestSeqContainsLess(t *testing.T) { tests := []struct { s string q uint32 @@ -143,7 +143,7 @@ func TestNumRangeContainsLess(t *testing.T) { {"4:*", max, true, false}, } for _, test := range tests { - s, err := parseNumRange(test.s) + s, err := parseSeq(test.s) if err != nil { t.Errorf("parseSeq(%q) unexpected error; %v", test.s, err) continue @@ -157,7 +157,7 @@ func TestNumRangeContainsLess(t *testing.T) { } } -func TestNumRangeMerge(T *testing.T) { +func TestSeqMerge(T *testing.T) { tests := []struct { s, t, out string }{ @@ -340,23 +340,23 @@ func TestNumRangeMerge(T *testing.T) { {"1:4294967295", "2:*", "1:*"}, } for _, test := range tests { - s, err := parseNumRange(test.s) + s, err := parseSeq(test.s) if err != nil { T.Errorf("parseSeq(%q) unexpected error; %v", test.s, err) continue } - t, err := parseNumRange(test.t) + t, err := parseSeq(test.t) if err != nil { T.Errorf("parseSeq(%q) unexpected error; %v", test.t, err) continue } - testOK := test.out != "" + test_ok := test.out != "" for i := 0; i < 2; i++ { - if !testOK { + if !test_ok { test.out = test.s } out, ok := s.Merge(t) - if out.String() != test.out || ok != testOK { + if out.String() != test.out || ok != test_ok { T.Errorf("%q.Merge(%q) expected %q; got %q", test.s, test.t, test.out, out) } // Swap s & t, result should be identical @@ -366,31 +366,31 @@ func TestNumRangeMerge(T *testing.T) { } } -func checkNumSet(s Set, t *testing.T) { - n := len(s) - for i, v := range s { +func checkSeqSet(s *SeqSet, t *testing.T) { + n := len(s.Set) + for i, v := range s.Set { if v.Start == 0 { if v.Stop != 0 { - t.Errorf(`NumSet(%q) index %d: "*:n" range`, s, i) + t.Errorf(`SeqSet(%q) index %d: "*:n" range`, s, i) } else if i != n-1 { - t.Errorf(`NumSet(%q) index %d: "*" not at the end`, s, i) + t.Errorf(`SeqSet(%q) index %d: "*" not at the end`, s, i) } continue } - if i > 0 && s[i-1].Stop >= v.Start-1 { - t.Errorf(`NumSet(%q) index %d: overlap`, s, i) + if i > 0 && s.Set[i-1].Stop >= v.Start-1 { + t.Errorf(`SeqSet(%q) index %d: overlap`, s, i) } if v.Stop < v.Start { if v.Stop != 0 { - t.Errorf(`NumSet(%q) index %d: reversed range`, s, i) + t.Errorf(`SeqSet(%q) index %d: reversed range`, s, i) } else if i != n-1 { - t.Errorf(`NumSet(%q) index %d: "n:*" not at the end`, s, i) + t.Errorf(`SeqSet(%q) index %d: "n:*" not at the end`, s, i) } } } } -func TestNumSetInfo(t *testing.T) { +func TestSeqSetInfo(t *testing.T) { tests := []struct { s string q uint32 @@ -531,26 +531,26 @@ func TestNumSetInfo(t *testing.T) { {"1,3:5,7,9,42,60:70,100:*", max, true}, } for _, test := range tests { - s, _ := ParseSet(test.s) - checkNumSet(s, t) + s, _ := ParseSeqSet(test.s) + checkSeqSet(s, t) if s.Contains(test.q) != test.contains { t.Errorf("%q.Contains(%v) expected %v", test.s, test.q, test.contains) } if str := s.String(); str != test.s { t.Errorf("%q.String() expected %q; got %q", test.s, test.s, str) } - testEmpty := len(test.s) == 0 - if (len(s) == 0) != testEmpty { - t.Errorf("%q.Empty() expected %v", test.s, testEmpty) + test_empty := len(test.s) == 0 + if s.Empty() != test_empty { + t.Errorf("%q.Empty() expected %v", test.s, test_empty) } - testDynamic := !testEmpty && test.s[len(test.s)-1] == '*' - if s.Dynamic() != testDynamic { - t.Errorf("%q.Dynamic() expected %v", test.s, testDynamic) + test_dynamic := !test_empty && test.s[len(test.s)-1] == '*' + if s.Dynamic() != test_dynamic { + t.Errorf("%q.Dynamic() expected %v", test.s, test_dynamic) } } } -func TestParseNumSet(t *testing.T) { +func TestSeqSetAdd(t *testing.T) { tests := []struct { in string out string @@ -673,12 +673,12 @@ func TestParseNumSet(t *testing.T) { } for _, test := range tests { for i := 0; i < 100 && test.in != ""; i++ { - s, err := ParseSet(test.in) - if err != nil { + s := &SeqSet{} + if err := s.Add(test.in); err != nil { t.Errorf("Add(%q) unexpected error; %v", test.in, err) i = 100 } - checkNumSet(s, t) + checkSeqSet(s, t) if out := s.String(); out != test.out { t.Errorf("%q.String() expected %q; got %q", test.in, test.out, out) i = 100 @@ -688,34 +688,34 @@ func TestParseNumSet(t *testing.T) { } } -func TestNumSetAddNumRangeSet(t *testing.T) { +func TestSeqSetAddNumRangeSet(t *testing.T) { type num []uint32 tests := []struct { num num - rng Range + rng Seq set string out string }{ - {num{5}, Range{1, 3}, "1:2,5,7:13,15,17:*", "1:3,5,7:13,15,17:*"}, - {num{5}, Range{3, 1}, "2:3,7:13,15,17:*", "1:3,5,7:13,15,17:*"}, + {num{5}, Seq{1, 3}, "1:2,5,7:13,15,17:*", "1:3,5,7:13,15,17:*"}, + {num{5}, Seq{3, 1}, "2:3,7:13,15,17:*", "1:3,5,7:13,15,17:*"}, - {num{15}, Range{17, 0}, "1:3,5,7:13", "1:3,5,7:13,15,17:*"}, - {num{15}, Range{0, 17}, "1:3,5,7:13", "1:3,5,7:13,15,17:*"}, + {num{15}, Seq{17, 0}, "1:3,5,7:13", "1:3,5,7:13,15,17:*"}, + {num{15}, Seq{0, 17}, "1:3,5,7:13", "1:3,5,7:13,15,17:*"}, - {num{1, 3, 5, 7, 9, 11, 0}, Range{8, 13}, "2,15,17:*", "1:3,5,7:13,15,17:*"}, - {num{5, 1, 7, 3, 9, 0, 11}, Range{8, 13}, "2,15,17:*", "1:3,5,7:13,15,17:*"}, - {num{5, 1, 7, 3, 9, 0, 11}, Range{13, 8}, "2,15,17:*", "1:3,5,7:13,15,17:*"}, + {num{1, 3, 5, 7, 9, 11, 0}, Seq{8, 13}, "2,15,17:*", "1:3,5,7:13,15,17:*"}, + {num{5, 1, 7, 3, 9, 0, 11}, Seq{8, 13}, "2,15,17:*", "1:3,5,7:13,15,17:*"}, + {num{5, 1, 7, 3, 9, 0, 11}, Seq{13, 8}, "2,15,17:*", "1:3,5,7:13,15,17:*"}, } for _, test := range tests { - other, _ := ParseSet(test.set) + other, _ := ParseSeqSet(test.set) - var s Set + s := &SeqSet{} s.AddNum(test.num...) - checkNumSet(s, t) + checkSeqSet(s, t) s.AddRange(test.rng.Start, test.rng.Stop) - checkNumSet(s, t) + checkSeqSet(s, t) s.AddSet(other) - checkNumSet(s, t) + checkSeqSet(s, t) if out := s.String(); out != test.out { t.Errorf("(%v + %v + %q).String() expected %q; got %q", test.num, test.rng, test.set, test.out, out) diff --git a/server/cmd_any.go b/server/cmd_any.go new file mode 100644 index 0000000..f79492c --- /dev/null +++ b/server/cmd_any.go @@ -0,0 +1,52 @@ +package server + +import ( + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" +) + +type Capability struct { + commands.Capability +} + +func (cmd *Capability) Handle(conn Conn) error { + res := &responses.Capability{Caps: conn.Capabilities()} + return conn.WriteResp(res) +} + +type Noop struct { + commands.Noop +} + +func (cmd *Noop) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox != nil { + // If a mailbox is selected, NOOP can be used to poll for server updates + if mbox, ok := ctx.Mailbox.(backend.MailboxPoller); ok { + return mbox.Poll() + } + } + + return nil +} + +type Logout struct { + commands.Logout +} + +func (cmd *Logout) Handle(conn Conn) error { + res := &imap.StatusResp{ + Type: imap.StatusRespBye, + Info: "Closing connection", + } + + if err := conn.WriteResp(res); err != nil { + return err + } + + // Request to close the connection + conn.Context().State = imap.LogoutState + return nil +} diff --git a/server/cmd_any_test.go b/server/cmd_any_test.go new file mode 100644 index 0000000..db5bebf --- /dev/null +++ b/server/cmd_any_test.go @@ -0,0 +1,129 @@ +package server_test + +import ( + "bufio" + "io" + "net" + "strings" + "testing" + + "github.com/emersion/go-imap/server" + "github.com/emersion/go-sasl" +) + +func testServerGreeted(t *testing.T) (s *server.Server, c net.Conn, scanner *bufio.Scanner) { + s, c = testServer(t) + scanner = bufio.NewScanner(c) + + scanner.Scan() // Greeting + return +} + +func TestCapability(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CAPABILITY\r\n") + + scanner.Scan() + if scanner.Text() != "* CAPABILITY IMAP4rev1 "+builtinExtensions+" AUTH=PLAIN" { + t.Fatal("Bad capability:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestNoop(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 NOOP\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestLogout(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 LOGOUT\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "* BYE ") { + t.Fatal("Bad BYE response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +type xnoop struct{} + +func (ext *xnoop) Capabilities(server.Conn) []string { + return []string{"XNOOP"} +} + +func (ext *xnoop) Command(string) server.HandlerFactory { + return nil +} + +func TestServer_Enable(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + s.Enable(&xnoop{}) + + io.WriteString(c, "a001 CAPABILITY\r\n") + + scanner.Scan() + if scanner.Text() != "* CAPABILITY IMAP4rev1 "+builtinExtensions+" AUTH=PLAIN XNOOP" { + t.Fatal("Bad capability:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +type xnoopAuth struct{} + +func (ext *xnoopAuth) Next(response []byte) (challenge []byte, done bool, err error) { + done = true + return +} + +func TestServer_EnableAuth(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + s.EnableAuth("XNOOP", func(server.Conn) sasl.Server { + return &xnoopAuth{} + }) + + io.WriteString(c, "a001 CAPABILITY\r\n") + + scanner.Scan() + if scanner.Text() != "* CAPABILITY IMAP4rev1 "+builtinExtensions+" AUTH=PLAIN AUTH=XNOOP" && + scanner.Text() != "* CAPABILITY IMAP4rev1 "+builtinExtensions+" AUTH=XNOOP AUTH=PLAIN" { + t.Fatal("Bad capability:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} diff --git a/server/cmd_auth.go b/server/cmd_auth.go new file mode 100644 index 0000000..808e39b --- /dev/null +++ b/server/cmd_auth.go @@ -0,0 +1,324 @@ +package server + +import ( + "bufio" + "errors" + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" +) + +// imap errors in Authenticated state. +var ( + ErrNotAuthenticated = errors.New("Not authenticated") +) + +type Select struct { + commands.Select +} + +func (cmd *Select) Handle(conn Conn) error { + ctx := conn.Context() + + // As per RFC1730#6.3.1, + // The SELECT command automatically deselects any + // currently selected mailbox before attempting the new selection. + // Consequently, if a mailbox is selected and a SELECT command that + // fails is attempted, no mailbox is selected. + // For example, some clients (e.g. Apple Mail) perform SELECT "" when the + // server doesn't announce the UNSELECT capability. + ctx.Mailbox = nil + ctx.MailboxReadOnly = false + + if ctx.User == nil { + return ErrNotAuthenticated + } + mbox, err := ctx.User.GetMailbox(cmd.Mailbox) + if err != nil { + return err + } + + items := []imap.StatusItem{ + imap.StatusMessages, imap.StatusRecent, imap.StatusUnseen, + imap.StatusUidNext, imap.StatusUidValidity, + } + + status, err := mbox.Status(items) + if err != nil { + return err + } + + ctx.Mailbox = mbox + ctx.MailboxReadOnly = cmd.ReadOnly || status.ReadOnly + + res := &responses.Select{Mailbox: status} + if err := conn.WriteResp(res); err != nil { + return err + } + + var code imap.StatusRespCode = imap.CodeReadWrite + if ctx.MailboxReadOnly { + code = imap.CodeReadOnly + } + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespOk, + Code: code, + }) +} + +type Create struct { + commands.Create +} + +func (cmd *Create) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.User == nil { + return ErrNotAuthenticated + } + + return ctx.User.CreateMailbox(cmd.Mailbox) +} + +type Delete struct { + commands.Delete +} + +func (cmd *Delete) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.User == nil { + return ErrNotAuthenticated + } + + return ctx.User.DeleteMailbox(cmd.Mailbox) +} + +type Rename struct { + commands.Rename +} + +func (cmd *Rename) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.User == nil { + return ErrNotAuthenticated + } + + return ctx.User.RenameMailbox(cmd.Existing, cmd.New) +} + +type Subscribe struct { + commands.Subscribe +} + +func (cmd *Subscribe) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.User == nil { + return ErrNotAuthenticated + } + + mbox, err := ctx.User.GetMailbox(cmd.Mailbox) + if err != nil { + return err + } + + return mbox.SetSubscribed(true) +} + +type Unsubscribe struct { + commands.Unsubscribe +} + +func (cmd *Unsubscribe) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.User == nil { + return ErrNotAuthenticated + } + + mbox, err := ctx.User.GetMailbox(cmd.Mailbox) + if err != nil { + return err + } + + return mbox.SetSubscribed(false) +} + +type List struct { + commands.List +} + +func (cmd *List) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.User == nil { + return ErrNotAuthenticated + } + + ch := make(chan *imap.MailboxInfo) + res := &responses.List{Mailboxes: ch, Subscribed: cmd.Subscribed} + + done := make(chan error, 1) + go (func() { + done <- conn.WriteResp(res) + // Make sure to drain the channel. + for range ch { + } + })() + + mailboxes, err := ctx.User.ListMailboxes(cmd.Subscribed) + if err != nil { + // Close channel to signal end of results + close(ch) + return err + } + + for _, mbox := range mailboxes { + info, err := mbox.Info() + if err != nil { + // Close channel to signal end of results + close(ch) + return err + } + + // An empty ("" string) mailbox name argument is a special request to return + // the hierarchy delimiter and the root name of the name given in the + // reference. + if cmd.Mailbox == "" { + ch <- &imap.MailboxInfo{ + Attributes: []string{imap.NoSelectAttr}, + Delimiter: info.Delimiter, + Name: info.Delimiter, + } + break + } + + if info.Match(cmd.Reference, cmd.Mailbox) { + ch <- info + } + } + // Close channel to signal end of results + close(ch) + + return <-done +} + +type Status struct { + commands.Status +} + +func (cmd *Status) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.User == nil { + return ErrNotAuthenticated + } + + mbox, err := ctx.User.GetMailbox(cmd.Mailbox) + if err != nil { + return err + } + + status, err := mbox.Status(cmd.Items) + if err != nil { + return err + } + + // Only keep items thqat have been requested + items := make(map[imap.StatusItem]interface{}) + for _, k := range cmd.Items { + items[k] = status.Items[k] + } + status.Items = items + + res := &responses.Status{Mailbox: status} + return conn.WriteResp(res) +} + +type Append struct { + commands.Append +} + +func (cmd *Append) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.User == nil { + return ErrNotAuthenticated + } + + mbox, err := ctx.User.GetMailbox(cmd.Mailbox) + if err == backend.ErrNoSuchMailbox { + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespNo, + Code: imap.CodeTryCreate, + Info: err.Error(), + }) + } else if err != nil { + return err + } + + if err := mbox.CreateMessage(cmd.Flags, cmd.Date, cmd.Message); err != nil { + if err == backend.ErrTooBig { + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespNo, + Code: "TOOBIG", + Info: "Message size exceeding limit", + }) + } + return err + } + + // If APPEND targets the currently selected mailbox, send an untagged EXISTS + // Do this only if the backend doesn't send updates itself + if conn.Server().Updates == nil && ctx.Mailbox != nil && ctx.Mailbox.Name() == mbox.Name() { + status, err := mbox.Status([]imap.StatusItem{imap.StatusMessages}) + if err != nil { + return err + } + status.Flags = nil + status.PermanentFlags = nil + status.UnseenSeqNum = 0 + + res := &responses.Select{Mailbox: status} + if err := conn.WriteResp(res); err != nil { + return err + } + } + + return nil +} + +type Unselect struct { + commands.Unselect +} + +func (cmd *Unselect) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + + ctx.Mailbox = nil + ctx.MailboxReadOnly = false + return nil +} + +type Idle struct { + commands.Idle +} + +func (cmd *Idle) Handle(conn Conn) error { + cont := &imap.ContinuationReq{Info: "idling"} + if err := conn.WriteResp(cont); err != nil { + return err + } + + // Wait for DONE + scanner := bufio.NewScanner(conn) + scanner.Scan() + if err := scanner.Err(); err != nil { + return err + } + + if strings.ToUpper(scanner.Text()) != "DONE" { + return errors.New("Expected DONE") + } + return nil +} diff --git a/server/cmd_auth_test.go b/server/cmd_auth_test.go new file mode 100644 index 0000000..f8ad616 --- /dev/null +++ b/server/cmd_auth_test.go @@ -0,0 +1,611 @@ +package server_test + +import ( + "bufio" + "io" + "net" + "strings" + "testing" + + "github.com/emersion/go-imap/server" +) + +func testServerAuthenticated(t *testing.T) (s *server.Server, c net.Conn, scanner *bufio.Scanner) { + s, c, scanner = testServerGreeted(t) + + io.WriteString(c, "a000 LOGIN username password\r\n") + scanner.Scan() // OK response + return +} + +func TestSelect_Ok(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 SELECT INBOX\r\n") + + got := map[string]bool{ + "OK": false, + "FLAGS": false, + "EXISTS": false, + "RECENT": false, + "PERMANENTFLAGS": false, + "UIDNEXT": false, + "UIDVALIDITY": false, + } + + for scanner.Scan() { + res := scanner.Text() + + if res == "* FLAGS (\\Seen)" { + got["FLAGS"] = true + } else if res == "* 1 EXISTS" { + got["EXISTS"] = true + } else if res == "* 0 RECENT" { + got["RECENT"] = true + } else if strings.HasPrefix(res, "* OK [PERMANENTFLAGS (\\*)]") { + got["PERMANENTFLAGS"] = true + } else if strings.HasPrefix(res, "* OK [UIDNEXT 7]") { + got["UIDNEXT"] = true + } else if strings.HasPrefix(res, "* OK [UIDVALIDITY 1]") { + got["UIDVALIDITY"] = true + } else if strings.HasPrefix(res, "a001 OK [READ-WRITE] ") { + got["OK"] = true + break + } else { + t.Fatal("Unexpected response:", res) + } + } + + for name, val := range got { + if !val { + t.Error("Did not got response:", name) + } + } +} + +func TestSelect_ReadOnly(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 EXAMINE INBOX\r\n") + + gotOk := true + for scanner.Scan() { + res := scanner.Text() + + if strings.HasPrefix(res, "a001 OK [READ-ONLY]") { + gotOk = true + break + } + } + + if !gotOk { + t.Error("Did not get a correct OK response") + } +} + +func TestSelect_InvalidMailbox(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 SELECT idontexist\r\n") + + scanner.Scan() + + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestSelect_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 SELECT INBOX\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestCreate(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CREATE test\r\n") + scanner.Scan() + + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestCreate_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CREATE test\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestDelete(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CREATE test\r\n") + scanner.Scan() + + io.WriteString(c, "a001 DELETE test\r\n") + scanner.Scan() + + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestDelete_InvalidMailbox(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 DELETE test\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestDelete_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 DELETE INBOX\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestRename(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CREATE test\r\n") + scanner.Scan() + + io.WriteString(c, "a001 RENAME test test2\r\n") + scanner.Scan() + + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestRename_InvalidMailbox(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 RENAME test test2\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestRename_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 RENAME test test2\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestSubscribe(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 SUBSCRIBE INBOX\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 SUBSCRIBE idontexist\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestSubscribe_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 SUBSCRIBE INBOX\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestUnsubscribe(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 SUBSCRIBE INBOX\r\n") + scanner.Scan() + + io.WriteString(c, "a001 UNSUBSCRIBE INBOX\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 UNSUBSCRIBE idontexist\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestUnsubscribe_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 UNSUBSCRIBE INBOX\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestList(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 LIST \"\" *\r\n") + + scanner.Scan() + if scanner.Text() != "* LIST () \"/\" INBOX" { + t.Fatal("Invalid LIST response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestList_Nested(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CREATE first\r\n") + scanner.Scan() + io.WriteString(c, "a001 CREATE first/second\r\n") + scanner.Scan() + io.WriteString(c, "a001 CREATE first/second/third\r\n") + scanner.Scan() + io.WriteString(c, "a001 CREATE first/second/third2\r\n") + scanner.Scan() + + check := func(mailboxes []string) { + checked := map[string]bool{} + + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "a001 OK ") { + break + } else if strings.HasPrefix(scanner.Text(), "* LIST ") { + found := false + for _, name := range mailboxes { + if strings.HasSuffix(scanner.Text(), " \""+name+"\"") || strings.HasSuffix(scanner.Text(), " "+name) { + checked[name] = true + found = true + break + } + } + + if !found { + t.Fatal("Unexpected mailbox:", scanner.Text()) + } + } else { + t.Fatal("Invalid LIST response:", scanner.Text()) + } + } + + for _, name := range mailboxes { + if !checked[name] { + t.Fatal("Missing mailbox:", name) + } + } + } + + io.WriteString(c, "a001 LIST \"\" *\r\n") + check([]string{"INBOX", "first", "first/second", "first/second/third", "first/second/third2"}) + + io.WriteString(c, "a001 LIST \"\" %\r\n") + check([]string{"INBOX", "first"}) + + io.WriteString(c, "a001 LIST first *\r\n") + check([]string{"first/second", "first/second/third", "first/second/third2"}) + + io.WriteString(c, "a001 LIST first %\r\n") + check([]string{"first/second"}) + + io.WriteString(c, "a001 LIST first/second *\r\n") + check([]string{"first/second/third", "first/second/third2"}) + + io.WriteString(c, "a001 LIST first/second %\r\n") + check([]string{"first/second/third", "first/second/third2"}) + + io.WriteString(c, "a001 LIST first second\r\n") + check([]string{"first/second"}) + + io.WriteString(c, "a001 LIST first/second third\r\n") + check([]string{"first/second/third"}) +} + +func TestList_Subscribed(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 LSUB \"\" *\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 SUBSCRIBE INBOX\r\n") + scanner.Scan() + + io.WriteString(c, "a001 LSUB \"\" *\r\n") + + scanner.Scan() + if scanner.Text() != "* LSUB () \"/\" INBOX" { + t.Fatal("Invalid LSUB response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestTLS_AlreadyAuthenticated(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STARTTLS\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestList_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 LIST \"\" *\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestList_Delimiter(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 LIST \"\" \"\"\r\n") + + scanner.Scan() + if scanner.Text() != "* LIST (\\Noselect) \"/\" \"/\"" { + t.Fatal("Invalid LIST response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStatus(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STATUS INBOX (MESSAGES RECENT UIDNEXT UIDVALIDITY UNSEEN)\r\n") + + scanner.Scan() + line := scanner.Text() + if !strings.HasPrefix(line, "* STATUS INBOX (") { + t.Fatal("Invalid STATUS response:", line) + } + parts := []string{"MESSAGES 1", "RECENT 0", "UIDNEXT 7", "UIDVALIDITY 1", "UNSEEN 0"} + for _, p := range parts { + if !strings.Contains(line, p) { + t.Fatal("Invalid STATUS response:", line) + } + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStatus_InvalidMailbox(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STATUS idontexist (MESSAGES)\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStatus_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STATUS INBOX (MESSAGES)\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestAppend(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 APPEND INBOX {80}\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "+ ") { + t.Fatal("Invalid continuation request:", scanner.Text()) + } + + io.WriteString(c, "From: Edward Snowden \r\n") + io.WriteString(c, "To: Julian Assange \r\n") + io.WriteString(c, "\r\n") + io.WriteString(c, "<3\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestAppend_WithFlags(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 APPEND INBOX (\\Draft) {11}\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "+ ") { + t.Fatal("Invalid continuation request:", scanner.Text()) + } + + io.WriteString(c, "Hello World\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestAppend_WithFlagsAndDate(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 APPEND INBOX (\\Draft) \"5-Nov-1984 13:37:00 -0700\" {11}\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "+ ") { + t.Fatal("Invalid continuation request:", scanner.Text()) + } + + io.WriteString(c, "Hello World\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestAppend_Selected(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 APPEND INBOX {11}\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "+ ") { + t.Fatal("Invalid continuation request:", scanner.Text()) + } + + io.WriteString(c, "Hello World\r\n") + + scanner.Scan() + if scanner.Text() != "* 2 EXISTS" { + t.Fatal("Invalid untagged response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestAppend_InvalidMailbox(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 APPEND idontexist {11}\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "+ ") { + t.Fatal("Invalid continuation request:", scanner.Text()) + } + + io.WriteString(c, "Hello World\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestAppend_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 APPEND INBOX {11}\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "+ ") { + t.Fatal("Invalid continuation request:", scanner.Text()) + } + + io.WriteString(c, "Hello World\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} diff --git a/server/cmd_noauth.go b/server/cmd_noauth.go new file mode 100644 index 0000000..ac221cc --- /dev/null +++ b/server/cmd_noauth.go @@ -0,0 +1,132 @@ +package server + +import ( + "crypto/tls" + "errors" + "net" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-sasl" +) + +// IMAP errors in Not Authenticated state. +var ( + ErrAlreadyAuthenticated = errors.New("Already authenticated") + ErrAuthDisabled = errors.New("Authentication disabled") +) + +type StartTLS struct { + commands.StartTLS +} + +func (cmd *StartTLS) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.State != imap.NotAuthenticatedState { + return ErrAlreadyAuthenticated + } + if conn.IsTLS() { + return errors.New("TLS is already enabled") + } + if conn.Server().TLSConfig == nil { + return errors.New("TLS support not enabled") + } + + // Send an OK status response to let the client know that the TLS handshake + // can begin + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespOk, + Info: "Begin TLS negotiation now", + }) +} + +func (cmd *StartTLS) Upgrade(conn Conn) error { + tlsConfig := conn.Server().TLSConfig + + var tlsConn *tls.Conn + err := conn.Upgrade(func(sock net.Conn) (net.Conn, error) { + conn.WaitReady() + tlsConn = tls.Server(sock, tlsConfig) + err := tlsConn.Handshake() + return tlsConn, err + }) + if err != nil { + return err + } + + conn.setTLSConn(tlsConn) + + return nil +} + +func afterAuthStatus(conn Conn) error { + caps := conn.Capabilities() + capAtoms := make([]interface{}, 0, len(caps)) + for _, cap := range caps { + capAtoms = append(capAtoms, imap.RawString(cap)) + } + + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespOk, + Code: imap.CodeCapability, + Arguments: capAtoms, + }) +} + +func canAuth(conn Conn) bool { + for _, cap := range conn.Capabilities() { + if cap == "AUTH=PLAIN" { + return true + } + } + return false +} + +type Login struct { + commands.Login +} + +func (cmd *Login) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.State != imap.NotAuthenticatedState { + return ErrAlreadyAuthenticated + } + if !canAuth(conn) { + return ErrAuthDisabled + } + + user, err := conn.Server().Backend.Login(conn.Info(), cmd.Username, cmd.Password) + if err != nil { + return err + } + + ctx.State = imap.AuthenticatedState + ctx.User = user + return afterAuthStatus(conn) +} + +type Authenticate struct { + commands.Authenticate +} + +func (cmd *Authenticate) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.State != imap.NotAuthenticatedState { + return ErrAlreadyAuthenticated + } + if !canAuth(conn) { + return ErrAuthDisabled + } + + mechanisms := map[string]sasl.Server{} + for name, newSasl := range conn.Server().auths { + mechanisms[name] = newSasl(conn) + } + + err := cmd.Authenticate.Handle(mechanisms, conn) + if err != nil { + return err + } + + return afterAuthStatus(conn) +} diff --git a/server/cmd_noauth_test.go b/server/cmd_noauth_test.go new file mode 100644 index 0000000..b762e67 --- /dev/null +++ b/server/cmd_noauth_test.go @@ -0,0 +1,225 @@ +package server_test + +import ( + "bufio" + "crypto/tls" + "io" + "net" + "strings" + "testing" + + "github.com/emersion/go-imap/internal" + "github.com/emersion/go-imap/server" +) + +func testServerTLS(t *testing.T) (s *server.Server, c net.Conn, scanner *bufio.Scanner) { + s, c, scanner = testServerGreeted(t) + + cert, err := tls.X509KeyPair(internal.LocalhostCert, internal.LocalhostKey) + if err != nil { + t.Fatal(err) + } + + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{cert}, + } + + s.AllowInsecureAuth = false + s.TLSConfig = tlsConfig + + io.WriteString(c, "a001 CAPABILITY\r\n") + scanner.Scan() + if scanner.Text() != "* CAPABILITY IMAP4rev1 "+builtinExtensions+" STARTTLS LOGINDISABLED" { + t.Fatal("Bad CAPABILITY response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } + + io.WriteString(c, "a001 STARTTLS\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } + sc := tls.Client(c, tlsConfig) + if err = sc.Handshake(); err != nil { + t.Fatal(err) + } + + c = sc + scanner = bufio.NewScanner(c) + return +} + +func TestStartTLS(t *testing.T) { + s, c, scanner := testServerTLS(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CAPABILITY\r\n") + + scanner.Scan() + if scanner.Text() != "* CAPABILITY IMAP4rev1 "+builtinExtensions+" AUTH=PLAIN" { + t.Fatal("Bad CAPABILITY response:", scanner.Text()) + } +} + +func TestStartTLS_AlreadyEnabled(t *testing.T) { + s, c, scanner := testServerTLS(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STARTTLS\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestStartTLS_NotSupported(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STARTTLS\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestLogin_Ok(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 LOGIN username password\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestLogin_AlreadyAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 LOGIN username password\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } + + io.WriteString(c, "a001 LOGIN username password\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestLogin_No(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 LOGIN username wrongpassword\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestAuthenticate_Plain_Ok(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 AUTHENTICATE PLAIN\r\n") + + scanner.Scan() + if scanner.Text() != "+" { + t.Fatal("Bad continuation request:", scanner.Text()) + } + + // :usename:password + io.WriteString(c, "AHVzZXJuYW1lAHBhc3N3b3Jk\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestAuthenticate_Plain_Cancel(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 AUTHENTICATE PLAIN\r\n") + + scanner.Scan() + if scanner.Text() != "+" { + t.Fatal("Bad continuation request:", scanner.Text()) + } + + io.WriteString(c, "*\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 BAD negotiation cancelled") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestAuthenticate_Plain_InitialResponse(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 AUTHENTICATE PLAIN AHVzZXJuYW1lAHBhc3N3b3Jk\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestAuthenticate_Plain_No(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 AUTHENTICATE PLAIN\r\n") + + scanner.Scan() + if scanner.Text() != "+" { + t.Fatal("Bad continuation request:", scanner.Text()) + } + + // Invalid challenge + io.WriteString(c, "BHVzZXJuYW1lAHBhc3N3b6Jk\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestAuthenticate_No(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 AUTHENTICATE XIDONTEXIST\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} diff --git a/server/cmd_selected.go b/server/cmd_selected.go new file mode 100644 index 0000000..1d77102 --- /dev/null +++ b/server/cmd_selected.go @@ -0,0 +1,346 @@ +package server + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" +) + +// imap errors in Selected state. +var ( + ErrNoMailboxSelected = errors.New("No mailbox selected") + ErrMailboxReadOnly = errors.New("Mailbox opened in read-only mode") +) + +// A command handler that supports UIDs. +type UidHandler interface { + Handler + + // Handle this command using UIDs for a given connection. + UidHandle(conn Conn) error +} + +type Check struct { + commands.Check +} + +func (cmd *Check) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + if ctx.MailboxReadOnly { + return ErrMailboxReadOnly + } + + return ctx.Mailbox.Check() +} + +type Close struct { + commands.Close +} + +func (cmd *Close) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + + mailbox := ctx.Mailbox + ctx.Mailbox = nil + ctx.MailboxReadOnly = false + + // No need to send expunge updates here, since the mailbox is already unselected + return mailbox.Expunge() +} + +type Expunge struct { + commands.Expunge +} + +func (cmd *Expunge) Handle(conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + if ctx.MailboxReadOnly { + return ErrMailboxReadOnly + } + + // Get a list of messages that will be deleted + // That will allow us to send expunge updates if the backend doesn't support it + var seqnums []uint32 + if conn.Server().Updates == nil { + criteria := &imap.SearchCriteria{ + WithFlags: []string{imap.DeletedFlag}, + } + + var err error + seqnums, err = ctx.Mailbox.SearchMessages(false, criteria) + if err != nil { + return err + } + } + + if err := ctx.Mailbox.Expunge(); err != nil { + return err + } + + // If the backend doesn't support expunge updates, let's do it ourselves + if conn.Server().Updates == nil { + done := make(chan error, 1) + + ch := make(chan uint32) + res := &responses.Expunge{SeqNums: ch} + + go (func() { + done <- conn.WriteResp(res) + // Don't need to drain 'ch', sender will stop sending when error written to 'done. + })() + + // Iterate sequence numbers from the last one to the first one, as deleting + // messages changes their respective numbers + for i := len(seqnums) - 1; i >= 0; i-- { + // Send sequence numbers to channel, and check if conn.WriteResp() finished early. + select { + case ch <- seqnums[i]: // Send next seq. number + case err := <-done: // Check for errors + close(ch) + return err + } + } + close(ch) + + if err := <-done; err != nil { + return err + } + } + + return nil +} + +type Search struct { + commands.Search +} + +func (cmd *Search) handle(uid bool, conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + + ids, err := ctx.Mailbox.SearchMessages(uid, cmd.Criteria) + if err != nil { + return err + } + + res := &responses.Search{Ids: ids} + return conn.WriteResp(res) +} + +func (cmd *Search) Handle(conn Conn) error { + return cmd.handle(false, conn) +} + +func (cmd *Search) UidHandle(conn Conn) error { + return cmd.handle(true, conn) +} + +type Fetch struct { + commands.Fetch +} + +func (cmd *Fetch) handle(uid bool, conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + + ch := make(chan *imap.Message) + res := &responses.Fetch{Messages: ch} + + done := make(chan error, 1) + go (func() { + done <- conn.WriteResp(res) + // Make sure to drain the message channel. + for _ = range ch { + } + })() + + err := ctx.Mailbox.ListMessages(uid, cmd.SeqSet, cmd.Items, ch) + if err != nil { + return err + } + + return <-done +} + +func (cmd *Fetch) Handle(conn Conn) error { + return cmd.handle(false, conn) +} + +func (cmd *Fetch) UidHandle(conn Conn) error { + // Append UID to the list of requested items if it isn't already present + hasUid := false + for _, item := range cmd.Items { + if item == "UID" { + hasUid = true + break + } + } + if !hasUid { + cmd.Items = append(cmd.Items, "UID") + } + + return cmd.handle(true, conn) +} + +type Store struct { + commands.Store +} + +func (cmd *Store) handle(uid bool, conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + if ctx.MailboxReadOnly { + return ErrMailboxReadOnly + } + + // Only flags operations are supported + op, silent, err := imap.ParseFlagsOp(cmd.Item) + if err != nil { + return err + } + + var flags []string + + if flagsList, ok := cmd.Value.([]interface{}); ok { + // Parse list of flags + if strs, err := imap.ParseStringList(flagsList); err == nil { + flags = strs + } else { + return err + } + } else { + // Parse single flag + if str, err := imap.ParseString(cmd.Value); err == nil { + flags = []string{str} + } else { + return err + } + } + for i, flag := range flags { + flags[i] = imap.CanonicalFlag(flag) + } + + // If the backend supports message updates, this will prevent this connection + // from receiving them + // TODO: find a better way to do this, without conn.silent + *conn.silent() = silent + err = ctx.Mailbox.UpdateMessagesFlags(uid, cmd.SeqSet, op, flags) + *conn.silent() = false + if err != nil { + return err + } + + // Not silent: send FETCH updates if the backend doesn't support message + // updates + if conn.Server().Updates == nil && !silent { + inner := &Fetch{} + inner.SeqSet = cmd.SeqSet + inner.Items = []imap.FetchItem{imap.FetchFlags} + if uid { + inner.Items = append(inner.Items, "UID") + } + + if err := inner.handle(uid, conn); err != nil { + return err + } + } + + return nil +} + +func (cmd *Store) Handle(conn Conn) error { + return cmd.handle(false, conn) +} + +func (cmd *Store) UidHandle(conn Conn) error { + return cmd.handle(true, conn) +} + +type Copy struct { + commands.Copy +} + +func (cmd *Copy) handle(uid bool, conn Conn) error { + ctx := conn.Context() + if ctx.Mailbox == nil { + return ErrNoMailboxSelected + } + + return ctx.Mailbox.CopyMessages(uid, cmd.SeqSet, cmd.Mailbox) +} + +func (cmd *Copy) Handle(conn Conn) error { + return cmd.handle(false, conn) +} + +func (cmd *Copy) UidHandle(conn Conn) error { + return cmd.handle(true, conn) +} + +type Move struct { + commands.Move +} + +func (h *Move) handle(uid bool, conn Conn) error { + mailbox := conn.Context().Mailbox + if mailbox == nil { + return ErrNoMailboxSelected + } + + if m, ok := mailbox.(backend.MoveMailbox); ok { + return m.MoveMessages(uid, h.SeqSet, h.Mailbox) + } + return errors.New("MOVE extension not supported") +} + +func (h *Move) Handle(conn Conn) error { + return h.handle(false, conn) +} + +func (h *Move) UidHandle(conn Conn) error { + return h.handle(true, conn) +} + +type Uid struct { + commands.Uid +} + +func (cmd *Uid) Handle(conn Conn) error { + inner := cmd.Cmd.Command() + hdlr, err := conn.commandHandler(inner) + if err != nil { + return err + } + + uidHdlr, ok := hdlr.(UidHandler) + if !ok { + return errors.New("Command unsupported with UID") + } + + if err := uidHdlr.UidHandle(conn); err != nil { + return err + } + + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespOk, + Info: "UID " + inner.Name + " completed", + }) +} diff --git a/server/cmd_selected_test.go b/server/cmd_selected_test.go new file mode 100644 index 0000000..d049653 --- /dev/null +++ b/server/cmd_selected_test.go @@ -0,0 +1,584 @@ +package server_test + +import ( + "bufio" + "io" + "net" + "strings" + "testing" + + "github.com/emersion/go-imap/server" +) + +func testServerSelected(t *testing.T, readOnly bool) (s *server.Server, c net.Conn, scanner *bufio.Scanner) { + s, c, scanner = testServerAuthenticated(t) + + if readOnly { + io.WriteString(c, "a000 EXAMINE INBOX\r\n") + } else { + io.WriteString(c, "a000 SELECT INBOX\r\n") + } + + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "a000 ") { + break + } + } + return +} + +func TestNoop_Selected(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 NOOP\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Bad status response:", scanner.Text()) + } +} + +func TestCheck(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CHECK\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestCheck_ReadOnly(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CHECK\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestCheck_NotSelected(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CHECK\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestClose(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CLOSE\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestClose_NotSelected(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CLOSE\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestExpunge(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 EXPUNGE\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 STORE 1 +FLAGS.SILENT (\\Deleted)\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 EXPUNGE\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 EXPUNGE" { + t.Fatal("Invalid EXPUNGE response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestExpunge_ReadOnly(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 EXPUNGE\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestExpunge_NotSelected(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 EXPUNGE\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestSearch(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 SEARCH UNDELETED\r\n") + scanner.Scan() + if scanner.Text() != "* SEARCH 1" { + t.Fatal("Invalid SEARCH response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 SEARCH DELETED\r\n") + scanner.Scan() + if scanner.Text() != "* SEARCH" { + t.Fatal("Invalid SEARCH response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestSearch_NotSelected(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 SEARCH UNDELETED\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestSearch_Uid(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 UID SEARCH UNDELETED\r\n") + scanner.Scan() + if scanner.Text() != "* SEARCH 6" { + t.Fatal("Invalid SEARCH response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestFetch(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 FETCH 1 (UID FLAGS)\r\n") + scanner.Scan() + if scanner.Text() != "* 1 FETCH (UID 6 FLAGS (\\Seen))" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 FETCH 1 (BODY.PEEK[TEXT])\r\n") + scanner.Scan() + if scanner.Text() != "* 1 FETCH (BODY[TEXT] {11}" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "Hi there :))") { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestFetch_NotSelected(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 FETCH 1 (UID FLAGS)\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestFetch_Uid(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 UID FETCH 6 (UID)\r\n") + scanner.Scan() + if scanner.Text() != "* 1 FETCH (UID 6)" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestFetch_Uid_UidNotRequested(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 UID FETCH 6 (FLAGS)\r\n") + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS (\\Seen) UID 6)" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStore(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STORE 1 +FLAGS (\\Flagged)\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS (\\Seen \\Flagged))" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 STORE 1 FLAGS (\\Answered)\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS (\\Answered))" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 STORE 1 -FLAGS (\\Answered)\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS ())" { + t.Fatal("Invalid status response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 STORE 1 +FLAGS.SILENT (\\Flagged \\Seen)\r\n") + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStore_NotSelected(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STORE 1 +FLAGS (\\Flagged)\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStore_ReadOnly(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STORE 1 +FLAGS (\\Flagged)\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStore_InvalidOperation(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STORE 1 IDONTEXIST (\\Flagged)\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStore_InvalidFlags(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 STORE 1 +FLAGS ((nested)(lists))\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStore_SingleFlagNonList(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer c.Close() + defer s.Close() + + io.WriteString(c, "a001 STORE 1 FLAGS somestring\r\n") + + gotOK := false + gotFetch := false + for scanner.Scan() { + res := scanner.Text() + + if res == "* 1 FETCH (FLAGS (somestring))" { + gotFetch = true + } else if strings.HasPrefix(res, "a001 OK ") { + gotOK = true + break + } else { + t.Fatal("Unexpected response:", res) + } + } + + if !gotFetch { + t.Fatal("Missing FETCH response.") + } + + if !gotOK { + t.Fatal("Missing status response.") + } +} + +func TestStore_NonList(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer c.Close() + defer s.Close() + + io.WriteString(c, "a001 STORE 1 FLAGS somestring someanotherstring\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS (somestring someanotherstring))" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStore_RecentFlag(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer c.Close() + defer s.Close() + + // Add Recent flag + io.WriteString(c, "a001 STORE 1 FLAGS \\Recent\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS (\\Recent))" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + // Set flags to: something + // Should still get Recent flag back + io.WriteString(c, "a001 STORE 1 FLAGS something\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS (\\Recent something))" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + // Try adding Recent flag again + io.WriteString(c, "a001 STORE 1 FLAGS \\Recent anotherflag\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS (\\Recent anotherflag))" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestStore_Uid(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 UID STORE 6 +FLAGS (\\Flagged)\r\n") + + scanner.Scan() + if scanner.Text() != "* 1 FETCH (FLAGS (\\Seen \\Flagged) UID 6)" { + t.Fatal("Invalid FETCH response:", scanner.Text()) + } + + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestCopy(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CREATE CopyDest\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 COPY 1 CopyDest\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 STATUS CopyDest (MESSAGES)\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "* STATUS \"CopyDest\" (MESSAGES 1)") { + t.Fatal("Invalid status response:", scanner.Text()) + } + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestCopy_NotSelected(t *testing.T) { + s, c, scanner := testServerAuthenticated(t) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CREATE CopyDest\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 COPY 1 CopyDest\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestCopy_Uid(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 CREATE CopyDest\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 UID COPY 6 CopyDest\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 OK ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} + +func TestUid_InvalidCommand(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer s.Close() + defer c.Close() + + io.WriteString(c, "a001 UID IDONTEXIST\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + io.WriteString(c, "a001 UID CLOSE\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } +} diff --git a/server/conn.go b/server/conn.go new file mode 100644 index 0000000..37a0356 --- /dev/null +++ b/server/conn.go @@ -0,0 +1,421 @@ +package server + +import ( + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "runtime/debug" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" +) + +// Conn is a connection to a client. +type Conn interface { + io.Reader + + // Server returns this connection's server. + Server() *Server + // Context returns this connection's context. + Context() *Context + // Capabilities returns a list of capabilities enabled for this connection. + Capabilities() []string + // WriteResp writes a response to this connection. + WriteResp(res imap.WriterTo) error + // IsTLS returns true if TLS is enabled. + IsTLS() bool + // TLSState returns the TLS connection state if TLS is enabled, nil otherwise. + TLSState() *tls.ConnectionState + // Upgrade upgrades a connection, e.g. wrap an unencrypted connection with an + // encrypted tunnel. + Upgrade(upgrader imap.ConnUpgrader) error + // Close closes this connection. + Close() error + WaitReady() + + Info() *imap.ConnInfo + + setTLSConn(*tls.Conn) + silent() *bool // TODO: remove this + serve(Conn) error + commandHandler(cmd *imap.Command) (hdlr Handler, err error) +} + +// Context stores a connection's metadata. +type Context struct { + // This connection's current state. + State imap.ConnState + // If the client is logged in, the user. + User backend.User + // If the client has selected a mailbox, the mailbox. + Mailbox backend.Mailbox + // True if the currently selected mailbox has been opened in read-only mode. + MailboxReadOnly bool + // Responses to send to the client. + Responses chan<- imap.WriterTo + // Closed when the client is logged out. + LoggedOut <-chan struct{} +} + +type conn struct { + *imap.Conn + + conn Conn // With extensions overrides + s *Server + ctx *Context + tlsConn *tls.Conn + continues chan bool + upgrade chan bool + responses chan imap.WriterTo + loggedOut chan struct{} + silentVal bool +} + +func newConn(s *Server, c net.Conn) *conn { + // Create an imap.Reader and an imap.Writer + continues := make(chan bool) + r := imap.NewServerReader(nil, continues) + w := imap.NewWriter(nil) + + responses := make(chan imap.WriterTo) + loggedOut := make(chan struct{}) + + tlsConn, _ := c.(*tls.Conn) + + conn := &conn{ + Conn: imap.NewConn(c, r, w), + + s: s, + ctx: &Context{ + State: imap.ConnectingState, + Responses: responses, + LoggedOut: loggedOut, + }, + tlsConn: tlsConn, + continues: continues, + upgrade: make(chan bool), + responses: responses, + loggedOut: loggedOut, + } + + if s.Debug != nil { + conn.Conn.SetDebug(s.Debug) + } + if s.MaxLiteralSize > 0 { + conn.Conn.MaxLiteralSize = s.MaxLiteralSize + } + + go conn.send() + + return conn +} + +func (c *conn) Server() *Server { + return c.s +} + +func (c *conn) Context() *Context { + return c.ctx +} + +type response struct { + response imap.WriterTo + done chan struct{} +} + +func (r *response) WriteTo(w *imap.Writer) error { + err := r.response.WriteTo(w) + close(r.done) + return err +} + +func (c *conn) setDeadline() { + if c.s.AutoLogout == 0 { + return + } + + dur := c.s.AutoLogout + if dur < MinAutoLogout { + dur = MinAutoLogout + } + t := time.Now().Add(dur) + + c.Conn.SetDeadline(t) +} + +func (c *conn) WriteResp(r imap.WriterTo) error { + done := make(chan struct{}) + c.responses <- &response{r, done} + <-done + c.setDeadline() + return nil +} + +func (c *conn) Close() error { + if c.ctx.User != nil { + c.ctx.User.Logout() + } + + return c.Conn.Close() +} + +func (c *conn) Capabilities() []string { + caps := []string{"IMAP4rev1", "LITERAL+", "SASL-IR", "CHILDREN", "UNSELECT", "MOVE", "IDLE"} + + appendLimitSet := false + if c.ctx.State == imap.AuthenticatedState { + if u, ok := c.ctx.User.(backend.AppendLimitUser); ok { + if limit := u.CreateMessageLimit(); limit != nil { + caps = append(caps, fmt.Sprintf("APPENDLIMIT=%v", *limit)) + appendLimitSet = true + } + } + } else if be, ok := c.Server().Backend.(backend.AppendLimitBackend); ok { + if limit := be.CreateMessageLimit(); limit != nil { + caps = append(caps, fmt.Sprintf("APPENDLIMIT=%v", *limit)) + appendLimitSet = true + } + } + if !appendLimitSet { + caps = append(caps, "APPENDLIMIT") + } + + if c.ctx.State == imap.NotAuthenticatedState { + if !c.IsTLS() && c.s.TLSConfig != nil { + caps = append(caps, "STARTTLS") + } + + if !c.canAuth() { + caps = append(caps, "LOGINDISABLED") + } else { + for name := range c.s.auths { + caps = append(caps, "AUTH="+name) + } + } + } + + for _, ext := range c.s.extensions { + caps = append(caps, ext.Capabilities(c)...) + } + + return caps +} + +func (c *conn) writeAndFlush(w imap.WriterTo) error { + if err := w.WriteTo(c.Writer); err != nil { + return err + } + return c.Writer.Flush() +} + +func (c *conn) send() { + // Send responses + for { + select { + case <-c.upgrade: + // Wait until upgrade is finished. + c.Wait() + case needCont := <-c.continues: + // Send continuation requests + if needCont { + resp := &imap.ContinuationReq{Info: "send literal"} + if err := c.writeAndFlush(resp); err != nil { + c.Server().ErrorLog.Println("cannot send continuation request: ", err) + } + } + case res := <-c.responses: + // Got a response that needs to be sent + // Request to send the response + if err := c.writeAndFlush(res); err != nil { + c.Server().ErrorLog.Println("cannot send response: ", err) + } + case <-c.loggedOut: + return + } + } +} + +func (c *conn) greet() error { + c.ctx.State = imap.NotAuthenticatedState + + caps := c.Capabilities() + args := make([]interface{}, len(caps)) + for i, cap := range caps { + args[i] = imap.RawString(cap) + } + + greeting := &imap.StatusResp{ + Type: imap.StatusRespOk, + Code: imap.CodeCapability, + Arguments: args, + Info: "IMAP4rev1 Service Ready", + } + + return c.WriteResp(greeting) +} + +func (c *conn) setTLSConn(tlsConn *tls.Conn) { + c.tlsConn = tlsConn +} + +func (c *conn) IsTLS() bool { + return c.tlsConn != nil +} + +func (c *conn) TLSState() *tls.ConnectionState { + if c.tlsConn != nil { + state := c.tlsConn.ConnectionState() + return &state + } + return nil +} + +// canAuth checks if the client can use plain text authentication. +func (c *conn) canAuth() bool { + return c.IsTLS() || c.s.AllowInsecureAuth +} + +func (c *conn) silent() *bool { + return &c.silentVal +} + +func (c *conn) serve(conn Conn) (err error) { + c.conn = conn + + defer func() { + c.ctx.State = imap.LogoutState + close(c.loggedOut) + }() + + defer func() { + if r := recover(); r != nil { + c.WriteResp(&imap.StatusResp{ + Type: imap.StatusRespBye, + Info: "Internal server error, closing connection.", + }) + + stack := debug.Stack() + c.s.ErrorLog.Printf("panic serving %v: %v\n%s", c.Info().RemoteAddr, r, stack) + + err = fmt.Errorf("%v", r) + } + }() + + // Send greeting + if err := c.greet(); err != nil { + return err + } + + for { + if c.ctx.State == imap.LogoutState { + return nil + } + + var res *imap.StatusResp + var up Upgrader + + fields, err := c.ReadLine() + if err == io.EOF || c.ctx.State == imap.LogoutState { + return nil + } + c.setDeadline() + + if err != nil { + if imap.IsParseError(err) { + res = &imap.StatusResp{ + Type: imap.StatusRespBad, + Info: err.Error(), + } + } else { + c.s.ErrorLog.Println("cannot read command:", err) + return err + } + } else { + cmd := &imap.Command{} + if err := cmd.Parse(fields); err != nil { + res = &imap.StatusResp{ + Tag: cmd.Tag, + Type: imap.StatusRespBad, + Info: err.Error(), + } + } else { + var err error + res, up, err = c.handleCommand(cmd) + if err != nil { + res = &imap.StatusResp{ + Tag: cmd.Tag, + Type: imap.StatusRespBad, + Info: err.Error(), + } + } + } + } + + if res != nil { + + if err := c.WriteResp(res); err != nil { + c.s.ErrorLog.Println("cannot write response:", err) + continue + } + + if up != nil && res.Type == imap.StatusRespOk { + if err := up.Upgrade(c.conn); err != nil { + c.s.ErrorLog.Println("cannot upgrade connection:", err) + return err + } + } + } + } +} + +func (c *conn) WaitReady() { + c.upgrade <- true + c.Conn.WaitReady() +} + +func (c *conn) commandHandler(cmd *imap.Command) (hdlr Handler, err error) { + newHandler := c.s.Command(cmd.Name) + if newHandler == nil { + err = errors.New("Unknown command") + return + } + + hdlr = newHandler() + err = hdlr.Parse(cmd.Arguments) + return +} + +func (c *conn) handleCommand(cmd *imap.Command) (res *imap.StatusResp, up Upgrader, err error) { + hdlr, err := c.commandHandler(cmd) + if err != nil { + return + } + + hdlrErr := hdlr.Handle(c.conn) + if statusErr, ok := hdlrErr.(*imap.ErrStatusResp); ok { + res = statusErr.Resp + } else if hdlrErr != nil { + res = &imap.StatusResp{ + Type: imap.StatusRespNo, + Info: hdlrErr.Error(), + } + } else { + res = &imap.StatusResp{ + Type: imap.StatusRespOk, + } + } + + if res != nil { + res.Tag = cmd.Tag + + if res.Type == imap.StatusRespOk && res.Info == "" { + res.Info = cmd.Name + " completed" + } + } + + up, _ = hdlr.(Upgrader) + return +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..b2b9178 --- /dev/null +++ b/server/server.go @@ -0,0 +1,419 @@ +// Package server provides an IMAP server. +package server + +import ( + "crypto/tls" + "errors" + "io" + "log" + "net" + "os" + "sync" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/responses" + "github.com/emersion/go-sasl" +) + +// The minimum autologout duration defined in RFC 3501 section 5.4. +const MinAutoLogout = 30 * time.Minute + +// A command handler. +type Handler interface { + imap.Parser + + // Handle this command for a given connection. + // + // By default, after this function has returned a status response is sent. To + // prevent this behavior handlers can use imap.ErrStatusResp. + Handle(conn Conn) error +} + +// A connection upgrader. If a Handler is also an Upgrader, the connection will +// be upgraded after the Handler succeeds. +// +// This should only be used by libraries implementing an IMAP extension (e.g. +// COMPRESS). +type Upgrader interface { + // Upgrade the connection. This method should call conn.Upgrade(). + Upgrade(conn Conn) error +} + +// A function that creates handlers. +type HandlerFactory func() Handler + +// A function that creates SASL servers. +type SASLServerFactory func(conn Conn) sasl.Server + +// An IMAP extension. +type Extension interface { + // Get capabilities provided by this extension for a given connection. + Capabilities(c Conn) []string + // Get the command handler factory for the provided command name. + Command(name string) HandlerFactory +} + +// An extension that provides additional features to each connection. +type ConnExtension interface { + Extension + + // This function will be called when a client connects to the server. It can + // be used to add new features to the default Conn interface by implementing + // new methods. + NewConn(c Conn) Conn +} + +// ErrStatusResp can be returned by a Handler to replace the default status +// response. The response tag must be empty. +// +// Deprecated: Use imap.ErrStatusResp{res} instead. +// +// To disable the default status response, use imap.ErrStatusResp{nil} instead. +func ErrStatusResp(res *imap.StatusResp) error { + return &imap.ErrStatusResp{res} +} + +// ErrNoStatusResp can be returned by a Handler to prevent the default status +// response from being sent. +// +// Deprecated: Use imap.ErrStatusResp{nil} instead +func ErrNoStatusResp() error { + return &imap.ErrStatusResp{nil} +} + +// An IMAP server. +type Server struct { + locker sync.Mutex + listeners map[net.Listener]struct{} + conns map[Conn]struct{} + + commands map[string]HandlerFactory + auths map[string]SASLServerFactory + extensions []Extension + + // TCP address to listen on. + Addr string + // This server's TLS configuration. + TLSConfig *tls.Config + // This server's backend. + Backend backend.Backend + // Backend updates that will be sent to connected clients. + Updates <-chan backend.Update + // Automatically logout clients after a duration. To do not logout users + // automatically, set this to zero. The duration MUST be at least + // MinAutoLogout (as stated in RFC 3501 section 5.4). + AutoLogout time.Duration + // Allow authentication over unencrypted connections. + AllowInsecureAuth bool + // An io.Writer to which all network activity will be mirrored. + Debug io.Writer + // ErrorLog specifies an optional logger for errors accepting + // connections and unexpected behavior from handlers. + // If nil, logging goes to os.Stderr via the log package's + // standard logger. + ErrorLog imap.Logger + // The maximum literal size, in bytes. Literals exceeding this size will be + // rejected. A value of zero disables the limit (this is the default). + MaxLiteralSize uint32 +} + +// Create a new IMAP server from an existing listener. +func New(bkd backend.Backend) *Server { + s := &Server{ + listeners: make(map[net.Listener]struct{}), + conns: make(map[Conn]struct{}), + Backend: bkd, + ErrorLog: log.New(os.Stderr, "imap/server: ", log.LstdFlags), + } + + s.auths = map[string]SASLServerFactory{ + sasl.Plain: func(conn Conn) sasl.Server { + return sasl.NewPlainServer(func(identity, username, password string) error { + if identity != "" && identity != username { + return errors.New("Identities not supported") + } + + user, err := bkd.Login(conn.Info(), username, password) + if err != nil { + return err + } + + ctx := conn.Context() + ctx.State = imap.AuthenticatedState + ctx.User = user + return nil + }) + }, + } + + s.commands = map[string]HandlerFactory{ + "NOOP": func() Handler { return &Noop{} }, + "CAPABILITY": func() Handler { return &Capability{} }, + "LOGOUT": func() Handler { return &Logout{} }, + + "STARTTLS": func() Handler { return &StartTLS{} }, + "LOGIN": func() Handler { return &Login{} }, + "AUTHENTICATE": func() Handler { return &Authenticate{} }, + + "SELECT": func() Handler { return &Select{} }, + "EXAMINE": func() Handler { + hdlr := &Select{} + hdlr.ReadOnly = true + return hdlr + }, + "CREATE": func() Handler { return &Create{} }, + "DELETE": func() Handler { return &Delete{} }, + "RENAME": func() Handler { return &Rename{} }, + "SUBSCRIBE": func() Handler { return &Subscribe{} }, + "UNSUBSCRIBE": func() Handler { return &Unsubscribe{} }, + "LIST": func() Handler { return &List{} }, + "LSUB": func() Handler { + hdlr := &List{} + hdlr.Subscribed = true + return hdlr + }, + "STATUS": func() Handler { return &Status{} }, + "APPEND": func() Handler { return &Append{} }, + "UNSELECT": func() Handler { return &Unselect{} }, + "IDLE": func() Handler { return &Idle{} }, + + "CHECK": func() Handler { return &Check{} }, + "CLOSE": func() Handler { return &Close{} }, + "EXPUNGE": func() Handler { return &Expunge{} }, + "SEARCH": func() Handler { return &Search{} }, + "FETCH": func() Handler { return &Fetch{} }, + "STORE": func() Handler { return &Store{} }, + "COPY": func() Handler { return &Copy{} }, + "MOVE": func() Handler { return &Move{} }, + "UID": func() Handler { return &Uid{} }, + } + + return s +} + +// Serve accepts incoming connections on the Listener l. +func (s *Server) Serve(l net.Listener) error { + s.locker.Lock() + s.listeners[l] = struct{}{} + s.locker.Unlock() + + defer func() { + s.locker.Lock() + defer s.locker.Unlock() + l.Close() + delete(s.listeners, l) + }() + + updater, ok := s.Backend.(backend.BackendUpdater) + if ok { + s.Updates = updater.Updates() + go s.listenUpdates() + } + + for { + c, err := l.Accept() + if err != nil { + return err + } + + var conn Conn = newConn(s, c) + for _, ext := range s.extensions { + if ext, ok := ext.(ConnExtension); ok { + conn = ext.NewConn(conn) + } + } + + go s.serveConn(conn) + } +} + +// ListenAndServe listens on the TCP network address s.Addr and then calls Serve +// to handle requests on incoming connections. +// +// If s.Addr is blank, ":imap" is used. +func (s *Server) ListenAndServe() error { + addr := s.Addr + if addr == "" { + addr = ":imap" + } + + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + return s.Serve(l) +} + +// ListenAndServeTLS listens on the TCP network address s.Addr and then calls +// Serve to handle requests on incoming TLS connections. +// +// If s.Addr is blank, ":imaps" is used. +func (s *Server) ListenAndServeTLS() error { + addr := s.Addr + if addr == "" { + addr = ":imaps" + } + + l, err := tls.Listen("tcp", addr, s.TLSConfig) + if err != nil { + return err + } + + return s.Serve(l) +} + +func (s *Server) serveConn(conn Conn) error { + s.locker.Lock() + s.conns[conn] = struct{}{} + s.locker.Unlock() + + defer func() { + s.locker.Lock() + defer s.locker.Unlock() + conn.Close() + delete(s.conns, conn) + }() + + return conn.serve(conn) +} + +// Command gets a command handler factory for the provided command name. +func (s *Server) Command(name string) HandlerFactory { + // Extensions can override builtin commands + for _, ext := range s.extensions { + if h := ext.Command(name); h != nil { + return h + } + } + + return s.commands[name] +} + +func (s *Server) listenUpdates() { + for { + update := <-s.Updates + + var res imap.WriterTo + switch update := update.(type) { + case *backend.StatusUpdate: + res = update.StatusResp + case *backend.MailboxUpdate: + res = &responses.Select{Mailbox: update.MailboxStatus} + case *backend.MailboxInfoUpdate: + ch := make(chan *imap.MailboxInfo, 1) + ch <- update.MailboxInfo + close(ch) + + res = &responses.List{Mailboxes: ch} + case *backend.MessageUpdate: + ch := make(chan *imap.Message, 1) + ch <- update.Message + close(ch) + + res = &responses.Fetch{Messages: ch} + case *backend.ExpungeUpdate: + ch := make(chan uint32, 1) + ch <- update.SeqNum + close(ch) + + res = &responses.Expunge{SeqNums: ch} + default: + s.ErrorLog.Printf("unhandled update: %T\n", update) + } + if res == nil { + continue + } + + sends := make(chan struct{}) + wait := 0 + s.locker.Lock() + for conn := range s.conns { + ctx := conn.Context() + + if update.Username() != "" && (ctx.User == nil || ctx.User.Username() != update.Username()) { + continue + } + if update.Mailbox() != "" && (ctx.Mailbox == nil || ctx.Mailbox.Name() != update.Mailbox()) { + continue + } + if *conn.silent() { + // If silent is set, do not send message updates + if _, ok := res.(*responses.Fetch); ok { + continue + } + } + + conn := conn // Copy conn to a local variable + go func() { + done := make(chan struct{}) + conn.Context().Responses <- &response{ + response: res, + done: done, + } + <-done + sends <- struct{}{} + }() + + wait++ + } + s.locker.Unlock() + + if wait > 0 { + go func() { + for done := 0; done < wait; done++ { + <-sends + } + + close(update.Done()) + }() + } else { + close(update.Done()) + } + } +} + +// ForEachConn iterates through all opened connections. +func (s *Server) ForEachConn(f func(Conn)) { + s.locker.Lock() + defer s.locker.Unlock() + for conn := range s.conns { + f(conn) + } +} + +// Stops listening and closes all current connections. +func (s *Server) Close() error { + s.locker.Lock() + defer s.locker.Unlock() + + for l := range s.listeners { + l.Close() + } + + for conn := range s.conns { + conn.Close() + } + + return nil +} + +// Enable some IMAP extensions on this server. +// Wiki entry: https://github.com/emersion/go-imap/wiki/Using-extensions +func (s *Server) Enable(extensions ...Extension) { + for _, ext := range extensions { + // Ignore built-in extensions + if ext.Command("UNSELECT") != nil || ext.Command("MOVE") != nil || ext.Command("IDLE") != nil { + continue + } + s.extensions = append(s.extensions, ext) + } +} + +// Enable an authentication mechanism on this server. +// Wiki entry: https://github.com/emersion/go-imap/wiki/Using-authentication-mechanisms +func (s *Server) EnableAuth(name string, f SASLServerFactory) { + s.auths[name] = f +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..32bfe85 --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,49 @@ +package server_test + +import ( + "bufio" + "net" + "testing" + + "github.com/emersion/go-imap/backend/memory" + "github.com/emersion/go-imap/server" +) + +// Extnesions that are always advertised by go-imap server. +const builtinExtensions = "LITERAL+ SASL-IR CHILDREN UNSELECT MOVE IDLE APPENDLIMIT" + +func testServer(t *testing.T) (s *server.Server, conn net.Conn) { + bkd := memory.New() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal("Cannot listen:", err) + } + + s = server.New(bkd) + s.AllowInsecureAuth = true + + go s.Serve(l) + + conn, err = net.Dial("tcp", l.Addr().String()) + if err != nil { + t.Fatal("Cannot connect to server:", err) + } + + return +} + +func TestServer_greeting(t *testing.T) { + s, conn := testServer(t) + defer s.Close() + defer conn.Close() + + scanner := bufio.NewScanner(conn) + + scanner.Scan() // Wait for greeting + greeting := scanner.Text() + + if greeting != "* OK [CAPABILITY IMAP4rev1 "+builtinExtensions+" AUTH=PLAIN] IMAP4rev1 Service Ready" { + t.Fatal("Bad greeting:", greeting) + } +} diff --git a/status.go b/status.go index f399456..81ffd1b 100644 --- a/status.go +++ b/status.go @@ -1,35 +1,136 @@ package imap -// StatusOptions contains options for the STATUS command. -type StatusOptions struct { - NumMessages bool - NumRecent bool // Obsolete, IMAP4rev1 only. Server-only, not supported in imapclient. - UIDNext bool - UIDValidity bool - NumUnseen bool - NumDeleted bool // requires IMAP4rev2 or QUOTA - Size bool // requires IMAP4rev2 or STATUS=SIZE +import ( + "errors" +) - AppendLimit bool // requires APPENDLIMIT - DeletedStorage bool // requires QUOTA=RES-STORAGE - HighestModSeq bool // requires CONDSTORE +// A status response type. +type StatusRespType string + +// Status response types defined in RFC 3501 section 7.1. +const ( + // The OK response indicates an information message from the server. When + // tagged, it indicates successful completion of the associated command. + // The untagged form indicates an information-only message. + StatusRespOk StatusRespType = "OK" + + // The NO response indicates an operational error message from the + // server. When tagged, it indicates unsuccessful completion of the + // associated command. The untagged form indicates a warning; the + // command can still complete successfully. + StatusRespNo StatusRespType = "NO" + + // The BAD response indicates an error message from the server. When + // tagged, it reports a protocol-level error in the client's command; + // the tag indicates the command that caused the error. The untagged + // form indicates a protocol-level error for which the associated + // command can not be determined; it can also indicate an internal + // server failure. + StatusRespBad StatusRespType = "BAD" + + // The PREAUTH response is always untagged, and is one of three + // possible greetings at connection startup. It indicates that the + // connection has already been authenticated by external means; thus + // no LOGIN command is needed. + StatusRespPreauth StatusRespType = "PREAUTH" + + // The BYE response is always untagged, and indicates that the server + // is about to close the connection. + StatusRespBye StatusRespType = "BYE" +) + +type StatusRespCode string + +// Status response codes defined in RFC 3501 section 7.1. +const ( + CodeAlert StatusRespCode = "ALERT" + CodeBadCharset StatusRespCode = "BADCHARSET" + CodeCapability StatusRespCode = "CAPABILITY" + CodeParse StatusRespCode = "PARSE" + CodePermanentFlags StatusRespCode = "PERMANENTFLAGS" + CodeReadOnly StatusRespCode = "READ-ONLY" + CodeReadWrite StatusRespCode = "READ-WRITE" + CodeTryCreate StatusRespCode = "TRYCREATE" + CodeUidNext StatusRespCode = "UIDNEXT" + CodeUidValidity StatusRespCode = "UIDVALIDITY" + CodeUnseen StatusRespCode = "UNSEEN" +) + +// A status response. +// See RFC 3501 section 7.1 +type StatusResp struct { + // The response tag. If empty, it defaults to *. + Tag string + // The status type. + Type StatusRespType + // The status code. + // See https://www.iana.org/assignments/imap-response-codes/imap-response-codes.xhtml + Code StatusRespCode + // Arguments provided with the status code. + Arguments []interface{} + // The status info. + Info string } -// StatusData is the data returned by a STATUS command. +func (r *StatusResp) resp() {} + +// If this status is NO or BAD, returns an error with the status info. +// Otherwise, returns nil. +func (r *StatusResp) Err() error { + if r == nil { + // No status response, connection closed before we get one + return errors.New("imap: connection closed during command execution") + } + + if r.Type == StatusRespNo || r.Type == StatusRespBad { + return errors.New(r.Info) + } + return nil +} + +func (r *StatusResp) WriteTo(w *Writer) error { + tag := RawString(r.Tag) + if tag == "" { + tag = "*" + } + + if err := w.writeFields([]interface{}{RawString(tag), RawString(r.Type)}); err != nil { + return err + } + + if err := w.writeString(string(sp)); err != nil { + return err + } + + if r.Code != "" { + if err := w.writeRespCode(r.Code, r.Arguments); err != nil { + return err + } + + if err := w.writeString(string(sp)); err != nil { + return err + } + } + + if err := w.writeString(r.Info); err != nil { + return err + } + + return w.writeCrlf() +} + +// ErrStatusResp can be returned by a server.Handler to replace the default status +// response. The response tag must be empty. // -// The mailbox name is always populated. The remaining fields are optional. -type StatusData struct { - Mailbox string - - NumMessages *uint32 - NumRecent *uint32 // Obsolete, IMAP4rev1 only. Server-only, not supported in imapclient. - UIDNext UID - UIDValidity uint32 - NumUnseen *uint32 - NumDeleted *uint32 - Size *int64 - - AppendLimit *uint32 - DeletedStorage *int64 - HighestModSeq uint64 +// To suppress default response, Resp should be set to nil. +type ErrStatusResp struct { + // Response to send instead of default. + Resp *StatusResp +} + +func (err *ErrStatusResp) Error() string { + if err.Resp == nil { + return "imap: suppressed response" + } + return err.Resp.Info } diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..b36f660 --- /dev/null +++ b/status_test.go @@ -0,0 +1,94 @@ +package imap_test + +import ( + "bytes" + "testing" + + "github.com/emersion/go-imap" +) + +func TestStatusResp_WriteTo(t *testing.T) { + tests := []struct { + input *imap.StatusResp + expected string + }{ + { + input: &imap.StatusResp{ + Tag: "*", + Type: imap.StatusRespOk, + }, + expected: "* OK \r\n", + }, + { + input: &imap.StatusResp{ + Tag: "*", + Type: imap.StatusRespOk, + Info: "LOGIN completed", + }, + expected: "* OK LOGIN completed\r\n", + }, + { + input: &imap.StatusResp{ + Tag: "42", + Type: imap.StatusRespBad, + Info: "Invalid arguments", + }, + expected: "42 BAD Invalid arguments\r\n", + }, + { + input: &imap.StatusResp{ + Tag: "a001", + Type: imap.StatusRespOk, + Code: "READ-ONLY", + Info: "EXAMINE completed", + }, + expected: "a001 OK [READ-ONLY] EXAMINE completed\r\n", + }, + { + input: &imap.StatusResp{ + Tag: "*", + Type: imap.StatusRespOk, + Code: "CAPABILITY", + Arguments: []interface{}{imap.RawString("IMAP4rev1")}, + Info: "IMAP4rev1 service ready", + }, + expected: "* OK [CAPABILITY IMAP4rev1] IMAP4rev1 service ready\r\n", + }, + } + + for i, test := range tests { + b := &bytes.Buffer{} + w := imap.NewWriter(b) + + if err := test.input.WriteTo(w); err != nil { + t.Errorf("Cannot write status #%v, got error: %v", i, err) + continue + } + + o := b.String() + if o != test.expected { + t.Errorf("Invalid output for status #%v: %v", i, o) + } + } +} + +func TestStatus_Err(t *testing.T) { + status := &imap.StatusResp{Type: imap.StatusRespOk, Info: "All green"} + if err := status.Err(); err != nil { + t.Error("OK status returned error:", err) + } + + status = &imap.StatusResp{Type: imap.StatusRespBad, Info: "BAD!"} + if err := status.Err(); err == nil { + t.Error("BAD status didn't returned error:", err) + } else if err.Error() != "BAD!" { + t.Error("BAD status returned incorrect error message:", err) + } + + status = &imap.StatusResp{Type: imap.StatusRespNo, Info: "NO!"} + if err := status.Err(); err == nil { + t.Error("NO status didn't returned error:", err) + } else if err.Error() != "NO!" { + t.Error("NO status returned incorrect error message:", err) + } +} diff --git a/internal/utf7/decoder.go b/utf7/decoder.go similarity index 61% rename from internal/utf7/decoder.go rename to utf7/decoder.go index b8e906e..cfcba8c 100644 --- a/internal/utf7/decoder.go +++ b/utf7/decoder.go @@ -2,38 +2,40 @@ package utf7 import ( "errors" - "strings" "unicode/utf16" "unicode/utf8" + + "golang.org/x/text/transform" ) -// ErrInvalidUTF7 means that a decoder encountered invalid UTF-7. +// ErrInvalidUTF7 means that a transformer encountered invalid UTF-7. var ErrInvalidUTF7 = errors.New("utf7: invalid UTF-7") -// Decode decodes a string encoded with modified UTF-7. -// -// Note, raw UTF-8 is accepted. -func Decode(src string) (string, error) { - if !utf8.ValidString(src) { - return "", errors.New("invalid UTF-8") - } +type decoder struct { + ascii bool +} - var sb strings.Builder - sb.Grow(len(src)) - - ascii := true +func (d *decoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { for i := 0; i < len(src); i++ { ch := src[i] - if ch < min || (ch > max && ch < utf8.RuneSelf) { - // Illegal code point in ASCII mode. Note, UTF-8 codepoints are - // always allowed. - return "", ErrInvalidUTF7 + if ch < min || ch > max { // Illegal code point in ASCII mode + err = ErrInvalidUTF7 + return } if ch != '&' { - sb.WriteByte(ch) - ascii = true + if nDst+1 > len(dst) { + err = transform.ErrShortDst + return + } + + nSrc++ + + dst[nDst] = ch + nDst++ + + d.ascii = true continue } @@ -41,33 +43,62 @@ func Decode(src string) (string, error) { start := i + 1 for i++; i < len(src) && src[i] != '-'; i++ { if src[i] == '\r' || src[i] == '\n' { // base64 package ignores CR and LF - return "", ErrInvalidUTF7 + err = ErrInvalidUTF7 + return } } if i == len(src) { // Implicit shift ("&...") - return "", ErrInvalidUTF7 + if atEOF { + err = ErrInvalidUTF7 + } else { + err = transform.ErrShortSrc + } + return } + var b []byte if i == start { // Escape sequence "&-" - sb.WriteByte('&') - ascii = true + b = []byte{'&'} + d.ascii = true } else { // Control or non-ASCII code points in base64 - if !ascii { // Null shift ("&...-&...-") - return "", ErrInvalidUTF7 + if !d.ascii { // Null shift ("&...-&...-") + err = ErrInvalidUTF7 + return } - b := decode([]byte(src[start:i])) - if len(b) == 0 { // Bad encoding - return "", ErrInvalidUTF7 - } - sb.Write(b) + b = decode(src[start:i]) + d.ascii = false + } - ascii = false + if len(b) == 0 { // Bad encoding + err = ErrInvalidUTF7 + return + } + + if nDst+len(b) > len(dst) { + d.ascii = true + err = transform.ErrShortDst + return + } + + nSrc = i + 1 + + for _, ch := range b { + dst[nDst] = ch + nDst++ } } - return sb.String(), nil + if atEOF { + d.ascii = true + } + + return +} + +func (d *decoder) Reset() { + d.ascii = true } // Extracts UTF-16-BE bytes from base64 data and converts them to UTF-8. @@ -106,7 +137,7 @@ func decode(b64 []byte) []byte { return nil } r2 := rune(b[i])<<8 | rune(b[i+1]) - if r = utf16.DecodeRune(r, r2); r == utf8.RuneError { + if r = utf16.DecodeRune(r, r2); r == repl { return nil } } else if min <= r && r <= max { diff --git a/internal/utf7/decoder_test.go b/utf7/decoder_test.go similarity index 95% rename from internal/utf7/decoder_test.go rename to utf7/decoder_test.go index 8584d96..5ff9fc2 100644 --- a/internal/utf7/decoder_test.go +++ b/utf7/decoder_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/emersion/go-imap/v2/internal/utf7" + "github.com/emersion/go-imap/utf7" ) var decode = []struct { @@ -30,10 +30,8 @@ var decode = []struct { {"\x1F", "", false}, {"abc\n", "", false}, {"abc\x7Fxyz", "", false}, - - // Invalid UTF-8 - {"\xc3\x28", "", false}, - {"\xe2\x82\x28", "", false}, + {"\uFFFD", "", false}, + {"\u041C", "", false}, // Invalid Base64 alphabet {"&/+8-", "", false}, @@ -99,8 +97,10 @@ var decode = []struct { } func TestDecoder(t *testing.T) { + dec := utf7.Encoding.NewDecoder() + for _, test := range decode { - out, err := utf7.Decode(test.in) + out, err := dec.String(test.in) if out != test.out { t.Errorf("UTF7Decode(%+q) expected %+q; got %+q", test.in, test.out, out) } diff --git a/internal/utf7/encoder.go b/utf7/encoder.go similarity index 65% rename from internal/utf7/encoder.go rename to utf7/encoder.go index e7107c3..8414d10 100644 --- a/internal/utf7/encoder.go +++ b/utf7/encoder.go @@ -1,23 +1,23 @@ package utf7 import ( - "strings" "unicode/utf16" "unicode/utf8" + + "golang.org/x/text/transform" ) -// Encode encodes a string with modified UTF-7. -func Encode(src string) string { - var sb strings.Builder - sb.Grow(len(src)) +type encoder struct{} +func (e *encoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { for i := 0; i < len(src); { ch := src[i] + var b []byte if min <= ch && ch <= max { - sb.WriteByte(ch) + b = []byte{ch} if ch == '&' { - sb.WriteByte('-') + b = append(b, '-') } i++ @@ -30,13 +30,32 @@ func Encode(src string) string { i++ } - sb.Write(encode([]byte(src[start:i]))) + if !atEOF && i == len(src) { + err = transform.ErrShortSrc + return + } + + b = encode(src[start:i]) + } + + if nDst+len(b) > len(dst) { + err = transform.ErrShortDst + return + } + + nSrc = i + + for _, ch := range b { + dst[nDst] = ch + nDst++ } } - return sb.String() + return } +func (e *encoder) Reset() {} + // Converts string s from UTF-8 to UTF-16-BE, encodes the result as base64, // removes the padding, and adds UTF-7 shifts. func encode(s []byte) []byte { @@ -49,7 +68,7 @@ func encode(s []byte) []byte { r, size = utf8.RuneError, 1 // Bug fix (issue 3785) } s = s[size:] - if r1, r2 := utf16.EncodeRune(r); r1 != utf8.RuneError { + if r1, r2 := utf16.EncodeRune(r); r1 != repl { b = append(b, byte(r1>>8), byte(r1)) r = r2 } @@ -70,19 +89,3 @@ func encode(s []byte) []byte { b64[n-1] = '-' return b64 } - -// Escape passes through raw UTF-8 as-is and escapes the special UTF-7 marker -// (the ampersand character). -func Escape(src string) string { - var sb strings.Builder - sb.Grow(len(src)) - - for _, ch := range src { - sb.WriteRune(ch) - if ch == '&' { - sb.WriteByte('-') - } - } - - return sb.String() -} diff --git a/internal/utf7/encoder_test.go b/utf7/encoder_test.go similarity index 97% rename from internal/utf7/encoder_test.go rename to utf7/encoder_test.go index afa81e3..d1112a0 100644 --- a/internal/utf7/encoder_test.go +++ b/utf7/encoder_test.go @@ -3,7 +3,7 @@ package utf7_test import ( "testing" - "github.com/emersion/go-imap/v2/internal/utf7" + "github.com/emersion/go-imap/utf7" ) var encode = []struct { @@ -115,8 +115,10 @@ var encode = []struct { } func TestEncoder(t *testing.T) { + enc := utf7.Encoding.NewEncoder() + for _, test := range encode { - out := utf7.Encode(test.in) + out, _ := enc.String(test.in) if out != test.out { t.Errorf("UTF7Encode(%+q) expected %+q; got %+q", test.in, test.out, out) } diff --git a/utf7/utf7.go b/utf7/utf7.go new file mode 100644 index 0000000..b9dd962 --- /dev/null +++ b/utf7/utf7.go @@ -0,0 +1,34 @@ +// Package utf7 implements modified UTF-7 encoding defined in RFC 3501 section 5.1.3 +package utf7 + +import ( + "encoding/base64" + + "golang.org/x/text/encoding" +) + +const ( + min = 0x20 // Minimum self-representing UTF-7 value + max = 0x7E // Maximum self-representing UTF-7 value + + repl = '\uFFFD' // Unicode replacement code point +) + +var b64Enc = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,") + +type enc struct{} + +func (e enc) NewDecoder() *encoding.Decoder { + return &encoding.Decoder{ + Transformer: &decoder{true}, + } +} + +func (e enc) NewEncoder() *encoding.Encoder { + return &encoding.Encoder{ + Transformer: &encoder{}, + } +} + +// Encoding is the modified UTF-7 encoding. +var Encoding encoding.Encoding = enc{} diff --git a/write.go b/write.go new file mode 100644 index 0000000..c295e4e --- /dev/null +++ b/write.go @@ -0,0 +1,255 @@ +package imap + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "strconv" + "time" + "unicode" +) + +type flusher interface { + Flush() error +} + +type ( + // A raw string. + RawString string +) + +type WriterTo interface { + WriteTo(w *Writer) error +} + +func formatNumber(num uint32) string { + return strconv.FormatUint(uint64(num), 10) +} + +// Convert a string list to a field list. +func FormatStringList(list []string) (fields []interface{}) { + fields = make([]interface{}, len(list)) + for i, v := range list { + fields[i] = v + } + return +} + +// Check if a string is 8-bit clean. +func isAscii(s string) bool { + for _, c := range s { + if c > unicode.MaxASCII || unicode.IsControl(c) { + return false + } + } + return true +} + +// An IMAP writer. +type Writer struct { + io.Writer + + AllowAsyncLiterals bool + + continues <-chan bool +} + +// Helper function to write a string to w. +func (w *Writer) writeString(s string) error { + _, err := io.WriteString(w.Writer, s) + return err +} + +func (w *Writer) writeCrlf() error { + if err := w.writeString(crlf); err != nil { + return err + } + + return w.Flush() +} + +func (w *Writer) writeNumber(num uint32) error { + return w.writeString(formatNumber(num)) +} + +func (w *Writer) writeQuoted(s string) error { + return w.writeString(strconv.Quote(s)) +} + +func (w *Writer) writeQuotedOrLiteral(s string) error { + if !isAscii(s) { + // IMAP doesn't allow 8-bit data outside literals + return w.writeLiteral(bytes.NewBufferString(s)) + } + + return w.writeQuoted(s) +} + +func (w *Writer) writeDateTime(t time.Time, layout string) error { + if t.IsZero() { + return w.writeString(nilAtom) + } + return w.writeQuoted(t.Format(layout)) +} + +func (w *Writer) writeFields(fields []interface{}) error { + for i, field := range fields { + if i > 0 { // Write separator + if err := w.writeString(string(sp)); err != nil { + return err + } + } + + if err := w.writeField(field); err != nil { + return err + } + } + + return nil +} + +func (w *Writer) writeList(fields []interface{}) error { + if err := w.writeString(string(listStart)); err != nil { + return err + } + + if err := w.writeFields(fields); err != nil { + return err + } + + return w.writeString(string(listEnd)) +} + +// LiteralLengthErr is returned when the Len() of the Literal object does not +// match the actual length of the byte stream. +type LiteralLengthErr struct { + Actual int + Expected int +} + +func (e LiteralLengthErr) Error() string { + return fmt.Sprintf("imap: size of Literal is not equal to Len() (%d != %d)", e.Expected, e.Actual) +} + +func (w *Writer) writeLiteral(l Literal) error { + if l == nil { + return w.writeString(nilAtom) + } + + unsyncLiteral := w.AllowAsyncLiterals && l.Len() <= 4096 + + header := string(literalStart) + strconv.Itoa(l.Len()) + if unsyncLiteral { + header += string('+') + } + header += string(literalEnd) + crlf + if err := w.writeString(header); err != nil { + return err + } + + // If a channel is available, wait for a continuation request before sending data + if !unsyncLiteral && w.continues != nil { + // Make sure to flush the writer, otherwise we may never receive a continuation request + if err := w.Flush(); err != nil { + return err + } + + if !<-w.continues { + return fmt.Errorf("imap: cannot send literal: no continuation request received") + } + } + + // In case of bufio.Buffer, it will be 0 after io.Copy. + literalLen := int64(l.Len()) + + n, err := io.CopyN(w, l, literalLen) + if err != nil { + if err == io.EOF && n != literalLen { + return LiteralLengthErr{int(n), l.Len()} + } + return err + } + extra, _ := io.Copy(ioutil.Discard, l) + if extra != 0 { + return LiteralLengthErr{int(n + extra), l.Len()} + } + + return nil +} + +func (w *Writer) writeField(field interface{}) error { + if field == nil { + return w.writeString(nilAtom) + } + + switch field := field.(type) { + case RawString: + return w.writeString(string(field)) + case string: + return w.writeQuotedOrLiteral(field) + case int: + return w.writeNumber(uint32(field)) + case uint32: + return w.writeNumber(field) + case Literal: + return w.writeLiteral(field) + case []interface{}: + return w.writeList(field) + case envelopeDateTime: + return w.writeDateTime(time.Time(field), envelopeDateTimeLayout) + case searchDate: + return w.writeDateTime(time.Time(field), searchDateLayout) + case Date: + return w.writeDateTime(time.Time(field), DateLayout) + case DateTime: + return w.writeDateTime(time.Time(field), DateTimeLayout) + case time.Time: + return w.writeDateTime(field, DateTimeLayout) + case *SeqSet: + return w.writeString(field.String()) + case *BodySectionName: + // Can contain spaces - that's why we don't just pass it as a string + return w.writeString(string(field.FetchItem())) + } + + return fmt.Errorf("imap: cannot format field: %v", field) +} + +func (w *Writer) writeRespCode(code StatusRespCode, args []interface{}) error { + if err := w.writeString(string(respCodeStart)); err != nil { + return err + } + + fields := []interface{}{RawString(code)} + fields = append(fields, args...) + + if err := w.writeFields(fields); err != nil { + return err + } + + return w.writeString(string(respCodeEnd)) +} + +func (w *Writer) writeLine(fields ...interface{}) error { + if err := w.writeFields(fields); err != nil { + return err + } + + return w.writeCrlf() +} + +func (w *Writer) Flush() error { + if f, ok := w.Writer.(flusher); ok { + return f.Flush() + } + return nil +} + +func NewWriter(w io.Writer) *Writer { + return &Writer{Writer: w} +} + +func NewClientWriter(w io.Writer, continues <-chan bool) *Writer { + return &Writer{Writer: w, continues: continues} +} diff --git a/write_test.go b/write_test.go new file mode 100644 index 0000000..e3abcfb --- /dev/null +++ b/write_test.go @@ -0,0 +1,292 @@ +package imap + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func newWriter() (w *Writer, b *bytes.Buffer) { + b = &bytes.Buffer{} + w = NewWriter(b) + return +} + +func TestWriter_WriteCrlf(t *testing.T) { + w, b := newWriter() + + if err := w.writeCrlf(); err != nil { + t.Error(err) + } + if b.String() != "\r\n" { + t.Error("Not a CRLF") + } +} + +func TestWriter_WriteField_Nil(t *testing.T) { + w, b := newWriter() + + if err := w.writeField(nil); err != nil { + t.Error(err) + } + if b.String() != "NIL" { + t.Error("Not NIL") + } +} + +func TestWriter_WriteField_Number(t *testing.T) { + w, b := newWriter() + + if err := w.writeField(uint32(42)); err != nil { + t.Error(err) + } + if b.String() != "42" { + t.Error("Not the expected number") + } +} + +func TestWriter_WriteField_Atom(t *testing.T) { + w, b := newWriter() + + if err := w.writeField(RawString("BODY[]")); err != nil { + t.Error(err) + } + if b.String() != "BODY[]" { + t.Error("Not the expected atom") + } +} + +func TestWriter_WriteString_Quoted(t *testing.T) { + w, b := newWriter() + + if err := w.writeField("I love potatoes!"); err != nil { + t.Error(err) + } + if b.String() != "\"I love potatoes!\"" { + t.Error("Not the expected quoted string") + } +} + +func TestWriter_WriteString_Quoted_WithSpecials(t *testing.T) { + w, b := newWriter() + + if err := w.writeField("I love \"1984\"!"); err != nil { + t.Error(err) + } + if b.String() != "\"I love \\\"1984\\\"!\"" { + t.Error("Not the expected quoted string") + } +} + +func TestWriter_WriteField_ForcedQuoted(t *testing.T) { + w, b := newWriter() + + if err := w.writeField("dille"); err != nil { + t.Error(err) + } + if b.String() != "\"dille\"" { + t.Error("Not the expected atom:", b.String()) + } +} + +func TestWriter_WriteField_8bitString(t *testing.T) { + w, b := newWriter() + + if err := w.writeField("☺"); err != nil { + t.Error(err) + } + if b.String() != "{3}\r\n☺" { + t.Error("Not the expected atom") + } +} + +func TestWriter_WriteField_NilString(t *testing.T) { + w, b := newWriter() + + if err := w.writeField("NIL"); err != nil { + t.Error(err) + } + if b.String() != "\"NIL\"" { + t.Error("Not the expected quoted string") + } +} + +func TestWriter_WriteField_EmptyString(t *testing.T) { + w, b := newWriter() + + if err := w.writeField(""); err != nil { + t.Error(err) + } + if b.String() != "\"\"" { + t.Error("Not the expected quoted string") + } +} + +func TestWriter_WriteField_DateTime(t *testing.T) { + w, b := newWriter() + + dt := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + if err := w.writeField(dt); err != nil { + t.Error(err) + } + if b.String() != `"10-Nov-2009 23:00:00 +0000"` { + t.Error("Invalid date:", b.String()) + } +} + +func TestWriter_WriteField_ZeroDateTime(t *testing.T) { + w, b := newWriter() + + dt := time.Time{} + if err := w.writeField(dt); err != nil { + t.Error(err) + } + if b.String() != "NIL" { + t.Error("Invalid nil date:", b.String()) + } +} + +func TestWriter_WriteFields(t *testing.T) { + w, b := newWriter() + + if err := w.writeFields([]interface{}{RawString("hey"), 42}); err != nil { + t.Error(err) + } + if b.String() != "hey 42" { + t.Error("Not the expected fields") + } +} + +func TestWriter_WriteField_SimpleList(t *testing.T) { + w, b := newWriter() + + if err := w.writeField([]interface{}{RawString("hey"), 42}); err != nil { + t.Error(err) + } + if b.String() != "(hey 42)" { + t.Error("Not the expected list") + } +} + +func TestWriter_WriteField_NestedList(t *testing.T) { + w, b := newWriter() + + list := []interface{}{ + RawString("toplevel"), + []interface{}{ + RawString("nested"), + 0, + }, + 22, + } + + if err := w.writeField(list); err != nil { + t.Error(err) + } + if b.String() != "(toplevel (nested 0) 22)" { + t.Error("Not the expected list:", b.String()) + } +} + +func TestWriter_WriteField_Literal(t *testing.T) { + w, b := newWriter() + + literal := bytes.NewBufferString("hello world") + + if err := w.writeField(literal); err != nil { + t.Error(err) + } + if b.String() != "{11}\r\nhello world" { + t.Error("Not the expected literal") + } +} + +func TestWriter_WriteField_NonSyncLiteral(t *testing.T) { + w, b := newWriter() + w.AllowAsyncLiterals = true + + literal := bytes.NewBufferString("hello world") + + if err := w.writeField(literal); err != nil { + t.Error(err) + } + if b.String() != "{11+}\r\nhello world" { + t.Error("Not the expected literal") + } +} + +func TestWriter_WriteField_LargeNonSyncLiteral(t *testing.T) { + w, b := newWriter() + w.AllowAsyncLiterals = true + + s := strings.Repeat("A", 4097) + literal := bytes.NewBufferString(s) + + if err := w.writeField(literal); err != nil { + t.Error(err) + } + if b.String() != "{4097}\r\n"+s { + t.Error("Not the expected literal") + } +} + +func TestWriter_WriteField_SeqSet(t *testing.T) { + w, b := newWriter() + + seqSet, _ := ParseSeqSet("3:4,6,42:*") + + if err := w.writeField(seqSet); err != nil { + t.Error(err) + } + if s := b.String(); s != "3:4,6,42:*" { + t.Error("Not the expected sequence set", s) + } +} + +func TestWriter_WriteField_BodySectionName(t *testing.T) { + w, b := newWriter() + + name, _ := ParseBodySectionName("BODY.PEEK[HEADER.FIELDS (date subject from to cc)]") + + if err := w.writeField(name.resp()); err != nil { + t.Error(err) + } + if s := b.String(); s != "BODY[HEADER.FIELDS (date subject from to cc)]" { + t.Error("Not the expected body section name", s) + } +} + +func TestWriter_WriteRespCode_NoArgs(t *testing.T) { + w, b := newWriter() + + if err := w.writeRespCode("READ-ONLY", nil); err != nil { + t.Error(err) + } + if b.String() != "[READ-ONLY]" { + t.Error("Not the expected response code:", b.String()) + } +} + +func TestWriter_WriteRespCode_WithArgs(t *testing.T) { + w, b := newWriter() + + args := []interface{}{RawString("IMAP4rev1"), RawString("STARTTLS"), RawString("LOGINDISABLED")} + if err := w.writeRespCode("CAPABILITY", args); err != nil { + t.Error(err) + } + if b.String() != "[CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED]" { + t.Error("Not the expected response code:", b.String()) + } +} + +func TestWriter_WriteLine(t *testing.T) { + w, b := newWriter() + + if err := w.writeLine(RawString("*"), RawString("OK")); err != nil { + t.Error(err) + } + if b.String() != "* OK\r\n" { + t.Error("Not the expected line:", b.String()) + } +}