commit bcc3f95e8efe60c2bdd00bb00f3a662deb7bb6d2 Author: Anastasios Svolis Date: Thu May 1 11:58:18 2025 +0300 Forked the emersion/go-imap v1 project. 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..f123a1b --- /dev/null +++ b/capability.go @@ -0,0 +1,213 @@ +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 + + CapAuthPlain Cap = "AUTH=PLAIN" + + 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 + + CapACL Cap = "ACL" // RFC 4314 + CapAppendLimit Cap = "APPENDLIMIT" // RFC 7889 + CapBinary Cap = "BINARY" // RFC 3516 + CapCatenate Cap = "CATENATE" // RFC 4469 + CapChildren Cap = "CHILDREN" // RFC 3348 + 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: {}, +} + +// 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..91423a6 --- /dev/null +++ b/cmd/imapmemserver/main.go @@ -0,0 +1,81 @@ +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) + user.Create("INBOX", nil) + 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..4700a30 --- /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.1 + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a05948 --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= +github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +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..02c071f --- /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.Errorf("SetACL().Wait() error: %v", err) + } + + // execute GETACL command to reset cache on server + getACLData, err := client.GetACL(tc.mailbox).Wait() + if err != nil { + t.Errorf("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..5e028f1 --- /dev/null +++ b/imapclient/capability.go @@ -0,0 +1,55 @@ +package imapclient + +import ( + "fmt" + + "github.com/emersion/go-imap/v2" + "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() { + } + + var name string + if !dec.ExpectAtom(&name) { + return caps, fmt.Errorf("in capability-data: %v", dec.Err()) + } + caps[imap.Cap(name)] = struct{}{} + } + return caps, nil +} diff --git a/imapclient/client.go b/imapclient/client.go new file mode 100644 index 0000000..bd0076b --- /dev/null +++ b/imapclient/client.go @@ -0,0 +1,1215 @@ +// 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 +) + +var dialer = &net.Dialer{ + Timeout: 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 +} + +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 != nil && options.TLSConfig != nil { + return options.TLSConfig.Clone() + } else { + return new(tls.Config) + } +} + +// 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) { + conn, err := 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) { + tlsConfig := options.tlsConfig() + if tlsConfig.NextProtos == nil { + tlsConfig.NextProtos = []string{"imap"} + } + + 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 := 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 +} + +// 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. +func (c *Client) Rename(mailbox, newName string) *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 +} + +// Subscribe 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/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..1038600 --- /dev/null +++ b/imapclient/dovecot_test.go @@ -0,0 +1,64 @@ +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 := `log_path = "` + tempDir + `/dovecot.log" +ssl = no +mail_home = "` + tempDir + `/%u" +mail_location = maildir:~/Mail + +namespace inbox { + separator = / + prefix = + inbox = yes +} + +mail_plugins = $mail_plugins acl +protocol imap { + mail_plugins = $mail_plugins imap_acl +} +plugin { + acl = 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..bc6a8f8 --- /dev/null +++ b/imapclient/example_test.go @@ -0,0 +1,365 @@ +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) + } + + // Wait for 30s to receive updates from the server + time.Sleep(30 * time.Second) + + // Stop idling + if err := idleCmd.Close(); err != nil { + log.Fatalf("failed to stop idling: %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) + } +} 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..74d95f1 --- /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 == io.EOF && 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..ee2b2b9 --- /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() + } + data.UID = name == "UID" + + if data.UID { + 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 data.UID { + 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..90cd115 --- /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.PermanentFlags = 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..dfd2c1f --- /dev/null +++ b/imapserver/append.go @@ -0,0 +1,120 @@ +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" +) + +// appendLimit is the maximum size of an APPEND payload. +// +// TODO: make configurable +const appendLimit = 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 + } + + 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..b3e7c99 --- /dev/null +++ b/imapserver/capability.go @@ -0,0 +1,95 @@ +package imapserver + +import ( + "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) { + caps = append(caps, []imap.Cap{ + imap.CapUnselect, + imap.CapEnable, + imap.CapIdle, + imap.CapUTF8Accept, + }...) + addAvailableCaps(&caps, available, []imap.Cap{ + imap.CapNamespace, + imap.CapUIDPlus, + imap.CapESearch, + imap.CapSearchRes, + imap.CapListExtended, + imap.CapListStatus, + imap.CapMove, + imap.CapStatusSize, + imap.CapBinary, + }) + } + addAvailableCaps(&caps, available, []imap.Cap{ + imap.CapCreateSpecialUse, + imap.CapLiteralPlus, + imap.CapUnauthenticate, + }) + } + 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..1d8adb5 --- /dev/null +++ b/imapserver/conn.go @@ -0,0 +1,617 @@ +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 + } + return c.session.Rename(oldName, newName) +} + +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..b5636d3 --- /dev/null +++ b/imapserver/enable.go @@ -0,0 +1,46 @@ +package imapserver + +import ( + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +func (c *Conn) handleEnable(dec *imapwire.Decoder) error { + var requested []imap.Cap + for dec.SP() { + var c string + if !dec.ExpectAtom(&c) { + return dec.Err() + } + requested = append(requested, imap.Cap(c)) + } + 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..6dcdcbd --- /dev/null +++ b/imapserver/fetch.go @@ -0,0 +1,711 @@ +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.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 { + enc.String(strings.ToUpper(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 i, child := range bs.Children { + if i > 0 { + enc.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..88c156b --- /dev/null +++ b/imapserver/imapmemserver/mailbox.go @@ -0,0 +1,486 @@ +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 + 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 + } + + data := imap.ListData{ + Mailbox: mbox.name, + Delim: mailboxDelim, + } + if mbox.subscribed { + data.Attrs = append(data.Attrs, imap.MailboxAttrSubscribed) + } + 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 + } + 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) + + return &imap.SelectData{ + Flags: flags, + PermanentFlags: permanentFlags, + NumMessages: uint32(len(mbox.l)), + UIDNext: mbox.uidNext, + UIDValidity: mbox.uidValidity, + } +} + +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) + + data := imap.SearchData{UID: numKind == imapserver.NumKindUID} + + var ( + 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..d2914d1 --- /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) 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..70c2de2 --- /dev/null +++ b/imapserver/list.go @@ -0,0 +1,325 @@ +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 + 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 "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..3d4f055 --- /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) + } else { + return c.writeSearch(data.All) + } +} + +func (c *Conn) writeESearch(tag string, data *imap.SearchData, options *imap.SearchOptions) error { + enc := newResponseEncoder(c) + defer enc.end() + + enc.Atom("*").SP().Atom("ESEARCH") + if tag != "" { + enc.SP().Special('(').Atom("TAG").SP().Atom(tag).Special(')') + } + if data.UID { + 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.Flag, 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 nil + } + 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 nil + } + if !dec.ExpectSP() { + return dec.Err() + } + if err := readSearchKey(&or[1], dec); err != nil { + return nil + } + 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..0eb669f --- /dev/null +++ b/imapserver/select.go @@ -0,0 +1,160 @@ +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) { + if err := c.writeObsoleteRecent(data.NumRecent); 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) 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..873bdef --- /dev/null +++ b/imapserver/session.go @@ -0,0 +1,116 @@ +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) 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 +} 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..7053d83 --- /dev/null +++ b/internal/internal.go @@ -0,0 +1,170 @@ +package internal + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +const ( + DateTimeLayout = "_2-Jan-2006 15:04:05 -0700" + DateLayout = "2-Jan-2006" +) + +const FlagRecent imap.Flag = "\\Recent" // removed in IMAP4rev2 + +func DecodeDateTime(dec *imapwire.Decoder) (time.Time, error) { + var s string + if !dec.Quoted(&s) { + return time.Time{}, nil + } + t, err := time.Parse(DateTimeLayout, s) + if err != nil { + return time.Time{}, fmt.Errorf("in date-time: %v", err) // TODO: use imapwire.DecodeExpectError? + } + return t, err +} + +func ExpectDateTime(dec *imapwire.Decoder) (time.Time, error) { + t, err := DecodeDateTime(dec) + if err != nil { + return t, err + } + if !dec.Expect(!t.IsZero(), "date-time") { + return t, dec.Err() + } + return t, nil +} + +func ExpectDate(dec *imapwire.Decoder) (time.Time, error) { + var s string + if !dec.ExpectAString(&s) { + return time.Time{}, dec.Err() + } + t, err := time.Parse(DateLayout, s) + if err != nil { + return time.Time{}, fmt.Errorf("in date: %v", err) // use imapwire.DecodeExpectError? + } + return t, nil +} + +func ExpectFlagList(dec *imapwire.Decoder) ([]imap.Flag, error) { + var flags []imap.Flag + err := dec.ExpectList(func() error { + // Some servers start the list with a space, so we need to skip it + // https://github.com/emersion/go-imap/pull/633 + dec.SP() + + flag, err := ExpectFlag(dec) + if err != nil { + return err + } + flags = append(flags, flag) + return nil + }) + return flags, err +} + +func ExpectFlag(dec *imapwire.Decoder) (imap.Flag, error) { + isSystem := dec.Special('\\') + if isSystem && dec.Special('*') { + return imap.FlagWildcard, nil // flag-perm + } + var name string + if !dec.ExpectAtom(&name) { + return "", fmt.Errorf("in flag: %w", dec.Err()) + } + if isSystem { + name = "\\" + name + } + return canonicalFlag(name), nil +} + +func ExpectMailboxAttrList(dec *imapwire.Decoder) ([]imap.MailboxAttr, error) { + var attrs []imap.MailboxAttr + err := dec.ExpectList(func() error { + attr, err := ExpectMailboxAttr(dec) + if err != nil { + return err + } + attrs = append(attrs, attr) + return nil + }) + return attrs, err +} + +func ExpectMailboxAttr(dec *imapwire.Decoder) (imap.MailboxAttr, error) { + flag, err := ExpectFlag(dec) + return canonicalMailboxAttr(string(flag)), err +} + +var ( + canonOnce sync.Once + canonFlag map[string]imap.Flag + canonMailboxAttr map[string]imap.MailboxAttr +) + +func canonInit() { + flags := []imap.Flag{ + imap.FlagSeen, + imap.FlagAnswered, + imap.FlagFlagged, + imap.FlagDeleted, + imap.FlagDraft, + imap.FlagForwarded, + imap.FlagMDNSent, + imap.FlagJunk, + imap.FlagNotJunk, + imap.FlagPhishing, + imap.FlagImportant, + } + mailboxAttrs := []imap.MailboxAttr{ + imap.MailboxAttrNonExistent, + imap.MailboxAttrNoInferiors, + imap.MailboxAttrNoSelect, + imap.MailboxAttrHasChildren, + imap.MailboxAttrHasNoChildren, + imap.MailboxAttrMarked, + imap.MailboxAttrUnmarked, + imap.MailboxAttrSubscribed, + imap.MailboxAttrRemote, + imap.MailboxAttrAll, + imap.MailboxAttrArchive, + imap.MailboxAttrDrafts, + imap.MailboxAttrFlagged, + imap.MailboxAttrJunk, + imap.MailboxAttrSent, + imap.MailboxAttrTrash, + imap.MailboxAttrImportant, + } + + canonFlag = make(map[string]imap.Flag) + for _, flag := range flags { + canonFlag[strings.ToLower(string(flag))] = flag + } + + canonMailboxAttr = make(map[string]imap.MailboxAttr) + for _, attr := range mailboxAttrs { + canonMailboxAttr[strings.ToLower(string(attr))] = attr + } +} + +func canonicalFlag(s string) imap.Flag { + canonOnce.Do(canonInit) + if flag, ok := canonFlag[strings.ToLower(s)]; ok { + return flag + } + return imap.Flag(s) +} + +func canonicalMailboxAttr(s string) imap.MailboxAttr { + canonOnce.Do(canonInit) + if attr, ok := canonMailboxAttr[strings.ToLower(s)]; ok { + return attr + } + return imap.MailboxAttr(s) +} diff --git a/internal/sasl.go b/internal/sasl.go 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/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..e5b7720 --- /dev/null +++ b/search.go @@ -0,0 +1,202 @@ +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 + UID bool + Min uint32 + Max uint32 + Count uint32 + + // requires CONDSTORE + ModSeq uint64 +} + +// AllSeqNums returns All as a slice of sequence numbers. +func (data *SearchData) AllSeqNums() []uint32 { + seqSet, ok := data.All.(SeqSet) + 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..97411b3 --- /dev/null +++ b/select.go @@ -0,0 +1,28 @@ +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 + // Number of recent messages in this mailbox ("RECENT") (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" +)