From a65a31fdac75a2be5361519b2fa37172b0847b34 Mon Sep 17 00:00:00 2001 From: Anastasios Svolis Date: Mon, 8 Dec 2025 06:42:29 +0200 Subject: [PATCH] Added files. --- LICENSE | 23 + README.md | 29 + acl.go | 104 +++ append.go | 18 + capability.go | 212 +++++ cmd/imapmemserver/main.go | 128 +++ copy.go | 9 + create.go | 6 + fetch.go | 284 ++++++ go.mod | 8 + go.sum | 35 + id.go | 15 + imap.go | 105 +++ imapclient/acl.go | 138 +++ imapclient/acl_test.go | 115 +++ imapclient/append.go | 58 ++ imapclient/append_test.go | 28 + imapclient/authenticate.go | 100 ++ imapclient/authenticate_test.go | 24 + imapclient/capability.go | 56 ++ imapclient/client.go | 1243 +++++++++++++++++++++++++ imapclient/client_test.go | 277 ++++++ imapclient/connection_test.go | 37 + imapclient/copy.go | 37 + imapclient/create.go | 21 + imapclient/create_test.go | 57 ++ imapclient/dovecot_test.go | 72 ++ imapclient/enable.go | 69 ++ imapclient/example_test.go | 411 +++++++++ imapclient/expunge.go | 84 ++ imapclient/expunge_test.go | 36 + imapclient/fetch.go | 1326 +++++++++++++++++++++++++++ imapclient/fetch_test.go | 39 + imapclient/id.go | 163 ++++ imapclient/idle.go | 157 ++++ imapclient/idle_test.go | 42 + imapclient/list.go | 259 ++++++ imapclient/list_test.go | 42 + imapclient/metadata.go | 205 +++++ imapclient/move.go | 74 ++ imapclient/namespace.go | 110 +++ imapclient/quota.go | 176 ++++ imapclient/search.go | 401 ++++++++ imapclient/search_test.go | 61 ++ imapclient/select.go | 100 ++ imapclient/select_test.go | 20 + imapclient/sort.go | 84 ++ imapclient/starttls.go | 83 ++ imapclient/starttls_test.go | 27 + imapclient/status.go | 164 ++++ imapclient/status_test.go | 34 + imapclient/store.go | 44 + imapclient/store_test.go | 40 + imapclient/thread.go | 85 ++ imapserver/append.go | 123 +++ imapserver/authenticate.go | 148 +++ imapserver/capability.go | 114 +++ imapserver/conn.go | 618 +++++++++++++ imapserver/copy.go | 55 ++ imapserver/create.go | 45 + imapserver/enable.go | 47 + imapserver/expunge.go | 50 + imapserver/fetch.go | 715 +++++++++++++++ imapserver/idle.go | 50 + imapserver/imapmemserver/mailbox.go | 511 +++++++++++ imapserver/imapmemserver/message.go | 273 ++++++ imapserver/imapmemserver/server.go | 61 ++ imapserver/imapmemserver/session.go | 140 +++ imapserver/imapmemserver/user.go | 204 +++++ imapserver/list.go | 329 +++++++ imapserver/list_test.go | 51 ++ imapserver/login.go | 28 + imapserver/message.go | 336 +++++++ imapserver/move.go | 40 + imapserver/namespace.go | 54 ++ imapserver/search.go | 343 +++++++ imapserver/select.go | 174 ++++ imapserver/server.go | 222 +++++ imapserver/session.go | 126 +++ imapserver/starttls.go | 83 ++ imapserver/status.go | 125 +++ imapserver/store.go | 78 ++ imapserver/tracker.go | 284 ++++++ imapserver/tracker_test.go | 155 ++++ internal/acl.go | 13 + internal/imapnum/numset.go | 306 +++++++ internal/imapnum/numset_test.go | 724 +++++++++++++++ internal/imapwire/decoder.go | 654 +++++++++++++ internal/imapwire/encoder.go | 341 +++++++ internal/imapwire/imapwire.go | 47 + internal/imapwire/num.go | 39 + internal/internal.go | 188 ++++ internal/sasl.go | 23 + internal/utf7/decoder.go | 118 +++ internal/utf7/decoder_test.go | 115 +++ internal/utf7/encoder.go | 88 ++ internal/utf7/encoder_test.go | 124 +++ internal/utf7/utf7.go | 13 + list.go | 30 + namespace.go | 14 + numset.go | 149 +++ quota.go | 13 + rename.go | 4 + response.go | 81 ++ search.go | 201 ++++ select.go | 31 + status.go | 35 + store.go | 22 + thread.go | 9 + 109 files changed, 16539 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 acl.go create mode 100644 append.go create mode 100644 capability.go create mode 100644 cmd/imapmemserver/main.go create mode 100644 copy.go create mode 100644 create.go create mode 100644 fetch.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 id.go create mode 100644 imap.go create mode 100644 imapclient/acl.go create mode 100644 imapclient/acl_test.go create mode 100644 imapclient/append.go create mode 100644 imapclient/append_test.go create mode 100644 imapclient/authenticate.go create mode 100644 imapclient/authenticate_test.go create mode 100644 imapclient/capability.go create mode 100644 imapclient/client.go create mode 100644 imapclient/client_test.go create mode 100644 imapclient/connection_test.go create mode 100644 imapclient/copy.go create mode 100644 imapclient/create.go create mode 100644 imapclient/create_test.go create mode 100644 imapclient/dovecot_test.go create mode 100644 imapclient/enable.go create mode 100644 imapclient/example_test.go create mode 100644 imapclient/expunge.go create mode 100644 imapclient/expunge_test.go create mode 100644 imapclient/fetch.go create mode 100644 imapclient/fetch_test.go create mode 100644 imapclient/id.go create mode 100644 imapclient/idle.go create mode 100644 imapclient/idle_test.go create mode 100644 imapclient/list.go create mode 100644 imapclient/list_test.go create mode 100644 imapclient/metadata.go create mode 100644 imapclient/move.go create mode 100644 imapclient/namespace.go create mode 100644 imapclient/quota.go create mode 100644 imapclient/search.go create mode 100644 imapclient/search_test.go create mode 100644 imapclient/select.go create mode 100644 imapclient/select_test.go create mode 100644 imapclient/sort.go create mode 100644 imapclient/starttls.go create mode 100644 imapclient/starttls_test.go create mode 100644 imapclient/status.go create mode 100644 imapclient/status_test.go create mode 100644 imapclient/store.go create mode 100644 imapclient/store_test.go create mode 100644 imapclient/thread.go create mode 100644 imapserver/append.go create mode 100644 imapserver/authenticate.go create mode 100644 imapserver/capability.go create mode 100644 imapserver/conn.go create mode 100644 imapserver/copy.go create mode 100644 imapserver/create.go create mode 100644 imapserver/enable.go create mode 100644 imapserver/expunge.go create mode 100644 imapserver/fetch.go create mode 100644 imapserver/idle.go create mode 100644 imapserver/imapmemserver/mailbox.go create mode 100644 imapserver/imapmemserver/message.go create mode 100644 imapserver/imapmemserver/server.go create mode 100644 imapserver/imapmemserver/session.go create mode 100644 imapserver/imapmemserver/user.go create mode 100644 imapserver/list.go create mode 100644 imapserver/list_test.go create mode 100644 imapserver/login.go create mode 100644 imapserver/message.go create mode 100644 imapserver/move.go create mode 100644 imapserver/namespace.go create mode 100644 imapserver/search.go create mode 100644 imapserver/select.go create mode 100644 imapserver/server.go create mode 100644 imapserver/session.go create mode 100644 imapserver/starttls.go create mode 100644 imapserver/status.go create mode 100644 imapserver/store.go create mode 100644 imapserver/tracker.go create mode 100644 imapserver/tracker_test.go create mode 100644 internal/acl.go create mode 100644 internal/imapnum/numset.go create mode 100644 internal/imapnum/numset_test.go create mode 100644 internal/imapwire/decoder.go create mode 100644 internal/imapwire/encoder.go create mode 100644 internal/imapwire/imapwire.go create mode 100644 internal/imapwire/num.go create mode 100644 internal/internal.go create mode 100644 internal/sasl.go create mode 100644 internal/utf7/decoder.go create mode 100644 internal/utf7/decoder_test.go create mode 100644 internal/utf7/encoder.go create mode 100644 internal/utf7/encoder_test.go create mode 100644 internal/utf7/utf7.go create mode 100644 list.go create mode 100644 namespace.go create mode 100644 numset.go create mode 100644 quota.go create mode 100644 rename.go create mode 100644 response.go create mode 100644 search.go create mode 100644 select.go create mode 100644 status.go create mode 100644 store.go create mode 100644 thread.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d6718dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2013 The Go-IMAP Authors +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c84fdb9 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# 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) + +An [IMAP4rev2] library for Go. + +> **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]. + +## Usage + +To add go-imap to your project, run: + + go get github.com/emersion/go-imap/v2 + +Documentation and examples for the module are available here: + +- [Client docs] +- [Server docs] + +## 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 new file mode 100644 index 0000000..4d9431e --- /dev/null +++ b/acl.go @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..13d887f --- /dev/null +++ b/append.go @@ -0,0 +1,18 @@ +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/capability.go b/capability.go new file mode 100644 index 0000000..e8b656c --- /dev/null +++ b/capability.go @@ -0,0 +1,212 @@ +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/cmd/imapmemserver/main.go b/cmd/imapmemserver/main.go new file mode 100644 index 0000000..781e1c3 --- /dev/null +++ b/cmd/imapmemserver/main.go @@ -0,0 +1,128 @@ +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/copy.go b/copy.go new file mode 100644 index 0000000..f685a60 --- /dev/null +++ b/copy.go @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..09e8bc4 --- /dev/null +++ b/create.go @@ -0,0 +1,6 @@ +package imap + +// CreateOptions contains options for the CREATE command. +type CreateOptions struct { + SpecialUse []MailboxAttr // requires CREATE-SPECIAL-USE +} diff --git a/fetch.go b/fetch.go new file mode 100644 index 0000000..f146c89 --- /dev/null +++ b/fetch.go @@ -0,0 +1,284 @@ +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 new file mode 100644 index 0000000..b5910df --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/emersion/go-imap/v2 + +go 1.18 + +require ( + github.com/emersion/go-message v0.18.2 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1aa91fb --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..de7ca0e --- /dev/null +++ b/id.go @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..7b43357 --- /dev/null +++ b/imap.go @@ -0,0 +1,105 @@ +// 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 + +import ( + "fmt" + "io" +) + +// ConnState describes the connection state. +// +// See RFC 9051 section 3. +type ConnState int + +const ( + ConnStateNone ConnState = iota + ConnStateNotAuthenticated + ConnStateAuthenticated + ConnStateSelected + ConnStateLogout +) + +// 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" + default: + panic(fmt.Errorf("imap: unknown connection state %v", int(state))) + } +} + +// MailboxAttr is a mailbox attribute. +// +// Mailbox attributes are defined in RFC 9051 section 7.3.1. +type MailboxAttr 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 +) + +// Flag is a message flag. +// +// Message flags are defined in RFC 9051 section 2.3.2. +type Flag string + +const ( + // System flags + FlagSeen Flag = "\\Seen" + FlagAnswered Flag = "\\Answered" + FlagFlagged Flag = "\\Flagged" + FlagDeleted Flag = "\\Deleted" + FlagDraft Flag = "\\Draft" + + // 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 +} + +// UID is a message unique identifier. +type UID uint32 diff --git a/imapclient/acl.go b/imapclient/acl.go new file mode 100644 index 0000000..b20be3b --- /dev/null +++ b/imapclient/acl.go @@ -0,0 +1,138 @@ +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 new file mode 100644 index 0000000..34a62f4 --- /dev/null +++ b/imapclient/acl_test.go @@ -0,0 +1,115 @@ +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 new file mode 100644 index 0000000..5bfff23 --- /dev/null +++ b/imapclient/append.go @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..c5a30dc --- /dev/null +++ b/imapclient/append_test.go @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..e0f67d0 --- /dev/null +++ b/imapclient/authenticate.go @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..223eef5 --- /dev/null +++ b/imapclient/authenticate_test.go @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..a27c77c --- /dev/null +++ b/imapclient/capability.go @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..4933d2f --- /dev/null +++ b/imapclient/client.go @@ -0,0 +1,1243 @@ +// 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 new file mode 100644 index 0000000..9e5c206 --- /dev/null +++ b/imapclient/client_test.go @@ -0,0 +1,277 @@ +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 new file mode 100644 index 0000000..a15e338 --- /dev/null +++ b/imapclient/connection_test.go @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..c1081d8 --- /dev/null +++ b/imapclient/copy.go @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..827ecce --- /dev/null +++ b/imapclient/create.go @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..63969c4 --- /dev/null +++ b/imapclient/create_test.go @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..fdcda74 --- /dev/null +++ b/imapclient/dovecot_test.go @@ -0,0 +1,72 @@ +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 new file mode 100644 index 0000000..8957666 --- /dev/null +++ b/imapclient/enable.go @@ -0,0 +1,69 @@ +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 new file mode 100644 index 0000000..6765d92 --- /dev/null +++ b/imapclient/example_test.go @@ -0,0 +1,411 @@ +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 new file mode 100644 index 0000000..11e477c --- /dev/null +++ b/imapclient/expunge.go @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..d8eb105 --- /dev/null +++ b/imapclient/expunge_test.go @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..309825c --- /dev/null +++ b/imapclient/fetch.go @@ -0,0 +1,1326 @@ +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 new file mode 100644 index 0000000..abd1680 --- /dev/null +++ b/imapclient/fetch_test.go @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..0c10d60 --- /dev/null +++ b/imapclient/id.go @@ -0,0 +1,163 @@ +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 new file mode 100644 index 0000000..1613bff --- /dev/null +++ b/imapclient/idle.go @@ -0,0 +1,157 @@ +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 new file mode 100644 index 0000000..ce8379c --- /dev/null +++ b/imapclient/idle_test.go @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..2c0ce16 --- /dev/null +++ b/imapclient/list.go @@ -0,0 +1,259 @@ +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 new file mode 100644 index 0000000..1eaa4f1 --- /dev/null +++ b/imapclient/list_test.go @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..c8a0e72 --- /dev/null +++ b/imapclient/metadata.go @@ -0,0 +1,205 @@ +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 new file mode 100644 index 0000000..6fa0b62 --- /dev/null +++ b/imapclient/move.go @@ -0,0 +1,74 @@ +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 new file mode 100644 index 0000000..8c4738e --- /dev/null +++ b/imapclient/namespace.go @@ -0,0 +1,110 @@ +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 new file mode 100644 index 0000000..6775b9f --- /dev/null +++ b/imapclient/quota.go @@ -0,0 +1,176 @@ +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 new file mode 100644 index 0000000..17ac116 --- /dev/null +++ b/imapclient/search.go @@ -0,0 +1,401 @@ +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 new file mode 100644 index 0000000..a01f1ff --- /dev/null +++ b/imapclient/search_test.go @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..c325ff0 --- /dev/null +++ b/imapclient/select.go @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..d65645c --- /dev/null +++ b/imapclient/select_test.go @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..260706d --- /dev/null +++ b/imapclient/sort.go @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..8b63cca --- /dev/null +++ b/imapclient/starttls.go @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..9e8a12b --- /dev/null +++ b/imapclient/starttls_test.go @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..973345b --- /dev/null +++ b/imapclient/status.go @@ -0,0 +1,164 @@ +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 new file mode 100644 index 0000000..33966af --- /dev/null +++ b/imapclient/status_test.go @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..a8be6d1 --- /dev/null +++ b/imapclient/store.go @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..5ecbeb9 --- /dev/null +++ b/imapclient/store_test.go @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..c341a18 --- /dev/null +++ b/imapclient/thread.go @@ -0,0 +1,85 @@ +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 new file mode 100644 index 0000000..8ec614e --- /dev/null +++ b/imapserver/append.go @@ -0,0 +1,123 @@ +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 new file mode 100644 index 0000000..14fd460 --- /dev/null +++ b/imapserver/authenticate.go @@ -0,0 +1,148 @@ +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 new file mode 100644 index 0000000..37da104 --- /dev/null +++ b/imapserver/capability.go @@ -0,0 +1,114 @@ +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 new file mode 100644 index 0000000..291f37e --- /dev/null +++ b/imapserver/conn.go @@ -0,0 +1,618 @@ +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 new file mode 100644 index 0000000..5c933a2 --- /dev/null +++ b/imapserver/copy.go @@ -0,0 +1,55 @@ +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 new file mode 100644 index 0000000..f15841d --- /dev/null +++ b/imapserver/create.go @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..051e6c6 --- /dev/null +++ b/imapserver/enable.go @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..3e4d71b --- /dev/null +++ b/imapserver/expunge.go @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..48605c2 --- /dev/null +++ b/imapserver/fetch.go @@ -0,0 +1,715 @@ +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 new file mode 100644 index 0000000..3b67c73 --- /dev/null +++ b/imapserver/idle.go @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..dee9a67 --- /dev/null +++ b/imapserver/imapmemserver/mailbox.go @@ -0,0 +1,511 @@ +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 new file mode 100644 index 0000000..d558045 --- /dev/null +++ b/imapserver/imapmemserver/message.go @@ -0,0 +1,273 @@ +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 new file mode 100644 index 0000000..e31453a --- /dev/null +++ b/imapserver/imapmemserver/server.go @@ -0,0 +1,61 @@ +// 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 new file mode 100644 index 0000000..70e9d2f --- /dev/null +++ b/imapserver/imapmemserver/session.go @@ -0,0 +1,140 @@ +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 new file mode 100644 index 0000000..9af1d7b --- /dev/null +++ b/imapserver/imapmemserver/user.go @@ -0,0 +1,204 @@ +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 new file mode 100644 index 0000000..1aab73d --- /dev/null +++ b/imapserver/list.go @@ -0,0 +1,329 @@ +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 new file mode 100644 index 0000000..bf290f4 --- /dev/null +++ b/imapserver/list_test.go @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..2e6c279 --- /dev/null +++ b/imapserver/login.go @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..9650e08 --- /dev/null +++ b/imapserver/message.go @@ -0,0 +1,336 @@ +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 new file mode 100644 index 0000000..1305a31 --- /dev/null +++ b/imapserver/move.go @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..10e973d --- /dev/null +++ b/imapserver/namespace.go @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..b0f9fb8 --- /dev/null +++ b/imapserver/search.go @@ -0,0 +1,343 @@ +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 new file mode 100644 index 0000000..3535fe7 --- /dev/null +++ b/imapserver/select.go @@ -0,0 +1,174 @@ +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 new file mode 100644 index 0000000..fd6eff1 --- /dev/null +++ b/imapserver/server.go @@ -0,0 +1,222 @@ +// 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) error { + s.mutex.Lock() + ok := !s.closed + if ok { + s.listeners[ln] = 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) +} + +// 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) +} + +// 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 new file mode 100644 index 0000000..35b40e8 --- /dev/null +++ b/imapserver/session.go @@ -0,0 +1,126 @@ +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 new file mode 100644 index 0000000..d5151d7 --- /dev/null +++ b/imapserver/starttls.go @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..b2b5feb --- /dev/null +++ b/imapserver/status.go @@ -0,0 +1,125 @@ +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 new file mode 100644 index 0000000..848fac5 --- /dev/null +++ b/imapserver/store.go @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..b22958c --- /dev/null +++ b/imapserver/tracker.go @@ -0,0 +1,284 @@ +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 new file mode 100644 index 0000000..480bcba --- /dev/null +++ b/imapserver/tracker_test.go @@ -0,0 +1,155 @@ +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 new file mode 100644 index 0000000..43c0787 --- /dev/null +++ b/internal/acl.go @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..25a4f29 --- /dev/null +++ b/internal/imapnum/numset.go @@ -0,0 +1,306 @@ +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/imapnum/numset_test.go b/internal/imapnum/numset_test.go new file mode 100644 index 0000000..440abbb --- /dev/null +++ b/internal/imapnum/numset_test.go @@ -0,0 +1,724 @@ +package imapnum + +import ( + "math/rand" + "strings" + "testing" +) + +const max = ^uint32(0) + +func TestParseNumRange(t *testing.T) { + tests := []struct { + in string + out Range + 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}, + + // 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}, + + // 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}, + + // 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}, + } + for _, test := range tests { + out, err := parseNumRange(test.in) + if !test.ok { + if err == nil { + t.Errorf("parseSeq(%q) expected error; got %q", test.in, out) + } + } else if err != nil { + t.Errorf("parseSeq(%q) expected %q; got %v", test.in, test.out, err) + } else if out != test.out { + t.Errorf("parseSeq(%q) expected %q; got %q", test.in, test.out, out) + } + } +} + +func TestNumRangeContainsLess(t *testing.T) { + tests := []struct { + s string + q uint32 + contains bool + less bool + }{ + {"2", 0, false, true}, + {"2", 1, false, false}, + {"2", 2, true, false}, + {"2", 3, false, true}, + {"2", max, false, true}, + + {"*", 0, true, false}, + {"*", 1, false, false}, + {"*", 2, false, false}, + {"*", 3, false, false}, + {"*", max, false, false}, + + {"2:3", 0, false, true}, + {"2:3", 1, false, false}, + {"2:3", 2, true, false}, + {"2:3", 3, true, false}, + {"2:3", 4, false, true}, + {"2:3", 5, false, true}, + + {"2:4", 0, false, true}, + {"2:4", 1, false, false}, + {"2:4", 2, true, false}, + {"2:4", 3, true, false}, + {"2:4", 4, true, false}, + {"2:4", 5, false, true}, + + {"4:4294967295", 0, false, true}, + {"4:4294967295", 1, false, false}, + {"4:4294967295", 2, false, false}, + {"4:4294967295", 3, false, false}, + {"4:4294967295", 4, true, false}, + {"4:4294967295", 5, true, false}, + {"4:4294967295", max, true, false}, + + {"4:*", 0, true, false}, + {"4:*", 1, false, false}, + {"4:*", 2, false, false}, + {"4:*", 3, false, false}, + {"4:*", 4, true, false}, + {"4:*", 5, true, false}, + {"4:*", max, true, false}, + } + for _, test := range tests { + s, err := parseNumRange(test.s) + if err != nil { + t.Errorf("parseSeq(%q) unexpected error; %v", test.s, err) + continue + } + if s.Contains(test.q) != test.contains { + t.Errorf("%q.Contains(%d) expected %v", test.s, test.q, test.contains) + } + if s.Less(test.q) != test.less { + t.Errorf("%q.Less(%d) expected %v", test.s, test.q, test.less) + } + } +} + +func TestNumRangeMerge(T *testing.T) { + tests := []struct { + s, t, out string + }{ + // Number with number + {"1", "1", "1"}, + {"1", "2", "1:2"}, + {"1", "3", ""}, + {"1", "4294967295", ""}, + {"1", "*", ""}, + + {"4", "1", ""}, + {"4", "2", ""}, + {"4", "3", "3:4"}, + {"4", "4", "4"}, + {"4", "5", "4:5"}, + {"4", "6", ""}, + + {"4294967295", "4294967293", ""}, + {"4294967295", "4294967294", "4294967294:4294967295"}, + {"4294967295", "4294967295", "4294967295"}, + {"4294967295", "*", ""}, + + {"*", "1", ""}, + {"*", "2", ""}, + {"*", "4294967294", ""}, + {"*", "4294967295", ""}, + {"*", "*", "*"}, + + // Range with number + {"1:3", "1", "1:3"}, + {"1:3", "2", "1:3"}, + {"1:3", "3", "1:3"}, + {"1:3", "4", "1:4"}, + {"1:3", "5", ""}, + {"1:3", "*", ""}, + + {"3:4", "1", ""}, + {"3:4", "2", "2:4"}, + {"3:4", "3", "3:4"}, + {"3:4", "4", "3:4"}, + {"3:4", "5", "3:5"}, + {"3:4", "6", ""}, + {"3:4", "*", ""}, + + {"2:3", "5", ""}, + {"2:4", "5", "2:5"}, + {"2:5", "5", "2:5"}, + {"2:6", "5", "2:6"}, + {"2:7", "5", "2:7"}, + {"2:*", "5", "2:*"}, + {"3:4", "5", "3:5"}, + {"3:5", "5", "3:5"}, + {"3:6", "5", "3:6"}, + {"3:7", "5", "3:7"}, + {"3:*", "5", "3:*"}, + {"4:5", "5", "4:5"}, + {"4:6", "5", "4:6"}, + {"4:7", "5", "4:7"}, + {"4:*", "5", "4:*"}, + {"5:6", "5", "5:6"}, + {"5:7", "5", "5:7"}, + {"5:*", "5", "5:*"}, + {"6:7", "5", "5:7"}, + {"6:*", "5", "5:*"}, + {"7:8", "5", ""}, + {"7:*", "5", ""}, + + {"3:4294967294", "1", ""}, + {"3:4294967294", "2", "2:4294967294"}, + {"3:4294967294", "3", "3:4294967294"}, + {"3:4294967294", "4", "3:4294967294"}, + {"3:4294967294", "4294967293", "3:4294967294"}, + {"3:4294967294", "4294967294", "3:4294967294"}, + {"3:4294967294", "4294967295", "3:4294967295"}, + {"3:4294967294", "*", ""}, + + {"3:4294967295", "1", ""}, + {"3:4294967295", "2", "2:4294967295"}, + {"3:4294967295", "3", "3:4294967295"}, + {"3:4294967295", "4", "3:4294967295"}, + {"3:4294967295", "4294967294", "3:4294967295"}, + {"3:4294967295", "4294967295", "3:4294967295"}, + {"3:4294967295", "*", ""}, + + {"1:4294967295", "1", "1:4294967295"}, + {"1:4294967295", "4294967295", "1:4294967295"}, + {"1:4294967295", "*", ""}, + + {"1:*", "1", "1:*"}, + {"1:*", "2", "1:*"}, + {"1:*", "4294967294", "1:*"}, + {"1:*", "4294967295", "1:*"}, + {"1:*", "*", "1:*"}, + + // Range with range + {"5:8", "1:2", ""}, + {"5:8", "1:3", ""}, + {"5:8", "1:4", "1:8"}, + {"5:8", "1:5", "1:8"}, + {"5:8", "1:6", "1:8"}, + {"5:8", "1:7", "1:8"}, + {"5:8", "1:8", "1:8"}, + {"5:8", "1:9", "1:9"}, + {"5:8", "1:10", "1:10"}, + {"5:8", "1:11", "1:11"}, + {"5:8", "1:*", "1:*"}, + + {"5:8", "2:3", ""}, + {"5:8", "2:4", "2:8"}, + {"5:8", "2:5", "2:8"}, + {"5:8", "2:6", "2:8"}, + {"5:8", "2:7", "2:8"}, + {"5:8", "2:8", "2:8"}, + {"5:8", "2:9", "2:9"}, + {"5:8", "2:10", "2:10"}, + {"5:8", "2:11", "2:11"}, + {"5:8", "2:*", "2:*"}, + + {"5:8", "3:4", "3:8"}, + {"5:8", "3:5", "3:8"}, + {"5:8", "3:6", "3:8"}, + {"5:8", "3:7", "3:8"}, + {"5:8", "3:8", "3:8"}, + {"5:8", "3:9", "3:9"}, + {"5:8", "3:10", "3:10"}, + {"5:8", "3:11", "3:11"}, + {"5:8", "3:*", "3:*"}, + + {"5:8", "4:5", "4:8"}, + {"5:8", "4:6", "4:8"}, + {"5:8", "4:7", "4:8"}, + {"5:8", "4:8", "4:8"}, + {"5:8", "4:9", "4:9"}, + {"5:8", "4:10", "4:10"}, + {"5:8", "4:11", "4:11"}, + {"5:8", "4:*", "4:*"}, + + {"5:8", "5:6", "5:8"}, + {"5:8", "5:7", "5:8"}, + {"5:8", "5:8", "5:8"}, + {"5:8", "5:9", "5:9"}, + {"5:8", "5:10", "5:10"}, + {"5:8", "5:11", "5:11"}, + {"5:8", "5:*", "5:*"}, + + {"5:8", "6:7", "5:8"}, + {"5:8", "6:8", "5:8"}, + {"5:8", "6:9", "5:9"}, + {"5:8", "6:10", "5:10"}, + {"5:8", "6:11", "5:11"}, + {"5:8", "6:*", "5:*"}, + + {"5:8", "7:8", "5:8"}, + {"5:8", "7:9", "5:9"}, + {"5:8", "7:10", "5:10"}, + {"5:8", "7:11", "5:11"}, + {"5:8", "7:*", "5:*"}, + + {"5:8", "8:9", "5:9"}, + {"5:8", "8:10", "5:10"}, + {"5:8", "8:11", "5:11"}, + {"5:8", "8:*", "5:*"}, + + {"5:8", "9:10", "5:10"}, + {"5:8", "9:11", "5:11"}, + {"5:8", "9:*", "5:*"}, + + {"5:8", "10:11", ""}, + {"5:8", "10:*", ""}, + + {"1:*", "1:*", "1:*"}, + {"1:*", "2:*", "1:*"}, + {"1:*", "1:4294967294", "1:*"}, + {"1:*", "1:4294967295", "1:*"}, + {"1:*", "2:4294967295", "1:*"}, + + {"1:4294967295", "1:4294967294", "1:4294967295"}, + {"1:4294967295", "1:4294967295", "1:4294967295"}, + {"1:4294967295", "2:4294967295", "1:4294967295"}, + {"1:4294967295", "2:*", "1:*"}, + } + for _, test := range tests { + s, err := parseNumRange(test.s) + if err != nil { + T.Errorf("parseSeq(%q) unexpected error; %v", test.s, err) + continue + } + t, err := parseNumRange(test.t) + if err != nil { + T.Errorf("parseSeq(%q) unexpected error; %v", test.t, err) + continue + } + testOK := test.out != "" + for i := 0; i < 2; i++ { + if !testOK { + test.out = test.s + } + out, ok := s.Merge(t) + if out.String() != test.out || ok != testOK { + T.Errorf("%q.Merge(%q) expected %q; got %q", test.s, test.t, test.out, out) + } + // Swap s & t, result should be identical + test.s, test.t = test.t, test.s + s, t = t, s + } + } +} + +func checkNumSet(s Set, t *testing.T) { + n := len(s) + for i, v := range s { + if v.Start == 0 { + if v.Stop != 0 { + t.Errorf(`NumSet(%q) index %d: "*:n" range`, s, i) + } else if i != n-1 { + t.Errorf(`NumSet(%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 v.Stop < v.Start { + if v.Stop != 0 { + t.Errorf(`NumSet(%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) + } + } + } +} + +func TestNumSetInfo(t *testing.T) { + tests := []struct { + s string + q uint32 + contains bool + }{ + {"", 0, false}, + {"", 1, false}, + {"", 2, false}, + {"", 3, false}, + {"", max, false}, + + {"2", 0, false}, + {"2", 1, false}, + {"2", 2, true}, + {"2", 3, false}, + {"2", max, false}, + + {"*", 0, false}, // Contains("*") is always false, use Dynamic() instead + {"*", 1, false}, + {"*", 2, false}, + {"*", 3, false}, + {"*", max, false}, + + {"1:*", 0, false}, + {"1:*", 1, true}, + {"1:*", max, true}, + + {"2:4", 0, false}, + {"2:4", 1, false}, + {"2:4", 2, true}, + {"2:4", 3, true}, + {"2:4", 4, true}, + {"2:4", 5, false}, + {"2:4", max, false}, + + {"2,4", 0, false}, + {"2,4", 1, false}, + {"2,4", 2, true}, + {"2,4", 3, false}, + {"2,4", 4, true}, + {"2,4", 5, false}, + {"2,4", max, false}, + + {"2:4,6", 0, false}, + {"2:4,6", 1, false}, + {"2:4,6", 2, true}, + {"2:4,6", 3, true}, + {"2:4,6", 4, true}, + {"2:4,6", 5, false}, + {"2:4,6", 6, true}, + {"2:4,6", 7, false}, + + {"2,4:6", 0, false}, + {"2,4:6", 1, false}, + {"2,4:6", 2, true}, + {"2,4:6", 3, false}, + {"2,4:6", 4, true}, + {"2,4:6", 5, true}, + {"2,4:6", 6, true}, + {"2,4:6", 7, false}, + + {"2,4,6", 0, false}, + {"2,4,6", 1, false}, + {"2,4,6", 2, true}, + {"2,4,6", 3, false}, + {"2,4,6", 4, true}, + {"2,4,6", 5, false}, + {"2,4,6", 6, true}, + {"2,4,6", 7, false}, + + {"1,3:5,7,9:*", 0, false}, + {"1,3:5,7,9:*", 1, true}, + {"1,3:5,7,9:*", 2, false}, + {"1,3:5,7,9:*", 3, true}, + {"1,3:5,7,9:*", 4, true}, + {"1,3:5,7,9:*", 5, true}, + {"1,3:5,7,9:*", 6, false}, + {"1,3:5,7,9:*", 7, true}, + {"1,3:5,7,9:*", 8, false}, + {"1,3:5,7,9:*", 9, true}, + {"1,3:5,7,9:*", 10, true}, + {"1,3:5,7,9:*", max, true}, + + {"1,3:5,7,9,42", 0, false}, + {"1,3:5,7,9,42", 1, true}, + {"1,3:5,7,9,42", 2, false}, + {"1,3:5,7,9,42", 3, true}, + {"1,3:5,7,9,42", 4, true}, + {"1,3:5,7,9,42", 5, true}, + {"1,3:5,7,9,42", 6, false}, + {"1,3:5,7,9,42", 7, true}, + {"1,3:5,7,9,42", 8, false}, + {"1,3:5,7,9,42", 9, true}, + {"1,3:5,7,9,42", 10, false}, + {"1,3:5,7,9,42", 41, false}, + {"1,3:5,7,9,42", 42, true}, + {"1,3:5,7,9,42", 43, false}, + {"1,3:5,7,9,42", max, false}, + + {"1,3:5,7,9,42,*", 0, false}, + {"1,3:5,7,9,42,*", 1, true}, + {"1,3:5,7,9,42,*", 2, false}, + {"1,3:5,7,9,42,*", 3, true}, + {"1,3:5,7,9,42,*", 4, true}, + {"1,3:5,7,9,42,*", 5, true}, + {"1,3:5,7,9,42,*", 6, false}, + {"1,3:5,7,9,42,*", 7, true}, + {"1,3:5,7,9,42,*", 8, false}, + {"1,3:5,7,9,42,*", 9, true}, + {"1,3:5,7,9,42,*", 10, false}, + {"1,3:5,7,9,42,*", 41, false}, + {"1,3:5,7,9,42,*", 42, true}, + {"1,3:5,7,9,42,*", 43, false}, + {"1,3:5,7,9,42,*", max, false}, + + {"1,3:5,7,9,42,60:70,100:*", 0, false}, + {"1,3:5,7,9,42,60:70,100:*", 1, true}, + {"1,3:5,7,9,42,60:70,100:*", 2, false}, + {"1,3:5,7,9,42,60:70,100:*", 3, true}, + {"1,3:5,7,9,42,60:70,100:*", 4, true}, + {"1,3:5,7,9,42,60:70,100:*", 5, true}, + {"1,3:5,7,9,42,60:70,100:*", 6, false}, + {"1,3:5,7,9,42,60:70,100:*", 7, true}, + {"1,3:5,7,9,42,60:70,100:*", 8, false}, + {"1,3:5,7,9,42,60:70,100:*", 9, true}, + {"1,3:5,7,9,42,60:70,100:*", 10, false}, + {"1,3:5,7,9,42,60:70,100:*", 41, false}, + {"1,3:5,7,9,42,60:70,100:*", 42, true}, + {"1,3:5,7,9,42,60:70,100:*", 43, false}, + {"1,3:5,7,9,42,60:70,100:*", 59, false}, + {"1,3:5,7,9,42,60:70,100:*", 60, true}, + {"1,3:5,7,9,42,60:70,100:*", 65, true}, + {"1,3:5,7,9,42,60:70,100:*", 70, true}, + {"1,3:5,7,9,42,60:70,100:*", 71, false}, + {"1,3:5,7,9,42,60:70,100:*", 99, false}, + {"1,3:5,7,9,42,60:70,100:*", 100, true}, + {"1,3:5,7,9,42,60:70,100:*", 1000, true}, + {"1,3:5,7,9,42,60:70,100:*", max, true}, + } + for _, test := range tests { + s, _ := ParseSet(test.s) + checkNumSet(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) + } + testDynamic := !testEmpty && test.s[len(test.s)-1] == '*' + if s.Dynamic() != testDynamic { + t.Errorf("%q.Dynamic() expected %v", test.s, testDynamic) + } + } +} + +func TestParseNumSet(t *testing.T) { + tests := []struct { + in string + out string + }{ + {"1,1", "1"}, + {"1,2", "1:2"}, + {"1,3", "1,3"}, + {"1,*", "1,*"}, + + {"1,1,1", "1"}, + {"1,1,2", "1:2"}, + {"1,1:2", "1:2"}, + {"1,1,3", "1,3"}, + {"1,1:3", "1:3"}, + {"1,2,2", "1:2"}, + {"1,2,3", "1:3"}, + {"1,2:3", "1:3"}, + {"1,2,4", "1:2,4"}, + {"1,3,3", "1,3"}, + {"1,3,4", "1,3:4"}, + {"1,3:4", "1,3:4"}, + {"1,3,5", "1,3,5"}, + {"1,3:5", "1,3:5"}, + {"1:3,5", "1:3,5"}, + {"1:5,3", "1:5"}, + + {"1,2,3,4", "1:4"}, + {"1,2,4,5", "1:2,4:5"}, + {"1,2,4:5", "1:2,4:5"}, + {"1:2,4:5", "1:2,4:5"}, + + {"1,2,3,4,5", "1:5"}, + {"1,2:3,4:5", "1:5"}, + + {"1,2,4,5,7,9", "1:2,4:5,7,9"}, + {"1,2,4,5,7:9", "1:2,4:5,7:9"}, + {"1:2,4:5,7:9", "1:2,4:5,7:9"}, + {"1,2,4,5,7,8,9", "1:2,4:5,7:9"}, + {"1:2,4:5,7,8,9", "1:2,4:5,7:9"}, + + {"3,5:10,15:20", "3,5:10,15:20"}, + {"4,5:10,15:20", "4:10,15:20"}, + {"5,5:10,15:20", "5:10,15:20"}, + {"7,5:10,15:20", "5:10,15:20"}, + {"10,5:10,15:20", "5:10,15:20"}, + {"11,5:10,15:20", "5:11,15:20"}, + {"12,5:10,15:20", "5:10,12,15:20"}, + {"14,5:10,15:20", "5:10,14:20"}, + {"17,5:10,15:20", "5:10,15:20"}, + {"21,5:10,15:20", "5:10,15:21"}, + {"22,5:10,15:20", "5:10,15:20,22"}, + {"*,5:10,15:20", "5:10,15:20,*"}, + + {"1:3,5:10,15:20", "1:3,5:10,15:20"}, + {"1:4,5:10,15:20", "1:10,15:20"}, + {"1:8,5:10,15:20", "1:10,15:20"}, + {"1:13,5:10,15:20", "1:13,15:20"}, + {"1:14,5:10,15:20", "1:20"}, + {"7:17,5:10,15:20", "5:20"}, + {"11:14,5:10,15:20", "5:20"}, + {"12,13,5:10,15:20", "5:10,12:13,15:20"}, + {"12:13,5:10,15:20", "5:10,12:13,15:20"}, + {"12:14,5:10,15:20", "5:10,12:20"}, + {"11:13,5:10,15:20", "5:13,15:20"}, + {"11,12,13,14,5:10,15:20", "5:20"}, + + {"1:*,5:10,15:20", "1:*"}, + {"4:*,5:10,15:20", "4:*"}, + {"6:*,5:10,15:20", "5:*"}, + {"12:*,5:10,15:20", "5:10,12:*"}, + {"19:*,5:10,15:20", "5:10,15:*"}, + + {"5:8,6,7:10,15,16,17,18:20,19,21:*", "5:10,15:*"}, + + {"4:13,1,5,10,15,20", "1,4:13,15,20"}, + {"4:14,1,5,10,15,20", "1,4:15,20"}, + {"4:15,1,5,10,15,20", "1,4:15,20"}, + {"4:16,1,5,10,15,20", "1,4:16,20"}, + {"4:17,1,5,10,15,20", "1,4:17,20"}, + {"4:18,1,5,10,15,20", "1,4:18,20"}, + {"4:19,1,5,10,15,20", "1,4:20"}, + {"4:20,1,5,10,15,20", "1,4:20"}, + {"4:21,1,5,10,15,20", "1,4:21"}, + {"4:*,1,5,10,15,20", "1,4:*"}, + + {"1,3,5,7,9,11,13,15,17,19", "1,3,5,7,9,11,13,15,17,19"}, + {"1,3,5,7,9,11:13,15,17,19", "1,3,5,7,9,11:13,15,17,19"}, + {"1,3,5,7,9:11,13:15,17,19", "1,3,5,7,9:11,13:15,17,19"}, + {"1,3,5,7:9,11:13,15:17,19", "1,3,5,7:9,11:13,15:17,19"}, + {"1,3,5,7,9,11,13,15,17,19,*", "1,3,5,7,9,11,13,15,17,19,*"}, + {"1,3,5,7,9,11,13,15,17,19:*", "1,3,5,7,9,11,13,15,17,19:*"}, + {"1:20,3,5,7,9,11,13,15,17,19,*", "1:20,*"}, + {"1:20,3,5,7,9,11,13,15,17,19:*", "1:*"}, + + {"4294967295,*", "4294967295,*"}, + {"1,4294967295,*", "1,4294967295,*"}, + {"1:4294967295,*", "1:4294967295,*"}, + {"1,4294967295:*", "1,4294967295:*"}, + {"1:*,4294967295", "1:*"}, + {"1:*,4294967295:*", "1:*"}, + {"1:4294967295,4294967295:*", "1:*"}, + } + prng := rand.New(rand.NewSource(19860201)) + done := make(map[string]bool) + permute := func(in string) string { + v := strings.Split(in, ",") + r := make([]string, len(v)) + + // Try to find a permutation that hasn't been checked already + for i := 0; i < 50; i++ { + for i, j := range prng.Perm(len(v)) { + r[i] = v[j] + } + if s := strings.Join(r, ","); !done[s] { + done[s] = true + return s + } + } + return "" + } + for _, test := range tests { + for i := 0; i < 100 && test.in != ""; i++ { + s, err := ParseSet(test.in) + if err != nil { + t.Errorf("Add(%q) unexpected error; %v", test.in, err) + i = 100 + } + checkNumSet(s, t) + if out := s.String(); out != test.out { + t.Errorf("%q.String() expected %q; got %q", test.in, test.out, out) + i = 100 + } + test.in = permute(test.in) + } + } +} + +func TestNumSetAddNumRangeSet(t *testing.T) { + type num []uint32 + tests := []struct { + num num + rng Range + 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{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{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:*"}, + } + for _, test := range tests { + other, _ := ParseSet(test.set) + + var s Set + s.AddNum(test.num...) + checkNumSet(s, t) + s.AddRange(test.rng.Start, test.rng.Stop) + checkNumSet(s, t) + s.AddSet(other) + checkNumSet(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/internal/imapwire/decoder.go b/internal/imapwire/decoder.go new file mode 100644 index 0000000..cfd2995 --- /dev/null +++ b/internal/imapwire/decoder.go @@ -0,0 +1,654 @@ +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 new file mode 100644 index 0000000..b27589a --- /dev/null +++ b/internal/imapwire/encoder.go @@ -0,0 +1,341 @@ +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 new file mode 100644 index 0000000..716d1c2 --- /dev/null +++ b/internal/imapwire/imapwire.go @@ -0,0 +1,47 @@ +// 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 new file mode 100644 index 0000000..270afe1 --- /dev/null +++ b/internal/imapwire/num.go @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..f07e86d --- /dev/null +++ b/internal/internal.go @@ -0,0 +1,188 @@ +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 new file mode 100644 index 0000000..85d9f3d --- /dev/null +++ b/internal/sasl.go @@ -0,0 +1,23 @@ +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/utf7/decoder.go b/internal/utf7/decoder.go new file mode 100644 index 0000000..b8e906e --- /dev/null +++ b/internal/utf7/decoder.go @@ -0,0 +1,118 @@ +package utf7 + +import ( + "errors" + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// ErrInvalidUTF7 means that a decoder 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") + } + + var sb strings.Builder + sb.Grow(len(src)) + + ascii := true + 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 != '&' { + sb.WriteByte(ch) + ascii = true + continue + } + + // Find the end of the Base64 or "&-" segment + 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 + } + } + + if i == len(src) { // Implicit shift ("&...") + return "", ErrInvalidUTF7 + } + + if i == start { // Escape sequence "&-" + sb.WriteByte('&') + ascii = true + } else { // Control or non-ASCII code points in base64 + if !ascii { // Null shift ("&...-&...-") + return "", ErrInvalidUTF7 + } + + b := decode([]byte(src[start:i])) + if len(b) == 0 { // Bad encoding + return "", ErrInvalidUTF7 + } + sb.Write(b) + + ascii = false + } + } + + return sb.String(), nil +} + +// Extracts UTF-16-BE bytes from base64 data and converts them to UTF-8. +// A nil slice is returned if the encoding is invalid. +func decode(b64 []byte) []byte { + var b []byte + + // Allocate a single block of memory large enough to store the Base64 data + // (if padding is required), UTF-16-BE bytes, and decoded UTF-8 bytes. + // Since a 2-byte UTF-16 sequence may expand into a 3-byte UTF-8 sequence, + // double the space allocation for UTF-8. + if n := len(b64); b64[n-1] == '=' { + return nil + } else if n&3 == 0 { + b = make([]byte, b64Enc.DecodedLen(n)*3) + } else { + n += 4 - n&3 + b = make([]byte, n+b64Enc.DecodedLen(n)*3) + copy(b[copy(b, b64):n], []byte("==")) + b64, b = b[:n], b[n:] + } + + // Decode Base64 into the first 1/3rd of b + n, err := b64Enc.Decode(b, b64) + if err != nil || n&1 == 1 { + return nil + } + + // Decode UTF-16-BE into the remaining 2/3rds of b + b, s := b[:n], b[n:] + j := 0 + for i := 0; i < n; i += 2 { + r := rune(b[i])<<8 | rune(b[i+1]) + if utf16.IsSurrogate(r) { + if i += 2; i == n { + return nil + } + r2 := rune(b[i])<<8 | rune(b[i+1]) + if r = utf16.DecodeRune(r, r2); r == utf8.RuneError { + return nil + } + } else if min <= r && r <= max { + return nil + } + j += utf8.EncodeRune(s[j:], r) + } + return s[:j] +} diff --git a/internal/utf7/decoder_test.go b/internal/utf7/decoder_test.go new file mode 100644 index 0000000..8584d96 --- /dev/null +++ b/internal/utf7/decoder_test.go @@ -0,0 +1,115 @@ +package utf7_test + +import ( + "strings" + "testing" + + "github.com/emersion/go-imap/v2/internal/utf7" +) + +var decode = []struct { + in string + out string + ok bool +}{ + // Basics (the inverse test on encode checks other valid inputs) + {"", "", true}, + {"abc", "abc", true}, + {"&-abc", "&abc", true}, + {"abc&-", "abc&", true}, + {"a&-b&-c", "a&b&c", true}, + {"&ABk-", "\x19", true}, + {"&AB8-", "\x1F", true}, + {"ABk-", "ABk-", true}, + {"&-,&-&AP8-&-", "&,&\u00FF&", true}, + {"&-&-,&AP8-&-", "&&,\u00FF&", true}, + {"abc &- &AP8A,wD,- &- xyz", "abc & \u00FF\u00FF\u00FF & xyz", true}, + + // Illegal code point in ASCII + {"\x00", "", false}, + {"\x1F", "", false}, + {"abc\n", "", false}, + {"abc\x7Fxyz", "", false}, + + // Invalid UTF-8 + {"\xc3\x28", "", false}, + {"\xe2\x82\x28", "", false}, + + // Invalid Base64 alphabet + {"&/+8-", "", false}, + {"&*-", "", false}, + {"&ZeVnLIqe -", "", false}, + + // CR and LF in Base64 + {"&ZeVnLIqe\r\n-", "", false}, + {"&ZeVnLIqe\r\n\r\n-", "", false}, + {"&ZeVn\r\n\r\nLIqe-", "", false}, + + // Padding not stripped + {"&AAAAHw=-", "", false}, + {"&AAAAHw==-", "", false}, + {"&AAAAHwB,AIA=-", "", false}, + {"&AAAAHwB,AIA==-", "", false}, + + // One byte short + {"&2A-", "", false}, + {"&2ADc-", "", false}, + {"&AAAAHwB,A-", "", false}, + {"&AAAAHwB,A=-", "", false}, + {"&AAAAHwB,A==-", "", false}, + {"&AAAAHwB,A===-", "", false}, + {"&AAAAHwB,AI-", "", false}, + {"&AAAAHwB,AI=-", "", false}, + {"&AAAAHwB,AI==-", "", false}, + + // Implicit shift + {"&", "", false}, + {"&Jjo", "", false}, + {"Jjo&", "", false}, + {"&Jjo&", "", false}, + {"&Jjo!", "", false}, + {"&Jjo+", "", false}, + {"abc&Jjo", "", false}, + + // Null shift + {"&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}, // "&" + {"&AGgAZQBsAGwAbw-", "", false}, // "hello" + {"&JjoAIQ-", "", false}, // "\u263a!" + + // Bad surrogate + {"&2AA-", "", false}, // U+D800 + {"&2AD-", "", false}, // U+D800 + {"&3AA-", "", false}, // U+DC00 + {"&2AAAQQ-", "", false}, // U+D800 'A' + {"&2AD,,w-", "", false}, // U+D800 U+FFFF + {"&3ADYAA-", "", false}, // U+DC00 U+D800 +} + +func TestDecoder(t *testing.T) { + for _, test := range decode { + out, err := utf7.Decode(test.in) + if out != test.out { + t.Errorf("UTF7Decode(%+q) expected %+q; got %+q", test.in, test.out, out) + } + if test.ok { + if err != nil { + t.Errorf("UTF7Decode(%+q) unexpected error; %v", test.in, err) + } + } else if err == nil { + t.Errorf("UTF7Decode(%+q) expected error", test.in) + } + } +} diff --git a/internal/utf7/encoder.go b/internal/utf7/encoder.go new file mode 100644 index 0000000..e7107c3 --- /dev/null +++ b/internal/utf7/encoder.go @@ -0,0 +1,88 @@ +package utf7 + +import ( + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// Encode encodes a string with modified UTF-7. +func Encode(src string) string { + var sb strings.Builder + sb.Grow(len(src)) + + for i := 0; i < len(src); { + ch := src[i] + + if min <= ch && ch <= max { + sb.WriteByte(ch) + if ch == '&' { + sb.WriteByte('-') + } + + i++ + } else { + start := i + + // Find the next printable ASCII code point + i++ + for i < len(src) && (src[i] < min || src[i] > max) { + i++ + } + + sb.Write(encode([]byte(src[start:i]))) + } + } + + return sb.String() +} + +// 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 { + // len(s) is sufficient for UTF-8 to UTF-16 conversion if there are no + // control code points (see table below). + b := make([]byte, 0, len(s)+4) + for len(s) > 0 { + r, size := utf8.DecodeRune(s) + if r > utf8.MaxRune { + r, size = utf8.RuneError, 1 // Bug fix (issue 3785) + } + s = s[size:] + if r1, r2 := utf16.EncodeRune(r); r1 != utf8.RuneError { + b = append(b, byte(r1>>8), byte(r1)) + r = r2 + } + b = append(b, byte(r>>8), byte(r)) + } + + // Encode as base64 + n := b64Enc.EncodedLen(len(b)) + 2 + b64 := make([]byte, n) + b64Enc.Encode(b64[1:], b) + + // Strip padding + n -= 2 - (len(b)+2)%3 + b64 = b64[:n] + + // Add UTF-7 shifts + b64[0] = '&' + 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/internal/utf7/encoder_test.go new file mode 100644 index 0000000..afa81e3 --- /dev/null +++ b/internal/utf7/encoder_test.go @@ -0,0 +1,124 @@ +package utf7_test + +import ( + "testing" + + "github.com/emersion/go-imap/v2/internal/utf7" +) + +var encode = []struct { + in string + out string + ok bool +}{ + // Printable ASCII + {"", "", true}, + {"a", "a", true}, + {"ab", "ab", true}, + {"-", "-", true}, + {"&", "&-", true}, + {"&&", "&-&-", true}, + {"&&&-&", "&-&-&--&-", true}, + {"-&*&-", "-&-*&--", true}, + {"a&b", "a&-b", true}, + {"a&", "a&-", true}, + {"&b", "&-b", true}, + {"-a&", "-a&-", true}, + {"&b-", "&-b-", true}, + + // Unicode range + {"\u0000", "&AAA-", true}, + {"\n", "&AAo-", true}, + {"\r", "&AA0-", true}, + {"\u001F", "&AB8-", true}, + {"\u0020", " ", true}, + {"\u0025", "%", true}, + {"\u0026", "&-", true}, + {"\u0027", "'", true}, + {"\u007E", "~", true}, + {"\u007F", "&AH8-", true}, + {"\u0080", "&AIA-", true}, + {"\u00FF", "&AP8-", true}, + {"\u07FF", "&B,8-", true}, + {"\u0800", "&CAA-", true}, + {"\uFFEF", "&,+8-", true}, + {"\uFFFF", "&,,8-", true}, + {"\U00010000", "&2ADcAA-", true}, + {"\U0010FFFF", "&2,,f,w-", true}, + + // Padding + {"\x00\x1F", "&AAAAHw-", true}, // 2 + {"\x00\x1F\x7F", "&AAAAHwB,-", true}, // 0 + {"\x00\x1F\x7F\u0080", "&AAAAHwB,AIA-", true}, // 1 + {"\x00\x1F\x7F\u0080\u00FF", "&AAAAHwB,AIAA,w-", true}, // 2 + + // Mix + {"a\x00", "a&AAA-", true}, + {"\x00a", "&AAA-a", true}, + {"&\x00", "&-&AAA-", true}, + {"\x00&", "&AAA-&-", true}, + {"a\x00&", "a&AAA-&-", true}, + {"a&\x00", "a&-&AAA-", true}, + {"&a\x00", "&-a&AAA-", true}, + {"&\x00a", "&-&AAA-a", true}, + {"\x00&a", "&AAA-&-a", true}, + {"\x00a&", "&AAA-a&-", true}, + {"ab&\uFFFF", "ab&-&,,8-", true}, + {"a&b\uFFFF", "a&-b&,,8-", true}, + {"&ab\uFFFF", "&-ab&,,8-", true}, + {"ab\uFFFF&", "ab&,,8-&-", true}, + {"a\uFFFFb&", "a&,,8-b&-", true}, + {"\uFFFFab&", "&,,8-ab&-", true}, + + {"\x20\x25&\x27\x7E", " %&-'~", true}, + {"\x1F\x20&\x7E\x7F", "&AB8- &-~&AH8-", true}, + {"&\x00\x19\x7F\u0080", "&-&AAAAGQB,AIA-", true}, + {"\x00&\x19\x7F\u0080", "&AAA-&-&ABkAfwCA-", true}, + {"\x00\x19&\x7F\u0080", "&AAAAGQ-&-&AH8AgA-", true}, + {"\x00\x19\x7F&\u0080", "&AAAAGQB,-&-&AIA-", true}, + {"\x00\x19\x7F\u0080&", "&AAAAGQB,AIA-&-", true}, + {"&\x00\x1F\x7F\u0080", "&-&AAAAHwB,AIA-", true}, + {"\x00&\x1F\x7F\u0080", "&AAA-&-&AB8AfwCA-", true}, + {"\x00\x1F&\x7F\u0080", "&AAAAHw-&-&AH8AgA-", true}, + {"\x00\x1F\x7F&\u0080", "&AAAAHwB,-&-&AIA-", true}, + {"\x00\x1F\x7F\u0080&", "&AAAAHwB,AIA-&-", true}, + + // Russian + {"\u041C\u0430\u043A\u0441\u0438\u043C \u0425\u0438\u0442\u0440\u043E\u0432", + "&BBwEMAQ6BEEEOAQ8- &BCUEOARCBEAEPgQy-", true}, + + // RFC 3501 + {"~peter/mail/\u53F0\u5317/\u65E5\u672C\u8A9E", "~peter/mail/&U,BTFw-/&ZeVnLIqe-", true}, + {"~peter/mail/\u53F0\u5317/\u65E5\u672C\u8A9E", "~peter/mail/&U,BTFw-/&ZeVnLIqe-", true}, + {"\u263A!", "&Jjo-!", true}, + {"\u53F0\u5317\u65E5\u672C\u8A9E", "&U,BTF2XlZyyKng-", true}, + + // RFC 2152 (modified) + {"\u0041\u2262\u0391\u002E", "A&ImIDkQ-.", true}, + {"Hi Mom -\u263A-!", "Hi Mom -&Jjo--!", true}, + {"\u65E5\u672C\u8A9E", "&ZeVnLIqe-", true}, + + // 8->16 and 24->16 byte UTF-8 to UTF-16 conversion + {"\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007", "&AAAAAQACAAMABAAFAAYABw-", true}, + {"\u0800\u0801\u0802\u0803\u0804\u0805\u0806\u0807", "&CAAIAQgCCAMIBAgFCAYIBw-", true}, + + // Invalid UTF-8 (bad bytes are converted to U+FFFD) + {"\xC0\x80", "&,,3,,Q-", false}, // U+0000 + {"\xF4\x90\x80\x80", "&,,3,,f,9,,0-", false}, // U+110000 + {"\xF7\xBF\xBF\xBF", "&,,3,,f,9,,0-", false}, // U+1FFFFF + {"\xF8\x88\x80\x80\x80", "&,,3,,f,9,,3,,Q-", false}, // U+200000 + {"\xF4\x8F\xBF\x3F", "&,,3,,f,9-?", false}, // U+10FFFF (bad byte) + {"\xF4\x8F\xBF", "&,,3,,f,9-", false}, // U+10FFFF (short) + {"\xF4\x8F", "&,,3,,Q-", false}, + {"\xF4", "&,,0-", false}, + {"\x00\xF4\x00", "&AAD,,QAA-", false}, +} + +func TestEncoder(t *testing.T) { + for _, test := range encode { + out := utf7.Encode(test.in) + if out != test.out { + t.Errorf("UTF7Encode(%+q) expected %+q; got %+q", test.in, test.out, out) + } + } +} diff --git a/internal/utf7/utf7.go b/internal/utf7/utf7.go new file mode 100644 index 0000000..3ff09a9 --- /dev/null +++ b/internal/utf7/utf7.go @@ -0,0 +1,13 @@ +// 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 new file mode 100644 index 0000000..a3103a6 --- /dev/null +++ b/list.go @@ -0,0 +1,30 @@ +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/namespace.go b/namespace.go new file mode 100644 index 0000000..e538a39 --- /dev/null +++ b/namespace.go @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..a96b181 --- /dev/null +++ b/numset.go @@ -0,0 +1,149 @@ +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 new file mode 100644 index 0000000..f128fe4 --- /dev/null +++ b/quota.go @@ -0,0 +1,13 @@ +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/rename.go b/rename.go new file mode 100644 index 0000000..a87f0e2 --- /dev/null +++ b/rename.go @@ -0,0 +1,4 @@ +package imap + +// RenameOptions contains options for the RENAME command. +type RenameOptions struct{} diff --git a/response.go b/response.go new file mode 100644 index 0000000..0ce54cf --- /dev/null +++ b/response.go @@ -0,0 +1,81 @@ +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 +} + +// 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) + } + text := err.Text + if text == "" { + text = "" + } + fmt.Fprintf(&sb, " %v", text) + return sb.String() +} diff --git a/search.go b/search.go new file mode 100644 index 0000000..a94f52f --- /dev/null +++ b/search.go @@ -0,0 +1,201 @@ +package imap + +import ( + "reflect" + "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 +} + +// 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"}}, +// }}} +type SearchCriteria struct { + SeqNum []SeqSet + UID []UIDSet + + // Only the date is used, the time and timezone are ignored + Since time.Time + Before time.Time + SentSince time.Time + SentBefore time.Time + + Header []SearchCriteriaHeaderField + Body []string + Text []string + + Flag []Flag + NotFlag []Flag + + Larger int64 + Smaller int64 + + Not []SearchCriteria + Or [][2]SearchCriteria + + ModSeq *SearchCriteriaModSeq // requires CONDSTORE +} + +// 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...) + + 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 + } + + criteria.Not = append(criteria.Not, other.Not...) + criteria.Or = append(criteria.Or, other.Or...) +} + +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 + } +} + +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) + if !ok { + return nil + } + + // 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 +} + +// AllUIDs returns All as a slice of UIDs. +func (data *SearchData) AllUIDs() []UID { + uidSet, ok := data.All.(UIDSet) + if !ok { + return nil + } + + // 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") + } + 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() +) + +// 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 +} + +// 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 +} diff --git a/select.go b/select.go new file mode 100644 index 0000000..f307ff3 --- /dev/null +++ b/select.go @@ -0,0 +1,31 @@ +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/status.go b/status.go new file mode 100644 index 0000000..f399456 --- /dev/null +++ b/status.go @@ -0,0 +1,35 @@ +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 + + AppendLimit bool // requires APPENDLIMIT + DeletedStorage bool // requires QUOTA=RES-STORAGE + HighestModSeq bool // requires CONDSTORE +} + +// 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 + + NumMessages *uint32 + NumRecent *uint32 // Obsolete, IMAP4rev1 only. Server-only, not supported in imapclient. + UIDNext UID + UIDValidity uint32 + NumUnseen *uint32 + NumDeleted *uint32 + Size *int64 + + AppendLimit *uint32 + DeletedStorage *int64 + HighestModSeq uint64 +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..c1ea26f --- /dev/null +++ b/store.go @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..e4e3122 --- /dev/null +++ b/thread.go @@ -0,0 +1,9 @@ +package imap + +// ThreadAlgorithm is a threading algorithm. +type ThreadAlgorithm string + +const ( + ThreadOrderedSubject ThreadAlgorithm = "ORDEREDSUBJECT" + ThreadReferences ThreadAlgorithm = "REFERENCES" +)