Repository: emersion/go-imap
Pull request #490 (2023-03-11 14:59:33)

v2: initial implementation
- [x] Hangs when a parse error happens in a FETCH (`FetchCommand` channel not closed)
- [x] Recover from panics in decoder goroutine
- [x] Figure out a better design for `FetchItemDataSection`
- [x] Figure out a better design for unilateral responses getting mixed up in regular responses
- [x] Add a way to test a connection's liveness (v1's `LoggedOut` channel)
- [x] Timeouts
- [x] Retrofit IMAP4rev1 support
- [x] Expose current state, capabilities, and selected mailbox data
- [x] Validate flags/mailbox/etc before encoding
- [ ] Consider adding separate types for `FetchItemData{Body,Binary}Section.Section`
- [x] Parse and expose `COPYUID` in response to `MOVE` command
- [x] Add support for `LITERAL-`
- [ ] Add IMAP4rev1 fallbacks for IDLE and MOVE
- [ ] Add support for NAMESPACE
- [ ] Add support for ESEARCH and SEARCH-RES


Patch:
diff --git a/capability.go b/capability.go
new file mode 100644
index 0000000..61cb050
--- /dev/null
+++ b/capability.go
@@ -0,0 +1,150 @@
+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
+	CapThread           Cap = "THREAD"             // RFC 5256
+	CapUnauthenticate   Cap = "UNAUTHENTICATE"     // RFC 8437
+	CapURLPartial       Cap = "URL-PARTIAL"        // RFC 5550
+	CapURLAuth          Cap = "URLAUTH"            // RFC 4467
+	CapWithin           Cap = "WITHIN"             // RFC 5032
+)
+
+var imap4rev2Caps = CapSet{
+	CapNamespace:    {},
+	CapUnselect:     {},
+	CapUIDPlus:      {},
+	CapESearch:      {},
+	CapSearchRes:    {},
+	CapEnable:       {},
+	CapIdle:         {},
+	CapSASLIR:       {},
+	CapListExtended: {},
+	CapListStatus:   {},
+	CapMove:         {},
+	CapLiteralMinus: {},
+	CapStatusSize:   {},
+}
+
+// CapSet is a set of capabilities.
+type CapSet map[Cap]struct{}
+
+func (set CapSet) has(c Cap) bool {
+	_, ok := set[c]
+	return ok
+}
+
+// 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 == CapAppendLimit {
+		_, ok := set.AppendLimit()
+		return ok
+	}
+
+	return false
+}
+
+// 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
+}
diff --git a/go.mod b/go.mod
index b08047a..3516e3d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,7 @@
 module github.com/emersion/go-imap/v2
 
 go 1.18
