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 0000000..2fa486a Binary files /dev/null and b/go-imap-1.zip differ 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()) + } +}