Forked the emersion/go-imap v1 project.

This commit is contained in:
2025-05-01 11:58:18 +03:00
commit bcc3f95e8e
107 changed files with 16268 additions and 0 deletions

23
LICENSE Normal file
View File

@@ -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.

29
README.md Normal file
View File

@@ -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

104
acl.go Normal file
View File

@@ -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
}

18
append.go Normal file
View File

@@ -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
}

213
capability.go Normal file
View File

@@ -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
}

81
cmd/imapmemserver/main.go Normal file
View File

@@ -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)
}
}

9
copy.go Normal file
View File

@@ -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
}

6
create.go Normal file
View File

@@ -0,0 +1,6 @@
package imap
// CreateOptions contains options for the CREATE command.
type CreateOptions struct {
SpecialUse []MailboxAttr // requires CREATE-SPECIAL-USE
}

284
fetch.go Normal file
View File

@@ -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)

8
go.mod Normal file
View File

@@ -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
)

35
go.sum Normal file
View File

@@ -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=

15
id.go Normal file
View File

@@ -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
}

105
imap.go Normal file
View File

@@ -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

138
imapclient/acl.go Normal file
View File

@@ -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
}

115
imapclient/acl_test.go Normal file
View File

@@ -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")
}
})
}

58
imapclient/append.go Normal file
View File

@@ -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()
}

28
imapclient/append_test.go Normal file
View File

@@ -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
}

100
imapclient/authenticate.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

55
imapclient/capability.go Normal file
View File

@@ -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
}

1215
imapclient/client.go Normal file

File diff suppressed because it is too large Load Diff

277
imapclient/client_test.go Normal file
View File

@@ -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")
}
}

37
imapclient/copy.go Normal file
View File

@@ -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
}

21
imapclient/create.go Normal file
View File

@@ -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
}

57
imapclient/create_test.go Normal file
View File

@@ -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)
})
}

View File

@@ -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()
}

69
imapclient/enable.go Normal file
View File

@@ -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
}

365
imapclient/example_test.go Normal file
View File

@@ -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: <root@nsa.gov>\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)
}
}

84
imapclient/expunge.go Normal file
View File

@@ -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()
}

View File

@@ -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)
}
}

1326
imapclient/fetch.go Normal file

File diff suppressed because it is too large Load Diff

39
imapclient/fetch_test.go Normal file
View File

@@ -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)
}
}

163
imapclient/id.go Normal file
View File

@@ -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()
}

157
imapclient/idle.go Normal file
View File

@@ -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()
}

42
imapclient/idle_test.go Normal file
View File

@@ -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")
}
}

259
imapclient/list.go Normal file
View File

@@ -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
}
}

42
imapclient/list_test.go Normal file
View File

@@ -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)
}
}

205
imapclient/metadata.go Normal file
View File

@@ -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
}

74
imapclient/move.go Normal file
View File

@@ -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
}

110
imapclient/namespace.go Normal file
View File

@@ -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
}

176
imapclient/quota.go Normal file
View File

@@ -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
}

401
imapclient/search.go Normal file
View File

@@ -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, &not)
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(&not) {
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
}

61
imapclient/search_test.go Normal file
View File

@@ -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)
}
}

100
imapclient/select.go Normal file
View File

@@ -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
}

20
imapclient/select_test.go Normal file
View File

@@ -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)
}
}

84
imapclient/sort.go Normal file
View File

@@ -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
}

83
imapclient/starttls.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}
}

164
imapclient/status.go Normal file
View File

@@ -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
}

34
imapclient/status_test.go Normal file
View File

@@ -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)
}
}

44
imapclient/store.go Normal file
View File

@@ -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
}

40
imapclient/store_test.go Normal file
View File

@@ -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)
}
}

85
imapclient/thread.go Normal file
View File

@@ -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
}

120
imapserver/append.go Normal file
View File

@@ -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()
}

148
imapserver/authenticate.go Normal file
View File

@@ -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
}

95
imapserver/capability.go Normal file
View File

@@ -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)
}
}
}

617
imapserver/conn.go Normal file
View File

@@ -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()
}

55
imapserver/copy.go Normal file
View File

@@ -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
}

45
imapserver/create.go Normal file
View File

@@ -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)
}

46
imapserver/enable.go Normal file
View File

@@ -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()
}

50
imapserver/expunge.go Normal file
View File

@@ -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)
}

711
imapserver/fetch.go Normal file
View File

@@ -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, &section)
if err != nil {
return err
}
section.Partial, err = maybeReadPartial(dec)
if err != nil {
return err
}
options.BodySection = append(options.BodySection, &section)
case "BODY.PEEK":
if !dec.ExpectSpecial('[') {
return dec.Err()
}
section := imap.FetchItemBodySection{Peek: true}
err := readSection(dec, &section)
if err != nil {
return err
}
section.Partial, err = maybeReadPartial(dec)
if err != nil {
return err
}
options.BodySection = append(options.BodySection, &section)
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])
})
}
}

50
imapserver/idle.go Normal file
View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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, &not) {
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)))
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

325
imapserver/list.go Normal file
View File

@@ -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)
}

51
imapserver/list_test.go Normal file
View File

@@ -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)
}
}
}

28
imapserver/login.go Normal file
View File

@@ -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")
}

336
imapserver/message.go Normal file
View File

@@ -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
}

40
imapserver/move.go Normal file
View File

@@ -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)
}

54
imapserver/namespace.go Normal file
View File

@@ -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(')')
})
}

343
imapserver/search.go Normal file
View File

@@ -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(&not, 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)))
}

160
imapserver/select.go Normal file
View File

@@ -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()
}

222
imapserver/server.go Normal file
View File

@@ -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
}

116
imapserver/session.go Normal file
View File

@@ -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
}

83
imapserver/starttls.go Normal file
View File

@@ -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)
}

125
imapserver/status.go Normal file
View File

@@ -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
}

78
imapserver/store.go Normal file
View File

@@ -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)
}

284
imapserver/tracker.go Normal file
View File

@@ -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
}

155
imapserver/tracker_test.go Normal file
View File

@@ -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)
}
})
}
}

13
internal/acl.go Normal file
View File

@@ -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)
}

306
internal/imapnum/numset.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

39
internal/imapwire/num.go Normal file
View File

@@ -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
}

170
internal/internal.go Normal file
View File

@@ -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)
}

23
internal/sasl.go Normal file
View File

@@ -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)
}
}

118
internal/utf7/decoder.go Normal file
View File

@@ -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]
}

View File

@@ -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)
}
}
}

88
internal/utf7/encoder.go Normal file
View File

@@ -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()
}

View File

@@ -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)
}
}
}

13
internal/utf7/utf7.go Normal file
View File

@@ -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+,")

30
list.go Normal file
View File

@@ -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
}

14
namespace.go Normal file
View File

@@ -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
}

149
numset.go Normal file
View File

@@ -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))
}

Some files were not shown because too many files have changed in this diff Show More