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..9a8ef8d 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,170 @@ # 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) +[![GoDoc](https://godoc.org/github.com/emersion/go-imap?status.svg)](https://godoc.org/github.com/emersion/go-imap) +[![Build Status](https://travis-ci.org/emersion/go-imap.svg?branch=master)](https://travis-ci.org/emersion/go-imap) +[![Codecov](https://codecov.io/gh/emersion/go-imap/branch/master/graph/badge.svg)](https://codecov.io/gh/emersion/go-imap) +[![Go Report +Card](https://goreportcard.com/badge/github.com/emersion/go-imap)](https://goreportcard.com/report/github.com/emersion/go-imap) +[![Unstable](https://img.shields.io/badge/stability-unstable-yellow.svg)](https://github.com/emersion/stability-badges#unstable) -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]. +```shell +go get github.com/emersion/go-imap/... +``` ## Usage -To add go-imap to your project, run: +### Client [![GoDoc](https://godoc.org/github.com/emersion/go-imap/client?status.svg)](https://godoc.org/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 substract 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 [![GoDoc](https://godoc.org/github.com/emersion/go-imap/server?status.svg)](https://godoc.org/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. + +## 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. + +* [APPENDLIMIT](https://github.com/emersion/go-imap-appendlimit) +* [COMPRESS](https://github.com/emersion/go-imap-compress) +* [ENABLE](https://github.com/emersion/go-imap-enable) +* [ID](https://github.com/ProtonMail/go-imap-id) +* [IDLE](https://github.com/emersion/go-imap-idle) +* [MOVE](https://github.com/emersion/go-imap-move) +* [QUOTA](https://github.com/emersion/go-imap-quota) +* [SPECIAL-USE](https://github.com/emersion/go-imap-specialuse) +* [UNSELECT](https://github.com/emersion/go-imap-unselect) +* [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) + +### Related projects + +* [go-message](https://github.com/emersion/go-message) - parsing and formatting MIME and mail messages +* [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 +* [go-dkim](https://github.com/emersion/go-dkim) - creating and verifying DKIM signatures ## 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/acl.go b/acl.go deleted file mode 100644 index 4d9431e..0000000 --- a/acl.go +++ /dev/null @@ -1,104 +0,0 @@ -package imap - -import ( - "fmt" - "strings" -) - -// IMAP4 ACL extension (RFC 2086) - -// Right describes a set of operations controlled by the IMAP ACL extension. -type Right byte - -const ( - // Standard rights - RightLookup = Right('l') // mailbox is visible to LIST/LSUB commands - RightRead = Right('r') // SELECT the mailbox, perform CHECK, FETCH, PARTIAL, SEARCH, COPY from mailbox - RightSeen = Right('s') // keep seen/unseen information across sessions (STORE SEEN flag) - RightWrite = Right('w') // STORE flags other than SEEN and DELETED - RightInsert = Right('i') // perform APPEND, COPY into mailbox - RightPost = Right('p') // send mail to submission address for mailbox, not enforced by IMAP4 itself - RightCreate = Right('c') // CREATE new sub-mailboxes in any implementation-defined hierarchy - RightDelete = Right('d') // STORE DELETED flag, perform EXPUNGE - RightAdminister = Right('a') // perform SETACL -) - -// RightSetAll contains all standard rights. -var RightSetAll = RightSet("lrswipcda") - -// RightsIdentifier is an ACL identifier. -type RightsIdentifier string - -// RightsIdentifierAnyone is the universal identity (matches everyone). -const RightsIdentifierAnyone = RightsIdentifier("anyone") - -// NewRightsIdentifierUsername returns a rights identifier referring to a -// username, checking for reserved values. -func NewRightsIdentifierUsername(username string) (RightsIdentifier, error) { - if username == string(RightsIdentifierAnyone) || strings.HasPrefix(username, "-") { - return "", fmt.Errorf("imap: reserved rights identifier") - } - return RightsIdentifier(username), nil -} - -// RightModification indicates how to mutate a right set. -type RightModification byte - -const ( - RightModificationReplace = RightModification(0) - RightModificationAdd = RightModification('+') - RightModificationRemove = RightModification('-') -) - -// A RightSet is a set of rights. -type RightSet []Right - -// String returns a string representation of the right set. -func (r RightSet) String() string { - return string(r) -} - -// Add returns a new right set containing rights from both sets. -func (r RightSet) Add(rights RightSet) RightSet { - newRights := make(RightSet, len(r), len(r)+len(rights)) - copy(newRights, r) - - for _, right := range rights { - if !strings.ContainsRune(string(r), rune(right)) { - newRights = append(newRights, right) - } - } - - return newRights -} - -// Remove returns a new right set containing all rights in r except these in -// the provided set. -func (r RightSet) Remove(rights RightSet) RightSet { - newRights := make(RightSet, 0, len(r)) - - for _, right := range r { - if !strings.ContainsRune(string(rights), rune(right)) { - newRights = append(newRights, right) - } - } - - return newRights -} - -// Equal returns true if both right sets contain exactly the same rights. -func (rs1 RightSet) Equal(rs2 RightSet) bool { - for _, r := range rs1 { - if !strings.ContainsRune(string(rs2), rune(r)) { - return false - } - } - - for _, r := range rs2 { - if !strings.ContainsRune(string(rs1), rune(r)) { - return false - } - } - - return true -} diff --git a/append.go b/append.go deleted file mode 100644 index 13d887f..0000000 --- a/append.go +++ /dev/null @@ -1,18 +0,0 @@ -package imap - -import ( - "time" -) - -// AppendOptions contains options for the APPEND command. -type AppendOptions struct { - Flags []Flag - Time time.Time -} - -// AppendData is the data returned by an APPEND command. -type AppendData struct { - // requires UIDPLUS or IMAP4rev2 - UID UID - UIDValidity uint32 -} diff --git a/backend/backend.go b/backend/backend.go new file mode 100644 index 0000000..031f427 --- /dev/null +++ b/backend/backend.go @@ -0,0 +1,16 @@ +// Package backend defines an IMAP server backend interface. +package backend + +import "errors" + +// 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(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..545fb1b --- /dev/null +++ b/backend/backendutil/backendutil_test.go @@ -0,0 +1,55 @@ +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" + + "From: Mitsuha Miyamizu \r\n" + + "Message-Id: 42@example.org\r\n" + + "Subject: Your Name.\r\n" + + "To: Taki Tachibana \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 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 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..5466c8b --- /dev/null +++ b/backend/backendutil/body.go @@ -0,0 +1,75 @@ +package backendutil + +import ( + "bytes" + "errors" + "io" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" +) + +var errNoSuchPart = errors.New("backendutil: no such message body part") + +// FetchBodySection extracts a body section from a message. +func FetchBodySection(e *message.Entity, 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 := e.MultipartReader() + if mr == nil { + 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 { + e = p + break + } + } + } + + // Then, write the requested data to a buffer + b := new(bytes.Buffer) + + // Write the header + mw, err := message.CreateWriter(b, e.Header) + if err != nil { + return nil, err + } + defer mw.Close() + + 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(mw, e.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..2d8730c --- /dev/null +++ b/backend/backendutil/body_test.go @@ -0,0 +1,109 @@ +package backendutil + +import ( + "io/ioutil" + "strings" + "testing" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" +) + +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[1.1.HEADER]", + body: testTextHeaderString, + }, + { + 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) { + e, err := message.Read(strings.NewReader(testMailString)) + 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(e, 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) + } + } + }) + } +} diff --git a/backend/backendutil/bodystructure.go b/backend/backendutil/bodystructure.go new file mode 100644 index 0000000..6268cf5 --- /dev/null +++ b/backend/backendutil/bodystructure.go @@ -0,0 +1,60 @@ +package backendutil + +import ( + "io" + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" +) + +// FetchBodyStructure computes a message's body structure from its content. +func FetchBodyStructure(e *message.Entity, extended bool) (*imap.BodyStructure, error) { + bs := new(imap.BodyStructure) + + mediaType, mediaParams, _ := e.Header.ContentType() + typeParts := strings.SplitN(mediaType, "/", 2) + bs.MIMEType = typeParts[0] + if len(typeParts) == 2 { + bs.MIMESubType = typeParts[1] + } + bs.Params = mediaParams + + bs.Id = e.Header.Get("Content-Id") + bs.Description = e.Header.Get("Content-Description") + bs.Encoding = e.Header.Get("Content-Encoding") + // TODO: bs.Size + + if mr := e.MultipartReader(); 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, extended) + if err != nil { + return nil, err + } + parts = append(parts, pbs) + } + bs.Parts = parts + } + + // TODO: bs.Envelope, bs.BodyStructure + // TODO: bs.Lines + + if extended { + bs.Extended = true + + bs.Disposition, bs.DispositionParams, _ = e.Header.ContentDisposition() + + // 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..958d655 --- /dev/null +++ b/backend/backendutil/bodystructure_test.go @@ -0,0 +1,67 @@ +package backendutil + +import ( + "reflect" + "strings" + "testing" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" +) + +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{}, + }, + { + MIMEType: "text", + MIMESubType: "html", + Params: map[string]string{}, + Extended: true, + Disposition: "inline", + DispositionParams: map[string]string{}, + }, + }, + }, + { + MIMEType: "text", + MIMESubType: "plain", + Params: map[string]string{}, + Extended: true, + Disposition: "attachment", + DispositionParams: map[string]string{"filename": "note.txt"}, + }, + }, + Extended: true, +} + +func TestFetchBodyStructure(t *testing.T) { + e, err := message.Read(strings.NewReader(testMailString)) + if err != nil { + t.Fatal("Expected no error while reading mail, got:", err) + } + + bs, err := FetchBodyStructure(e, 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..4fe49b5 --- /dev/null +++ b/backend/backendutil/envelope.go @@ -0,0 +1,50 @@ +package backendutil + +import ( + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" + "github.com/emersion/go-message/mail" +) + +func headerAddressList(h mail.Header, key string) ([]*imap.Address, error) { + addrs, err := h.AddressList(key) + + 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 message.Header) (*imap.Envelope, error) { + mh := mail.Header{h} + + env := new(imap.Envelope) + env.Date, _ = mh.Date() + env.Subject, _ = mh.Subject() + env.From, _ = headerAddressList(mh, "From") + env.Sender, _ = headerAddressList(mh, "Sender") + env.ReplyTo, _ = headerAddressList(mh, "Reply-To") + env.To, _ = headerAddressList(mh, "To") + env.Cc, _ = headerAddressList(mh, "Cc") + env.Bcc, _ = headerAddressList(mh, "Bcc") + env.InReplyTo = mh.Get("In-Reply-To") + env.MessageId = mh.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..6124599 --- /dev/null +++ b/backend/backendutil/envelope_test.go @@ -0,0 +1,39 @@ +package backendutil + +import ( + "reflect" + "strings" + "testing" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" +) + +var testEnvelope = &imap.Envelope{ + Date: testDate, + Subject: "Your Name.", + From: []*imap.Address{{PersonalName: "Mitsuha Miyamizu", MailboxName: "mitsuha.miyamizu", HostName: "example.org"}}, + Sender: []*imap.Address{}, + ReplyTo: []*imap.Address{}, + 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) { + e, err := message.Read(strings.NewReader(testMailString)) + if err != nil { + t.Fatal("Expected no error while reading mail, got:", err) + } + + env, err := FetchEnvelope(e.Header) + 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..31563a6 --- /dev/null +++ b/backend/backendutil/flags.go @@ -0,0 +1,40 @@ +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 { + switch op { + case imap.SetFlags: + // TODO: keep \Recent if it is present + return flags + case imap.AddFlags: + // Check for duplicates + for _, flag := range current { + for i, addFlag := range flags { + if addFlag == flag { + flags = append(flags[:i], flags[i+1:]...) + break + } + } + } + return append(current, flags...) + case imap.RemoveFlags: + // Iterate through flags from the last one to the first one, to be able to + // delete some of them. + for i := len(current) - 1; i >= 0; i-- { + flag := current[i] + + for _, removeFlag := range flags { + if removeFlag == flag { + current = append(current[:i], current[i+1:]...) + break + } + } + } + return current + } + return current +} diff --git a/backend/backendutil/flags_test.go b/backend/backendutil/flags_test.go new file mode 100644 index 0000000..26c5d9d --- /dev/null +++ b/backend/backendutil/flags_test.go @@ -0,0 +1,46 @@ +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"}, + }, +} + +func TestUpdateFlags(t *testing.T) { + current := []string{"a", "b", "c"} + for _, test := range updateFlagsTests { + 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) + } + } +} diff --git a/backend/backendutil/search.go b/backend/backendutil/search.go new file mode 100644 index 0000000..2d7322f --- /dev/null +++ b/backend/backendutil/search.go @@ -0,0 +1,225 @@ +package backendutil + +import ( + "bytes" + "fmt" + "io" + "strings" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" + "github.com/emersion/go-message/mail" +) + +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 +} + +func bodyLen(e *message.Entity) (int, error) { + if l, ok := e.Body.(lengther); ok { + return l.Len(), nil + } + + b, err := bufferBody(e) + if err != nil { + return 0, err + } + return b.Len(), nil +} + +// Match returns true if a message matches the provided criteria. Sequence +// number, UID, flag and internal date contrainsts are not checked. +func Match(e *message.Entity, 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{e.Header} + + if !c.SentBefore.IsZero() || !c.SentSince.IsZero() { + t, err := h.Date() + if err != nil { + return false, err + } + t = t.Round(24 * time.Hour) + + if !c.SentBefore.IsZero() && !t.Before(c.SentBefore) { + return false, nil + } + if !c.SentSince.IsZero() && !t.After(c.SentSince) { + return false, nil + } + } + + for key, wantValues := range c.Header { + values, ok := e.Header[key] + for _, wantValue := range wantValues { + if wantValue == "" && !ok { + return false, nil + } + if wantValue != "" { + ok := false + for _, v := range values { + if matchString(v, 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 { + // TODO: also match header fields + if ok, err := matchBody(e, text); err != nil || !ok { + 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 + } + } + + for _, not := range c.Not { + ok, err := Match(e, not) + if err != nil || ok { + return false, err + } + } + for _, or := range c.Or { + ok1, err := Match(e, or[0]) + if err != nil { + return ok1, err + } + + ok2, err := Match(e, or[1]) + if err != nil || (!ok1 && !ok2) { + return false, err + } + } + + return true, nil +} + +func matchFlags(flags map[string]bool, c *imap.SearchCriteria) bool { + for _, f := range c.WithFlags { + if !flags[f] { + return false + } + } + for _, f := range c.WithoutFlags { + if flags[f] { + return false + } + } + + for _, not := range c.Not { + if matchFlags(flags, not) { + return false + } + } + for _, or := range c.Or { + if !matchFlags(flags, or[0]) && !matchFlags(flags, or[1]) { + return false + } + } + return true +} + +// MatchFlags returns true if a flag list matches the provided criteria. +func MatchFlags(flags []string, c *imap.SearchCriteria) bool { + flagsMap := make(map[string]bool) + for _, f := range flags { + flagsMap[f] = true + } + + return matchFlags(flagsMap, c) +} + +// MatchSeqNumAndUid returns true if a sequence number and a UID matches the +// provided criteria. +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 + } + + for _, not := range c.Not { + if MatchSeqNumAndUid(seqNum, uid, not) { + return false + } + } + for _, or := range c.Or { + if !MatchSeqNumAndUid(seqNum, uid, or[0]) && !MatchSeqNumAndUid(seqNum, uid, or[1]) { + return false + } + } + return true +} + +// MatchDate returns true if a date matches the provided criteria. +func MatchDate(date time.Time, c *imap.SearchCriteria) bool { + date = date.Round(24 * time.Hour) + if !c.Since.IsZero() && !date.After(c.Since) { + return false + } + if !c.Before.IsZero() && !date.Before(c.Before) { + return false + } + + for _, not := range c.Not { + if MatchDate(date, not) { + return false + } + } + for _, or := range c.Or { + if !MatchDate(date, or[0]) && !MatchDate(date, or[1]) { + return false + } + } + return true +} diff --git a/backend/backendutil/search_test.go b/backend/backendutil/search_test.go new file mode 100644 index 0000000..101d665 --- /dev/null +++ b/backend/backendutil/search_test.go @@ -0,0 +1,264 @@ +package backendutil + +import ( + "net/textproto" + "strings" + "testing" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" +) + +var matchTests = []struct { + criteria *imap.SearchCriteria + 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{"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, + }, +} + +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.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) + } + } +} + +var flagsTests = []struct { + flags []string + criteria *imap.SearchCriteria + res bool +}{ + { + 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, + }, +} + +func TestMatchFlags(t *testing.T) { + for i, test := range flagsTests { + ok := MatchFlags(test.flags, test.criteria) + 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 TestMatchSeqNumAndUid(t *testing.T) { + seqNum := uint32(42) + uid := uint32(69) + + c := &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Uid: new(imap.SeqSet), + Not: []*imap.SearchCriteria{{SeqNum: new(imap.SeqSet)}}, + }, + { + SeqNum: new(imap.SeqSet), + }, + }}, + } + + if MatchSeqNumAndUid(seqNum, uid, c) { + t.Error("Expected not to match criteria") + } + + c.Or[0][0].Uid.AddNum(uid) + if !MatchSeqNumAndUid(seqNum, uid, c) { + t.Error("Expected to match criteria") + } + + c.Or[0][0].Not[0].SeqNum.AddNum(seqNum) + if MatchSeqNumAndUid(seqNum, uid, c) { + t.Error("Expected not to match criteria") + } + + c.Or[0][1].SeqNum.AddNum(seqNum) + if !MatchSeqNumAndUid(seqNum, uid, c) { + t.Error("Expected to match criteria") + } +} + +func TestMatchDate(t *testing.T) { + date := time.Unix(1483997966, 0) + + c := &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{ + { + Since: date.Add(48 * time.Hour), + Not: []*imap.SearchCriteria{{ + Since: date.Add(48 * time.Hour), + }}, + }, + { + Before: date.Add(-48 * time.Hour), + }, + }}, + } + + if MatchDate(date, c) { + t.Error("Expected not to match criteria") + } + + c.Or[0][0].Since = date.Add(-48 * time.Hour) + if !MatchDate(date, c) { + t.Error("Expected to match criteria") + } + + c.Or[0][0].Not[0].Since = date.Add(-48 * time.Hour) + if MatchDate(date, c) { + t.Error("Expected not to match criteria") + } + + c.Or[0][1].Before = date.Add(48 * time.Hour) + if !MatchDate(date, c) { + t.Error("Expected to match 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..68f85f4 --- /dev/null +++ b/backend/memory/backend.go @@ -0,0 +1,55 @@ +// A memory backend. +package memory + +import ( + "errors" + "time" + + "github.com/emersion/go-imap/backend" +) + +type Backend struct { + users map[string]*User +} + +func (be *Backend) Login(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 +To: contact@example.org +Subject: A little message, just for you +Date: Wed, 11 May 2016 14:31:59 +0000 +Message-ID: <0000000@localhost/> +Content-Type: text/plain + +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}, + } +} 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..5c98681 --- /dev/null +++ b/backend/memory/message.go @@ -0,0 +1,70 @@ +package memory + +import ( + "bytes" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend/backendutil" + "github.com/emersion/go-message" +) + +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) Fetch(seqNum uint32, items []imap.FetchItem) (*imap.Message, error) { + fetched := imap.NewMessage(seqNum, items) + for _, item := range items { + switch item { + case imap.FetchEnvelope: + e, _ := m.entity() + fetched.Envelope, _ = backendutil.FetchEnvelope(e.Header) + case imap.FetchBody, imap.FetchBodyStructure: + e, _ := m.entity() + fetched.BodyStructure, _ = backendutil.FetchBodyStructure(e, 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 + } + + e, _ := m.entity() + l, _ := backendutil.FetchBodySection(e, section) + fetched.Body[section] = l + } + } + + return fetched, nil +} + +func (m *Message) Match(seqNum uint32, c *imap.SearchCriteria) (bool, error) { + if !backendutil.MatchSeqNumAndUid(seqNum, m.Uid, c) { + return false, nil + } + if !backendutil.MatchDate(m.Date, c) { + return false, nil + } + if !backendutil.MatchFlags(m.Flags, c) { + return false, nil + } + + e, _ := m.entity() + return backendutil.Match(e, 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/updates.go b/backend/updates.go new file mode 100644 index 0000000..f882f5b --- /dev/null +++ b/backend/updates.go @@ -0,0 +1,92 @@ +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 +} + +// 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/capability.go b/capability.go deleted file mode 100644 index e8b656c..0000000 --- a/capability.go +++ /dev/null @@ -1,212 +0,0 @@ -package imap - -import ( - "strconv" - "strings" -) - -// Cap represents an IMAP capability. -type Cap string - -// Registered capabilities. -// -// See: https://www.iana.org/assignments/imap-capabilities/ -const ( - CapIMAP4rev1 Cap = "IMAP4rev1" // RFC 3501 - CapIMAP4rev2 Cap = "IMAP4rev2" // RFC 9051 - - CapStartTLS Cap = "STARTTLS" - CapLoginDisabled Cap = "LOGINDISABLED" - - // Folded in IMAP4rev2 - CapNamespace Cap = "NAMESPACE" // RFC 2342 - CapUnselect Cap = "UNSELECT" // RFC 3691 - CapUIDPlus Cap = "UIDPLUS" // RFC 4315 - CapESearch Cap = "ESEARCH" // RFC 4731 - CapSearchRes Cap = "SEARCHRES" // RFC 5182 - CapEnable Cap = "ENABLE" // RFC 5161 - CapIdle Cap = "IDLE" // RFC 2177 - CapSASLIR Cap = "SASL-IR" // RFC 4959 - CapListExtended Cap = "LIST-EXTENDED" // RFC 5258 - CapListStatus Cap = "LIST-STATUS" // RFC 5819 - CapMove Cap = "MOVE" // RFC 6851 - CapLiteralMinus Cap = "LITERAL-" // RFC 7888 - CapStatusSize Cap = "STATUS=SIZE" // RFC 8438 - CapChildren Cap = "CHILDREN" // RFC 3348 - - CapACL Cap = "ACL" // RFC 4314 - CapAppendLimit Cap = "APPENDLIMIT" // RFC 7889 - CapBinary Cap = "BINARY" // RFC 3516 - CapCatenate Cap = "CATENATE" // RFC 4469 - CapCondStore Cap = "CONDSTORE" // RFC 7162 - CapConvert Cap = "CONVERT" // RFC 5259 - CapCreateSpecialUse Cap = "CREATE-SPECIAL-USE" // RFC 6154 - CapESort Cap = "ESORT" // RFC 5267 - CapFilters Cap = "FILTERS" // RFC 5466 - CapID Cap = "ID" // RFC 2971 - CapLanguage Cap = "LANGUAGE" // RFC 5255 - CapListMyRights Cap = "LIST-MYRIGHTS" // RFC 8440 - CapLiteralPlus Cap = "LITERAL+" // RFC 7888 - CapLoginReferrals Cap = "LOGIN-REFERRALS" // RFC 2221 - CapMailboxReferrals Cap = "MAILBOX-REFERRALS" // RFC 2193 - CapMetadata Cap = "METADATA" // RFC 5464 - CapMetadataServer Cap = "METADATA-SERVER" // RFC 5464 - CapMultiAppend Cap = "MULTIAPPEND" // RFC 3502 - CapMultiSearch Cap = "MULTISEARCH" // RFC 7377 - CapNotify Cap = "NOTIFY" // RFC 5465 - CapObjectID Cap = "OBJECTID" // RFC 8474 - CapPreview Cap = "PREVIEW" // RFC 8970 - CapQResync Cap = "QRESYNC" // RFC 7162 - CapQuota Cap = "QUOTA" // RFC 9208 - CapQuotaSet Cap = "QUOTASET" // RFC 9208 - CapReplace Cap = "REPLACE" // RFC 8508 - CapSaveDate Cap = "SAVEDATE" // RFC 8514 - CapSearchFuzzy Cap = "SEARCH=FUZZY" // RFC 6203 - CapSort Cap = "SORT" // RFC 5256 - CapSortDisplay Cap = "SORT=DISPLAY" // RFC 5957 - CapSpecialUse Cap = "SPECIAL-USE" // RFC 6154 - CapUnauthenticate Cap = "UNAUTHENTICATE" // RFC 8437 - CapURLPartial Cap = "URL-PARTIAL" // RFC 5550 - CapURLAuth Cap = "URLAUTH" // RFC 4467 - CapUTF8Accept Cap = "UTF8=ACCEPT" // RFC 6855 - CapUTF8Only Cap = "UTF8=ONLY" // RFC 6855 - CapWithin Cap = "WITHIN" // RFC 5032 - CapUIDOnly Cap = "UIDONLY" // RFC 9586 - CapListMetadata Cap = "LIST-METADATA" // RFC 9590 - CapInProgress Cap = "INPROGRESS" // RFC 9585 -) - -var imap4rev2Caps = CapSet{ - CapNamespace: {}, - CapUnselect: {}, - CapUIDPlus: {}, - CapESearch: {}, - CapSearchRes: {}, - CapEnable: {}, - CapIdle: {}, - CapSASLIR: {}, - CapListExtended: {}, - CapListStatus: {}, - CapMove: {}, - CapLiteralMinus: {}, - CapStatusSize: {}, - CapChildren: {}, -} - -// AuthCap returns the capability name for an SASL authentication mechanism. -func AuthCap(mechanism string) Cap { - return Cap("AUTH=" + mechanism) -} - -// CapSet is a set of capabilities. -type CapSet map[Cap]struct{} - -func (set CapSet) has(c Cap) bool { - _, ok := set[c] - return ok -} - -func (set CapSet) Copy() CapSet { - newSet := make(CapSet, len(set)) - for c := range set { - newSet[c] = struct{}{} - } - return newSet -} - -// Has checks whether a capability is supported. -// -// Some capabilities are implied by others, as such Has may return true even if -// the capability is not in the map. -func (set CapSet) Has(c Cap) bool { - if set.has(c) { - return true - } - - if set.has(CapIMAP4rev2) && imap4rev2Caps.has(c) { - return true - } - - if c == CapLiteralMinus && set.has(CapLiteralPlus) { - return true - } - if c == CapCondStore && set.has(CapQResync) { - return true - } - if c == CapUTF8Accept && set.has(CapUTF8Only) { - return true - } - if c == CapAppendLimit { - _, ok := set.AppendLimit() - return ok - } - - return false -} - -// AuthMechanisms returns the list of supported SASL mechanisms for -// authentication. -func (set CapSet) AuthMechanisms() []string { - var l []string - for c := range set { - if !strings.HasPrefix(string(c), "AUTH=") { - continue - } - mech := strings.TrimPrefix(string(c), "AUTH=") - l = append(l, mech) - } - return l -} - -// AppendLimit checks the APPENDLIMIT capability. -// -// If the server supports APPENDLIMIT, ok is true. If the server doesn't have -// the same upload limit for all mailboxes, limit is nil and per-mailbox -// limits must be queried via STATUS. -func (set CapSet) AppendLimit() (limit *uint32, ok bool) { - if set.has(CapAppendLimit) { - return nil, true - } - - for c := range set { - if !strings.HasPrefix(string(c), "APPENDLIMIT=") { - continue - } - - limitStr := strings.TrimPrefix(string(c), "APPENDLIMIT=") - limit64, err := strconv.ParseUint(limitStr, 10, 32) - if err == nil && limit64 > 0 { - limit32 := uint32(limit64) - return &limit32, true - } - } - - limit32 := ^uint32(0) - return &limit32, false -} - -// QuotaResourceTypes returns the list of supported QUOTA resource types. -func (set CapSet) QuotaResourceTypes() []QuotaResourceType { - var l []QuotaResourceType - for c := range set { - if !strings.HasPrefix(string(c), "QUOTA=RES-") { - continue - } - t := strings.TrimPrefix(string(c), "QUOTA=RES-") - l = append(l, QuotaResourceType(t)) - } - return l -} - -// ThreadAlgorithms returns the list of supported threading algorithms. -func (set CapSet) ThreadAlgorithms() []ThreadAlgorithm { - var l []ThreadAlgorithm - for c := range set { - if !strings.HasPrefix(string(c), "THREAD=") { - continue - } - alg := strings.TrimPrefix(string(c), "THREAD=") - l = append(l, ThreadAlgorithm(alg)) - } - return l -} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..a9e96ef --- /dev/null +++ b/client/client.go @@ -0,0 +1,591 @@ +// Package client provides an IMAP client. +package client + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net" + "os" + "sync" + "time" + + "github.com/emersion/go-imap" + "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 + + greeted chan struct{} + loggedOut chan struct{} + + 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 + + // 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) read(greeted <-chan struct{}) error { + greetedClosed := false + + defer func() { + // Ensure we close the greeted channel. New may be waiting on an indication + // that we've seen the greeting. + if !greetedClosed { + close(c.greeted) + greetedClosed = true + } + close(c.loggedOut) + }() + + first := true + for { + if c.State() == imap.LogoutState { + return nil + } + + c.conn.Wait() + + if first { + first = false + } else { + <-greeted + if !greetedClosed { + close(c.greeted) + greetedClosed = true + } + } + + resp, err := imap.ReadResp(c.conn.Reader) + if err == io.EOF || c.State() == imap.LogoutState { + return nil + } else if err != nil { + c.ErrorLog.Println("error reading response:", err) + if imap.IsParseError(err) { + continue + } else { + return err + } + } + + if err := c.handle(resp); err != nil && err != responses.ErrUnhandled { + c.ErrorLog.Println("cannot handle response ", resp, err) + } + } +} + +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() + + 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 + } + } + + // 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} + 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 + doneWrite := make(chan error, 1) + go func() { + doneWrite <- cmd.WriteTo(c.conn.Writer) + }() + + for { + select { + 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 err := <-doneWrite: + if err != nil { + // Error while sending the command + close(unregister) + return nil, err + } + 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(continues chan<- bool) { + c.registerHandler(responses.HandlerFunc(func(resp imap.Resp) error { + if _, ok := resp.(*imap.ContinuationReq); ok { + go func() { + 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 { + done := make(chan error, 2) + greeted := make(chan struct{}) + + c.registerHandler(responses.HandlerFunc(func(resp imap.Resp) error { + status, ok := resp.(*imap.StatusResp) + if !ok { + done <- 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() + done <- fmt.Errorf("invalid greeting received from server: %v", status.Type) + return errUnregisterHandler + } + c.locker.Unlock() + + if status.Code == imap.CodeCapability { + c.gotStatusCaps(status.Arguments) + } + + close(greeted) + done <- nil + return errUnregisterHandler + })) + + // Make sure to start reading after we have set up this handler, otherwise + // some messages will be lost. + go func() { + done <- c.read(greeted) + }() + + return <-done +} + +// 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) { + c.conn.SetDebug(w) +} + +// 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), + greeted: make(chan struct{}), + loggedOut: make(chan struct{}), + state: imap.ConnectingState, + ErrorLog: log.New(os.Stderr, "imap/client: ", log.LstdFlags), + } + + c.handleContinuationReqs(continues) + c.handleUnilateral() + err := c.handleGreetAndStartReading() + return c, err +} + +// Dial connects to an IMAP server using an unencrypted connection. +func Dial(addr string) (c *Client, err error) { + conn, err := net.Dial("tcp", addr) + if err != nil { + return + } + + c, err = New(conn) + return +} + +// 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 *net.Dialer, address string) (c *Client, err error) { + conn, err := dialer.Dial("tcp", address) + 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 dialer.Timeout > 0 { + err = conn.SetDeadline(time.Now().Add(dialer.Timeout)) + if err != nil { + return + } + } + + c, err = New(conn) + return +} + +// DialTLS connects to an IMAP server using an encrypted connection. +func DialTLS(addr string, tlsConfig *tls.Config) (c *Client, err error) { + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return + } + + c, err = New(conn) + c.isTLS = true + return +} + +// 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 *net.Dialer, addr string, + tlsConfig *tls.Config) (c *Client, err error) { + conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) + if err != nil { + return + } + + // 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 dialer.Timeout > 0 { + err = conn.SetDeadline(time.Now().Add(dialer.Timeout)) + if err != nil { + return + } + } + + c, err = New(conn) + c.isTLS = true + return +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..87df0b2 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,173 @@ +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) { + 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) + } + + greeting := "* OK [CAPABILITY IMAP4rev1 STARTTLS AUTH=PLAIN] Server ready.\r\n" + 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 + c.SetDebug(&b) + + done := make(chan error) + 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..3268052 --- /dev/null +++ b/client/cmd_any.go @@ -0,0 +1,87 @@ +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..62e4d01 --- /dev/null +++ b/client/cmd_auth.go @@ -0,0 +1,254 @@ +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 { + if err := c.ensureAuthenticated(); err != nil { + return err + } + + defer close(ch) + + 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 { + if err := c.ensureAuthenticated(); err != nil { + return err + } + + defer close(ch) + + 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. +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() +} diff --git a/client/cmd_auth_test.go b/client/cmd_auth_test.go new file mode 100644 index 0000000..580b69f --- /dev/null +++ b/client/cmd_auth_test.go @@ -0,0 +1,359 @@ +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) + } +} + +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) + } + + 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..06256c3 --- /dev/null +++ b/client/cmd_noauth.go @@ -0,0 +1,151 @@ +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 + } + + cmd := new(commands.StartTLS) + + err := c.Upgrade(func(conn net.Conn) (net.Conn, error) { + if status, err := c.execute(cmd, nil); err != nil { + return nil, err + } else if err := status.Err(); err != nil { + return nil, err + } + + 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, + } + + res := &responses.Authenticate{ + Mechanism: auth, + InitialResponse: ir, + Writer: c.Writer(), + } + + 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..5687c72 --- /dev/null +++ b/client/cmd_noauth_test.go @@ -0,0 +1,207 @@ +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_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_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..47dc416 --- /dev/null +++ b/client/cmd_selected.go @@ -0,0 +1,263 @@ +package client + +import ( + "errors" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" +) + +// ErrNoMailboxSelected is returned if a command that requires a mailbox to be +// selected is called when there isn't. +var ErrNoMailboxSelected = errors.New("No mailbox selected") + +// 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 c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + cmd := new(commands.Expunge) + + var h responses.Handler + if ch != nil { + h = &responses.Expunge{SeqNums: ch} + defer close(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 + cmd = &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) { + ids, status, err := c.executeSearch(uid, criteria, "UTF-8") + if status != nil && status.Code == imap.CodeBadCharset { + // Some servers don't support UTF-8 + ids, _, err = c.executeSearch(uid, criteria, "US-ASCII") + } + 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. +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 { + if c.State() != imap.SelectedState { + return ErrNoMailboxSelected + } + + defer close(ch) + + var cmd imap.Commander + cmd = &commands.Fetch{ + SeqSet: seqset, + Items: items, + } + if uid { + cmd = &commands.Uid{Cmd: cmd} + } + + res := &responses.Fetch{Messages: ch} + + 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 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.Atom(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 + cmd = &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} + defer close(ch) + } + + 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) +} diff --git a/client/cmd_selected_test.go b/client/cmd_selected_test.go new file mode 100644 index 0000000..fade001 --- /dev/null +++ b/client/cmd_selected_test.go @@ -0,0 +1,450 @@ +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_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_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_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_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_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}, updates) + }() + + tag, cmd := s.ScanCmd() + if cmd != "STORE 2 +FLAGS (\\Seen)" { + t.Fatalf("client sent command %v, want %v", cmd, "STORE 2 +FLAGS (\\Seen)") + } + + s.WriteString("* 2 FETCH (FLAGS (\\Seen))\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) != 1 || msg.Flags[0] != "\\Seen" { + 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}, nil) + }() + + tag, cmd := s.ScanCmd() + if cmd != "STORE 2:3 +FLAGS.SILENT (\\Seen)" { + t.Fatalf("client sent command %v, want %v", cmd, "STORE 2:3 +FLAGS.SILENT (\\Seen)") + } + + 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}, nil) + }() + + tag, cmd := s.ScanCmd() + if cmd != "UID STORE 27:901 +FLAGS.SILENT (\\Deleted)" { + t.Fatalf("client sent command %v, want %v", cmd, "UID STORE 27:901 +FLAGS.SILENT (\\Deleted)") + } + + 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) + } +} diff --git a/client/example_test.go b/client/example_test.go new file mode 100644 index 0000000..875273a --- /dev/null +++ b/client/example_test.go @@ -0,0 +1,262 @@ +package client_test + +import ( + "crypto/tls" + "io/ioutil" + "log" + "net/mail" + + "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_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!") +} 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/cmd/imapmemserver/main.go b/cmd/imapmemserver/main.go deleted file mode 100644 index 781e1c3..0000000 --- a/cmd/imapmemserver/main.go +++ /dev/null @@ -1,128 +0,0 @@ -package main - -import ( - "crypto/tls" - "flag" - "io" - "log" - "net" - "os" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/imapserver" - "github.com/emersion/go-imap/v2/imapserver/imapmemserver" -) - -var ( - listen string - tlsCert string - tlsKey string - username string - password string - debug bool - insecureAuth bool -) - -func main() { - flag.StringVar(&listen, "listen", "localhost:143", "listening address") - flag.StringVar(&tlsCert, "tls-cert", "", "TLS certificate") - flag.StringVar(&tlsKey, "tls-key", "", "TLS key") - flag.StringVar(&username, "username", "user", "Username") - flag.StringVar(&password, "password", "user", "Password") - flag.BoolVar(&debug, "debug", false, "Print all commands and responses") - flag.BoolVar(&insecureAuth, "insecure-auth", false, "Allow authentication without TLS") - flag.Parse() - - var tlsConfig *tls.Config - if tlsCert != "" || tlsKey != "" { - cert, err := tls.LoadX509KeyPair(tlsCert, tlsKey) - if err != nil { - log.Fatalf("Failed to load TLS key pair: %v", err) - } - tlsConfig = &tls.Config{ - Certificates: []tls.Certificate{cert}, - } - } - - ln, err := net.Listen("tcp", listen) - if err != nil { - log.Fatalf("Failed to listen: %v", err) - } - log.Printf("IMAP server listening on %v", ln.Addr()) - - memServer := imapmemserver.New() - - if username != "" || password != "" { - user := imapmemserver.NewUser(username, password) - - // Create standard mailboxes with special-use attributes as per RFC 6154 - if err := user.Create("INBOX", nil); err != nil { - log.Printf("Failed to create INBOX: %v", err) - } - - if err := user.Create("Drafts", &imap.CreateOptions{ - SpecialUse: []imap.MailboxAttr{imap.MailboxAttrDrafts}, - }); err != nil { - log.Printf("Failed to create Drafts mailbox: %v", err) - } - - if err := user.Create("Sent", &imap.CreateOptions{ - SpecialUse: []imap.MailboxAttr{imap.MailboxAttrSent}, - }); err != nil { - log.Printf("Failed to create Sent mailbox: %v", err) - } - - if err := user.Create("Archive", &imap.CreateOptions{ - SpecialUse: []imap.MailboxAttr{imap.MailboxAttrArchive}, - }); err != nil { - log.Printf("Failed to create Archive mailbox: %v", err) - } - - if err := user.Create("Junk", &imap.CreateOptions{ - SpecialUse: []imap.MailboxAttr{imap.MailboxAttrJunk}, - }); err != nil { - log.Printf("Failed to create Junk mailbox: %v", err) - } - - if err := user.Create("Trash", &imap.CreateOptions{ - SpecialUse: []imap.MailboxAttr{imap.MailboxAttrTrash}, - }); err != nil { - log.Printf("Failed to create Trash mailbox: %v", err) - } - - if err := user.Create("Flagged", &imap.CreateOptions{ - SpecialUse: []imap.MailboxAttr{imap.MailboxAttrFlagged}, - }); err != nil { - log.Printf("Failed to create Flagged mailbox: %v", err) - } - - // Subscribe to the most commonly used mailboxes - _ = user.Subscribe("INBOX") - _ = user.Subscribe("Drafts") - _ = user.Subscribe("Sent") - _ = user.Subscribe("Trash") - - memServer.AddUser(user) - } - - var debugWriter io.Writer - if debug { - debugWriter = os.Stdout - } - - server := imapserver.New(&imapserver.Options{ - NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { - return memServer.NewSession(), nil, nil - }, - Caps: imap.CapSet{ - imap.CapIMAP4rev1: {}, - imap.CapIMAP4rev2: {}, - }, - TLSConfig: tlsConfig, - InsecureAuth: insecureAuth, - DebugWriter: debugWriter, - }) - if err := server.Serve(ln); err != nil { - log.Fatalf("Serve() = %v", err) - } -} diff --git a/command.go b/command.go new file mode 100644 index 0000000..cfbd9b7 --- /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{}{tag, 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..ef5f6f3 --- /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") + } +} + +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") + } +} + +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..1433484 --- /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, mailbox) + + if cmd.Flags != nil { + flags := make([]interface{}, len(cmd.Flags)) + for i, flag := range cmd.Flags { + flags[i] = imap.Atom(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..7c9ef92 --- /dev/null +++ b/commands/authenticate.go @@ -0,0 +1,83 @@ +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 +} + +func (cmd *Authenticate) Command() *imap.Command { + return &imap.Command{ + Name: "AUTHENTICATE", + Arguments: []interface{}{cmd.Mechanism}, + } +} + +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) + 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) + + var response []byte + 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 + } + + scanner.Scan() + if err := scanner.Err(); err != nil { + return err + } + + encoded = scanner.Text() + if encoded != "" { + 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..29c70e7 --- /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, 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..a87840d --- /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{}{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/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..75cbe59 --- /dev/null +++ b/commands/fetch.go @@ -0,0 +1,59 @@ +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 { + items := make([]interface{}, len(cmd.Items)) + for i, item := range cmd.Items { + if section, err := imap.ParseBodySectionName(item); err == nil { + items[i] = section + } else { + items[i] = string(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/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/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..f8def4a --- /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{}{existingName, 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..42e9db5 --- /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, "CHARSET", 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..6a07f67 --- /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{}{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..42ee5cd --- /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] = string(item) + } + + return &imap.Command{ + Name: "STATUS", + Arguments: []interface{}{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..d27babb --- /dev/null +++ b/commands/store.go @@ -0,0 +1,47 @@ +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, string(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)) + } + + // TODO: could be fields[2:] according to RFC 3501 page 91 "store-att-flags" + cmd.Value = fields[2] + return nil +} diff --git a/commands/subscribe.go b/commands/subscribe.go new file mode 100644 index 0000000..a3284c0 --- /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{}{mailbox}, + } +} + +func (cmd *Subscribe) Parse(fields []interface{}) error { + if len(fields) < 0 { + 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{}{mailbox}, + } +} + +func (cmd *Unsubscribe) Parse(fields []interface{}) error { + if len(fields) < 0 { + 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..c97f96d --- /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{}{inner.Name} + args = append(args, inner.Arguments...) + + return &imap.Command{ + Name: "UID", + Arguments: args, + } +} + +func (cmd *Uid) Parse(fields []interface{}) error { + if len(fields) < 0 { + 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/conn.go b/conn.go new file mode 100644 index 0000000..64c03fa --- /dev/null +++ b/conn.go @@ -0,0 +1,194 @@ +package imap + +import ( + "bufio" + "io" + "net" +) + +// 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 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} +} + +// An IMAP connection. +type Conn struct { + net.Conn + *Reader + *Writer + + br *bufio.Reader + bw *bufio.Writer + + waits chan struct{} + + // 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) 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 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), + } + } +} + +// 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 { + // Flush all buffered data + if err := c.Flush(); err != nil { + return err + } + + // Block reads and writes during the upgrading process + c.waits = make(chan struct{}) + defer close(c.waits) + + upgraded, err := upgrader(c.Conn) + if err != nil { + return err + } + + c.Conn = upgraded + c.init() + return nil +} + +// Wait waits for the connection to be ready for reads and writes. +func (c *Conn) Wait() { + if c.waits != nil { + <-c.waits + } +} + +// 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..0b3cc68 --- /dev/null +++ b/conn_test.go @@ -0,0 +1,106 @@ +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{}{} + 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/copy.go b/copy.go deleted file mode 100644 index f685a60..0000000 --- a/copy.go +++ /dev/null @@ -1,9 +0,0 @@ -package imap - -// CopyData is the data returned by a COPY command. -type CopyData struct { - // requires UIDPLUS or IMAP4rev2 - UIDValidity uint32 - SourceUIDs UIDSet - DestUIDs UIDSet -} diff --git a/create.go b/create.go deleted file mode 100644 index 09e8bc4..0000000 --- a/create.go +++ /dev/null @@ -1,6 +0,0 @@ -package imap - -// CreateOptions contains options for the CREATE command. -type CreateOptions struct { - SpecialUse []MailboxAttr // requires CREATE-SPECIAL-USE -} diff --git a/date.go b/date.go new file mode 100644 index 0000000..176be73 --- /dev/null +++ b/date.go @@ -0,0 +1,72 @@ +package imap + +import ( + "fmt" + "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:05 -0700 (MST)", + "_2 Jan 2006 15:04 -0700", + "_2 Jan 2006 15:04 MST", + "_2 Jan 2006 15:04 -0700 (MST)", + "_2 Jan 06 15:04:05 -0700", + "_2 Jan 06 15:04:05 MST", + "_2 Jan 06 15:04:05 -0700 (MST)", + "_2 Jan 06 15:04 -0700", + "_2 Jan 06 15:04 MST", + "_2 Jan 06 15:04 -0700 (MST)", + "Mon, _2 Jan 2006 15:04:05 -0700", + "Mon, _2 Jan 2006 15:04:05 MST", + "Mon, _2 Jan 2006 15:04:05 -0700 (MST)", + "Mon, _2 Jan 2006 15:04 -0700", + "Mon, _2 Jan 2006 15:04 MST", + "Mon, _2 Jan 2006 15:04 -0700 (MST)", + "Mon, _2 Jan 06 15:04:05 -0700", + "Mon, _2 Jan 06 15:04:05 MST", + "Mon, _2 Jan 06 15:04:05 -0700 (MST)", + "Mon, _2 Jan 06 15:04 -0700", + "Mon, _2 Jan 06 15:04 MST", + "Mon, _2 Jan 06 15:04 -0700 (MST)", +} + +// 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) { + 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/fetch.go b/fetch.go deleted file mode 100644 index f146c89..0000000 --- a/fetch.go +++ /dev/null @@ -1,284 +0,0 @@ -package imap - -import ( - "fmt" - "strings" - "time" -) - -// FetchOptions contains options for the FETCH command. -type FetchOptions struct { - // Fields to fetch - BodyStructure *FetchItemBodyStructure - Envelope bool - Flags bool - InternalDate bool - RFC822Size bool - UID bool - BodySection []*FetchItemBodySection - BinarySection []*FetchItemBinarySection // requires IMAP4rev2 or BINARY - BinarySectionSize []*FetchItemBinarySectionSize // requires IMAP4rev2 or BINARY - ModSeq bool // requires CONDSTORE - - ChangedSince uint64 // requires CONDSTORE -} - -// FetchItemBodyStructure contains FETCH options for the body structure. -type FetchItemBodyStructure struct { - Extended bool -} - -// PartSpecifier describes whether to fetch a part's header, body, or both. -type PartSpecifier string - -const ( - PartSpecifierNone PartSpecifier = "" - PartSpecifierHeader PartSpecifier = "HEADER" - PartSpecifierMIME PartSpecifier = "MIME" - PartSpecifierText PartSpecifier = "TEXT" -) - -// SectionPartial describes a byte range when fetching a message's payload. -type SectionPartial struct { - Offset, Size int64 -} - -// FetchItemBodySection is a FETCH BODY[] data item. -// -// To fetch the whole body of a message, use the zero FetchItemBodySection: -// -// imap.FetchItemBodySection{} -// -// To fetch only a specific part, use the Part field: -// -// imap.FetchItemBodySection{Part: []int{1, 2, 3}} -// -// To fetch only the header of the message, use the Specifier field: -// -// imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader} -type FetchItemBodySection struct { - Specifier PartSpecifier - Part []int - HeaderFields []string - HeaderFieldsNot []string - Partial *SectionPartial - Peek bool -} - -// FetchItemBinarySection is a FETCH BINARY[] data item. -type FetchItemBinarySection struct { - Part []int - Partial *SectionPartial - Peek bool -} - -// FetchItemBinarySectionSize is a FETCH BINARY.SIZE[] data item. -type FetchItemBinarySectionSize struct { - Part []int -} - -// Envelope is the envelope structure of a message. -// -// The subject and addresses are UTF-8 (ie, not in their encoded form). The -// In-Reply-To and Message-ID values contain message identifiers without angle -// brackets. -type Envelope struct { - Date time.Time - Subject string - From []Address - Sender []Address - ReplyTo []Address - To []Address - Cc []Address - Bcc []Address - InReplyTo []string - MessageID string -} - -// Address represents a sender or recipient of a message. -type Address struct { - Name string - Mailbox string - Host string -} - -// Addr returns the e-mail address in the form "foo@example.org". -// -// If the address is a start or end of group, the empty string is returned. -func (addr *Address) Addr() string { - if addr.Mailbox == "" || addr.Host == "" { - return "" - } - return addr.Mailbox + "@" + addr.Host -} - -// IsGroupStart returns true if this address is a start of group marker. -// -// In that case, Mailbox contains the group name phrase. -func (addr *Address) IsGroupStart() bool { - return addr.Host == "" && addr.Mailbox != "" -} - -// IsGroupEnd returns true if this address is a end of group marker. -func (addr *Address) IsGroupEnd() bool { - return addr.Host == "" && addr.Mailbox == "" -} - -// BodyStructure describes the body structure of a message. -// -// A BodyStructure value is either a *BodyStructureSinglePart or a -// *BodyStructureMultiPart. -type BodyStructure interface { - // MediaType returns the MIME type of this body structure, e.g. "text/plain". - MediaType() string - // 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. - Walk(f BodyStructureWalkFunc) - // Disposition returns the body structure disposition, if available. - Disposition() *BodyStructureDisposition - - bodyStructure() -} - -var ( - _ BodyStructure = (*BodyStructureSinglePart)(nil) - _ BodyStructure = (*BodyStructureMultiPart)(nil) -) - -// BodyStructureSinglePart is a body structure with a single part. -type BodyStructureSinglePart struct { - Type, Subtype string - Params map[string]string - ID string - Description string - Encoding string - Size uint32 - - MessageRFC822 *BodyStructureMessageRFC822 // only for "message/rfc822" - Text *BodyStructureText // only for "text/*" - Extended *BodyStructureSinglePartExt -} - -func (bs *BodyStructureSinglePart) MediaType() string { - return strings.ToLower(bs.Type) + "/" + strings.ToLower(bs.Subtype) -} - -func (bs *BodyStructureSinglePart) Walk(f BodyStructureWalkFunc) { - f([]int{1}, bs) -} - -func (bs *BodyStructureSinglePart) Disposition() *BodyStructureDisposition { - if bs.Extended == nil { - return nil - } - return bs.Extended.Disposition -} - -// Filename decodes the body structure's filename, if any. -func (bs *BodyStructureSinglePart) Filename() string { - var filename string - if bs.Extended != nil && bs.Extended.Disposition != nil { - filename = bs.Extended.Disposition.Params["filename"] - } - if filename == "" { - // Note: using "name" in Content-Type is discouraged - filename = bs.Params["name"] - } - return filename -} - -func (*BodyStructureSinglePart) bodyStructure() {} - -// BodyStructureMessageRFC822 contains metadata specific to RFC 822 parts for -// BodyStructureSinglePart. -type BodyStructureMessageRFC822 struct { - Envelope *Envelope - BodyStructure BodyStructure - NumLines int64 -} - -// BodyStructureText contains metadata specific to text parts for -// BodyStructureSinglePart. -type BodyStructureText struct { - NumLines int64 -} - -// BodyStructureSinglePartExt contains extended body structure data for -// BodyStructureSinglePart. -type BodyStructureSinglePartExt struct { - Disposition *BodyStructureDisposition - Language []string - Location string -} - -// BodyStructureMultiPart is a body structure with multiple parts. -type BodyStructureMultiPart struct { - Children []BodyStructure - Subtype string - - Extended *BodyStructureMultiPartExt -} - -func (bs *BodyStructureMultiPart) MediaType() string { - return "multipart/" + strings.ToLower(bs.Subtype) -} - -func (bs *BodyStructureMultiPart) Walk(f BodyStructureWalkFunc) { - bs.walk(f, nil) -} - -func (bs *BodyStructureMultiPart) walk(f BodyStructureWalkFunc, path []int) { - if !f(path, bs) { - return - } - - pathBuf := make([]int, len(path)) - copy(pathBuf, path) - for i, part := range bs.Children { - num := i + 1 - partPath := append(pathBuf, num) - - switch part := part.(type) { - case *BodyStructureSinglePart: - f(partPath, part) - case *BodyStructureMultiPart: - part.walk(f, partPath) - default: - panic(fmt.Errorf("unsupported body structure type %T", part)) - } - } -} - -func (bs *BodyStructureMultiPart) Disposition() *BodyStructureDisposition { - if bs.Extended == nil { - return nil - } - return bs.Extended.Disposition -} - -func (*BodyStructureMultiPart) bodyStructure() {} - -// BodyStructureMultiPartExt contains extended body structure data for -// BodyStructureMultiPart. -type BodyStructureMultiPartExt struct { - Params map[string]string - Disposition *BodyStructureDisposition - Language []string - Location string -} - -// BodyStructureDisposition describes the content disposition of a part -// (specified in the Content-Disposition header field). -type BodyStructureDisposition struct { - Value string - Params map[string]string -} - -// BodyStructureWalkFunc is a function called for each body structure visited -// by BodyStructure.Walk. -// -// The path argument contains the IMAP part path. -// -// 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) diff --git a/go.mod b/go.mod index b5910df..f13be86 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ -module github.com/emersion/go-imap/v2 - -go 1.18 +module github.com/emersion/go-imap require ( - github.com/emersion/go-message v0.18.2 - github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 + github.com/emersion/go-message v0.9.1 + github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197 + github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe // indirect + golang.org/x/text v0.3.0 ) diff --git a/go.sum b/go.sum deleted file mode 100644 index 1aa91fb..0000000 --- a/go.sum +++ /dev/null @@ -1,35 +0,0 @@ -github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= -github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= -github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= -github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/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= -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/id.go b/id.go deleted file mode 100644 index de7ca0e..0000000 --- a/id.go +++ /dev/null @@ -1,15 +0,0 @@ -package imap - -type IDData struct { - Name string - Version string - OS string - OSVersion string - Vendor string - SupportURL string - Address string - Date string - Command string - Arguments string - Environment string -} diff --git a/imap.go b/imap.go index 7b43357..ab70eb9 100644 --- a/imap.go +++ b/imap.go @@ -1,105 +1,106 @@ -// 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 = "RECENT" + StatusUidNext = "UIDNEXT" + StatusUidValidity = "UIDVALIDITY" + StatusUnseen = "UNSEEN" ) -// 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. +const ( + // Macros + FetchAll FetchItem = "ALL" + FetchFast = "FAST" + FetchFull = "FULL" + + // Items + FetchBody = "BODY" + FetchBodyStructure = "BODYSTRUCTURE" + FetchEnvelope = "ENVELOPE" + FetchFlags = "FLAGS" + FetchInternalDate = "INTERNALDATE" + FetchRFC822 = "RFC822" + FetchRFC822Header = "RFC822.HEADER" + FetchRFC822Size = "RFC822.SIZE" + FetchRFC822Text = "RFC822.TEXT" + FetchUid = "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/imapclient/acl.go b/imapclient/acl.go deleted file mode 100644 index b20be3b..0000000 --- a/imapclient/acl.go +++ /dev/null @@ -1,138 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// MyRights sends a MYRIGHTS command. -// -// This command requires support for the ACL extension. -func (c *Client) MyRights(mailbox string) *MyRightsCommand { - cmd := &MyRightsCommand{} - enc := c.beginCommand("MYRIGHTS", cmd) - enc.SP().Mailbox(mailbox) - enc.end() - return cmd -} - -// SetACL sends a SETACL command. -// -// This command requires support for the ACL extension. -func (c *Client) SetACL(mailbox string, ri imap.RightsIdentifier, rm imap.RightModification, rs imap.RightSet) *SetACLCommand { - cmd := &SetACLCommand{} - enc := c.beginCommand("SETACL", cmd) - enc.SP().Mailbox(mailbox).SP().String(string(ri)).SP() - enc.String(internal.FormatRights(rm, rs)) - enc.end() - return cmd -} - -// SetACLCommand is a SETACL command. -type SetACLCommand struct { - commandBase -} - -func (cmd *SetACLCommand) Wait() error { - return cmd.wait() -} - -// GetACL sends a GETACL command. -// -// This command requires support for the ACL extension. -func (c *Client) GetACL(mailbox string) *GetACLCommand { - cmd := &GetACLCommand{} - enc := c.beginCommand("GETACL", cmd) - enc.SP().Mailbox(mailbox) - enc.end() - return cmd -} - -// GetACLCommand is a GETACL command. -type GetACLCommand struct { - commandBase - data GetACLData -} - -func (cmd *GetACLCommand) Wait() (*GetACLData, error) { - return &cmd.data, cmd.wait() -} - -func (c *Client) handleMyRights() error { - data, err := readMyRights(c.dec) - if err != nil { - return fmt.Errorf("in myrights-response: %v", err) - } - if cmd := findPendingCmdByType[*MyRightsCommand](c); cmd != nil { - cmd.data = *data - } - return nil -} - -func (c *Client) handleGetACL() error { - data, err := readGetACL(c.dec) - if err != nil { - return fmt.Errorf("in getacl-response: %v", err) - } - if cmd := findPendingCmdByType[*GetACLCommand](c); cmd != nil { - cmd.data = *data - } - return nil -} - -// MyRightsCommand is a MYRIGHTS command. -type MyRightsCommand struct { - commandBase - data MyRightsData -} - -func (cmd *MyRightsCommand) Wait() (*MyRightsData, error) { - return &cmd.data, cmd.wait() -} - -// MyRightsData is the data returned by the MYRIGHTS command. -type MyRightsData struct { - Mailbox string - Rights imap.RightSet -} - -func readMyRights(dec *imapwire.Decoder) (*MyRightsData, error) { - var ( - rights string - data MyRightsData - ) - if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() || !dec.ExpectAString(&rights) { - return nil, dec.Err() - } - - data.Rights = imap.RightSet(rights) - return &data, nil -} - -// GetACLData is the data returned by the GETACL command. -type GetACLData struct { - Mailbox string - Rights map[imap.RightsIdentifier]imap.RightSet -} - -func readGetACL(dec *imapwire.Decoder) (*GetACLData, error) { - data := &GetACLData{Rights: make(map[imap.RightsIdentifier]imap.RightSet)} - - if !dec.ExpectMailbox(&data.Mailbox) { - return nil, dec.Err() - } - - for dec.SP() { - var rsStr, riStr string - if !dec.ExpectAString(&riStr) || !dec.ExpectSP() || !dec.ExpectAString(&rsStr) { - return nil, dec.Err() - } - - data.Rights[imap.RightsIdentifier(riStr)] = imap.RightSet(rsStr) - } - - return data, nil -} diff --git a/imapclient/acl_test.go b/imapclient/acl_test.go deleted file mode 100644 index 34a62f4..0000000 --- a/imapclient/acl_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package imapclient_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2" -) - -// order matters -var testCases = []struct { - name string - mailbox string - setRightsModification imap.RightModification - setRights imap.RightSet - expectedRights imap.RightSet - execStatusCmd bool -}{ - { - name: "inbox", - mailbox: "INBOX", - setRightsModification: imap.RightModificationReplace, - setRights: imap.RightSet("akxeilprwtscd"), - expectedRights: imap.RightSet("akxeilprwtscd"), - }, - { - name: "custom_folder", - mailbox: "MyFolder", - setRightsModification: imap.RightModificationReplace, - setRights: imap.RightSet("ailw"), - expectedRights: imap.RightSet("ailw"), - }, - { - name: "custom_child_folder", - mailbox: "MyFolder/Child", - setRightsModification: imap.RightModificationReplace, - setRights: imap.RightSet("aelrwtd"), - expectedRights: imap.RightSet("aelrwtd"), - }, - { - name: "add_rights", - mailbox: "MyFolder", - setRightsModification: imap.RightModificationAdd, - setRights: imap.RightSet("rwi"), - expectedRights: imap.RightSet("ailwr"), - }, - { - name: "remove_rights", - mailbox: "MyFolder", - setRightsModification: imap.RightModificationRemove, - setRights: imap.RightSet("iwc"), - expectedRights: imap.RightSet("alr"), - }, - { - name: "empty_rights", - mailbox: "MyFolder/Child", - setRightsModification: imap.RightModificationReplace, - setRights: imap.RightSet("a"), - expectedRights: imap.RightSet("a"), - }, -} - -// TestACL runs tests on SetACL, GetACL and MyRights commands. -func TestACL(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateAuthenticated) - defer client.Close() - defer server.Close() - - if !client.Caps().Has(imap.CapACL) { - t.Skipf("server doesn't support ACL") - } - - if err := client.Create("MyFolder", nil).Wait(); err != nil { - t.Fatalf("create MyFolder error: %v", err) - } - - if err := client.Create("MyFolder/Child", nil).Wait(); err != nil { - t.Fatalf("create MyFolder/Child error: %v", err) - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // execute SETACL command - err := client.SetACL(tc.mailbox, testUsername, tc.setRightsModification, tc.setRights).Wait() - if err != nil { - t.Fatalf("SetACL().Wait() error: %v", err) - } - - // execute GETACL command to reset cache on server - getACLData, err := client.GetACL(tc.mailbox).Wait() - if err != nil { - t.Fatalf("GetACL().Wait() error: %v", err) - } - - if !tc.expectedRights.Equal(getACLData.Rights[testUsername]) { - t.Errorf("GETACL returned wrong rights; expected: %s, got: %s", tc.expectedRights, getACLData.Rights[testUsername]) - } - - // execute MYRIGHTS command - myRightsData, err := client.MyRights(tc.mailbox).Wait() - if err != nil { - t.Errorf("MyRights().Wait() error: %v", err) - } - - if !tc.expectedRights.Equal(myRightsData.Rights) { - t.Errorf("MYRIGHTS returned wrong rights; expected: %s, got: %s", tc.expectedRights, myRightsData.Rights) - } - }) - } - - t.Run("nonexistent_mailbox", func(t *testing.T) { - if client.SetACL("BibiMailbox", testUsername, imap.RightModificationReplace, nil).Wait() == nil { - t.Errorf("expected error") - } - }) -} diff --git a/imapclient/append.go b/imapclient/append.go deleted file mode 100644 index 5bfff23..0000000 --- a/imapclient/append.go +++ /dev/null @@ -1,58 +0,0 @@ -package imapclient - -import ( - "io" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" -) - -// Append sends an APPEND command. -// -// The caller must call AppendCommand.Close. -// -// The options are optional. -func (c *Client) Append(mailbox string, size int64, options *imap.AppendOptions) *AppendCommand { - cmd := &AppendCommand{} - cmd.enc = c.beginCommand("APPEND", cmd) - cmd.enc.SP().Mailbox(mailbox).SP() - if options != nil && len(options.Flags) > 0 { - cmd.enc.List(len(options.Flags), func(i int) { - cmd.enc.Flag(options.Flags[i]) - }).SP() - } - if options != nil && !options.Time.IsZero() { - cmd.enc.String(options.Time.Format(internal.DateTimeLayout)).SP() - } - // TODO: literal8 for BINARY - // TODO: UTF8 data ext for UTF8=ACCEPT, with literal8 - cmd.wc = cmd.enc.Literal(size) - return cmd -} - -// AppendCommand is an APPEND command. -// -// Callers must write the message contents, then call Close. -type AppendCommand struct { - commandBase - enc *commandEncoder - wc io.WriteCloser - data imap.AppendData -} - -func (cmd *AppendCommand) Write(b []byte) (int, error) { - return cmd.wc.Write(b) -} - -func (cmd *AppendCommand) Close() error { - err := cmd.wc.Close() - if cmd.enc != nil { - cmd.enc.end() - cmd.enc = nil - } - return err -} - -func (cmd *AppendCommand) Wait() (*imap.AppendData, error) { - return &cmd.data, cmd.wait() -} diff --git a/imapclient/append_test.go b/imapclient/append_test.go deleted file mode 100644 index c5a30dc..0000000 --- a/imapclient/append_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package imapclient_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestAppend(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - body := "This is a test message." - - appendCmd := client.Append("INBOX", int64(len(body)), nil) - if _, err := appendCmd.Write([]byte(body)); err != nil { - t.Fatalf("AppendCommand.Write() = %v", err) - } - if err := appendCmd.Close(); err != nil { - t.Fatalf("AppendCommand.Close() = %v", err) - } - if _, err := appendCmd.Wait(); err != nil { - t.Fatalf("AppendCommand.Wait() = %v", err) - } - - // TODO: fetch back message and check body -} diff --git a/imapclient/authenticate.go b/imapclient/authenticate.go deleted file mode 100644 index e0f67d0..0000000 --- a/imapclient/authenticate.go +++ /dev/null @@ -1,100 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-sasl" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" -) - -// Authenticate sends an AUTHENTICATE command. -// -// Unlike other commands, this method blocks until the SASL exchange completes. -func (c *Client) Authenticate(saslClient sasl.Client) error { - mech, initialResp, err := saslClient.Start() - if err != nil { - return err - } - - // c.Caps may send a CAPABILITY command, so check it before c.beginCommand - var hasSASLIR bool - if initialResp != nil { - hasSASLIR = c.Caps().Has(imap.CapSASLIR) - } - - cmd := &authenticateCommand{} - contReq := c.registerContReq(cmd) - enc := c.beginCommand("AUTHENTICATE", cmd) - enc.SP().Atom(mech) - if initialResp != nil && hasSASLIR { - enc.SP().Atom(internal.EncodeSASL(initialResp)) - initialResp = nil - } - enc.flush() - defer enc.end() - - for { - challengeStr, err := contReq.Wait() - if err != nil { - return cmd.wait() - } - - if challengeStr == "" { - if initialResp == nil { - return fmt.Errorf("imapclient: server requested SASL initial response, but we don't have one") - } - - contReq = c.registerContReq(cmd) - if err := c.writeSASLResp(initialResp); err != nil { - return err - } - initialResp = nil - continue - } - - challenge, err := internal.DecodeSASL(challengeStr) - if err != nil { - return err - } - - resp, err := saslClient.Next(challenge) - if err != nil { - return err - } - - contReq = c.registerContReq(cmd) - if err := c.writeSASLResp(resp); err != nil { - return err - } - } -} - -type authenticateCommand struct { - commandBase -} - -func (c *Client) writeSASLResp(resp []byte) error { - respStr := internal.EncodeSASL(resp) - if _, err := c.bw.WriteString(respStr + "\r\n"); err != nil { - return err - } - if err := c.bw.Flush(); err != nil { - return err - } - return nil -} - -// Unauthenticate sends an UNAUTHENTICATE command. -// -// This command requires support for the UNAUTHENTICATE extension. -func (c *Client) Unauthenticate() *Command { - cmd := &unauthenticateCommand{} - c.beginCommand("UNAUTHENTICATE", cmd).end() - return &cmd.Command -} - -type unauthenticateCommand struct { - Command -} diff --git a/imapclient/authenticate_test.go b/imapclient/authenticate_test.go deleted file mode 100644 index 223eef5..0000000 --- a/imapclient/authenticate_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package imapclient_test - -import ( - "testing" - - "github.com/emersion/go-sasl" - - "github.com/emersion/go-imap/v2" -) - -func TestClient_Authenticate(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateNotAuthenticated) - defer client.Close() - defer server.Close() - - saslClient := sasl.NewPlainClient("", testUsername, testPassword) - if err := client.Authenticate(saslClient); err != nil { - t.Fatalf("Authenticate() = %v", err) - } - - if state := client.State(); state != imap.ConnStateAuthenticated { - t.Errorf("State() = %v, want %v", state, imap.ConnStateAuthenticated) - } -} diff --git a/imapclient/capability.go b/imapclient/capability.go deleted file mode 100644 index a27c77c..0000000 --- a/imapclient/capability.go +++ /dev/null @@ -1,56 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// Capability sends a CAPABILITY command. -func (c *Client) Capability() *CapabilityCommand { - cmd := &CapabilityCommand{} - c.beginCommand("CAPABILITY", cmd).end() - return cmd -} - -func (c *Client) handleCapability() error { - caps, err := readCapabilities(c.dec) - if err != nil { - return err - } - c.setCaps(caps) - if cmd := findPendingCmdByType[*CapabilityCommand](c); cmd != nil { - cmd.caps = caps - } - return nil -} - -// CapabilityCommand is a CAPABILITY command. -type CapabilityCommand struct { - commandBase - caps imap.CapSet -} - -func (cmd *CapabilityCommand) Wait() (imap.CapSet, error) { - err := cmd.wait() - return cmd.caps, err -} - -func readCapabilities(dec *imapwire.Decoder) (imap.CapSet, error) { - caps := make(imap.CapSet) - for dec.SP() { - // Some IMAP servers send multiple SP between caps: - // https://github.com/emersion/go-imap/pull/652 - for dec.SP() { - } - - cap, err := internal.ExpectCap(dec) - if err != nil { - return caps, fmt.Errorf("in capability-data: %w", err) - } - caps[cap] = struct{}{} - } - return caps, nil -} diff --git a/imapclient/client.go b/imapclient/client.go deleted file mode 100644 index 4933d2f..0000000 --- a/imapclient/client.go +++ /dev/null @@ -1,1243 +0,0 @@ -// Package imapclient implements an IMAP client. -// -// # Charset decoding -// -// By default, only basic charset decoding is performed. For non-UTF-8 decoding -// of message subjects and e-mail address names, users can set -// Options.WordDecoder. For instance, to use go-message's collection of -// charsets: -// -// import ( -// "mime" -// -// "github.com/emersion/go-message/charset" -// ) -// -// options := &imapclient.Options{ -// WordDecoder: &mime.WordDecoder{CharsetReader: charset.Reader}, -// } -// client, err := imapclient.DialTLS("imap.example.org:993", options) -package imapclient - -import ( - "bufio" - "crypto/tls" - "errors" - "fmt" - "io" - "mime" - "net" - "runtime/debug" - "strconv" - "strings" - "sync" - "time" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -const ( - idleReadTimeout = time.Duration(0) - respReadTimeout = 30 * time.Second - literalReadTimeout = 5 * time.Minute - - cmdWriteTimeout = 30 * time.Second - literalWriteTimeout = 5 * time.Minute - - defaultDialTimeout = 30 * time.Second -) - -// SelectedMailbox contains metadata for the currently selected mailbox. -type SelectedMailbox struct { - Name string - NumMessages uint32 - Flags []imap.Flag - PermanentFlags []imap.Flag -} - -func (mbox *SelectedMailbox) copy() *SelectedMailbox { - copy := *mbox - return © -} - -// Options contains options for Client. -type Options struct { - // TLS configuration for use by DialTLS and DialStartTLS. If nil, the - // default configuration is used. - TLSConfig *tls.Config - // Raw ingress and egress data will be written to this writer, if any. - // Note, this may include sensitive information such as credentials used - // during authentication. - DebugWriter io.Writer - // Unilateral data handler. - UnilateralDataHandler *UnilateralDataHandler - // Decoder for RFC 2047 words. - WordDecoder *mime.WordDecoder - // Dialer to use when establishing connections with the Dial* functions. - // If nil, a default dialer with a 30 second timeout is used. - Dialer *net.Dialer -} - -func (options *Options) wrapReadWriter(rw io.ReadWriter) io.ReadWriter { - if options.DebugWriter == nil { - return rw - } - return struct { - io.Reader - io.Writer - }{ - Reader: io.TeeReader(rw, options.DebugWriter), - Writer: io.MultiWriter(rw, options.DebugWriter), - } -} - -func (options *Options) decodeText(s string) (string, error) { - wordDecoder := options.WordDecoder - if wordDecoder == nil { - wordDecoder = &mime.WordDecoder{} - } - out, err := wordDecoder.DecodeHeader(s) - if err != nil { - return s, err - } - return out, nil -} - -func (options *Options) unilateralDataHandler() *UnilateralDataHandler { - if options.UnilateralDataHandler == nil { - return &UnilateralDataHandler{} - } - return options.UnilateralDataHandler -} - -func (options *Options) tlsConfig() *tls.Config { - if options.TLSConfig != nil { - return options.TLSConfig.Clone() - } else { - return new(tls.Config) - } -} - -func (options *Options) dialer() *net.Dialer { - if options.Dialer == nil { - return &net.Dialer{Timeout: defaultDialTimeout} - } - return options.Dialer -} - -// Client is an IMAP client. -// -// IMAP commands are exposed as methods. These methods will block until the -// command has been sent to the server, but won't block until the server sends -// a response. They return a command struct which can be used to wait for the -// server response. This can be used to execute multiple commands concurrently, -// however care must be taken to avoid ambiguities. See RFC 9051 section 5.5. -// -// A client can be safely used from multiple goroutines, however this doesn't -// guarantee any command ordering and is subject to the same caveats as command -// pipelining (see above). Additionally, some commands (e.g. StartTLS, -// Authenticate, Idle) block the client during their execution. -type Client struct { - conn net.Conn - options Options - br *bufio.Reader - bw *bufio.Writer - dec *imapwire.Decoder - encMutex sync.Mutex - - greetingCh chan struct{} - greetingRecv bool - greetingErr error - - decCh chan struct{} - decErr error - - mutex sync.Mutex - state imap.ConnState - caps imap.CapSet - enabled imap.CapSet - pendingCapCh chan struct{} - mailbox *SelectedMailbox - cmdTag uint64 - pendingCmds []command - contReqs []continuationRequest - closed bool -} - -// New creates a new IMAP client. -// -// This function doesn't perform I/O. -// -// A nil options pointer is equivalent to a zero options value. -func New(conn net.Conn, options *Options) *Client { - if options == nil { - options = &Options{} - } - - rw := options.wrapReadWriter(conn) - br := bufio.NewReader(rw) - bw := bufio.NewWriter(rw) - - client := &Client{ - conn: conn, - options: *options, - br: br, - bw: bw, - dec: imapwire.NewDecoder(br, imapwire.ConnSideClient), - greetingCh: make(chan struct{}), - decCh: make(chan struct{}), - state: imap.ConnStateNone, - enabled: make(imap.CapSet), - } - go client.read() - return client -} - -// NewStartTLS creates a new IMAP client with STARTTLS. -// -// A nil options pointer is equivalent to a zero options value. -func NewStartTLS(conn net.Conn, options *Options) (*Client, error) { - if options == nil { - options = &Options{} - } - - client := New(conn, options) - if err := client.startTLS(options.TLSConfig); err != nil { - conn.Close() - return nil, err - } - - // Per section 7.1.4, refuse PREAUTH when using STARTTLS - if client.State() != imap.ConnStateNotAuthenticated { - client.Close() - return nil, fmt.Errorf("imapclient: server sent PREAUTH on unencrypted connection") - } - - return client, nil -} - -// DialInsecure connects to an IMAP server without any encryption at all. -func DialInsecure(address string, options *Options) (*Client, error) { - if options == nil { - options = &Options{} - } - - conn, err := options.dialer().Dial("tcp", address) - if err != nil { - return nil, err - } - return New(conn, options), nil -} - -// DialTLS connects to an IMAP server with implicit TLS. -func DialTLS(address string, options *Options) (*Client, error) { - if options == nil { - options = &Options{} - } - - tlsConfig := options.tlsConfig() - if tlsConfig.NextProtos == nil { - tlsConfig.NextProtos = []string{"imap"} - } - - dialer := options.dialer() - conn, err := tls.DialWithDialer(dialer, "tcp", address, tlsConfig) - if err != nil { - return nil, err - } - return New(conn, options), nil -} - -// DialStartTLS connects to an IMAP server with STARTTLS. -func DialStartTLS(address string, options *Options) (*Client, error) { - if options == nil { - options = &Options{} - } - - host, _, err := net.SplitHostPort(address) - if err != nil { - return nil, err - } - - conn, err := options.dialer().Dial("tcp", address) - if err != nil { - return nil, err - } - - tlsConfig := options.tlsConfig() - if tlsConfig.ServerName == "" { - tlsConfig.ServerName = host - } - newOptions := *options - newOptions.TLSConfig = tlsConfig - return NewStartTLS(conn, &newOptions) -} - -func (c *Client) setReadTimeout(dur time.Duration) { - if dur > 0 { - c.conn.SetReadDeadline(time.Now().Add(dur)) - } else { - c.conn.SetReadDeadline(time.Time{}) - } -} - -func (c *Client) setWriteTimeout(dur time.Duration) { - if dur > 0 { - c.conn.SetWriteDeadline(time.Now().Add(dur)) - } else { - c.conn.SetWriteDeadline(time.Time{}) - } -} - -// State returns the current connection state of the client. -func (c *Client) State() imap.ConnState { - c.mutex.Lock() - defer c.mutex.Unlock() - return c.state -} - -func (c *Client) setState(state imap.ConnState) { - c.mutex.Lock() - c.state = state - if c.state != imap.ConnStateSelected { - c.mailbox = nil - } - c.mutex.Unlock() -} - -// Caps returns the capabilities advertised by the server. -// -// When the server hasn't sent the capability list, this method will request it -// and block until it's received. If the capabilities cannot be fetched, nil is -// returned. -func (c *Client) Caps() imap.CapSet { - if err := c.WaitGreeting(); err != nil { - return nil - } - - c.mutex.Lock() - caps := c.caps - capCh := c.pendingCapCh - c.mutex.Unlock() - - if caps != nil { - return caps - } - - if capCh == nil { - capCmd := c.Capability() - capCh := make(chan struct{}) - go func() { - capCmd.Wait() - close(capCh) - }() - c.mutex.Lock() - c.pendingCapCh = capCh - c.mutex.Unlock() - } - - timer := time.NewTimer(respReadTimeout) - defer timer.Stop() - select { - case <-timer.C: - return nil - case <-capCh: - // ok - } - - // TODO: this is racy if caps are reset before we get the reply - c.mutex.Lock() - defer c.mutex.Unlock() - return c.caps -} - -func (c *Client) setCaps(caps imap.CapSet) { - // If the capabilities are being reset, request the updated capabilities - // from the server - var capCh chan struct{} - if caps == nil { - capCh = make(chan struct{}) - - // We need to send the CAPABILITY command in a separate goroutine: - // setCaps might be called with Client.encMutex locked - go func() { - c.Capability().Wait() - close(capCh) - }() - } - - c.mutex.Lock() - c.caps = caps - c.pendingCapCh = capCh - c.mutex.Unlock() -} - -// Mailbox returns the state of the currently selected mailbox. -// -// If there is no currently selected mailbox, nil is returned. -// -// The returned struct must not be mutated. -func (c *Client) Mailbox() *SelectedMailbox { - c.mutex.Lock() - defer c.mutex.Unlock() - return c.mailbox -} - -// Closed returns a channel that is closed when the connection is closed. -// -// This channel cannot be used to reliably determine whether a connection is healthy. If -// the underlying connection times out, the channel will be closed eventually, but not -// immediately. To check whether the connection is healthy, send a command (such as Noop). -func (c *Client) Closed() <-chan struct{} { - return c.decCh -} - -// Close immediately closes the connection. -func (c *Client) Close() error { - c.mutex.Lock() - alreadyClosed := c.closed - c.closed = true - c.mutex.Unlock() - - // Ignore net.ErrClosed here, because we also call conn.Close in c.read - if err := c.conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, io.ErrClosedPipe) { - return err - } - - <-c.decCh - if err := c.decErr; err != nil { - return err - } - - if alreadyClosed { - return net.ErrClosed - } - return nil -} - -// beginCommand starts sending a command to the server. -// -// The command name and a space are written. -// -// The caller must call commandEncoder.end. -func (c *Client) beginCommand(name string, cmd command) *commandEncoder { - c.encMutex.Lock() // unlocked by commandEncoder.end - - c.mutex.Lock() - - c.cmdTag++ - tag := fmt.Sprintf("T%v", c.cmdTag) - - baseCmd := cmd.base() - *baseCmd = commandBase{ - tag: tag, - done: make(chan error, 1), - } - - c.pendingCmds = append(c.pendingCmds, cmd) - quotedUTF8 := c.caps.Has(imap.CapIMAP4rev2) || c.enabled.Has(imap.CapUTF8Accept) - literalMinus := c.caps.Has(imap.CapLiteralMinus) - literalPlus := c.caps.Has(imap.CapLiteralPlus) - - c.mutex.Unlock() - - c.setWriteTimeout(cmdWriteTimeout) - - wireEnc := imapwire.NewEncoder(c.bw, imapwire.ConnSideClient) - wireEnc.QuotedUTF8 = quotedUTF8 - wireEnc.LiteralMinus = literalMinus - wireEnc.LiteralPlus = literalPlus - wireEnc.NewContinuationRequest = func() *imapwire.ContinuationRequest { - return c.registerContReq(cmd) - } - - enc := &commandEncoder{ - Encoder: wireEnc, - client: c, - cmd: baseCmd, - } - enc.Atom(tag).SP().Atom(name) - return enc -} - -func (c *Client) deletePendingCmdByTag(tag string) command { - c.mutex.Lock() - defer c.mutex.Unlock() - - for i, cmd := range c.pendingCmds { - if cmd.base().tag == tag { - c.pendingCmds = append(c.pendingCmds[:i], c.pendingCmds[i+1:]...) - return cmd - } - } - return nil -} - -func (c *Client) findPendingCmdFunc(f func(cmd command) bool) command { - c.mutex.Lock() - defer c.mutex.Unlock() - - for _, cmd := range c.pendingCmds { - if f(cmd) { - return cmd - } - } - return nil -} - -func findPendingCmdByType[T command](c *Client) T { - c.mutex.Lock() - defer c.mutex.Unlock() - - for _, cmd := range c.pendingCmds { - if cmd, ok := cmd.(T); ok { - return cmd - } - } - - var cmd T - return cmd -} - -func (c *Client) completeCommand(cmd command, err error) { - done := cmd.base().done - done <- err - close(done) - - // Ensure the command is not blocked waiting on continuation requests - c.mutex.Lock() - var filtered []continuationRequest - for _, contReq := range c.contReqs { - if contReq.cmd != cmd.base() { - filtered = append(filtered, contReq) - } else { - contReq.Cancel(err) - } - } - c.contReqs = filtered - c.mutex.Unlock() - - switch cmd := cmd.(type) { - case *authenticateCommand, *loginCommand: - if err == nil { - c.setState(imap.ConnStateAuthenticated) - } - case *unauthenticateCommand: - if err == nil { - c.mutex.Lock() - c.state = imap.ConnStateNotAuthenticated - c.mailbox = nil - c.enabled = make(imap.CapSet) - c.mutex.Unlock() - } - case *SelectCommand: - if err == nil { - c.mutex.Lock() - c.state = imap.ConnStateSelected - c.mailbox = &SelectedMailbox{ - Name: cmd.mailbox, - NumMessages: cmd.data.NumMessages, - Flags: cmd.data.Flags, - PermanentFlags: cmd.data.PermanentFlags, - } - c.mutex.Unlock() - } - case *unselectCommand: - if err == nil { - c.setState(imap.ConnStateAuthenticated) - } - case *logoutCommand: - if err == nil { - c.setState(imap.ConnStateLogout) - } - case *ListCommand: - if cmd.pendingData != nil { - cmd.mailboxes <- cmd.pendingData - } - close(cmd.mailboxes) - case *FetchCommand: - close(cmd.msgs) - case *ExpungeCommand: - close(cmd.seqNums) - } -} - -func (c *Client) registerContReq(cmd command) *imapwire.ContinuationRequest { - contReq := imapwire.NewContinuationRequest() - - c.mutex.Lock() - c.contReqs = append(c.contReqs, continuationRequest{ - ContinuationRequest: contReq, - cmd: cmd.base(), - }) - c.mutex.Unlock() - - return contReq -} - -func (c *Client) closeWithError(err error) { - c.conn.Close() - - c.mutex.Lock() - c.state = imap.ConnStateLogout - pendingCmds := c.pendingCmds - c.pendingCmds = nil - c.mutex.Unlock() - - for _, cmd := range pendingCmds { - c.completeCommand(cmd, err) - } -} - -// read continuously reads data coming from the server. -// -// All the data is decoded in the read goroutine, then dispatched via channels -// to pending commands. -func (c *Client) read() { - defer close(c.decCh) - defer func() { - if v := recover(); v != nil { - c.decErr = fmt.Errorf("imapclient: panic reading response: %v\n%s", v, debug.Stack()) - } - - cmdErr := c.decErr - if cmdErr == nil { - cmdErr = io.ErrUnexpectedEOF - } - c.closeWithError(cmdErr) - }() - - c.setReadTimeout(respReadTimeout) // We're waiting for the greeting - for { - // Ignore net.ErrClosed here, because we also call conn.Close in c.Close - if c.dec.EOF() || errors.Is(c.dec.Err(), net.ErrClosed) || errors.Is(c.dec.Err(), io.ErrClosedPipe) { - break - } - if err := c.readResponse(); err != nil { - c.decErr = err - break - } - if c.greetingErr != nil { - break - } - } -} - -func (c *Client) readResponse() error { - c.setReadTimeout(respReadTimeout) - defer c.setReadTimeout(idleReadTimeout) - - if c.dec.Special('+') { - if err := c.readContinueReq(); err != nil { - return fmt.Errorf("in continue-req: %v", err) - } - return nil - } - - var tag, typ string - if !c.dec.Expect(c.dec.Special('*') || c.dec.Atom(&tag), "'*' or atom") { - return fmt.Errorf("in response: cannot read tag: %v", c.dec.Err()) - } - if !c.dec.ExpectSP() { - return fmt.Errorf("in response: %v", c.dec.Err()) - } - if !c.dec.ExpectAtom(&typ) { - return fmt.Errorf("in response: cannot read type: %v", c.dec.Err()) - } - - // Change typ to uppercase, as it's case-insensitive - typ = strings.ToUpper(typ) - - var ( - token string - err error - startTLS *startTLSCommand - ) - if tag != "" { - token = "response-tagged" - startTLS, err = c.readResponseTagged(tag, typ) - } else { - token = "response-data" - err = c.readResponseData(typ) - } - if err != nil { - return fmt.Errorf("in %v: %v", token, err) - } - - if !c.dec.ExpectCRLF() { - return fmt.Errorf("in response: %v", c.dec.Err()) - } - - if startTLS != nil { - c.upgradeStartTLS(startTLS) - } - - return nil -} - -func (c *Client) readContinueReq() error { - var text string - if c.dec.SP() { - c.dec.Text(&text) - } - if !c.dec.ExpectCRLF() { - return c.dec.Err() - } - - var contReq *imapwire.ContinuationRequest - c.mutex.Lock() - if len(c.contReqs) > 0 { - contReq = c.contReqs[0].ContinuationRequest - c.contReqs = append(c.contReqs[:0], c.contReqs[1:]...) - } - c.mutex.Unlock() - - if contReq == nil { - return fmt.Errorf("received unmatched continuation request") - } - - contReq.Done(text) - return nil -} - -func (c *Client) readResponseTagged(tag, typ string) (startTLS *startTLSCommand, err error) { - cmd := c.deletePendingCmdByTag(tag) - if cmd == nil { - return nil, fmt.Errorf("received tagged response with unknown tag %q", tag) - } - - // We've removed the command from the pending queue above. Make sure we - // don't stall it on error. - defer func() { - if err != nil { - c.completeCommand(cmd, err) - } - }() - - // Some servers don't provide a text even if the RFC requires it, - // see #500 and #502 - hasSP := c.dec.SP() - - var code string - if hasSP && c.dec.Special('[') { // resp-text-code - if !c.dec.ExpectAtom(&code) { - return nil, fmt.Errorf("in resp-text-code: %v", c.dec.Err()) - } - // TODO: LONGENTRIES and MAXSIZE from METADATA - switch code { - case "CAPABILITY": // capability-data - caps, err := readCapabilities(c.dec) - if err != nil { - return nil, fmt.Errorf("in capability-data: %v", err) - } - c.setCaps(caps) - case "APPENDUID": - var ( - uidValidity uint32 - uid imap.UID - ) - if !c.dec.ExpectSP() || !c.dec.ExpectNumber(&uidValidity) || !c.dec.ExpectSP() || !c.dec.ExpectUID(&uid) { - return nil, fmt.Errorf("in resp-code-apnd: %v", c.dec.Err()) - } - if cmd, ok := cmd.(*AppendCommand); ok { - cmd.data.UID = uid - cmd.data.UIDValidity = uidValidity - } - case "COPYUID": - if !c.dec.ExpectSP() { - return nil, c.dec.Err() - } - uidValidity, srcUIDs, dstUIDs, err := readRespCodeCopyUID(c.dec) - if err != nil { - return nil, fmt.Errorf("in resp-code-copy: %v", err) - } - switch cmd := cmd.(type) { - case *CopyCommand: - cmd.data.UIDValidity = uidValidity - cmd.data.SourceUIDs = srcUIDs - cmd.data.DestUIDs = dstUIDs - case *MoveCommand: - // This can happen when Client.Move falls back to COPY + - // STORE + EXPUNGE - cmd.data.UIDValidity = uidValidity - cmd.data.SourceUIDs = srcUIDs - cmd.data.DestUIDs = dstUIDs - } - default: // [SP 1*] - if c.dec.SP() { - c.dec.DiscardUntilByte(']') - } - } - if !c.dec.ExpectSpecial(']') { - return nil, fmt.Errorf("in resp-text: %v", c.dec.Err()) - } - hasSP = c.dec.SP() - } - var text string - if hasSP && !c.dec.ExpectText(&text) { - return nil, fmt.Errorf("in resp-text: %v", c.dec.Err()) - } - - var cmdErr error - switch typ { - case "OK": - // nothing to do - case "NO", "BAD": - cmdErr = &imap.Error{ - Type: imap.StatusResponseType(typ), - Code: imap.ResponseCode(code), - Text: text, - } - default: - return nil, fmt.Errorf("in resp-cond-state: expected OK, NO or BAD status condition, but got %v", typ) - } - - c.completeCommand(cmd, cmdErr) - - if cmd, ok := cmd.(*startTLSCommand); ok && cmdErr == nil { - startTLS = cmd - } - - if cmdErr == nil && code != "CAPABILITY" { - switch cmd.(type) { - case *startTLSCommand, *loginCommand, *authenticateCommand, *unauthenticateCommand: - // These commands invalidate the capabilities - c.setCaps(nil) - } - } - - return startTLS, nil -} - -func (c *Client) readResponseData(typ string) error { - // number SP ("EXISTS" / "RECENT" / "FETCH" / "EXPUNGE") - var num uint32 - if typ[0] >= '0' && typ[0] <= '9' { - v, err := strconv.ParseUint(typ, 10, 32) - if err != nil { - return err - } - - num = uint32(v) - if !c.dec.ExpectSP() || !c.dec.ExpectAtom(&typ) { - return c.dec.Err() - } - } - - // All response type are case insensitive - switch strings.ToUpper(typ) { - case "OK", "PREAUTH", "NO", "BAD", "BYE": // resp-cond-state / resp-cond-bye / resp-cond-auth - // Some servers don't provide a text even if the RFC requires it, - // see #500 and #502 - hasSP := c.dec.SP() - - var code string - if hasSP && c.dec.Special('[') { // resp-text-code - if !c.dec.ExpectAtom(&code) { - return fmt.Errorf("in resp-text-code: %v", c.dec.Err()) - } - switch code { - case "CAPABILITY": // capability-data - caps, err := readCapabilities(c.dec) - if err != nil { - return fmt.Errorf("in capability-data: %v", err) - } - c.setCaps(caps) - case "PERMANENTFLAGS": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - flags, err := internal.ExpectFlagList(c.dec) - if err != nil { - return err - } - - c.mutex.Lock() - if c.state == imap.ConnStateSelected { - c.mailbox = c.mailbox.copy() - c.mailbox.PermanentFlags = flags - } - c.mutex.Unlock() - - if cmd := findPendingCmdByType[*SelectCommand](c); cmd != nil { - cmd.data.PermanentFlags = flags - } else if handler := c.options.unilateralDataHandler().Mailbox; handler != nil { - handler(&UnilateralDataMailbox{PermanentFlags: flags}) - } - case "UIDNEXT": - var uidNext imap.UID - if !c.dec.ExpectSP() || !c.dec.ExpectUID(&uidNext) { - return c.dec.Err() - } - if cmd := findPendingCmdByType[*SelectCommand](c); cmd != nil { - cmd.data.UIDNext = uidNext - } - case "UIDVALIDITY": - var uidValidity uint32 - if !c.dec.ExpectSP() || !c.dec.ExpectNumber(&uidValidity) { - return c.dec.Err() - } - if cmd := findPendingCmdByType[*SelectCommand](c); cmd != nil { - cmd.data.UIDValidity = uidValidity - } - case "COPYUID": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - uidValidity, srcUIDs, dstUIDs, err := readRespCodeCopyUID(c.dec) - if err != nil { - return fmt.Errorf("in resp-code-copy: %v", err) - } - if cmd := findPendingCmdByType[*MoveCommand](c); cmd != nil { - cmd.data.UIDValidity = uidValidity - cmd.data.SourceUIDs = srcUIDs - cmd.data.DestUIDs = dstUIDs - } - case "HIGHESTMODSEQ": - var modSeq uint64 - if !c.dec.ExpectSP() || !c.dec.ExpectModSeq(&modSeq) { - return c.dec.Err() - } - if cmd := findPendingCmdByType[*SelectCommand](c); cmd != nil { - cmd.data.HighestModSeq = modSeq - } - case "NOMODSEQ": - // ignore - default: // [SP 1*] - if c.dec.SP() { - c.dec.DiscardUntilByte(']') - } - } - if !c.dec.ExpectSpecial(']') { - return fmt.Errorf("in resp-text: %v", c.dec.Err()) - } - hasSP = c.dec.SP() - } - - var text string - if hasSP && !c.dec.ExpectText(&text) { - return fmt.Errorf("in resp-text: %v", c.dec.Err()) - } - - if code == "CLOSED" { - c.setState(imap.ConnStateAuthenticated) - } - - if !c.greetingRecv { - switch typ { - case "OK": - c.setState(imap.ConnStateNotAuthenticated) - case "PREAUTH": - c.setState(imap.ConnStateAuthenticated) - default: - c.setState(imap.ConnStateLogout) - c.greetingErr = &imap.Error{ - Type: imap.StatusResponseType(typ), - Code: imap.ResponseCode(code), - Text: text, - } - } - c.greetingRecv = true - if c.greetingErr == nil && code != "CAPABILITY" { - c.setCaps(nil) // request initial capabilities - } - close(c.greetingCh) - } - case "ID": - return c.handleID() - case "CAPABILITY": - return c.handleCapability() - case "ENABLED": - return c.handleEnabled() - case "NAMESPACE": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleNamespace() - case "FLAGS": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleFlags() - case "EXISTS": - return c.handleExists(num) - case "RECENT": - // ignore - case "LIST": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleList() - case "STATUS": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleStatus() - case "FETCH": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleFetch(num) - case "EXPUNGE": - return c.handleExpunge(num) - case "SEARCH": - return c.handleSearch() - case "ESEARCH": - return c.handleESearch() - case "SORT": - return c.handleSort() - case "THREAD": - return c.handleThread() - case "METADATA": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleMetadata() - case "QUOTA": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleQuota() - case "QUOTAROOT": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleQuotaRoot() - case "MYRIGHTS": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleMyRights() - case "ACL": - if !c.dec.ExpectSP() { - return c.dec.Err() - } - return c.handleGetACL() - default: - return fmt.Errorf("unsupported response type %q", typ) - } - - return nil -} - -// WaitGreeting waits for the server's initial greeting. -func (c *Client) WaitGreeting() error { - select { - case <-c.greetingCh: - return c.greetingErr - case <-c.decCh: - if c.decErr != nil { - return fmt.Errorf("got error before greeting: %v", c.decErr) - } - return fmt.Errorf("connection closed before greeting") - } -} - -// Noop sends a NOOP command. -func (c *Client) Noop() *Command { - cmd := &Command{} - c.beginCommand("NOOP", cmd).end() - return cmd -} - -// Logout sends a LOGOUT command. -// -// This command informs the server that the client is done with the connection. -func (c *Client) Logout() *Command { - cmd := &logoutCommand{} - c.beginCommand("LOGOUT", cmd).end() - return &cmd.Command -} - -// Login sends a LOGIN command. -func (c *Client) Login(username, password string) *Command { - cmd := &loginCommand{} - enc := c.beginCommand("LOGIN", cmd) - enc.SP().String(username).SP().String(password) - enc.end() - return &cmd.Command -} - -// Delete sends a DELETE command. -func (c *Client) Delete(mailbox string) *Command { - cmd := &Command{} - enc := c.beginCommand("DELETE", cmd) - enc.SP().Mailbox(mailbox) - enc.end() - return cmd -} - -// Rename sends a RENAME command. -// -// A nil options pointer is equivalent to a zero options value. -func (c *Client) Rename(mailbox, newName string, options *imap.RenameOptions) *Command { - cmd := &Command{} - enc := c.beginCommand("RENAME", cmd) - enc.SP().Mailbox(mailbox).SP().Mailbox(newName) - enc.end() - return cmd -} - -// Subscribe sends a SUBSCRIBE command. -func (c *Client) Subscribe(mailbox string) *Command { - cmd := &Command{} - enc := c.beginCommand("SUBSCRIBE", cmd) - enc.SP().Mailbox(mailbox) - enc.end() - return cmd -} - -// Unsubscribe sends an UNSUBSCRIBE command. -func (c *Client) Unsubscribe(mailbox string) *Command { - cmd := &Command{} - enc := c.beginCommand("UNSUBSCRIBE", cmd) - enc.SP().Mailbox(mailbox) - enc.end() - return cmd -} - -func uidCmdName(name string, kind imapwire.NumKind) string { - switch kind { - case imapwire.NumKindSeq: - return name - case imapwire.NumKindUID: - return "UID " + name - default: - panic("imapclient: invalid imapwire.NumKind") - } -} - -type commandEncoder struct { - *imapwire.Encoder - client *Client - cmd *commandBase -} - -// end ends an outgoing command. -// -// A CRLF is written, the encoder is flushed and its lock is released. -func (ce *commandEncoder) end() { - if ce.Encoder != nil { - ce.flush() - } - ce.client.setWriteTimeout(0) - ce.client.encMutex.Unlock() -} - -// flush sends an outgoing command, but keeps the encoder lock. -// -// A CRLF is written and the encoder is flushed. Callers must call -// commandEncoder.end to release the lock. -func (ce *commandEncoder) flush() { - if err := ce.Encoder.CRLF(); err != nil { - // TODO: consider stashing the error in Client to return it in future - // calls - ce.client.closeWithError(err) - } - ce.Encoder = nil -} - -// Literal encodes a literal. -func (ce *commandEncoder) Literal(size int64) io.WriteCloser { - var contReq *imapwire.ContinuationRequest - ce.client.mutex.Lock() - hasCapLiteralMinus := ce.client.caps.Has(imap.CapLiteralMinus) - ce.client.mutex.Unlock() - if size > 4096 || !hasCapLiteralMinus { - contReq = ce.client.registerContReq(ce.cmd) - } - ce.client.setWriteTimeout(literalWriteTimeout) - return literalWriter{ - WriteCloser: ce.Encoder.Literal(size, contReq), - client: ce.client, - } -} - -type literalWriter struct { - io.WriteCloser - client *Client -} - -func (lw literalWriter) Close() error { - lw.client.setWriteTimeout(cmdWriteTimeout) - return lw.WriteCloser.Close() -} - -// continuationRequest is a pending continuation request. -type continuationRequest struct { - *imapwire.ContinuationRequest - cmd *commandBase -} - -// UnilateralDataMailbox describes a mailbox status update. -// -// If a field is nil, it hasn't changed. -type UnilateralDataMailbox struct { - NumMessages *uint32 - Flags []imap.Flag - PermanentFlags []imap.Flag -} - -// UnilateralDataHandler handles unilateral data. -// -// The handler will block the client while running. If the caller intends to -// perform slow operations, a buffered channel and a separate goroutine should -// be used. -// -// The handler will be invoked in an arbitrary goroutine. -// -// See Options.UnilateralDataHandler. -type UnilateralDataHandler struct { - Expunge func(seqNum uint32) - Mailbox func(data *UnilateralDataMailbox) - Fetch func(msg *FetchMessageData) - - // requires ENABLE METADATA or ENABLE SERVER-METADATA - Metadata func(mailbox string, entries []string) -} - -// command is an interface for IMAP commands. -// -// Commands are represented by the Command type, but can be extended by other -// types (e.g. CapabilityCommand). -type command interface { - base() *commandBase -} - -type commandBase struct { - tag string - done chan error - err error -} - -func (cmd *commandBase) base() *commandBase { - return cmd -} - -func (cmd *commandBase) wait() error { - if cmd.err == nil { - cmd.err = <-cmd.done - } - return cmd.err -} - -// Command is a basic IMAP command. -type Command struct { - commandBase -} - -// Wait blocks until the command has completed. -func (cmd *Command) Wait() error { - return cmd.wait() -} - -type loginCommand struct { - Command -} - -// logoutCommand is a LOGOUT command. -type logoutCommand struct { - Command -} diff --git a/imapclient/client_test.go b/imapclient/client_test.go deleted file mode 100644 index 9e5c206..0000000 --- a/imapclient/client_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package imapclient_test - -import ( - "crypto/tls" - "io" - "net" - "os" - "sync" - "testing" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/imapclient" - "github.com/emersion/go-imap/v2/imapserver" - "github.com/emersion/go-imap/v2/imapserver/imapmemserver" -) - -const ( - testUsername = "test-user" - testPassword = "test-password" -) - -const simpleRawMessage = `MIME-Version: 1.0 -Message-Id: <191101702316132@example.com> -Content-Transfer-Encoding: 8bit -Content-Type: text/plain; charset=utf-8 - -This is my letter!` - -var rsaCertPEM = `-----BEGIN CERTIFICATE----- -MIIDOTCCAiGgAwIBAgIQSRJrEpBGFc7tNb1fb5pKFzANBgkqhkiG9w0BAQsFADAS -MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw -MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEA6Gba5tHV1dAKouAaXO3/ebDUU4rvwCUg/CNaJ2PT5xLD4N1Vcb8r -bFSW2HXKq+MPfVdwIKR/1DczEoAGf/JWQTW7EgzlXrCd3rlajEX2D73faWJekD0U -aUgz5vtrTXZ90BQL7WvRICd7FlEZ6FPOcPlumiyNmzUqtwGhO+9ad1W5BqJaRI6P -YfouNkwR6Na4TzSj5BrqUfP0FwDizKSJ0XXmh8g8G9mtwxOSN3Ru1QFc61Xyeluk -POGKBV/q6RBNklTNe0gI8usUMlYyoC7ytppNMW7X2vodAelSu25jgx2anj9fDVZu -h7AXF5+4nJS4AAt0n1lNY7nGSsdZas8PbQIDAQABo4GIMIGFMA4GA1UdDwEB/wQE -AwICpDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud -DgQWBBStsdjh3/JCXXYlQryOrL4Sh7BW5TAuBgNVHREEJzAlggtleGFtcGxlLmNv -bYcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAQEAxWGI -5NhpF3nwwy/4yB4i/CwwSpLrWUa70NyhvprUBC50PxiXav1TeDzwzLx/o5HyNwsv -cxv3HdkLW59i/0SlJSrNnWdfZ19oTcS+6PtLoVyISgtyN6DpkKpdG1cOkW3Cy2P2 -+tK/tKHRP1Y/Ra0RiDpOAmqn0gCOFGz8+lqDIor/T7MTpibL3IxqWfPrvfVRHL3B -grw/ZQTTIVjjh4JBSW3WyWgNo/ikC1lrVxzl4iPUGptxT36Cr7Zk2Bsg0XqwbOvK -5d+NTDREkSnUbie4GeutujmX3Dsx88UiV6UY/4lHJa6I5leHUNOHahRbpbWeOfs/ -WkBKOclmOV2xlTVuPw== ------END CERTIFICATE----- -` - -var rsaKeyPEM = `-----BEGIN RSA PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoZtrm0dXV0Aqi -4Bpc7f95sNRTiu/AJSD8I1onY9PnEsPg3VVxvytsVJbYdcqr4w99V3AgpH/UNzMS -gAZ/8lZBNbsSDOVesJ3euVqMRfYPvd9pYl6QPRRpSDPm+2tNdn3QFAvta9EgJ3sW -URnoU85w+W6aLI2bNSq3AaE771p3VbkGolpEjo9h+i42TBHo1rhPNKPkGupR8/QX -AOLMpInRdeaHyDwb2a3DE5I3dG7VAVzrVfJ6W6Q84YoFX+rpEE2SVM17SAjy6xQy -VjKgLvK2mk0xbtfa+h0B6VK7bmODHZqeP18NVm6HsBcXn7iclLgAC3SfWU1jucZK -x1lqzw9tAgMBAAECggEABWzxS1Y2wckblnXY57Z+sl6YdmLV+gxj2r8Qib7g4ZIk -lIlWR1OJNfw7kU4eryib4fc6nOh6O4AWZyYqAK6tqNQSS/eVG0LQTLTTEldHyVJL -dvBe+MsUQOj4nTndZW+QvFzbcm2D8lY5n2nBSxU5ypVoKZ1EqQzytFcLZpTN7d89 -EPj0qDyrV4NZlWAwL1AygCwnlwhMQjXEalVF1ylXwU3QzyZ/6MgvF6d3SSUlh+sq -XefuyigXw484cQQgbzopv6niMOmGP3of+yV4JQqUSb3IDmmT68XjGd2Dkxl4iPki -6ZwXf3CCi+c+i/zVEcufgZ3SLf8D99kUGE7v7fZ6AQKBgQD1ZX3RAla9hIhxCf+O -3D+I1j2LMrdjAh0ZKKqwMR4JnHX3mjQI6LwqIctPWTU8wYFECSh9klEclSdCa64s -uI/GNpcqPXejd0cAAdqHEEeG5sHMDt0oFSurL4lyud0GtZvwlzLuwEweuDtvT9cJ -Wfvl86uyO36IW8JdvUprYDctrQKBgQDycZ697qutBieZlGkHpnYWUAeImVA878sJ -w44NuXHvMxBPz+lbJGAg8Cn8fcxNAPqHIraK+kx3po8cZGQywKHUWsxi23ozHoxo -+bGqeQb9U661TnfdDspIXia+xilZt3mm5BPzOUuRqlh4Y9SOBpSWRmEhyw76w4ZP -OPxjWYAgwQKBgA/FehSYxeJgRjSdo+MWnK66tjHgDJE8bYpUZsP0JC4R9DL5oiaA -brd2fI6Y+SbyeNBallObt8LSgzdtnEAbjIH8uDJqyOmknNePRvAvR6mP4xyuR+Bv -m+Lgp0DMWTw5J9CKpydZDItc49T/mJ5tPhdFVd+am0NAQnmr1MCZ6nHxAoGABS3Y -LkaC9FdFUUqSU8+Chkd/YbOkuyiENdkvl6t2e52jo5DVc1T7mLiIrRQi4SI8N9bN -/3oJWCT+uaSLX2ouCtNFunblzWHBrhxnZzTeqVq4SLc8aESAnbslKL4i8/+vYZlN -s8xtiNcSvL+lMsOBORSXzpj/4Ot8WwTkn1qyGgECgYBKNTypzAHeLE6yVadFp3nQ -Ckq9yzvP/ib05rvgbvrne00YeOxqJ9gtTrzgh7koqJyX1L4NwdkEza4ilDWpucn0 -xiUZS4SoaJq6ZvcBYS62Yr1t8n09iG47YL8ibgtmH3L+svaotvpVxVK+d7BLevA/ -ZboOWVe3icTy64BT3OQhmg== ------END RSA PRIVATE KEY----- -` - -func newMemClientServerPair(t *testing.T) (net.Conn, io.Closer) { - memServer := imapmemserver.New() - - user := imapmemserver.NewUser(testUsername, testPassword) - user.Create("INBOX", nil) - - memServer.AddUser(user) - - cert, err := tls.X509KeyPair([]byte(rsaCertPEM), []byte(rsaKeyPEM)) - if err != nil { - t.Fatalf("tls.X509KeyPair() = %v", err) - } - - server := imapserver.New(&imapserver.Options{ - NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { - return memServer.NewSession(), nil, nil - }, - TLSConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, - }, - InsecureAuth: true, - Caps: imap.CapSet{ - imap.CapIMAP4rev1: {}, - imap.CapIMAP4rev2: {}, - }, - }) - - ln, err := net.Listen("tcp", "localhost:0") - if err != nil { - t.Fatalf("net.Listen() = %v", err) - } - - go func() { - if err := server.Serve(ln); err != nil { - t.Errorf("Serve() = %v", err) - } - }() - - conn, err := net.Dial("tcp", ln.Addr().String()) - if err != nil { - t.Fatalf("net.Dial() = %v", err) - } - - return conn, server -} - -func newClientServerPair(t *testing.T, initialState imap.ConnState) (*imapclient.Client, io.Closer) { - var useDovecot bool - switch os.Getenv("GOIMAP_TEST_DOVECOT") { - case "0", "": - // ok - case "1": - useDovecot = true - default: - t.Fatalf("invalid GOIMAP_TEST_DOVECOT env var") - } - - var ( - conn net.Conn - server io.Closer - ) - if useDovecot { - if initialState < imap.ConnStateAuthenticated { - t.Skip("Dovecot connections are pre-authenticated") - } - conn, server = newDovecotClientServerPair(t) - } else { - conn, server = newMemClientServerPair(t) - } - - var debugWriter swapWriter - debugWriter.Swap(io.Discard) - - var options imapclient.Options - if testing.Verbose() { - options.DebugWriter = &debugWriter - } - client := imapclient.New(conn, &options) - - if initialState >= imap.ConnStateAuthenticated { - // Dovecot connections are pre-authenticated - if !useDovecot { - if err := client.Login(testUsername, testPassword).Wait(); err != nil { - t.Fatalf("Login().Wait() = %v", err) - } - } - - appendCmd := client.Append("INBOX", int64(len(simpleRawMessage)), nil) - appendCmd.Write([]byte(simpleRawMessage)) - appendCmd.Close() - if _, err := appendCmd.Wait(); err != nil { - t.Fatalf("AppendCommand.Wait() = %v", err) - } - } - if initialState >= imap.ConnStateSelected { - if _, err := client.Select("INBOX", nil).Wait(); err != nil { - t.Fatalf("Select().Wait() = %v", err) - } - } - - // Turn on debug logs after we're done initializing the test - debugWriter.Swap(os.Stderr) - - return client, server -} - -// swapWriter is an io.Writer which can be swapped at runtime. -type swapWriter struct { - w io.Writer - mutex sync.Mutex -} - -func (sw *swapWriter) Write(b []byte) (int, error) { - sw.mutex.Lock() - w := sw.w - sw.mutex.Unlock() - - return w.Write(b) -} - -func (sw *swapWriter) Swap(w io.Writer) { - sw.mutex.Lock() - sw.w = w - sw.mutex.Unlock() -} - -func TestLogin(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateNotAuthenticated) - defer client.Close() - defer server.Close() - - if err := client.Login(testUsername, testPassword).Wait(); err != nil { - t.Errorf("Login().Wait() = %v", err) - } -} - -func TestLogout(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateAuthenticated) - defer server.Close() - - if _, ok := server.(*dovecotServer); ok { - t.Skip("Dovecot connections don't reply to LOGOUT") - } - - if err := client.Logout().Wait(); err != nil { - t.Errorf("Logout().Wait() = %v", err) - } - if err := client.Close(); err != nil { - t.Errorf("Close() = %v", err) - } -} - -// https://github.com/emersion/go-imap/issues/562 -func TestFetch_invalid(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - _, err := client.Fetch(imap.UIDSet(nil), nil).Collect() - if err == nil { - t.Fatalf("UIDFetch().Collect() = %v", err) - } -} - -func TestFetch_closeUnreadBody(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - fetchCmd := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{ - BodySection: []*imap.FetchItemBodySection{ - { - Specifier: imap.PartSpecifierNone, - Peek: true, - }, - }, - }) - if err := fetchCmd.Close(); err != nil { - t.Fatalf("UIDFetch().Close() = %v", err) - } -} - -func TestWaitGreeting_eof(t *testing.T) { - // bad server: connected but without greeting - clientConn, serverConn := net.Pipe() - - client := imapclient.New(clientConn, nil) - defer client.Close() - - if err := serverConn.Close(); err != nil { - t.Fatalf("serverConn.Close() = %v", err) - } - - if err := client.WaitGreeting(); err == nil { - t.Fatalf("WaitGreeting() should fail") - } -} diff --git a/imapclient/connection_test.go b/imapclient/connection_test.go deleted file mode 100644 index a15e338..0000000 --- a/imapclient/connection_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package imapclient_test - -import ( - "testing" - "time" - - "github.com/emersion/go-imap/v2" -) - -// TestClient_Closed tests that the Closed() channel is closed when the -// connection is explicitly closed via Close(). -func TestClient_Closed(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateAuthenticated) - defer server.Close() - - closedCh := client.Closed() - if closedCh == nil { - t.Fatal("Closed() returned nil channel") - } - - select { - case <-closedCh: - t.Fatal("Closed() channel closed before calling Close()") - default: // Expected - } - - if err := client.Close(); err != nil { - t.Fatalf("Close() = %v", err) - } - - select { - case <-closedCh: - t.Log("Closed() channel properly closed after Close()") - case <-time.After(2 * time.Second): - t.Fatal("Closed() channel not closed after Close()") - } -} diff --git a/imapclient/copy.go b/imapclient/copy.go deleted file mode 100644 index c1081d8..0000000 --- a/imapclient/copy.go +++ /dev/null @@ -1,37 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// Copy sends a COPY command. -func (c *Client) Copy(numSet imap.NumSet, mailbox string) *CopyCommand { - cmd := &CopyCommand{} - enc := c.beginCommand(uidCmdName("COPY", imapwire.NumSetKind(numSet)), cmd) - enc.SP().NumSet(numSet).SP().Mailbox(mailbox) - enc.end() - return cmd -} - -// CopyCommand is a COPY command. -type CopyCommand struct { - commandBase - data imap.CopyData -} - -func (cmd *CopyCommand) Wait() (*imap.CopyData, error) { - return &cmd.data, cmd.wait() -} - -func readRespCodeCopyUID(dec *imapwire.Decoder) (uidValidity uint32, srcUIDs, dstUIDs imap.UIDSet, err error) { - if !dec.ExpectNumber(&uidValidity) || !dec.ExpectSP() || !dec.ExpectUIDSet(&srcUIDs) || !dec.ExpectSP() || !dec.ExpectUIDSet(&dstUIDs) { - return 0, nil, nil, dec.Err() - } - if srcUIDs.Dynamic() || dstUIDs.Dynamic() { - return 0, nil, nil, fmt.Errorf("imapclient: server returned dynamic number set in COPYUID response") - } - return uidValidity, srcUIDs, dstUIDs, nil -} diff --git a/imapclient/create.go b/imapclient/create.go deleted file mode 100644 index 827ecce..0000000 --- a/imapclient/create.go +++ /dev/null @@ -1,21 +0,0 @@ -package imapclient - -import ( - "github.com/emersion/go-imap/v2" -) - -// Create sends a CREATE command. -// -// A nil options pointer is equivalent to a zero options value. -func (c *Client) Create(mailbox string, options *imap.CreateOptions) *Command { - cmd := &Command{} - enc := c.beginCommand("CREATE", cmd) - enc.SP().Mailbox(mailbox) - if options != nil && len(options.SpecialUse) > 0 { - enc.SP().Special('(').Atom("USE").SP().List(len(options.SpecialUse), func(i int) { - enc.MailboxAttr(options.SpecialUse[i]) - }).Special(')') - } - enc.end() - return cmd -} diff --git a/imapclient/create_test.go b/imapclient/create_test.go deleted file mode 100644 index 63969c4..0000000 --- a/imapclient/create_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package imapclient_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2" -) - -func testCreate(t *testing.T, name string, utf8Accept bool) { - client, server := newClientServerPair(t, imap.ConnStateAuthenticated) - defer client.Close() - defer server.Close() - - if utf8Accept { - if !client.Caps().Has(imap.CapUTF8Accept) { - t.Skipf("missing UTF8=ACCEPT support") - } - if data, err := client.Enable(imap.CapUTF8Accept).Wait(); err != nil { - t.Fatalf("Enable(CapUTF8Accept) = %v", err) - } else if !data.Caps.Has(imap.CapUTF8Accept) { - t.Fatalf("server refused to enable UTF8=ACCEPT") - } - } - - if err := client.Create(name, nil).Wait(); err != nil { - t.Fatalf("Create() = %v", err) - } - - listCmd := client.List("", name, nil) - mailboxes, err := listCmd.Collect() - if err != nil { - t.Errorf("List() = %v", err) - } else if len(mailboxes) != 1 || mailboxes[0].Mailbox != name { - t.Errorf("List() = %v, want exactly one entry with correct name", mailboxes) - } -} - -func TestCreate(t *testing.T) { - t.Run("basic", func(t *testing.T) { - testCreate(t, "Test mailbox", false) - }) - - t.Run("unicode_utf7", func(t *testing.T) { - testCreate(t, "Cafè", false) - }) - t.Run("unicode_utf8", func(t *testing.T) { - testCreate(t, "Cafè", true) - }) - - // '&' is the UTF-7 escape character - t.Run("ampersand_utf7", func(t *testing.T) { - testCreate(t, "Angus & Julia", false) - }) - t.Run("ampersand_utf8", func(t *testing.T) { - testCreate(t, "Angus & Julia", true) - }) -} diff --git a/imapclient/dovecot_test.go b/imapclient/dovecot_test.go deleted file mode 100644 index fdcda74..0000000 --- a/imapclient/dovecot_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package imapclient_test - -import ( - "io" - "net" - "os" - "os/exec" - "path/filepath" - "testing" -) - -func newDovecotClientServerPair(t *testing.T) (net.Conn, io.Closer) { - tempDir := t.TempDir() - - cfgFilename := filepath.Join(tempDir, "dovecot.conf") - cfg := `dovecot_config_version = 2.4.0 -dovecot_storage_version = 2.4.0 - -log_path = "` + tempDir + `/dovecot.log" -ssl = no -mail_home = "` + tempDir + `/%{user}" -mail_driver = maildir -mail_path = "~/Mail" - -namespace inbox { - separator = / - prefix = - inbox = yes -} - -mail_plugins { - acl = yes -} - -protocol imap { - mail_plugins { - imap_acl = yes - } -} - -acl_driver = vfile -` - if err := os.WriteFile(cfgFilename, []byte(cfg), 0666); err != nil { - t.Fatalf("failed to write Dovecot config: %v", err) - } - - clientConn, serverConn := net.Pipe() - - cmd := exec.Command("doveadm", "-c", cfgFilename, "exec", "imap") - cmd.Env = []string{"USER=" + testUsername, "PATH=" + os.Getenv("PATH")} - cmd.Dir = tempDir - cmd.Stdin = serverConn - cmd.Stdout = serverConn - cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - t.Fatalf("failed to start Dovecot: %v", err) - } - - return clientConn, &dovecotServer{cmd, serverConn} -} - -type dovecotServer struct { - cmd *exec.Cmd - conn net.Conn -} - -func (srv *dovecotServer) Close() error { - if err := srv.conn.Close(); err != nil { - return err - } - return srv.cmd.Wait() -} diff --git a/imapclient/enable.go b/imapclient/enable.go deleted file mode 100644 index 8957666..0000000 --- a/imapclient/enable.go +++ /dev/null @@ -1,69 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" -) - -// Enable sends an ENABLE command. -// -// This command requires support for IMAP4rev2 or the ENABLE extension. -func (c *Client) Enable(caps ...imap.Cap) *EnableCommand { - // Enabling an extension may change the IMAP syntax, so only allow the - // extensions we support here - for _, name := range caps { - switch name { - case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer: - // ok - default: - done := make(chan error) - close(done) - err := fmt.Errorf("imapclient: cannot enable %q: not supported", name) - return &EnableCommand{commandBase: commandBase{done: done, err: err}} - } - } - - cmd := &EnableCommand{} - enc := c.beginCommand("ENABLE", cmd) - for _, c := range caps { - enc.SP().Atom(string(c)) - } - enc.end() - return cmd -} - -func (c *Client) handleEnabled() error { - caps, err := readCapabilities(c.dec) - if err != nil { - return err - } - - c.mutex.Lock() - for name := range caps { - c.enabled[name] = struct{}{} - } - c.mutex.Unlock() - - if cmd := findPendingCmdByType[*EnableCommand](c); cmd != nil { - cmd.data.Caps = caps - } - - return nil -} - -// EnableCommand is an ENABLE command. -type EnableCommand struct { - commandBase - data EnableData -} - -func (cmd *EnableCommand) Wait() (*EnableData, error) { - return &cmd.data, cmd.wait() -} - -// EnableData is the data returned by the ENABLE command. -type EnableData struct { - // Capabilities that were successfully enabled - Caps imap.CapSet -} diff --git a/imapclient/example_test.go b/imapclient/example_test.go deleted file mode 100644 index 6765d92..0000000 --- a/imapclient/example_test.go +++ /dev/null @@ -1,411 +0,0 @@ -package imapclient_test - -import ( - "io" - "log" - "time" - - "github.com/emersion/go-message/mail" - "github.com/emersion/go-sasl" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/imapclient" -) - -func ExampleClient() { - c, err := imapclient.DialTLS("mail.example.org:993", nil) - if err != nil { - log.Fatalf("failed to dial IMAP server: %v", err) - } - defer c.Close() - - if err := c.Login("root", "asdf").Wait(); err != nil { - log.Fatalf("failed to login: %v", err) - } - - mailboxes, err := c.List("", "%", nil).Collect() - if err != nil { - log.Fatalf("failed to list mailboxes: %v", err) - } - log.Printf("Found %v mailboxes", len(mailboxes)) - for _, mbox := range mailboxes { - log.Printf(" - %v", mbox.Mailbox) - } - - selectedMbox, err := c.Select("INBOX", nil).Wait() - if err != nil { - log.Fatalf("failed to select INBOX: %v", err) - } - log.Printf("INBOX contains %v messages", selectedMbox.NumMessages) - - if selectedMbox.NumMessages > 0 { - seqSet := imap.SeqSetNum(1) - fetchOptions := &imap.FetchOptions{Envelope: true} - messages, err := c.Fetch(seqSet, fetchOptions).Collect() - if err != nil { - log.Fatalf("failed to fetch first message in INBOX: %v", err) - } - log.Printf("subject of first message in INBOX: %v", messages[0].Envelope.Subject) - } - - if err := c.Logout().Wait(); err != nil { - log.Fatalf("failed to logout: %v", err) - } -} - -func ExampleClient_pipelining() { - var c *imapclient.Client - - uid := imap.UID(42) - fetchOptions := &imap.FetchOptions{Envelope: true} - - // Login, select and fetch a message in a single roundtrip - loginCmd := c.Login("root", "root") - selectCmd := c.Select("INBOX", nil) - fetchCmd := c.Fetch(imap.UIDSetNum(uid), fetchOptions) - - if err := loginCmd.Wait(); err != nil { - log.Fatalf("failed to login: %v", err) - } - if _, err := selectCmd.Wait(); err != nil { - log.Fatalf("failed to select INBOX: %v", err) - } - if messages, err := fetchCmd.Collect(); err != nil { - log.Fatalf("failed to fetch message: %v", err) - } else { - log.Printf("Subject: %v", messages[0].Envelope.Subject) - } -} - -func ExampleClient_Append() { - var c *imapclient.Client - - buf := []byte("From: \r\n\r\nHi <3") - size := int64(len(buf)) - appendCmd := c.Append("INBOX", size, nil) - if _, err := appendCmd.Write(buf); err != nil { - log.Fatalf("failed to write message: %v", err) - } - if err := appendCmd.Close(); err != nil { - log.Fatalf("failed to close message: %v", err) - } - if _, err := appendCmd.Wait(); err != nil { - log.Fatalf("APPEND command failed: %v", err) - } -} - -func ExampleClient_Status() { - var c *imapclient.Client - - options := imap.StatusOptions{NumMessages: true} - if data, err := c.Status("INBOX", &options).Wait(); err != nil { - log.Fatalf("STATUS command failed: %v", err) - } else { - log.Printf("INBOX contains %v messages", *data.NumMessages) - } -} - -func ExampleClient_List_stream() { - var c *imapclient.Client - - // ReturnStatus requires server support for IMAP4rev2 or LIST-STATUS - listCmd := c.List("", "%", &imap.ListOptions{ - ReturnStatus: &imap.StatusOptions{ - NumMessages: true, - NumUnseen: true, - }, - }) - for { - mbox := listCmd.Next() - if mbox == nil { - break - } - log.Printf("Mailbox %q contains %v messages (%v unseen)", mbox.Mailbox, mbox.Status.NumMessages, mbox.Status.NumUnseen) - } - if err := listCmd.Close(); err != nil { - log.Fatalf("LIST command failed: %v", err) - } -} - -func ExampleClient_Store() { - var c *imapclient.Client - - seqSet := imap.SeqSetNum(1) - storeFlags := imap.StoreFlags{ - Op: imap.StoreFlagsAdd, - Flags: []imap.Flag{imap.FlagFlagged}, - Silent: true, - } - if err := c.Store(seqSet, &storeFlags, nil).Close(); err != nil { - log.Fatalf("STORE command failed: %v", err) - } -} - -func ExampleClient_Fetch() { - var c *imapclient.Client - - seqSet := imap.SeqSetNum(1) - bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader} - fetchOptions := &imap.FetchOptions{ - Flags: true, - Envelope: true, - BodySection: []*imap.FetchItemBodySection{bodySection}, - } - messages, err := c.Fetch(seqSet, fetchOptions).Collect() - if err != nil { - log.Fatalf("FETCH command failed: %v", err) - } - - msg := messages[0] - header := msg.FindBodySection(bodySection) - - log.Printf("Flags: %v", msg.Flags) - log.Printf("Subject: %v", msg.Envelope.Subject) - log.Printf("Header:\n%v", string(header)) -} - -func ExampleClient_Fetch_streamBody() { - var c *imapclient.Client - - seqSet := imap.SeqSetNum(1) - bodySection := &imap.FetchItemBodySection{} - fetchOptions := &imap.FetchOptions{ - UID: true, - BodySection: []*imap.FetchItemBodySection{bodySection}, - } - fetchCmd := c.Fetch(seqSet, fetchOptions) - defer fetchCmd.Close() - - for { - msg := fetchCmd.Next() - if msg == nil { - break - } - - for { - item := msg.Next() - if item == nil { - break - } - - switch item := item.(type) { - case imapclient.FetchItemDataUID: - log.Printf("UID: %v", item.UID) - case imapclient.FetchItemDataBodySection: - b, err := io.ReadAll(item.Literal) - if err != nil { - log.Fatalf("failed to read body section: %v", err) - } - log.Printf("Body:\n%v", string(b)) - } - } - } - - if err := fetchCmd.Close(); err != nil { - log.Fatalf("FETCH command failed: %v", err) - } -} - -func ExampleClient_Fetch_parseBody() { - var c *imapclient.Client - - // Send a FETCH command to fetch the message body - seqSet := imap.SeqSetNum(1) - bodySection := &imap.FetchItemBodySection{} - fetchOptions := &imap.FetchOptions{ - BodySection: []*imap.FetchItemBodySection{bodySection}, - } - fetchCmd := c.Fetch(seqSet, fetchOptions) - defer fetchCmd.Close() - - msg := fetchCmd.Next() - if msg == nil { - log.Fatalf("FETCH command did not return any message") - } - - // Find the body section in the response - var bodySectionData imapclient.FetchItemDataBodySection - ok := false - for { - item := msg.Next() - if item == nil { - break - } - bodySectionData, ok = item.(imapclient.FetchItemDataBodySection) - if ok { - break - } - } - if !ok { - log.Fatalf("FETCH command did not return body section") - } - - // Read the message via the go-message library - mr, err := mail.CreateReader(bodySectionData.Literal) - if err != nil { - log.Fatalf("failed to create mail reader: %v", err) - } - - // Print a few header fields - h := mr.Header - if date, err := h.Date(); err != nil { - log.Printf("failed to parse Date header field: %v", err) - } else { - log.Printf("Date: %v", date) - } - if to, err := h.AddressList("To"); err != nil { - log.Printf("failed to parse To header field: %v", err) - } else { - log.Printf("To: %v", to) - } - if subject, err := h.Text("Subject"); err != nil { - log.Printf("failed to parse Subject header field: %v", err) - } else { - log.Printf("Subject: %v", subject) - } - - // Process the message's parts - for { - p, err := mr.NextPart() - if err == io.EOF { - break - } else if err != nil { - log.Fatalf("failed to read message part: %v", err) - } - - switch h := p.Header.(type) { - case *mail.InlineHeader: - // This is the message's text (can be plain-text or HTML) - b, _ := io.ReadAll(p.Body) - log.Printf("Inline text: %v", string(b)) - case *mail.AttachmentHeader: - // This is an attachment - filename, _ := h.Filename() - log.Printf("Attachment: %v", filename) - } - } - - if err := fetchCmd.Close(); err != nil { - log.Fatalf("FETCH command failed: %v", err) - } -} - -func ExampleClient_Search() { - var c *imapclient.Client - - data, err := c.UIDSearch(&imap.SearchCriteria{ - Body: []string{"Hello world"}, - }, nil).Wait() - if err != nil { - log.Fatalf("UID SEARCH command failed: %v", err) - } - log.Fatalf("UIDs matching the search criteria: %v", data.AllUIDs()) -} - -func ExampleClient_Idle() { - options := imapclient.Options{ - UnilateralDataHandler: &imapclient.UnilateralDataHandler{ - Expunge: func(seqNum uint32) { - log.Printf("message %v has been expunged", seqNum) - }, - Mailbox: func(data *imapclient.UnilateralDataMailbox) { - if data.NumMessages != nil { - log.Printf("a new message has been received") - } - }, - }, - } - - c, err := imapclient.DialTLS("mail.example.org:993", &options) - if err != nil { - log.Fatalf("failed to dial IMAP server: %v", err) - } - defer c.Close() - - if err := c.Login("root", "asdf").Wait(); err != nil { - log.Fatalf("failed to login: %v", err) - } - if _, err := c.Select("INBOX", nil).Wait(); err != nil { - log.Fatalf("failed to select INBOX: %v", err) - } - - // Start idling - idleCmd, err := c.Idle() - if err != nil { - log.Fatalf("IDLE command failed: %v", err) - } - defer idleCmd.Close() - - done := make(chan error, 1) - go func() { - done <- idleCmd.Wait() - }() - - // Wait for 30s to receive updates from the server, then stop idling - t := time.NewTimer(30 * time.Second) - defer t.Stop() - select { - case <-t.C: - if err := idleCmd.Close(); err != nil { - log.Fatalf("failed to stop idling: %v", err) - } - if err := <-done; err != nil { - log.Fatalf("IDLE command failed: %v", err) - } - case err := <-done: - if err != nil { - log.Fatalf("IDLE command failed: %v", err) - } - } -} - -func ExampleClient_Authenticate_oauth() { - var ( - c *imapclient.Client - username string - token string - ) - - if !c.Caps().Has(imap.AuthCap(sasl.OAuthBearer)) { - log.Fatal("OAUTHBEARER not supported by the server") - } - - saslClient := sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{ - Username: username, - Token: token, - }) - if err := c.Authenticate(saslClient); err != nil { - log.Fatalf("authentication failed: %v", err) - } -} - -func ExampleClient_Closed() { - c, err := imapclient.DialTLS("mail.example.org:993", nil) - if err != nil { - log.Fatalf("failed to dial IMAP server: %v", err) - } - - selected := false - - go func(c *imapclient.Client) { - if err := c.Login("root", "asdf").Wait(); err != nil { - log.Fatalf("failed to login: %v", err) - } - - if _, err := c.Select("INBOX", nil).Wait(); err != nil { - log.Fatalf("failed to select INBOX: %v", err) - } - - selected = true - - c.Close() - }(c) - - // This channel shall be closed when the connection is closed. - <-c.Closed() - log.Println("Connection has been closed") - - if !selected { - log.Fatalf("Connection was closed before selecting mailbox") - } -} diff --git a/imapclient/expunge.go b/imapclient/expunge.go deleted file mode 100644 index 11e477c..0000000 --- a/imapclient/expunge.go +++ /dev/null @@ -1,84 +0,0 @@ -package imapclient - -import ( - "github.com/emersion/go-imap/v2" -) - -// Expunge sends an EXPUNGE command. -func (c *Client) Expunge() *ExpungeCommand { - cmd := &ExpungeCommand{seqNums: make(chan uint32, 128)} - c.beginCommand("EXPUNGE", cmd).end() - return cmd -} - -// UIDExpunge sends a UID EXPUNGE command. -// -// This command requires support for IMAP4rev2 or the UIDPLUS extension. -func (c *Client) UIDExpunge(uids imap.UIDSet) *ExpungeCommand { - cmd := &ExpungeCommand{seqNums: make(chan uint32, 128)} - enc := c.beginCommand("UID EXPUNGE", cmd) - enc.SP().NumSet(uids) - enc.end() - return cmd -} - -func (c *Client) handleExpunge(seqNum uint32) error { - c.mutex.Lock() - if c.state == imap.ConnStateSelected && c.mailbox.NumMessages > 0 { - c.mailbox = c.mailbox.copy() - c.mailbox.NumMessages-- - } - c.mutex.Unlock() - - cmd := findPendingCmdByType[*ExpungeCommand](c) - if cmd != nil { - cmd.seqNums <- seqNum - } else if handler := c.options.unilateralDataHandler().Expunge; handler != nil { - handler(seqNum) - } - - return nil -} - -// ExpungeCommand is an EXPUNGE command. -// -// The caller must fully consume the ExpungeCommand. A simple way to do so is -// to defer a call to FetchCommand.Close. -type ExpungeCommand struct { - commandBase - seqNums chan uint32 -} - -// Next advances to the next expunged message sequence number. -// -// On success, the message sequence number is returned. On error or if there -// are no more messages, 0 is returned. To check the error value, use Close. -func (cmd *ExpungeCommand) Next() uint32 { - return <-cmd.seqNums -} - -// Close releases the command. -// -// Calling Close unblocks the IMAP client decoder and lets it read the next -// responses. Next will always return nil after Close. -func (cmd *ExpungeCommand) Close() error { - for cmd.Next() != 0 { - // ignore - } - return cmd.wait() -} - -// Collect accumulates expunged sequence numbers into a list. -// -// This is equivalent to calling Next repeatedly and then Close. -func (cmd *ExpungeCommand) Collect() ([]uint32, error) { - var l []uint32 - for { - seqNum := cmd.Next() - if seqNum == 0 { - break - } - l = append(l, seqNum) - } - return l, cmd.Close() -} diff --git a/imapclient/expunge_test.go b/imapclient/expunge_test.go deleted file mode 100644 index d8eb105..0000000 --- a/imapclient/expunge_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package imapclient_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestExpunge(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - seqNums, err := client.Expunge().Collect() - if err != nil { - t.Fatalf("Expunge() = %v", err) - } else if len(seqNums) != 0 { - t.Errorf("Expunge().Collect() = %v, want []", seqNums) - } - - seqSet := imap.SeqSetNum(1) - storeFlags := imap.StoreFlags{ - Op: imap.StoreFlagsAdd, - Flags: []imap.Flag{imap.FlagDeleted}, - } - if err := client.Store(seqSet, &storeFlags, nil).Close(); err != nil { - t.Fatalf("Store() = %v", err) - } - - seqNums, err = client.Expunge().Collect() - if err != nil { - t.Fatalf("Expunge() = %v", err) - } else if len(seqNums) != 1 || seqNums[0] != 1 { - t.Errorf("Expunge().Collect() = %v, want [1]", seqNums) - } -} diff --git a/imapclient/fetch.go b/imapclient/fetch.go deleted file mode 100644 index 309825c..0000000 --- a/imapclient/fetch.go +++ /dev/null @@ -1,1326 +0,0 @@ -package imapclient - -import ( - "fmt" - "io" - netmail "net/mail" - "strings" - "time" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" - "github.com/emersion/go-message/mail" -) - -// Fetch sends a FETCH command. -// -// The caller must fully consume the FetchCommand. A simple way to do so is to -// defer a call to FetchCommand.Close. -// -// A nil options pointer is equivalent to a zero options value. -func (c *Client) Fetch(numSet imap.NumSet, options *imap.FetchOptions) *FetchCommand { - if options == nil { - options = new(imap.FetchOptions) - } - - numKind := imapwire.NumSetKind(numSet) - - cmd := &FetchCommand{ - numSet: numSet, - msgs: make(chan *FetchMessageData, 128), - } - enc := c.beginCommand(uidCmdName("FETCH", numKind), cmd) - enc.SP().NumSet(numSet).SP() - writeFetchItems(enc.Encoder, numKind, options) - if options.ChangedSince != 0 { - enc.SP().Special('(').Atom("CHANGEDSINCE").SP().ModSeq(options.ChangedSince).Special(')') - } - enc.end() - return cmd -} - -func writeFetchItems(enc *imapwire.Encoder, numKind imapwire.NumKind, options *imap.FetchOptions) { - listEnc := enc.BeginList() - - // Ensure we request UID as the first data item for UID FETCH, to be safer. - // We want to get it before any literal. - if options.UID || numKind == imapwire.NumKindUID { - listEnc.Item().Atom("UID") - } - - m := map[string]bool{ - "BODY": options.BodyStructure != nil && !options.BodyStructure.Extended, - "BODYSTRUCTURE": options.BodyStructure != nil && options.BodyStructure.Extended, - "ENVELOPE": options.Envelope, - "FLAGS": options.Flags, - "INTERNALDATE": options.InternalDate, - "RFC822.SIZE": options.RFC822Size, - "MODSEQ": options.ModSeq, - } - for k, req := range m { - if req { - listEnc.Item().Atom(k) - } - } - - for _, bs := range options.BodySection { - writeFetchItemBodySection(listEnc.Item(), bs) - } - for _, bs := range options.BinarySection { - writeFetchItemBinarySection(listEnc.Item(), bs) - } - for _, bss := range options.BinarySectionSize { - writeFetchItemBinarySectionSize(listEnc.Item(), bss) - } - - listEnc.End() -} - -func writeFetchItemBodySection(enc *imapwire.Encoder, item *imap.FetchItemBodySection) { - enc.Atom("BODY") - if item.Peek { - enc.Atom(".PEEK") - } - enc.Special('[') - writeSectionPart(enc, item.Part) - if len(item.Part) > 0 && item.Specifier != imap.PartSpecifierNone { - enc.Special('.') - } - if item.Specifier != imap.PartSpecifierNone { - enc.Atom(string(item.Specifier)) - - var headerList []string - if len(item.HeaderFields) > 0 { - headerList = item.HeaderFields - enc.Atom(".FIELDS") - } else if len(item.HeaderFieldsNot) > 0 { - headerList = item.HeaderFieldsNot - enc.Atom(".FIELDS.NOT") - } - - if len(headerList) > 0 { - enc.SP().List(len(headerList), func(i int) { - enc.String(headerList[i]) - }) - } - } - enc.Special(']') - writeSectionPartial(enc, item.Partial) -} - -func writeFetchItemBinarySection(enc *imapwire.Encoder, item *imap.FetchItemBinarySection) { - enc.Atom("BINARY") - if item.Peek { - enc.Atom(".PEEK") - } - enc.Special('[') - writeSectionPart(enc, item.Part) - enc.Special(']') - writeSectionPartial(enc, item.Partial) -} - -func writeFetchItemBinarySectionSize(enc *imapwire.Encoder, item *imap.FetchItemBinarySectionSize) { - enc.Atom("BINARY.SIZE") - enc.Special('[') - writeSectionPart(enc, item.Part) - enc.Special(']') -} - -func writeSectionPart(enc *imapwire.Encoder, part []int) { - if len(part) == 0 { - return - } - - var l []string - for _, num := range part { - l = append(l, fmt.Sprintf("%v", num)) - } - enc.Atom(strings.Join(l, ".")) -} - -func writeSectionPartial(enc *imapwire.Encoder, partial *imap.SectionPartial) { - if partial == nil { - return - } - enc.Special('<').Number64(partial.Offset).Special('.').Number64(partial.Size).Special('>') -} - -// FetchCommand is a FETCH command. -type FetchCommand struct { - commandBase - - numSet imap.NumSet - recvSeqSet imap.SeqSet - recvUIDSet imap.UIDSet - - msgs chan *FetchMessageData - prev *FetchMessageData -} - -func (cmd *FetchCommand) recvSeqNum(seqNum uint32) bool { - set, ok := cmd.numSet.(imap.SeqSet) - if !ok || !set.Contains(seqNum) { - return false - } - - if cmd.recvSeqSet.Contains(seqNum) { - return false - } - - cmd.recvSeqSet.AddNum(seqNum) - return true -} - -func (cmd *FetchCommand) recvUID(uid imap.UID) bool { - set, ok := cmd.numSet.(imap.UIDSet) - if !ok || !set.Contains(uid) { - return false - } - - if cmd.recvUIDSet.Contains(uid) { - return false - } - - cmd.recvUIDSet.AddNum(uid) - return true -} - -// Next advances to the next message. -// -// On success, the message is returned. On error or if there are no more -// messages, nil is returned. To check the error value, use Close. -func (cmd *FetchCommand) Next() *FetchMessageData { - if cmd.prev != nil { - cmd.prev.discard() - } - cmd.prev = <-cmd.msgs - return cmd.prev -} - -// Close releases the command. -// -// Calling Close unblocks the IMAP client decoder and lets it read the next -// responses. Next will always return nil after Close. -func (cmd *FetchCommand) Close() error { - for cmd.Next() != nil { - // ignore - } - return cmd.wait() -} - -// Collect accumulates message data into a list. -// -// This method will read and store message contents in memory. This is -// acceptable when the message contents have a reasonable size, but may not be -// suitable when fetching e.g. attachments. -// -// This is equivalent to calling Next repeatedly and then Close. -func (cmd *FetchCommand) Collect() ([]*FetchMessageBuffer, error) { - defer cmd.Close() - - var l []*FetchMessageBuffer - for { - msg := cmd.Next() - if msg == nil { - break - } - - buf, err := msg.Collect() - if err != nil { - return l, err - } - - l = append(l, buf) - } - return l, cmd.Close() -} - -func matchFetchItemBodySection(cmd, resp *imap.FetchItemBodySection) bool { - if cmd.Specifier != resp.Specifier { - return false - } - - if !intSliceEqual(cmd.Part, resp.Part) { - return false - } - if !stringSliceEqualFold(cmd.HeaderFields, resp.HeaderFields) { - return false - } - if !stringSliceEqualFold(cmd.HeaderFieldsNot, resp.HeaderFieldsNot) { - return false - } - - if (cmd.Partial == nil) != (resp.Partial == nil) { - return false - } - if cmd.Partial != nil && cmd.Partial.Offset != resp.Partial.Offset { - return false - } - - // Ignore Partial.Size and Peek: these are not echoed back by the server - return true -} - -func matchFetchItemBinarySection(cmd, resp *imap.FetchItemBinarySection) bool { - // Ignore Partial and Peek: these are not echoed back by the server - return intSliceEqual(cmd.Part, resp.Part) -} - -func intSliceEqual(a, b []int) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func stringSliceEqualFold(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if !strings.EqualFold(a[i], b[i]) { - return false - } - } - return true -} - -// FetchMessageData contains a message's FETCH data. -type FetchMessageData struct { - SeqNum uint32 - - items chan FetchItemData - prev FetchItemData -} - -// Next advances to the next data item for this message. -// -// If there is one or more data items left, the next item is returned. -// Otherwise nil is returned. -func (data *FetchMessageData) Next() FetchItemData { - if d, ok := data.prev.(discarder); ok { - d.discard() - } - - item := <-data.items - data.prev = item - return item -} - -func (data *FetchMessageData) discard() { - for { - if item := data.Next(); item == nil { - break - } - } -} - -// Collect accumulates message data into a struct. -// -// This method will read and store message contents in memory. This is -// acceptable when the message contents have a reasonable size, but may not be -// suitable when fetching e.g. attachments. -func (data *FetchMessageData) Collect() (*FetchMessageBuffer, error) { - defer data.discard() - - buf := &FetchMessageBuffer{SeqNum: data.SeqNum} - for { - item := data.Next() - if item == nil { - break - } - if err := buf.populateItemData(item); err != nil { - return buf, err - } - } - return buf, nil -} - -// FetchItemData contains a message's FETCH item data. -type FetchItemData interface { - fetchItemData() -} - -var ( - _ FetchItemData = FetchItemDataBodySection{} - _ FetchItemData = FetchItemDataBinarySection{} - _ FetchItemData = FetchItemDataFlags{} - _ FetchItemData = FetchItemDataEnvelope{} - _ FetchItemData = FetchItemDataInternalDate{} - _ FetchItemData = FetchItemDataRFC822Size{} - _ FetchItemData = FetchItemDataUID{} - _ FetchItemData = FetchItemDataBodyStructure{} -) - -type discarder interface { - discard() -} - -var ( - _ discarder = FetchItemDataBodySection{} - _ discarder = FetchItemDataBinarySection{} -) - -// FetchItemDataBodySection holds data returned by FETCH BODY[]. -// -// Literal might be nil. -type FetchItemDataBodySection struct { - Section *imap.FetchItemBodySection - Literal imap.LiteralReader -} - -func (FetchItemDataBodySection) fetchItemData() {} - -func (item FetchItemDataBodySection) discard() { - if item.Literal != nil { - io.Copy(io.Discard, item.Literal) - } -} - -// MatchCommand checks whether a section returned by the server in a response -// is compatible with a section requested by the client in a command. -func (dataItem *FetchItemDataBodySection) MatchCommand(item *imap.FetchItemBodySection) bool { - return matchFetchItemBodySection(item, dataItem.Section) -} - -// FetchItemDataBinarySection holds data returned by FETCH BINARY[]. -// -// Literal might be nil. -type FetchItemDataBinarySection struct { - Section *imap.FetchItemBinarySection - Literal imap.LiteralReader -} - -func (FetchItemDataBinarySection) fetchItemData() {} - -func (item FetchItemDataBinarySection) discard() { - if item.Literal != nil { - io.Copy(io.Discard, item.Literal) - } -} - -// MatchCommand checks whether a section returned by the server in a response -// is compatible with a section requested by the client in a command. -func (dataItem *FetchItemDataBinarySection) MatchCommand(item *imap.FetchItemBinarySection) bool { - return matchFetchItemBinarySection(item, dataItem.Section) -} - -// FetchItemDataFlags holds data returned by FETCH FLAGS. -type FetchItemDataFlags struct { - Flags []imap.Flag -} - -func (FetchItemDataFlags) fetchItemData() {} - -// FetchItemDataEnvelope holds data returned by FETCH ENVELOPE. -type FetchItemDataEnvelope struct { - Envelope *imap.Envelope -} - -func (FetchItemDataEnvelope) fetchItemData() {} - -// FetchItemDataInternalDate holds data returned by FETCH INTERNALDATE. -type FetchItemDataInternalDate struct { - Time time.Time -} - -func (FetchItemDataInternalDate) fetchItemData() {} - -// FetchItemDataRFC822Size holds data returned by FETCH RFC822.SIZE. -type FetchItemDataRFC822Size struct { - Size int64 -} - -func (FetchItemDataRFC822Size) fetchItemData() {} - -// FetchItemDataUID holds data returned by FETCH UID. -type FetchItemDataUID struct { - UID imap.UID -} - -func (FetchItemDataUID) fetchItemData() {} - -// FetchItemDataBodyStructure holds data returned by FETCH BODYSTRUCTURE or -// FETCH BODY. -type FetchItemDataBodyStructure struct { - BodyStructure imap.BodyStructure - IsExtended bool // True if BODYSTRUCTURE, false if BODY -} - -func (FetchItemDataBodyStructure) fetchItemData() {} - -// FetchItemDataBinarySectionSize holds data returned by FETCH BINARY.SIZE[]. -type FetchItemDataBinarySectionSize struct { - Part []int - Size uint32 -} - -func (FetchItemDataBinarySectionSize) fetchItemData() {} - -// MatchCommand checks whether a section size returned by the server in a -// response is compatible with a section size requested by the client in a -// command. -func (data *FetchItemDataBinarySectionSize) MatchCommand(item *imap.FetchItemBinarySectionSize) bool { - return intSliceEqual(item.Part, data.Part) -} - -// FetchItemDataModSeq holds data returned by FETCH MODSEQ. -// -// This requires the CONDSTORE extension. -type FetchItemDataModSeq struct { - ModSeq uint64 -} - -func (FetchItemDataModSeq) fetchItemData() {} - -// FetchBodySectionBuffer is a buffer for the data returned by -// FetchItemBodySection. -type FetchBodySectionBuffer struct { - Section *imap.FetchItemBodySection - Bytes []byte -} - -// FetchBinarySectionBuffer is a buffer for the data returned by -// FetchItemBinarySection. -type FetchBinarySectionBuffer struct { - Section *imap.FetchItemBinarySection - Bytes []byte -} - -// FetchMessageBuffer is a buffer for the data returned by FetchMessageData. -// -// The SeqNum field is always populated. All remaining fields are optional. -type FetchMessageBuffer struct { - SeqNum uint32 - Flags []imap.Flag - Envelope *imap.Envelope - InternalDate time.Time - RFC822Size int64 - UID imap.UID - BodyStructure imap.BodyStructure - BodySection []FetchBodySectionBuffer - BinarySection []FetchBinarySectionBuffer - BinarySectionSize []FetchItemDataBinarySectionSize - ModSeq uint64 // requires CONDSTORE -} - -func (buf *FetchMessageBuffer) populateItemData(item FetchItemData) error { - switch item := item.(type) { - case FetchItemDataBodySection: - var b []byte - if item.Literal != nil { - var err error - b, err = io.ReadAll(item.Literal) - if err != nil { - return err - } - } - buf.BodySection = append(buf.BodySection, FetchBodySectionBuffer{ - Section: item.Section, - Bytes: b, - }) - case FetchItemDataBinarySection: - var b []byte - if item.Literal != nil { - var err error - b, err = io.ReadAll(item.Literal) - if err != nil { - return err - } - } - buf.BinarySection = append(buf.BinarySection, FetchBinarySectionBuffer{ - Section: item.Section, - Bytes: b, - }) - case FetchItemDataFlags: - buf.Flags = item.Flags - case FetchItemDataEnvelope: - buf.Envelope = item.Envelope - case FetchItemDataInternalDate: - buf.InternalDate = item.Time - case FetchItemDataRFC822Size: - buf.RFC822Size = item.Size - case FetchItemDataUID: - buf.UID = item.UID - case FetchItemDataBodyStructure: - buf.BodyStructure = item.BodyStructure - case FetchItemDataBinarySectionSize: - buf.BinarySectionSize = append(buf.BinarySectionSize, item) - case FetchItemDataModSeq: - buf.ModSeq = item.ModSeq - default: - panic(fmt.Errorf("unsupported fetch item data %T", item)) - } - return nil -} - -// FindBodySection returns the contents of a requested body section. -// -// If the body section is not found, nil is returned. -func (buf *FetchMessageBuffer) FindBodySection(section *imap.FetchItemBodySection) []byte { - for _, s := range buf.BodySection { - if matchFetchItemBodySection(section, s.Section) { - return s.Bytes - } - } - return nil -} - -// FindBinarySection returns the contents of a requested binary section. -// -// If the binary section is not found, nil is returned. -func (buf *FetchMessageBuffer) FindBinarySection(section *imap.FetchItemBinarySection) []byte { - for _, s := range buf.BinarySection { - if matchFetchItemBinarySection(section, s.Section) { - return s.Bytes - } - } - return nil -} - -// FindBinarySectionSize returns a requested binary section size. -// -// If the binary section size is not found, false is returned. -func (buf *FetchMessageBuffer) FindBinarySectionSize(part []int) (uint32, bool) { - for _, s := range buf.BinarySectionSize { - if intSliceEqual(part, s.Part) { - return s.Size, true - } - } - return 0, false -} - -func (c *Client) handleFetch(seqNum uint32) error { - dec := c.dec - - items := make(chan FetchItemData, 32) - defer close(items) - - msg := &FetchMessageData{SeqNum: seqNum, items: items} - - // We're in a tricky situation: to know whether this FETCH response needs - // to be handled by a pending command, we may need to look at the UID in - // the response data. But the response data comes in in a streaming - // fashion: it can contain literals. Assume that the UID will be returned - // before any literal. - var uid imap.UID - handled := false - handleMsg := func() { - if handled { - return - } - - cmd := c.findPendingCmdFunc(func(anyCmd command) bool { - cmd, ok := anyCmd.(*FetchCommand) - if !ok { - return false - } - - // Skip if we haven't requested or already handled this message - if _, ok := cmd.numSet.(imap.UIDSet); ok { - return uid != 0 && cmd.recvUID(uid) - } else { - return seqNum != 0 && cmd.recvSeqNum(seqNum) - } - }) - if cmd != nil { - cmd := cmd.(*FetchCommand) - cmd.msgs <- msg - } else if handler := c.options.unilateralDataHandler().Fetch; handler != nil { - go handler(msg) - } else { - go msg.discard() - } - - handled = true - } - defer handleMsg() - - numAtts := 0 - return dec.ExpectList(func() error { - var attName string - if !dec.Expect(dec.Func(&attName, isMsgAttNameChar), "msg-att name") { - return dec.Err() - } - attName = strings.ToUpper(attName) - - var ( - item FetchItemData - done chan struct{} - ) - switch attName { - case "FLAGS": - if !dec.ExpectSP() { - return dec.Err() - } - - flags, err := internal.ExpectFlagList(dec) - if err != nil { - return err - } - - item = FetchItemDataFlags{Flags: flags} - case "ENVELOPE": - if !dec.ExpectSP() { - return dec.Err() - } - - envelope, err := readEnvelope(dec, &c.options) - if err != nil { - return fmt.Errorf("in envelope: %v", err) - } - - item = FetchItemDataEnvelope{Envelope: envelope} - case "INTERNALDATE": - if !dec.ExpectSP() { - return dec.Err() - } - - t, err := internal.ExpectDateTime(dec) - if err != nil { - return err - } - - item = FetchItemDataInternalDate{Time: t} - case "RFC822.SIZE": - var size int64 - if !dec.ExpectSP() || !dec.ExpectNumber64(&size) { - return dec.Err() - } - - item = FetchItemDataRFC822Size{Size: size} - case "UID": - if !dec.ExpectSP() || !dec.ExpectUID(&uid) { - return dec.Err() - } - - item = FetchItemDataUID{UID: uid} - case "BODY", "BINARY": - if dec.Special('[') { - var section interface{} - switch attName { - case "BODY": - var err error - section, err = readSectionSpec(dec) - if err != nil { - return fmt.Errorf("in section-spec: %v", err) - } - case "BINARY": - part, dot := readSectionPart(dec) - if dot { - return fmt.Errorf("in section-binary: expected number after dot") - } - if !dec.ExpectSpecial(']') { - return dec.Err() - } - section = &imap.FetchItemBinarySection{Part: part} - } - - if !dec.ExpectSP() { - return dec.Err() - } - - // Ignore literal8 marker, if any - if attName == "BINARY" { - dec.Special('~') - } - - lit, _, ok := dec.ExpectNStringReader() - if !ok { - return dec.Err() - } - - var fetchLit imap.LiteralReader - if lit != nil { - done = make(chan struct{}) - fetchLit = &fetchLiteralReader{ - LiteralReader: lit, - ch: done, - } - } - - switch section := section.(type) { - case *imap.FetchItemBodySection: - item = FetchItemDataBodySection{ - Section: section, - Literal: fetchLit, - } - case *imap.FetchItemBinarySection: - item = FetchItemDataBinarySection{ - Section: section, - Literal: fetchLit, - } - } - break - } - if !dec.Expect(attName == "BODY", "'['") { - return dec.Err() - } - fallthrough - case "BODYSTRUCTURE": - if !dec.ExpectSP() { - return dec.Err() - } - - bodyStruct, err := readBody(dec, &c.options) - if err != nil { - return err - } - - item = FetchItemDataBodyStructure{ - BodyStructure: bodyStruct, - IsExtended: attName == "BODYSTRUCTURE", - } - case "BINARY.SIZE": - if !dec.ExpectSpecial('[') { - return dec.Err() - } - part, dot := readSectionPart(dec) - if dot { - return fmt.Errorf("in section-binary: expected number after dot") - } - - var size uint32 - if !dec.ExpectSpecial(']') || !dec.ExpectSP() || !dec.ExpectNumber(&size) { - return dec.Err() - } - - item = FetchItemDataBinarySectionSize{ - Part: part, - Size: size, - } - case "MODSEQ": - var modSeq uint64 - if !dec.ExpectSP() || !dec.ExpectSpecial('(') || !dec.ExpectModSeq(&modSeq) || !dec.ExpectSpecial(')') { - return dec.Err() - } - item = FetchItemDataModSeq{ModSeq: modSeq} - default: - return fmt.Errorf("unsupported msg-att name: %q", attName) - } - - numAtts++ - if numAtts > cap(items) || done != nil { - // To avoid deadlocking we need to ask the message handler to - // consume the data - handleMsg() - } - - if done != nil { - c.setReadTimeout(literalReadTimeout) - } - items <- item - if done != nil { - <-done - c.setReadTimeout(respReadTimeout) - } - return nil - }) -} - -func isMsgAttNameChar(ch byte) bool { - return ch != '[' && imapwire.IsAtomChar(ch) -} - -func readEnvelope(dec *imapwire.Decoder, options *Options) (*imap.Envelope, error) { - var envelope imap.Envelope - - if !dec.ExpectSpecial('(') { - return nil, dec.Err() - } - - var date, subject string - if !dec.ExpectNString(&date) || !dec.ExpectSP() || !dec.ExpectNString(&subject) || !dec.ExpectSP() { - return nil, dec.Err() - } - // TODO: handle error - envelope.Date, _ = netmail.ParseDate(date) - envelope.Subject, _ = options.decodeText(subject) - - addrLists := []struct { - name string - out *[]imap.Address - }{ - {"env-from", &envelope.From}, - {"env-sender", &envelope.Sender}, - {"env-reply-to", &envelope.ReplyTo}, - {"env-to", &envelope.To}, - {"env-cc", &envelope.Cc}, - {"env-bcc", &envelope.Bcc}, - } - for _, addrList := range addrLists { - l, err := readAddressList(dec, options) - if err != nil { - return nil, fmt.Errorf("in %v: %v", addrList.name, err) - } else if !dec.ExpectSP() { - return nil, dec.Err() - } - *addrList.out = l - } - - var inReplyTo, messageID string - if !dec.ExpectNString(&inReplyTo) || !dec.ExpectSP() || !dec.ExpectNString(&messageID) { - return nil, dec.Err() - } - // TODO: handle errors - envelope.InReplyTo, _ = parseMsgIDList(inReplyTo) - envelope.MessageID, _ = parseMsgID(messageID) - - if !dec.ExpectSpecial(')') { - return nil, dec.Err() - } - return &envelope, nil -} - -func readAddressList(dec *imapwire.Decoder, options *Options) ([]imap.Address, error) { - var l []imap.Address - err := dec.ExpectNList(func() error { - addr, err := readAddress(dec, options) - if err != nil { - return err - } - l = append(l, *addr) - return nil - }) - return l, err -} - -func readAddress(dec *imapwire.Decoder, options *Options) (*imap.Address, error) { - var ( - addr imap.Address - name string - obsRoute string - ) - ok := dec.ExpectSpecial('(') && - dec.ExpectNString(&name) && dec.ExpectSP() && - dec.ExpectNString(&obsRoute) && dec.ExpectSP() && - dec.ExpectNString(&addr.Mailbox) && dec.ExpectSP() && - dec.ExpectNString(&addr.Host) && dec.ExpectSpecial(')') - if !ok { - return nil, fmt.Errorf("in address: %v", dec.Err()) - } - // TODO: handle error - addr.Name, _ = options.decodeText(name) - return &addr, nil -} - -func parseMsgID(s string) (string, error) { - var h mail.Header - h.Set("Message-Id", s) - return h.MessageID() -} - -func parseMsgIDList(s string) ([]string, error) { - var h mail.Header - h.Set("In-Reply-To", s) - return h.MsgIDList("In-Reply-To") -} - -func readBody(dec *imapwire.Decoder, options *Options) (imap.BodyStructure, error) { - if !dec.ExpectSpecial('(') { - return nil, dec.Err() - } - - var ( - mediaType string - token string - bs imap.BodyStructure - err error - ) - if dec.String(&mediaType) { - token = "body-type-1part" - bs, err = readBodyType1part(dec, mediaType, options) - } else { - token = "body-type-mpart" - bs, err = readBodyTypeMpart(dec, options) - } - if err != nil { - return nil, fmt.Errorf("in %v: %v", token, err) - } - - for dec.SP() { - if !dec.DiscardValue() { - return nil, dec.Err() - } - } - - if !dec.ExpectSpecial(')') { - return nil, dec.Err() - } - - return bs, nil -} - -func readBodyType1part(dec *imapwire.Decoder, typ string, options *Options) (*imap.BodyStructureSinglePart, error) { - bs := imap.BodyStructureSinglePart{Type: typ} - - if !dec.ExpectSP() || !dec.ExpectString(&bs.Subtype) || !dec.ExpectSP() { - return nil, dec.Err() - } - var err error - bs.Params, err = readBodyFldParam(dec, options) - if err != nil { - return nil, err - } - - var description string - if !dec.ExpectSP() || !dec.ExpectNString(&bs.ID) || !dec.ExpectSP() || !dec.ExpectNString(&description) || !dec.ExpectSP() || !dec.ExpectNString(&bs.Encoding) || !dec.ExpectSP() || !dec.ExpectBodyFldOctets(&bs.Size) { - return nil, dec.Err() - } - - // Content-Transfer-Encoding should always be set, but some non-standard - // servers leave it NIL. Default to 7bit. - if bs.Encoding == "" { - bs.Encoding = "7bit" - } - - // TODO: handle errors - bs.Description, _ = options.decodeText(description) - - // Some servers don't include the extra fields for message and text - // (see https://github.com/emersion/go-imap/issues/557) - hasSP := dec.SP() - if !hasSP { - return &bs, nil - } - - if strings.EqualFold(bs.Type, "message") && (strings.EqualFold(bs.Subtype, "rfc822") || strings.EqualFold(bs.Subtype, "global")) { - var msg imap.BodyStructureMessageRFC822 - - msg.Envelope, err = readEnvelope(dec, options) - if err != nil { - return nil, err - } - - if !dec.ExpectSP() { - return nil, dec.Err() - } - - msg.BodyStructure, err = readBody(dec, options) - if err != nil { - return nil, err - } - - if !dec.ExpectSP() || !dec.ExpectNumber64(&msg.NumLines) { - return nil, dec.Err() - } - - bs.MessageRFC822 = &msg - hasSP = false - } else if strings.EqualFold(bs.Type, "text") { - var text imap.BodyStructureText - - if !dec.ExpectNumber64(&text.NumLines) { - return nil, dec.Err() - } - - bs.Text = &text - hasSP = false - } - - if !hasSP { - hasSP = dec.SP() - } - if hasSP { - bs.Extended, err = readBodyExt1part(dec, options) - if err != nil { - return nil, fmt.Errorf("in body-ext-1part: %v", err) - } - } - - return &bs, nil -} - -func readBodyExt1part(dec *imapwire.Decoder, options *Options) (*imap.BodyStructureSinglePartExt, error) { - var ext imap.BodyStructureSinglePartExt - - var md5 string - if !dec.ExpectNString(&md5) { - return nil, dec.Err() - } - - if !dec.SP() { - return &ext, nil - } - - var err error - ext.Disposition, err = readBodyFldDsp(dec, options) - if err != nil { - return nil, fmt.Errorf("in body-fld-dsp: %v", err) - } - - if !dec.SP() { - return &ext, nil - } - - ext.Language, err = readBodyFldLang(dec) - if err != nil { - return nil, fmt.Errorf("in body-fld-lang: %v", err) - } - - if !dec.SP() { - return &ext, nil - } - - if !dec.ExpectNString(&ext.Location) { - return nil, dec.Err() - } - - return &ext, nil -} - -func readBodyTypeMpart(dec *imapwire.Decoder, options *Options) (*imap.BodyStructureMultiPart, error) { - var bs imap.BodyStructureMultiPart - - for { - child, err := readBody(dec, options) - if err != nil { - return nil, err - } - bs.Children = append(bs.Children, child) - - if dec.SP() && dec.String(&bs.Subtype) { - break - } - } - - if dec.SP() { - var err error - bs.Extended, err = readBodyExtMpart(dec, options) - if err != nil { - return nil, fmt.Errorf("in body-ext-mpart: %v", err) - } - } - - return &bs, nil -} - -func readBodyExtMpart(dec *imapwire.Decoder, options *Options) (*imap.BodyStructureMultiPartExt, error) { - var ext imap.BodyStructureMultiPartExt - - var err error - ext.Params, err = readBodyFldParam(dec, options) - if err != nil { - return nil, fmt.Errorf("in body-fld-param: %v", err) - } - - if !dec.SP() { - return &ext, nil - } - - ext.Disposition, err = readBodyFldDsp(dec, options) - if err != nil { - return nil, fmt.Errorf("in body-fld-dsp: %v", err) - } - - if !dec.SP() { - return &ext, nil - } - - ext.Language, err = readBodyFldLang(dec) - if err != nil { - return nil, fmt.Errorf("in body-fld-lang: %v", err) - } - - if !dec.SP() { - return &ext, nil - } - - if !dec.ExpectNString(&ext.Location) { - return nil, dec.Err() - } - - return &ext, nil -} - -func readBodyFldDsp(dec *imapwire.Decoder, options *Options) (*imap.BodyStructureDisposition, error) { - if !dec.Special('(') { - if !dec.ExpectNIL() { - return nil, dec.Err() - } - return nil, nil - } - - var disp imap.BodyStructureDisposition - if !dec.ExpectString(&disp.Value) || !dec.ExpectSP() { - return nil, dec.Err() - } - - var err error - disp.Params, err = readBodyFldParam(dec, options) - if err != nil { - return nil, err - } - if !dec.ExpectSpecial(')') { - return nil, dec.Err() - } - return &disp, nil -} - -func readBodyFldParam(dec *imapwire.Decoder, options *Options) (map[string]string, error) { - var ( - params map[string]string - k string - ) - err := dec.ExpectNList(func() error { - var s string - if !dec.ExpectString(&s) { - return dec.Err() - } - - if k == "" { - k = s - } else { - if params == nil { - params = make(map[string]string) - } - decoded, _ := options.decodeText(s) - // TODO: handle error - - params[strings.ToLower(k)] = decoded - k = "" - } - - return nil - }) - if err != nil { - return nil, err - } else if k != "" { - return nil, fmt.Errorf("in body-fld-param: key without value") - } - return params, nil -} - -func readBodyFldLang(dec *imapwire.Decoder) ([]string, error) { - var l []string - isList, err := dec.List(func() error { - var s string - if !dec.ExpectString(&s) { - return dec.Err() - } - l = append(l, s) - return nil - }) - if err != nil || isList { - return l, err - } - - var s string - if !dec.ExpectNString(&s) { - return nil, dec.Err() - } - if s != "" { - return []string{s}, nil - } else { - return nil, nil - } -} - -func readSectionSpec(dec *imapwire.Decoder) (*imap.FetchItemBodySection, error) { - var section imap.FetchItemBodySection - - var dot bool - section.Part, dot = readSectionPart(dec) - if dot || len(section.Part) == 0 { - var specifier string - if dot { - if !dec.ExpectAtom(&specifier) { - return nil, dec.Err() - } - } else { - dec.Atom(&specifier) - } - specifier = strings.ToUpper(specifier) - section.Specifier = imap.PartSpecifier(specifier) - - if specifier == "HEADER.FIELDS" || specifier == "HEADER.FIELDS.NOT" { - if !dec.ExpectSP() { - return nil, dec.Err() - } - var err error - headerList, err := readHeaderList(dec) - if err != nil { - return nil, err - } - section.Specifier = imap.PartSpecifierHeader - if specifier == "HEADER.FIELDS" { - section.HeaderFields = headerList - } else { - section.HeaderFieldsNot = headerList - } - } - } - - if !dec.ExpectSpecial(']') { - return nil, dec.Err() - } - - offset, err := readPartialOffset(dec) - if err != nil { - return nil, err - } - if offset != nil { - section.Partial = &imap.SectionPartial{Offset: int64(*offset)} - } - - return §ion, nil -} - -func readPartialOffset(dec *imapwire.Decoder) (*uint32, error) { - if !dec.Special('<') { - return nil, nil - } - var offset uint32 - if !dec.ExpectNumber(&offset) || !dec.ExpectSpecial('>') { - return nil, dec.Err() - } - return &offset, nil -} - -func readHeaderList(dec *imapwire.Decoder) ([]string, error) { - var l []string - err := dec.ExpectList(func() error { - var s string - if !dec.ExpectAString(&s) { - return dec.Err() - } - l = append(l, s) - return nil - }) - return l, err -} - -func readSectionPart(dec *imapwire.Decoder) (part []int, dot bool) { - for { - dot = len(part) > 0 - if dot && !dec.Special('.') { - return part, false - } - - var num uint32 - if !dec.Number(&num) { - return part, dot - } - part = append(part, int(num)) - } -} - -type fetchLiteralReader struct { - *imapwire.LiteralReader - ch chan<- struct{} -} - -func (lit *fetchLiteralReader) Read(b []byte) (int, error) { - n, err := lit.LiteralReader.Read(b) - if err != nil && lit.ch != nil { - close(lit.ch) - lit.ch = nil - } - return n, err -} diff --git a/imapclient/fetch_test.go b/imapclient/fetch_test.go deleted file mode 100644 index abd1680..0000000 --- a/imapclient/fetch_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package imapclient_test - -import ( - "strings" - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestFetch(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - seqSet := imap.SeqSetNum(1) - bodySection := &imap.FetchItemBodySection{} - fetchOptions := &imap.FetchOptions{ - BodySection: []*imap.FetchItemBodySection{bodySection}, - } - messages, err := client.Fetch(seqSet, fetchOptions).Collect() - if err != nil { - t.Fatalf("failed to fetch first message: %v", err) - } else if len(messages) != 1 { - t.Fatalf("len(messages) = %v, want 1", len(messages)) - } - - msg := messages[0] - if len(msg.BodySection) != 1 { - t.Fatalf("len(msg.BodySection) = %v, want 1", len(msg.BodySection)) - } - b := msg.FindBodySection(bodySection) - if b == nil { - t.Fatalf("FindBodySection() = nil") - } - body := strings.ReplaceAll(string(b), "\r\n", "\n") - if body != simpleRawMessage { - t.Errorf("body mismatch: got \n%v\n but want \n%v", body, simpleRawMessage) - } -} diff --git a/imapclient/id.go b/imapclient/id.go deleted file mode 100644 index 0c10d60..0000000 --- a/imapclient/id.go +++ /dev/null @@ -1,163 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// ID sends an ID command. -// -// The ID command is introduced in RFC 2971. It requires support for the ID -// extension. -// -// An example ID command: -// -// ID ("name" "go-imap" "version" "1.0" "os" "Linux" "os-version" "7.9.4" "vendor" "Yahoo") -func (c *Client) ID(idData *imap.IDData) *IDCommand { - cmd := &IDCommand{} - enc := c.beginCommand("ID", cmd) - - if idData == nil { - enc.SP().NIL() - enc.end() - return cmd - } - - enc.SP().Special('(') - isFirstKey := true - if idData.Name != "" { - addIDKeyValue(enc, &isFirstKey, "name", idData.Name) - } - if idData.Version != "" { - addIDKeyValue(enc, &isFirstKey, "version", idData.Version) - } - if idData.OS != "" { - addIDKeyValue(enc, &isFirstKey, "os", idData.OS) - } - if idData.OSVersion != "" { - addIDKeyValue(enc, &isFirstKey, "os-version", idData.OSVersion) - } - if idData.Vendor != "" { - addIDKeyValue(enc, &isFirstKey, "vendor", idData.Vendor) - } - if idData.SupportURL != "" { - addIDKeyValue(enc, &isFirstKey, "support-url", idData.SupportURL) - } - if idData.Address != "" { - addIDKeyValue(enc, &isFirstKey, "address", idData.Address) - } - if idData.Date != "" { - addIDKeyValue(enc, &isFirstKey, "date", idData.Date) - } - if idData.Command != "" { - addIDKeyValue(enc, &isFirstKey, "command", idData.Command) - } - if idData.Arguments != "" { - addIDKeyValue(enc, &isFirstKey, "arguments", idData.Arguments) - } - if idData.Environment != "" { - addIDKeyValue(enc, &isFirstKey, "environment", idData.Environment) - } - - enc.Special(')') - enc.end() - return cmd -} - -func addIDKeyValue(enc *commandEncoder, isFirstKey *bool, key, value string) { - if isFirstKey == nil { - panic("isFirstKey cannot be nil") - } else if !*isFirstKey { - enc.SP().Quoted(key).SP().Quoted(value) - } else { - enc.Quoted(key).SP().Quoted(value) - } - *isFirstKey = false -} - -func (c *Client) handleID() error { - data, err := c.readID(c.dec) - if err != nil { - return fmt.Errorf("in id: %v", err) - } - - if cmd := findPendingCmdByType[*IDCommand](c); cmd != nil { - cmd.data = *data - } - - return nil -} - -func (c *Client) readID(dec *imapwire.Decoder) (*imap.IDData, error) { - var data = imap.IDData{} - - if !dec.ExpectSP() { - return nil, dec.Err() - } - - if dec.ExpectNIL() { - return &data, nil - } - - currKey := "" - err := dec.ExpectList(func() error { - var keyOrValue string - if !dec.String(&keyOrValue) { - return fmt.Errorf("in id key-val list: %v", dec.Err()) - } - - if currKey == "" { - currKey = keyOrValue - return nil - } - - switch currKey { - case "name": - data.Name = keyOrValue - case "version": - data.Version = keyOrValue - case "os": - data.OS = keyOrValue - case "os-version": - data.OSVersion = keyOrValue - case "vendor": - data.Vendor = keyOrValue - case "support-url": - data.SupportURL = keyOrValue - case "address": - data.Address = keyOrValue - case "date": - data.Date = keyOrValue - case "command": - data.Command = keyOrValue - case "arguments": - data.Arguments = keyOrValue - case "environment": - data.Environment = keyOrValue - default: - // Ignore unknown key - // Yahoo server sends "host" and "remote-host" keys - // which are not defined in RFC 2971 - } - currKey = "" - - return nil - }) - - if err != nil { - return nil, err - } - - return &data, nil -} - -type IDCommand struct { - commandBase - data imap.IDData -} - -func (r *IDCommand) Wait() (*imap.IDData, error) { - return &r.data, r.wait() -} diff --git a/imapclient/idle.go b/imapclient/idle.go deleted file mode 100644 index 1613bff..0000000 --- a/imapclient/idle.go +++ /dev/null @@ -1,157 +0,0 @@ -package imapclient - -import ( - "fmt" - "sync/atomic" - "time" -) - -const idleRestartInterval = 28 * time.Minute - -// Idle sends an IDLE command. -// -// Unlike other commands, this method blocks until the server acknowledges it. -// On success, the IDLE command is running and other commands cannot be sent. -// The caller must invoke IdleCommand.Close to stop IDLE and unblock the -// client. -// -// This command requires support for IMAP4rev2 or the IDLE extension. The IDLE -// command is restarted automatically to avoid getting disconnected due to -// inactivity timeouts. -func (c *Client) Idle() (*IdleCommand, error) { - child, err := c.idle() - if err != nil { - return nil, err - } - - cmd := &IdleCommand{ - stop: make(chan struct{}), - done: make(chan struct{}), - } - go cmd.run(c, child) - return cmd, nil -} - -// IdleCommand is an IDLE command. -// -// Initially, the IDLE command is running. The server may send unilateral -// data. The client cannot send any command while IDLE is running. -// -// Close must be called to stop the IDLE command. -type IdleCommand struct { - stopped atomic.Bool - stop chan struct{} - done chan struct{} - - err error - lastChild *idleCommand -} - -func (cmd *IdleCommand) run(c *Client, child *idleCommand) { - defer close(cmd.done) - - timer := time.NewTimer(idleRestartInterval) - defer timer.Stop() - - defer func() { - if child != nil { - if err := child.Close(); err != nil && cmd.err == nil { - cmd.err = err - } - } - }() - - for { - select { - case <-timer.C: - timer.Reset(idleRestartInterval) - - if cmd.err = child.Close(); cmd.err != nil { - return - } - if child, cmd.err = c.idle(); cmd.err != nil { - return - } - case <-c.decCh: - cmd.lastChild = child - return - case <-cmd.stop: - cmd.lastChild = child - return - } - } -} - -// Close stops the IDLE command. -// -// This method blocks until the command to stop IDLE is written, but doesn't -// wait for the server to respond. Callers can use Wait for this purpose. -func (cmd *IdleCommand) Close() error { - if cmd.stopped.Swap(true) { - return fmt.Errorf("imapclient: IDLE already closed") - } - close(cmd.stop) - <-cmd.done - return cmd.err -} - -// Wait blocks until the IDLE command has completed. -func (cmd *IdleCommand) Wait() error { - <-cmd.done - if cmd.err != nil { - return cmd.err - } - return cmd.lastChild.Wait() -} - -func (c *Client) idle() (*idleCommand, error) { - cmd := &idleCommand{} - contReq := c.registerContReq(cmd) - cmd.enc = c.beginCommand("IDLE", cmd) - cmd.enc.flush() - - _, err := contReq.Wait() - if err != nil { - cmd.enc.end() - return nil, err - } - - return cmd, nil -} - -// idleCommand represents a singular IDLE command, without the restart logic. -type idleCommand struct { - commandBase - enc *commandEncoder -} - -// Close stops the IDLE command. -// -// This method blocks until the command to stop IDLE is written, but doesn't -// wait for the server to respond. Callers can use Wait for this purpose. -func (cmd *idleCommand) Close() error { - if cmd.err != nil { - return cmd.err - } - if cmd.enc == nil { - return fmt.Errorf("imapclient: IDLE command closed twice") - } - cmd.enc.client.setWriteTimeout(cmdWriteTimeout) - _, err := cmd.enc.client.bw.WriteString("DONE\r\n") - if err == nil { - err = cmd.enc.client.bw.Flush() - } - cmd.enc.end() - cmd.enc = nil - return err -} - -// Wait blocks until the IDLE command has completed. -// -// Wait can only be called after Close. -func (cmd *idleCommand) Wait() error { - if cmd.enc != nil { - panic("imapclient: idleCommand.Close must be called before Wait") - } - return cmd.wait() -} diff --git a/imapclient/idle_test.go b/imapclient/idle_test.go deleted file mode 100644 index ce8379c..0000000 --- a/imapclient/idle_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package imapclient_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestIdle(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - idleCmd, err := client.Idle() - if err != nil { - t.Fatalf("Idle() = %v", err) - } - // TODO: test unilateral updates - if err := idleCmd.Close(); err != nil { - t.Errorf("Close() = %v", err) - } -} - -func TestIdle_closedConn(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - idleCmd, err := client.Idle() - if err != nil { - t.Fatalf("Idle() = %v", err) - } - defer idleCmd.Close() - - if err := client.Close(); err != nil { - t.Fatalf("client.Close() = %v", err) - } - - if err := idleCmd.Wait(); err == nil { - t.Errorf("IdleCommand.Wait() = nil, want an error") - } -} diff --git a/imapclient/list.go b/imapclient/list.go deleted file mode 100644 index 2c0ce16..0000000 --- a/imapclient/list.go +++ /dev/null @@ -1,259 +0,0 @@ -package imapclient - -import ( - "fmt" - "strings" - "unicode/utf8" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func getSelectOpts(options *imap.ListOptions) []string { - if options == nil { - return nil - } - - var l []string - if options.SelectSubscribed { - l = append(l, "SUBSCRIBED") - } - if options.SelectRemote { - l = append(l, "REMOTE") - } - if options.SelectRecursiveMatch { - l = append(l, "RECURSIVEMATCH") - } - if options.SelectSpecialUse { - l = append(l, "SPECIAL-USE") - } - return l -} - -func getReturnOpts(options *imap.ListOptions) []string { - if options == nil { - return nil - } - - var l []string - if options.ReturnSubscribed { - l = append(l, "SUBSCRIBED") - } - if options.ReturnChildren { - l = append(l, "CHILDREN") - } - if options.ReturnStatus != nil { - l = append(l, "STATUS") - } - if options.ReturnSpecialUse { - l = append(l, "SPECIAL-USE") - } - return l -} - -// List sends a LIST command. -// -// The caller must fully consume the ListCommand. A simple way to do so is to -// defer a call to ListCommand.Close. -// -// A nil options pointer is equivalent to a zero options value. -// -// A non-zero options value requires support for IMAP4rev2 or the LIST-EXTENDED -// extension. -func (c *Client) List(ref, pattern string, options *imap.ListOptions) *ListCommand { - cmd := &ListCommand{ - mailboxes: make(chan *imap.ListData, 64), - returnStatus: options != nil && options.ReturnStatus != nil, - } - enc := c.beginCommand("LIST", cmd) - if selectOpts := getSelectOpts(options); len(selectOpts) > 0 { - enc.SP().List(len(selectOpts), func(i int) { - enc.Atom(selectOpts[i]) - }) - } - enc.SP().Mailbox(ref).SP().Mailbox(pattern) - if returnOpts := getReturnOpts(options); len(returnOpts) > 0 { - enc.SP().Atom("RETURN").SP().List(len(returnOpts), func(i int) { - opt := returnOpts[i] - enc.Atom(opt) - if opt == "STATUS" { - returnStatus := statusItems(options.ReturnStatus) - enc.SP().List(len(returnStatus), func(j int) { - enc.Atom(returnStatus[j]) - }) - } - }) - } - enc.end() - return cmd -} - -func (c *Client) handleList() error { - data, err := readList(c.dec) - if err != nil { - return fmt.Errorf("in LIST: %v", err) - } - - cmd := c.findPendingCmdFunc(func(cmd command) bool { - switch cmd := cmd.(type) { - case *ListCommand: - return true // TODO: match pattern, check if already handled - case *SelectCommand: - return cmd.mailbox == data.Mailbox && cmd.data.List == nil - default: - return false - } - }) - switch cmd := cmd.(type) { - case *ListCommand: - if cmd.returnStatus { - if cmd.pendingData != nil { - cmd.mailboxes <- cmd.pendingData - } - cmd.pendingData = data - } else { - cmd.mailboxes <- data - } - case *SelectCommand: - cmd.data.List = data - } - - return nil -} - -// ListCommand is a LIST command. -type ListCommand struct { - commandBase - mailboxes chan *imap.ListData - - returnStatus bool - pendingData *imap.ListData -} - -// Next advances to the next mailbox. -// -// On success, the mailbox LIST data is returned. On error or if there are no -// more mailboxes, nil is returned. -func (cmd *ListCommand) Next() *imap.ListData { - return <-cmd.mailboxes -} - -// Close releases the command. -// -// Calling Close unblocks the IMAP client decoder and lets it read the next -// responses. Next will always return nil after Close. -func (cmd *ListCommand) Close() error { - for cmd.Next() != nil { - // ignore - } - return cmd.wait() -} - -// Collect accumulates mailboxes into a list. -// -// This is equivalent to calling Next repeatedly and then Close. -func (cmd *ListCommand) Collect() ([]*imap.ListData, error) { - var l []*imap.ListData - for { - data := cmd.Next() - if data == nil { - break - } - l = append(l, data) - } - return l, cmd.Close() -} - -func readList(dec *imapwire.Decoder) (*imap.ListData, error) { - var data imap.ListData - - var err error - data.Attrs, err = internal.ExpectMailboxAttrList(dec) - if err != nil { - return nil, fmt.Errorf("in mbx-list-flags: %w", err) - } - - if !dec.ExpectSP() { - return nil, dec.Err() - } - - data.Delim, err = readDelim(dec) - if err != nil { - return nil, err - } - - if !dec.ExpectSP() || !dec.ExpectMailbox(&data.Mailbox) { - return nil, dec.Err() - } - - if dec.SP() { - err := dec.ExpectList(func() error { - var tag string - if !dec.ExpectAString(&tag) || !dec.ExpectSP() { - return dec.Err() - } - var err error - switch strings.ToUpper(tag) { - case "CHILDINFO": - data.ChildInfo, err = readChildInfoExtendedItem(dec) - if err != nil { - return fmt.Errorf("in childinfo-extended-item: %v", err) - } - case "OLDNAME": - data.OldName, err = readOldNameExtendedItem(dec) - if err != nil { - return fmt.Errorf("in oldname-extended-item: %v", err) - } - default: - if !dec.DiscardValue() { - return fmt.Errorf("in tagged-ext-val: %v", err) - } - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("in mbox-list-extended: %v", err) - } - } - - return &data, nil -} - -func readChildInfoExtendedItem(dec *imapwire.Decoder) (*imap.ListDataChildInfo, error) { - var childInfo imap.ListDataChildInfo - err := dec.ExpectList(func() error { - var opt string - if !dec.ExpectAString(&opt) { - return dec.Err() - } - if strings.ToUpper(opt) == "SUBSCRIBED" { - childInfo.Subscribed = true - } - return nil - }) - return &childInfo, err -} - -func readOldNameExtendedItem(dec *imapwire.Decoder) (string, error) { - var name string - if !dec.ExpectSpecial('(') || !dec.ExpectMailbox(&name) || !dec.ExpectSpecial(')') { - return "", dec.Err() - } - return name, nil -} - -func readDelim(dec *imapwire.Decoder) (rune, error) { - var delimStr string - if dec.Quoted(&delimStr) { - delim, size := utf8.DecodeRuneInString(delimStr) - if delim == utf8.RuneError || size != len(delimStr) { - return 0, fmt.Errorf("mailbox delimiter must be a single rune") - } - return delim, nil - } else if !dec.ExpectNIL() { - return 0, dec.Err() - } else { - return 0, nil - } -} diff --git a/imapclient/list_test.go b/imapclient/list_test.go deleted file mode 100644 index 1eaa4f1..0000000 --- a/imapclient/list_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package imapclient_test - -import ( - "reflect" - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestList(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateAuthenticated) - defer client.Close() - defer server.Close() - - options := imap.ListOptions{ - ReturnStatus: &imap.StatusOptions{ - NumMessages: true, - }, - } - mailboxes, err := client.List("", "%", &options).Collect() - if err != nil { - t.Fatalf("List() = %v", err) - } - - if len(mailboxes) != 1 { - t.Fatalf("List() returned %v mailboxes, want 1", len(mailboxes)) - } - mbox := mailboxes[0] - - wantNumMessages := uint32(1) - want := &imap.ListData{ - Delim: '/', - Mailbox: "INBOX", - Status: &imap.StatusData{ - Mailbox: "INBOX", - NumMessages: &wantNumMessages, - }, - } - if !reflect.DeepEqual(mbox, want) { - t.Errorf("got %#v but want %#v", mbox, want) - } -} diff --git a/imapclient/metadata.go b/imapclient/metadata.go deleted file mode 100644 index c8a0e72..0000000 --- a/imapclient/metadata.go +++ /dev/null @@ -1,205 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -type GetMetadataDepth int - -const ( - GetMetadataDepthZero GetMetadataDepth = 0 - GetMetadataDepthOne GetMetadataDepth = 1 - GetMetadataDepthInfinity GetMetadataDepth = -1 -) - -func (depth GetMetadataDepth) String() string { - switch depth { - case GetMetadataDepthZero: - return "0" - case GetMetadataDepthOne: - return "1" - case GetMetadataDepthInfinity: - return "infinity" - default: - panic(fmt.Errorf("imapclient: unknown GETMETADATA depth %d", depth)) - } -} - -// GetMetadataOptions contains options for the GETMETADATA command. -type GetMetadataOptions struct { - MaxSize *uint32 - Depth GetMetadataDepth -} - -func (options *GetMetadataOptions) names() []string { - if options == nil { - return nil - } - var l []string - if options.MaxSize != nil { - l = append(l, "MAXSIZE") - } - if options.Depth != GetMetadataDepthZero { - l = append(l, "DEPTH") - } - return l -} - -// GetMetadata sends a GETMETADATA command. -// -// This command requires support for the METADATA or METADATA-SERVER extension. -func (c *Client) GetMetadata(mailbox string, entries []string, options *GetMetadataOptions) *GetMetadataCommand { - cmd := &GetMetadataCommand{mailbox: mailbox} - enc := c.beginCommand("GETMETADATA", cmd) - enc.SP().Mailbox(mailbox) - if opts := options.names(); len(opts) > 0 { - enc.SP().List(len(opts), func(i int) { - opt := opts[i] - enc.Atom(opt).SP() - switch opt { - case "MAXSIZE": - enc.Number(*options.MaxSize) - case "DEPTH": - enc.Atom(options.Depth.String()) - default: - panic(fmt.Errorf("imapclient: unknown GETMETADATA option %q", opt)) - } - }) - } - enc.SP().List(len(entries), func(i int) { - enc.String(entries[i]) - }) - enc.end() - return cmd -} - -// SetMetadata sends a SETMETADATA command. -// -// To remove an entry, set it to nil. -// -// This command requires support for the METADATA or METADATA-SERVER extension. -func (c *Client) SetMetadata(mailbox string, entries map[string]*[]byte) *Command { - cmd := &Command{} - enc := c.beginCommand("SETMETADATA", cmd) - enc.SP().Mailbox(mailbox).SP().Special('(') - i := 0 - for k, v := range entries { - if i > 0 { - enc.SP() - } - enc.String(k).SP() - if v == nil { - enc.NIL() - } else { - enc.String(string(*v)) // TODO: use literals if required - } - i++ - } - enc.Special(')') - enc.end() - return cmd -} - -func (c *Client) handleMetadata() error { - data, err := readMetadataResp(c.dec) - if err != nil { - return fmt.Errorf("in metadata-resp: %v", err) - } - - cmd := c.findPendingCmdFunc(func(anyCmd command) bool { - cmd, ok := anyCmd.(*GetMetadataCommand) - return ok && cmd.mailbox == data.Mailbox - }) - if cmd != nil && len(data.EntryValues) > 0 { - cmd := cmd.(*GetMetadataCommand) - cmd.data.Mailbox = data.Mailbox - if cmd.data.Entries == nil { - cmd.data.Entries = make(map[string]*[]byte) - } - // The server might send multiple METADATA responses for a single - // METADATA command - for k, v := range data.EntryValues { - cmd.data.Entries[k] = v - } - } else if handler := c.options.unilateralDataHandler().Metadata; handler != nil && len(data.EntryList) > 0 { - handler(data.Mailbox, data.EntryList) - } - - return nil -} - -// GetMetadataCommand is a GETMETADATA command. -type GetMetadataCommand struct { - commandBase - mailbox string - data GetMetadataData -} - -func (cmd *GetMetadataCommand) Wait() (*GetMetadataData, error) { - return &cmd.data, cmd.wait() -} - -// GetMetadataData is the data returned by the GETMETADATA command. -type GetMetadataData struct { - Mailbox string - Entries map[string]*[]byte -} - -type metadataResp struct { - Mailbox string - EntryList []string - EntryValues map[string]*[]byte -} - -func readMetadataResp(dec *imapwire.Decoder) (*metadataResp, error) { - var data metadataResp - - if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() { - return nil, dec.Err() - } - - isList, err := dec.List(func() error { - var name string - if !dec.ExpectAString(&name) || !dec.ExpectSP() { - return dec.Err() - } - - // TODO: decode as []byte - var ( - value *[]byte - s string - ) - if dec.String(&s) || dec.Literal(&s) { - b := []byte(s) - value = &b - } else if !dec.ExpectNIL() { - return dec.Err() - } - - if data.EntryValues == nil { - data.EntryValues = make(map[string]*[]byte) - } - data.EntryValues[name] = value - return nil - }) - if err != nil { - return nil, err - } else if !isList { - var name string - if !dec.ExpectAString(&name) { - return nil, dec.Err() - } - data.EntryList = append(data.EntryList, name) - - for dec.SP() { - if !dec.ExpectAString(&name) { - return nil, dec.Err() - } - data.EntryList = append(data.EntryList, name) - } - } - - return &data, nil -} diff --git a/imapclient/move.go b/imapclient/move.go deleted file mode 100644 index 6fa0b62..0000000 --- a/imapclient/move.go +++ /dev/null @@ -1,74 +0,0 @@ -package imapclient - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// Move sends a MOVE command. -// -// If the server doesn't support IMAP4rev2 nor the MOVE extension, a fallback -// with COPY + STORE + EXPUNGE commands is used. -func (c *Client) Move(numSet imap.NumSet, mailbox string) *MoveCommand { - // If the server doesn't support MOVE, fallback to [UID] COPY, - // [UID] STORE +FLAGS.SILENT \Deleted and [UID] EXPUNGE - cmdName := "MOVE" - if !c.Caps().Has(imap.CapMove) { - cmdName = "COPY" - } - - cmd := &MoveCommand{} - enc := c.beginCommand(uidCmdName(cmdName, imapwire.NumSetKind(numSet)), cmd) - enc.SP().NumSet(numSet).SP().Mailbox(mailbox) - enc.end() - - if cmdName == "COPY" { - cmd.store = c.Store(numSet, &imap.StoreFlags{ - Op: imap.StoreFlagsAdd, - Silent: true, - Flags: []imap.Flag{imap.FlagDeleted}, - }, nil) - if uidSet, ok := numSet.(imap.UIDSet); ok && c.Caps().Has(imap.CapUIDPlus) { - cmd.expunge = c.UIDExpunge(uidSet) - } else { - cmd.expunge = c.Expunge() - } - } - - return cmd -} - -// MoveCommand is a MOVE command. -type MoveCommand struct { - commandBase - data MoveData - - // Fallback - store *FetchCommand - expunge *ExpungeCommand -} - -func (cmd *MoveCommand) Wait() (*MoveData, error) { - if err := cmd.wait(); err != nil { - return nil, err - } - if cmd.store != nil { - if err := cmd.store.Close(); err != nil { - return nil, err - } - } - if cmd.expunge != nil { - if err := cmd.expunge.Close(); err != nil { - return nil, err - } - } - return &cmd.data, nil -} - -// MoveData contains the data returned by a MOVE command. -type MoveData struct { - // requires UIDPLUS or IMAP4rev2 - UIDValidity uint32 - SourceUIDs imap.NumSet - DestUIDs imap.NumSet -} diff --git a/imapclient/namespace.go b/imapclient/namespace.go deleted file mode 100644 index 8c4738e..0000000 --- a/imapclient/namespace.go +++ /dev/null @@ -1,110 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// Namespace sends a NAMESPACE command. -// -// This command requires support for IMAP4rev2 or the NAMESPACE extension. -func (c *Client) Namespace() *NamespaceCommand { - cmd := &NamespaceCommand{} - c.beginCommand("NAMESPACE", cmd).end() - return cmd -} - -func (c *Client) handleNamespace() error { - data, err := readNamespaceResponse(c.dec) - if err != nil { - return fmt.Errorf("in namespace-response: %v", err) - } - if cmd := findPendingCmdByType[*NamespaceCommand](c); cmd != nil { - cmd.data = *data - } - return nil -} - -// NamespaceCommand is a NAMESPACE command. -type NamespaceCommand struct { - commandBase - data imap.NamespaceData -} - -func (cmd *NamespaceCommand) Wait() (*imap.NamespaceData, error) { - return &cmd.data, cmd.wait() -} - -func readNamespaceResponse(dec *imapwire.Decoder) (*imap.NamespaceData, error) { - var ( - data imap.NamespaceData - err error - ) - - data.Personal, err = readNamespace(dec) - if err != nil { - return nil, err - } - - if !dec.ExpectSP() { - return nil, dec.Err() - } - - data.Other, err = readNamespace(dec) - if err != nil { - return nil, err - } - - if !dec.ExpectSP() { - return nil, dec.Err() - } - - data.Shared, err = readNamespace(dec) - if err != nil { - return nil, err - } - - return &data, nil -} - -func readNamespace(dec *imapwire.Decoder) ([]imap.NamespaceDescriptor, error) { - var l []imap.NamespaceDescriptor - err := dec.ExpectNList(func() error { - descr, err := readNamespaceDescr(dec) - if err != nil { - return fmt.Errorf("in namespace-descr: %v", err) - } - l = append(l, *descr) - return nil - }) - return l, err -} - -func readNamespaceDescr(dec *imapwire.Decoder) (*imap.NamespaceDescriptor, error) { - var descr imap.NamespaceDescriptor - - if !dec.ExpectSpecial('(') || !dec.ExpectString(&descr.Prefix) || !dec.ExpectSP() { - return nil, dec.Err() - } - - var err error - descr.Delim, err = readDelim(dec) - if err != nil { - return nil, err - } - - // Skip namespace-response-extensions - for dec.SP() { - if !dec.DiscardValue() { - return nil, dec.Err() - } - } - - if !dec.ExpectSpecial(')') { - return nil, dec.Err() - } - - return &descr, nil -} diff --git a/imapclient/quota.go b/imapclient/quota.go deleted file mode 100644 index 6775b9f..0000000 --- a/imapclient/quota.go +++ /dev/null @@ -1,176 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// GetQuota sends a GETQUOTA command. -// -// This command requires support for the QUOTA extension. -func (c *Client) GetQuota(root string) *GetQuotaCommand { - cmd := &GetQuotaCommand{root: root} - enc := c.beginCommand("GETQUOTA", cmd) - enc.SP().String(root) - enc.end() - return cmd -} - -// GetQuotaRoot sends a GETQUOTAROOT command. -// -// This command requires support for the QUOTA extension. -func (c *Client) GetQuotaRoot(mailbox string) *GetQuotaRootCommand { - cmd := &GetQuotaRootCommand{mailbox: mailbox} - enc := c.beginCommand("GETQUOTAROOT", cmd) - enc.SP().Mailbox(mailbox) - enc.end() - return cmd -} - -// SetQuota sends a SETQUOTA command. -// -// This command requires support for the SETQUOTA extension. -func (c *Client) SetQuota(root string, limits map[imap.QuotaResourceType]int64) *Command { - // TODO: consider returning the QUOTA response data? - cmd := &Command{} - enc := c.beginCommand("SETQUOTA", cmd) - enc.SP().String(root).SP().Special('(') - i := 0 - for typ, limit := range limits { - if i > 0 { - enc.SP() - } - enc.Atom(string(typ)).SP().Number64(limit) - i++ - } - enc.Special(')') - enc.end() - return cmd -} - -func (c *Client) handleQuota() error { - data, err := readQuotaResponse(c.dec) - if err != nil { - return fmt.Errorf("in quota-response: %v", err) - } - - cmd := c.findPendingCmdFunc(func(cmd command) bool { - switch cmd := cmd.(type) { - case *GetQuotaCommand: - return cmd.root == data.Root - case *GetQuotaRootCommand: - for _, root := range cmd.roots { - if root == data.Root { - return true - } - } - return false - default: - return false - } - }) - switch cmd := cmd.(type) { - case *GetQuotaCommand: - cmd.data = data - case *GetQuotaRootCommand: - cmd.data = append(cmd.data, *data) - } - return nil -} - -func (c *Client) handleQuotaRoot() error { - mailbox, roots, err := readQuotaRoot(c.dec) - if err != nil { - return fmt.Errorf("in quotaroot-response: %v", err) - } - - cmd := c.findPendingCmdFunc(func(anyCmd command) bool { - cmd, ok := anyCmd.(*GetQuotaRootCommand) - if !ok { - return false - } - return cmd.mailbox == mailbox - }) - if cmd != nil { - cmd := cmd.(*GetQuotaRootCommand) - cmd.roots = roots - } - return nil -} - -// GetQuotaCommand is a GETQUOTA command. -type GetQuotaCommand struct { - commandBase - root string - data *QuotaData -} - -func (cmd *GetQuotaCommand) Wait() (*QuotaData, error) { - if err := cmd.wait(); err != nil { - return nil, err - } - return cmd.data, nil -} - -// GetQuotaRootCommand is a GETQUOTAROOT command. -type GetQuotaRootCommand struct { - commandBase - mailbox string - roots []string - data []QuotaData -} - -func (cmd *GetQuotaRootCommand) Wait() ([]QuotaData, error) { - if err := cmd.wait(); err != nil { - return nil, err - } - return cmd.data, nil -} - -// QuotaData is the data returned by a QUOTA response. -type QuotaData struct { - Root string - Resources map[imap.QuotaResourceType]QuotaResourceData -} - -// QuotaResourceData contains the usage and limit for a quota resource. -type QuotaResourceData struct { - Usage int64 - Limit int64 -} - -func readQuotaResponse(dec *imapwire.Decoder) (*QuotaData, error) { - var data QuotaData - if !dec.ExpectAString(&data.Root) || !dec.ExpectSP() { - return nil, dec.Err() - } - data.Resources = make(map[imap.QuotaResourceType]QuotaResourceData) - err := dec.ExpectList(func() error { - var ( - name string - resData QuotaResourceData - ) - if !dec.ExpectAtom(&name) || !dec.ExpectSP() || !dec.ExpectNumber64(&resData.Usage) || !dec.ExpectSP() || !dec.ExpectNumber64(&resData.Limit) { - return fmt.Errorf("in quota-resource: %v", dec.Err()) - } - data.Resources[imap.QuotaResourceType(name)] = resData - return nil - }) - return &data, err -} - -func readQuotaRoot(dec *imapwire.Decoder) (mailbox string, roots []string, err error) { - if !dec.ExpectMailbox(&mailbox) { - return "", nil, dec.Err() - } - for dec.SP() { - var root string - if !dec.ExpectAString(&root) { - return "", nil, dec.Err() - } - roots = append(roots, root) - } - return mailbox, roots, nil -} diff --git a/imapclient/search.go b/imapclient/search.go deleted file mode 100644 index 17ac116..0000000 --- a/imapclient/search.go +++ /dev/null @@ -1,401 +0,0 @@ -package imapclient - -import ( - "fmt" - "strings" - "time" - "unicode" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func returnSearchOptions(options *imap.SearchOptions) []string { - if options == nil { - return nil - } - - m := map[string]bool{ - "MIN": options.ReturnMin, - "MAX": options.ReturnMax, - "ALL": options.ReturnAll, - "COUNT": options.ReturnCount, - } - - var l []string - for k, ret := range m { - if ret { - l = append(l, k) - } - } - return l -} - -func (c *Client) search(numKind imapwire.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand { - // The IMAP4rev2 SEARCH charset defaults to UTF-8. When UTF8=ACCEPT is - // enabled, specifying any CHARSET is invalid. For IMAP4rev1 the default is - // undefined and only US-ASCII support is required. What's more, some - // servers completely reject the CHARSET keyword. So, let's check if we - // actually have UTF-8 strings in the search criteria before using that. - // TODO: there might be a benefit in specifying CHARSET UTF-8 for IMAP4rev1 - // servers even if we only send ASCII characters: the server then must - // decode encoded headers and Content-Transfer-Encoding before matching the - // criteria. - var charset string - if !c.Caps().Has(imap.CapIMAP4rev2) && !c.enabled.Has(imap.CapUTF8Accept) && !searchCriteriaIsASCII(criteria) { - charset = "UTF-8" - } - - var all imap.NumSet - switch numKind { - case imapwire.NumKindSeq: - all = imap.SeqSet(nil) - case imapwire.NumKindUID: - all = imap.UIDSet(nil) - } - - cmd := &SearchCommand{} - cmd.data.All = all - enc := c.beginCommand(uidCmdName("SEARCH", numKind), cmd) - if returnOpts := returnSearchOptions(options); len(returnOpts) > 0 { - enc.SP().Atom("RETURN").SP().List(len(returnOpts), func(i int) { - enc.Atom(returnOpts[i]) - }) - } - enc.SP() - if charset != "" { - enc.Atom("CHARSET").SP().Atom(charset).SP() - } - writeSearchKey(enc.Encoder, criteria) - enc.end() - return cmd -} - -// Search sends a SEARCH command. -func (c *Client) Search(criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand { - return c.search(imapwire.NumKindSeq, criteria, options) -} - -// UIDSearch sends a UID SEARCH command. -func (c *Client) UIDSearch(criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand { - return c.search(imapwire.NumKindUID, criteria, options) -} - -func (c *Client) handleSearch() error { - cmd := findPendingCmdByType[*SearchCommand](c) - for c.dec.SP() { - if c.dec.Special('(') { - var name string - if !c.dec.ExpectAtom(&name) || !c.dec.ExpectSP() { - return c.dec.Err() - } else if strings.ToUpper(name) != "MODSEQ" { - return fmt.Errorf("in search-sort-mod-seq: expected %q, got %q", "MODSEQ", name) - } - var modSeq uint64 - if !c.dec.ExpectModSeq(&modSeq) || !c.dec.ExpectSpecial(')') { - return c.dec.Err() - } - if cmd != nil { - cmd.data.ModSeq = modSeq - } - break - } - - var num uint32 - if !c.dec.ExpectNumber(&num) { - return c.dec.Err() - } - if cmd != nil { - switch all := cmd.data.All.(type) { - case imap.SeqSet: - all.AddNum(num) - cmd.data.All = all - case imap.UIDSet: - all.AddNum(imap.UID(num)) - cmd.data.All = all - } - } - } - return nil -} - -func (c *Client) handleESearch() error { - if !c.dec.ExpectSP() { - return c.dec.Err() - } - tag, data, err := readESearchResponse(c.dec) - if err != nil { - return err - } - cmd := c.findPendingCmdFunc(func(anyCmd command) bool { - cmd, ok := anyCmd.(*SearchCommand) - if !ok { - return false - } - if tag != "" { - return cmd.tag == tag - } else { - return true - } - }) - if cmd != nil { - cmd := cmd.(*SearchCommand) - cmd.data = *data - } - return nil -} - -// SearchCommand is a SEARCH command. -type SearchCommand struct { - commandBase - data imap.SearchData -} - -func (cmd *SearchCommand) Wait() (*imap.SearchData, error) { - return &cmd.data, cmd.wait() -} - -func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria) { - firstItem := true - encodeItem := func() *imapwire.Encoder { - if !firstItem { - enc.SP() - } - firstItem = false - return enc - } - - for _, seqSet := range criteria.SeqNum { - encodeItem().NumSet(seqSet) - } - for _, uidSet := range criteria.UID { - encodeItem().Atom("UID").SP().NumSet(uidSet) - } - - if !criteria.Since.IsZero() && !criteria.Before.IsZero() && criteria.Before.Sub(criteria.Since) == 24*time.Hour { - encodeItem().Atom("ON").SP().String(criteria.Since.Format(internal.DateLayout)) - } else { - if !criteria.Since.IsZero() { - encodeItem().Atom("SINCE").SP().String(criteria.Since.Format(internal.DateLayout)) - } - if !criteria.Before.IsZero() { - encodeItem().Atom("BEFORE").SP().String(criteria.Before.Format(internal.DateLayout)) - } - } - if !criteria.SentSince.IsZero() && !criteria.SentBefore.IsZero() && criteria.SentBefore.Sub(criteria.SentSince) == 24*time.Hour { - encodeItem().Atom("SENTON").SP().String(criteria.SentSince.Format(internal.DateLayout)) - } else { - if !criteria.SentSince.IsZero() { - encodeItem().Atom("SENTSINCE").SP().String(criteria.SentSince.Format(internal.DateLayout)) - } - if !criteria.SentBefore.IsZero() { - encodeItem().Atom("SENTBEFORE").SP().String(criteria.SentBefore.Format(internal.DateLayout)) - } - } - - for _, kv := range criteria.Header { - switch k := strings.ToUpper(kv.Key); k { - case "BCC", "CC", "FROM", "SUBJECT", "TO": - encodeItem().Atom(k) - default: - encodeItem().Atom("HEADER").SP().String(kv.Key) - } - enc.SP().String(kv.Value) - } - - for _, s := range criteria.Body { - encodeItem().Atom("BODY").SP().String(s) - } - for _, s := range criteria.Text { - encodeItem().Atom("TEXT").SP().String(s) - } - - for _, flag := range criteria.Flag { - if k := flagSearchKey(flag); k != "" { - encodeItem().Atom(k) - } else { - encodeItem().Atom("KEYWORD").SP().Flag(flag) - } - } - for _, flag := range criteria.NotFlag { - if k := flagSearchKey(flag); k != "" { - encodeItem().Atom("UN" + k) - } else { - encodeItem().Atom("UNKEYWORD").SP().Flag(flag) - } - } - - if criteria.Larger > 0 { - encodeItem().Atom("LARGER").SP().Number64(criteria.Larger) - } - if criteria.Smaller > 0 { - encodeItem().Atom("SMALLER").SP().Number64(criteria.Smaller) - } - - if modSeq := criteria.ModSeq; modSeq != nil { - encodeItem().Atom("MODSEQ") - if modSeq.MetadataName != "" && modSeq.MetadataType != "" { - enc.SP().Quoted(modSeq.MetadataName).SP().Atom(string(modSeq.MetadataType)) - } - enc.SP() - if modSeq.ModSeq != 0 { - enc.ModSeq(modSeq.ModSeq) - } else { - enc.Atom("0") - } - } - - for _, not := range criteria.Not { - encodeItem().Atom("NOT").SP() - enc.Special('(') - writeSearchKey(enc, ¬) - enc.Special(')') - } - for _, or := range criteria.Or { - encodeItem().Atom("OR").SP() - enc.Special('(') - writeSearchKey(enc, &or[0]) - enc.Special(')') - enc.SP() - enc.Special('(') - writeSearchKey(enc, &or[1]) - enc.Special(')') - } - - if firstItem { - enc.Atom("ALL") - } -} - -func flagSearchKey(flag imap.Flag) string { - switch flag { - case imap.FlagAnswered, imap.FlagDeleted, imap.FlagDraft, imap.FlagFlagged, imap.FlagSeen: - return strings.ToUpper(strings.TrimPrefix(string(flag), "\\")) - default: - return "" - } -} - -func readESearchResponse(dec *imapwire.Decoder) (tag string, data *imap.SearchData, err error) { - data = &imap.SearchData{} - if dec.Special('(') { // search-correlator - var correlator string - if !dec.ExpectAtom(&correlator) || !dec.ExpectSP() || !dec.ExpectAString(&tag) || !dec.ExpectSpecial(')') { - return "", nil, dec.Err() - } - if correlator != "TAG" { - return "", nil, fmt.Errorf("in search-correlator: name must be TAG, but got %q", correlator) - } - } - - var name string - if !dec.SP() { - return tag, data, nil - } else if !dec.ExpectAtom(&name) { - return "", nil, dec.Err() - } - isUID := name == "UID" - - if isUID { - if !dec.SP() { - return tag, data, nil - } else if !dec.ExpectAtom(&name) { - return "", nil, dec.Err() - } - } - - for { - if !dec.ExpectSP() { - return "", nil, dec.Err() - } - - switch strings.ToUpper(name) { - case "MIN": - var num uint32 - if !dec.ExpectNumber(&num) { - return "", nil, dec.Err() - } - data.Min = num - case "MAX": - var num uint32 - if !dec.ExpectNumber(&num) { - return "", nil, dec.Err() - } - data.Max = num - case "ALL": - numKind := imapwire.NumKindSeq - if isUID { - numKind = imapwire.NumKindUID - } - if !dec.ExpectNumSet(numKind, &data.All) { - return "", nil, dec.Err() - } - if data.All.Dynamic() { - return "", nil, fmt.Errorf("imapclient: server returned a dynamic ALL number set in SEARCH response") - } - case "COUNT": - var num uint32 - if !dec.ExpectNumber(&num) { - return "", nil, dec.Err() - } - data.Count = num - case "MODSEQ": - var modSeq uint64 - if !dec.ExpectModSeq(&modSeq) { - return "", nil, dec.Err() - } - data.ModSeq = modSeq - default: - if !dec.DiscardValue() { - return "", nil, dec.Err() - } - } - - if !dec.SP() { - break - } else if !dec.ExpectAtom(&name) { - return "", nil, dec.Err() - } - } - - return tag, data, nil -} - -func searchCriteriaIsASCII(criteria *imap.SearchCriteria) bool { - for _, kv := range criteria.Header { - if !isASCII(kv.Key) || !isASCII(kv.Value) { - return false - } - } - for _, s := range criteria.Body { - if !isASCII(s) { - return false - } - } - for _, s := range criteria.Text { - if !isASCII(s) { - return false - } - } - for _, not := range criteria.Not { - if !searchCriteriaIsASCII(¬) { - return false - } - } - for _, or := range criteria.Or { - if !searchCriteriaIsASCII(&or[0]) || !searchCriteriaIsASCII(&or[1]) { - return false - } - } - return true -} - -func isASCII(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] > unicode.MaxASCII { - return false - } - } - return true -} diff --git a/imapclient/search_test.go b/imapclient/search_test.go deleted file mode 100644 index a01f1ff..0000000 --- a/imapclient/search_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package imapclient_test - -import ( - "reflect" - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestSearch(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - criteria := imap.SearchCriteria{ - Header: []imap.SearchCriteriaHeaderField{{ - Key: "Message-Id", - Value: "<191101702316132@example.com>", - }}, - } - data, err := client.Search(&criteria, nil).Wait() - if err != nil { - t.Fatalf("Search().Wait() = %v", err) - } - seqSet, ok := data.All.(imap.SeqSet) - if !ok { - t.Fatalf("SearchData.All = %T, want SeqSet", data.All) - } - nums, _ := seqSet.Nums() - want := []uint32{1} - if !reflect.DeepEqual(nums, want) { - t.Errorf("SearchData.All.Nums() = %v, want %v", nums, want) - } -} - -func TestESearch(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - if !client.Caps().Has(imap.CapESearch) { - t.Skip("server doesn't support ESEARCH") - } - - criteria := imap.SearchCriteria{ - Header: []imap.SearchCriteriaHeaderField{{ - Key: "Message-Id", - Value: "<191101702316132@example.com>", - }}, - } - options := imap.SearchOptions{ - ReturnCount: true, - } - data, err := client.Search(&criteria, &options).Wait() - if err != nil { - t.Fatalf("Search().Wait() = %v", err) - } - if want := uint32(1); data.Count != want { - t.Errorf("Count = %v, want %v", data.Count, want) - } -} diff --git a/imapclient/select.go b/imapclient/select.go deleted file mode 100644 index c325ff0..0000000 --- a/imapclient/select.go +++ /dev/null @@ -1,100 +0,0 @@ -package imapclient - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" -) - -// Select sends a SELECT or EXAMINE command. -// -// A nil options pointer is equivalent to a zero options value. -func (c *Client) Select(mailbox string, options *imap.SelectOptions) *SelectCommand { - cmdName := "SELECT" - if options != nil && options.ReadOnly { - cmdName = "EXAMINE" - } - - cmd := &SelectCommand{mailbox: mailbox} - enc := c.beginCommand(cmdName, cmd) - enc.SP().Mailbox(mailbox) - if options != nil && options.CondStore { - enc.SP().Special('(').Atom("CONDSTORE").Special(')') - } - enc.end() - return cmd -} - -// Unselect sends an UNSELECT command. -// -// This command requires support for IMAP4rev2 or the UNSELECT extension. -func (c *Client) Unselect() *Command { - cmd := &unselectCommand{} - c.beginCommand("UNSELECT", cmd).end() - return &cmd.Command -} - -// UnselectAndExpunge sends a CLOSE command. -// -// CLOSE implicitly performs a silent EXPUNGE command. -func (c *Client) UnselectAndExpunge() *Command { - cmd := &unselectCommand{} - c.beginCommand("CLOSE", cmd).end() - return &cmd.Command -} - -func (c *Client) handleFlags() error { - flags, err := internal.ExpectFlagList(c.dec) - if err != nil { - return err - } - - c.mutex.Lock() - if c.state == imap.ConnStateSelected { - c.mailbox = c.mailbox.copy() - c.mailbox.Flags = flags - } - c.mutex.Unlock() - - cmd := findPendingCmdByType[*SelectCommand](c) - if cmd != nil { - cmd.data.Flags = flags - } else if handler := c.options.unilateralDataHandler().Mailbox; handler != nil { - handler(&UnilateralDataMailbox{Flags: flags}) - } - - return nil -} - -func (c *Client) handleExists(num uint32) error { - cmd := findPendingCmdByType[*SelectCommand](c) - if cmd != nil { - cmd.data.NumMessages = num - } else { - c.mutex.Lock() - if c.state == imap.ConnStateSelected { - c.mailbox = c.mailbox.copy() - c.mailbox.NumMessages = num - } - c.mutex.Unlock() - - if handler := c.options.unilateralDataHandler().Mailbox; handler != nil { - handler(&UnilateralDataMailbox{NumMessages: &num}) - } - } - return nil -} - -// SelectCommand is a SELECT command. -type SelectCommand struct { - commandBase - mailbox string - data imap.SelectData -} - -func (cmd *SelectCommand) Wait() (*imap.SelectData, error) { - return &cmd.data, cmd.wait() -} - -type unselectCommand struct { - Command -} diff --git a/imapclient/select_test.go b/imapclient/select_test.go deleted file mode 100644 index d65645c..0000000 --- a/imapclient/select_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package imapclient_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestSelect(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateAuthenticated) - defer client.Close() - defer server.Close() - - data, err := client.Select("INBOX", nil).Wait() - if err != nil { - t.Fatalf("Select() = %v", err) - } else if data.NumMessages != 1 { - t.Errorf("SelectData.NumMessages = %v, want %v", data.NumMessages, 1) - } -} diff --git a/imapclient/sort.go b/imapclient/sort.go deleted file mode 100644 index 260706d..0000000 --- a/imapclient/sort.go +++ /dev/null @@ -1,84 +0,0 @@ -package imapclient - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -type SortKey string - -const ( - SortKeyArrival SortKey = "ARRIVAL" - SortKeyCc SortKey = "CC" - SortKeyDate SortKey = "DATE" - SortKeyFrom SortKey = "FROM" - SortKeySize SortKey = "SIZE" - SortKeySubject SortKey = "SUBJECT" - SortKeyTo SortKey = "TO" -) - -type SortCriterion struct { - Key SortKey - Reverse bool -} - -// SortOptions contains options for the SORT command. -type SortOptions struct { - SearchCriteria *imap.SearchCriteria - SortCriteria []SortCriterion -} - -func (c *Client) sort(numKind imapwire.NumKind, options *SortOptions) *SortCommand { - cmd := &SortCommand{} - enc := c.beginCommand(uidCmdName("SORT", numKind), cmd) - enc.SP().List(len(options.SortCriteria), func(i int) { - criterion := options.SortCriteria[i] - if criterion.Reverse { - enc.Atom("REVERSE").SP() - } - enc.Atom(string(criterion.Key)) - }) - enc.SP().Atom("UTF-8").SP() - writeSearchKey(enc.Encoder, options.SearchCriteria) - enc.end() - return cmd -} - -func (c *Client) handleSort() error { - cmd := findPendingCmdByType[*SortCommand](c) - for c.dec.SP() { - var num uint32 - if !c.dec.ExpectNumber(&num) { - return c.dec.Err() - } - if cmd != nil { - cmd.nums = append(cmd.nums, num) - } - } - return nil -} - -// Sort sends a SORT command. -// -// This command requires support for the SORT extension. -func (c *Client) Sort(options *SortOptions) *SortCommand { - return c.sort(imapwire.NumKindSeq, options) -} - -// UIDSort sends a UID SORT command. -// -// See Sort. -func (c *Client) UIDSort(options *SortOptions) *SortCommand { - return c.sort(imapwire.NumKindUID, options) -} - -// SortCommand is a SORT command. -type SortCommand struct { - commandBase - nums []uint32 -} - -func (cmd *SortCommand) Wait() ([]uint32, error) { - err := cmd.wait() - return cmd.nums, err -} diff --git a/imapclient/starttls.go b/imapclient/starttls.go deleted file mode 100644 index 8b63cca..0000000 --- a/imapclient/starttls.go +++ /dev/null @@ -1,83 +0,0 @@ -package imapclient - -import ( - "bufio" - "bytes" - "crypto/tls" - "io" - "net" -) - -// startTLS sends a STARTTLS command. -// -// Unlike other commands, this method blocks until the command completes. -func (c *Client) startTLS(config *tls.Config) error { - upgradeDone := make(chan struct{}) - cmd := &startTLSCommand{ - tlsConfig: config, - upgradeDone: upgradeDone, - } - enc := c.beginCommand("STARTTLS", cmd) - enc.flush() - defer enc.end() - - // Once a client issues a STARTTLS command, it MUST NOT issue further - // commands until a server response is seen and the TLS negotiation is - // complete - - if err := cmd.wait(); err != nil { - return err - } - - // The decoder goroutine will invoke Client.upgradeStartTLS - <-upgradeDone - - return cmd.tlsConn.Handshake() -} - -// upgradeStartTLS finishes the STARTTLS upgrade after the server has sent an -// OK response. It runs in the decoder goroutine. -func (c *Client) upgradeStartTLS(startTLS *startTLSCommand) { - defer close(startTLS.upgradeDone) - - // Drain buffered data from our bufio.Reader - var buf bytes.Buffer - if _, err := io.CopyN(&buf, c.br, int64(c.br.Buffered())); err != nil { - panic(err) // unreachable - } - - var cleartextConn net.Conn - if buf.Len() > 0 { - r := io.MultiReader(&buf, c.conn) - cleartextConn = startTLSConn{c.conn, r} - } else { - cleartextConn = c.conn - } - - tlsConn := tls.Client(cleartextConn, startTLS.tlsConfig) - rw := c.options.wrapReadWriter(tlsConn) - - c.br.Reset(rw) - // Unfortunately we can't re-use the bufio.Writer here, it races with - // Client.StartTLS - c.bw = bufio.NewWriter(rw) - - startTLS.tlsConn = tlsConn -} - -type startTLSCommand struct { - commandBase - tlsConfig *tls.Config - - upgradeDone chan<- struct{} - tlsConn *tls.Conn -} - -type startTLSConn struct { - net.Conn - r io.Reader -} - -func (conn startTLSConn) Read(b []byte) (int, error) { - return conn.r.Read(b) -} diff --git a/imapclient/starttls_test.go b/imapclient/starttls_test.go deleted file mode 100644 index 9e8a12b..0000000 --- a/imapclient/starttls_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package imapclient_test - -import ( - "crypto/tls" - "testing" - - "github.com/emersion/go-imap/v2/imapclient" -) - -func TestStartTLS(t *testing.T) { - conn, server := newMemClientServerPair(t) - defer conn.Close() - defer server.Close() - - options := imapclient.Options{ - TLSConfig: &tls.Config{InsecureSkipVerify: true}, - } - client, err := imapclient.NewStartTLS(conn, &options) - if err != nil { - t.Fatalf("NewStartTLS() = %v", err) - } - defer client.Close() - - if err := client.Noop().Wait(); err != nil { - t.Fatalf("Noop().Wait() = %v", err) - } -} diff --git a/imapclient/status.go b/imapclient/status.go deleted file mode 100644 index 973345b..0000000 --- a/imapclient/status.go +++ /dev/null @@ -1,164 +0,0 @@ -package imapclient - -import ( - "fmt" - "strings" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func statusItems(options *imap.StatusOptions) []string { - m := map[string]bool{ - "MESSAGES": options.NumMessages, - "UIDNEXT": options.UIDNext, - "UIDVALIDITY": options.UIDValidity, - "UNSEEN": options.NumUnseen, - "DELETED": options.NumDeleted, - "SIZE": options.Size, - "APPENDLIMIT": options.AppendLimit, - "DELETED-STORAGE": options.DeletedStorage, - "HIGHESTMODSEQ": options.HighestModSeq, - } - - var l []string - for k, req := range m { - if req { - l = append(l, k) - } - } - return l -} - -// Status sends a STATUS command. -// -// A nil options pointer is equivalent to a zero options value. -func (c *Client) Status(mailbox string, options *imap.StatusOptions) *StatusCommand { - if options == nil { - options = new(imap.StatusOptions) - } - if options.NumRecent { - panic("StatusOptions.NumRecent is not supported in imapclient") - } - - cmd := &StatusCommand{mailbox: mailbox} - enc := c.beginCommand("STATUS", cmd) - enc.SP().Mailbox(mailbox).SP() - items := statusItems(options) - enc.List(len(items), func(i int) { - enc.Atom(items[i]) - }) - enc.end() - return cmd -} - -func (c *Client) handleStatus() error { - data, err := readStatus(c.dec) - if err != nil { - return fmt.Errorf("in status: %v", err) - } - - cmd := c.findPendingCmdFunc(func(cmd command) bool { - switch cmd := cmd.(type) { - case *StatusCommand: - return cmd.mailbox == data.Mailbox - case *ListCommand: - return cmd.returnStatus && cmd.pendingData != nil && cmd.pendingData.Mailbox == data.Mailbox - default: - return false - } - }) - switch cmd := cmd.(type) { - case *StatusCommand: - cmd.data = *data - case *ListCommand: - cmd.pendingData.Status = data - cmd.mailboxes <- cmd.pendingData - cmd.pendingData = nil - } - - return nil -} - -// StatusCommand is a STATUS command. -type StatusCommand struct { - commandBase - mailbox string - data imap.StatusData -} - -func (cmd *StatusCommand) Wait() (*imap.StatusData, error) { - return &cmd.data, cmd.wait() -} - -func readStatus(dec *imapwire.Decoder) (*imap.StatusData, error) { - var data imap.StatusData - - if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() { - return nil, dec.Err() - } - - err := dec.ExpectList(func() error { - if err := readStatusAttVal(dec, &data); err != nil { - return fmt.Errorf("in status-att-val: %v", dec.Err()) - } - return nil - }) - return &data, err -} - -func readStatusAttVal(dec *imapwire.Decoder, data *imap.StatusData) error { - var name string - if !dec.ExpectAtom(&name) || !dec.ExpectSP() { - return dec.Err() - } - - var ok bool - switch strings.ToUpper(name) { - case "MESSAGES": - var num uint32 - ok = dec.ExpectNumber(&num) - data.NumMessages = &num - case "UIDNEXT": - var uidNext imap.UID - ok = dec.ExpectUID(&uidNext) - data.UIDNext = uidNext - case "UIDVALIDITY": - ok = dec.ExpectNumber(&data.UIDValidity) - case "UNSEEN": - var num uint32 - ok = dec.ExpectNumber(&num) - data.NumUnseen = &num - case "DELETED": - var num uint32 - ok = dec.ExpectNumber(&num) - data.NumDeleted = &num - case "SIZE": - var size int64 - ok = dec.ExpectNumber64(&size) - data.Size = &size - case "APPENDLIMIT": - var num uint32 - if dec.Number(&num) { - ok = true - } else { - ok = dec.ExpectNIL() - num = ^uint32(0) - } - data.AppendLimit = &num - case "DELETED-STORAGE": - var storage int64 - ok = dec.ExpectNumber64(&storage) - data.DeletedStorage = &storage - case "HIGHESTMODSEQ": - ok = dec.ExpectModSeq(&data.HighestModSeq) - default: - if !dec.DiscardValue() { - return dec.Err() - } - } - if !ok { - return dec.Err() - } - return nil -} diff --git a/imapclient/status_test.go b/imapclient/status_test.go deleted file mode 100644 index 33966af..0000000 --- a/imapclient/status_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package imapclient_test - -import ( - "reflect" - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestStatus(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateAuthenticated) - defer client.Close() - defer server.Close() - - options := imap.StatusOptions{ - NumMessages: true, - NumUnseen: true, - } - data, err := client.Status("INBOX", &options).Wait() - if err != nil { - t.Fatalf("Status() = %v", err) - } - - wantNumMessages := uint32(1) - wantNumUnseen := uint32(1) - want := &imap.StatusData{ - Mailbox: "INBOX", - NumMessages: &wantNumMessages, - NumUnseen: &wantNumUnseen, - } - if !reflect.DeepEqual(data, want) { - t.Errorf("Status() = %#v but want %#v", data, want) - } -} diff --git a/imapclient/store.go b/imapclient/store.go deleted file mode 100644 index a8be6d1..0000000 --- a/imapclient/store.go +++ /dev/null @@ -1,44 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// Store sends a STORE command. -// -// Unless StoreFlags.Silent is set, the server will return the updated values. -// -// A nil options pointer is equivalent to a zero options value. -func (c *Client) Store(numSet imap.NumSet, store *imap.StoreFlags, options *imap.StoreOptions) *FetchCommand { - cmd := &FetchCommand{ - numSet: numSet, - msgs: make(chan *FetchMessageData, 128), - } - enc := c.beginCommand(uidCmdName("STORE", imapwire.NumSetKind(numSet)), cmd) - enc.SP().NumSet(numSet).SP() - if options != nil && options.UnchangedSince != 0 { - enc.Special('(').Atom("UNCHANGEDSINCE").SP().ModSeq(options.UnchangedSince).Special(')').SP() - } - switch store.Op { - case imap.StoreFlagsSet: - // nothing to do - case imap.StoreFlagsAdd: - enc.Special('+') - case imap.StoreFlagsDel: - enc.Special('-') - default: - panic(fmt.Errorf("imapclient: unknown store flags op: %v", store.Op)) - } - enc.Atom("FLAGS") - if store.Silent { - enc.Atom(".SILENT") - } - enc.SP().List(len(store.Flags), func(i int) { - enc.Flag(store.Flags[i]) - }) - enc.end() - return cmd -} diff --git a/imapclient/store_test.go b/imapclient/store_test.go deleted file mode 100644 index 5ecbeb9..0000000 --- a/imapclient/store_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package imapclient_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2" -) - -func TestStore(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - seqSet := imap.SeqSetNum(1) - storeFlags := imap.StoreFlags{ - Op: imap.StoreFlagsAdd, - Flags: []imap.Flag{imap.FlagDeleted}, - } - msgs, err := client.Store(seqSet, &storeFlags, nil).Collect() - if err != nil { - t.Fatalf("Store().Collect() = %v", err) - } else if len(msgs) != 1 { - t.Fatalf("len(msgs) = %v, want %v", len(msgs), 1) - } - msg := msgs[0] - if msg.SeqNum != 1 { - t.Errorf("msg.SeqNum = %v, want %v", msg.SeqNum, 1) - } - - found := false - for _, f := range msg.Flags { - if f == imap.FlagDeleted { - found = true - break - } - } - if !found { - t.Errorf("msg.Flags is missing deleted flag: %v", msg.Flags) - } -} diff --git a/imapclient/thread.go b/imapclient/thread.go deleted file mode 100644 index c341a18..0000000 --- a/imapclient/thread.go +++ /dev/null @@ -1,85 +0,0 @@ -package imapclient - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// ThreadOptions contains options for the THREAD command. -type ThreadOptions struct { - Algorithm imap.ThreadAlgorithm - SearchCriteria *imap.SearchCriteria -} - -func (c *Client) thread(numKind imapwire.NumKind, options *ThreadOptions) *ThreadCommand { - cmd := &ThreadCommand{} - enc := c.beginCommand(uidCmdName("THREAD", numKind), cmd) - enc.SP().Atom(string(options.Algorithm)).SP().Atom("UTF-8").SP() - writeSearchKey(enc.Encoder, options.SearchCriteria) - enc.end() - return cmd -} - -// Thread sends a THREAD command. -// -// This command requires support for the THREAD extension. -func (c *Client) Thread(options *ThreadOptions) *ThreadCommand { - return c.thread(imapwire.NumKindSeq, options) -} - -// UIDThread sends a UID THREAD command. -// -// See Thread. -func (c *Client) UIDThread(options *ThreadOptions) *ThreadCommand { - return c.thread(imapwire.NumKindUID, options) -} - -func (c *Client) handleThread() error { - cmd := findPendingCmdByType[*ThreadCommand](c) - for c.dec.SP() { - data, err := readThreadList(c.dec) - if err != nil { - return fmt.Errorf("in thread-list: %v", err) - } - if cmd != nil { - cmd.data = append(cmd.data, *data) - } - } - return nil -} - -// ThreadCommand is a THREAD command. -type ThreadCommand struct { - commandBase - data []ThreadData -} - -func (cmd *ThreadCommand) Wait() ([]ThreadData, error) { - err := cmd.wait() - return cmd.data, err -} - -type ThreadData struct { - Chain []uint32 - SubThreads []ThreadData -} - -func readThreadList(dec *imapwire.Decoder) (*ThreadData, error) { - var data ThreadData - err := dec.ExpectList(func() error { - var num uint32 - if len(data.SubThreads) == 0 && dec.Number(&num) { - data.Chain = append(data.Chain, num) - } else { - sub, err := readThreadList(dec) - if err != nil { - return err - } - data.SubThreads = append(data.SubThreads, *sub) - } - return nil - }) - return &data, err -} diff --git a/imapserver/append.go b/imapserver/append.go deleted file mode 100644 index 8ec614e..0000000 --- a/imapserver/append.go +++ /dev/null @@ -1,123 +0,0 @@ -package imapserver - -import ( - "fmt" - "io" - "strings" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -// defaultAppendLimit is the default maximum size of an APPEND payload. -const defaultAppendLimit = 100 * 1024 * 1024 // 100MiB - -func (c *Conn) handleAppend(tag string, dec *imapwire.Decoder) error { - var ( - mailbox string - options imap.AppendOptions - ) - if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() { - return dec.Err() - } - - hasFlagList, err := dec.List(func() error { - flag, err := internal.ExpectFlag(dec) - if err != nil { - return err - } - options.Flags = append(options.Flags, flag) - return nil - }) - if err != nil { - return err - } - if hasFlagList && !dec.ExpectSP() { - return dec.Err() - } - - t, err := internal.DecodeDateTime(dec) - if err != nil { - return err - } - if !t.IsZero() && !dec.ExpectSP() { - return dec.Err() - } - options.Time = t - - var dataExt string - if !dec.Special('~') && dec.Atom(&dataExt) { // ignore literal8 prefix if any for BINARY - switch strings.ToUpper(dataExt) { - case "UTF8": - // '~' is the literal8 prefix - if !dec.ExpectSP() || !dec.ExpectSpecial('(') || !dec.ExpectSpecial('~') { - return dec.Err() - } - default: - return newClientBugError("Unknown APPEND data extension") - } - } - - lit, nonSync, err := dec.ExpectLiteralReader() - if err != nil { - return err - } - - appendLimit := int64(defaultAppendLimit) - if appendLimitSession, ok := c.session.(SessionAppendLimit); ok { - appendLimit = int64(appendLimitSession.AppendLimit()) - } - - if lit.Size() > appendLimit { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeTooBig, - Text: fmt.Sprintf("Literals are limited to %v bytes for this command", appendLimit), - } - } - if err := c.acceptLiteral(lit.Size(), nonSync); err != nil { - return err - } - - c.setReadTimeout(literalReadTimeout) - defer c.setReadTimeout(cmdReadTimeout) - - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - io.Copy(io.Discard, lit) - dec.CRLF() - return err - } - - data, appendErr := c.session.Append(mailbox, lit, &options) - if _, discardErr := io.Copy(io.Discard, lit); discardErr != nil { - return err - } - if dataExt != "" && !dec.ExpectSpecial(')') { - return dec.Err() - } - if !dec.ExpectCRLF() { - return err - } - if appendErr != nil { - return appendErr - } - if err := c.poll("APPEND"); err != nil { - return err - } - return c.writeAppendOK(tag, data) -} - -func (c *Conn) writeAppendOK(tag string, data *imap.AppendData) error { - enc := newResponseEncoder(c) - defer enc.end() - - enc.Atom(tag).SP().Atom("OK").SP() - if data != nil { - enc.Special('[') - enc.Atom("APPENDUID").SP().Number(data.UIDValidity).SP().UID(data.UID) - enc.Special(']').SP() - } - enc.Text("APPEND completed") - return enc.CRLF() -} diff --git a/imapserver/authenticate.go b/imapserver/authenticate.go deleted file mode 100644 index 14fd460..0000000 --- a/imapserver/authenticate.go +++ /dev/null @@ -1,148 +0,0 @@ -package imapserver - -import ( - "fmt" - "strings" - - "github.com/emersion/go-sasl" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleAuthenticate(tag string, dec *imapwire.Decoder) error { - var mech string - if !dec.ExpectSP() || !dec.ExpectAtom(&mech) { - return dec.Err() - } - mech = strings.ToUpper(mech) - - var initialResp []byte - if dec.SP() { - var initialRespStr string - if !dec.ExpectText(&initialRespStr) { - return dec.Err() - } - var err error - initialResp, err = internal.DecodeSASL(initialRespStr) - if err != nil { - return err - } - } - - if !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateNotAuthenticated); err != nil { - return err - } - if !c.canAuth() { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodePrivacyRequired, - Text: "TLS is required to authenticate", - } - } - - var saslServer sasl.Server - if authSess, ok := c.session.(SessionSASL); ok { - var err error - saslServer, err = authSess.Authenticate(mech) - if err != nil { - return err - } - } else { - if mech != "PLAIN" { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Text: "SASL mechanism not supported", - } - } - saslServer = sasl.NewPlainServer(func(identity, username, password string) error { - if identity != "" && identity != username { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeAuthorizationFailed, - Text: "SASL identity not supported", - } - } - return c.session.Login(username, password) - }) - } - - enc := newResponseEncoder(c) - defer enc.end() - - resp := initialResp - for { - challenge, done, err := saslServer.Next(resp) - if err != nil { - return err - } else if done { - break - } - - var challengeStr string - if challenge != nil { - challengeStr = internal.EncodeSASL(challenge) - } - if err := writeContReq(enc.Encoder, challengeStr); err != nil { - return err - } - - encodedResp, isPrefix, err := c.br.ReadLine() - if err != nil { - return err - } else if isPrefix { - return fmt.Errorf("SASL response too long") - } else if string(encodedResp) == "*" { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: "AUTHENTICATE cancelled", - } - } - - resp, err = decodeSASL(string(encodedResp)) - if err != nil { - return err - } - } - - c.state = imap.ConnStateAuthenticated - text := fmt.Sprintf("%v authentication successful", mech) - return writeCapabilityOK(enc.Encoder, tag, c.availableCaps(), text) -} - -func decodeSASL(s string) ([]byte, error) { - b, err := internal.DecodeSASL(s) - if err != nil { - return nil, &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: "Malformed SASL response", - } - } - return b, nil -} - -func (c *Conn) handleUnauthenticate(dec *imapwire.Decoder) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - session, ok := c.session.(SessionUnauthenticate) - if !ok { - return newClientBugError("UNAUTHENTICATE is not supported") - } - if err := session.Unauthenticate(); err != nil { - return err - } - c.state = imap.ConnStateNotAuthenticated - c.mutex.Lock() - c.enabled = make(imap.CapSet) - c.mutex.Unlock() - return nil -} diff --git a/imapserver/capability.go b/imapserver/capability.go deleted file mode 100644 index 37da104..0000000 --- a/imapserver/capability.go +++ /dev/null @@ -1,114 +0,0 @@ -package imapserver - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleCapability(dec *imapwire.Decoder) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Atom("CAPABILITY") - for _, c := range c.availableCaps() { - enc.SP().Atom(string(c)) - } - return enc.CRLF() -} - -// availableCaps returns the capabilities supported by the server. -// -// They depend on the connection state. -// -// Some extensions (e.g. SASL-IR, ENABLE) don't require backend support and -// thus are always enabled. -func (c *Conn) availableCaps() []imap.Cap { - available := c.server.options.caps() - - var caps []imap.Cap - addAvailableCaps(&caps, available, []imap.Cap{ - imap.CapIMAP4rev2, - imap.CapIMAP4rev1, - }) - if len(caps) == 0 { - panic("imapserver: must support at least IMAP4rev1 or IMAP4rev2") - } - - if available.Has(imap.CapIMAP4rev1) { - caps = append(caps, []imap.Cap{ - imap.CapSASLIR, - imap.CapLiteralMinus, - }...) - } - if c.canStartTLS() { - caps = append(caps, imap.CapStartTLS) - } - if c.canAuth() { - mechs := []string{"PLAIN"} - if authSess, ok := c.session.(SessionSASL); ok { - mechs = authSess.AuthenticateMechanisms() - } - for _, mech := range mechs { - caps = append(caps, imap.Cap("AUTH="+mech)) - } - } else if c.state == imap.ConnStateNotAuthenticated { - caps = append(caps, imap.CapLoginDisabled) - } - if c.state == imap.ConnStateAuthenticated || c.state == imap.ConnStateSelected { - if available.Has(imap.CapIMAP4rev1) { - // IMAP4rev1-specific capabilities that don't require backend - // support and are not applicable to IMAP4rev2 - caps = append(caps, []imap.Cap{ - imap.CapUnselect, - imap.CapEnable, - imap.CapIdle, - imap.CapUTF8Accept, - }...) - - // IMAP4rev1-specific capabilities which require backend support - // and are not applicable to IMAP4rev2 - addAvailableCaps(&caps, available, []imap.Cap{ - imap.CapNamespace, - imap.CapUIDPlus, - imap.CapESearch, - imap.CapSearchRes, - imap.CapListExtended, - imap.CapListStatus, - imap.CapMove, - imap.CapStatusSize, - imap.CapBinary, - imap.CapChildren, - }) - } - - // Capabilities which require backend support and apply to both - // IMAP4rev1 and IMAP4rev2 - addAvailableCaps(&caps, available, []imap.Cap{ - imap.CapSpecialUse, - imap.CapCreateSpecialUse, - imap.CapLiteralPlus, - imap.CapUnauthenticate, - }) - - if appendLimitSession, ok := c.session.(SessionAppendLimit); ok { - limit := appendLimitSession.AppendLimit() - caps = append(caps, imap.Cap(fmt.Sprintf("APPENDLIMIT=%d", limit))) - } else { - addAvailableCaps(&caps, available, []imap.Cap{imap.CapAppendLimit}) - } - } - return caps -} - -func addAvailableCaps(caps *[]imap.Cap, available imap.CapSet, l []imap.Cap) { - for _, c := range l { - if available.Has(c) { - *caps = append(*caps, c) - } - } -} diff --git a/imapserver/conn.go b/imapserver/conn.go deleted file mode 100644 index 291f37e..0000000 --- a/imapserver/conn.go +++ /dev/null @@ -1,618 +0,0 @@ -package imapserver - -import ( - "bufio" - "crypto/tls" - "errors" - "fmt" - "io" - "net" - "runtime/debug" - "strings" - "sync" - "time" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -const ( - cmdReadTimeout = 30 * time.Second - idleReadTimeout = 35 * time.Minute // section 5.4 says 30min minimum - literalReadTimeout = 5 * time.Minute - - respWriteTimeout = 30 * time.Second - literalWriteTimeout = 5 * time.Minute - - maxCommandSize = 50 * 1024 // RFC 2683 section 3.2.1.5 says 8KiB minimum -) - -var internalServerErrorResp = &imap.StatusResponse{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeServerBug, - Text: "Internal server error", -} - -// A Conn represents an IMAP connection to the server. -type Conn struct { - server *Server - br *bufio.Reader - bw *bufio.Writer - encMutex sync.Mutex - - mutex sync.Mutex - conn net.Conn - enabled imap.CapSet - - state imap.ConnState - session Session -} - -func newConn(c net.Conn, server *Server) *Conn { - rw := server.options.wrapReadWriter(c) - br := bufio.NewReader(rw) - bw := bufio.NewWriter(rw) - return &Conn{ - conn: c, - server: server, - br: br, - bw: bw, - enabled: make(imap.CapSet), - } -} - -// NetConn returns the underlying connection that is wrapped by the IMAP -// connection. -// -// Writing to or reading from this connection directly will corrupt the IMAP -// session. -func (c *Conn) NetConn() net.Conn { - c.mutex.Lock() - defer c.mutex.Unlock() - return c.conn -} - -// Bye terminates the IMAP connection. -func (c *Conn) Bye(text string) error { - respErr := c.writeStatusResp("", &imap.StatusResponse{ - Type: imap.StatusResponseTypeBye, - Text: text, - }) - closeErr := c.conn.Close() - if respErr != nil { - return respErr - } - return closeErr -} - -func (c *Conn) EnabledCaps() imap.CapSet { - c.mutex.Lock() - defer c.mutex.Unlock() - - return c.enabled.Copy() -} - -func (c *Conn) serve() { - defer func() { - if v := recover(); v != nil { - c.server.logger().Printf("panic handling command: %v\n%s", v, debug.Stack()) - } - - c.conn.Close() - }() - - c.server.mutex.Lock() - c.server.conns[c] = struct{}{} - c.server.mutex.Unlock() - defer func() { - c.server.mutex.Lock() - delete(c.server.conns, c) - c.server.mutex.Unlock() - }() - - var ( - greetingData *GreetingData - err error - ) - c.session, greetingData, err = c.server.options.NewSession(c) - if err != nil { - var ( - resp *imap.StatusResponse - imapErr *imap.Error - ) - if errors.As(err, &imapErr) && imapErr.Type == imap.StatusResponseTypeBye { - resp = (*imap.StatusResponse)(imapErr) - } else { - c.server.logger().Printf("failed to create session: %v", err) - resp = internalServerErrorResp - } - if err := c.writeStatusResp("", resp); err != nil { - c.server.logger().Printf("failed to write greeting: %v", err) - } - return - } - - defer func() { - if c.session != nil { - if err := c.session.Close(); err != nil { - c.server.logger().Printf("failed to close session: %v", err) - } - } - }() - - caps := c.server.options.caps() - if _, ok := c.session.(SessionIMAP4rev2); !ok && caps.Has(imap.CapIMAP4rev2) { - panic("imapserver: server advertises IMAP4rev2 but session doesn't support it") - } - if _, ok := c.session.(SessionNamespace); !ok && caps.Has(imap.CapNamespace) { - panic("imapserver: server advertises NAMESPACE but session doesn't support it") - } - if _, ok := c.session.(SessionMove); !ok && caps.Has(imap.CapMove) { - panic("imapserver: server advertises MOVE but session doesn't support it") - } - if _, ok := c.session.(SessionUnauthenticate); !ok && caps.Has(imap.CapUnauthenticate) { - panic("imapserver: server advertises UNAUTHENTICATE but session doesn't support it") - } - - c.state = imap.ConnStateNotAuthenticated - statusType := imap.StatusResponseTypeOK - if greetingData != nil && greetingData.PreAuth { - c.state = imap.ConnStateAuthenticated - statusType = imap.StatusResponseTypePreAuth - } - if err := c.writeCapabilityStatus("", statusType, "IMAP server ready"); err != nil { - c.server.logger().Printf("failed to write greeting: %v", err) - return - } - - for { - var readTimeout time.Duration - switch c.state { - case imap.ConnStateAuthenticated, imap.ConnStateSelected: - readTimeout = idleReadTimeout - default: - readTimeout = cmdReadTimeout - } - c.setReadTimeout(readTimeout) - - dec := imapwire.NewDecoder(c.br, imapwire.ConnSideServer) - dec.MaxSize = maxCommandSize - dec.CheckBufferedLiteralFunc = c.checkBufferedLiteral - - if c.state == imap.ConnStateLogout || dec.EOF() { - break - } - - c.setReadTimeout(cmdReadTimeout) - if err := c.readCommand(dec); err != nil { - if !errors.Is(err, net.ErrClosed) { - c.server.logger().Printf("failed to read command: %v", err) - } - break - } - } -} - -func (c *Conn) readCommand(dec *imapwire.Decoder) error { - var tag, name string - if !dec.ExpectAtom(&tag) || !dec.ExpectSP() || !dec.ExpectAtom(&name) { - return fmt.Errorf("in command: %w", dec.Err()) - } - name = strings.ToUpper(name) - - numKind := NumKindSeq - if name == "UID" { - numKind = NumKindUID - var subName string - if !dec.ExpectSP() || !dec.ExpectAtom(&subName) { - return fmt.Errorf("in command: %w", dec.Err()) - } - name = "UID " + strings.ToUpper(subName) - } - - // TODO: handle multiple commands concurrently - sendOK := true - var err error - switch name { - case "NOOP", "CHECK": - err = c.handleNoop(dec) - case "LOGOUT": - err = c.handleLogout(dec) - case "CAPABILITY": - err = c.handleCapability(dec) - case "STARTTLS": - err = c.handleStartTLS(tag, dec) - sendOK = false - case "AUTHENTICATE": - err = c.handleAuthenticate(tag, dec) - sendOK = false - case "UNAUTHENTICATE": - err = c.handleUnauthenticate(dec) - case "LOGIN": - err = c.handleLogin(tag, dec) - sendOK = false - case "ENABLE": - err = c.handleEnable(dec) - case "CREATE": - err = c.handleCreate(dec) - case "DELETE": - err = c.handleDelete(dec) - case "RENAME": - err = c.handleRename(dec) - case "SUBSCRIBE": - err = c.handleSubscribe(dec) - case "UNSUBSCRIBE": - err = c.handleUnsubscribe(dec) - case "STATUS": - err = c.handleStatus(dec) - case "LIST": - err = c.handleList(dec) - case "LSUB": - err = c.handleLSub(dec) - case "NAMESPACE": - err = c.handleNamespace(dec) - case "IDLE": - err = c.handleIdle(dec) - case "SELECT", "EXAMINE": - err = c.handleSelect(tag, dec, name == "EXAMINE") - sendOK = false - case "CLOSE", "UNSELECT": - err = c.handleUnselect(dec, name == "CLOSE") - case "APPEND": - err = c.handleAppend(tag, dec) - sendOK = false - case "FETCH", "UID FETCH": - err = c.handleFetch(dec, numKind) - case "EXPUNGE": - err = c.handleExpunge(dec) - case "UID EXPUNGE": - err = c.handleUIDExpunge(dec) - case "STORE", "UID STORE": - err = c.handleStore(dec, numKind) - case "COPY", "UID COPY": - err = c.handleCopy(tag, dec, numKind) - sendOK = false - case "MOVE", "UID MOVE": - err = c.handleMove(dec, numKind) - case "SEARCH", "UID SEARCH": - err = c.handleSearch(tag, dec, numKind) - default: - if c.state == imap.ConnStateNotAuthenticated { - // Don't allow a single unknown command before authentication to - // mitigate cross-protocol attacks: - // https://www-archive.mozilla.org/projects/netlib/portbanning - c.state = imap.ConnStateLogout - defer c.Bye("Unknown command") - } - err = &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: "Unknown command", - } - } - - dec.DiscardLine() - - var ( - resp *imap.StatusResponse - imapErr *imap.Error - decErr *imapwire.DecoderExpectError - ) - if errors.As(err, &imapErr) { - resp = (*imap.StatusResponse)(imapErr) - } else if errors.As(err, &decErr) { - resp = &imap.StatusResponse{ - Type: imap.StatusResponseTypeBad, - Code: imap.ResponseCodeClientBug, - Text: "Syntax error: " + decErr.Message, - } - } else if err != nil { - c.server.logger().Printf("handling %v command: %v", name, err) - resp = internalServerErrorResp - } else { - if !sendOK { - return nil - } - if err := c.poll(name); err != nil { - return err - } - resp = &imap.StatusResponse{ - Type: imap.StatusResponseTypeOK, - Text: fmt.Sprintf("%v completed", name), - } - } - return c.writeStatusResp(tag, resp) -} - -func (c *Conn) handleNoop(dec *imapwire.Decoder) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - return nil -} - -func (c *Conn) handleLogout(dec *imapwire.Decoder) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - - c.state = imap.ConnStateLogout - - return c.writeStatusResp("", &imap.StatusResponse{ - Type: imap.StatusResponseTypeBye, - Text: "Logging out", - }) -} - -func (c *Conn) handleDelete(dec *imapwire.Decoder) error { - var name string - if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() { - return dec.Err() - } - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - return c.session.Delete(name) -} - -func (c *Conn) handleRename(dec *imapwire.Decoder) error { - var oldName, newName string - if !dec.ExpectSP() || !dec.ExpectMailbox(&oldName) || !dec.ExpectSP() || !dec.ExpectMailbox(&newName) || !dec.ExpectCRLF() { - return dec.Err() - } - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - var options imap.RenameOptions - return c.session.Rename(oldName, newName, &options) -} - -func (c *Conn) handleSubscribe(dec *imapwire.Decoder) error { - var name string - if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() { - return dec.Err() - } - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - return c.session.Subscribe(name) -} - -func (c *Conn) handleUnsubscribe(dec *imapwire.Decoder) error { - var name string - if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() { - return dec.Err() - } - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - return c.session.Unsubscribe(name) -} - -func (c *Conn) checkBufferedLiteral(size int64, nonSync bool) error { - if size > 4096 { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeTooBig, - Text: "Literals are limited to 4096 bytes for this command", - } - } - - return c.acceptLiteral(size, nonSync) -} - -func (c *Conn) acceptLiteral(size int64, nonSync bool) error { - if nonSync && size > 4096 && !c.server.options.caps().Has(imap.CapLiteralPlus) { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: "Non-synchronizing literals are limited to 4096 bytes", - } - } - - if nonSync { - return nil - } - - return c.writeContReq("Ready for literal data") -} - -func (c *Conn) canAuth() bool { - if c.state != imap.ConnStateNotAuthenticated { - return false - } - _, isTLS := c.conn.(*tls.Conn) - return isTLS || c.server.options.InsecureAuth -} - -func (c *Conn) writeStatusResp(tag string, statusResp *imap.StatusResponse) error { - enc := newResponseEncoder(c) - defer enc.end() - return writeStatusResp(enc.Encoder, tag, statusResp) -} - -func (c *Conn) writeContReq(text string) error { - enc := newResponseEncoder(c) - defer enc.end() - return writeContReq(enc.Encoder, text) -} - -func (c *Conn) writeCapabilityStatus(tag string, typ imap.StatusResponseType, text string) error { - enc := newResponseEncoder(c) - defer enc.end() - return writeCapabilityStatus(enc.Encoder, tag, typ, c.availableCaps(), text) -} - -func (c *Conn) checkState(state imap.ConnState) error { - if state == imap.ConnStateAuthenticated && c.state == imap.ConnStateSelected { - return nil - } - if c.state != state { - return newClientBugError(fmt.Sprintf("This command is only valid in the %s state", state)) - } - return nil -} - -func (c *Conn) setReadTimeout(dur time.Duration) { - if dur > 0 { - c.conn.SetReadDeadline(time.Now().Add(dur)) - } else { - c.conn.SetReadDeadline(time.Time{}) - } -} - -func (c *Conn) setWriteTimeout(dur time.Duration) { - if dur > 0 { - c.conn.SetWriteDeadline(time.Now().Add(dur)) - } else { - c.conn.SetWriteDeadline(time.Time{}) - } -} - -func (c *Conn) poll(cmd string) error { - switch c.state { - case imap.ConnStateAuthenticated, imap.ConnStateSelected: - // nothing to do - default: - return nil - } - - allowExpunge := true - switch cmd { - case "FETCH", "STORE", "SEARCH": - allowExpunge = false - } - - w := &UpdateWriter{conn: c, allowExpunge: allowExpunge} - return c.session.Poll(w, allowExpunge) -} - -type responseEncoder struct { - *imapwire.Encoder - conn *Conn -} - -func newResponseEncoder(conn *Conn) *responseEncoder { - conn.mutex.Lock() - quotedUTF8 := conn.enabled.Has(imap.CapIMAP4rev2) || conn.enabled.Has(imap.CapUTF8Accept) - conn.mutex.Unlock() - - wireEnc := imapwire.NewEncoder(conn.bw, imapwire.ConnSideServer) - wireEnc.QuotedUTF8 = quotedUTF8 - - conn.encMutex.Lock() // released by responseEncoder.end - conn.setWriteTimeout(respWriteTimeout) - return &responseEncoder{ - Encoder: wireEnc, - conn: conn, - } -} - -func (enc *responseEncoder) end() { - if enc.Encoder == nil { - panic("imapserver: responseEncoder.end called twice") - } - enc.Encoder = nil - enc.conn.setWriteTimeout(0) - enc.conn.encMutex.Unlock() -} - -func (enc *responseEncoder) Literal(size int64) io.WriteCloser { - enc.conn.setWriteTimeout(literalWriteTimeout) - return literalWriter{ - WriteCloser: enc.Encoder.Literal(size, nil), - conn: enc.conn, - } -} - -type literalWriter struct { - io.WriteCloser - conn *Conn -} - -func (lw literalWriter) Close() error { - lw.conn.setWriteTimeout(respWriteTimeout) - return lw.WriteCloser.Close() -} - -func writeStatusResp(enc *imapwire.Encoder, tag string, statusResp *imap.StatusResponse) error { - if tag == "" { - tag = "*" - } - enc.Atom(tag).SP().Atom(string(statusResp.Type)).SP() - if statusResp.Code != "" { - enc.Atom(fmt.Sprintf("[%v]", statusResp.Code)).SP() - } - enc.Text(statusResp.Text) - return enc.CRLF() -} - -func writeCapabilityOK(enc *imapwire.Encoder, tag string, caps []imap.Cap, text string) error { - return writeCapabilityStatus(enc, tag, imap.StatusResponseTypeOK, caps, text) -} - -func writeCapabilityStatus(enc *imapwire.Encoder, tag string, typ imap.StatusResponseType, caps []imap.Cap, text string) error { - if tag == "" { - tag = "*" - } - - enc.Atom(tag).SP().Atom(string(typ)).SP().Special('[').Atom("CAPABILITY") - for _, c := range caps { - enc.SP().Atom(string(c)) - } - enc.Special(']').SP().Text(text) - return enc.CRLF() -} - -func writeContReq(enc *imapwire.Encoder, text string) error { - return enc.Atom("+").SP().Text(text).CRLF() -} - -func newClientBugError(text string) error { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Code: imap.ResponseCodeClientBug, - Text: text, - } -} - -// UpdateWriter writes status updates. -type UpdateWriter struct { - conn *Conn - allowExpunge bool -} - -// WriteExpunge writes an EXPUNGE response. -func (w *UpdateWriter) WriteExpunge(seqNum uint32) error { - if !w.allowExpunge { - return fmt.Errorf("imapserver: EXPUNGE updates are not allowed in this context") - } - return w.conn.writeExpunge(seqNum) -} - -// WriteNumMessages writes an EXISTS response. -func (w *UpdateWriter) WriteNumMessages(n uint32) error { - return w.conn.writeExists(n) -} - -// WriteNumRecent writes an RECENT response (not used in IMAP4rev2, will be ignored). -func (w *UpdateWriter) WriteNumRecent(n uint32) error { - if w.conn.enabled.Has(imap.CapIMAP4rev2) || !w.conn.server.options.caps().Has(imap.CapIMAP4rev1) { - return nil - } - return w.conn.writeObsoleteRecent(n) -} - -// WriteMailboxFlags writes a FLAGS response. -func (w *UpdateWriter) WriteMailboxFlags(flags []imap.Flag) error { - return w.conn.writeFlags(flags) -} - -// WriteMessageFlags writes a FETCH response with FLAGS. -func (w *UpdateWriter) WriteMessageFlags(seqNum uint32, uid imap.UID, flags []imap.Flag) error { - fetchWriter := &FetchWriter{conn: w.conn} - respWriter := fetchWriter.CreateMessage(seqNum) - if uid != 0 { - respWriter.WriteUID(uid) - } - respWriter.WriteFlags(flags) - return respWriter.Close() -} diff --git a/imapserver/copy.go b/imapserver/copy.go deleted file mode 100644 index 5c933a2..0000000 --- a/imapserver/copy.go +++ /dev/null @@ -1,55 +0,0 @@ -package imapserver - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleCopy(tag string, dec *imapwire.Decoder, numKind NumKind) error { - numSet, dest, err := readCopy(numKind, dec) - if err != nil { - return err - } - if err := c.checkState(imap.ConnStateSelected); err != nil { - return err - } - data, err := c.session.Copy(numSet, dest) - if err != nil { - return err - } - - cmdName := "COPY" - if numKind == NumKindUID { - cmdName = "UID COPY" - } - if err := c.poll(cmdName); err != nil { - return err - } - - return c.writeCopyOK(tag, data) -} - -func (c *Conn) writeCopyOK(tag string, data *imap.CopyData) error { - enc := newResponseEncoder(c) - defer enc.end() - - if tag == "" { - tag = "*" - } - - enc.Atom(tag).SP().Atom("OK").SP() - if data != nil { - enc.Special('[') - enc.Atom("COPYUID").SP().Number(data.UIDValidity).SP().NumSet(data.SourceUIDs).SP().NumSet(data.DestUIDs) - enc.Special(']').SP() - } - enc.Text("COPY completed") - return enc.CRLF() -} - -func readCopy(numKind NumKind, dec *imapwire.Decoder) (numSet imap.NumSet, dest string, err error) { - if !dec.ExpectSP() || !dec.ExpectNumSet(numKind.wire(), &numSet) || !dec.ExpectSP() || !dec.ExpectMailbox(&dest) || !dec.ExpectCRLF() { - return nil, "", dec.Err() - } - return numSet, dest, nil -} diff --git a/imapserver/create.go b/imapserver/create.go deleted file mode 100644 index f15841d..0000000 --- a/imapserver/create.go +++ /dev/null @@ -1,45 +0,0 @@ -package imapserver - -import ( - "strings" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleCreate(dec *imapwire.Decoder) error { - var ( - name string - options imap.CreateOptions - ) - if !dec.ExpectSP() || !dec.ExpectMailbox(&name) { - return dec.Err() - } - if dec.SP() { - var name string - if !dec.ExpectSpecial('(') || !dec.ExpectAtom(&name) || !dec.ExpectSP() { - return dec.Err() - } - switch strings.ToUpper(name) { - case "USE": - var err error - options.SpecialUse, err = internal.ExpectMailboxAttrList(dec) - if err != nil { - return err - } - default: - return newClientBugError("unknown CREATE parameter") - } - if !dec.ExpectSpecial(')') { - return dec.Err() - } - } - if !dec.ExpectCRLF() { - return dec.Err() - } - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - return c.session.Create(name, &options) -} diff --git a/imapserver/enable.go b/imapserver/enable.go deleted file mode 100644 index 051e6c6..0000000 --- a/imapserver/enable.go +++ /dev/null @@ -1,47 +0,0 @@ -package imapserver - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleEnable(dec *imapwire.Decoder) error { - var requested []imap.Cap - for dec.SP() { - cap, err := internal.ExpectCap(dec) - if err != nil { - return err - } - requested = append(requested, cap) - } - if !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - - var enabled []imap.Cap - for _, req := range requested { - switch req { - case imap.CapIMAP4rev2, imap.CapUTF8Accept: - enabled = append(enabled, req) - } - } - - c.mutex.Lock() - for _, e := range enabled { - c.enabled[e] = struct{}{} - } - c.mutex.Unlock() - - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Atom("ENABLED") - for _, c := range enabled { - enc.SP().Atom(string(c)) - } - return enc.CRLF() -} diff --git a/imapserver/expunge.go b/imapserver/expunge.go deleted file mode 100644 index 3e4d71b..0000000 --- a/imapserver/expunge.go +++ /dev/null @@ -1,50 +0,0 @@ -package imapserver - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleExpunge(dec *imapwire.Decoder) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - return c.expunge(nil) -} - -func (c *Conn) handleUIDExpunge(dec *imapwire.Decoder) error { - var uidSet imap.UIDSet - if !dec.ExpectSP() || !dec.ExpectUIDSet(&uidSet) || !dec.ExpectCRLF() { - return dec.Err() - } - return c.expunge(&uidSet) -} - -func (c *Conn) expunge(uids *imap.UIDSet) error { - if err := c.checkState(imap.ConnStateSelected); err != nil { - return err - } - w := &ExpungeWriter{conn: c} - return c.session.Expunge(w, uids) -} - -func (c *Conn) writeExpunge(seqNum uint32) error { - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Number(seqNum).SP().Atom("EXPUNGE") - return enc.CRLF() -} - -// ExpungeWriter writes EXPUNGE updates. -type ExpungeWriter struct { - conn *Conn -} - -// WriteExpunge notifies the client that the message with the provided sequence -// number has been deleted. -func (w *ExpungeWriter) WriteExpunge(seqNum uint32) error { - if w.conn == nil { - return nil - } - return w.conn.writeExpunge(seqNum) -} diff --git a/imapserver/fetch.go b/imapserver/fetch.go deleted file mode 100644 index 48605c2..0000000 --- a/imapserver/fetch.go +++ /dev/null @@ -1,715 +0,0 @@ -package imapserver - -import ( - "fmt" - "io" - "mime" - "sort" - "strings" - "time" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -const envelopeDateLayout = "Mon, 02 Jan 2006 15:04:05 -0700" - -type fetchWriterOptions struct { - bodyStructure struct { - extended bool // BODYSTRUCTURE - nonExtended bool // BODY - } - obsolete map[*imap.FetchItemBodySection]string -} - -func (c *Conn) handleFetch(dec *imapwire.Decoder, numKind NumKind) error { - var numSet imap.NumSet - if !dec.ExpectSP() || !dec.ExpectNumSet(numKind.wire(), &numSet) || !dec.ExpectSP() { - return dec.Err() - } - - var options imap.FetchOptions - writerOptions := fetchWriterOptions{obsolete: make(map[*imap.FetchItemBodySection]string)} - isList, err := dec.List(func() error { - name, err := readFetchAttName(dec) - if err != nil { - return err - } - switch name { - case "ALL", "FAST", "FULL": - return newClientBugError("FETCH macros are not allowed in a list") - } - return handleFetchAtt(dec, name, &options, &writerOptions) - }) - if err != nil { - return err - } - if !isList { - name, err := readFetchAttName(dec) - if err != nil { - return err - } - - // Handle macros - switch name { - case "ALL": - options.Flags = true - options.InternalDate = true - options.RFC822Size = true - options.Envelope = true - case "FAST": - options.Flags = true - options.InternalDate = true - options.RFC822Size = true - case "FULL": - options.Flags = true - options.InternalDate = true - options.RFC822Size = true - options.Envelope = true - handleFetchBodyStructure(&options, &writerOptions, false) - default: - if err := handleFetchAtt(dec, name, &options, &writerOptions); err != nil { - return err - } - } - } - - if !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateSelected); err != nil { - return err - } - - if numKind == NumKindUID { - options.UID = true - } - - w := &FetchWriter{conn: c, options: writerOptions} - if err := c.session.Fetch(w, numSet, &options); err != nil { - return err - } - return nil -} - -func handleFetchAtt(dec *imapwire.Decoder, attName string, options *imap.FetchOptions, writerOptions *fetchWriterOptions) error { - switch attName { - case "BODYSTRUCTURE": - handleFetchBodyStructure(options, writerOptions, true) - case "ENVELOPE": - options.Envelope = true - case "FLAGS": - options.Flags = true - case "INTERNALDATE": - options.InternalDate = true - case "RFC822.SIZE": - options.RFC822Size = true - case "UID": - options.UID = true - case "RFC822": // equivalent to BODY[] - bs := &imap.FetchItemBodySection{} - writerOptions.obsolete[bs] = attName - options.BodySection = append(options.BodySection, bs) - case "RFC822.PEEK": // obsolete, equivalent to BODY.PEEK[], used by Outlook - bs := &imap.FetchItemBodySection{Peek: true} - writerOptions.obsolete[bs] = attName - options.BodySection = append(options.BodySection, bs) - case "RFC822.HEADER": // equivalent to BODY.PEEK[HEADER] - bs := &imap.FetchItemBodySection{ - Specifier: imap.PartSpecifierHeader, - Peek: true, - } - writerOptions.obsolete[bs] = attName - options.BodySection = append(options.BodySection, bs) - case "RFC822.TEXT": // equivalent to BODY[TEXT] - bs := &imap.FetchItemBodySection{ - Specifier: imap.PartSpecifierText, - } - writerOptions.obsolete[bs] = attName - options.BodySection = append(options.BodySection, bs) - case "BINARY", "BINARY.PEEK": - part, err := readSectionBinary(dec) - if err != nil { - return err - } - partial, err := maybeReadPartial(dec) - if err != nil { - return err - } - bs := &imap.FetchItemBinarySection{ - Part: part, - Partial: partial, - Peek: attName == "BINARY.PEEK", - } - options.BinarySection = append(options.BinarySection, bs) - case "BINARY.SIZE": - part, err := readSectionBinary(dec) - if err != nil { - return err - } - bss := &imap.FetchItemBinarySectionSize{Part: part} - options.BinarySectionSize = append(options.BinarySectionSize, bss) - case "BODY": - if !dec.Special('[') { - handleFetchBodyStructure(options, writerOptions, false) - return nil - } - section := imap.FetchItemBodySection{} - err := readSection(dec, §ion) - if err != nil { - return err - } - section.Partial, err = maybeReadPartial(dec) - if err != nil { - return err - } - options.BodySection = append(options.BodySection, §ion) - case "BODY.PEEK": - if !dec.ExpectSpecial('[') { - return dec.Err() - } - section := imap.FetchItemBodySection{Peek: true} - err := readSection(dec, §ion) - if err != nil { - return err - } - section.Partial, err = maybeReadPartial(dec) - if err != nil { - return err - } - options.BodySection = append(options.BodySection, §ion) - default: - return newClientBugError("Unknown FETCH data item") - } - return nil -} - -func handleFetchBodyStructure(options *imap.FetchOptions, writerOptions *fetchWriterOptions, extended bool) { - if options.BodyStructure == nil || extended { - options.BodyStructure = &imap.FetchItemBodyStructure{Extended: extended} - } - if extended { - writerOptions.bodyStructure.extended = true - } else { - writerOptions.bodyStructure.nonExtended = true - } -} - -func readFetchAttName(dec *imapwire.Decoder) (string, error) { - var attName string - if !dec.Expect(dec.Func(&attName, isMsgAttNameChar), "msg-att name") { - return "", dec.Err() - } - return strings.ToUpper(attName), nil -} - -func isMsgAttNameChar(ch byte) bool { - return ch != '[' && imapwire.IsAtomChar(ch) -} - -func readSection(dec *imapwire.Decoder, section *imap.FetchItemBodySection) error { - if dec.Special(']') { - return nil - } - - var dot bool - section.Part, dot = readSectionPart(dec) - if dot || len(section.Part) == 0 { - var specifier string - if dot { - if !dec.ExpectAtom(&specifier) { - return dec.Err() - } - } else { - dec.Atom(&specifier) - } - - switch specifier := imap.PartSpecifier(strings.ToUpper(specifier)); specifier { - case imap.PartSpecifierNone, imap.PartSpecifierHeader, imap.PartSpecifierMIME, imap.PartSpecifierText: - section.Specifier = specifier - case "HEADER.FIELDS", "HEADER.FIELDS.NOT": - if !dec.ExpectSP() { - return dec.Err() - } - var err error - headerList, err := readHeaderList(dec) - if err != nil { - return err - } - section.Specifier = imap.PartSpecifierHeader - if specifier == "HEADER.FIELDS" { - section.HeaderFields = headerList - } else { - section.HeaderFieldsNot = headerList - } - default: - return newClientBugError("unknown body section specifier") - } - } - - if !dec.ExpectSpecial(']') { - return dec.Err() - } - - return nil -} - -func readSectionPart(dec *imapwire.Decoder) (part []int, dot bool) { - for { - dot = len(part) > 0 - if dot && !dec.Special('.') { - return part, false - } - - var num uint32 - if !dec.Number(&num) { - return part, dot - } - part = append(part, int(num)) - } -} - -func readHeaderList(dec *imapwire.Decoder) ([]string, error) { - var l []string - err := dec.ExpectList(func() error { - var s string - if !dec.ExpectAString(&s) { - return dec.Err() - } - l = append(l, s) - return nil - }) - return l, err -} - -func readSectionBinary(dec *imapwire.Decoder) ([]int, error) { - if !dec.ExpectSpecial('[') { - return nil, dec.Err() - } - if dec.Special(']') { - return nil, nil - } - - var l []int - for { - var num uint32 - if !dec.ExpectNumber(&num) { - return l, dec.Err() - } - l = append(l, int(num)) - - if !dec.Special('.') { - break - } - } - - if !dec.ExpectSpecial(']') { - return l, dec.Err() - } - return l, nil -} - -func maybeReadPartial(dec *imapwire.Decoder) (*imap.SectionPartial, error) { - if !dec.Special('<') { - return nil, nil - } - var partial imap.SectionPartial - if !dec.ExpectNumber64(&partial.Offset) || !dec.ExpectSpecial('.') || !dec.ExpectNumber64(&partial.Size) || !dec.ExpectSpecial('>') { - return nil, dec.Err() - } - return &partial, nil -} - -// FetchWriter writes FETCH responses. -type FetchWriter struct { - conn *Conn - options fetchWriterOptions -} - -// CreateMessage writes a FETCH response for a message. -// -// FetchResponseWriter.Close must be called. -func (cmd *FetchWriter) CreateMessage(seqNum uint32) *FetchResponseWriter { - enc := newResponseEncoder(cmd.conn) - enc.Atom("*").SP().Number(seqNum).SP().Atom("FETCH").SP().Special('(') - return &FetchResponseWriter{enc: enc, options: cmd.options} -} - -// FetchResponseWriter writes a single FETCH response for a message. -type FetchResponseWriter struct { - enc *responseEncoder - options fetchWriterOptions - - hasItem bool -} - -func (w *FetchResponseWriter) writeItemSep() { - if w.hasItem { - w.enc.SP() - } - w.hasItem = true -} - -// WriteUID writes the message's UID. -func (w *FetchResponseWriter) WriteUID(uid imap.UID) { - w.writeItemSep() - w.enc.Atom("UID").SP().UID(uid) -} - -// WriteFlags writes the message's flags. -func (w *FetchResponseWriter) WriteFlags(flags []imap.Flag) { - w.writeItemSep() - w.enc.Atom("FLAGS").SP().List(len(flags), func(i int) { - w.enc.Flag(flags[i]) - }) -} - -// WriteRFC822Size writes the message's full size. -func (w *FetchResponseWriter) WriteRFC822Size(size int64) { - w.writeItemSep() - w.enc.Atom("RFC822.SIZE").SP().Number64(size) -} - -// WriteInternalDate writes the message's internal date. -func (w *FetchResponseWriter) WriteInternalDate(t time.Time) { - w.writeItemSep() - w.enc.Atom("INTERNALDATE").SP().String(t.Format(internal.DateTimeLayout)) -} - -// WriteBodySection writes a body section. -// -// The returned io.WriteCloser must be closed before writing any more message -// data items. -func (w *FetchResponseWriter) WriteBodySection(section *imap.FetchItemBodySection, size int64) io.WriteCloser { - w.writeItemSep() - enc := w.enc.Encoder - - if obs, ok := w.options.obsolete[section]; ok { - enc.Atom(obs) - } else { - writeItemBodySection(enc, section) - } - - enc.SP() - return w.enc.Literal(size) -} - -func writeItemBodySection(enc *imapwire.Encoder, section *imap.FetchItemBodySection) { - enc.Atom("BODY") - enc.Special('[') - writeSectionPart(enc, section.Part) - if len(section.Part) > 0 && section.Specifier != imap.PartSpecifierNone { - enc.Special('.') - } - if section.Specifier != imap.PartSpecifierNone { - enc.Atom(string(section.Specifier)) - - var headerList []string - if len(section.HeaderFields) > 0 { - headerList = section.HeaderFields - enc.Atom(".FIELDS") - } else if len(section.HeaderFieldsNot) > 0 { - headerList = section.HeaderFieldsNot - enc.Atom(".FIELDS.NOT") - } - - if len(headerList) > 0 { - enc.SP().List(len(headerList), func(i int) { - enc.String(headerList[i]) - }) - } - } - enc.Special(']') - if partial := section.Partial; partial != nil { - enc.Special('<').Number(uint32(partial.Offset)).Special('>') - } -} - -// WriteBinarySection writes a binary section. -// -// The returned io.WriteCloser must be closed before writing any more message -// data items. -func (w *FetchResponseWriter) WriteBinarySection(section *imap.FetchItemBinarySection, size int64) io.WriteCloser { - w.writeItemSep() - enc := w.enc.Encoder - - enc.Atom("BINARY").Special('[') - writeSectionPart(enc, section.Part) - enc.Special(']').SP() - enc.Special('~') // indicates literal8 - return w.enc.Literal(size) -} - -// WriteBinarySectionSize writes a binary section size. -func (w *FetchResponseWriter) WriteBinarySectionSize(section *imap.FetchItemBinarySectionSize, size uint32) { - w.writeItemSep() - enc := w.enc.Encoder - - enc.Atom("BINARY.SIZE").Special('[') - writeSectionPart(enc, section.Part) - enc.Special(']').SP().Number(size) -} - -// WriteEnvelope writes the message's envelope. -func (w *FetchResponseWriter) WriteEnvelope(envelope *imap.Envelope) { - w.writeItemSep() - enc := w.enc.Encoder - enc.Atom("ENVELOPE").SP() - writeEnvelope(enc, envelope) -} - -// WriteBodyStructure writes the message's body structure (either BODYSTRUCTURE -// or BODY). -func (w *FetchResponseWriter) WriteBodyStructure(bs imap.BodyStructure) { - if w.options.bodyStructure.nonExtended { - w.writeBodyStructure(bs, false) - } - - if w.options.bodyStructure.extended { - var isExtended bool - switch bs := bs.(type) { - case *imap.BodyStructureSinglePart: - isExtended = bs.Extended != nil - case *imap.BodyStructureMultiPart: - isExtended = bs.Extended != nil - } - if !isExtended { - panic("imapserver: client requested extended body structure but a non-extended one is written back") - } - - w.writeBodyStructure(bs, true) - } -} - -func (w *FetchResponseWriter) writeBodyStructure(bs imap.BodyStructure, extended bool) { - item := "BODY" - if extended { - item = "BODYSTRUCTURE" - } - - w.writeItemSep() - enc := w.enc.Encoder - enc.Atom(item).SP() - writeBodyStructure(enc, bs, extended) -} - -// Close closes the FETCH message writer. -func (w *FetchResponseWriter) Close() error { - if w.enc == nil { - return fmt.Errorf("imapserver: FetchResponseWriter already closed") - } - err := w.enc.Special(')').CRLF() - w.enc.end() - w.enc = nil - return err -} - -func writeEnvelope(enc *imapwire.Encoder, envelope *imap.Envelope) { - if envelope == nil { - envelope = new(imap.Envelope) - } - - sender := envelope.Sender - if sender == nil { - sender = envelope.From - } - replyTo := envelope.ReplyTo - if replyTo == nil { - replyTo = envelope.From - } - - enc.Special('(') - if envelope.Date.IsZero() { - enc.NIL() - } else { - enc.String(envelope.Date.Format(envelopeDateLayout)) - } - enc.SP() - writeNString(enc, mime.QEncoding.Encode("utf-8", envelope.Subject)) - addrs := [][]imap.Address{ - envelope.From, - sender, - replyTo, - envelope.To, - envelope.Cc, - envelope.Bcc, - } - for _, l := range addrs { - enc.SP() - writeAddressList(enc, l) - } - enc.SP() - if len(envelope.InReplyTo) > 0 { - enc.String("<" + strings.Join(envelope.InReplyTo, "> <") + ">") - } else { - enc.NIL() - } - enc.SP() - if envelope.MessageID != "" { - enc.String("<" + envelope.MessageID + ">") - } else { - enc.NIL() - } - enc.Special(')') -} - -func writeAddressList(enc *imapwire.Encoder, l []imap.Address) { - if len(l) == 0 { - enc.NIL() - return - } - - enc.List(len(l), func(i int) { - addr := l[i] - enc.Special('(') - writeNString(enc, mime.QEncoding.Encode("utf-8", addr.Name)) - enc.SP().NIL().SP() - writeNString(enc, addr.Mailbox) - enc.SP() - writeNString(enc, addr.Host) - enc.Special(')') - }) -} - -func writeNString(enc *imapwire.Encoder, s string) { - if s == "" { - enc.NIL() - } else { - enc.String(s) - } -} - -func writeSectionPart(enc *imapwire.Encoder, part []int) { - if len(part) == 0 { - return - } - - var l []string - for _, num := range part { - l = append(l, fmt.Sprintf("%v", num)) - } - enc.Atom(strings.Join(l, ".")) -} - -func writeBodyStructure(enc *imapwire.Encoder, bs imap.BodyStructure, extended bool) { - enc.Special('(') - switch bs := bs.(type) { - case *imap.BodyStructureSinglePart: - writeBodyType1part(enc, bs, extended) - case *imap.BodyStructureMultiPart: - writeBodyTypeMpart(enc, bs, extended) - default: - panic(fmt.Errorf("unknown body structure type %T", bs)) - } - enc.Special(')') -} - -func writeBodyType1part(enc *imapwire.Encoder, bs *imap.BodyStructureSinglePart, extended bool) { - enc.String(bs.Type).SP().String(bs.Subtype).SP() - writeBodyFldParam(enc, bs.Params) - enc.SP() - writeNString(enc, bs.ID) - enc.SP() - writeNString(enc, bs.Description) - enc.SP() - if bs.Encoding == "" { - enc.String("7bit") - } else { - // Outlook for iOS chokes on upper-case encodings - enc.String(strings.ToLower(bs.Encoding)) - } - enc.SP().Number(bs.Size) - - if msg := bs.MessageRFC822; msg != nil { - enc.SP() - writeEnvelope(enc, msg.Envelope) - enc.SP() - writeBodyStructure(enc, msg.BodyStructure, extended) - enc.SP().Number64(msg.NumLines) - } else if text := bs.Text; text != nil { - enc.SP().Number64(text.NumLines) - } - - if !extended { - return - } - ext := bs.Extended - - enc.SP() - enc.NIL() // MD5 - enc.SP() - writeBodyFldDsp(enc, ext.Disposition) - enc.SP() - writeBodyFldLang(enc, ext.Language) - enc.SP() - writeNString(enc, ext.Location) -} - -func writeBodyTypeMpart(enc *imapwire.Encoder, bs *imap.BodyStructureMultiPart, extended bool) { - if len(bs.Children) == 0 { - panic("imapserver: imap.BodyStructureMultiPart must have at least one child") - } - for _, child := range bs.Children { - // ABNF for body-type-mpart doesn't have SP between body entries, and - // Outlook for iOS chokes on SP - writeBodyStructure(enc, child, extended) - } - - enc.SP().String(bs.Subtype) - - if !extended { - return - } - ext := bs.Extended - - enc.SP() - writeBodyFldParam(enc, ext.Params) - enc.SP() - writeBodyFldDsp(enc, ext.Disposition) - enc.SP() - writeBodyFldLang(enc, ext.Language) - enc.SP() - writeNString(enc, ext.Location) -} - -func writeBodyFldParam(enc *imapwire.Encoder, params map[string]string) { - if len(params) == 0 { - enc.NIL() - return - } - - var l []string - for k := range params { - l = append(l, k) - } - sort.Strings(l) - - enc.List(len(l), func(i int) { - k := l[i] - v := params[k] - enc.String(k).SP().String(v) - }) -} - -func writeBodyFldDsp(enc *imapwire.Encoder, disp *imap.BodyStructureDisposition) { - if disp == nil { - enc.NIL() - return - } - - enc.Special('(').String(disp.Value).SP() - writeBodyFldParam(enc, disp.Params) - enc.Special(')') -} - -func writeBodyFldLang(enc *imapwire.Encoder, l []string) { - if len(l) == 0 { - enc.NIL() - } else { - enc.List(len(l), func(i int) { - enc.String(l[i]) - }) - } -} diff --git a/imapserver/idle.go b/imapserver/idle.go deleted file mode 100644 index 3b67c73..0000000 --- a/imapserver/idle.go +++ /dev/null @@ -1,50 +0,0 @@ -package imapserver - -import ( - "fmt" - "io" - "runtime/debug" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleIdle(dec *imapwire.Decoder) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - - if err := c.writeContReq("idling"); err != nil { - return err - } - - stop := make(chan struct{}) - done := make(chan error, 1) - go func() { - defer func() { - if v := recover(); v != nil { - c.server.logger().Printf("panic idling: %v\n%s", v, debug.Stack()) - done <- fmt.Errorf("imapserver: panic idling") - } - }() - w := &UpdateWriter{conn: c, allowExpunge: true} - done <- c.session.Idle(w, stop) - }() - - c.setReadTimeout(idleReadTimeout) - line, isPrefix, err := c.br.ReadLine() - close(stop) - if err == io.EOF { - return nil - } else if err != nil { - return err - } else if isPrefix || string(line) != "DONE" { - return newClientBugError("Syntax error: expected DONE to end IDLE command") - } - - return <-done -} diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go deleted file mode 100644 index dee9a67..0000000 --- a/imapserver/imapmemserver/mailbox.go +++ /dev/null @@ -1,511 +0,0 @@ -package imapmemserver - -import ( - "bytes" - "sort" - "sync" - "time" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/imapserver" -) - -// Mailbox is an in-memory mailbox. -// -// The same mailbox can be shared between multiple connections and multiple -// users. -type Mailbox struct { - tracker *imapserver.MailboxTracker - uidValidity uint32 - - mutex sync.Mutex - name string - subscribed bool - specialUse []imap.MailboxAttr - l []*message - uidNext imap.UID -} - -// NewMailbox creates a new mailbox. -func NewMailbox(name string, uidValidity uint32) *Mailbox { - return &Mailbox{ - tracker: imapserver.NewMailboxTracker(0), - uidValidity: uidValidity, - name: name, - uidNext: 1, - } -} - -func (mbox *Mailbox) list(options *imap.ListOptions) *imap.ListData { - mbox.mutex.Lock() - defer mbox.mutex.Unlock() - - if options.SelectSubscribed && !mbox.subscribed { - return nil - } - if options.SelectSpecialUse && len(mbox.specialUse) == 0 { - return nil - } - - data := imap.ListData{ - Mailbox: mbox.name, - Delim: mailboxDelim, - } - if mbox.subscribed { - data.Attrs = append(data.Attrs, imap.MailboxAttrSubscribed) - } - if (options.ReturnSpecialUse || options.SelectSpecialUse) && len(mbox.specialUse) > 0 { - data.Attrs = append(data.Attrs, mbox.specialUse...) - } - if options.ReturnStatus != nil { - data.Status = mbox.statusDataLocked(options.ReturnStatus) - } - return &data -} - -// StatusData returns data for the STATUS command. -func (mbox *Mailbox) StatusData(options *imap.StatusOptions) *imap.StatusData { - mbox.mutex.Lock() - defer mbox.mutex.Unlock() - return mbox.statusDataLocked(options) -} - -func (mbox *Mailbox) statusDataLocked(options *imap.StatusOptions) *imap.StatusData { - data := imap.StatusData{Mailbox: mbox.name} - if options.NumMessages { - num := uint32(len(mbox.l)) - data.NumMessages = &num - } - if options.UIDNext { - data.UIDNext = mbox.uidNext - } - if options.UIDValidity { - data.UIDValidity = mbox.uidValidity - } - if options.NumUnseen { - num := uint32(len(mbox.l)) - mbox.countByFlagLocked(imap.FlagSeen) - data.NumUnseen = &num - } - if options.NumDeleted { - num := mbox.countByFlagLocked(imap.FlagDeleted) - data.NumDeleted = &num - } - if options.Size { - size := mbox.sizeLocked() - data.Size = &size - } - if options.NumRecent { - num := uint32(0) - data.NumRecent = &num - } - return &data -} - -func (mbox *Mailbox) countByFlagLocked(flag imap.Flag) uint32 { - var n uint32 - for _, msg := range mbox.l { - if _, ok := msg.flags[canonicalFlag(flag)]; ok { - n++ - } - } - return n -} - -func (mbox *Mailbox) sizeLocked() int64 { - var size int64 - for _, msg := range mbox.l { - size += int64(len(msg.buf)) - } - return size -} - -func (mbox *Mailbox) appendLiteral(r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) { - var buf bytes.Buffer - if _, err := buf.ReadFrom(r); err != nil { - return nil, err - } - return mbox.appendBytes(buf.Bytes(), options), nil -} - -func (mbox *Mailbox) copyMsg(msg *message) *imap.AppendData { - return mbox.appendBytes(msg.buf, &imap.AppendOptions{ - Time: msg.t, - Flags: msg.flagList(), - }) -} - -func (mbox *Mailbox) appendBytes(buf []byte, options *imap.AppendOptions) *imap.AppendData { - msg := &message{ - flags: make(map[imap.Flag]struct{}), - buf: buf, - } - - if options.Time.IsZero() { - msg.t = time.Now() - } else { - msg.t = options.Time - } - - for _, flag := range options.Flags { - msg.flags[canonicalFlag(flag)] = struct{}{} - } - - mbox.mutex.Lock() - defer mbox.mutex.Unlock() - - msg.uid = mbox.uidNext - mbox.uidNext++ - - mbox.l = append(mbox.l, msg) - mbox.tracker.QueueNumMessages(uint32(len(mbox.l))) - - return &imap.AppendData{ - UIDValidity: mbox.uidValidity, - UID: msg.uid, - } -} - -func (mbox *Mailbox) rename(newName string) { - mbox.mutex.Lock() - mbox.name = newName - mbox.mutex.Unlock() -} - -// SetSubscribed changes the subscription state of this mailbox. -func (mbox *Mailbox) SetSubscribed(subscribed bool) { - mbox.mutex.Lock() - mbox.subscribed = subscribed - mbox.mutex.Unlock() -} - -func (mbox *Mailbox) selectDataLocked() *imap.SelectData { - flags := mbox.flagsLocked() - - permanentFlags := make([]imap.Flag, len(flags)) - copy(permanentFlags, flags) - permanentFlags = append(permanentFlags, imap.FlagWildcard) - - // TODO: skip if IMAP4rev1 is disabled by the server, or IMAP4rev2 is - // enabled by the client - firstUnseenSeqNum := mbox.firstUnseenSeqNumLocked() - - return &imap.SelectData{ - Flags: flags, - PermanentFlags: permanentFlags, - NumMessages: uint32(len(mbox.l)), - FirstUnseenSeqNum: firstUnseenSeqNum, - UIDNext: mbox.uidNext, - UIDValidity: mbox.uidValidity, - } -} - -func (mbox *Mailbox) firstUnseenSeqNumLocked() uint32 { - for i, msg := range mbox.l { - seqNum := uint32(i) + 1 - if _, ok := msg.flags[canonicalFlag(imap.FlagSeen)]; !ok { - return seqNum - } - } - return 0 -} - -func (mbox *Mailbox) flagsLocked() []imap.Flag { - m := make(map[imap.Flag]struct{}) - for _, msg := range mbox.l { - for flag := range msg.flags { - m[flag] = struct{}{} - } - } - - var l []imap.Flag - for flag := range m { - l = append(l, flag) - } - - sort.Slice(l, func(i, j int) bool { - return l[i] < l[j] - }) - - return l -} - -func (mbox *Mailbox) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error { - expunged := make(map[*message]struct{}) - mbox.mutex.Lock() - for _, msg := range mbox.l { - if uids != nil && !uids.Contains(msg.uid) { - continue - } - if _, ok := msg.flags[canonicalFlag(imap.FlagDeleted)]; ok { - expunged[msg] = struct{}{} - } - } - mbox.mutex.Unlock() - - if len(expunged) == 0 { - return nil - } - - mbox.mutex.Lock() - mbox.expungeLocked(expunged) - mbox.mutex.Unlock() - - return nil -} - -func (mbox *Mailbox) expungeLocked(expunged map[*message]struct{}) (seqNums []uint32) { - // TODO: optimize - - // Iterate in reverse order, to keep sequence numbers consistent - var filtered []*message - for i := len(mbox.l) - 1; i >= 0; i-- { - msg := mbox.l[i] - if _, ok := expunged[msg]; ok { - seqNum := uint32(i) + 1 - seqNums = append(seqNums, seqNum) - mbox.tracker.QueueExpunge(seqNum) - } else { - filtered = append(filtered, msg) - } - } - - // Reverse filtered - for i := 0; i < len(filtered)/2; i++ { - j := len(filtered) - i - 1 - filtered[i], filtered[j] = filtered[j], filtered[i] - } - - mbox.l = filtered - - return seqNums -} - -// NewView creates a new view into this mailbox. -// -// Callers must call MailboxView.Close once they are done with the mailbox view. -func (mbox *Mailbox) NewView() *MailboxView { - return &MailboxView{ - Mailbox: mbox, - tracker: mbox.tracker.NewSession(), - } -} - -// A MailboxView is a view into a mailbox. -// -// Each view has its own queue of pending unilateral updates. -// -// Once the mailbox view is no longer used, Close must be called. -// -// Typically, a new MailboxView is created for each IMAP connection in the -// selected state. -type MailboxView struct { - *Mailbox - tracker *imapserver.SessionTracker - searchRes imap.UIDSet -} - -// Close releases the resources allocated for the mailbox view. -func (mbox *MailboxView) Close() { - mbox.tracker.Close() -} - -func (mbox *MailboxView) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error { - markSeen := false - for _, bs := range options.BodySection { - if !bs.Peek { - markSeen = true - break - } - } - - var err error - mbox.forEach(numSet, func(seqNum uint32, msg *message) { - if err != nil { - return - } - - if markSeen { - msg.flags[canonicalFlag(imap.FlagSeen)] = struct{}{} - mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), nil) - } - - respWriter := w.CreateMessage(mbox.tracker.EncodeSeqNum(seqNum)) - err = msg.fetch(respWriter, options) - }) - return err -} - -func (mbox *MailboxView) Search(numKind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) { - mbox.mutex.Lock() - defer mbox.mutex.Unlock() - - mbox.staticSearchCriteria(criteria) - - var ( - data imap.SearchData - seqSet imap.SeqSet - uidSet imap.UIDSet - ) - for i, msg := range mbox.l { - seqNum := mbox.tracker.EncodeSeqNum(uint32(i) + 1) - - if !msg.search(seqNum, criteria) { - continue - } - - // Always populate the UID set, since it may be saved later for SEARCHRES - uidSet.AddNum(msg.uid) - - var num uint32 - switch numKind { - case imapserver.NumKindSeq: - if seqNum == 0 { - continue - } - seqSet.AddNum(seqNum) - num = seqNum - case imapserver.NumKindUID: - num = uint32(msg.uid) - } - if data.Min == 0 || num < data.Min { - data.Min = num - } - if data.Max == 0 || num > data.Max { - data.Max = num - } - data.Count++ - } - - switch numKind { - case imapserver.NumKindSeq: - data.All = seqSet - case imapserver.NumKindUID: - data.All = uidSet - } - - if options.ReturnSave { - mbox.searchRes = uidSet - } - - return &data, nil -} - -func (mbox *MailboxView) staticSearchCriteria(criteria *imap.SearchCriteria) { - seqNums := make([]imap.SeqSet, 0, len(criteria.SeqNum)) - for _, seqSet := range criteria.SeqNum { - numSet := mbox.staticNumSet(seqSet) - switch numSet := numSet.(type) { - case imap.SeqSet: - seqNums = append(seqNums, numSet) - case imap.UIDSet: // can happen with SEARCHRES - criteria.UID = append(criteria.UID, numSet) - } - } - criteria.SeqNum = seqNums - - for i, uidSet := range criteria.UID { - criteria.UID[i] = mbox.staticNumSet(uidSet).(imap.UIDSet) - } - - for i := range criteria.Not { - mbox.staticSearchCriteria(&criteria.Not[i]) - } - for i := range criteria.Or { - for j := range criteria.Or[i] { - mbox.staticSearchCriteria(&criteria.Or[i][j]) - } - } -} - -func (mbox *MailboxView) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { - mbox.forEach(numSet, func(seqNum uint32, msg *message) { - msg.store(flags) - mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker) - }) - if !flags.Silent { - return mbox.Fetch(w, numSet, &imap.FetchOptions{Flags: true}) - } - return nil -} - -func (mbox *MailboxView) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error { - return mbox.tracker.Poll(w, allowExpunge) -} - -func (mbox *MailboxView) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error { - return mbox.tracker.Idle(w, stop) -} - -func (mbox *MailboxView) forEach(numSet imap.NumSet, f func(seqNum uint32, msg *message)) { - mbox.mutex.Lock() - defer mbox.mutex.Unlock() - mbox.forEachLocked(numSet, f) -} - -func (mbox *MailboxView) forEachLocked(numSet imap.NumSet, f func(seqNum uint32, msg *message)) { - // TODO: optimize - - numSet = mbox.staticNumSet(numSet) - - for i, msg := range mbox.l { - seqNum := uint32(i) + 1 - - var contains bool - switch numSet := numSet.(type) { - case imap.SeqSet: - seqNum := mbox.tracker.EncodeSeqNum(seqNum) - contains = seqNum != 0 && numSet.Contains(seqNum) - case imap.UIDSet: - contains = numSet.Contains(msg.uid) - } - if !contains { - continue - } - - f(seqNum, msg) - } -} - -// staticNumSet converts a dynamic sequence set into a static one. -// -// This is necessary to properly handle the special symbol "*", which -// represents the maximum sequence number or UID in the mailbox. -// -// This function also handles the special SEARCHRES marker "$". -func (mbox *MailboxView) staticNumSet(numSet imap.NumSet) imap.NumSet { - if imap.IsSearchRes(numSet) { - return mbox.searchRes - } - - switch numSet := numSet.(type) { - case imap.SeqSet: - max := uint32(len(mbox.l)) - for i := range numSet { - r := &numSet[i] - staticNumRange(&r.Start, &r.Stop, max) - } - case imap.UIDSet: - max := uint32(mbox.uidNext) - 1 - for i := range numSet { - r := &numSet[i] - staticNumRange((*uint32)(&r.Start), (*uint32)(&r.Stop), max) - } - } - - return numSet -} - -func staticNumRange(start, stop *uint32, max uint32) { - dyn := false - if *start == 0 { - *start = max - dyn = true - } - if *stop == 0 { - *stop = max - dyn = true - } - if dyn && *start > *stop { - *start, *stop = *stop, *start - } -} diff --git a/imapserver/imapmemserver/message.go b/imapserver/imapmemserver/message.go deleted file mode 100644 index d558045..0000000 --- a/imapserver/imapmemserver/message.go +++ /dev/null @@ -1,273 +0,0 @@ -package imapmemserver - -import ( - "bufio" - "bytes" - "fmt" - "io" - "strings" - "time" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/imapserver" - gomessage "github.com/emersion/go-message" - "github.com/emersion/go-message/mail" - "github.com/emersion/go-message/textproto" -) - -type message struct { - // immutable - uid imap.UID - buf []byte - t time.Time - - // mutable, protected by Mailbox.mutex - flags map[imap.Flag]struct{} -} - -func (msg *message) fetch(w *imapserver.FetchResponseWriter, options *imap.FetchOptions) error { - w.WriteUID(msg.uid) - - if options.Flags { - w.WriteFlags(msg.flagList()) - } - if options.InternalDate { - w.WriteInternalDate(msg.t) - } - if options.RFC822Size { - w.WriteRFC822Size(int64(len(msg.buf))) - } - if options.Envelope { - w.WriteEnvelope(msg.envelope()) - } - if options.BodyStructure != nil { - w.WriteBodyStructure(imapserver.ExtractBodyStructure(bytes.NewReader(msg.buf))) - } - - for _, bs := range options.BodySection { - buf := imapserver.ExtractBodySection(bytes.NewReader(msg.buf), bs) - wc := w.WriteBodySection(bs, int64(len(buf))) - _, writeErr := wc.Write(buf) - closeErr := wc.Close() - if writeErr != nil { - return writeErr - } - if closeErr != nil { - return closeErr - } - } - - for _, bs := range options.BinarySection { - buf := imapserver.ExtractBinarySection(bytes.NewReader(msg.buf), bs) - wc := w.WriteBinarySection(bs, int64(len(buf))) - _, writeErr := wc.Write(buf) - closeErr := wc.Close() - if writeErr != nil { - return writeErr - } - if closeErr != nil { - return closeErr - } - } - - for _, bss := range options.BinarySectionSize { - n := imapserver.ExtractBinarySectionSize(bytes.NewReader(msg.buf), bss) - w.WriteBinarySectionSize(bss, n) - } - - return w.Close() -} - -func (msg *message) envelope() *imap.Envelope { - br := bufio.NewReader(bytes.NewReader(msg.buf)) - header, err := textproto.ReadHeader(br) - if err != nil { - return nil - } - return imapserver.ExtractEnvelope(header) -} - -func (msg *message) flagList() []imap.Flag { - var flags []imap.Flag - for flag := range msg.flags { - flags = append(flags, flag) - } - return flags -} - -func (msg *message) store(store *imap.StoreFlags) { - switch store.Op { - case imap.StoreFlagsSet: - msg.flags = make(map[imap.Flag]struct{}) - fallthrough - case imap.StoreFlagsAdd: - for _, flag := range store.Flags { - msg.flags[canonicalFlag(flag)] = struct{}{} - } - case imap.StoreFlagsDel: - for _, flag := range store.Flags { - delete(msg.flags, canonicalFlag(flag)) - } - default: - panic(fmt.Errorf("unknown STORE flag operation: %v", store.Op)) - } -} - -func (msg *message) reader() *gomessage.Entity { - r, _ := gomessage.Read(bytes.NewReader(msg.buf)) - if r == nil { - r, _ = gomessage.New(gomessage.Header{}, bytes.NewReader(nil)) - } - return r -} - -func (msg *message) search(seqNum uint32, criteria *imap.SearchCriteria) bool { - for _, seqSet := range criteria.SeqNum { - if seqNum == 0 || !seqSet.Contains(seqNum) { - return false - } - } - for _, uidSet := range criteria.UID { - if !uidSet.Contains(msg.uid) { - return false - } - } - if !matchDate(msg.t, criteria.Since, criteria.Before) { - return false - } - - for _, flag := range criteria.Flag { - if _, ok := msg.flags[canonicalFlag(flag)]; !ok { - return false - } - } - for _, flag := range criteria.NotFlag { - if _, ok := msg.flags[canonicalFlag(flag)]; ok { - return false - } - } - - if criteria.Larger != 0 && int64(len(msg.buf)) <= criteria.Larger { - return false - } - if criteria.Smaller != 0 && int64(len(msg.buf)) >= criteria.Smaller { - return false - } - - header := mail.Header{msg.reader().Header} - - for _, fieldCriteria := range criteria.Header { - if !matchHeaderFields(header.FieldsByKey(fieldCriteria.Key), fieldCriteria.Value) { - return false - } - } - - if !criteria.SentSince.IsZero() || !criteria.SentBefore.IsZero() { - t, err := header.Date() - if err != nil { - return false - } else if !matchDate(t, criteria.SentSince, criteria.SentBefore) { - return false - } - } - - for _, text := range criteria.Text { - if !matchEntity(msg.reader(), text, true) { - return false - } - } - for _, body := range criteria.Body { - if !matchEntity(msg.reader(), body, false) { - return false - } - } - - for _, not := range criteria.Not { - if msg.search(seqNum, ¬) { - return false - } - } - for _, or := range criteria.Or { - if !msg.search(seqNum, &or[0]) && !msg.search(seqNum, &or[1]) { - return false - } - } - - return true -} - -func matchDate(t, since, before time.Time) bool { - // We discard time zone information by setting it to UTC. - // RFC 3501 explicitly requires zone unaware date comparison. - t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) - - if !since.IsZero() && t.Before(since) { - return false - } - if !before.IsZero() && !t.Before(before) { - return false - } - return true -} - -func matchHeaderFields(fields gomessage.HeaderFields, pattern string) bool { - if pattern == "" { - return fields.Len() > 0 - } - - pattern = strings.ToLower(pattern) - for fields.Next() { - v, _ := fields.Text() - if strings.Contains(strings.ToLower(v), pattern) { - return true - } - } - return false -} - -func matchEntity(e *gomessage.Entity, pattern string, includeHeader bool) bool { - if pattern == "" { - return true - } - - if includeHeader && matchHeaderFields(e.Header.Fields(), pattern) { - return true - } - - if mr := e.MultipartReader(); mr != nil { - for { - part, err := mr.NextPart() - if err == io.EOF { - break - } else if err != nil { - return false - } - - if matchEntity(part, pattern, includeHeader) { - return true - } - } - - return false - } else { - t, _, err := e.Header.ContentType() - if err != nil { - return false - } - - if !strings.HasPrefix(t, "text/") && !strings.HasPrefix(t, "message/") { - return false - } - - buf, err := io.ReadAll(e.Body) - if err != nil { - return false - } - - return bytes.Contains(bytes.ToLower(buf), bytes.ToLower([]byte(pattern))) - } -} - -func canonicalFlag(flag imap.Flag) imap.Flag { - return imap.Flag(strings.ToLower(string(flag))) -} diff --git a/imapserver/imapmemserver/server.go b/imapserver/imapmemserver/server.go deleted file mode 100644 index e31453a..0000000 --- a/imapserver/imapmemserver/server.go +++ /dev/null @@ -1,61 +0,0 @@ -// Package imapmemserver implements an in-memory IMAP server. -package imapmemserver - -import ( - "sync" - - "github.com/emersion/go-imap/v2/imapserver" -) - -// Server is a server instance. -// -// A server contains a list of users. -type Server struct { - mutex sync.Mutex - users map[string]*User -} - -// New creates a new server. -func New() *Server { - return &Server{ - users: make(map[string]*User), - } -} - -// NewSession creates a new IMAP session. -func (s *Server) NewSession() imapserver.Session { - return &serverSession{server: s} -} - -func (s *Server) user(username string) *User { - s.mutex.Lock() - defer s.mutex.Unlock() - return s.users[username] -} - -// AddUser adds a user to the server. -func (s *Server) AddUser(user *User) { - s.mutex.Lock() - s.users[user.username] = user - s.mutex.Unlock() -} - -type serverSession struct { - *UserSession // may be nil - - server *Server // immutable -} - -var _ imapserver.Session = (*serverSession)(nil) - -func (sess *serverSession) Login(username, password string) error { - u := sess.server.user(username) - if u == nil { - return imapserver.ErrAuthFailed - } - if err := u.Login(username, password); err != nil { - return err - } - sess.UserSession = NewUserSession(u) - return nil -} diff --git a/imapserver/imapmemserver/session.go b/imapserver/imapmemserver/session.go deleted file mode 100644 index 70e9d2f..0000000 --- a/imapserver/imapmemserver/session.go +++ /dev/null @@ -1,140 +0,0 @@ -package imapmemserver - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/imapserver" -) - -type ( - user = User - mailbox = MailboxView -) - -// UserSession represents a session tied to a specific user. -// -// UserSession implements imapserver.Session. Typically, a UserSession pointer -// is embedded into a larger struct which overrides Login. -type UserSession struct { - *user // immutable - *mailbox // may be nil -} - -var _ imapserver.SessionIMAP4rev2 = (*UserSession)(nil) - -// NewUserSession creates a new user session. -func NewUserSession(user *User) *UserSession { - return &UserSession{user: user} -} - -func (sess *UserSession) Close() error { - if sess != nil && sess.mailbox != nil { - sess.mailbox.Close() - } - return nil -} - -func (sess *UserSession) Select(name string, options *imap.SelectOptions) (*imap.SelectData, error) { - mbox, err := sess.user.mailbox(name) - if err != nil { - return nil, err - } - mbox.mutex.Lock() - defer mbox.mutex.Unlock() - sess.mailbox = mbox.NewView() - return mbox.selectDataLocked(), nil -} - -func (sess *UserSession) Unselect() error { - sess.mailbox.Close() - sess.mailbox = nil - return nil -} - -func (sess *UserSession) Copy(numSet imap.NumSet, destName string) (*imap.CopyData, error) { - dest, err := sess.user.mailbox(destName) - if err != nil { - return nil, &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeTryCreate, - Text: "No such mailbox", - } - } else if sess.mailbox != nil && dest == sess.mailbox.Mailbox { - return nil, &imap.Error{ - Type: imap.StatusResponseTypeNo, - Text: "Source and destination mailboxes are identical", - } - } - - var sourceUIDs, destUIDs imap.UIDSet - sess.mailbox.forEach(numSet, func(seqNum uint32, msg *message) { - appendData := dest.copyMsg(msg) - sourceUIDs.AddNum(msg.uid) - destUIDs.AddNum(appendData.UID) - }) - - return &imap.CopyData{ - UIDValidity: dest.uidValidity, - SourceUIDs: sourceUIDs, - DestUIDs: destUIDs, - }, nil -} - -func (sess *UserSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, destName string) error { - dest, err := sess.user.mailbox(destName) - if err != nil { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeTryCreate, - Text: "No such mailbox", - } - } else if sess.mailbox != nil && dest == sess.mailbox.Mailbox { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Text: "Source and destination mailboxes are identical", - } - } - - sess.mailbox.mutex.Lock() - defer sess.mailbox.mutex.Unlock() - - var sourceUIDs, destUIDs imap.UIDSet - expunged := make(map[*message]struct{}) - sess.mailbox.forEachLocked(numSet, func(seqNum uint32, msg *message) { - appendData := dest.copyMsg(msg) - sourceUIDs.AddNum(msg.uid) - destUIDs.AddNum(appendData.UID) - expunged[msg] = struct{}{} - }) - seqNums := sess.mailbox.expungeLocked(expunged) - - err = w.WriteCopyData(&imap.CopyData{ - UIDValidity: dest.uidValidity, - SourceUIDs: sourceUIDs, - DestUIDs: destUIDs, - }) - if err != nil { - return err - } - - for _, seqNum := range seqNums { - if err := w.WriteExpunge(sess.mailbox.tracker.EncodeSeqNum(seqNum)); err != nil { - return err - } - } - - return nil -} - -func (sess *UserSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error { - if sess.mailbox == nil { - return nil - } - return sess.mailbox.Poll(w, allowExpunge) -} - -func (sess *UserSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error { - if sess.mailbox == nil { - return nil // TODO - } - return sess.mailbox.Idle(w, stop) -} diff --git a/imapserver/imapmemserver/user.go b/imapserver/imapmemserver/user.go deleted file mode 100644 index 9af1d7b..0000000 --- a/imapserver/imapmemserver/user.go +++ /dev/null @@ -1,204 +0,0 @@ -package imapmemserver - -import ( - "crypto/subtle" - "sort" - "strings" - "sync" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/imapserver" -) - -const mailboxDelim rune = '/' - -type User struct { - username, password string - - mutex sync.Mutex - mailboxes map[string]*Mailbox - prevUidValidity uint32 -} - -func NewUser(username, password string) *User { - return &User{ - username: username, - password: password, - mailboxes: make(map[string]*Mailbox), - } -} - -func (u *User) Login(username, password string) error { - if username != u.username { - return imapserver.ErrAuthFailed - } - if subtle.ConstantTimeCompare([]byte(password), []byte(u.password)) != 1 { - return imapserver.ErrAuthFailed - } - return nil -} - -func (u *User) mailboxLocked(name string) (*Mailbox, error) { - mbox := u.mailboxes[name] - if mbox == nil { - return nil, &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeNonExistent, - Text: "No such mailbox", - } - } - return mbox, nil -} - -func (u *User) mailbox(name string) (*Mailbox, error) { - u.mutex.Lock() - defer u.mutex.Unlock() - return u.mailboxLocked(name) -} - -func (u *User) Status(name string, options *imap.StatusOptions) (*imap.StatusData, error) { - mbox, err := u.mailbox(name) - if err != nil { - return nil, err - } - return mbox.StatusData(options), nil -} - -func (u *User) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { - u.mutex.Lock() - defer u.mutex.Unlock() - - // TODO: fail if ref doesn't exist - - if len(patterns) == 0 { - return w.WriteList(&imap.ListData{ - Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect}, - Delim: mailboxDelim, - }) - } - - var l []imap.ListData - for name, mbox := range u.mailboxes { - match := false - for _, pattern := range patterns { - match = imapserver.MatchList(name, mailboxDelim, ref, pattern) - if match { - break - } - } - if !match { - continue - } - - data := mbox.list(options) - if data != nil { - l = append(l, *data) - } - } - - sort.Slice(l, func(i, j int) bool { - return l[i].Mailbox < l[j].Mailbox - }) - - for _, data := range l { - if err := w.WriteList(&data); err != nil { - return err - } - } - - return nil -} - -func (u *User) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) { - mbox, err := u.mailbox(mailbox) - if err != nil { - return nil, &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeTryCreate, - Text: "No such mailbox", - } - } - return mbox.appendLiteral(r, options) -} - -func (u *User) Create(name string, options *imap.CreateOptions) error { - u.mutex.Lock() - defer u.mutex.Unlock() - - name = strings.TrimRight(name, string(mailboxDelim)) - - if u.mailboxes[name] != nil { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeAlreadyExists, - Text: "Mailbox already exists", - } - } - - // UIDVALIDITY must change if a mailbox is deleted and re-created with the - // same name. - u.prevUidValidity++ - u.mailboxes[name] = NewMailbox(name, u.prevUidValidity) - return nil -} - -func (u *User) Delete(name string) error { - u.mutex.Lock() - defer u.mutex.Unlock() - - if _, err := u.mailboxLocked(name); err != nil { - return err - } - - delete(u.mailboxes, name) - return nil -} - -func (u *User) Rename(oldName, newName string, options *imap.RenameOptions) error { - u.mutex.Lock() - defer u.mutex.Unlock() - - newName = strings.TrimRight(newName, string(mailboxDelim)) - - mbox, err := u.mailboxLocked(oldName) - if err != nil { - return err - } - - if u.mailboxes[newName] != nil { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeAlreadyExists, - Text: "Mailbox already exists", - } - } - - mbox.rename(newName) - u.mailboxes[newName] = mbox - delete(u.mailboxes, oldName) - return nil -} - -func (u *User) Subscribe(name string) error { - mbox, err := u.mailbox(name) - if err != nil { - return err - } - mbox.SetSubscribed(true) - return nil -} - -func (u *User) Unsubscribe(name string) error { - mbox, err := u.mailbox(name) - if err != nil { - return err - } - mbox.SetSubscribed(false) - return nil -} - -func (u *User) Namespace() (*imap.NamespaceData, error) { - return &imap.NamespaceData{ - Personal: []imap.NamespaceDescriptor{{Delim: mailboxDelim}}, - }, nil -} diff --git a/imapserver/list.go b/imapserver/list.go deleted file mode 100644 index 1aab73d..0000000 --- a/imapserver/list.go +++ /dev/null @@ -1,329 +0,0 @@ -package imapserver - -import ( - "fmt" - "strings" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" - "github.com/emersion/go-imap/v2/internal/utf7" -) - -func (c *Conn) handleList(dec *imapwire.Decoder) error { - ref, pattern, options, err := readListCmd(dec) - if err != nil { - return err - } - - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - - w := &ListWriter{ - conn: c, - options: options, - } - return c.session.List(w, ref, pattern, options) -} - -func (c *Conn) handleLSub(dec *imapwire.Decoder) error { - var ref string - if !dec.ExpectSP() || !dec.ExpectMailbox(&ref) || !dec.ExpectSP() { - return dec.Err() - } - pattern, err := readListMailbox(dec) - if err != nil { - return err - } - if !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - - options := &imap.ListOptions{SelectSubscribed: true} - w := &ListWriter{ - conn: c, - lsub: true, - } - return c.session.List(w, ref, []string{pattern}, options) -} - -func (c *Conn) writeList(data *imap.ListData) error { - enc := newResponseEncoder(c) - defer enc.end() - - enc.Atom("*").SP().Atom("LIST").SP() - enc.List(len(data.Attrs), func(i int) { - enc.MailboxAttr(data.Attrs[i]) - }) - enc.SP() - if data.Delim == 0 { - enc.NIL() - } else { - enc.Quoted(string(data.Delim)) - } - enc.SP().Mailbox(data.Mailbox) - - var ext []string - if data.ChildInfo != nil { - ext = append(ext, "CHILDINFO") - } - if data.OldName != "" { - ext = append(ext, "OLDNAME") - } - - // TODO: omit extended data if the client didn't ask for it - if len(ext) > 0 { - enc.SP().List(len(ext), func(i int) { - name := ext[i] - enc.Atom(name).SP() - switch name { - case "CHILDINFO": - enc.Special('(') - if data.ChildInfo.Subscribed { - enc.Quoted("SUBSCRIBED") - } - enc.Special(')') - case "OLDNAME": - enc.Special('(').Mailbox(data.OldName).Special(')') - default: - panic(fmt.Errorf("imapserver: unknown LIST extended-item %v", name)) - } - }) - } - - return enc.CRLF() -} - -func (c *Conn) writeLSub(data *imap.ListData) error { - enc := newResponseEncoder(c) - defer enc.end() - - enc.Atom("*").SP().Atom("LSUB").SP() - enc.List(len(data.Attrs), func(i int) { - enc.MailboxAttr(data.Attrs[i]) - }) - enc.SP() - if data.Delim == 0 { - enc.NIL() - } else { - enc.Quoted(string(data.Delim)) - } - enc.SP().Mailbox(data.Mailbox) - return enc.CRLF() -} - -func readListCmd(dec *imapwire.Decoder) (ref string, patterns []string, options *imap.ListOptions, err error) { - options = &imap.ListOptions{} - - if !dec.ExpectSP() { - return "", nil, nil, dec.Err() - } - - hasSelectOpts, err := dec.List(func() error { - var selectOpt string - if !dec.ExpectAString(&selectOpt) { - return dec.Err() - } - switch strings.ToUpper(selectOpt) { - case "SUBSCRIBED": - options.SelectSubscribed = true - case "REMOTE": - options.SelectRemote = true - case "RECURSIVEMATCH": - options.SelectRecursiveMatch = true - case "SPECIAL-USE": - options.SelectSpecialUse = true - default: - return newClientBugError("Unknown LIST select option") - } - return nil - }) - if err != nil { - return "", nil, nil, fmt.Errorf("in list-select-opts: %w", err) - } - if hasSelectOpts && !dec.ExpectSP() { - return "", nil, nil, dec.Err() - } - - if !dec.ExpectMailbox(&ref) || !dec.ExpectSP() { - return "", nil, nil, dec.Err() - } - - hasPatterns, err := dec.List(func() error { - pattern, err := readListMailbox(dec) - if err == nil && pattern != "" { - patterns = append(patterns, pattern) - } - return err - }) - if err != nil { - return "", nil, nil, err - } else if hasPatterns && len(patterns) == 0 { - return "", nil, nil, newClientBugError("LIST-EXTENDED requires a non-empty parenthesized pattern list") - } else if !hasPatterns { - pattern, err := readListMailbox(dec) - if err != nil { - return "", nil, nil, err - } - if pattern != "" { - patterns = append(patterns, pattern) - } - } - - if dec.SP() { // list-return-opts - var atom string - if !dec.ExpectAtom(&atom) || !dec.Expect(strings.EqualFold(atom, "RETURN"), "RETURN") || !dec.ExpectSP() { - return "", nil, nil, dec.Err() - } - - err := dec.ExpectList(func() error { - return readReturnOption(dec, options) - }) - if err != nil { - return "", nil, nil, fmt.Errorf("in list-return-opts: %w", err) - } - } - - if !dec.ExpectCRLF() { - return "", nil, nil, dec.Err() - } - - if options.SelectRecursiveMatch && !options.SelectSubscribed { - return "", nil, nil, newClientBugError("The LIST RECURSIVEMATCH select option requires SUBSCRIBED") - } - - return ref, patterns, options, nil -} - -func readListMailbox(dec *imapwire.Decoder) (string, error) { - var mailbox string - if !dec.String(&mailbox) { - if !dec.Expect(dec.Func(&mailbox, isListChar), "list-char") { - return "", dec.Err() - } - } - return utf7.Decode(mailbox) -} - -func isListChar(ch byte) bool { - switch ch { - case '%', '*': // list-wildcards - return true - case ']': // resp-specials - return true - default: - return imapwire.IsAtomChar(ch) - } -} - -func readReturnOption(dec *imapwire.Decoder, options *imap.ListOptions) error { - var name string - if !dec.ExpectAtom(&name) { - return dec.Err() - } - - switch strings.ToUpper(name) { - case "SUBSCRIBED": - options.ReturnSubscribed = true - case "CHILDREN": - options.ReturnChildren = true - case "SPECIAL-USE": - options.ReturnSpecialUse = true - case "STATUS": - if !dec.ExpectSP() { - return dec.Err() - } - options.ReturnStatus = new(imap.StatusOptions) - return dec.ExpectList(func() error { - return readStatusItem(dec, options.ReturnStatus) - }) - default: - return newClientBugError("Unknown LIST RETURN options") - } - return nil -} - -// ListWriter writes LIST responses. -type ListWriter struct { - conn *Conn - options *imap.ListOptions - lsub bool -} - -// WriteList writes a single LIST response for a mailbox. -func (w *ListWriter) WriteList(data *imap.ListData) error { - if w.lsub { - return w.conn.writeLSub(data) - } - - if err := w.conn.writeList(data); err != nil { - return err - } - if w.options.ReturnStatus != nil && data.Status != nil { - if err := w.conn.writeStatus(data.Status, w.options.ReturnStatus); err != nil { - return err - } - } - return nil -} - -// MatchList checks whether a reference and a pattern matches a mailbox. -func MatchList(name string, delim rune, reference, pattern string) bool { - var delimStr string - if delim != 0 { - delimStr = string(delim) - } - - if delimStr != "" && strings.HasPrefix(pattern, delimStr) { - reference = "" - pattern = strings.TrimPrefix(pattern, delimStr) - } - if reference != "" { - if delimStr != "" && !strings.HasSuffix(reference, delimStr) { - reference += delimStr - } - if !strings.HasPrefix(name, reference) { - return false - } - name = strings.TrimPrefix(name, reference) - } - - return matchList(name, delimStr, pattern) -} - -func matchList(name, delim, pattern string) bool { - // TODO: optimize - - 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]) == delim { - break // Stop on delimiter if wildcard is % - } - // Try to match the rest from here - if matchList(name[j:], delim, rest) { - return true - } - } - - return matchList(name[j:], delim, rest) -} diff --git a/imapserver/list_test.go b/imapserver/list_test.go deleted file mode 100644 index bf290f4..0000000 --- a/imapserver/list_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package imapserver_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2/imapserver" -) - -var matchListTests = []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 TestMatchList(t *testing.T) { - delim := '/' - for _, test := range matchListTests { - result := imapserver.MatchList(test.name, delim, 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) - } - } -} diff --git a/imapserver/login.go b/imapserver/login.go deleted file mode 100644 index 2e6c279..0000000 --- a/imapserver/login.go +++ /dev/null @@ -1,28 +0,0 @@ -package imapserver - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleLogin(tag string, dec *imapwire.Decoder) error { - var username, password string - if !dec.ExpectSP() || !dec.ExpectAString(&username) || !dec.ExpectSP() || !dec.ExpectAString(&password) || !dec.ExpectCRLF() { - return dec.Err() - } - if err := c.checkState(imap.ConnStateNotAuthenticated); err != nil { - return err - } - if !c.canAuth() { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodePrivacyRequired, - Text: "TLS is required to authenticate", - } - } - if err := c.session.Login(username, password); err != nil { - return err - } - c.state = imap.ConnStateAuthenticated - return c.writeCapabilityStatus(tag, imap.StatusResponseTypeOK, "Logged in") -} diff --git a/imapserver/message.go b/imapserver/message.go deleted file mode 100644 index 9650e08..0000000 --- a/imapserver/message.go +++ /dev/null @@ -1,336 +0,0 @@ -package imapserver - -import ( - "bufio" - "bytes" - "io" - "strings" - - gomessage "github.com/emersion/go-message" - "github.com/emersion/go-message/mail" - "github.com/emersion/go-message/textproto" - - "github.com/emersion/go-imap/v2" -) - -// ExtractBodySection extracts a section of a message body. -// -// It can be used by server backends to implement Session.Fetch. -func ExtractBodySection(r io.Reader, item *imap.FetchItemBodySection) []byte { - var ( - header textproto.Header - body io.Reader - ) - - br := bufio.NewReader(r) - header, err := textproto.ReadHeader(br) - if err != nil { - return nil - } - body = br - - parentMediaType, header, body := findMessagePart(header, body, item.Part) - if body == nil { - return nil - } - - if len(item.Part) > 0 { - switch item.Specifier { - case imap.PartSpecifierHeader, imap.PartSpecifierText: - header, body = openMessagePart(header, body, parentMediaType) - } - } - - // Filter header fields - if len(item.HeaderFields) > 0 { - keep := make(map[string]struct{}) - for _, k := range item.HeaderFields { - keep[strings.ToLower(k)] = struct{}{} - } - for field := header.Fields(); field.Next(); { - if _, ok := keep[strings.ToLower(field.Key())]; !ok { - field.Del() - } - } - } - for _, k := range item.HeaderFieldsNot { - header.Del(k) - } - - // Write the requested data to a buffer - var buf bytes.Buffer - - writeHeader := true - switch item.Specifier { - case imap.PartSpecifierNone: - writeHeader = len(item.Part) == 0 - case imap.PartSpecifierText: - writeHeader = false - } - if writeHeader { - if err := textproto.WriteHeader(&buf, header); err != nil { - return nil - } - } - - switch item.Specifier { - case imap.PartSpecifierNone, imap.PartSpecifierText: - if _, err := io.Copy(&buf, body); err != nil { - return nil - } - } - - return extractPartial(buf.Bytes(), item.Partial) -} - -func findMessagePart(header textproto.Header, body io.Reader, partPath []int) (string, textproto.Header, io.Reader) { - // First part of non-multipart message refers to the message itself - msgHeader := gomessage.Header{header} - mediaType, _, _ := msgHeader.ContentType() - if !strings.HasPrefix(mediaType, "multipart/") && len(partPath) > 0 && partPath[0] == 1 { - partPath = partPath[1:] - } - - var parentMediaType string - for i := 0; i < len(partPath); i++ { - partNum := partPath[i] - - header, body = openMessagePart(header, body, parentMediaType) - - msgHeader := gomessage.Header{header} - mediaType, typeParams, _ := msgHeader.ContentType() - if !strings.HasPrefix(mediaType, "multipart/") { - if partNum != 1 { - return "", textproto.Header{}, nil - } - continue - } - - mr := textproto.NewMultipartReader(body, typeParams["boundary"]) - found := false - for j := 1; j <= partNum; j++ { - p, err := mr.NextPart() - if err != nil { - return "", textproto.Header{}, nil - } - - if j == partNum { - parentMediaType = mediaType - header = p.Header - body = p - found = true - break - } - } - if !found { - return "", textproto.Header{}, nil - } - } - - return parentMediaType, header, body -} - -func openMessagePart(header textproto.Header, body io.Reader, parentMediaType string) (textproto.Header, io.Reader) { - msgHeader := gomessage.Header{header} - mediaType, _, _ := msgHeader.ContentType() - if !msgHeader.Has("Content-Type") && parentMediaType == "multipart/digest" { - mediaType = "message/rfc822" - } - if mediaType == "message/rfc822" || mediaType == "message/global" { - br := bufio.NewReader(body) - header, _ = textproto.ReadHeader(br) - return header, br - } - return header, body -} - -func extractPartial(b []byte, partial *imap.SectionPartial) []byte { - if partial == nil { - return b - } - - end := partial.Offset + partial.Size - if partial.Offset > int64(len(b)) { - return nil - } - if end > int64(len(b)) { - end = int64(len(b)) - } - return b[partial.Offset:end] -} - -func ExtractBinarySection(r io.Reader, item *imap.FetchItemBinarySection) []byte { - var ( - header textproto.Header - body io.Reader - ) - - br := bufio.NewReader(r) - header, err := textproto.ReadHeader(br) - if err != nil { - return nil - } - body = br - - _, header, body = findMessagePart(header, body, item.Part) - if body == nil { - return nil - } - - part, err := gomessage.New(gomessage.Header{header}, body) - if err != nil { - return nil - } - - // Write the requested data to a buffer - var buf bytes.Buffer - - if len(item.Part) == 0 { - if err := textproto.WriteHeader(&buf, part.Header.Header); err != nil { - return nil - } - } - - if _, err := io.Copy(&buf, part.Body); err != nil { - return nil - } - - return extractPartial(buf.Bytes(), item.Partial) -} - -func ExtractBinarySectionSize(r io.Reader, item *imap.FetchItemBinarySectionSize) uint32 { - // TODO: optimize - b := ExtractBinarySection(r, &imap.FetchItemBinarySection{Part: item.Part}) - return uint32(len(b)) -} - -// ExtractEnvelope returns a message envelope from its header. -// -// It can be used by server backends to implement Session.Fetch. -func ExtractEnvelope(h textproto.Header) *imap.Envelope { - mh := mail.Header{gomessage.Header{h}} - date, _ := mh.Date() - subject, _ := mh.Subject() - inReplyTo, _ := mh.MsgIDList("In-Reply-To") - messageID, _ := mh.MessageID() - return &imap.Envelope{ - Date: date, - Subject: subject, - From: parseAddressList(mh, "From"), - Sender: parseAddressList(mh, "Sender"), - ReplyTo: parseAddressList(mh, "Reply-To"), - To: parseAddressList(mh, "To"), - Cc: parseAddressList(mh, "Cc"), - Bcc: parseAddressList(mh, "Bcc"), - InReplyTo: inReplyTo, - MessageID: messageID, - } -} - -func parseAddressList(mh mail.Header, k string) []imap.Address { - // TODO: handle groups - addrs, _ := mh.AddressList(k) - var l []imap.Address - for _, addr := range addrs { - mailbox, host, ok := strings.Cut(addr.Address, "@") - if !ok { - continue - } - l = append(l, imap.Address{ - Name: addr.Name, - Mailbox: mailbox, - Host: host, - }) - } - return l -} - -// ExtractBodyStructure extracts the structure of a message body. -// -// It can be used by server backends to implement Session.Fetch. -func ExtractBodyStructure(r io.Reader) imap.BodyStructure { - br := bufio.NewReader(r) - header, _ := textproto.ReadHeader(br) - return extractBodyStructure(header, br) -} - -func extractBodyStructure(rawHeader textproto.Header, r io.Reader) imap.BodyStructure { - header := gomessage.Header{rawHeader} - - mediaType, typeParams, _ := header.ContentType() - primaryType, subType, _ := strings.Cut(mediaType, "/") - - if primaryType == "multipart" { - bs := &imap.BodyStructureMultiPart{Subtype: subType} - mr := textproto.NewMultipartReader(r, typeParams["boundary"]) - for { - part, _ := mr.NextPart() - if part == nil { - break - } - bs.Children = append(bs.Children, extractBodyStructure(part.Header, part)) - } - bs.Extended = &imap.BodyStructureMultiPartExt{ - Params: typeParams, - Disposition: getContentDisposition(header), - Language: getContentLanguage(header), - Location: header.Get("Content-Location"), - } - return bs - } else { - body, _ := io.ReadAll(r) // TODO: optimize - bs := &imap.BodyStructureSinglePart{ - Type: primaryType, - Subtype: subType, - Params: typeParams, - ID: header.Get("Content-Id"), - Description: header.Get("Content-Description"), - Encoding: header.Get("Content-Transfer-Encoding"), - Size: uint32(len(body)), - } - if mediaType == "message/rfc822" || mediaType == "message/global" { - br := bufio.NewReader(bytes.NewReader(body)) - childHeader, _ := textproto.ReadHeader(br) - bs.MessageRFC822 = &imap.BodyStructureMessageRFC822{ - Envelope: ExtractEnvelope(childHeader), - BodyStructure: extractBodyStructure(childHeader, br), - NumLines: int64(bytes.Count(body, []byte("\n"))), - } - } - if primaryType == "text" { - bs.Text = &imap.BodyStructureText{ - NumLines: int64(bytes.Count(body, []byte("\n"))), - } - } - bs.Extended = &imap.BodyStructureSinglePartExt{ - Disposition: getContentDisposition(header), - Language: getContentLanguage(header), - Location: header.Get("Content-Location"), - } - return bs - } -} - -func getContentDisposition(header gomessage.Header) *imap.BodyStructureDisposition { - disp, dispParams, _ := header.ContentDisposition() - if disp == "" { - return nil - } - return &imap.BodyStructureDisposition{ - Value: disp, - Params: dispParams, - } -} - -func getContentLanguage(header gomessage.Header) []string { - v := header.Get("Content-Language") - if v == "" { - return nil - } - // TODO: handle CFWS - l := strings.Split(v, ",") - for i, lang := range l { - l[i] = strings.TrimSpace(lang) - } - return l -} diff --git a/imapserver/move.go b/imapserver/move.go deleted file mode 100644 index 1305a31..0000000 --- a/imapserver/move.go +++ /dev/null @@ -1,40 +0,0 @@ -package imapserver - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleMove(dec *imapwire.Decoder, numKind NumKind) error { - numSet, dest, err := readCopy(numKind, dec) - if err != nil { - return err - } - if err := c.checkState(imap.ConnStateSelected); err != nil { - return err - } - session, ok := c.session.(SessionMove) - if !ok { - return newClientBugError("MOVE is not supported") - } - w := &MoveWriter{conn: c} - return session.Move(w, numSet, dest) -} - -// MoveWriter writes responses for the MOVE command. -// -// Servers must first call WriteCopyData once, then call WriteExpunge any -// number of times. -type MoveWriter struct { - conn *Conn -} - -// WriteCopyData writes the untagged COPYUID response for a MOVE command. -func (w *MoveWriter) WriteCopyData(data *imap.CopyData) error { - return w.conn.writeCopyOK("", data) -} - -// WriteExpunge writes an EXPUNGE response for a MOVE command. -func (w *MoveWriter) WriteExpunge(seqNum uint32) error { - return w.conn.writeExpunge(seqNum) -} diff --git a/imapserver/namespace.go b/imapserver/namespace.go deleted file mode 100644 index 10e973d..0000000 --- a/imapserver/namespace.go +++ /dev/null @@ -1,54 +0,0 @@ -package imapserver - -import ( - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleNamespace(dec *imapwire.Decoder) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - - session, ok := c.session.(SessionNamespace) - if !ok { - return newClientBugError("NAMESPACE is not supported") - } - - data, err := session.Namespace() - if err != nil { - return err - } - - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Atom("NAMESPACE").SP() - writeNamespace(enc.Encoder, data.Personal) - enc.SP() - writeNamespace(enc.Encoder, data.Other) - enc.SP() - writeNamespace(enc.Encoder, data.Shared) - return enc.CRLF() -} - -func writeNamespace(enc *imapwire.Encoder, l []imap.NamespaceDescriptor) { - if l == nil { - enc.NIL() - return - } - - enc.List(len(l), func(i int) { - descr := l[i] - enc.Special('(').String(descr.Prefix).SP() - if descr.Delim == 0 { - enc.NIL() - } else { - enc.Quoted(string(descr.Delim)) - } - enc.Special(')') - }) -} diff --git a/imapserver/search.go b/imapserver/search.go deleted file mode 100644 index b0f9fb8..0000000 --- a/imapserver/search.go +++ /dev/null @@ -1,343 +0,0 @@ -package imapserver - -import ( - "fmt" - "strings" - "time" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleSearch(tag string, dec *imapwire.Decoder, numKind NumKind) error { - if !dec.ExpectSP() { - return dec.Err() - } - var ( - atom string - options imap.SearchOptions - extended bool - ) - if maybeReadSearchKeyAtom(dec, &atom) && strings.EqualFold(atom, "RETURN") { - if err := readSearchReturnOpts(dec, &options); err != nil { - return fmt.Errorf("in search-return-opts: %w", err) - } - if !dec.ExpectSP() { - return dec.Err() - } - extended = true - atom = "" - maybeReadSearchKeyAtom(dec, &atom) - } - if strings.EqualFold(atom, "CHARSET") { - var charset string - if !dec.ExpectSP() || !dec.ExpectAString(&charset) || !dec.ExpectSP() { - return dec.Err() - } - switch strings.ToUpper(charset) { - case "US-ASCII", "UTF-8": - // nothing to do - default: - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeBadCharset, // TODO: return list of supported charsets - Text: "Only US-ASCII and UTF-8 are supported SEARCH charsets", - } - } - atom = "" - maybeReadSearchKeyAtom(dec, &atom) - } - - var criteria imap.SearchCriteria - for { - var err error - if atom != "" { - err = readSearchKeyWithAtom(&criteria, dec, atom) - atom = "" - } else { - err = readSearchKey(&criteria, dec) - } - if err != nil { - return fmt.Errorf("in search-key: %w", err) - } - - if !dec.SP() { - break - } - } - - if !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateSelected); err != nil { - return err - } - - // If no return option is specified, ALL is assumed - if !options.ReturnMin && !options.ReturnMax && !options.ReturnAll && !options.ReturnCount { - options.ReturnAll = true - } - - data, err := c.session.Search(numKind, &criteria, &options) - if err != nil { - return err - } - - if c.enabled.Has(imap.CapIMAP4rev2) || extended { - return c.writeESearch(tag, data, &options, numKind) - } else { - return c.writeSearch(data.All) - } -} - -func (c *Conn) writeESearch(tag string, data *imap.SearchData, options *imap.SearchOptions, numKind NumKind) error { - enc := newResponseEncoder(c) - defer enc.end() - - enc.Atom("*").SP().Atom("ESEARCH") - if tag != "" { - enc.SP().Special('(').Atom("TAG").SP().String(tag).Special(')') - } - if numKind == NumKindUID { - enc.SP().Atom("UID") - } - // When there is no result, we need to send an ESEARCH response with no ALL - // keyword - if options.ReturnAll && !isNumSetEmpty(data.All) { - enc.SP().Atom("ALL").SP().NumSet(data.All) - } - if options.ReturnMin && data.Min > 0 { - enc.SP().Atom("MIN").SP().Number(data.Min) - } - if options.ReturnMax && data.Max > 0 { - enc.SP().Atom("MAX").SP().Number(data.Max) - } - if options.ReturnCount { - enc.SP().Atom("COUNT").SP().Number(data.Count) - } - return enc.CRLF() -} - -func isNumSetEmpty(numSet imap.NumSet) bool { - switch numSet := numSet.(type) { - case imap.SeqSet: - return len(numSet) == 0 - case imap.UIDSet: - return len(numSet) == 0 - default: - panic("unknown imap.NumSet type") - } -} - -func (c *Conn) writeSearch(numSet imap.NumSet) error { - enc := newResponseEncoder(c) - defer enc.end() - - enc.Atom("*").SP().Atom("SEARCH") - var ok bool - switch numSet := numSet.(type) { - case imap.SeqSet: - var nums []uint32 - nums, ok = numSet.Nums() - for _, num := range nums { - enc.SP().Number(num) - } - case imap.UIDSet: - var uids []imap.UID - uids, ok = numSet.Nums() - for _, uid := range uids { - enc.SP().UID(uid) - } - } - if !ok { - return fmt.Errorf("imapserver: failed to enumerate message numbers in SEARCH response") - } - return enc.CRLF() -} - -func readSearchReturnOpts(dec *imapwire.Decoder, options *imap.SearchOptions) error { - if !dec.ExpectSP() { - return dec.Err() - } - return dec.ExpectList(func() error { - var name string - if !dec.ExpectAtom(&name) { - return dec.Err() - } - switch strings.ToUpper(name) { - case "MIN": - options.ReturnMin = true - case "MAX": - options.ReturnMax = true - case "ALL": - options.ReturnAll = true - case "COUNT": - options.ReturnCount = true - case "SAVE": - options.ReturnSave = true - default: - return newClientBugError("unknown SEARCH RETURN option") - } - return nil - }) -} - -func maybeReadSearchKeyAtom(dec *imapwire.Decoder, ptr *string) bool { - return dec.Func(ptr, func(ch byte) bool { - return ch == '*' || imapwire.IsAtomChar(ch) - }) -} - -func readSearchKey(criteria *imap.SearchCriteria, dec *imapwire.Decoder) error { - var key string - if maybeReadSearchKeyAtom(dec, &key) { - return readSearchKeyWithAtom(criteria, dec, key) - } - return dec.ExpectList(func() error { - return readSearchKey(criteria, dec) - }) -} - -func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, key string) error { - key = strings.ToUpper(key) - switch key { - case "ALL": - // nothing to do - case "UID": - var uidSet imap.UIDSet - if !dec.ExpectSP() || !dec.ExpectUIDSet(&uidSet) { - return dec.Err() - } - criteria.UID = append(criteria.UID, uidSet) - case "ANSWERED", "DELETED", "DRAFT", "FLAGGED", "RECENT", "SEEN": - criteria.Flag = append(criteria.Flag, searchKeyFlag(key)) - case "UNANSWERED", "UNDELETED", "UNDRAFT", "UNFLAGGED", "UNSEEN": - notKey := strings.TrimPrefix(key, "UN") - criteria.NotFlag = append(criteria.NotFlag, searchKeyFlag(notKey)) - case "NEW": - criteria.Flag = append(criteria.Flag, internal.FlagRecent) - criteria.NotFlag = append(criteria.NotFlag, imap.FlagSeen) - case "OLD": - criteria.NotFlag = append(criteria.NotFlag, internal.FlagRecent) - case "KEYWORD", "UNKEYWORD": - if !dec.ExpectSP() { - return dec.Err() - } - flag, err := internal.ExpectFlag(dec) - if err != nil { - return err - } - switch key { - case "KEYWORD": - criteria.Flag = append(criteria.Flag, flag) - case "UNKEYWORD": - criteria.NotFlag = append(criteria.NotFlag, flag) - } - case "BCC", "CC", "FROM", "SUBJECT", "TO": - var value string - if !dec.ExpectSP() || !dec.ExpectAString(&value) { - return dec.Err() - } - criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{ - Key: strings.Title(strings.ToLower(key)), - Value: value, - }) - case "HEADER": - var key, value string - if !dec.ExpectSP() || !dec.ExpectAString(&key) || !dec.ExpectSP() || !dec.ExpectAString(&value) { - return dec.Err() - } - criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{ - Key: key, - Value: value, - }) - case "SINCE", "BEFORE", "ON", "SENTSINCE", "SENTBEFORE", "SENTON": - if !dec.ExpectSP() { - return dec.Err() - } - t, err := internal.ExpectDate(dec) - if err != nil { - return err - } - var dateCriteria imap.SearchCriteria - switch key { - case "SINCE": - dateCriteria.Since = t - case "BEFORE": - dateCriteria.Before = t - case "ON": - dateCriteria.Since = t - dateCriteria.Before = t.Add(24 * time.Hour) - case "SENTSINCE": - dateCriteria.SentSince = t - case "SENTBEFORE": - dateCriteria.SentBefore = t - case "SENTON": - dateCriteria.SentSince = t - dateCriteria.SentBefore = t.Add(24 * time.Hour) - } - criteria.And(&dateCriteria) - case "BODY": - var body string - if !dec.ExpectSP() || !dec.ExpectAString(&body) { - return dec.Err() - } - criteria.Body = append(criteria.Body, body) - case "TEXT": - var text string - if !dec.ExpectSP() || !dec.ExpectAString(&text) { - return dec.Err() - } - criteria.Text = append(criteria.Text, text) - case "LARGER", "SMALLER": - var n int64 - if !dec.ExpectSP() || !dec.ExpectNumber64(&n) { - return dec.Err() - } - switch key { - case "LARGER": - criteria.And(&imap.SearchCriteria{Larger: n}) - case "SMALLER": - criteria.And(&imap.SearchCriteria{Smaller: n}) - } - case "NOT": - if !dec.ExpectSP() { - return dec.Err() - } - var not imap.SearchCriteria - if err := readSearchKey(¬, dec); err != nil { - return err - } - criteria.Not = append(criteria.Not, not) - case "OR": - if !dec.ExpectSP() { - return dec.Err() - } - var or [2]imap.SearchCriteria - if err := readSearchKey(&or[0], dec); err != nil { - return err - } - if !dec.ExpectSP() { - return dec.Err() - } - if err := readSearchKey(&or[1], dec); err != nil { - return err - } - criteria.Or = append(criteria.Or, or) - case "$": - criteria.UID = append(criteria.UID, imap.SearchRes()) - default: - seqSet, err := imapwire.ParseSeqSet(key) - if err != nil { - return err - } - criteria.SeqNum = append(criteria.SeqNum, seqSet) - } - return nil -} - -func searchKeyFlag(key string) imap.Flag { - return imap.Flag("\\" + strings.Title(strings.ToLower(key))) -} diff --git a/imapserver/select.go b/imapserver/select.go deleted file mode 100644 index 3535fe7..0000000 --- a/imapserver/select.go +++ /dev/null @@ -1,174 +0,0 @@ -package imapserver - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) error { - var mailbox string - if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - - if c.state == imap.ConnStateSelected { - if err := c.session.Unselect(); err != nil { - return err - } - c.state = imap.ConnStateAuthenticated - err := c.writeStatusResp("", &imap.StatusResponse{ - Type: imap.StatusResponseTypeOK, - Code: "CLOSED", - Text: "Previous mailbox is now closed", - }) - if err != nil { - return err - } - } - - options := imap.SelectOptions{ReadOnly: readOnly} - data, err := c.session.Select(mailbox, &options) - if err != nil { - return err - } - - if err := c.writeExists(data.NumMessages); err != nil { - return err - } - if !c.enabled.Has(imap.CapIMAP4rev2) && c.server.options.caps().Has(imap.CapIMAP4rev1) { - if err := c.writeObsoleteRecent(data.NumRecent); err != nil { - return err - } - if data.FirstUnseenSeqNum != 0 { - if err := c.writeObsoleteUnseen(data.FirstUnseenSeqNum); err != nil { - return err - } - } - } - if err := c.writeUIDValidity(data.UIDValidity); err != nil { - return err - } - if err := c.writeUIDNext(data.UIDNext); err != nil { - return err - } - if err := c.writeFlags(data.Flags); err != nil { - return err - } - if err := c.writePermanentFlags(data.PermanentFlags); err != nil { - return err - } - if data.List != nil { - if err := c.writeList(data.List); err != nil { - return err - } - } - - c.state = imap.ConnStateSelected - // TODO: forbid write commands in read-only mode - - var ( - cmdName string - code imap.ResponseCode - ) - if readOnly { - cmdName = "EXAMINE" - code = "READ-ONLY" - } else { - cmdName = "SELECT" - code = "READ-WRITE" - } - return c.writeStatusResp(tag, &imap.StatusResponse{ - Type: imap.StatusResponseTypeOK, - Code: code, - Text: fmt.Sprintf("%v completed", cmdName), - }) -} - -func (c *Conn) handleUnselect(dec *imapwire.Decoder, expunge bool) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - - if err := c.checkState(imap.ConnStateSelected); err != nil { - return err - } - - if expunge { - w := &ExpungeWriter{} - if err := c.session.Expunge(w, nil); err != nil { - return err - } - } - - if err := c.session.Unselect(); err != nil { - return err - } - - c.state = imap.ConnStateAuthenticated - return nil -} - -func (c *Conn) writeExists(numMessages uint32) error { - enc := newResponseEncoder(c) - defer enc.end() - return enc.Atom("*").SP().Number(numMessages).SP().Atom("EXISTS").CRLF() -} - -func (c *Conn) writeObsoleteRecent(n uint32) error { - enc := newResponseEncoder(c) - defer enc.end() - return enc.Atom("*").SP().Number(n).SP().Atom("RECENT").CRLF() -} - -func (c *Conn) writeObsoleteUnseen(n uint32) error { - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Atom("OK").SP() - enc.Special('[').Atom("UNSEEN").SP().Number(n).Special(']') - enc.SP().Text("First unseen message") - return enc.CRLF() -} - -func (c *Conn) writeUIDValidity(uidValidity uint32) error { - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Atom("OK").SP() - enc.Special('[').Atom("UIDVALIDITY").SP().Number(uidValidity).Special(']') - enc.SP().Text("UIDs valid") - return enc.CRLF() -} - -func (c *Conn) writeUIDNext(uidNext imap.UID) error { - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Atom("OK").SP() - enc.Special('[').Atom("UIDNEXT").SP().UID(uidNext).Special(']') - enc.SP().Text("Predicted next UID") - return enc.CRLF() -} - -func (c *Conn) writeFlags(flags []imap.Flag) error { - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Atom("FLAGS").SP().List(len(flags), func(i int) { - enc.Flag(flags[i]) - }) - return enc.CRLF() -} - -func (c *Conn) writePermanentFlags(flags []imap.Flag) error { - enc := newResponseEncoder(c) - defer enc.end() - enc.Atom("*").SP().Atom("OK").SP() - enc.Special('[').Atom("PERMANENTFLAGS").SP().List(len(flags), func(i int) { - enc.Flag(flags[i]) - }).Special(']') - enc.SP().Text("Permanent flags") - return enc.CRLF() -} diff --git a/imapserver/server.go b/imapserver/server.go deleted file mode 100644 index 627baf3..0000000 --- a/imapserver/server.go +++ /dev/null @@ -1,222 +0,0 @@ -// Package imapserver implements an IMAP server. -package imapserver - -import ( - "crypto/tls" - "errors" - "fmt" - "io" - "log" - "net" - "sync" - "time" - - "github.com/emersion/go-imap/v2" -) - -var errClosed = errors.New("imapserver: server closed") - -// Logger is a facility to log error messages. -type Logger interface { - Printf(format string, args ...interface{}) -} - -// Options contains server options. -// -// The only required field is NewSession. -type Options struct { - // NewSession is called when a client connects. - NewSession func(*Conn) (Session, *GreetingData, error) - // Supported capabilities. If nil, only IMAP4rev1 is advertised. This set - // must contain at least IMAP4rev1 or IMAP4rev2. - // - // The following capabilities are part of IMAP4rev2 and need to be - // explicitly enabled by IMAP4rev1-only servers: - // - // - NAMESPACE - // - UIDPLUS - // - ESEARCH - // - LIST-EXTENDED - // - LIST-STATUS - // - MOVE - // - STATUS=SIZE - Caps imap.CapSet - // Logger is a logger to print error messages. If nil, log.Default is used. - Logger Logger - // TLSConfig is a TLS configuration for STARTTLS. If nil, STARTTLS is - // disabled. - TLSConfig *tls.Config - // InsecureAuth allows clients to authenticate without TLS. In this mode, - // the server is susceptible to man-in-the-middle attacks. - InsecureAuth bool - // Raw ingress and egress data will be written to this writer, if any. - // Note, this may include sensitive information such as credentials used - // during authentication. - DebugWriter io.Writer -} - -func (options *Options) wrapReadWriter(rw io.ReadWriter) io.ReadWriter { - if options.DebugWriter == nil { - return rw - } - return struct { - io.Reader - io.Writer - }{ - Reader: io.TeeReader(rw, options.DebugWriter), - Writer: io.MultiWriter(rw, options.DebugWriter), - } -} - -func (options *Options) caps() imap.CapSet { - if options.Caps != nil { - return options.Caps - } - return imap.CapSet{imap.CapIMAP4rev1: {}} -} - -// Server is an IMAP server. -type Server struct { - options Options - - listenerWaitGroup sync.WaitGroup - - mutex sync.Mutex - listeners map[net.Listener]struct{} - conns map[*Conn]struct{} - closed bool -} - -// New creates a new server. -func New(options *Options) *Server { - if caps := options.caps(); !caps.Has(imap.CapIMAP4rev2) && !caps.Has(imap.CapIMAP4rev1) { - panic("imapserver: at least IMAP4rev1 must be supported") - } - return &Server{ - options: *options, - listeners: make(map[net.Listener]struct{}), - conns: make(map[*Conn]struct{}), - } -} - -func (s *Server) logger() Logger { - if s.options.Logger == nil { - return log.Default() - } - return s.options.Logger -} - -// Serve accepts incoming connections on the listener ln. -func (s *Server) Serve(ln net.Listener, addr string) error { - s.mutex.Lock() - ok := !s.closed - if ok { - s.listeners[addr] = struct{}{} - } - s.mutex.Unlock() - if !ok { - return errClosed - } - - defer func() { - s.mutex.Lock() - delete(s.listeners, ln) - s.mutex.Unlock() - }() - - s.listenerWaitGroup.Add(1) - defer s.listenerWaitGroup.Done() - - var delay time.Duration - for { - conn, err := ln.Accept() - if ne, ok := err.(net.Error); ok && ne.Temporary() { - if delay == 0 { - delay = 5 * time.Millisecond - } else { - delay *= 2 - } - if max := 1 * time.Second; delay > max { - delay = max - } - s.logger().Printf("accept error (retrying in %v): %v", delay, err) - time.Sleep(delay) - continue - } else if errors.Is(err, net.ErrClosed) { - return nil - } else if err != nil { - return fmt.Errorf("accept error: %w", err) - } - - delay = 0 - go newConn(conn, s).serve() - } -} - -// ListenAndServe listens on the TCP network address addr and then calls Serve. -// -// If addr is empty, ":143" is used. -func (s *Server) ListenAndServe(addr string) error { - if addr == "" { - addr = ":143" - } - ln, err := net.Listen("tcp", addr) - if err != nil { - return err - } - return s.Serve(ln, addr) -} - -// ListenAndServeTLS listens on the TCP network address addr and then calls -// Serve to handle incoming TLS connections. -// -// The TLS configuration set in Options.TLSConfig is used. If addr is empty, -// ":993" is used. -func (s *Server) ListenAndServeTLS(addr string) error { - if addr == "" { - addr = ":993" - } - ln, err := tls.Listen("tcp", addr, s.options.TLSConfig) - if err != nil { - return err - } - return s.Serve(ln, addr) -} - -// Close immediately closes all active listeners and connections. -// -// Close returns any error returned from closing the server's underlying -// listeners. -// -// Once Close has been called on a server, it may not be reused; future calls -// to methods such as Serve will return an error. -func (s *Server) Close() error { - var err error - - s.mutex.Lock() - ok := !s.closed - if ok { - s.closed = true - for l := range s.listeners { - if closeErr := l.Close(); closeErr != nil && err == nil { - err = closeErr - } - } - } - s.mutex.Unlock() - if !ok { - return errClosed - } - - s.listenerWaitGroup.Wait() - - s.mutex.Lock() - for c := range s.conns { - c.mutex.Lock() - c.conn.Close() - c.mutex.Unlock() - } - s.mutex.Unlock() - - return err -} diff --git a/imapserver/session.go b/imapserver/session.go deleted file mode 100644 index 35b40e8..0000000 --- a/imapserver/session.go +++ /dev/null @@ -1,126 +0,0 @@ -package imapserver - -import ( - "fmt" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" - "github.com/emersion/go-sasl" -) - -var errAuthFailed = &imap.Error{ - Type: imap.StatusResponseTypeNo, - Code: imap.ResponseCodeAuthenticationFailed, - Text: "Authentication failed", -} - -// ErrAuthFailed is returned by Session.Login on authentication failure. -var ErrAuthFailed = errAuthFailed - -// GreetingData is the data associated with an IMAP greeting. -type GreetingData struct { - PreAuth bool -} - -// NumKind describes how a number should be interpreted: either as a sequence -// number, either as a UID. -type NumKind int - -const ( - NumKindSeq = NumKind(imapwire.NumKindSeq) - NumKindUID = NumKind(imapwire.NumKindUID) -) - -// String implements fmt.Stringer. -func (kind NumKind) String() string { - switch kind { - case NumKindSeq: - return "seq" - case NumKindUID: - return "uid" - default: - panic(fmt.Errorf("imapserver: unknown NumKind %d", kind)) - } -} - -func (kind NumKind) wire() imapwire.NumKind { - return imapwire.NumKind(kind) -} - -// Session is an IMAP session. -type Session interface { - Close() error - - // Not authenticated state - Login(username, password string) error - - // Authenticated state - Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) - Create(mailbox string, options *imap.CreateOptions) error - Delete(mailbox string) error - Rename(mailbox, newName string, options *imap.RenameOptions) error - Subscribe(mailbox string) error - Unsubscribe(mailbox string) error - List(w *ListWriter, ref string, patterns []string, options *imap.ListOptions) error - Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) - Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) - Poll(w *UpdateWriter, allowExpunge bool) error - Idle(w *UpdateWriter, stop <-chan struct{}) error - - // Selected state - Unselect() error - Expunge(w *ExpungeWriter, uids *imap.UIDSet) error - Search(kind NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) - Fetch(w *FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error - Store(w *FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error - Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) -} - -// SessionNamespace is an IMAP session which supports NAMESPACE. -type SessionNamespace interface { - Session - - // Authenticated state - Namespace() (*imap.NamespaceData, error) -} - -// SessionMove is an IMAP session which supports MOVE. -type SessionMove interface { - Session - - // Selected state - Move(w *MoveWriter, numSet imap.NumSet, dest string) error -} - -// SessionIMAP4rev2 is an IMAP session which supports IMAP4rev2. -type SessionIMAP4rev2 interface { - Session - SessionNamespace - SessionMove -} - -// SessionSASL is an IMAP session which supports its own set of SASL -// authentication mechanisms. -type SessionSASL interface { - Session - AuthenticateMechanisms() []string - Authenticate(mech string) (sasl.Server, error) -} - -// SessionUnauthenticate is an IMAP session which supports UNAUTHENTICATE. -type SessionUnauthenticate interface { - Session - - // Authenticated state - Unauthenticate() error -} - -// SessionAppendLimit is an IMAP session which has the same APPEND limit for -// all mailboxes. -type SessionAppendLimit interface { - Session - - // AppendLimit returns the maximum size in bytes that can be uploaded to - // this server in an APPEND command. - AppendLimit() uint32 -} diff --git a/imapserver/starttls.go b/imapserver/starttls.go deleted file mode 100644 index d5151d7..0000000 --- a/imapserver/starttls.go +++ /dev/null @@ -1,83 +0,0 @@ -package imapserver - -import ( - "bytes" - "crypto/tls" - "io" - "net" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) canStartTLS() bool { - _, isTLS := c.conn.(*tls.Conn) - return c.server.options.TLSConfig != nil && c.state == imap.ConnStateNotAuthenticated && !isTLS -} - -func (c *Conn) handleStartTLS(tag string, dec *imapwire.Decoder) error { - if !dec.ExpectCRLF() { - return dec.Err() - } - - if c.server.options.TLSConfig == nil { - return &imap.Error{ - Type: imap.StatusResponseTypeNo, - Text: "STARTTLS not supported", - } - } - if !c.canStartTLS() { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: "STARTTLS not available", - } - } - - // Do not allow to write cleartext data past this point: keep c.encMutex - // locked until the end - enc := newResponseEncoder(c) - defer enc.end() - - err := writeStatusResp(enc.Encoder, tag, &imap.StatusResponse{ - Type: imap.StatusResponseTypeOK, - Text: "Begin TLS negotiation now", - }) - if err != nil { - return err - } - - // Drain buffered data from our bufio.Reader - var buf bytes.Buffer - if _, err := io.CopyN(&buf, c.br, int64(c.br.Buffered())); err != nil { - panic(err) // unreachable - } - - var cleartextConn net.Conn - if buf.Len() > 0 { - r := io.MultiReader(&buf, c.conn) - cleartextConn = startTLSConn{c.conn, r} - } else { - cleartextConn = c.conn - } - - tlsConn := tls.Server(cleartextConn, c.server.options.TLSConfig) - - c.mutex.Lock() - c.conn = tlsConn - c.mutex.Unlock() - - rw := c.server.options.wrapReadWriter(tlsConn) - c.br.Reset(rw) - c.bw.Reset(rw) - - return nil -} - -type startTLSConn struct { - net.Conn - r io.Reader -} - -func (conn startTLSConn) Read(b []byte) (int, error) { - return conn.r.Read(b) -} diff --git a/imapserver/status.go b/imapserver/status.go deleted file mode 100644 index b2b5feb..0000000 --- a/imapserver/status.go +++ /dev/null @@ -1,125 +0,0 @@ -package imapserver - -import ( - "strings" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleStatus(dec *imapwire.Decoder) error { - var mailbox string - if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() { - return dec.Err() - } - - var options imap.StatusOptions - err := dec.ExpectList(func() error { - err := readStatusItem(dec, &options) - if err != nil { - return err - } - return nil - }) - if err != nil { - return err - } - - if !dec.ExpectCRLF() { - return dec.Err() - } - - if options.NumRecent && !c.server.options.caps().Has(imap.CapIMAP4rev1) { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: "Unknown STATUS data item", - } - } - - if err := c.checkState(imap.ConnStateAuthenticated); err != nil { - return err - } - - data, err := c.session.Status(mailbox, &options) - if err != nil { - return err - } - - return c.writeStatus(data, &options) -} - -func (c *Conn) writeStatus(data *imap.StatusData, options *imap.StatusOptions) error { - enc := newResponseEncoder(c) - defer enc.end() - - enc.Atom("*").SP().Atom("STATUS").SP().Mailbox(data.Mailbox).SP() - listEnc := enc.BeginList() - if options.NumMessages { - listEnc.Item().Atom("MESSAGES").SP().Number(*data.NumMessages) - } - if options.UIDNext { - listEnc.Item().Atom("UIDNEXT").SP().UID(data.UIDNext) - } - if options.UIDValidity { - listEnc.Item().Atom("UIDVALIDITY").SP().Number(data.UIDValidity) - } - if options.NumUnseen { - listEnc.Item().Atom("UNSEEN").SP().Number(*data.NumUnseen) - } - if options.NumDeleted { - listEnc.Item().Atom("DELETED").SP().Number(*data.NumDeleted) - } - if options.Size { - listEnc.Item().Atom("SIZE").SP().Number64(*data.Size) - } - if options.AppendLimit { - listEnc.Item().Atom("APPENDLIMIT").SP() - if data.AppendLimit != nil { - enc.Number(*data.AppendLimit) - } else { - enc.NIL() - } - } - if options.DeletedStorage { - listEnc.Item().Atom("DELETED-STORAGE").SP().Number64(*data.DeletedStorage) - } - if options.NumRecent { - listEnc.Item().Atom("RECENT").SP().Number(*data.NumRecent) - } - listEnc.End() - - return enc.CRLF() -} - -func readStatusItem(dec *imapwire.Decoder, options *imap.StatusOptions) error { - var name string - if !dec.ExpectAtom(&name) { - return dec.Err() - } - switch strings.ToUpper(name) { - case "MESSAGES": - options.NumMessages = true - case "UIDNEXT": - options.UIDNext = true - case "UIDVALIDITY": - options.UIDValidity = true - case "UNSEEN": - options.NumUnseen = true - case "DELETED": - options.NumDeleted = true - case "SIZE": - options.Size = true - case "APPENDLIMIT": - options.AppendLimit = true - case "DELETED-STORAGE": - options.DeletedStorage = true - case "RECENT": - options.NumRecent = true - default: - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: "Unknown STATUS data item", - } - } - return nil -} diff --git a/imapserver/store.go b/imapserver/store.go deleted file mode 100644 index 848fac5..0000000 --- a/imapserver/store.go +++ /dev/null @@ -1,78 +0,0 @@ -package imapserver - -import ( - "strings" - - "github.com/emersion/go-imap/v2" - "github.com/emersion/go-imap/v2/internal" - "github.com/emersion/go-imap/v2/internal/imapwire" -) - -func (c *Conn) handleStore(dec *imapwire.Decoder, numKind NumKind) error { - var ( - numSet imap.NumSet - item string - ) - if !dec.ExpectSP() || !dec.ExpectNumSet(numKind.wire(), &numSet) || !dec.ExpectSP() || !dec.ExpectAtom(&item) || !dec.ExpectSP() { - return dec.Err() - } - var flags []imap.Flag - isList, err := dec.List(func() error { - flag, err := internal.ExpectFlag(dec) - if err != nil { - return err - } - flags = append(flags, flag) - return nil - }) - if err != nil { - return err - } else if !isList { - for { - flag, err := internal.ExpectFlag(dec) - if err != nil { - return err - } - flags = append(flags, flag) - - if !dec.SP() { - break - } - } - } - if !dec.ExpectCRLF() { - return dec.Err() - } - - item = strings.ToUpper(item) - silent := strings.HasSuffix(item, ".SILENT") - item = strings.TrimSuffix(item, ".SILENT") - - var op imap.StoreFlagsOp - switch { - case strings.HasPrefix(item, "+"): - op = imap.StoreFlagsAdd - item = strings.TrimPrefix(item, "+") - case strings.HasPrefix(item, "-"): - op = imap.StoreFlagsDel - item = strings.TrimPrefix(item, "-") - default: - op = imap.StoreFlagsSet - } - - if item != "FLAGS" { - return newClientBugError("STORE can only change FLAGS") - } - - if err := c.checkState(imap.ConnStateSelected); err != nil { - return err - } - - w := &FetchWriter{conn: c} - options := imap.StoreOptions{} - return c.session.Store(w, numSet, &imap.StoreFlags{ - Op: op, - Silent: silent, - Flags: flags, - }, &options) -} diff --git a/imapserver/tracker.go b/imapserver/tracker.go deleted file mode 100644 index b22958c..0000000 --- a/imapserver/tracker.go +++ /dev/null @@ -1,284 +0,0 @@ -package imapserver - -import ( - "fmt" - "sync" - - "github.com/emersion/go-imap/v2" -) - -// MailboxTracker tracks the state of a mailbox. -// -// A mailbox can have multiple sessions listening for updates. Each session has -// its own view of the mailbox, because IMAP clients asynchronously receive -// mailbox updates. -type MailboxTracker struct { - mutex sync.Mutex - numMessages uint32 - sessions map[*SessionTracker]struct{} -} - -// NewMailboxTracker creates a new mailbox tracker. -func NewMailboxTracker(numMessages uint32) *MailboxTracker { - return &MailboxTracker{ - numMessages: numMessages, - sessions: make(map[*SessionTracker]struct{}), - } -} - -// NewSession creates a new session tracker for the mailbox. -// -// The caller must call SessionTracker.Close once they are done with the -// session. -func (t *MailboxTracker) NewSession() *SessionTracker { - st := &SessionTracker{mailbox: t} - t.mutex.Lock() - t.sessions[st] = struct{}{} - t.mutex.Unlock() - return st -} - -func (t *MailboxTracker) queueUpdate(update *trackerUpdate, source *SessionTracker) { - t.mutex.Lock() - defer t.mutex.Unlock() - - if update.expunge != 0 && update.expunge > t.numMessages { - panic(fmt.Errorf("imapserver: expunge sequence number (%v) out of range (%v messages in mailbox)", update.expunge, t.numMessages)) - } - if update.numMessages != 0 && update.numMessages < t.numMessages { - panic(fmt.Errorf("imapserver: cannot decrease mailbox number of messages from %v to %v", t.numMessages, update.numMessages)) - } - - for st := range t.sessions { - if source != nil && st == source { - continue - } - st.queueUpdate(update) - } - - switch { - case update.expunge != 0: - t.numMessages-- - case update.numMessages != 0: - t.numMessages = update.numMessages - } -} - -// QueueExpunge queues a new EXPUNGE update. -func (t *MailboxTracker) QueueExpunge(seqNum uint32) { - if seqNum == 0 { - panic("imapserver: invalid expunge message sequence number") - } - t.queueUpdate(&trackerUpdate{expunge: seqNum}, nil) -} - -// QueueNumMessages queues a new EXISTS update. -func (t *MailboxTracker) QueueNumMessages(n uint32) { - // TODO: merge consecutive NumMessages updates - t.queueUpdate(&trackerUpdate{numMessages: n}, nil) -} - -// QueueMailboxFlags queues a new FLAGS update. -func (t *MailboxTracker) QueueMailboxFlags(flags []imap.Flag) { - if flags == nil { - flags = []imap.Flag{} - } - t.queueUpdate(&trackerUpdate{mailboxFlags: flags}, nil) -} - -// QueueMessageFlags queues a new FETCH FLAGS update. -// -// If source is not nil, the update won't be dispatched to it. -func (t *MailboxTracker) QueueMessageFlags(seqNum uint32, uid imap.UID, flags []imap.Flag, source *SessionTracker) { - t.queueUpdate(&trackerUpdate{fetch: &trackerUpdateFetch{ - seqNum: seqNum, - uid: uid, - flags: flags, - }}, source) -} - -type trackerUpdate struct { - expunge uint32 - numMessages uint32 - mailboxFlags []imap.Flag - fetch *trackerUpdateFetch -} - -type trackerUpdateFetch struct { - seqNum uint32 - uid imap.UID - flags []imap.Flag -} - -// SessionTracker tracks the state of a mailbox for an IMAP client. -type SessionTracker struct { - mailbox *MailboxTracker - - mutex sync.Mutex - queue []trackerUpdate - updates chan<- struct{} -} - -// Close unregisters the session. -func (t *SessionTracker) Close() { - t.mailbox.mutex.Lock() - delete(t.mailbox.sessions, t) - t.mailbox.mutex.Unlock() - t.mailbox = nil -} - -func (t *SessionTracker) queueUpdate(update *trackerUpdate) { - var updates chan<- struct{} - t.mutex.Lock() - t.queue = append(t.queue, *update) - updates = t.updates - t.mutex.Unlock() - - if updates != nil { - select { - case updates <- struct{}{}: - // we notified SessionTracker.Idle about the update - default: - // skip the update - } - } -} - -// Poll dequeues pending mailbox updates for this session. -func (t *SessionTracker) Poll(w *UpdateWriter, allowExpunge bool) error { - var updates []trackerUpdate - t.mutex.Lock() - if allowExpunge { - updates = t.queue - t.queue = nil - } else { - stopIndex := -1 - for i, update := range t.queue { - if update.expunge != 0 { - stopIndex = i - break - } - updates = append(updates, update) - } - if stopIndex >= 0 { - t.queue = t.queue[stopIndex:] - } else { - t.queue = nil - } - } - t.mutex.Unlock() - - for _, update := range updates { - var err error - switch { - case update.expunge != 0: - err = w.WriteExpunge(update.expunge) - case update.numMessages != 0: - err = w.WriteNumMessages(update.numMessages) - case update.mailboxFlags != nil: - err = w.WriteMailboxFlags(update.mailboxFlags) - case update.fetch != nil: - err = w.WriteMessageFlags(update.fetch.seqNum, update.fetch.uid, update.fetch.flags) - default: - panic(fmt.Errorf("imapserver: unknown tracker update %#v", update)) - } - if err != nil { - return err - } - } - return nil -} - -// Idle continuously writes mailbox updates. -// -// When the stop channel is closed, it returns. -// -// Idle cannot be invoked concurrently from two separate goroutines. -func (t *SessionTracker) Idle(w *UpdateWriter, stop <-chan struct{}) error { - updates := make(chan struct{}, 64) - t.mutex.Lock() - ok := t.updates == nil - if ok { - t.updates = updates - } - t.mutex.Unlock() - if !ok { - return fmt.Errorf("imapserver: only a single SessionTracker.Idle call is allowed at a time") - } - - defer func() { - t.mutex.Lock() - t.updates = nil - t.mutex.Unlock() - }() - - for { - select { - case <-updates: - if err := t.Poll(w, true); err != nil { - return err - } - case <-stop: - return nil - } - } -} - -// DecodeSeqNum converts a message sequence number from the client view to the -// server view. -// -// Zero is returned if the message doesn't exist from the server point-of-view. -func (t *SessionTracker) DecodeSeqNum(seqNum uint32) uint32 { - if seqNum == 0 { - return 0 - } - - t.mutex.Lock() - defer t.mutex.Unlock() - - for _, update := range t.queue { - if update.expunge == 0 { - continue - } - if seqNum == update.expunge { - return 0 - } else if seqNum > update.expunge { - seqNum-- - } - } - - if seqNum > t.mailbox.numMessages { - return 0 - } - - return seqNum -} - -// EncodeSeqNum converts a message sequence number from the server view to the -// client view. -// -// Zero is returned if the message doesn't exist from the client point-of-view. -func (t *SessionTracker) EncodeSeqNum(seqNum uint32) uint32 { - if seqNum == 0 { - return 0 - } - - t.mutex.Lock() - defer t.mutex.Unlock() - - if seqNum > t.mailbox.numMessages { - return 0 - } - - for i := len(t.queue) - 1; i >= 0; i-- { - update := t.queue[i] - // TODO: this doesn't handle increments > 1 - if update.numMessages != 0 && seqNum == update.numMessages { - return 0 - } - if update.expunge != 0 && seqNum >= update.expunge { - seqNum++ - } - } - return seqNum -} diff --git a/imapserver/tracker_test.go b/imapserver/tracker_test.go deleted file mode 100644 index 480bcba..0000000 --- a/imapserver/tracker_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package imapserver_test - -import ( - "testing" - - "github.com/emersion/go-imap/v2/imapserver" -) - -type trackerUpdate struct { - expunge uint32 - numMessages uint32 -} - -var sessionTrackerSeqNumTests = []struct { - name string - pending []trackerUpdate - clientSeqNum, serverSeqNum uint32 -}{ - { - name: "noop", - pending: nil, - clientSeqNum: 20, - serverSeqNum: 20, - }, - { - name: "noop_last", - pending: nil, - clientSeqNum: 42, - serverSeqNum: 42, - }, - { - name: "noop_client_oob", - pending: nil, - clientSeqNum: 43, - serverSeqNum: 0, - }, - { - name: "noop_server_oob", - pending: nil, - clientSeqNum: 0, - serverSeqNum: 43, - }, - { - name: "expunge_eq", - pending: []trackerUpdate{{expunge: 20}}, - clientSeqNum: 20, - serverSeqNum: 0, - }, - { - name: "expunge_lt", - pending: []trackerUpdate{{expunge: 20}}, - clientSeqNum: 10, - serverSeqNum: 10, - }, - { - name: "expunge_gt", - pending: []trackerUpdate{{expunge: 10}}, - clientSeqNum: 20, - serverSeqNum: 19, - }, - { - name: "append_eq", - pending: []trackerUpdate{{numMessages: 43}}, - clientSeqNum: 0, - serverSeqNum: 43, - }, - { - name: "append_lt", - pending: []trackerUpdate{{numMessages: 43}}, - clientSeqNum: 42, - serverSeqNum: 42, - }, - { - name: "expunge_append", - pending: []trackerUpdate{ - {expunge: 42}, - {numMessages: 42}, - }, - clientSeqNum: 42, - serverSeqNum: 0, - }, - { - name: "expunge_append", - pending: []trackerUpdate{ - {expunge: 42}, - {numMessages: 42}, - }, - clientSeqNum: 0, - serverSeqNum: 42, - }, - { - name: "append_expunge", - pending: []trackerUpdate{ - {numMessages: 43}, - {expunge: 42}, - }, - clientSeqNum: 42, - serverSeqNum: 0, - }, - { - name: "append_expunge", - pending: []trackerUpdate{ - {numMessages: 43}, - {expunge: 42}, - }, - clientSeqNum: 0, - serverSeqNum: 42, - }, - { - name: "multi_expunge_middle", - pending: []trackerUpdate{ - {expunge: 3}, - {expunge: 1}, - }, - clientSeqNum: 2, - serverSeqNum: 1, - }, - { - name: "multi_expunge_after", - pending: []trackerUpdate{ - {expunge: 3}, - {expunge: 1}, - }, - clientSeqNum: 4, - serverSeqNum: 2, - }, -} - -func TestSessionTracker(t *testing.T) { - for _, tc := range sessionTrackerSeqNumTests { - tc := tc // capture range variable - t.Run(tc.name, func(t *testing.T) { - mboxTracker := imapserver.NewMailboxTracker(42) - sessTracker := mboxTracker.NewSession() - for _, update := range tc.pending { - switch { - case update.expunge != 0: - mboxTracker.QueueExpunge(update.expunge) - case update.numMessages != 0: - mboxTracker.QueueNumMessages(update.numMessages) - } - } - - serverSeqNum := sessTracker.DecodeSeqNum(tc.clientSeqNum) - if tc.clientSeqNum != 0 && serverSeqNum != tc.serverSeqNum { - t.Errorf("DecodeSeqNum(%v): got %v, want %v", tc.clientSeqNum, serverSeqNum, tc.serverSeqNum) - } - - clientSeqNum := sessTracker.EncodeSeqNum(tc.serverSeqNum) - if tc.serverSeqNum != 0 && clientSeqNum != tc.clientSeqNum { - t.Errorf("EncodeSeqNum(%v): got %v, want %v", tc.serverSeqNum, clientSeqNum, tc.clientSeqNum) - } - }) - } -} 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 f07e86d..0000000 --- a/internal/internal.go +++ /dev/null @@ -1,188 +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 ExpectCap(dec *imapwire.Decoder) (imap.Cap, error) { - var name string - if !dec.ExpectAtom(&name) { - return "", dec.Err() - } - return canonicalCap(name), nil -} - -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) -} - -func canonicalCap(s string) imap.Cap { - // Only two caps are not fully uppercase - for _, cap := range []imap.Cap{imap.CapIMAP4rev1, imap.CapIMAP4rev2} { - if strings.EqualFold(s, string(cap)) { - return cap - } - } - return imap.Cap(strings.ToUpper(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/list.go b/list.go deleted file mode 100644 index a3103a6..0000000 --- a/list.go +++ /dev/null @@ -1,30 +0,0 @@ -package imap - -// ListOptions contains options for the LIST command. -type ListOptions struct { - SelectSubscribed bool - SelectRemote bool - SelectRecursiveMatch bool // requires SelectSubscribed to be set - SelectSpecialUse bool // requires SPECIAL-USE - - ReturnSubscribed bool - ReturnChildren bool - ReturnStatus *StatusOptions // requires IMAP4rev2 or LIST-STATUS - ReturnSpecialUse bool // requires SPECIAL-USE -} - -// ListData is the mailbox data returned by a LIST command. -type ListData struct { - Attrs []MailboxAttr - Delim rune - Mailbox string - - // Extended data - ChildInfo *ListDataChildInfo - OldName string - Status *StatusData -} - -type ListDataChildInfo struct { - Subscribed bool -} 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..8f3d692 --- /dev/null +++ b/mailbox.go @@ -0,0 +1,250 @@ +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" + +// 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.ToUpper(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" +) + +// 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 { + return errors.New("Mailbox delimiter must be a string") + } + + 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] = Atom(attr) + } + // Thunderbird doesn't understand delimiters if not quoted + return []interface{}{attrs, Quoted(info.Delimiter), 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 strings.HasPrefix(pattern, info.Delimiter) { + reference = "" + pattern = strings.TrimPrefix(pattern, info.Delimiter) + } + if reference != "" { + if !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 +} + +// 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) + 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 + } + + fields = append(fields, string(k), v) + } + return fields +} diff --git a/mailbox_test.go b/mailbox_test.go new file mode 100644 index 0000000..cae4170 --- /dev/null +++ b/mailbox_test.go @@ -0,0 +1,180 @@ +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() + sort.Sort(internal.MapListSorter(fields)) + + sort.Sort(internal.MapListSorter(test.fields)) + + if !reflect.DeepEqual(fields, 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..764029a --- /dev/null +++ b/message.go @@ -0,0 +1,1081 @@ +package imap + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime" + "strconv" + "strings" + "time" +) + +// Message flags, defined in RFC 3501 section 2.3.2. +const ( + SeenFlag = "\\Seen" + AnsweredFlag = "\\Answered" + FlaggedFlag = "\\Flagged" + DeletedFlag = "\\Deleted" + DraftFlag = "\\Draft" + RecentFlag = "\\Recent" +) + +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" +) + +// 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 { + flag = strings.ToLower(flag) + for _, f := range flags { + if strings.ToLower(f) == flag { + return f + } + } + return 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, Quoted(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 parseHeaderParamList(fields []interface{}) (map[string]string, error) { + params, err := ParseParamList(fields) + if err != nil { + return nil, err + } + + for k, v := range params { + 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 + if kstr, ok := f.(string); !ok { + return fmt.Errorf("cannot parse message: key is not a string, but a %T", f) + } else { + k = FetchItem(strings.ToUpper(kstr)) + } + } 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{} = string(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] = Atom(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 +} + +// Get 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) { + 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 != false { + resp.Peek = false + } + if len(resp.Partial) == 2 { + resp.Partial = []int{resp.Partial[0]} + } + 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 +} + +// 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) + } + if s, err := ParseString(fields[2]); err == nil { + addr.MailboxName, _ = decodeHeader(s) + } + if s, err := ParseString(fields[3]); err == nil { + 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) { + addrs = make([]*Address, len(fields)) + + for i, f := range fields { + if addrFields, ok := f.([]interface{}); ok { + addr := &Address{} + if err := addr.Parse(addrFields); err == nil { + addrs[i] = addr + } + } + } + + return +} + +// Format an address list to fields. +func FormatAddressList(addrs []*Address) (fields []interface{}) { + fields = make([]interface{}, len(addrs)) + + for i, addr := range addrs { + fields[i] = addr.Format() + } + + return +} + +// 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{}) { + return []interface{}{ + envelopeDateTime(e.Date), + encodeHeader(e.Subject), + FormatAddressList(e.From), + FormatAddressList(e.Sender), + FormatAddressList(e.ReplyTo), + FormatAddressList(e.To), + FormatAddressList(e.Cc), + FormatAddressList(e.Bcc), + e.InReplyTo, + e.MessageId, + } +} + +// A body structure. +// See RFC 3501 page 74. +type BodyStructure struct { + // Basic fields + + // The MIME type. + MIMEType string + // The MIME subtype. + 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) + } + 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, _ = fields[0].(string) + bs.MIMESubType, _ = fields[1].(string) + + 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, _ = fields[5].(string) + bs.Size, _ = ParseNumber(fields[6]) + + end := 7 + + // Type-specific fields + if bs.MIMEType == "message" && 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 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) + } + 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 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 bs.MIMEType == "message" && 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 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 +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..e8bdf94 --- /dev/null +++ b/message_test.go @@ -0,0 +1,602 @@ +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{}{ + "ENVELOPE", envelopeTests[0].fields, + "BODY", bodyStructureTests[0].fields, + "FLAGS", []interface{}{Atom(SeenFlag), Atom(AnsweredFlag)}, + "RFC822.SIZE", "4242", + "UID", "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: []*Address{}, + ReplyTo: []*Address{}, + To: []*Address{}, + Cc: []*Address{}, + Bcc: []*Address{}, + InReplyTo: "42@example.org", + MessageId: "43@example.org", + }, + fields: []interface{}{ + "Tue, 10 Nov 2009 23:00:00 -0600", + "Hello World!", + []interface{}{addrTests[0].fields}, + []interface{}{}, + []interface{}{}, + []interface{}{}, + []interface{}{}, + []interface{}{}, + "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 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) + } +} + +var paramsListTest = []struct { + fields []interface{} + params map[string]string +}{ + { + fields: nil, + params: map[string]string{}, + }, + { + fields: []interface{}{"a", Quoted("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", "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", Quoted("utf-8")}, nil, nil, "us-ascii", "42", "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", "42", + (&Envelope{}).Format(), + (&BodyStructure{}).Format(), + "67", + }, + bodyStructure: &BodyStructure{ + MIMEType: "message", + MIMESubType: "rfc822", + Params: map[string]string{}, + Encoding: "us-ascii", + Size: 42, + Lines: 67, + Envelope: &Envelope{ + From: []*Address{}, + Sender: []*Address{}, + ReplyTo: []*Address{}, + To: []*Address{}, + Cc: []*Address{}, + Bcc: []*Address{}, + }, + BodyStructure: &BodyStructure{ + Params: map[string]string{}, + }, + }, + }, + { + fields: []interface{}{ + "application", "pdf", []interface{}{}, nil, nil, "base64", "4242", + "e0323a9039add2978bf5b49550572c7c", + []interface{}{"attachment", []interface{}{"filename", Quoted("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", "87", "22"}, + []interface{}{"text", "html", []interface{}{}, nil, nil, "us-ascii", "106", "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", "87", "22"}, + "alternative", []interface{}{"hello", Quoted("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_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) + } + } +} diff --git a/namespace.go b/namespace.go deleted file mode 100644 index e538a39..0000000 --- a/namespace.go +++ /dev/null @@ -1,14 +0,0 @@ -package imap - -// NamespaceData is the data returned by the NAMESPACE command. -type NamespaceData struct { - Personal []NamespaceDescriptor - Other []NamespaceDescriptor - Shared []NamespaceDescriptor -} - -// NamespaceDescriptor describes a namespace. -type NamespaceDescriptor struct { - Prefix string - Delim rune -} diff --git a/numset.go b/numset.go deleted file mode 100644 index a96b181..0000000 --- a/numset.go +++ /dev/null @@ -1,149 +0,0 @@ -package imap - -import ( - "unsafe" - - "github.com/emersion/go-imap/v2/internal/imapnum" -) - -// NumSet is a set of numbers identifying messages. NumSet is either a SeqSet -// or a UIDSet. -type NumSet interface { - // String returns the IMAP representation of the message number set. - String() string - // Dynamic returns true if the set contains "*" or "n:*" ranges or if the - // set represents the special SEARCHRES marker. - Dynamic() bool - - numSet() imapnum.Set -} - -var ( - _ NumSet = SeqSet(nil) - _ NumSet = UIDSet(nil) -) - -// SeqSet is a set of message sequence numbers. -type SeqSet []SeqRange - -// SeqSetNum returns a new SeqSet containing the specified sequence numbers. -func SeqSetNum(nums ...uint32) SeqSet { - var s SeqSet - s.AddNum(nums...) - return s -} - -func (s *SeqSet) numSetPtr() *imapnum.Set { - return (*imapnum.Set)(unsafe.Pointer(s)) -} - -func (s SeqSet) numSet() imapnum.Set { - return *s.numSetPtr() -} - -func (s SeqSet) String() string { - return s.numSet().String() -} - -func (s SeqSet) Dynamic() bool { - return s.numSet().Dynamic() -} - -// Contains returns true if the non-zero sequence number num is contained in -// the set. -func (s *SeqSet) Contains(num uint32) bool { - return s.numSet().Contains(num) -} - -// Nums returns a slice of all sequence numbers contained in the set. -func (s *SeqSet) Nums() ([]uint32, bool) { - return s.numSet().Nums() -} - -// AddNum inserts new sequence numbers into the set. The value 0 represents "*". -func (s *SeqSet) AddNum(nums ...uint32) { - s.numSetPtr().AddNum(nums...) -} - -// AddRange inserts a new range into the set. -func (s *SeqSet) AddRange(start, stop uint32) { - s.numSetPtr().AddRange(start, stop) -} - -// AddSet inserts all sequence numbers from other into s. -func (s *SeqSet) AddSet(other SeqSet) { - s.numSetPtr().AddSet(other.numSet()) -} - -// SeqRange is a range of message sequence numbers. -type SeqRange struct { - Start, Stop uint32 -} - -// UIDSet is a set of message UIDs. -type UIDSet []UIDRange - -// UIDSetNum returns a new UIDSet containing the specified UIDs. -func UIDSetNum(uids ...UID) UIDSet { - var s UIDSet - s.AddNum(uids...) - return s -} - -func (s *UIDSet) numSetPtr() *imapnum.Set { - return (*imapnum.Set)(unsafe.Pointer(s)) -} - -func (s UIDSet) numSet() imapnum.Set { - return *s.numSetPtr() -} - -func (s UIDSet) String() string { - if IsSearchRes(s) { - return "$" - } - return s.numSet().String() -} - -func (s UIDSet) Dynamic() bool { - return s.numSet().Dynamic() || IsSearchRes(s) -} - -// Contains returns true if the non-zero UID uid is contained in the set. -func (s UIDSet) Contains(uid UID) bool { - return s.numSet().Contains(uint32(uid)) -} - -// Nums returns a slice of all UIDs contained in the set. -func (s UIDSet) Nums() ([]UID, bool) { - nums, ok := s.numSet().Nums() - return uidListFromNumList(nums), ok -} - -// AddNum inserts new UIDs into the set. The value 0 represents "*". -func (s *UIDSet) AddNum(uids ...UID) { - s.numSetPtr().AddNum(numListFromUIDList(uids)...) -} - -// AddRange inserts a new range into the set. -func (s *UIDSet) AddRange(start, stop UID) { - s.numSetPtr().AddRange(uint32(start), uint32(stop)) -} - -// AddSet inserts all UIDs from other into s. -func (s *UIDSet) AddSet(other UIDSet) { - s.numSetPtr().AddSet(other.numSet()) -} - -// UIDRange is a range of message UIDs. -type UIDRange struct { - Start, Stop UID -} - -func numListFromUIDList(uids []UID) []uint32 { - return *(*[]uint32)(unsafe.Pointer(&uids)) -} - -func uidListFromNumList(nums []uint32) []UID { - return *(*[]UID)(unsafe.Pointer(&nums)) -} diff --git a/quota.go b/quota.go deleted file mode 100644 index f128fe4..0000000 --- a/quota.go +++ /dev/null @@ -1,13 +0,0 @@ -package imap - -// QuotaResourceType is a QUOTA resource type. -// -// See RFC 9208 section 5. -type QuotaResourceType string - -const ( - QuotaResourceStorage QuotaResourceType = "STORAGE" - QuotaResourceMessage QuotaResourceType = "MESSAGE" - QuotaResourceMailbox QuotaResourceType = "MAILBOX" - QuotaResourceAnnotationStorage QuotaResourceType = "ANNOTATION-STORAGE" -) diff --git a/read.go b/read.go new file mode 100644 index 0000000..5285afb --- /dev/null +++ b/read.go @@ -0,0 +1,461 @@ +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 + } + + s, ok := f.(string) + if !ok { + return 0, newParseError("expected a number, got a non-atom") + } + + nbr, err := strconv.ParseUint(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 q, ok := f.(Quoted); ok { + return string(q), nil + } + if a, ok := f.(Atom); 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 != 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 { + 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 == "NIL" { + 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) + 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 { + 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 == listEnd || char == respCodeEnd { + if char == cr { + 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(cr)) + if err != nil { + return + } + info = strings.TrimSuffix(info, string(cr)) + info = strings.TrimLeft(info, " ") + + var char rune + if char, _, err = r.ReadRune(); err != nil { + return + } + if char != lf { + err = newParseError("line doesn't end with a LF") + } + 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..3d1a5d1 --- /dev/null +++ b/read_test.go @@ -0,0 +1,500 @@ +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") + } + + b, 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") + } + + b, r = newReader("") + if err := r.ReadCrlf(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("\n") + if err := r.ReadCrlf(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("\r") + if err := r.ReadCrlf(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, 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(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") + } + } + + b, r = newReader("") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("[7}\r\nabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("{7]\r\nabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("{7.4}\r\nabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("{7}abcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("{7}\rabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("{7}\nabcdefg") + if _, err := r.ReadLiteral(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, 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") + } + } + + b, 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) + } + + b, r = newReader("") + if _, err := r.ReadQuotedString(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("hello gopher\"\r\n") + if _, err := r.ReadQuotedString(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("\"hello gopher\r\n") + if _, err := r.ReadQuotedString(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, 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") + } + } + + b, r = newReader("") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("fi\"eld1 \"field2\"\r\n") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("field1 ") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("field1 (") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("field1\"field2\"\r\n") + if _, err := r.ReadFields(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, 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") + } + + b, r = newReader("") + if _, err := r.ReadList(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("[field1 field2 field3)") + if _, err := r.ReadList(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("(field1 fie\"ld2 field3)") + if _, err := r.ReadList(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, 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") + } + + b, r = newReader("") + if _, err := r.ReadLine(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, 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") + } + + b, r = newReader("") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("{CAPABILITY NOOP STARTTLS]") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("[CAPABILITY NO\"OP STARTTLS]") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("[]") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("[{3}\r\nabc]") + if _, _, err := r.ReadRespCode(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, 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") + } + + b, r = newReader("I love potatoes.") + if _, err := r.ReadInfo(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("I love potatoes.\r") + if _, err := r.ReadInfo(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("I love potatoes.\n") + if _, err := r.ReadInfo(); err == nil { + t.Error("Invalid read didn't fail") + } + + b, r = newReader("I love potatoes.\rabc") + if _, err := r.ReadInfo(); err == nil { + t.Error("Invalid read didn't fail") + } +} diff --git a/rename.go b/rename.go deleted file mode 100644 index a87f0e2..0000000 --- a/rename.go +++ /dev/null @@ -1,4 +0,0 @@ -package imap - -// RenameOptions contains options for the RENAME command. -type RenameOptions struct{} diff --git a/response.go b/response.go index 0ce54cf..fa81e62 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 := Atom(r.Tag) + if tag == "" { + tag = Atom("*") + } + + fields := []interface{}{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..aa83092 --- /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{}{"76", "FETCH", []interface{}{"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..153e62f --- /dev/null +++ b/responses/authenticate.go @@ -0,0 +1,58 @@ +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 + Writer *imap.Writer +} + +func (r *Authenticate) writeLine(l string) error { + if _, err := r.Writer.Write([]byte(l + "\r\n")); err != nil { + return err + } + return r.Writer.Flush() +} + +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..4c6b5f9 --- /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{}{"CAPABILITY"} + for _, cap := range r.Caps { + fields = append(fields, cap) + } + + return imap.NewUntaggedResp(fields).WriteTo(w) +} diff --git a/responses/expunge.go b/responses/expunge.go new file mode 100644 index 0000000..5343845 --- /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, 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..d61ef5c --- /dev/null +++ b/responses/fetch.go @@ -0,0 +1,47 @@ +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 +} + +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 + } + + r.Messages <- msg + return nil +} + +func (r *Fetch) WriteTo(w *imap.Writer) error { + for msg := range r.Messages { + resp := imap.NewUntaggedResp([]interface{}{msg.SeqNum, fetchName, msg.Format()}) + if err := resp.WriteTo(w); err != nil { + return err + } + } + + return nil +} diff --git a/responses/list.go b/responses/list.go new file mode 100644 index 0000000..2a13d8a --- /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{}{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/responses.go b/responses/responses.go new file mode 100644 index 0000000..2c1b34b --- /dev/null +++ b/responses/responses.go @@ -0,0 +1,28 @@ +// 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) +} diff --git a/responses/search.go b/responses/search.go new file mode 100644 index 0000000..3a602ae --- /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{}{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..4157574 --- /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.Atom(f) + } + res := imap.NewUntaggedResp([]interface{}{"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.Atom(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, "EXISTS"}) + if err := res.WriteTo(w); err != nil { + return err + } + case imap.StatusRecent: + res := imap.NewUntaggedResp([]interface{}{mbox.Recent, "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..e34cd94 --- /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{}{statusName, name, mbox.Format()} + return imap.NewUntaggedResp(fields).WriteTo(w) +} diff --git a/search.go b/search.go index a94f52f..25d7a8b 100644 --- a/search.go +++ b/search.go @@ -1,201 +1,366 @@ 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 - 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, "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, "ON", searchDate(c.Since)) + } else { + if !c.Since.IsZero() { + fields = append(fields, "SINCE", searchDate(c.Since)) + } + if !c.Before.IsZero() { + fields = append(fields, "BEFORE", searchDate(c.Before)) + } + } + if !c.SentSince.IsZero() && !c.SentBefore.IsZero() && c.SentBefore.Sub(c.SentSince) == 24*time.Hour { + fields = append(fields, "SENTON", searchDate(c.SentSince)) + } else { + if !c.SentSince.IsZero() { + fields = append(fields, "SENTSINCE", searchDate(c.SentSince)) + } + if !c.SentBefore.IsZero() { + fields = append(fields, "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{}{strings.ToUpper(key)} + default: + prefields = []interface{}{"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, "BODY", value) + } + for _, value := range c.Text { + fields = append(fields, "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{}{strings.ToUpper(strings.TrimPrefix(flag, "\\"))} + default: + subfields = []interface{}{"KEYWORD", flag} + } + fields = append(fields, subfields...) + } + for _, flag := range c.WithoutFlags { + var subfields []interface{} + switch flag { + case AnsweredFlag, DeletedFlag, DraftFlag, FlaggedFlag, SeenFlag: + subfields = []interface{}{"UN" + strings.ToUpper(strings.TrimPrefix(flag, "\\"))} + case RecentFlag: + subfields = []interface{}{"OLD"} + default: + subfields = []interface{}{"UNKEYWORD", flag} + } + fields = append(fields, subfields...) + } + + if c.Larger > 0 { + fields = append(fields, "LARGER", c.Larger) + } + if c.Smaller > 0 { + fields = append(fields, "SMALLER", c.Smaller) + } + + for _, not := range c.Not { + fields = append(fields, "NOT", not.Format()) + } + + for _, or := range c.Or { + fields = append(fields, "OR", or[0].Format(), or[1].Format()) + } + + return fields } diff --git a/search_test.go b/search_test.go new file mode 100644 index 0000000..e85a1cf --- /dev/null +++ b/search_test.go @@ -0,0 +1,137 @@ +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}, + }, + }}, + }, + }, +} + +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/select.go b/select.go deleted file mode 100644 index f307ff3..0000000 --- a/select.go +++ /dev/null @@ -1,31 +0,0 @@ -package imap - -// SelectOptions contains options for the SELECT or EXAMINE command. -type SelectOptions struct { - ReadOnly bool - CondStore bool // requires CONDSTORE -} - -// SelectData is the data returned by a SELECT command. -// -// In the old RFC 2060, PermanentFlags, UIDNext and UIDValidity are optional. -type SelectData struct { - // Flags defined for this mailbox - Flags []Flag - // Flags that the client can change permanently - PermanentFlags []Flag - // Number of messages in this mailbox (aka. "EXISTS") - NumMessages uint32 - // Sequence number of the first unseen message. Obsolete, IMAP4rev1 only. - // Server-only, not supported in imapclient. - FirstUnseenSeqNum uint32 - // Number of recent messages in this mailbox. Obsolete, IMAP4rev1 only. - // Server-only, not supported in imapclient. - NumRecent uint32 - UIDNext UID - UIDValidity uint32 - - List *ListData // requires IMAP4rev2 - - HighestModSeq uint64 // requires CONDSTORE -} 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..a01f0a6 --- /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 c.Close() + defer s.Close() + + io.WriteString(c, "a001 CAPABILITY\r\n") + + scanner.Scan() + if scanner.Text() != "* CAPABILITY IMAP4rev1 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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.Close() + + s.Enable(&xnoop{}) + + io.WriteString(c, "a001 CAPABILITY\r\n") + + scanner.Scan() + if scanner.Text() != "* CAPABILITY IMAP4rev1 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 c.Close() + defer s.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 AUTH=PLAIN AUTH=XNOOP" && + scanner.Text() != "* CAPABILITY IMAP4rev1 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..ce25f7f --- /dev/null +++ b/server/cmd_auth.go @@ -0,0 +1,263 @@ +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 Authenticated state. +var ( + ErrNotAuthenticated = errors.New("Not authenticated") +) + +type Select struct { + commands.Select +} + +func (cmd *Select) 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 + } + + 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) + close(done) + })() + + mailboxes, err := ctx.User.ListMailboxes(cmd.Subscribed) + if err != nil { + close(ch) + return err + } + + for _, mbox := range mailboxes { + info, err := mbox.Info() + if err != nil { + 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(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 { + 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 +} diff --git a/server/cmd_auth_test.go b/server/cmd_auth_test.go new file mode 100644 index 0000000..36537da --- /dev/null +++ b/server/cmd_auth_test.go @@ -0,0 +1,599 @@ +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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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) { + 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 c.Close() + defer s.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 TestList_NotAuthenticated(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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..a7f926e --- /dev/null +++ b/server/cmd_noauth.go @@ -0,0 +1,127 @@ +package server + +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" +) + +// 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(conn net.Conn) (net.Conn, error) { + tlsConn = tls.Server(conn, tlsConfig) + err := tlsConn.Handshake() + return tlsConn, err + }) + if err != nil { + return err + } + + conn.setTLSConn(tlsConn) + + res := &responses.Capability{Caps: conn.Capabilities()} + return conn.WriteResp(res) +} + +func afterAuthStatus(conn Conn) error { + return ErrStatusResp(&imap.StatusResp{ + Type: imap.StatusRespOk, + Code: imap.CodeCapability, + Arguments: imap.FormatStringList(conn.Capabilities()), + }) +} + +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(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..9b0004b --- /dev/null +++ b/server/cmd_noauth_test.go @@ -0,0 +1,137 @@ +package server_test + +import ( + "bufio" + "crypto/tls" + "io" + "strings" + "testing" + + "github.com/emersion/go-imap/internal" +) + +func TestStartTLS(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer c.Close() + defer s.Close() + + 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 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) + } + scanner = bufio.NewScanner(sc) + + scanner.Scan() + if scanner.Text() != "* CAPABILITY IMAP4rev1 AUTH=PLAIN" { + t.Fatal("Bad CAPABILITY response:", scanner.Text()) + } +} + +func TestLogin_Ok(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer c.Close() + defer s.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_No(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer c.Close() + defer s.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 c.Close() + defer s.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_No(t *testing.T) { + s, c, scanner := testServerGreeted(t) + defer c.Close() + defer s.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 c.Close() + defer s.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..f57623b --- /dev/null +++ b/server/cmd_selected.go @@ -0,0 +1,303 @@ +package server + +import ( + "errors" + + "github.com/emersion/go-imap" + "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) + defer close(done) + + ch := make(chan uint32) + res := &responses.Expunge{SeqNums: ch} + + go (func() { + done <- conn.WriteResp(res) + })() + + // 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-- { + ch <- seqnums[i] + } + 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) + })() + + 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 + } + + flagsList, ok := cmd.Value.([]interface{}) + if !ok { + return errors.New("Flags must be a list") + } + flags, err := imap.ParseStringList(flagsList) + if err != nil { + 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 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..8cdb1bf --- /dev/null +++ b/server/cmd_selected_test.go @@ -0,0 +1,470 @@ +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 TestCheck(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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_Uid(t *testing.T) { + s, c, scanner := testServerSelected(t, true) + defer c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.Close() + + io.WriteString(c, "a001 STORE 1 +FLAGS somestring\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "a001 NO ") { + t.Fatal("Invalid status response:", scanner.Text()) + } + + 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_Uid(t *testing.T) { + s, c, scanner := testServerSelected(t, false) + defer c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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 c.Close() + defer s.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..d6e5df2 --- /dev/null +++ b/server/conn.go @@ -0,0 +1,394 @@ +package server + +import ( + "crypto/tls" + "errors" + "io" + "net" + "sync" + "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 + + 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 + l sync.Locker + tlsConn *tls.Conn + continues 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, + l: &sync.Mutex{}, + ctx: &Context{ + State: imap.ConnectingState, + Responses: responses, + LoggedOut: loggedOut, + }, + tlsConn: tlsConn, + continues: continues, + responses: responses, + loggedOut: loggedOut, + } + + if s.Debug != nil { + conn.Conn.SetDebug(s.Debug) + } + if s.MaxLiteralSize > 0 { + conn.Conn.MaxLiteralSize = s.MaxLiteralSize + } + + conn.l.Lock() + 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"} + + 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) send() { + // Send continuation requests + go func() { + for range c.continues { + resp := &imap.ContinuationReq{Info: "send literal"} + if err := resp.WriteTo(c.Writer); err != nil { + c.Server().ErrorLog.Println("cannot send continuation request: ", err) + } else if err := c.Writer.Flush(); err != nil { + c.Server().ErrorLog.Println("cannot flush connection: ", err) + } + } + }() + + // Send responses + for { + // Get a response that needs to be sent + select { + case res := <-c.responses: + // Request to send the response + c.l.Lock() + + // Send the response + if err := res.WriteTo(c.Writer); err != nil { + c.Server().ErrorLog.Println("cannot send response: ", err) + } else if err := c.Writer.Flush(); err != nil { + c.Server().ErrorLog.Println("cannot flush connection: ", err) + } + + c.l.Unlock() + 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] = cap + } + + greeting := &imap.StatusResp{ + Type: imap.StatusRespOk, + Code: imap.CodeCapability, + Arguments: args, + Info: "IMAP4rev1 Service Ready", + } + + c.l.Unlock() + defer c.l.Lock() + + 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) error { + c.conn = conn + + defer func() { + c.ctx.State = imap.LogoutState + close(c.continues) + close(c.loggedOut) + }() + + // 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 + + c.Wait() + 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 { + c.l.Unlock() + + if err := c.WriteResp(res); err != nil { + c.s.ErrorLog.Println("cannot write response:", err) + c.l.Lock() + 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 + } + } + + c.l.Lock() + } + } +} + +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 + } + + c.l.Unlock() + defer c.l.Lock() + + hdlrErr := hdlr.Handle(c.conn) + if statusErr, ok := hdlrErr.(*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..391025a --- /dev/null +++ b/server/server.go @@ -0,0 +1,412 @@ +// 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 ErrStatusResp or ErrNoStatusResp. + 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 +} + +type errStatusResp struct { + resp *imap.StatusResp +} + +func (err *errStatusResp) Error() string { + return "" +} + +// ErrStatusResp can be returned by a Handler to replace the default status +// response. The response tag must be empty. +// +// To disable the default status response, use ErrNoStatusResp instead. +func ErrStatusResp(res *imap.StatusResp) error { + return &errStatusResp{res} +} + +// ErrNoStatusResp can be returned by a Handler to prevent the default status +// response from being sent. +func ErrNoStatusResp() error { + return &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(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{} }, + + "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{} }, + "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) +} + +// Get 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.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. +// +// This function should not be called directly, it must only be used by +// libraries implementing extensions of the IMAP protocol. +func (s *Server) Enable(extensions ...Extension) { + s.extensions = append(s.extensions, extensions...) +} + +// Enable an authentication mechanism on this server. +// +// This function should not be called directly, it must only be used by +// libraries implementing extensions of the IMAP protocol. +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..429f77d --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,46 @@ +package server_test + +import ( + "bufio" + "net" + "testing" + + "github.com/emersion/go-imap/backend/memory" + "github.com/emersion/go-imap/server" +) + +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 conn.Close() + defer s.Close() + + scanner := bufio.NewScanner(conn) + + scanner.Scan() // Wait for greeting + greeting := scanner.Text() + + if greeting != "* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] IMAP4rev1 Service Ready" { + t.Fatal("Bad greeting:", greeting) + } +} diff --git a/status.go b/status.go index f399456..2dfe26f 100644 --- a/status.go +++ b/status.go @@ -1,35 +1,120 @@ 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 = "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 = "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 = "PREAUTH" + + // The BYE response is always untagged, and indicates that the server + // is about to close the connection. + StatusRespBye = "BYE" +) + +type StatusRespCode string + +// Status response codes defined in RFC 3501 section 7.1. +const ( + CodeAlert StatusRespCode = "ALERT" + CodeBadCharset = "BADCHARSET" + CodeCapability = "CAPABILITY" + CodeParse = "PARSE" + CodePermanentFlags = "PERMANENTFLAGS" + CodeReadOnly = "READ-ONLY" + CodeReadWrite = "READ-WRITE" + CodeTryCreate = "TRYCREATE" + CodeUidNext = "UIDNEXT" + CodeUidValidity = "UIDVALIDITY" + CodeUnseen = "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. -// -// The mailbox name is always populated. The remaining fields are optional. -type StatusData struct { - Mailbox string +func (r *StatusResp) resp() {} - NumMessages *uint32 - NumRecent *uint32 // Obsolete, IMAP4rev1 only. Server-only, not supported in imapclient. - UIDNext UID - UIDValidity uint32 - NumUnseen *uint32 - NumDeleted *uint32 - Size *int64 +// 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") + } - AppendLimit *uint32 - DeletedStorage *int64 - HighestModSeq uint64 + if r.Type == StatusRespNo || r.Type == StatusRespBad { + return errors.New(r.Info) + } + return nil +} + +func (r *StatusResp) WriteTo(w *Writer) error { + tag := Atom(r.Tag) + if tag == "" { + tag = "*" + } + + if err := w.writeFields([]interface{}{tag, string(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() } diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..c4a2ea6 --- /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{}{"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/store.go b/store.go deleted file mode 100644 index c1ea26f..0000000 --- a/store.go +++ /dev/null @@ -1,22 +0,0 @@ -package imap - -// StoreOptions contains options for the STORE command. -type StoreOptions struct { - UnchangedSince uint64 // requires CONDSTORE -} - -// StoreFlagsOp is a flag operation: set, add or delete. -type StoreFlagsOp int - -const ( - StoreFlagsSet StoreFlagsOp = iota - StoreFlagsAdd - StoreFlagsDel -) - -// StoreFlags alters message flags. -type StoreFlags struct { - Op StoreFlagsOp - Silent bool - Flags []Flag -} diff --git a/thread.go b/thread.go deleted file mode 100644 index e4e3122..0000000 --- a/thread.go +++ /dev/null @@ -1,9 +0,0 @@ -package imap - -// ThreadAlgorithm is a threading algorithm. -type ThreadAlgorithm string - -const ( - ThreadOrderedSubject ThreadAlgorithm = "ORDEREDSUBJECT" - ThreadReferences ThreadAlgorithm = "REFERENCES" -) 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..1c5f1c4 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,61 @@ 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) { + 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 +136,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 74% rename from internal/utf7/decoder_test.go rename to utf7/decoder_test.go index 8584d96..f8728f1 100644 --- a/internal/utf7/decoder_test.go +++ b/utf7/decoder_test.go @@ -1,10 +1,9 @@ package utf7_test import ( - "strings" "testing" - "github.com/emersion/go-imap/v2/internal/utf7" + "github.com/emersion/go-imap/utf7" ) var decode = []struct { @@ -30,10 +29,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}, @@ -75,14 +72,6 @@ var decode = []struct { {"&AGE-&Jjo-", "", false}, {"&U,BTFw-&ZeVnLIqe-", "", false}, - // Long input with Base64 at the end - {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa &2D3eCg- &2D3eCw- &2D3eDg-", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \U0001f60a \U0001f60b \U0001f60e", true}, - - // Long input in Base64 between short ASCII - {"00000000000000000000 &MEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEI- 00000000000000000000", - "00000000000000000000 " + strings.Repeat("\U00003042", 37) + " 00000000000000000000", true}, - // ASCII in Base64 {"&AGE-", "", false}, // "a" {"&ACY-", "", false}, // "&" @@ -99,8 +88,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..e55755a --- /dev/null +++ b/utf7/utf7.go @@ -0,0 +1,34 @@ +// 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..274b052 --- /dev/null +++ b/write.go @@ -0,0 +1,234 @@ +package imap + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + "time" + "unicode" +) + +type flusher interface { + Flush() error +} + +type ( + // A string that will be quoted. + Quoted string + // A raw atom. + Atom 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 + + 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) writeAtom(s string) error { + return w.writeString(s) +} + +func (w *Writer) writeAstring(s string) error { + if !isAscii(s) { + // IMAP doesn't allow 8-bit data outside literals + return w.writeLiteral(bytes.NewBufferString(s)) + } + + if strings.ToUpper(s) == nilAtom || s == "" || strings.ContainsAny(s, atomSpecials) { + return w.writeQuoted(s) + } + + return w.writeAtom(s) +} + +func (w *Writer) writeDateTime(t time.Time, layout string) error { + if t.IsZero() { + return w.writeAtom(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)) +} + +func (w *Writer) writeLiteral(l Literal) error { + if l == nil { + return w.writeString(nilAtom) + } + + header := string(literalStart) + strconv.Itoa(l.Len()) + 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 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") + } + } + + _, err := io.Copy(w, l) + return err +} + +func (w *Writer) writeField(field interface{}) error { + if field == nil { + return w.writeAtom(nilAtom) + } + + switch field := field.(type) { + case string: + return w.writeAstring(field) + case Quoted: + return w.writeQuoted(string(field)) + case Atom: + return w.writeAtom(string(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{}{string(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..7ba9fef --- /dev/null +++ b/write_test.go @@ -0,0 +1,262 @@ +package imap + +import ( + "bytes" + "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(Atom("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(Quoted("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{}{"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{}{"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{}{ + "toplevel", + []interface{}{ + "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") + } +} + +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_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") + } +} + +func TestWriter_WriteRespCode_WithArgs(t *testing.T) { + w, b := newWriter() + + args := []interface{}{"IMAP4rev1", "STARTTLS", "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") + } +} + +func TestWriter_WriteLine(t *testing.T) { + w, b := newWriter() + + if err := w.writeLine(Atom("*"), "OK"); err != nil { + t.Error(err) + } + if b.String() != "* OK\r\n" { + t.Error("Not the expected line:", b.String()) + } +}