+
+require golang.org/x/text v0.8.0
+
+require github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..5570d7a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
+github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
+golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
diff --git a/imap.go b/imap.go
new file mode 100644
index 0000000..4de97f3
--- /dev/null
+++ b/imap.go
@@ -0,0 +1,56 @@
+// Package IMAP implements IMAP4rev2.
+//
+// IMAP4rev2 is defined in RFC 9051.
+package imap
+
+// 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"
+)
+
+// 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 = "\\*"
+)
diff --git a/imapclient/append.go b/imapclient/append.go
new file mode 100644
index 0000000..a7f2ba8
--- /dev/null
+++ b/imapclient/append.go
@@ -0,0 +1,67 @@
+package imapclient
+
+import (
+	"io"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+)
+
+// AppendOptions contains options for the APPEND command.
+type AppendOptions struct {
+	Flags []imap.Flag
+	Time  time.Time
+}
+
+// Append sends an APPEND command.
+//
+// The caller must call AppendCommand.Close.
+//
+// The options are optional.
+func (c *Client) Append(mailbox string, size int64, options *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(dateTimeLayout)).SP()
+	}
+	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 {
+	cmd
+	enc  *commandEncoder
+	wc   io.WriteCloser
+	data 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() (*AppendData, error) {
+	return &cmd.data, cmd.cmd.Wait()
+}
+
+// AppendData is the data returned by an APPEND command.
+type AppendData struct {
+	UID, UIDValidity uint32 // requires UIDPLUS or IMAP4rev2
+}
diff --git a/imapclient/authenticate.go b/imapclient/authenticate.go
new file mode 100644
index 0000000..a2328c6
--- /dev/null
+++ b/imapclient/authenticate.go
@@ -0,0 +1,97 @@
+package imapclient
+
+import (
+	"encoding/base64"
+	"fmt"
+
+	"github.com/emersion/go-sasl"
+
+	"github.com/emersion/go-imap/v2"
+)
+
+// 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
+	}
+
+	cmd := &authenticateCommand{}
+	contReq := c.registerContReq(cmd)
+	enc := c.beginCommand("AUTHENTICATE", cmd)
+	enc.SP().Atom(mech)
+	if initialResp != nil && c.Caps().Has(imap.CapSASLIR) {
+		enc.SP().Atom(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 := 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 {
+	cmd
+}
+
+func (c *Client) writeSASLResp(resp []byte) error {
+	respStr := 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
+}
+
+func encodeSASL(b []byte) string {
+	if len(b) == 0 {
+		return "="
+	} else {
+		return base64.StdEncoding.EncodeToString(b)
+	}
+}
+
+func decodeSASL(s string) ([]byte, error) {
+	if s == "=" {
+		return []byte{}, nil
+	} else {
+		return base64.StdEncoding.DecodeString(s)
+	}
+}
diff --git a/imapclient/capability.go b/imapclient/capability.go
new file mode 100644
index 0000000..c0074f8
--- /dev/null
+++ b/imapclient/capability.go
@@ -0,0 +1,38 @@
+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
+}
+
+// CapabilityCommand is a CAPABILITY command.
+type CapabilityCommand struct {
+	cmd
+	caps imap.CapSet
+}
+
+func (cmd *CapabilityCommand) Wait() (imap.CapSet, error) {
+	err := cmd.cmd.Wait()
+	return cmd.caps, err
+}
+
+func readCapabilities(dec *imapwire.Decoder) (imap.CapSet, error) {
+	caps := make(imap.CapSet)
+	for dec.SP() {
+		var name string
+		if !dec.ExpectAtom(&name) {
+			return caps, fmt.Errorf("in capability-data: %v", dec.Err())
+		}
+		caps[imap.Cap(name)] = struct{}{}
+	}
+	return caps, nil
+}
diff --git a/imapclient/client.go b/imapclient/client.go
new file mode 100644
index 0000000..37b717e
--- /dev/null
+++ b/imapclient/client.go
@@ -0,0 +1,1171 @@
+package imapclient
+
+import (
+	"bufio"
+	"crypto/tls"
+	"fmt"
+	"io"
+	"mime"
+	"net"
+	"runtime/debug"
+	"strconv"
+	"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"
+
+const (
+	idleReadTimeout    = time.Duration(0)
+	respReadTimeout    = 30 * time.Second
+	literalReadTimeout = 5 * time.Minute
+
+	respWriteTimeout    = 30 * time.Second
+	literalWriteTimeout = 5 * time.Minute
+)
+
+// State describes the client state.
+//
+// See RFC 9051 section 3.
+type State int
+
+const (
+	StateNone State = iota
+	StateNotAuthenticated
+	StateAuthenticated
+	StateSelected
+	StateLogout
+)
+
+// String implements fmt.Stringer.
+func (state State) String() string {
+	switch state {
+	case StateNone:
+		return "none"
+	case StateNotAuthenticated:
+		return "not authenticated"
+	case StateAuthenticated:
+		return "authenticated"
+	case StateSelected:
+		return "selected"
+	case StateLogout:
+		return "logout"
+	default:
+		panic(fmt.Errorf("imapclient: unknown state %v", int(state)))
+	}
+}
+
+// SelectedMailbox contains metadata for the currently selected mailbox.
+type SelectedMailbox struct {
+	Name           string
+	NumMessages    uint32
+	Flags          []imap.Flag
+	PermanentFlags []imap.Flag
+}
+
+func (mbox *SelectedMailbox) copy() *SelectedMailbox {
+	copy := *mbox
+	return &copy
+}
+
+// Options contains options for Client.
+type Options struct {
+	// Raw ingress and egress data will be written to this writer, if any
+	DebugWriter io.Writer
+	// Unilateral data handler
+	UnilateralDataHandler *UnilateralDataHandler
+	// Decoder for RFC 2047 words
+	WordDecoder *mime.WordDecoder
+}
+
+func (options *Options) wrapReadWriter(rw io.ReadWriter) io.ReadWriter {
+	if options.DebugWriter == nil {
+		return rw
+	}
+	return struct {
+		io.Reader
+		io.Writer
+	}{
+		Reader: io.TeeReader(rw, options.DebugWriter),
+		Writer: io.MultiWriter(rw, options.DebugWriter),
+	}
+}
+
+func (options *Options) decodeText(s string) (string, error) {
+	wordDecoder := options.WordDecoder
+	if wordDecoder == nil {
+		wordDecoder = &mime.WordDecoder{}
+	}
+	out, err := wordDecoder.DecodeHeader(s)
+	if err != nil {
+		return s, err
+	}
+	return out, nil
+}
+
+func (options *Options) unilateralDataHandler() *UnilateralDataHandler {
+	if options.UnilateralDataHandler == nil {
+		return &UnilateralDataHandler{}
+	}
+	return options.UnilateralDataHandler
+}
+
+// Client is an IMAP client.
+//
+// IMAP commands are exposed as methods. These methods will block until the
+// command has been sent to the server, but won't block until the server sends
+// a response. They return a command struct which can be used to wait for the
+// server response.
+type Client struct {
+	conn     net.Conn
+	options  Options
+	br       *bufio.Reader
+	bw       *bufio.Writer
+	dec      *imapwire.Decoder
+	encMutex sync.Mutex
+
+	greetingCh   chan struct{}
+	greetingRecv bool
+	greetingErr  error
+
+	decCh  chan struct{}
+	decErr error
+
+	mutex       sync.Mutex
+	state       State
+	caps        imap.CapSet
+	mailbox     *SelectedMailbox
+	cmdTag      uint64
+	pendingCmds []command
+	contReqs    []continuationRequest
+	closed      bool
+}
+
+// New creates a new IMAP client.
+//
+// This function doesn't perform I/O.
+//
+// A nil options pointer is equivalent to a zero options value.
+func New(conn net.Conn, options *Options) *Client {
+	if options == nil {
+		options = &Options{}
+	}
+
+	rw := options.wrapReadWriter(conn)
+	br := bufio.NewReader(rw)
+	bw := bufio.NewWriter(rw)
+
+	client := &Client{
+		conn:       conn,
+		options:    *options,
+		br:         br,
+		bw:         bw,
+		dec:        imapwire.NewDecoder(br),
+		greetingCh: make(chan struct{}),
+		decCh:      make(chan struct{}),
+		state:      StateNone,
+	}
+	go client.read()
+	return client
+}
+
+// DialTLS connects to an IMAP server with implicit TLS.
+func DialTLS(address string, options *Options) (*Client, error) {
+	conn, err := tls.Dial("tcp", address, &tls.Config{
+		NextProtos: []string{"imap"},
+	})
+	if err != nil {
+		return nil, err
+	}
+	return New(conn, options), nil
+}
+
+// DialStartTLS connects to an IMAP server with STARTTLS.
+func DialStartTLS(address string, options *Options) (*Client, error) {
+	host, _, err := net.SplitHostPort(address)
+	if err != nil {
+		return nil, err
+	}
+
+	conn, err := net.Dial("tcp", address)
+	if err != nil {
+		return nil, err
+	}
+
+	client := New(conn, options)
+	if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil {
+		conn.Close()
+		return nil, err
+	}
+
+	return client, err
+}
+
+func (c *Client) setReadTimeout(dur time.Duration) {
+	if dur > 0 {
+		c.conn.SetReadDeadline(time.Now().Add(dur))
+	} else {
+		c.conn.SetReadDeadline(time.Time{})
+	}
+}
+
+func (c *Client) setWriteTimeout(dur time.Duration) {
+	if dur > 0 {
+		c.conn.SetWriteDeadline(time.Now().Add(dur))
+	} else {
+		c.conn.SetWriteDeadline(time.Time{})
+	}
+}
+
+// State returns the current state of the client.
+func (c *Client) State() State {
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+	return c.state
+}
+
+func (c *Client) setState(state State) {
+	c.mutex.Lock()
+	c.state = state
+	if c.state != StateSelected {
+		c.mailbox = nil
+	}
+	c.mutex.Unlock()
+}
+
+// Caps returns the capabilities advertised by the server.
+//
+// When the server hasn't sent the capability list, this method will request it
+// and block until it's received. If the capabilities cannot be fetched, nil is
+// returned.
+func (c *Client) Caps() imap.CapSet {
+	if err := c.WaitGreeting(); err != nil {
+		return nil
+	}
+
+	c.mutex.Lock()
+	caps := c.caps
+	c.mutex.Unlock()
+
+	if caps != nil {
+		return caps
+	}
+
+	caps, _ = c.Capability().Wait()
+	return caps
+}
+
+func (c *Client) setCaps(caps imap.CapSet) {
+	c.mutex.Lock()
+	c.caps = caps
+	c.mutex.Unlock()
+}
+
+// Mailbox returns the state of the currently selected mailbox.
+//
+// If there is no currently selected mailbox, nil is returned.
+//
+// The returned struct must not be mutated.
+func (c *Client) Mailbox() *SelectedMailbox {
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+	return c.mailbox
+}
+
+// Close immediately closes the connection.
+func (c *Client) Close() error {
+	c.mutex.Lock()
+	alreadyClosed := c.closed
+	c.closed = true
+	c.mutex.Unlock()
+
+	// Ignore net.ErrClosed here, because we also call conn.Close in c.read
+	if err := c.conn.Close(); err != nil && err != net.ErrClosed {
+		return err
+	}
+
+	<-c.decCh
+	if err := c.decErr; err != nil {
+		return err
+	}
+
+	if alreadyClosed {
+		return net.ErrClosed
+	}
+	return nil
+}
+
+// beginCommand starts sending a command to the server.
+//
+// The command name and a space are written.
+//
+// The caller must call commandEncoder.end.
+func (c *Client) beginCommand(name string, cmd command) *commandEncoder {
+	c.encMutex.Lock() // unlocked by commandEncoder.end
+
+	c.mutex.Lock()
+	c.cmdTag++
+	tag := fmt.Sprintf("T%v", c.cmdTag)
+	c.pendingCmds = append(c.pendingCmds, cmd)
+	c.mutex.Unlock()
+
+	c.setWriteTimeout(respWriteTimeout)
+
+	baseCmd := cmd.base()
+	*baseCmd = Command{
+		tag:  tag,
+		done: make(chan error, 1),
+	}
+	enc := &commandEncoder{
+		Encoder: imapwire.NewEncoder(c.bw),
+		client:  c,
+		cmd:     baseCmd,
+	}
+	enc.Atom(tag).SP().Atom(name)
+	return enc
+}
+
+func (c *Client) deletePendingCmdByTag(tag string) command {
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
+	for i, cmd := range c.pendingCmds {
+		if cmd.base().tag == tag {
+			c.pendingCmds = append(c.pendingCmds[:i], c.pendingCmds[i+1:]...)
+			return cmd
+		}
+	}
+	return nil
+}
+
+func (c *Client) findPendingCmdFunc(f func(cmd command) bool) command {
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
+	for _, cmd := range c.pendingCmds {
+		if f(cmd) {
+			return cmd
+		}
+	}
+	return nil
+}
+
+func findPendingCmdByType[T command](c *Client) T {
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
+	for _, cmd := range c.pendingCmds {
+		if cmd, ok := cmd.(T); ok {
+			return cmd
+		}
+	}
+
+	var cmd T
+	return cmd
+}
+
+func (c *Client) completeCommand(cmd command, err error) {
+	done := cmd.base().done
+	done <- err
+	close(done)
+
+	// Ensure the command is not blocked waiting on continuation requests
+	c.mutex.Lock()
+	var filtered []continuationRequest
+	for _, contReq := range c.contReqs {
+		if contReq.cmd != cmd.base() {
+			filtered = append(filtered, contReq)
+		} else {
+			contReq.Cancel(err)
+		}
+	}
+	c.contReqs = filtered
+	c.mutex.Unlock()
+
+	switch cmd := cmd.(type) {
+	case *authenticateCommand, *loginCommand:
+		if err == nil {
+			c.setState(StateAuthenticated)
+		}
+	case *SelectCommand:
+		if err == nil {
+			c.mutex.Lock()
+			c.state = StateSelected
+			c.mailbox = &SelectedMailbox{
+				Name:           cmd.mailbox,
+				NumMessages:    cmd.data.NumMessages,
+				Flags:          cmd.data.Flags,
+				PermanentFlags: cmd.data.PermanentFlags,
+			}
+			c.mutex.Unlock()
+		}
+	case *unselectCommand:
+		if err == nil {
+			c.setState(StateAuthenticated)
+		}
+	case *logoutCommand:
+		if err == nil {
+			c.setState(StateLogout)
+		}
+	case *ListCommand:
+		if cmd.pendingData != nil {
+			cmd.mailboxes <- cmd.pendingData
+		}
+		close(cmd.mailboxes)
+	case *FetchCommand:
+		close(cmd.msgs)
+	case *ExpungeCommand:
+		close(cmd.seqNums)
+	}
+}
+
+func (c *Client) registerContReq(cmd command) *imapwire.ContinuationRequest {
+	contReq := imapwire.NewContinuationRequest()
+
+	c.mutex.Lock()
+	c.contReqs = append(c.contReqs, continuationRequest{
+		ContinuationRequest: contReq,
+		cmd:                 cmd.base(),
+	})
+	c.mutex.Unlock()
+
+	return contReq
+}
+
+func (c *Client) unregisterContReq(contReq *imapwire.ContinuationRequest) {
+	c.mutex.Lock()
+	for i := range c.contReqs {
+		if c.contReqs[i].ContinuationRequest == contReq {
+			c.contReqs = append(c.contReqs[:i], c.contReqs[i+1:]...)
+			break
+		}
+	}
+	c.mutex.Unlock()
+}
+
+// read continuously reads data coming from the server.
+//
+// All the data is decoded in the read goroutine, then dispatched via channels
+// to pending commands.
+func (c *Client) read() {
+	defer close(c.decCh)
+	defer func() {
+		if v := recover(); v != nil {
+			c.decErr = fmt.Errorf("imapclient: panic reading response: %v\n%s", v, debug.Stack())
+		}
+
+		c.conn.Close()
+
+		c.mutex.Lock()
+		c.state = StateLogout
+		pendingCmds := c.pendingCmds
+		c.pendingCmds = nil
+		c.mutex.Unlock()
+
+		cmdErr := c.decErr
+		if cmdErr == nil {
+			cmdErr = io.ErrUnexpectedEOF
+		}
+		for _, cmd := range pendingCmds {
+			c.completeCommand(cmd, cmdErr)
+		}
+	}()
+
+	c.setReadTimeout(idleReadTimeout)
+	for {
+		if c.dec.EOF() {
+			break
+		}
+		if err := c.readResponse(); err != nil {
+			c.decErr = err
+			break
+		}
+		if c.greetingErr != nil {
+			break
+		}
+	}
+}
+
+func (c *Client) readResponse() error {
+	c.setReadTimeout(respReadTimeout)
+	defer c.setReadTimeout(idleReadTimeout)
+
+	if c.dec.Special('+') {
+		if err := c.readContinueReq(); err != nil {
+			return fmt.Errorf("in continue-req: %v", err)
+		}
+		return nil
+	}
+
+	var tag, typ string
+	if !c.dec.Expect(c.dec.Special('*') || c.dec.Atom(&tag), "'*' or atom") {
+		return fmt.Errorf("in response: cannot read tag: %v", c.dec.Err())
+	}
+	if !c.dec.ExpectSP() {
+		return fmt.Errorf("in response: %v", c.dec.Err())
+	}
+	if !c.dec.ExpectAtom(&typ) {
+		return fmt.Errorf("in response: cannot read type: %v", c.dec.Err())
+	}
+
+	var (
+		token    string
+		err      error
+		startTLS *startTLSCommand
+	)
+	if tag != "" {
+		token = "response-tagged"
+		startTLS, err = c.readResponseTagged(tag, typ)
+	} else if typ == "BYE" {
+		token = "resp-cond-bye"
+		var text string
+		if !c.dec.ExpectText(&text) {
+			return fmt.Errorf("in resp-text: %v", c.dec.Err())
+		}
+	} else {
+		token = "response-data"
+		err = c.readResponseData(typ)
+	}
+	if err != nil {
+		return fmt.Errorf("in %v: %v", token, err)
+	}
+
+	if !c.dec.ExpectCRLF() {
+		return fmt.Errorf("in response: %v", c.dec.Err())
+	}
+
+	if startTLS != nil {
+		c.upgradeStartTLS(startTLS.tlsConfig)
+		close(startTLS.upgradeDone)
+	}
+
+	return nil
+}
+
+func (c *Client) readContinueReq() error {
+	var text string
+	if c.dec.SP() {
+		c.dec.Text(&text)
+	}
+	if !c.dec.ExpectCRLF() {
+		return c.dec.Err()
+	}
+
+	var contReq *imapwire.ContinuationRequest
+	c.mutex.Lock()
+	if len(c.contReqs) > 0 {
+		contReq = c.contReqs[0].ContinuationRequest
+		c.contReqs = append(c.contReqs[:0], c.contReqs[1:]...)
+	}
+	c.mutex.Unlock()
+
+	if contReq == nil {
+		return fmt.Errorf("received unmatched continuation request")
+	}
+
+	contReq.Done(text)
+	return nil
+}
+
+func (c *Client) readResponseTagged(tag, typ string) (*startTLSCommand, error) {
+	cmd := c.deletePendingCmdByTag(tag)
+	if cmd == nil {
+		return nil, fmt.Errorf("received tagged response with unknown tag %q", tag)
+	}
+
+	if !c.dec.ExpectSP() {
+		return nil, c.dec.Err()
+	}
+	var code string
+	if c.dec.Special('[') { // resp-text-code
+		if !c.dec.ExpectAtom(&code) {
+			return nil, fmt.Errorf("in resp-text-code: %v", c.dec.Err())
+		}
+		switch code {
+		case "CAPABILITY": // capability-data
+			caps, err := readCapabilities(c.dec)
+			if err != nil {
+				return nil, fmt.Errorf("in capability-data: %v", err)
+			}
+			c.setCaps(caps)
+		case "APPENDUID":
+			var uidValidity, uid uint32
+			if !c.dec.ExpectSP() || !c.dec.ExpectNumber(&uidValidity) || !c.dec.ExpectSP() || !c.dec.ExpectNumber(&uid) {
+				return nil, fmt.Errorf("in resp-code-apnd: %v", c.dec.Err())
+			}
+			if cmd, ok := cmd.(*AppendCommand); ok {
+				cmd.data.UID = uid
+				cmd.data.UIDValidity = uidValidity
+			}
+		case "COPYUID":
+			if !c.dec.ExpectSP() {
+				return nil, c.dec.Err()
+			}
+			uidValidity, srcUIDs, dstUIDs, err := readRespCodeCopy(c.dec)
+			if err != nil {
+				return nil, fmt.Errorf("in resp-code-copy: %v", err)
+			}
+			if cmd, ok := cmd.(*CopyCommand); ok {
+				cmd.data.UIDValidity = uidValidity
+				cmd.data.SourceUIDs = srcUIDs
+				cmd.data.DestUIDs = dstUIDs
+			}
+		default: // [SP 1*<any TEXT-CHAR except "]">]
+			if c.dec.SP() {
+				c.dec.Skip(']')
+			}
+		}
+		if !c.dec.ExpectSpecial(']') || !c.dec.ExpectSP() {
+			return nil, fmt.Errorf("in resp-text: %v", c.dec.Err())
+		}
+	}
+	var text string
+	if !c.dec.ExpectText(&text) {
+		return nil, fmt.Errorf("in resp-text: %v", c.dec.Err())
+	}
+
+	var cmdErr error
+	switch typ {
+	case "OK":
+		// nothing to do
+	case "NO", "BAD":
+		cmdErr = &imap.Error{
+			Type: imap.StatusResponseType(typ),
+			Code: imap.ResponseCode(code),
+			Text: text,
+		}
+	default:
+		return nil, fmt.Errorf("in resp-cond-state: expected OK, NO or BAD status condition, but got %v", typ)
+	}
+
+	c.completeCommand(cmd, cmdErr)
+
+	var startTLS *startTLSCommand
+	if cmd, ok := cmd.(*startTLSCommand); ok && cmdErr == nil {
+		startTLS = cmd
+	}
+
+	if cmdErr == nil && code != "CAPABILITY" {
+		switch cmd.(type) {
+		case *startTLSCommand, *loginCommand, *authenticateCommand:
+			c.setCaps(nil)
+		}
+	}
+
+	return startTLS, nil
+}
+
+func (c *Client) readResponseData(typ string) error {
+	// number SP ("EXISTS" / "RECENT" / "FETCH" / "EXPUNGE")
+	var num uint32
+	if typ[0] >= '0' && typ[0] <= '9' {
+		v, err := strconv.ParseUint(typ, 10, 32)
+		if err != nil {
+			return err
+		}
+
+		num = uint32(v)
+		if !c.dec.ExpectSP() || !c.dec.ExpectAtom(&typ) {
+			return c.dec.Err()
+		}
+	}
+
+	switch typ {
+	case "OK", "PREAUTH", "NO", "BAD", "BYE": // resp-cond-state / resp-cond-bye / resp-cond-auth
+		if !c.dec.ExpectSP() {
+			return c.dec.Err()
+		}
+
+		var code string
+		if c.dec.Special('[') { // resp-text-code
+			if !c.dec.ExpectAtom(&code) {
+				return fmt.Errorf("in resp-text-code: %v", c.dec.Err())
+			}
+			switch code {
+			case "CAPABILITY": // capability-data
+				caps, err := readCapabilities(c.dec)
+				if err != nil {
+					return fmt.Errorf("in capability-data: %v", err)
+				}
+				c.setCaps(caps)
+			case "PERMANENTFLAGS":
+				if !c.dec.ExpectSP() {
+					return c.dec.Err()
+				}
+				flags, err := readFlagList(c.dec)
+				if err != nil {
+					return err
+				}
+
+				c.mutex.Lock()
+				if c.state == StateSelected {
+					c.mailbox = c.mailbox.copy()
+					c.mailbox.PermanentFlags = flags
+				}
+				c.mutex.Unlock()
+
+				if cmd := findPendingCmdByType[*SelectCommand](c); cmd != nil {
+					cmd.data.PermanentFlags = flags
+				} else if handler := c.options.unilateralDataHandler().Mailbox; handler != nil {
+					handler(&UnilateralDataMailbox{PermanentFlags: flags})
+				}
+			case "UIDNEXT":
+				if !c.dec.ExpectSP() {
+					return c.dec.Err()
+				}
+				var uidNext uint32
+				if !c.dec.ExpectNumber(&uidNext) {
+					return c.dec.Err()
+				}
+				if cmd := findPendingCmdByType[*SelectCommand](c); cmd != nil {
+					cmd.data.UIDNext = uidNext
+				}
+			case "UIDVALIDITY":
+				if !c.dec.ExpectSP() {
+					return c.dec.Err()
+				}
+				var uidValidity uint32
+				if !c.dec.ExpectNumber(&uidValidity) {
+					return c.dec.Err()
+				}
+				if cmd := findPendingCmdByType[*SelectCommand](c); cmd != nil {
+					cmd.data.UIDValidity = uidValidity
+				}
+			case "COPYUID":
+				if !c.dec.ExpectSP() {
+					return c.dec.Err()
+				}
+				uidValidity, srcUIDs, dstUIDs, err := readRespCodeCopy(c.dec)
+				if err != nil {
+					return fmt.Errorf("in resp-code-copy: %v", err)
+				}
+				if cmd := findPendingCmdByType[*MoveCommand](c); cmd != nil {
+					cmd.data.UIDValidity = uidValidity
+					cmd.data.SourceUIDs = srcUIDs
+					cmd.data.DestUIDs = dstUIDs
+				}
+			default: // [SP 1*<any TEXT-CHAR except "]">]
+				if c.dec.SP() {
+					c.dec.Skip(']')
+				}
+			}
+			if !c.dec.ExpectSpecial(']') || !c.dec.ExpectSP() {
+				return fmt.Errorf("in resp-text: %v", c.dec.Err())
+			}
+		}
+
+		var text string
+		if !c.dec.ExpectText(&text) {
+			return fmt.Errorf("in resp-text: %v", c.dec.Err())
+		}
+
+		if code == "CLOSED" {
+			c.setState(StateAuthenticated)
+		}
+
+		if !c.greetingRecv {
+			switch typ {
+			case "OK":
+				c.setState(StateNotAuthenticated)
+			case "PREAUTH":
+				c.setState(StateAuthenticated)
+			default:
+				c.setState(StateLogout)
+				c.greetingErr = &imap.Error{
+					Type: imap.StatusResponseType(typ),
+					Code: imap.ResponseCode(code),
+					Text: text,
+				}
+			}
+			c.greetingRecv = true
+			close(c.greetingCh)
+		}
+	case "CAPABILITY": // capability-data
+		caps, err := readCapabilities(c.dec)
+		if err != nil {
+			return err
+		}
+		c.setCaps(caps)
+		if cmd := findPendingCmdByType[*CapabilityCommand](c); cmd != nil {
+			cmd.caps = caps
+		}
+	case "ENABLED":
+		caps, err := readCapabilities(c.dec)
+		if err != nil {
+			return err
+		}
+		if cmd := findPendingCmdByType[*EnableCommand](c); cmd != nil {
+			cmd.data.Caps = caps
+		}
+	case "FLAGS":
+		if !c.dec.ExpectSP() {
+			return c.dec.Err()
+		}
+		flags, err := readFlagList(c.dec)
+		if err != nil {
+			return err
+		}
+
+		c.mutex.Lock()
+		if c.state == StateSelected {
+			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})
+		}
+	case "EXISTS":
+		cmd := findPendingCmdByType[*SelectCommand](c)
+		if cmd != nil {
+			cmd.data.NumMessages = num
+		} else {
+			c.mutex.Lock()
+			if c.state == StateSelected {
+				c.mailbox = c.mailbox.copy()
+				c.mailbox.NumMessages = num
+			}
+			c.mutex.Unlock()
+
+			if handler := c.options.unilateralDataHandler().Mailbox; handler != nil {
+				handler(&UnilateralDataMailbox{NumMessages: &num})
+			}
+		}
+	case "RECENT":
+		// ignore
+	case "LIST":
+		if !c.dec.ExpectSP() {
+			return c.dec.Err()
+		}
+		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
+		}
+	case "STATUS":
+		if !c.dec.ExpectSP() {
+			return c.dec.Err()
+		}
+		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
+		}
+	case "FETCH":
+		if !c.dec.ExpectSP() {
+			return c.dec.Err()
+		}
+		if err := readMsgAtt(c, num); err != nil {
+			return fmt.Errorf("in msg-att: %v", err)
+		}
+	case "EXPUNGE":
+		c.mutex.Lock()
+		if c.state == StateSelected && c.mailbox.NumMessages > 0 {
+			c.mailbox = c.mailbox.copy()
+			c.mailbox.NumMessages--
+		}
+		c.mutex.Unlock()
+
+		cmd := findPendingCmdByType[*ExpungeCommand](c)
+		if cmd != nil {
+			cmd.seqNums <- num
+		} else if handler := c.options.unilateralDataHandler().Expunge; handler != nil {
+			handler(num)
+		}
+	case "SEARCH":
+		// TODO: handle ESEARCH
+		cmd := findPendingCmdByType[*SearchCommand](c)
+		for c.dec.SP() {
+			var num uint32
+			if !c.dec.ExpectNumber(&num) {
+				return c.dec.Err()
+			}
+			if cmd != nil {
+				cmd.data.All.AddNum(num)
+			}
+		}
+	default:
+		return fmt.Errorf("unsupported response type %q", typ)
+	}
+
+	return nil
+}
+
+// WaitGreeting waits for the server's initial greeting.
+func (c *Client) WaitGreeting() error {
+	<-c.greetingCh
+	return c.greetingErr
+}
+
+// Noop sends a NOOP command.
+func (c *Client) Noop() *Command {
+	cmd := &Command{}
+	c.beginCommand("NOOP", cmd).end()
+	return cmd
+}
+
+// Logout sends a LOGOUT command.
+//
+// This command informs the server that the client is done with the connection.
+func (c *Client) Logout() *Command {
+	cmd := &logoutCommand{}
+	c.beginCommand("LOGOUT", cmd).end()
+	return &cmd.cmd
+}
+
+// Login sends a LOGIN command.
+func (c *Client) Login(username, password string) *Command {
+	cmd := &loginCommand{}
+	enc := c.beginCommand("LOGIN", cmd)
+	enc.SP().String(username).SP().String(password)
+	enc.end()
+	return &cmd.cmd
+}
+
+// Create sends a CREATE command.
+func (c *Client) Create(mailbox string) *Command {
+	cmd := &Command{}
+	enc := c.beginCommand("CREATE", cmd)
+	enc.SP().Mailbox(mailbox)
+	enc.end()
+	return cmd
+}
+
+// Delete sends a DELETE command.
+func (c *Client) Delete(mailbox string) *Command {
+	cmd := &Command{}
+	enc := c.beginCommand("DELETE", cmd)
+	enc.SP().Mailbox(mailbox)
+	enc.end()
+	return cmd
+}
+
+// Rename sends a RENAME command.
+func (c *Client) Rename(mailbox, newName string) *Command {
+	cmd := &Command{}
+	enc := c.beginCommand("RENAME", cmd)
+	enc.SP().Mailbox(mailbox).SP().Mailbox(newName)
+	enc.end()
+	return cmd
+}
+
+// Subscribe sends a SUBSCRIBE command.
+func (c *Client) Subscribe(mailbox string) *Command {
+	cmd := &Command{}
+	enc := c.beginCommand("SUBSCRIBE", cmd)
+	enc.SP().Mailbox(mailbox)
+	enc.end()
+	return cmd
+}
+
+// Subscribe sends an UNSUBSCRIBE command.
+func (c *Client) Unsubscribe(mailbox string) *Command {
+	cmd := &Command{}
+	enc := c.beginCommand("UNSUBSCRIBE", cmd)
+	enc.SP().Mailbox(mailbox)
+	enc.end()
+	return cmd
+}
+
+func uidCmdName(name string, uid bool) string {
+	if uid {
+		return "UID " + name
+	} else {
+		return name
+	}
+}
+
+type commandEncoder struct {
+	*imapwire.Encoder
+	client *Client
+	cmd    *Command
+}
+
+// end ends an outgoing command.
+//
+// A CRLF is written, the encoder is flushed and its lock is released.
+func (ce *commandEncoder) end() {
+	if ce.Encoder != nil {
+		ce.flush()
+	}
+	ce.client.setWriteTimeout(0)
+	ce.client.encMutex.Unlock()
+}
+
+// flush sends an outgoing command, but keeps the encoder lock.
+//
+// A CRLF is written and the encoder is flushed. Callers must call
+// commandEncoder.end to release the lock.
+func (ce *commandEncoder) flush() {
+	if err := ce.Encoder.CRLF(); err != nil {
+		ce.cmd.err = err
+	}
+	ce.Encoder = nil
+}
+
+// Literal encodes a literal.
+func (ce *commandEncoder) Literal(size int64) io.WriteCloser {
+	var contReq *imapwire.ContinuationRequest
+	if size > 4096 || !ce.client.Caps().Has(imap.CapLiteralMinus) {
+		contReq = ce.client.registerContReq(ce.cmd)
+	}
+	ce.client.setWriteTimeout(literalWriteTimeout)
+	return literalWriter{
+		WriteCloser: ce.Encoder.Literal(size, contReq),
+		client:      ce.client,
+	}
+}
+
+type literalWriter struct {
+	io.WriteCloser
+	client *Client
+}
+
+func (lw literalWriter) Close() error {
+	lw.client.setWriteTimeout(respWriteTimeout)
+	return lw.WriteCloser.Close()
+}
+
+// continuationRequest is a pending continuation request.
+type continuationRequest struct {
+	*imapwire.ContinuationRequest
+	cmd *Command
+}
+
+// UnilateralDataMailbox describes a mailbox status update.
+//
+// If a field is nil, it hasn't changed.
+type UnilateralDataMailbox struct {
+	NumMessages    *uint32
+	Flags          []imap.Flag
+	PermanentFlags []imap.Flag
+}
+
+// UnilateralDataHandler handles unilateral data.
+//
+// The handler will block the client while running. If the caller intends to
+// perform slow operations, a buffered channel and a separate goroutine should
+// be used.
+//
+// The handler will be invoked in an arbitrary goroutine.
+//
+// See Options.UnilateralDataHandler.
+type UnilateralDataHandler struct {
+	Expunge func(seqNum uint32)
+	Mailbox func(data *UnilateralDataMailbox)
+	Fetch   func(msg *FetchMessageData)
+}
+
+// command is an interface for IMAP commands.
+//
+// Commands are represented by the Command type, but can be extended by other
+// types (e.g. CapabilityCommand).
+type command interface {
+	base() *Command
+}
+
+// Command is a basic IMAP command.
+type Command struct {
+	tag  string
+	done chan error
+	err  error
+}
+
+func (cmd *Command) base() *Command {
+	return cmd
+}
+
+// Wait blocks until the command has completed.
+func (cmd *Command) Wait() error {
+	if cmd.err == nil {
+		cmd.err = <-cmd.done
+	}
+	return cmd.err
+}
+
+type cmd = Command // type alias to avoid exporting anonymous struct fields
+
+type loginCommand struct {
+	cmd
+}
+
+// logoutCommand is a LOGOUT command.
+type logoutCommand struct {
+	cmd
+}
+
+func readFlagList(dec *imapwire.Decoder) ([]imap.Flag, error) {
+	var flags []imap.Flag
+	err := dec.ExpectList(func() error {
+		flag, err := readFlag(dec)
+		if err != nil {
+			return err
+		}
+		flags = append(flags, imap.Flag(flag))
+		return nil
+	})
+	return flags, err
+}
+
+func readFlag(dec *imapwire.Decoder) (string, error) {
+	isSystem := dec.Special('\\')
+	if isSystem && dec.Special('*') {
+		return "\\*", nil // flag-perm
+	}
+	var name string
+	if !dec.ExpectAtom(&name) {
+		return "", fmt.Errorf("in flag: %v", dec.Err())
+	}
+	if isSystem {
+		name = "\\" + name
+	}
+	return name, nil
+}
diff --git a/imapclient/copy.go b/imapclient/copy.go
new file mode 100644
index 0000000..f80b384
--- /dev/null
+++ b/imapclient/copy.go
@@ -0,0 +1,60 @@
+package imapclient
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Client) copy(uid bool, seqSet imap.SeqSet, mailbox string) *CopyCommand {
+	cmd := &CopyCommand{}
+	enc := c.beginCommand(uidCmdName("COPY", uid), cmd)
+	enc.SP().Atom(seqSet.String()).SP().Mailbox(mailbox)
+	enc.end()
+	return cmd
+}
+
+// Copy sends a COPY command.
+func (c *Client) Copy(seqSet imap.SeqSet, mailbox string) *CopyCommand {
+	return c.copy(false, seqSet, mailbox)
+}
+
+// UIDCopy sends a UID COPY command.
+//
+// See Copy.
+func (c *Client) UIDCopy(seqSet imap.SeqSet, mailbox string) *CopyCommand {
+	return c.copy(true, seqSet, mailbox)
+}
+
+// CopyCommand is a COPY command.
+type CopyCommand struct {
+	cmd
+	data CopyData
+}
+
+func (cmd *CopyCommand) Wait() (*CopyData, error) {
+	return &cmd.data, cmd.cmd.Wait()
+}
+
+// CopyData is the data returned by a COPY command.
+type CopyData struct {
+	// requires UIDPLUS or IMAP4rev2
+	UIDValidity uint32
+	SourceUIDs  imap.SeqSet
+	DestUIDs    imap.SeqSet
+}
+
+func readRespCodeCopy(dec *imapwire.Decoder) (uidValidity uint32, srcUIDs, dstUIDs imap.SeqSet, err error) {
+	var srcStr, dstStr string
+	if !dec.ExpectNumber(&uidValidity) || !dec.ExpectSP() || !dec.ExpectAtom(&srcStr) || !dec.ExpectSP() || !dec.ExpectAtom(&dstStr) {
+		return 0, imap.SeqSet{}, imap.SeqSet{}, dec.Err()
+	}
+	srcUIDs, err = imap.ParseSeqSet(srcStr)
+	if err != nil {
+		return 0, imap.SeqSet{}, imap.SeqSet{}, dec.Err()
+	}
+	dstUIDs, err = imap.ParseSeqSet(dstStr)
+	if err != nil {
+		return 0, imap.SeqSet{}, imap.SeqSet{}, dec.Err()
+	}
+	return uidValidity, srcUIDs, dstUIDs, nil
+}
diff --git a/imapclient/enable.go b/imapclient/enable.go
new file mode 100644
index 0000000..5ed3939
--- /dev/null
+++ b/imapclient/enable.go
@@ -0,0 +1,34 @@
+package imapclient
+
+import (
+	"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 {
+	cmd := &EnableCommand{}
+	enc := c.beginCommand("ENABLE", cmd)
+	for _, c := range caps {
+		enc.SP().Atom(string(c))
+	}
+	enc.end()
+	return cmd
+}
+
+// EnableCommand is an ENABLE command.
+type EnableCommand struct {
+	cmd
+	data EnableData
+}
+
+func (cmd *EnableCommand) Wait() (*EnableData, error) {
+	return &cmd.data, cmd.cmd.Wait()
+}
+
+// EnableData is the data returned by the ENABLE command.
+type EnableData struct {
+	// Capabilities that were successfully enabled
+	Caps imap.CapSet
+}
diff --git a/imapclient/expunge.go b/imapclient/expunge.go
new file mode 100644
index 0000000..c79a3a6
--- /dev/null
+++ b/imapclient/expunge.go
@@ -0,0 +1,66 @@
+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.SeqSet) *ExpungeCommand {
+	cmd := &ExpungeCommand{seqNums: make(chan uint32, 128)}
+	enc := c.beginCommand("UID EXPUNGE", cmd)
+	enc.SP().Atom(uids.String())
+	enc.end()
+	return cmd
+}
+
+// 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 {
+	cmd
+	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.cmd.Wait()
+}
+
+// Collect accumulates expunged sequence numbers into a list.
+//
+// This is equivalent to calling Next repeatedly and then Close.
+func (cmd *ExpungeCommand) Collect() ([]uint32, error) {
+	var l []uint32
+	for {
+		seqNum := cmd.Next()
+		if seqNum == 0 {
+			break
+		}
+		l = append(l, seqNum)
+	}
+	return l, cmd.Close()
+}
diff --git a/imapclient/fetch.go b/imapclient/fetch.go
new file mode 100644
index 0000000..815ae84
--- /dev/null
+++ b/imapclient/fetch.go
@@ -0,0 +1,1360 @@
+package imapclient
+
+import (
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Client) fetch(uid bool, seqSet imap.SeqSet, items []FetchItem) *FetchCommand {
+	// Ensure we request UID as the first data item for UID FETCH, to be safer.
+	// We want to get it before any literal.
+	if uid {
+		itemsWithUID := []FetchItem{FetchItemUID}
+		for _, item := range items {
+			if item != FetchItemUID {
+				itemsWithUID = append(itemsWithUID, item)
+			}
+		}
+		items = itemsWithUID
+	}
+
+	cmd := &FetchCommand{
+		uid:    uid,
+		seqSet: seqSet,
+		msgs:   make(chan *FetchMessageData, 128),
+	}
+	enc := c.beginCommand(uidCmdName("FETCH", uid), cmd)
+	enc.SP().Atom(seqSet.String()).SP().List(len(items), func(i int) {
+		writeFetchItem(enc.Encoder, items[i])
+	})
+	enc.end()
+	return cmd
+}
+
+// Fetch sends a FETCH command.
+//
+// The caller must fully consume the FetchCommand. A simple way to do so is to
+// defer a call to FetchCommand.Close.
+func (c *Client) Fetch(seqSet imap.SeqSet, items []FetchItem) *FetchCommand {
+	return c.fetch(false, seqSet, items)
+}
+
+// UIDFetch sends a UID FETCH command.
+//
+// See Fetch.
+func (c *Client) UIDFetch(seqSet imap.SeqSet, items []FetchItem) *FetchCommand {
+	return c.fetch(true, seqSet, items)
+}
+
+// FetchItem is a message data item which can be requested by a FETCH command.
+type FetchItem interface {
+	fetchItem()
+}
+
+var (
+	_ FetchItem = FetchItemKeyword("")
+	_ FetchItem = (*FetchItemBodySection)(nil)
+	_ FetchItem = (*FetchItemBinarySection)(nil)
+	_ FetchItem = (*FetchItemBinarySectionSize)(nil)
+)
+
+// FetchItemKeyword is a FETCH item described by a single keyword.
+type FetchItemKeyword string
+
+func (FetchItemKeyword) fetchItem() {}
+
+var (
+	// Macros
+	FetchItemAll  FetchItem = FetchItemKeyword("ALL")
+	FetchItemFast FetchItem = FetchItemKeyword("FAST")
+	FetchItemFull FetchItem = FetchItemKeyword("FULL")
+
+	FetchItemBody          FetchItem = FetchItemKeyword("BODY")
+	FetchItemBodyStructure FetchItem = FetchItemKeyword("BODYSTRUCTURE")
+	FetchItemEnvelope      FetchItem = FetchItemKeyword("ENVELOPE")
+	FetchItemFlags         FetchItem = FetchItemKeyword("FLAGS")
+	FetchItemInternalDate  FetchItem = FetchItemKeyword("INTERNALDATE")
+	FetchItemRFC822Size    FetchItem = FetchItemKeyword("RFC822.SIZE")
+	FetchItemUID           FetchItem = FetchItemKeyword("UID")
+)
+
+type PartSpecifier string
+
+const (
+	PartSpecifierNone   PartSpecifier = ""
+	PartSpecifierHeader PartSpecifier = "HEADER"
+	PartSpecifierMIME   PartSpecifier = "MIME"
+	PartSpecifierText   PartSpecifier = "TEXT"
+)
+
+type SectionPartial struct {
+	Offset, Size int64
+}
+
+// FetchItemBodySection is a FETCH BODY[] data item.
+type FetchItemBodySection struct {
+	Specifier       PartSpecifier
+	Part            []int
+	HeaderFields    []string
+	HeaderFieldsNot []string
+	Partial         *SectionPartial
+	Peek            bool
+}
+
+func (*FetchItemBodySection) fetchItem() {}
+
+// FetchItemBinarySection is a FETCH BINARY[] data item.
+type FetchItemBinarySection struct {
+	Part    []int
+	Partial *SectionPartial
+	Peek    bool
+}
+
+func (*FetchItemBinarySection) fetchItem() {}
+
+// FetchItemBinarySectionSize is a FETCH BINARY.SIZE[] data item.
+type FetchItemBinarySectionSize struct {
+	Part []int
+}
+
+func (*FetchItemBinarySectionSize) fetchItem() {}
+
+func writeFetchItem(enc *imapwire.Encoder, item FetchItem) {
+	switch item := item.(type) {
+	case FetchItemKeyword:
+		enc.Atom(string(item))
+	case *FetchItemBodySection:
+		enc.Atom("BODY")
+		if item.Peek {
+			enc.Atom(".PEEK")
+		}
+		enc.Special('[')
+		writeSectionPart(enc, item.Part)
+		if len(item.Part) > 0 && item.Specifier != PartSpecifierNone {
+			enc.Special('.')
+		}
+		if item.Specifier != PartSpecifierNone {
+			enc.Atom(string(item.Specifier))
+
+			var headerList []string
+			if len(item.HeaderFields) > 0 {
+				headerList = item.HeaderFields
+				enc.Atom(".FIELDS")
+			} else if len(item.HeaderFieldsNot) > 0 {
+				headerList = item.HeaderFieldsNot
+				enc.Atom(".FIELDS.NOT")
+			}
+
+			if len(headerList) > 0 {
+				enc.SP().List(len(headerList), func(i int) {
+					enc.String(headerList[i])
+				})
+			}
+		}
+		enc.Special(']')
+		writeSectionPartial(enc, item.Partial)
+	case *FetchItemBinarySection:
+		enc.Atom("BINARY")
+		if item.Peek {
+			enc.Atom(".PEEK")
+		}
+		enc.Special('[')
+		writeSectionPart(enc, item.Part)
+		enc.Special(']')
+		writeSectionPartial(enc, item.Partial)
+	case *FetchItemBinarySectionSize:
+		enc.Atom("BINARY.SIZE")
+		enc.Special('[')
+		writeSectionPart(enc, item.Part)
+		enc.Special(']')
+	default:
+		panic(fmt.Errorf("imapclient: unknown fetch item type %T", item))
+	}
+}
+
+func writeSectionPart(enc *imapwire.Encoder, part []int) {
+	if len(part) == 0 {
+		return
+	}
+
+	var l []string
+	for _, num := range part {
+		l = append(l, fmt.Sprintf("%v", num))
+	}
+	enc.Atom(strings.Join(l, "."))
+}
+
+func writeSectionPartial(enc *imapwire.Encoder, partial *SectionPartial) {
+	if partial == nil {
+		return
+	}
+	enc.Special('<').Number64(partial.Offset).Special('.').Number64(partial.Size).Special('>')
+}
+
+// FetchCommand is a FETCH command.
+type FetchCommand struct {
+	cmd
+
+	uid        bool
+	seqSet     imap.SeqSet
+	recvSeqSet imap.SeqSet
+
+	msgs chan *FetchMessageData
+	prev *FetchMessageData
+}
+
+// Next advances to the next message.
+//
+// On success, the message is returned. On error or if there are no more
+// messages, nil is returned. To check the error value, use Close.
+func (cmd *FetchCommand) Next() *FetchMessageData {
+	if cmd.prev != nil {
+		cmd.prev.discard()
+	}
+	return <-cmd.msgs
+}
+
+// Close releases the command.
+//
+// Calling Close unblocks the IMAP client decoder and lets it read the next
+// responses. Next will always return nil after Close.
+func (cmd *FetchCommand) Close() error {
+	for cmd.Next() != nil {
+		// ignore
+	}
+	return cmd.cmd.Wait()
+}
+
+// Collect accumulates message data into a list.
+//
+// This method will read and store message contents in memory. This is
+// acceptable when the message contents have a reasonable size, but may not be
+// suitable when fetching e.g. attachments.
+//
+// This is equivalent to calling Next repeatedly and then Close.
+func (cmd *FetchCommand) Collect() ([]*FetchMessageBuffer, error) {
+	defer cmd.Close()
+
+	var l []*FetchMessageBuffer
+	for {
+		msg := cmd.Next()
+		if msg == nil {
+			break
+		}
+
+		buf, err := msg.Collect()
+		if err != nil {
+			return l, err
+		}
+
+		l = append(l, buf)
+	}
+	return l, cmd.Close()
+}
+
+// FetchMessageData contains a message's FETCH data.
+type FetchMessageData struct {
+	SeqNum uint32
+
+	items chan FetchItemData
+	prev  FetchItemData
+}
+
+// Next advances to the next data item for this message.
+//
+// If there is one or more data items left, the next item is returned.
+// Otherwise nil is returned.
+func (data *FetchMessageData) Next() FetchItemData {
+	if d, ok := data.prev.(discarder); ok {
+		d.discard()
+	}
+
+	item := <-data.items
+	data.prev = item
+	return item
+}
+
+func (data *FetchMessageData) discard() {
+	for {
+		if item := data.Next(); item == nil {
+			break
+		}
+	}
+}
+
+// Collect accumulates message data into a struct.
+//
+// This method will read and store message contents in memory. This is
+// acceptable when the message contents have a reasonable size, but may not be
+// suitable when fetching e.g. attachments.
+func (data *FetchMessageData) Collect() (*FetchMessageBuffer, error) {
+	defer data.discard()
+
+	buf := &FetchMessageBuffer{SeqNum: data.SeqNum}
+	for {
+		item := data.Next()
+		if item == nil {
+			break
+		}
+		if err := buf.populateItemData(item); err != nil {
+			return buf, err
+		}
+	}
+	return buf, nil
+}
+
+// FetchItemData contains a message's FETCH item data.
+type FetchItemData interface {
+	fetchItemData()
+}
+
+var (
+	_ FetchItemData = FetchItemDataBodySection{}
+	_ FetchItemData = FetchItemDataBinarySection{}
+	_ FetchItemData = FetchItemDataFlags{}
+	_ FetchItemData = FetchItemDataEnvelope{}
+	_ FetchItemData = FetchItemDataInternalDate{}
+	_ FetchItemData = FetchItemDataRFC822Size{}
+	_ FetchItemData = FetchItemDataUID{}
+	_ FetchItemData = FetchItemDataBodyStructure{}
+)
+
+type discarder interface {
+	discard()
+}
+
+var (
+	_ discarder = FetchItemDataBodySection{}
+	_ discarder = FetchItemDataBinarySection{}
+)
+
+// FetchItemDataBodySection holds data returned by FETCH BODY[].
+type FetchItemDataBodySection struct {
+	Section *FetchItemBodySection
+	Literal LiteralReader
+}
+
+func (FetchItemDataBodySection) fetchItemData() {}
+
+func (item FetchItemDataBodySection) discard() {
+	io.Copy(io.Discard, item.Literal)
+}
+
+// FetchItemDataBinarySection holds data returned by FETCH BINARY[].
+type FetchItemDataBinarySection struct {
+	Section *FetchItemBinarySection
+	Literal LiteralReader
+}
+
+func (FetchItemDataBinarySection) fetchItemData() {}
+
+func (item FetchItemDataBinarySection) discard() {
+	io.Copy(io.Discard, item.Literal)
+}
+
+// FetchItemDataFlags holds data returned by FETCH FLAGS.
+type FetchItemDataFlags struct {
+	Flags []imap.Flag
+}
+
+func (FetchItemDataFlags) fetchItemData() {}
+
+// FetchItemDataEnvelope holds data returned by FETCH ENVELOPE.
+type FetchItemDataEnvelope struct {
+	Envelope *Envelope
+}
+
+func (FetchItemDataEnvelope) fetchItemData() {}
+
+// FetchItemDataInternalDate holds data returned by FETCH INTERNALDATE.
+type FetchItemDataInternalDate struct {
+	Time time.Time
+}
+
+func (FetchItemDataInternalDate) fetchItemData() {}
+
+// FetchItemDataRFC822Size holds data returned by FETCH RFC822.SIZE.
+type FetchItemDataRFC822Size struct {
+	Size int64
+}
+
+func (FetchItemDataRFC822Size) fetchItemData() {}
+
+// FetchItemDataUID holds data returned by FETCH UID.
+type FetchItemDataUID struct {
+	UID uint32
+}
+
+func (FetchItemDataUID) fetchItemData() {}
+
+// FetchItemDataBodyStructure holds data returned by FETCH BODYSTRUCTURE or
+// FETCH BODY.
+type FetchItemDataBodyStructure struct {
+	BodyStructure BodyStructure
+	IsExtended    bool // True if BODYSTRUCTURE, false if BODY
+}
+
+func (FetchItemDataBodyStructure) fetchItemData() {}
+
+// FetchItemDataBinarySectionSize holds data returned by FETCH BINARY.SIZE[].
+type FetchItemDataBinarySectionSize struct {
+	Part []int
+	Size uint32
+}
+
+func (FetchItemDataBinarySectionSize) fetchItemData() {}
+
+// Envelope is the envelope structure of a message.
+type Envelope struct {
+	Date      string // see net/mail.ParseDate
+	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() {}
+
+type BodyStructureMessageRFC822 struct {
+	Envelope      *Envelope
+	BodyStructure BodyStructure
+	NumLines      int64
+}
+
+type BodyStructureText struct {
+	NumLines int64
+}
+
+type BodyStructureSinglePartExt struct {
+	MD5         string
+	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() {}
+
+type BodyStructureMultiPartExt struct {
+	Params      map[string]string
+	Disposition *BodyStructureDisposition
+	Language    []string
+	Location    string
+}
+
+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)
+
+// LiteralReader is a reader for IMAP literals.
+type LiteralReader interface {
+	io.Reader
+	Size() int64
+}
+
+// FetchMessageBuffer is a buffer for the data returned by FetchMessageData.
+//
+// The SeqNum field is always populated. All remaining fields are optional.
+type FetchMessageBuffer struct {
+	SeqNum            uint32
+	Flags             []imap.Flag
+	Envelope          *Envelope
+	InternalDate      time.Time
+	RFC822Size        int64
+	UID               uint32
+	BodyStructure     BodyStructure
+	BodySection       map[*FetchItemBodySection][]byte
+	BinarySection     map[*FetchItemBinarySection][]byte
+	BinarySectionSize []FetchItemDataBinarySectionSize
+}
+
+func (buf *FetchMessageBuffer) populateItemData(item FetchItemData) error {
+	switch item := item.(type) {
+	case FetchItemDataBodySection:
+		b, err := io.ReadAll(item.Literal)
+		if err != nil {
+			return err
+		}
+		if buf.BodySection == nil {
+			buf.BodySection = make(map[*FetchItemBodySection][]byte)
+		}
+		buf.BodySection[item.Section] = b
+	case FetchItemDataBinarySection:
+		b, err := io.ReadAll(item.Literal)
+		if err != nil {
+			return err
+		}
+		if buf.BinarySection == nil {
+			buf.BinarySection = make(map[*FetchItemBinarySection][]byte)
+		}
+		buf.BinarySection[item.Section] = b
+	case FetchItemDataFlags:
+		buf.Flags = item.Flags
+	case FetchItemDataEnvelope:
+		buf.Envelope = item.Envelope
+	case FetchItemDataInternalDate:
+		buf.InternalDate = item.Time
+	case FetchItemDataRFC822Size:
+		buf.RFC822Size = item.Size
+	case FetchItemDataUID:
+		buf.UID = item.UID
+	case FetchItemDataBodyStructure:
+		buf.BodyStructure = item.BodyStructure
+	case FetchItemDataBinarySectionSize:
+		buf.BinarySectionSize = append(buf.BinarySectionSize, item)
+	default:
+		panic(fmt.Errorf("unsupported fetch item data %T", item))
+	}
+	return nil
+}
+
+func readMsgAtt(c *Client, seqNum uint32) error {
+	dec := c.dec
+
+	items := make(chan FetchItemData, 32)
+	defer close(items)
+
+	msg := &FetchMessageData{SeqNum: seqNum, items: items}
+
+	// We're in a tricky situation: to know whether this FETCH response needs
+	// to be handled by a pending command, we may need to look at the UID in
+	// the response data. But the response data comes in in a streaming
+	// fashion: it can contain literals. Assume that the UID will be returned
+	// before any literal.
+	var uid uint32
+	handled := false
+	handleMsg := func() {
+		if handled {
+			return
+		}
+
+		cmd := c.findPendingCmdFunc(func(anyCmd command) bool {
+			cmd, ok := anyCmd.(*FetchCommand)
+			if !ok {
+				return false
+			}
+
+			// Skip if we haven't requested or already handled this message
+			var num uint32
+			if cmd.uid {
+				num = uid
+			} else {
+				num = seqNum
+			}
+			if num == 0 || !cmd.seqSet.Contains(num) || cmd.recvSeqSet.Contains(num) {
+				return false
+			}
+			cmd.recvSeqSet.AddNum(num)
+
+			return true
+		})
+		if cmd != nil {
+			cmd := cmd.(*FetchCommand)
+			cmd.msgs <- msg
+		} else if handler := c.options.unilateralDataHandler().Fetch; handler != nil {
+			go handler(msg)
+		} else {
+			go msg.discard()
+		}
+
+		handled = true
+	}
+	defer handleMsg()
+
+	numAtts := 0
+	return dec.ExpectList(func() error {
+		var attName string
+		if !dec.Expect(dec.Func(&attName, isMsgAttNameChar), "msg-att name") {
+			return dec.Err()
+		}
+
+		var (
+			item FetchItemData
+			done chan struct{}
+		)
+		switch attName := FetchItemKeyword(attName); attName {
+		case FetchItemFlags:
+			if !dec.ExpectSP() {
+				return dec.Err()
+			}
+
+			flags, err := readFlagList(dec)
+			if err != nil {
+				return err
+			}
+
+			item = FetchItemDataFlags{Flags: flags}
+		case FetchItemEnvelope:
+			if !dec.ExpectSP() {
+				return dec.Err()
+			}
+
+			envelope, err := readEnvelope(dec, &c.options)
+			if err != nil {
+				return fmt.Errorf("in envelope: %v", err)
+			}
+
+			item = FetchItemDataEnvelope{Envelope: envelope}
+		case FetchItemInternalDate:
+			if !dec.ExpectSP() {
+				return dec.Err()
+			}
+
+			t, err := readDateTime(dec)
+			if err != nil {
+				return err
+			}
+
+			item = FetchItemDataInternalDate{Time: t}
+		case FetchItemRFC822Size:
+			var size int64
+			if !dec.ExpectSP() || !dec.ExpectNumber64(&size) {
+				return dec.Err()
+			}
+
+			item = FetchItemDataRFC822Size{Size: size}
+		case FetchItemUID:
+			if !dec.ExpectSP() || !dec.ExpectNumber(&uid) {
+				return dec.Err()
+			}
+
+			item = FetchItemDataUID{UID: uid}
+		case "BODY", "BINARY":
+			if dec.Special('[') {
+				var section FetchItem
+				switch attName {
+				case "BODY":
+					var err error
+					section, err = readSectionSpec(dec)
+					if err != nil {
+						return fmt.Errorf("in section-spec: %v", err)
+					}
+				case "BINARY":
+					part, dot := readSectionPart(dec)
+					if dot {
+						return fmt.Errorf("in section-binary: expected number after dot")
+					}
+					if !dec.ExpectSpecial(']') {
+						return dec.Err()
+					}
+					section = &FetchItemBinarySection{Part: part}
+				}
+
+				if !dec.ExpectSP() {
+					return dec.Err()
+				}
+
+				lit, _, ok := dec.ExpectNStringReader()
+				if !ok {
+					return dec.Err()
+				}
+
+				var fetchLit LiteralReader
+				if lit != nil {
+					done = make(chan struct{})
+					fetchLit = &fetchLiteralReader{
+						LiteralReader: lit,
+						ch:            done,
+					}
+				}
+
+				switch section := section.(type) {
+				case *FetchItemBodySection:
+					item = FetchItemDataBodySection{
+						Section: section,
+						Literal: fetchLit,
+					}
+				case *FetchItemBinarySection:
+					item = FetchItemDataBinarySection{
+						Section: section,
+						Literal: fetchLit,
+					}
+				}
+				break
+			}
+			if !dec.Expect(attName == "BODY", "'['") {
+				return dec.Err()
+			}
+			fallthrough
+		case FetchItemBodyStructure:
+			if !dec.ExpectSP() {
+				return dec.Err()
+			}
+
+			bodyStruct, err := readBody(dec, &c.options)
+			if err != nil {
+				return err
+			}
+
+			item = FetchItemDataBodyStructure{
+				BodyStructure: bodyStruct,
+				IsExtended:    attName == FetchItemBodyStructure,
+			}
+		case "BINARY.SIZE":
+			part, dot := readSectionPart(dec)
+			if dot {
+				return fmt.Errorf("in section-binary: expected number after dot")
+			}
+
+			var size uint32
+			if !dec.ExpectSpecial(']') || !dec.ExpectSP() || !dec.ExpectNumber(&size) {
+				return dec.Err()
+			}
+
+			item = FetchItemDataBinarySectionSize{
+				Part: part,
+				Size: size,
+			}
+		default:
+			return fmt.Errorf("unsupported msg-att name: %q", attName)
+		}
+
+		numAtts++
+		if numAtts > cap(items) || done != nil {
+			// To avoid deadlocking we need to ask the message handler to
+			// consume the data
+			handleMsg()
+		}
+
+		if done != nil {
+			c.setReadTimeout(literalReadTimeout)
+		}
+		items <- item
+		if done != nil {
+			<-done
+			c.setReadTimeout(respReadTimeout)
+		}
+		return nil
+	})
+}
+
+func isMsgAttNameChar(ch byte) bool {
+	return ch != '[' && imapwire.IsAtomChar(ch)
+}
+
+func readEnvelope(dec *imapwire.Decoder, options *Options) (*Envelope, error) {
+	var envelope Envelope
+
+	if !dec.ExpectSpecial('(') {
+		return nil, dec.Err()
+	}
+
+	var subject string
+	if !dec.ExpectNString(&envelope.Date) || !dec.ExpectSP() || !dec.ExpectNString(&subject) || !dec.ExpectSP() {
+		return nil, dec.Err()
+	}
+	// TODO: handle error
+	envelope.Subject, _ = options.decodeText(subject)
+
+	addrLists := []struct {
+		name string
+		out  *[]Address
+	}{
+		{"env-from", &envelope.From},
+		{"env-sender", &envelope.Sender},
+		{"env-reply-to", &envelope.ReplyTo},
+		{"env-to", &envelope.To},
+		{"env-cc", &envelope.Cc},
+		{"env-bcc", &envelope.Bcc},
+	}
+	for _, addrList := range addrLists {
+		l, err := readAddressList(dec, options)
+		if err != nil {
+			return nil, fmt.Errorf("in %v: %v", addrList.name, err)
+		} else if !dec.ExpectSP() {
+			return nil, dec.Err()
+		}
+		*addrList.out = l
+	}
+
+	if !dec.ExpectNString(&envelope.InReplyTo) || !dec.ExpectSP() || !dec.ExpectNString(&envelope.MessageID) {
+		return nil, dec.Err()
+	}
+
+	if !dec.ExpectSpecial(')') {
+		return nil, dec.Err()
+	}
+	return &envelope, nil
+}
+
+func readAddressList(dec *imapwire.Decoder, options *Options) ([]Address, error) {
+	var l []Address
+	err := dec.ExpectNList(func() error {
+		addr, err := readAddress(dec, options)
+		if err != nil {
+			return err
+		}
+		l = append(l, *addr)
+		return nil
+	})
+	return l, err
+}
+
+func readAddress(dec *imapwire.Decoder, options *Options) (*Address, error) {
+	var (
+		addr     Address
+		name     string
+		obsRoute string
+	)
+	ok := dec.ExpectSpecial('(') &&
+		dec.ExpectNString(&name) && dec.ExpectSP() &&
+		dec.ExpectNString(&obsRoute) && dec.ExpectSP() &&
+		dec.ExpectNString(&addr.Mailbox) && dec.ExpectSP() &&
+		dec.ExpectNString(&addr.Host) && dec.ExpectSpecial(')')
+	if !ok {
+		return nil, fmt.Errorf("in address: %v", dec.Err())
+	}
+	// TODO: handle error
+	addr.Name, _ = options.decodeText(name)
+	return &addr, nil
+}
+
+func readDateTime(dec *imapwire.Decoder) (time.Time, error) {
+	var s string
+	if !dec.Expect(dec.Quoted(&s), "date-time") {
+		return time.Time{}, dec.Err()
+	}
+	t, err := time.Parse(dateTimeLayout, s)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("in date-time: %v", err)
+	}
+	return t, err
+}
+
+func readBody(dec *imapwire.Decoder, options *Options) (BodyStructure, error) {
+	if !dec.ExpectSpecial('(') {
+		return nil, dec.Err()
+	}
+
+	var (
+		mediaType string
+		token     string
+		bs        BodyStructure
+		err       error
+	)
+	if dec.String(&mediaType) {
+		token = "body-type-1part"
+		bs, err = readBodyType1part(dec, mediaType, options)
+	} else {
+		token = "body-type-mpart"
+		bs, err = readBodyTypeMpart(dec, options)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("in %v: %v", token, err)
+	}
+
+	// TODO: skip all unread fields until ')'
+	if !dec.ExpectSpecial(')') {
+		return nil, dec.Err()
+	}
+
+	return bs, nil
+}
+
+func readBodyType1part(dec *imapwire.Decoder, typ string, options *Options) (*BodyStructureSinglePart, error) {
+	bs := BodyStructureSinglePart{Type: typ}
+
+	if !dec.ExpectSP() || !dec.ExpectString(&bs.Subtype) || !dec.ExpectSP() {
+		return nil, dec.Err()
+	}
+
+	var err error
+	bs.Params, err = readBodyFldParam(dec)
+	if err != nil {
+		return nil, err
+	}
+	if name, ok := bs.Params["name"]; ok {
+		// TODO: handle error
+		bs.Params["name"], _ = options.decodeText(name)
+	}
+
+	var description string
+	if !dec.ExpectSP() || !dec.ExpectNString(&bs.ID) || !dec.ExpectSP() || !dec.ExpectNString(&description) || !dec.ExpectSP() || !dec.ExpectString(&bs.Encoding) || !dec.ExpectSP() || !dec.ExpectNumber(&bs.Size) {
+		return nil, dec.Err()
+	}
+	// TODO: handle errors
+	bs.Description, _ = options.decodeText(description)
+
+	if strings.EqualFold(bs.Type, "message") && (strings.EqualFold(bs.Subtype, "rfc822") || strings.EqualFold(bs.Subtype, "global")) {
+		var msg BodyStructureMessageRFC822
+
+		if !dec.ExpectSP() {
+			return nil, dec.Err()
+		}
+
+		msg.Envelope, err = readEnvelope(dec, options)
+		if err != nil {
+			return nil, err
+		}
+
+		if !dec.ExpectSP() {
+			return nil, dec.Err()
+		}
+
+		msg.BodyStructure, err = readBody(dec, options)
+		if err != nil {
+			return nil, err
+		}
+
+		if !dec.ExpectSP() || !dec.ExpectNumber64(&msg.NumLines) {
+			return nil, dec.Err()
+		}
+
+		bs.MessageRFC822 = &msg
+	} else if strings.EqualFold(bs.Type, "text") {
+		var text BodyStructureText
+
+		if !dec.ExpectSP() || !dec.ExpectNumber64(&text.NumLines) {
+			return nil, dec.Err()
+		}
+
+		bs.Text = &text
+	}
+
+	if dec.SP() {
+		bs.Extended, err = readBodyExt1part(dec, options)
+		if err != nil {
+			return nil, fmt.Errorf("in body-ext-1part: %v", err)
+		}
+	}
+
+	return &bs, nil
+}
+
+func readBodyExt1part(dec *imapwire.Decoder, options *Options) (*BodyStructureSinglePartExt, error) {
+	var ext BodyStructureSinglePartExt
+
+	if !dec.ExpectNString(&ext.MD5) {
+		return nil, dec.Err()
+	}
+
+	if !dec.SP() {
+		return &ext, nil
+	}
+
+	var err error
+	ext.Disposition, err = readBodyFldDsp(dec, options)
+	if err != nil {
+		return nil, fmt.Errorf("in body-fld-dsp: %v", err)
+	}
+
+	if !dec.SP() {
+		return &ext, nil
+	}
+
+	ext.Language, err = readBodyFldLang(dec)
+	if err != nil {
+		return nil, fmt.Errorf("in body-fld-lang: %v", err)
+	}
+
+	if !dec.SP() {
+		return &ext, nil
+	}
+
+	if !dec.ExpectNString(&ext.Location) {
+		return nil, dec.Err()
+	}
+
+	return &ext, nil
+}
+
+func readBodyTypeMpart(dec *imapwire.Decoder, options *Options) (*BodyStructureMultiPart, error) {
+	var bs BodyStructureMultiPart
+
+	for {
+		child, err := readBody(dec, options)
+		if err != nil {
+			return nil, err
+		}
+		bs.Children = append(bs.Children, child)
+
+		if dec.SP() && dec.String(&bs.Subtype) {
+			break
+		}
+	}
+
+	if dec.SP() {
+		var err error
+		bs.Extended, err = readBodyExtMpart(dec, options)
+		if err != nil {
+			return nil, fmt.Errorf("in body-ext-mpart: %v", err)
+		}
+	}
+
+	return &bs, nil
+}
+
+func readBodyExtMpart(dec *imapwire.Decoder, options *Options) (*BodyStructureMultiPartExt, error) {
+	var ext BodyStructureMultiPartExt
+
+	var err error
+	ext.Params, err = readBodyFldParam(dec)
+	if err != nil {
+		return nil, fmt.Errorf("in body-fld-param: %v", err)
+	}
+
+	if !dec.SP() {
+		return &ext, nil
+	}
+
+	ext.Disposition, err = readBodyFldDsp(dec, options)
+	if err != nil {
+		return nil, fmt.Errorf("in body-fld-dsp: %v", err)
+	}
+
+	if !dec.SP() {
+		return &ext, nil
+	}
+
+	ext.Language, err = readBodyFldLang(dec)
+	if err != nil {
+		return nil, fmt.Errorf("in body-fld-lang: %v", err)
+	}
+
+	if !dec.SP() {
+		return &ext, nil
+	}
+
+	if !dec.ExpectNString(&ext.Location) {
+		return nil, dec.Err()
+	}
+
+	return &ext, nil
+}
+
+func readBodyFldDsp(dec *imapwire.Decoder, options *Options) (*BodyStructureDisposition, error) {
+	if !dec.Special('(') {
+		if !dec.ExpectNIL() {
+			return nil, dec.Err()
+		}
+		return nil, nil
+	}
+
+	var disp BodyStructureDisposition
+	if !dec.ExpectString(&disp.Value) || !dec.ExpectSP() {
+		return nil, dec.Err()
+	}
+
+	var err error
+	disp.Params, err = readBodyFldParam(dec)
+	if err != nil {
+		return nil, err
+	}
+	if filename, ok := disp.Params["filename"]; ok {
+		// TODO: handle error
+		disp.Params["filename"], _ = options.decodeText(filename)
+	}
+
+	if !dec.ExpectSpecial(')') {
+		return nil, dec.Err()
+	}
+	return &disp, nil
+}
+
+func readBodyFldParam(dec *imapwire.Decoder) (map[string]string, error) {
+	var (
+		params map[string]string
+		k      string
+	)
+	err := dec.ExpectNList(func() error {
+		var s string
+		if !dec.ExpectString(&s) {
+			return dec.Err()
+		}
+
+		if k == "" {
+			k = s
+		} else {
+			if params == nil {
+				params = make(map[string]string)
+			}
+			params[k] = s
+			k = ""
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	} else if k != "" {
+		return nil, fmt.Errorf("in body-fld-param: key without value")
+	}
+	return params, nil
+}
+
+func readBodyFldLang(dec *imapwire.Decoder) ([]string, error) {
+	var l []string
+	isList, err := dec.List(func() error {
+		var s string
+		if !dec.ExpectString(&s) {
+			return dec.Err()
+		}
+		l = append(l, s)
+		return nil
+	})
+	if err != nil || isList {
+		return l, err
+	}
+
+	var s string
+	if !dec.ExpectNString(&s) {
+		return nil, dec.Err()
+	}
+	if s != "" {
+		return []string{s}, nil
+	} else {
+		return nil, nil
+	}
+}
+
+func readSectionSpec(dec *imapwire.Decoder) (*FetchItemBodySection, error) {
+	var section FetchItemBodySection
+
+	var dot bool
+	section.Part, dot = readSectionPart(dec)
+	if dot {
+		var specifier string
+		if !dec.ExpectAtom(&specifier) {
+			return nil, dec.Err()
+		}
+		section.Specifier = PartSpecifier(specifier)
+
+		if specifier == "HEADER.FIELDS" || specifier == "HEADER.FIELDS.NOT" {
+			if !dec.ExpectSP() {
+				return nil, dec.Err()
+			}
+			var err error
+			headerList, err := readHeaderList(dec)
+			if err != nil {
+				return nil, err
+			}
+			if specifier == "HEADER.FIELDS" {
+				section.HeaderFields = headerList
+			} else {
+				section.HeaderFieldsNot = headerList
+			}
+		}
+	}
+
+	if !dec.ExpectSpecial(']') {
+		return nil, dec.Err()
+	}
+
+	offset, err := readPartialOffset(dec)
+	if err != nil {
+		return nil, err
+	}
+	if offset != nil {
+		section.Partial = &SectionPartial{Offset: int64(*offset)}
+	}
+
+	return &section, nil
+}
+
+func readPartialOffset(dec *imapwire.Decoder) (*uint32, error) {
+	if !dec.Special('<') {
+		return nil, nil
+	}
+	var offset uint32
+	if !dec.ExpectNumber(&offset) || !dec.ExpectSpecial('>') {
+		return nil, dec.Err()
+	}
+	return &offset, nil
+}
+
+func readHeaderList(dec *imapwire.Decoder) ([]string, error) {
+	var l []string
+	err := dec.ExpectList(func() error {
+		var s string
+		if !dec.ExpectAString(&s) {
+			return dec.Err()
+		}
+		l = append(l, s)
+		return nil
+	})
+	return l, err
+}
+
+func readSectionPart(dec *imapwire.Decoder) (part []int, dot bool) {
+	for {
+		if len(part) > 0 && !dec.Special('.') {
+			return part, false
+		}
+
+		var num uint32
+		if !dec.Number(&num) {
+			return part, true
+		}
+		part = append(part, int(num))
+	}
+}
+
+type fetchLiteralReader struct {
+	*imapwire.LiteralReader
+	ch chan<- struct{}
+}
+
+func (lit *fetchLiteralReader) Read(b []byte) (int, error) {
+	n, err := lit.LiteralReader.Read(b)
+	if err == io.EOF && lit.ch != nil {
+		close(lit.ch)
+		lit.ch = nil
+	}
+	return n, err
+}
diff --git a/imapclient/idle.go b/imapclient/idle.go
new file mode 100644
index 0000000..08250f5
--- /dev/null
+++ b/imapclient/idle.go
@@ -0,0 +1,70 @@
+package imapclient
+
+import (
+	"fmt"
+)
+
+// 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.
+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 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 {
+	cmd
+	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(respWriteTimeout)
+	_, 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 {
+		return fmt.Errorf("imapclient: IdleCommand.Close must be called before Wait")
+	}
+	return cmd.cmd.Wait()
+}
diff --git a/imapclient/list.go b/imapclient/list.go
new file mode 100644
index 0000000..71d20e9
--- /dev/null
+++ b/imapclient/list.go
@@ -0,0 +1,249 @@
+package imapclient
+
+import (
+	"fmt"
+	"unicode/utf8"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+// ListOptions contains options for the LIST command.
+type ListOptions struct {
+	SelectSubscribed     bool
+	SelectRemote         bool
+	SelectRecursiveMatch bool // requires SelectSubscribed to be set
+
+	ReturnSubscribed bool
+	ReturnChildren   bool
+	ReturnStatus     []StatusItem // requires IMAP4rev2 or LIST-STATUS
+}
+
+func (options *ListOptions) selectOpts() []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")
+	}
+	return l
+}
+
+func (options *ListOptions) returnOpts() []string {
+	if options == nil {
+		return nil
+	}
+
+	var l []string
+	if options.ReturnSubscribed {
+		l = append(l, "SUBSCRIBED")
+	}
+	if options.ReturnChildren {
+		l = append(l, "CHILDREN")
+	}
+	if len(options.ReturnStatus) > 0 {
+		l = append(l, "STATUS")
+	}
+	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 *ListOptions) *ListCommand {
+	cmd := &ListCommand{
+		mailboxes:    make(chan *ListData, 64),
+		returnStatus: options != nil && len(options.ReturnStatus) > 0,
+	}
+	enc := c.beginCommand("LIST", cmd)
+	if selectOpts := options.selectOpts(); len(selectOpts) > 0 {
+		enc.SP().List(len(selectOpts), func(i int) {
+			enc.Atom(selectOpts[i])
+		})
+	}
+	enc.SP().Mailbox(ref).SP().String(pattern)
+	if returnOpts := options.returnOpts(); len(returnOpts) > 0 {
+		enc.SP().Atom("RETURN").SP().List(len(returnOpts), func(i int) {
+			opt := returnOpts[i]
+			enc.Atom(opt)
+			if opt == "STATUS" {
+				enc.SP().List(len(options.ReturnStatus), func(j int) {
+					enc.Atom(string(options.ReturnStatus[j]))
+				})
+			}
+		})
+	}
+	enc.end()
+	return cmd
+}
+
+// ListCommand is a LIST command.
+type ListCommand struct {
+	cmd
+	mailboxes chan *ListData
+
+	returnStatus bool
+	pendingData  *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() *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.cmd.Wait()
+}
+
+// Collect accumulates mailboxes into a list.
+//
+// This is equivalent to calling Next repeatedly and then Close.
+func (cmd *ListCommand) Collect() ([]*ListData, error) {
+	var l []*ListData
+	for {
+		data := cmd.Next()
+		if data == nil {
+			break
+		}
+		l = append(l, data)
+	}
+	return l, cmd.Close()
+}
+
+// ListData is the mailbox data returned by a LIST command.
+type ListData struct {
+	Attrs   []imap.MailboxAttr
+	Delim   rune
+	Mailbox string
+
+	// Extended data
+	ChildInfo *ListDataChildInfo
+	OldName   string
+	Status    *StatusData
+}
+
+type ListDataChildInfo struct {
+	Subscribed bool
+}
+
+func readList(dec *imapwire.Decoder) (*ListData, error) {
+	var data ListData
+
+	err := dec.ExpectList(func() error {
+		attr, err := readFlag(dec)
+		if err != nil {
+			return err
+		}
+		data.Attrs = append(data.Attrs, imap.MailboxAttr(attr))
+		return nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("in mbx-list-flags")
+	}
+
+	if !dec.ExpectSP() {
+		return nil, dec.Err()
+	}
+
+	var delimStr string
+	if dec.Quoted(&delimStr) {
+		delim, size := utf8.DecodeRuneInString(delimStr)
+		if delim == utf8.RuneError || size != len(delimStr) {
+			return nil, fmt.Errorf("mailbox delimiter must be a single rune")
+		}
+		data.Delim = delim
+	} else if !dec.ExpectNIL() {
+		return nil, dec.Err()
+	}
+
+	if !dec.ExpectSP() {
+		return nil, dec.Err()
+	}
+
+	if data.Mailbox, err = dec.ExpectMailbox(); err != nil {
+		return nil, 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 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:
+				return fmt.Errorf("unsupported mbox-list-extended-item-tag %q", tag)
+			}
+			return nil
+		})
+		if err != nil {
+			return nil, fmt.Errorf("in mbox-list-extended: %v", err)
+		}
+	}
+
+	return &data, nil
+}
+
+func readChildInfoExtendedItem(dec *imapwire.Decoder) (*ListDataChildInfo, error) {
+	var childInfo ListDataChildInfo
+	err := dec.ExpectList(func() error {
+		var opt string
+		if !dec.ExpectAString(&opt) {
+			return dec.Err()
+		}
+		if opt == "SUBSCRIBED" {
+			childInfo.Subscribed = true
+		}
+		return nil
+	})
+	return &childInfo, err
+}
+
+func readOldNameExtendedItem(dec *imapwire.Decoder) (string, error) {
+	if !dec.ExpectSpecial('(') {
+		return "", dec.Err()
+	}
+	name, err := dec.ExpectMailbox()
+	if err != nil {
+		return "", err
+	}
+	if !dec.ExpectSpecial(')') {
+		return "", dec.Err()
+	}
+	return name, nil
+}
diff --git a/imapclient/move.go b/imapclient/move.go
new file mode 100644
index 0000000..1152b09
--- /dev/null
+++ b/imapclient/move.go
@@ -0,0 +1,84 @@
+package imapclient
+
+import (
+	"github.com/emersion/go-imap/v2"
+)
+
+func (c *Client) move(uid bool, seqSet imap.SeqSet, 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, uid), cmd)
+	enc.SP().Atom(seqSet.String()).SP().Mailbox(mailbox)
+	enc.end()
+
+	if cmdName == "COPY" {
+		cmd.store = c.store(uid, seqSet, &StoreFlags{
+			Op:     StoreFlagsAdd,
+			Silent: true,
+			Flags:  []imap.Flag{imap.FlagDeleted},
+		})
+		if uid && c.Caps().Has(imap.CapUIDPlus) {
+			cmd.expunge = c.UIDExpunge(seqSet)
+		} else {
+			cmd.expunge = c.Expunge()
+		}
+	}
+
+	return cmd
+}
+
+// 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(seqSet imap.SeqSet, mailbox string) *MoveCommand {
+	return c.move(false, seqSet, mailbox)
+}
+
+// UIDMove sends a UID MOVE command.
+//
+// See Move.
+func (c *Client) UIDMove(seqSet imap.SeqSet, mailbox string) *MoveCommand {
+	return c.move(true, seqSet, mailbox)
+}
+
+// MoveCommand is a MOVE command.
+type MoveCommand struct {
+	cmd
+	data MoveData
+
+	// Fallback
+	store   *FetchCommand
+	expunge *ExpungeCommand
+}
+
+func (cmd *MoveCommand) Wait() (*MoveData, error) {
+	if err := cmd.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.SeqSet
+	DestUIDs    imap.SeqSet
+}
diff --git a/imapclient/search.go b/imapclient/search.go
new file mode 100644
index 0000000..1145d52
--- /dev/null
+++ b/imapclient/search.go
@@ -0,0 +1,180 @@
+package imapclient
+
+import (
+	"strings"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+const searchDateLayout = "2-Jan-2006"
+
+func (c *Client) search(uid bool, criteria *SearchCriteria) *SearchCommand {
+	// TODO: result specifier, charset
+	cmd := &SearchCommand{}
+	enc := c.beginCommand(uidCmdName("SEARCH", uid), cmd)
+	enc.SP()
+	writeSearchKey(enc.Encoder, criteria)
+	enc.end()
+	return cmd
+}
+
+// Search sends a SEARCH command.
+func (c *Client) Search(criteria *SearchCriteria) *SearchCommand {
+	return c.search(false, criteria)
+}
+
+// UIDSearch sends a UID SEARCH command.
+func (c *Client) UIDSearch(criteria *SearchCriteria) *SearchCommand {
+	return c.search(true, criteria)
+}
+
+// SearchCommand is a SEARCH command.
+type SearchCommand struct {
+	cmd
+	data SearchData
+}
+
+func (cmd *SearchCommand) Wait() (*SearchData, error) {
+	return &cmd.data, cmd.cmd.Wait()
+}
+
+// SearchData is the data returned by a SEARCH command.
+type SearchData struct {
+	All imap.SeqSet
+	// TODO: MIN, MAX, COUNT
+}
+
+// AllNums returns All as a slice of numbers.
+func (data *SearchData) AllNums() []uint32 {
+	// Note: a dynamic sequence set would be a server bug
+	nums, _ := data.All.Nums()
+	return nums
+}
+
+// SearchCriteria is a criteria for the SEARCH command.
+//
+// When multiple fields are populated, the result is the intersection ("and"
+// function) of all messages that match the fields.
+type SearchCriteria struct {
+	SeqNum imap.SeqSet
+	UID    imap.SeqSet
+
+	// Only the date is used, the time and timezone are ignored
+	Since      time.Time
+	Before     time.Time
+	SentSince  time.Time
+	SentBefore time.Time
+
+	Header []SearchCriteriaHeaderField
+	Body   []string
+	Text   []string
+
+	Flag    []imap.Flag
+	NotFlag []imap.Flag
+
+	Larger  int64
+	Smaller int64
+
+	Not []SearchCriteria
+	Or  [][2]SearchCriteria
+}
+
+type SearchCriteriaHeaderField struct {
+	Key, Value string
+}
+
+func writeSearchKey(enc *imapwire.Encoder, criteria *SearchCriteria) {
+	enc.Special('(')
+	enc.Atom("ALL") // TODO: remove
+
+	if len(criteria.SeqNum) > 0 {
+		enc.SP().Atom(criteria.SeqNum.String())
+	}
+	if len(criteria.UID) > 0 {
+		enc.SP().Atom("UID").SP().Atom(criteria.UID.String())
+	}
+
+	if !criteria.Since.IsZero() && !criteria.Before.IsZero() && criteria.Before.Sub(criteria.Since) == 24*time.Hour {
+		enc.SP().Atom("ON").SP().String(criteria.Since.Format(searchDateLayout))
+	} else {
+		if !criteria.Since.IsZero() {
+			enc.SP().Atom("SINCE").SP().String(criteria.Since.Format(searchDateLayout))
+		}
+		if !criteria.Before.IsZero() {
+			enc.SP().Atom("BEFORE").SP().String(criteria.Before.Format(searchDateLayout))
+		}
+	}
+	if !criteria.SentSince.IsZero() && !criteria.SentBefore.IsZero() && criteria.SentBefore.Sub(criteria.SentSince) == 24*time.Hour {
+		enc.SP().Atom("SENTON").SP().String(criteria.SentSince.Format(searchDateLayout))
+	} else {
+		if !criteria.SentSince.IsZero() {
+			enc.SP().Atom("SENTSINCE").SP().String(criteria.SentSince.Format(searchDateLayout))
+		}
+		if !criteria.SentBefore.IsZero() {
+			enc.SP().Atom("SENTBEFORE").SP().String(criteria.SentBefore.Format(searchDateLayout))
+		}
+	}
+
+	for _, kv := range criteria.Header {
+		switch k := strings.ToUpper(kv.Key); k {
+		case "BCC", "CC", "FROM", "SUBJECT", "TO":
+			enc.SP().Atom(k)
+		default:
+			enc.SP().Atom("HEADER").SP().String(kv.Key)
+		}
+		enc.SP().String(kv.Value)
+	}
+
+	for _, s := range criteria.Body {
+		enc.SP().Atom("BODY").SP().String(s)
+	}
+	for _, s := range criteria.Text {
+		enc.SP().Atom("TEXT").SP().String(s)
+	}
+
+	for _, flag := range criteria.Flag {
+		if k := flagSearchKey(flag); k != "" {
+			enc.SP().Atom(k)
+		} else {
+			enc.SP().Atom("KEYWORD").SP().Flag(flag)
+		}
+	}
+	for _, flag := range criteria.NotFlag {
+		if k := flagSearchKey(flag); k != "" {
+			enc.SP().Atom("UN" + k)
+		} else {
+			enc.SP().Atom("UNKEYWORD").SP().Flag(flag)
+		}
+	}
+
+	if criteria.Larger > 0 {
+		enc.SP().Atom("LARGER").SP().Number64(criteria.Larger)
+	}
+	if criteria.Smaller > 0 {
+		enc.SP().Atom("SMALLER").SP().Number64(criteria.Smaller)
+	}
+
+	for _, not := range criteria.Not {
+		enc.SP().Atom("NOT").SP()
+		writeSearchKey(enc, &not)
+	}
+	for _, or := range criteria.Or {
+		enc.SP().Atom("OR").SP()
+		writeSearchKey(enc, &or[0])
+		enc.SP()
+		writeSearchKey(enc, &or[1])
+	}
+
+	enc.Special(')')
+}
+
+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 ""
+	}
+}
diff --git a/imapclient/select.go b/imapclient/select.go
new file mode 100644
index 0000000..56ffec9
--- /dev/null
+++ b/imapclient/select.go
@@ -0,0 +1,74 @@
+package imapclient
+
+import (
+	"github.com/emersion/go-imap/v2"
+)
+
+// Select sends a SELECT command.
+func (c *Client) Select(mailbox string) *SelectCommand {
+	cmd := &SelectCommand{mailbox: mailbox}
+	enc := c.beginCommand("SELECT", cmd)
+	enc.SP().Mailbox(mailbox)
+	enc.end()
+	return cmd
+}
+
+// Examine sends an EXAMINE command.
+//
+// See Select.
+func (c *Client) Examine(mailbox string) *SelectCommand {
+	cmd := &SelectCommand{mailbox: mailbox}
+	enc := c.beginCommand("EXAMINE", cmd)
+	enc.SP().Mailbox(mailbox)
+	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.cmd
+}
+
+// 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.cmd
+}
+
+// SelectCommand is a SELECT command.
+type SelectCommand struct {
+	cmd
+	mailbox string
+	data    SelectData
+}
+
+func (cmd *SelectCommand) Wait() (*SelectData, error) {
+	return &cmd.data, cmd.cmd.Wait()
+}
+
+type unselectCommand struct {
+	cmd
+}
+
+// SelectData is the data returned by a SELECT command.
+//
+// In the old RFC 2060, PermanentFlags, UIDNext and UIDValidity are optional.
+type SelectData struct {
+	// Flags defined for this mailbox
+	Flags []imap.Flag
+	// Flags that the client can change permanently
+	PermanentFlags []imap.Flag
+	// Number of messages in this mailbox (aka. "EXISTS")
+	NumMessages uint32
+	UIDNext     uint32
+	UIDValidity uint32
+
+	List *ListData // requires IMAP4rev2
+}
diff --git a/imapclient/starttls.go b/imapclient/starttls.go
new file mode 100644
index 0000000..dfb50b2
--- /dev/null
+++ b/imapclient/starttls.go
@@ -0,0 +1,74 @@
+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 nil
+}
+
+func (c *Client) upgradeStartTLS(tlsConfig *tls.Config) {
+	// 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, 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)
+}
+
+type startTLSCommand struct {
+	cmd
+	tlsConfig   *tls.Config
+	upgradeDone chan<- struct{}
+}
+
+type startTLSConn struct {
+	net.Conn
+	r io.Reader
+}
+
+func (conn startTLSConn) Read(b []byte) (int, error) {
+	return conn.r.Read(b)
+}
diff --git a/imapclient/status.go b/imapclient/status.go
new file mode 100644
index 0000000..fb9498d
--- /dev/null
+++ b/imapclient/status.go
@@ -0,0 +1,128 @@
+package imapclient
+
+import (
+	"fmt"
+
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+// Status sends a STATUS command.
+func (c *Client) Status(mailbox string, items []StatusItem) *StatusCommand {
+	cmd := &StatusCommand{mailbox: mailbox}
+	enc := c.beginCommand("STATUS", cmd)
+	enc.SP().Mailbox(mailbox).SP()
+	enc.List(len(items), func(i int) {
+		enc.Atom(string(items[i]))
+	})
+	enc.end()
+	return cmd
+}
+
+// StatusCommand is a STATUS command.
+type StatusCommand struct {
+	cmd
+	mailbox string
+	data    StatusData
+}
+
+func (cmd *StatusCommand) Wait() (*StatusData, error) {
+	return &cmd.data, cmd.cmd.Wait()
+}
+
+// StatusItem is a data item which can be requested by a STATUS command.
+type StatusItem string
+
+const (
+	StatusItemNumMessages StatusItem = "MESSAGES"
+	StatusItemUIDNext     StatusItem = "UIDNEXT"
+	StatusItemUIDValidity StatusItem = "UIDVALIDITY"
+	StatusItemNumUnseen   StatusItem = "UNSEEN"
+	StatusItemNumDeleted  StatusItem = "DELETED" // requires IMAP4rev2
+	StatusItemSize        StatusItem = "SIZE"    // requires IMAP4rev2 or STATUS=SIZE
+
+	StatusItemAppendLimit StatusItem = "APPENDLIMIT" // requires APPENDLIMIT
+)
+
+// StatusData is the data returned by a STATUS command.
+//
+// The mailbox name is always populated. The remaining fields are optional.
+type StatusData struct {
+	Mailbox string
+
+	NumMessages *uint32
+	UIDNext     uint32
+	UIDValidity uint32
+	NumUnseen   *uint32
+	NumDeleted  *uint32
+	Size        *int64
+
+	AppendLimit *uint32
+}
+
+func readStatus(dec *imapwire.Decoder) (*StatusData, error) {
+	var data StatusData
+
+	var err error
+	if data.Mailbox, err = dec.ExpectMailbox(); err != nil {
+		return nil, err
+	}
+
+	if !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 *StatusData) error {
+	var name string
+	if !dec.ExpectAtom(&name) || !dec.ExpectSP() {
+		return dec.Err()
+	}
+
+	var ok bool
+	switch StatusItem(name) {
+	case StatusItemNumMessages:
+		var num uint32
+		ok = dec.ExpectNumber(&num)
+		data.NumMessages = &num
+	case StatusItemUIDNext:
+		ok = dec.ExpectNumber(&data.UIDNext)
+	case StatusItemUIDValidity:
+		ok = dec.ExpectNumber(&data.UIDValidity)
+	case StatusItemNumUnseen:
+		var num uint32
+		ok = dec.ExpectNumber(&num)
+		data.NumUnseen = &num
+	case StatusItemNumDeleted:
+		var num uint32
+		ok = dec.ExpectNumber(&num)
+		data.NumDeleted = &num
+	case StatusItemSize:
+		var size int64
+		ok = dec.ExpectNumber64(&size)
+		data.Size = &size
+	case StatusItemAppendLimit:
+		var num uint32
+		if dec.Number(&num) {
+			ok = true
+		} else {
+			ok = dec.ExpectNIL()
+			num = ^uint32(0)
+		}
+		data.AppendLimit = &num
+	default:
+		// TODO: skip tagged-ext
+		return fmt.Errorf("unsupported status-att-val %q", name)
+	}
+	if !ok {
+		return dec.Err()
+	}
+	return nil
+}
diff --git a/imapclient/store.go b/imapclient/store.go
new file mode 100644
index 0000000..ee7b380
--- /dev/null
+++ b/imapclient/store.go
@@ -0,0 +1,62 @@
+package imapclient
+
+import (
+	"fmt"
+
+	"github.com/emersion/go-imap/v2"
+)
+
+func (c *Client) store(uid bool, seqSet imap.SeqSet, store *StoreFlags) *FetchCommand {
+	cmd := &FetchCommand{msgs: make(chan *FetchMessageData, 128)}
+	enc := c.beginCommand(uidCmdName("STORE", uid), cmd)
+	enc.SP().Atom(seqSet.String()).SP()
+	switch store.Op {
+	case StoreFlagsSet:
+		// nothing to do
+	case StoreFlagsAdd:
+		enc.Special('+')
+	case 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
+}
+
+// Store sends a STORE command.
+//
+// Unless StoreFlags.Silent is set, the server will return the updated values.
+func (c *Client) Store(seqSet imap.SeqSet, store *StoreFlags) *FetchCommand {
+	return c.store(false, seqSet, store)
+}
+
+// UIDStore sends a UID STORE command.
+//
+// See Store.
+func (c *Client) UIDStore(seqSet imap.SeqSet, store *StoreFlags) *FetchCommand {
+	return c.store(true, seqSet, store)
+}
+
+// StoreFlagsOp is a flag operation: set, add or delete.
+type StoreFlagsOp int
+
+const (
+	StoreFlagsSet StoreFlagsOp = iota
+	StoreFlagsAdd
+	StoreFlagsDel
+)
+
+// StoreFlags alters message flags.
+type StoreFlags struct {
+	Op     StoreFlagsOp
+	Silent bool
+	Flags  []imap.Flag
+}
diff --git a/internal/imapwire/decoder.go b/internal/imapwire/decoder.go
new file mode 100644
index 0000000..fadcf70
--- /dev/null
+++ b/internal/imapwire/decoder.go
@@ -0,0 +1,456 @@
+package imapwire
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+	"unicode"
+
+	"github.com/emersion/go-imap/v2/internal/utf7"
+)
+
+// 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))
+	}
+}
+
+// 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 {
+	r       *bufio.Reader
+	err     error
+	literal bool
+}
+
+// NewDecoder creates a new decoder.
+func NewDecoder(r *bufio.Reader) *Decoder {
+	return &Decoder{r: r}
+}
+
+func (dec *Decoder) mustUnreadByte() {
+	if err := dec.r.UnreadByte(); err != nil {
+		panic(fmt.Errorf("imapwire: failed to unread byte: %v", err))
+	}
+}
+
+// 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.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)
+	}
+	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 {
+		err := fmt.Errorf("expected %v", name)
+		if dec.r.Buffered() > 0 {
+			b, _ := dec.r.Peek(1)
+			err = fmt.Errorf("%v, got '%v'", err, string(b))
+		}
+		return dec.returnErr(err)
+	}
+	return true
+}
+
+func (dec *Decoder) SP() bool {
+	if dec.acceptByte(' ') {
+		return true
+	}
+
+	// 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 {
+	return dec.acceptByte('\r') && dec.acceptByte('\n')
+}
+
+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) Skip(untilCh byte) {
+	for {
+		ch, ok := dec.readByte()
+		if !ok {
+			return
+		} else if ch == untilCh {
+			dec.mustUnreadByte()
+			return
+		}
+	}
+}
+
+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) 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) 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
+	}
+	// TODO: accept unquoted resp-specials
+	return dec.ExpectAtom(ptr)
+}
+
+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 s == "NIL" {
+			return nil, true, true
+		}
+		return newLiteralReaderFromString(s), 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
+	}
+
+	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() (string, error) {
+	var name string
+	if !dec.ExpectAString(&name) {
+		return "", dec.Err()
+	}
+	return utf7.Encoding.NewDecoder().String(name)
+}
+
+func (dec *Decoder) Literal(ptr *string) bool {
+	lit, _, ok := dec.LiteralReader()
+	if !ok {
+		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
+	}
+	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
+}
+
+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.dec != nil {
+		lit.dec.literal = false
+		lit.dec = nil
+	}
+	return n, err
+}
diff --git a/internal/imapwire/encoder.go b/internal/imapwire/encoder.go
new file mode 100644
index 0000000..e7e79fe
--- /dev/null
+++ b/internal/imapwire/encoder.go
@@ -0,0 +1,203 @@
+package imapwire
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+
+	"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 {
+	w       *bufio.Writer
+	err     error
+	literal bool
+}
+
+// NewEncoder creates a new encoder.
+func NewEncoder(w *bufio.Writer) *Encoder {
+	return &Encoder{w: w}
+}
+
+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) String(s string) *Encoder {
+	// TODO: if the string contains CR/LF, use a literal
+	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) Mailbox(name string) *Encoder {
+	if strings.EqualFold(name, "INBOX") {
+		return enc.Atom("INBOX")
+	} else {
+		name, _ = utf7.Encoding.NewEncoder().String(name)
+		return enc.String(name)
+	}
+}
+
+func (enc *Encoder) Flag(flag imap.Flag) *Encoder {
+	if flag != "\\*" {
+		for i := 0; i < len(flag); i++ {
+			ch := flag[i]
+			valid := false
+			switch ch {
+			case '\\':
+				valid = i == 0
+			default:
+				valid = IsAtomChar(ch)
+			}
+			if !valid {
+				if enc.err == nil {
+					enc.err = fmt.Errorf("imapwire: character '%v' not allowed in flag", string(ch))
+				}
+				return enc
+			}
+		}
+	}
+	return enc.Atom(string(flag))
+}
+
+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))
+}
+
+// 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
+}
+
+// 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 {
+	// TODO: literal8
+	enc.writeString("{")
+	enc.Number64(size)
+	if sync == nil {
+		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 {
+			if enc.err == nil {
+				enc.err = 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
+}
diff --git a/internal/imapwire/imapwire.go b/internal/imapwire/imapwire.go
new file mode 100644
index 0000000..f325f9c
--- /dev/null
+++ b/internal/imapwire/imapwire.go
@@ -0,0 +1,39 @@
+// Package imapwire implements the IMAP wire protocol.
+//
+// The IMAP wire protocol is defined in RFC 9051 section 4.
+package imapwire
+
+import (
+	"fmt"
+)
+
+// ContinuationRequest is a continuation request.
+//
+// The sender must call either Done or Cancel. The receiver must call Wait.
+type ContinuationRequest struct {
+	done chan struct{}
+	err  error
+	text string
+}
+
+func NewContinuationRequest() *ContinuationRequest {
+	return &ContinuationRequest{done: make(chan struct{})}
+}
+
+func (cont *ContinuationRequest) Cancel(err error) {
+	if err == nil {
+		err = fmt.Errorf("imapwire: continuation request cancelled")
+	}
+	cont.err = err
+	close(cont.done)
+}
+
+func (cont *ContinuationRequest) Done(text string) {
+	cont.text = text
+	close(cont.done)
+}
+
+func (cont *ContinuationRequest) Wait() (string, error) {
+	<-cont.done
+	return cont.text, cont.err
+}
diff --git a/internal/utf7/decoder.go b/internal/utf7/decoder.go
new file mode 100644
index 0000000..cfcba8c
--- /dev/null
+++ b/internal/utf7/decoder.go
@@ -0,0 +1,149 @@
+package utf7
+
+import (
+	"errors"
+	"unicode/utf16"
+	"unicode/utf8"
+
+	"golang.org/x/text/transform"
+)
+
+// ErrInvalidUTF7 means that a transformer encountered invalid UTF-7.
+var ErrInvalidUTF7 = errors.New("utf7: invalid UTF-7")
+
+type decoder struct {
+	ascii bool
+}
+
+func (d *decoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
+	for i := 0; i < len(src); i++ {
+		ch := src[i]
+
+		if ch < min || ch > max { // Illegal code point in ASCII mode
+			err = ErrInvalidUTF7
+			return
+		}
+
+		if ch != '&' {
+			if nDst+1 > len(dst) {
+				err = transform.ErrShortDst
+				return
+			}
+
+			nSrc++
+
+			dst[nDst] = ch
+			nDst++
+
+			d.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
+				err = ErrInvalidUTF7
+				return
+			}
+		}
+
+		if i == len(src) { // Implicit shift ("&...")
+			if atEOF {
+				err = ErrInvalidUTF7
+			} else {
+				err = transform.ErrShortSrc
+			}
+			return
+		}
+
+		var b []byte
+		if i == start { // Escape sequence "&-"
+			b = []byte{'&'}
+			d.ascii = true
+		} else { // Control or non-ASCII code points in base64
+			if !d.ascii { // Null shift ("&...-&...-")
+				err = ErrInvalidUTF7
+				return
+			}
+
+			b = decode(src[start:i])
+			d.ascii = false
+		}
+
+		if len(b) == 0 { // Bad encoding
+			err = ErrInvalidUTF7
+			return
+		}
+
+		if nDst+len(b) > len(dst) {
+			d.ascii = true
+			err = transform.ErrShortDst
+			return
+		}
+
+		nSrc = i + 1
+
+		for _, ch := range b {
+			dst[nDst] = ch
+			nDst++
+		}
+	}
+
+	if atEOF {
+		d.ascii = true
+	}
+
+	return
+}
+
+func (d *decoder) Reset() {
+	d.ascii = true
+}
+
+// Extracts UTF-16-BE bytes from base64 data and converts them to UTF-8.
+// 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 == repl {
+				return nil
+			}
+		} else if min <= r && r <= max {
+			return nil
+		}
+		j += utf8.EncodeRune(s[j:], r)
+	}
+	return s[:j]
+}
diff --git a/internal/utf7/encoder.go b/internal/utf7/encoder.go
new file mode 100644
index 0000000..8414d10
--- /dev/null
+++ b/internal/utf7/encoder.go
@@ -0,0 +1,91 @@
+package utf7
+
+import (
+	"unicode/utf16"
+	"unicode/utf8"
+
+	"golang.org/x/text/transform"
+)
+
+type encoder struct{}
+
+func (e *encoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
+	for i := 0; i < len(src); {
+		ch := src[i]
+
+		var b []byte
+		if min <= ch && ch <= max {
+			b = []byte{ch}
+			if ch == '&' {
+				b = append(b, '-')
+			}
+
+			i++
+		} else {
+			start := i
+
+			// Find the next printable ASCII code point
+			i++
+			for i < len(src) && (src[i] < min || src[i] > max) {
+				i++
+			}
+
+			if !atEOF && i == len(src) {
+				err = transform.ErrShortSrc
+				return
+			}
+
+			b = encode(src[start:i])
+		}
+
+		if nDst+len(b) > len(dst) {
+			err = transform.ErrShortDst
+			return
+		}
+
+		nSrc = i
+
+		for _, ch := range b {
+			dst[nDst] = ch
+			nDst++
+		}
+	}
+
+	return
+}
+
+func (e *encoder) Reset() {}
+
+// Converts string s from UTF-8 to UTF-16-BE, encodes the result as base64,
+// removes the padding, and adds UTF-7 shifts.
+func encode(s []byte) []byte {
+	// 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 != repl {
+			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
+}
diff --git a/internal/utf7/utf7.go b/internal/utf7/utf7.go
new file mode 100644
index 0000000..b9dd962
--- /dev/null
+++ b/internal/utf7/utf7.go
@@ -0,0 +1,34 @@
+// Package utf7 implements modified UTF-7 encoding defined in RFC 3501 section 5.1.3
+package utf7
+
+import (
+	"encoding/base64"
+
+	"golang.org/x/text/encoding"
+)
+
+const (
+	min = 0x20 // Minimum self-representing UTF-7 value
+	max = 0x7E // Maximum self-representing UTF-7 value
+
+	repl = '\uFFFD' // Unicode replacement code point
+)
+
+var b64Enc = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,")
+
+type enc struct{}
+
+func (e enc) NewDecoder() *encoding.Decoder {
+	return &encoding.Decoder{
+		Transformer: &decoder{true},
+	}
+}
+
+func (e enc) NewEncoder() *encoding.Encoder {
+	return &encoding.Encoder{
+		Transformer: &encoder{},
+	}
+}
+
+// Encoding is the modified UTF-7 encoding.
+var Encoding encoding.Encoding = enc{}
diff --git a/seqset.go b/seqset.go
new file mode 100644
index 0000000..3dcc362
--- /dev/null
+++ b/seqset.go
@@ -0,0 +1,319 @@
+package imap
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// errBadSeqSet is used to report problems with the format of a sequence set
+// value.
+type errBadSeqSet string
+
+func (err errBadSeqSet) Error() string {
+	return fmt.Sprintf("imap: bad sequence set value %q", string(err))
+}
+
+// Seq represents a single seq-number or seq-range value (RFC 3501 ABNF). Values
+// may be static (e.g. "1", "2:4") or dynamic (e.g. "*", "1:*"). A seq-number is
+// represented by setting Start = Stop. Zero is used to represent "*", which is
+// safe because seq-number uses nz-number rule. The order of values is always
+// Start <= Stop, except when representing "n:*", where Start = n and Stop = 0.
+type Seq struct {
+	Start, Stop uint32
+}
+
+// parseSeqNum parses a single seq-number value (non-zero uint32 or "*").
+func parseSeqNum(v string) (uint32, error) {
+	if n, err := strconv.ParseUint(v, 10, 32); err == nil && v[0] != '0' {
+		return uint32(n), nil
+	} else if v == "*" {
+		return 0, nil
+	}
+	return 0, errBadSeqSet(v)
+}
+
+// parseSeq creates a new seq instance by parsing strings in the format "n" or
+// "n:m", where n and/or m may be "*". An error is returned for invalid values.
+func parseSeq(v string) (Seq, error) {
+	var (
+		s   Seq
+		err error
+	)
+	if sep := strings.IndexRune(v, ':'); sep < 0 {
+		s.Start, err = parseSeqNum(v)
+		s.Stop = s.Start
+		return s, err
+	} else if s.Start, err = parseSeqNum(v[:sep]); err == nil {
+		if s.Stop, err = parseSeqNum(v[sep+1:]); err == nil {
+			if (s.Stop < s.Start && s.Stop != 0) || s.Start == 0 {
+				s.Start, s.Stop = s.Stop, s.Start
+			}
+			return s, nil
+		}
+	}
+	return s, errBadSeqSet(v)
+}
+
+// Contains returns true if the seq-number q is contained in sequence value s.
+// The dynamic value "*" contains only other "*" values, the dynamic range "n:*"
+// contains "*" and all numbers >= n.
+func (s Seq) Contains(q uint32) bool {
+	if q == 0 {
+		return s.Stop == 0 // "*" is contained only in "*" and "n:*"
+	}
+	return s.Start != 0 && s.Start <= q && (q <= s.Stop || s.Stop == 0)
+}
+
+// Less returns true if s precedes and does not contain seq-number q.
+func (s Seq) Less(q uint32) bool {
+	return (s.Stop < q || q == 0) && s.Stop != 0
+}
+
+// Merge combines sequence values s and t into a single union if the two
+// intersect or one is a superset of the other. The order of s and t does not
+// matter. If the values cannot be merged, s is returned unmodified and ok is
+// set to false.
+func (s Seq) Merge(t Seq) (union Seq, ok bool) {
+	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 Seq{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 sequence value s as a seq-number or seq-range string.
+func (s Seq) String() string {
+	if s.Start == s.Stop {
+		if s.Start == 0 {
+			return "*"
+		}
+		return strconv.FormatUint(uint64(s.Start), 10)
+	}
+	b := strconv.AppendUint(make([]byte, 0, 24), uint64(s.Start), 10)
+	if s.Stop == 0 {
+		return string(append(b, ':', '*'))
+	}
+	return string(strconv.AppendUint(append(b, ':'), uint64(s.Stop), 10))
+}
+
+func (s Seq) 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
+}
+
+// SeqSet is used to represent a set of message sequence numbers or UIDs (see
+// sequence-set ABNF rule). The zero value is an empty set.
+type SeqSet []Seq
+
+// ParseSeqSet returns a new SeqSet after parsing the set string.
+func ParseSeqSet(set string) (SeqSet, error) {
+	var s SeqSet
+	for _, sv := range strings.Split(set, ",") {
+		v, err := parseSeq(sv)
+		if err != nil {
+			return s, err
+		}
+		s.insert(v)
+	}
+	return s, nil
+}
+
+// SeqSetNum returns a new SeqSet containing the sequence numbers.
+func SeqSetNum(q ...uint32) SeqSet {
+	var s SeqSet
+	s.AddNum(q...)
+	return s
+}
+
+// SeqSetRange returns a new SeqSet containing the sequence range.
+func SeqSetRange(start, stop uint32) SeqSet {
+	var s SeqSet
+	s.AddRange(start, stop)
+	return s
+}
+
+// AddNum inserts new sequence numbers into the set. The value 0 represents "*".
+func (s *SeqSet) AddNum(q ...uint32) {
+	for _, v := range q {
+		s.insert(Seq{v, v})
+	}
+}
+
+// AddRange inserts a new sequence range into the set.
+func (s *SeqSet) AddRange(start, stop uint32) {
+	if (stop < start && stop != 0) || start == 0 {
+		s.insert(Seq{stop, start})
+	} else {
+		s.insert(Seq{start, stop})
+	}
+}
+
+// AddSet inserts all values from t into s.
+func (s *SeqSet) AddSet(t SeqSet) {
+	for _, v := range t {
+		s.insert(v)
+	}
+}
+
+// Dynamic returns true if the set contains "*" or "n:*" values.
+func (s SeqSet) 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 SeqSet) 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 sequence set.
+func (s SeqSet) 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 sequence values.
+func (s SeqSet) 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 sequence value v to the set.
+func (ptr *SeqSet) insert(v Seq) {
+	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 sequence value v at index i, resizing s.Set as needed.
+func (ptr *SeqSet) insertAt(i int, v Seq) {
+	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([]Seq, 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 sequence set value that contains q.
+// If no values contain q, the returned index is the position where q should be
+// inserted and ok is set to false.
+func (s SeqSet) search(q uint32) (i int, ok bool) {
+	min, max := 0, len(s)-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)
+}
diff --git a/status.go b/status.go
new file mode 100644
index 0000000..aa002d6
--- /dev/null
+++ b/status.go
@@ -0,0 +1,63 @@
+package imap
+
+import (
+	"fmt"
+)
+
+// StatusResponseType is a generic status response type.
+type StatusResponseType string
+
+const (
+	StatusResponseTypeOK      StatusResponseType = "OK"
+	StatusResponseTypeNo      StatusResponseType = "NO"
+	StatusResponseTypeBad     StatusResponseType = "BAD"
+	StatusResponseTypePreAuth StatusResponseType = "PREAUTH"
+	StatusResponseTypeBye     StatusResponseType = "BYE"
+)
+
+// ResponseCode is a response code.
+type ResponseCode string
+
+const (
+	ResponseCodeAlert                ResponseCode = "ALERT"
+	ResponseCodeAlreadyExists        ResponseCode = "ALREADYEXISTS"
+	ResponseCodeAuthenticationFailed ResponseCode = "AUTHENTICATIONFAILED"
+	ResponseCodeAuthorizationFailed  ResponseCode = "AUTHORIZATIONFAILED"
+	ResponseCodeBadCharset           ResponseCode = "BADCHARSET"
+	ResponseCodeCannot               ResponseCode = "CANNOT"
+	ResponseCodeClientBug            ResponseCode = "CLIENTBUG"
+	ResponseCodeContactAdmin         ResponseCode = "CONTACTADMIN"
+	ResponseCodeCorruption           ResponseCode = "CORRUPTION"
+	ResponseCodeExpired              ResponseCode = "EXPIRED"
+	ResponseCodeHasChildren          ResponseCode = "HASCHILDREN"
+	ResponseCodeInUse                ResponseCode = "INUSE"
+	ResponseCodeLimit                ResponseCode = "LIMIT"
+	ResponseCodeNonExistent          ResponseCode = "NONEXISTENT"
+	ResponseCodeNoPerm               ResponseCode = "NOPERM"
+	ResponseCodeOverQuota            ResponseCode = "OVERQUOTA"
+	ResponseCodeParse                ResponseCode = "PARSE"
+	ResponseCodePrivacyRequired      ResponseCode = "PRIVACYREQUIRED"
+	ResponseCodeServerBug            ResponseCode = "SERVERBUG"
+	ResponseCodeTryCreate            ResponseCode = "TRYCREATE"
+	ResponseCodeUnavailable          ResponseCode = "UNAVAILABLE"
+	ResponseCodeUnknownCTE           ResponseCode = "UNKNOWN-CTE"
+)
+
+// StatusResponse is a generic status response.
+//
+// See RFC 9051 section 7.1.
+type StatusResponse struct {
+	Type StatusResponseType
+	Code ResponseCode
+	Text string
+}
+
+// Error is an IMAP error caused by a status response.
+type Error StatusResponse
+
+var _ error = (*Error)(nil)
+
+// Error implements the error interface.
+func (err *Error) Error() string {
+	return fmt.Sprintf("imap: %v %v", err.Type, err.Text)
+}


Test patch:
diff --git a/internal/utf7/decoder_test.go b/internal/utf7/decoder_test.go
new file mode 100644
index 0000000..f53c6a1
--- /dev/null
+++ b/internal/utf7/decoder_test.go
@@ -0,0 +1,115 @@
+package utf7_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/emersion/go-imap/v2/internal/utf7"
+)
+
+var decode = []struct {
+	in  string
+	out string
+	ok  bool
+}{
+	// Basics (the inverse test on encode checks other valid inputs)
+	{"", "", true},
+	{"abc", "abc", true},
+	{"&-abc", "&abc", true},
+	{"abc&-", "abc&", true},
+	{"a&-b&-c", "a&b&c", true},
+	{"&ABk-", "\x19", true},
+	{"&AB8-", "\x1F", true},
+	{"ABk-", "ABk-", true},
+	{"&-,&-&AP8-&-", "&,&\u00FF&", true},
+	{"&-&-,&AP8-&-", "&&,\u00FF&", true},
+	{"abc &- &AP8A,wD,- &- xyz", "abc & \u00FF\u00FF\u00FF & xyz", true},
+
+	// Illegal code point in ASCII
+	{"\x00", "", false},
+	{"\x1F", "", false},
+	{"abc\n", "", false},
+	{"abc\x7Fxyz", "", false},
+	{"\uFFFD", "", false},
+	{"\u041C", "", 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) {
+	dec := utf7.Encoding.NewDecoder()
+
+	for _, test := range decode {
+		out, err := dec.String(test.in)
+		if out != test.out {
+			t.Errorf("UTF7Decode(%+q) expected %+q; got %+q", test.in, test.out, out)
+		}
+		if test.ok {
+			if err != nil {
+				t.Errorf("UTF7Decode(%+q) unexpected error; %v", test.in, err)
+			}
+		} else if err == nil {
+			t.Errorf("UTF7Decode(%+q) expected error", test.in)
+		}
+	}
+}
diff --git a/internal/utf7/encoder_test.go b/internal/utf7/encoder_test.go
new file mode 100644
index 0000000..20ba2c1
--- /dev/null
+++ b/internal/utf7/encoder_test.go
@@ -0,0 +1,126 @@
+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) {
+	enc := utf7.Encoding.NewEncoder()
+
+	for _, test := range encode {
+		out, _ := enc.String(test.in)
+		if out != test.out {
+			t.Errorf("UTF7Encode(%+q) expected %+q; got %+q", test.in, test.out, out)
+		}
+	}
+}
diff --git a/seqset_test.go b/seqset_test.go
new file mode 100644
index 0000000..78a85d3
--- /dev/null
+++ b/seqset_test.go
@@ -0,0 +1,724 @@
+package imap
+
+import (
+	"math/rand"
+	"strings"
+	"testing"
+)
+
+const max = ^uint32(0)
+
+func TestParseSeq(t *testing.T) {
+	tests := []struct {
+		in  string
+		out Seq
+		ok  bool
+	}{
+		// Invalid number
+		{"", Seq{}, false},
+		{" ", Seq{}, false},
+		{"A", Seq{}, false},
+		{"0", Seq{}, false},
+		{" 1", Seq{}, false},
+		{"1 ", Seq{}, false},
+		{"*1", Seq{}, false},
+		{"1*", Seq{}, false},
+		{"-1", Seq{}, false},
+		{"01", Seq{}, false},
+		{"0x1", Seq{}, false},
+		{"1 2", Seq{}, false},
+		{"1,2", Seq{}, false},
+		{"1.2", Seq{}, false},
+		{"4294967296", Seq{}, false},
+
+		// Valid number
+		{"*", Seq{0, 0}, true},
+		{"1", Seq{1, 1}, true},
+		{"42", Seq{42, 42}, true},
+		{"1000", Seq{1000, 1000}, true},
+		{"4294967295", Seq{max, max}, true},
+
+		// Invalid range
+		{":", Seq{}, false},
+		{"*:", Seq{}, false},
+		{":*", Seq{}, false},
+		{"1:", Seq{}, false},
+		{":1", Seq{}, false},
+		{"0:0", Seq{}, false},
+		{"0:*", Seq{}, false},
+		{"0:1", Seq{}, false},
+		{"1:0", Seq{}, false},
+		{"1:2 ", Seq{}, false},
+		{"1: 2", Seq{}, false},
+		{"1:2:", Seq{}, false},
+		{"1:2,", Seq{}, false},
+		{"1:2:3", Seq{}, false},
+		{"1:2,3", Seq{}, false},
+		{"*:4294967296", Seq{}, false},
+		{"0:4294967295", Seq{}, false},
+		{"1:4294967296", Seq{}, false},
+		{"4294967296:*", Seq{}, false},
+		{"4294967295:0", Seq{}, false},
+		{"4294967296:1", Seq{}, false},
+		{"4294967295:4294967296", Seq{}, false},
+
+		// Valid range
+		{"*:*", Seq{0, 0}, true},
+		{"1:*", Seq{1, 0}, true},
+		{"*:1", Seq{1, 0}, true},
+		{"2:2", Seq{2, 2}, true},
+		{"2:42", Seq{2, 42}, true},
+		{"42:2", Seq{2, 42}, true},
+		{"*:4294967294", Seq{max - 1, 0}, true},
+		{"*:4294967295", Seq{max, 0}, true},
+		{"4294967294:*", Seq{max - 1, 0}, true},
+		{"4294967295:*", Seq{max, 0}, true},
+		{"1:4294967294", Seq{1, max - 1}, true},
+		{"1:4294967295", Seq{1, max}, true},
+		{"4294967295:1000", Seq{1000, max}, true},
+		{"4294967294:4294967295", Seq{max - 1, max}, true},
+		{"4294967295:4294967295", Seq{max, max}, true},
+	}
+	for _, test := range tests {
+		out, err := parseSeq(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 TestSeqContainsLess(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 := parseSeq(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 TestSeqMerge(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 := parseSeq(test.s)
+		if err != nil {
+			T.Errorf("parseSeq(%q) unexpected error; %v", test.s, err)
+			continue
+		}
+		t, err := parseSeq(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 checkSeqSet(s SeqSet, t *testing.T) {
+	n := len(s)
+	for i, v := range s {
+		if v.Start == 0 {
+			if v.Stop != 0 {
+				t.Errorf(`SeqSet(%q) index %d: "*:n" range`, s, i)
+			} else if i != n-1 {
+				t.Errorf(`SeqSet(%q) index %d: "*" not at the end`, s, i)
+			}
+			continue
+		}
+		if i > 0 && s[i-1].Stop >= v.Start-1 {
+			t.Errorf(`SeqSet(%q) index %d: overlap`, s, i)
+		}
+		if v.Stop < v.Start {
+			if v.Stop != 0 {
+				t.Errorf(`SeqSet(%q) index %d: reversed range`, s, i)
+			} else if i != n-1 {
+				t.Errorf(`SeqSet(%q) index %d: "n:*" not at the end`, s, i)
+			}
+		}
+	}
+}
+
+func TestSeqSetInfo(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, _ := ParseSeqSet(test.s)
+		checkSeqSet(s, t)
+		if s.Contains(test.q) != test.contains {
+			t.Errorf("%q.Contains(%v) expected %v", test.s, test.q, test.contains)
+		}
+		if str := s.String(); str != test.s {
+			t.Errorf("%q.String() expected %q; got %q", test.s, test.s, str)
+		}
+		testEmpty := len(test.s) == 0
+		if (len(s) == 0) != testEmpty {
+			t.Errorf("%q.Empty() expected %v", test.s, testEmpty)
+		}
+		testDynamic := !testEmpty && test.s[len(test.s)-1] == '*'
+		if s.Dynamic() != testDynamic {
+			t.Errorf("%q.Dynamic() expected %v", test.s, testDynamic)
+		}
+	}
+}
+
+func TestParseSeqSet(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 := ParseSeqSet(test.in)
+			if err != nil {
+				t.Errorf("Add(%q) unexpected error; %v", test.in, err)
+				i = 100
+			}
+			checkSeqSet(s, t)
+			if out := s.String(); out != test.out {
+				t.Errorf("%q.String() expected %q; got %q", test.in, test.out, out)
+				i = 100
+			}
+			test.in = permute(test.in)
+		}
+	}
+}
+
+func TestSeqSetAddNumRangeSet(t *testing.T) {
+	type num []uint32
+	tests := []struct {
+		num num
+		rng Seq
+		set string
+		out string
+	}{
+		{num{5}, Seq{1, 3}, "1:2,5,7:13,15,17:*", "1:3,5,7:13,15,17:*"},
+		{num{5}, Seq{3, 1}, "2:3,7:13,15,17:*", "1:3,5,7:13,15,17:*"},
+
+		{num{15}, Seq{17, 0}, "1:3,5,7:13", "1:3,5,7:13,15,17:*"},
+		{num{15}, Seq{0, 17}, "1:3,5,7:13", "1:3,5,7:13,15,17:*"},
+
+		{num{1, 3, 5, 7, 9, 11, 0}, Seq{8, 13}, "2,15,17:*", "1:3,5,7:13,15,17:*"},
+		{num{5, 1, 7, 3, 9, 0, 11}, Seq{8, 13}, "2,15,17:*", "1:3,5,7:13,15,17:*"},
+		{num{5, 1, 7, 3, 9, 0, 11}, Seq{13, 8}, "2,15,17:*", "1:3,5,7:13,15,17:*"},
+	}
+	for _, test := range tests {
+		other, _ := ParseSeqSet(test.set)
+
+		var s SeqSet
+		s.AddNum(test.num...)
+		checkSeqSet(s, t)
+		s.AddRange(test.rng.Start, test.rng.Stop)
+		checkSeqSet(s, t)
+		s.AddSet(other)
+		checkSeqSet(s, t)
+
+		if out := s.String(); out != test.out {
+			t.Errorf("(%v + %v + %q).String() expected %q; got %q", test.num, test.rng, test.set, test.out, out)
+		}
+	}
+}