Repository: emersion/go-imap
Pull request #495 (2023-03-24 13:27:02)

[v2] Add basic server
- [x] Send `OK [CAPABILITY]`, `APPENDUID`, `COPYUID`
- [x] Send continuation requests for literals outside of `APPEND`
- [ ] Plumb context
- [x] Timeouts
- [x] Debug writer
- [x] Retrofit IMAP4rev1 support
- [x] Sort out unilateral updates
- [x] Add support for `FETCH`, `FLAGS`, unilateral updates
- [ ] Add support for `CREATE`, `DELETE`, `RENAME`, , `PERMANENTFLAGS` unilateral updates (maybe more?)
- [x] Add `Server.Close`
- [ ] Add `Server.Shutdown`
- [ ] Add `PREAUTH` support
- [ ] Add support for `SEARCHRES`

emersion: Regarding unilateral updates the following decisions have been made, but I'm not sure they're a good idea:

- `Session.Poll` is invoked at the end of every command. The alternative would be to only invoke it on `NOOP` and let the backend manually do the polling in each command's handler.
- It is not possible to write arbitrary updates to a `Conn` at any time. Backends can only send unilateral updates in `Poll` and `Idle`.
- `Session.Expunge` and `Session.Store` are passed in a writer to send responses to the client. However to properly behave when the same mailbox is selected from multiple connections, backends should send the updates in `Poll` instead. The `.SILENT` modifier for `STORE` is supposed to send updates to other clients only and is not handled. Maybe there should be a way to queue a `MailboxTracker` update on all connections except the current one?

Patch:
diff --git a/append.go b/append.go
new file mode 100644
index 0000000..4348e62
--- /dev/null
+++ b/append.go
@@ -0,0 +1,16 @@
+package imap
+
+import (
+	"time"
+)
+
+// AppendOptions contains options for the APPEND command.
+type AppendOptions struct {
+	Flags []Flag
+	Time  time.Time
+}
+
+// AppendData is the data returned by an APPEND command.
+type AppendData struct {
+	UID, UIDValidity uint32 // requires UIDPLUS or IMAP4rev2
+}
diff --git a/capability.go b/capability.go
index d02197d..c142ca2 100644
--- a/capability.go
+++ b/capability.go
@@ -70,6 +70,8 @@ const (
 	CapUnauthenticate   Cap = "UNAUTHENTICATE"     // RFC 8437
 	CapURLPartial       Cap = "URL-PARTIAL"        // RFC 5550
 	CapURLAuth          Cap = "URLAUTH"            // RFC 4467
+	CapUTF8Accept       Cap = "UTF8=ACCEPT"        // RFC 6855
+	CapUTF8Only         Cap = "UTF8=ONLY"          // RFC 6855
 	CapWithin           Cap = "WITHIN"             // RFC 5032
 )
 
diff --git a/copy.go b/copy.go
new file mode 100644
index 0000000..1988a6a
--- /dev/null
+++ b/copy.go
@@ -0,0 +1,9 @@
+package imap
+
+// CopyData is the data returned by a COPY command.
+type CopyData struct {
+	// requires UIDPLUS or IMAP4rev2
+	UIDValidity uint32
+	SourceUIDs  SeqSet
+	DestUIDs    SeqSet
+}
diff --git a/fetch.go b/fetch.go
new file mode 100644
index 0000000..e2bbcfe
--- /dev/null
+++ b/fetch.go
@@ -0,0 +1,271 @@
+package imap
+
+import (
+	"fmt"
+	"strings"
+)
+
+// 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() {}
+
+// 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 {
+	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)
diff --git a/go.mod b/go.mod
index 3516e3d..1fbffe0 100644
--- a/go.mod
+++ b/go.mod
@@ -4,4 +4,9 @@ go 1.18
 
 require golang.org/x/text v0.8.0
 
-require github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
+require (
+	github.com/emersion/go-message v0.16.0
+	github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
+)
+
+require github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
diff --git a/go.sum b/go.sum
index 5570d7a..6a35e32 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,10 @@
+github.com/emersion/go-message v0.16.0 h1:uZLz8ClLv3V5fSFF/fFdW9jXjrZkXIpE1Fn8fKx7pO4=
+github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKkUMPOxrPVG2eQ=
 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=
+github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
+github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/imap.go b/imap.go
index 15f3d88..2409387 100644
--- a/imap.go
+++ b/imap.go
@@ -6,6 +6,42 @@
 // server. See the imapclient and imapserver sub-packages.
 package imap
 
+import (
+	"fmt"
+	"io"
+)
+
+// ConnState describes the connection state.
+//
+// See RFC 9051 section 3.
+type ConnState int
+
+const (
+	ConnStateNone ConnState = iota
+	ConnStateNotAuthenticated
+	ConnStateAuthenticated
+	ConnStateSelected
+	ConnStateLogout
+)
+
+// String implements fmt.Stringer.
+func (state ConnState) String() string {
+	switch state {
+	case ConnStateNone:
+		return "none"
+	case ConnStateNotAuthenticated:
+		return "not authenticated"
+	case ConnStateAuthenticated:
+		return "authenticated"
+	case ConnStateSelected:
+		return "selected"
+	case ConnStateLogout:
+		return "logout"
+	default:
+		panic(fmt.Errorf("imap: unknown connection state %v", int(state)))
+	}
+}
+
 // MailboxAttr is a mailbox attribute.
 //
 // Mailbox attributes are defined in RFC 9051 section 7.3.1.
@@ -57,3 +93,9 @@ const (
 	// Permanent flags
 	FlagWildcard Flag = "\\*"
 )
+
+// LiteralReader is a reader for IMAP literals.
+type LiteralReader interface {
+	io.Reader
+	Size() int64
+}
diff --git a/imapclient/append.go b/imapclient/append.go
index a7f2ba8..4102ed1 100644
--- a/imapclient/append.go
+++ b/imapclient/append.go
@@ -2,23 +2,17 @@ package imapclient
 
 import (
 	"io"
-	"time"
 
 	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
 )
 
-// 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 {
+func (c *Client) Append(mailbox string, size int64, options *imap.AppendOptions) *AppendCommand {
 	cmd := &AppendCommand{}
 	cmd.enc = c.beginCommand("APPEND", cmd)
 	cmd.enc.SP().Mailbox(mailbox).SP()
@@ -28,7 +22,7 @@ func (c *Client) Append(mailbox string, size int64, options *AppendOptions) *App
 		}).SP()
 	}
 	if options != nil && !options.Time.IsZero() {
-		cmd.enc.String(options.Time.Format(dateTimeLayout)).SP()
+		cmd.enc.String(options.Time.Format(internal.DateTimeLayout)).SP()
 	}
 	cmd.wc = cmd.enc.Literal(size)
 	return cmd
@@ -41,7 +35,7 @@ type AppendCommand struct {
 	cmd
 	enc  *commandEncoder
 	wc   io.WriteCloser
-	data AppendData
+	data imap.AppendData
 }
 
 func (cmd *AppendCommand) Write(b []byte) (int, error) {
@@ -57,11 +51,6 @@ func (cmd *AppendCommand) Close() error {
 	return err
 }
 
-func (cmd *AppendCommand) Wait() (*AppendData, error) {
+func (cmd *AppendCommand) Wait() (*imap.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
index a2328c6..fd8594c 100644
--- a/imapclient/authenticate.go
+++ b/imapclient/authenticate.go
@@ -1,12 +1,12 @@
 package imapclient
 
 import (
-	"encoding/base64"
 	"fmt"
 
 	"github.com/emersion/go-sasl"
 
 	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
 )
 
 // Authenticate sends an AUTHENTICATE command.
@@ -18,12 +18,18 @@ func (c *Client) Authenticate(saslClient sasl.Client) error {
 		return err
 	}
 
+	// c.Caps may send a CAPABILITY command, so check it before c.beginCommand
+	var hasSASLIR bool
+	if initialResp != nil {
+		hasSASLIR = c.Caps().Has(imap.CapSASLIR)
+	}
+
 	cmd := &authenticateCommand{}
 	contReq := c.registerContReq(cmd)
 	enc := c.beginCommand("AUTHENTICATE", cmd)
 	enc.SP().Atom(mech)
-	if initialResp != nil && c.Caps().Has(imap.CapSASLIR) {
-		enc.SP().Atom(encodeSASL(initialResp))
+	if initialResp != nil && hasSASLIR {
+		enc.SP().Atom(internal.EncodeSASL(initialResp))
 		initialResp = nil
 	}
 	enc.flush()
@@ -48,7 +54,7 @@ func (c *Client) Authenticate(saslClient sasl.Client) error {
 			continue
 		}
 
-		challenge, err := decodeSASL(challengeStr)
+		challenge, err := internal.DecodeSASL(challengeStr)
 		if err != nil {
 			return err
 		}
@@ -70,7 +76,7 @@ type authenticateCommand struct {
 }
 
 func (c *Client) writeSASLResp(resp []byte) error {
-	respStr := encodeSASL(resp)
+	respStr := internal.EncodeSASL(resp)
 	if _, err := c.bw.WriteString(respStr + "\r\n"); err != nil {
 		return err
 	}
@@ -79,19 +85,3 @@ func (c *Client) writeSASLResp(resp []byte) error {
 	}
 	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/client.go b/imapclient/client.go
index 7e1fe22..95ffa53 100644
--- a/imapclient/client.go
+++ b/imapclient/client.go
@@ -14,51 +14,19 @@ import (
 	"time"
 
 	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
 	"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
+	cmdWriteTimeout     = 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
@@ -74,11 +42,13 @@ func (mbox *SelectedMailbox) copy() *SelectedMailbox {
 
 // Options contains options for Client.
 type Options struct {
-	// Raw ingress and egress data will be written to this writer, if any
+	// Raw ingress and egress data will be written to this writer, if any.
+	// Note, this may include sensitive information such as credentials used
+	// during authentication.
 	DebugWriter io.Writer
-	// Unilateral data handler
+	// Unilateral data handler.
 	UnilateralDataHandler *UnilateralDataHandler
-	// Decoder for RFC 2047 words
+	// Decoder for RFC 2047 words.
 	WordDecoder *mime.WordDecoder
 }
 
@@ -142,7 +112,7 @@ type Client struct {
 	decErr error
 
 	mutex       sync.Mutex
-	state       State
+	state       imap.ConnState
 	caps        imap.CapSet
 	mailbox     *SelectedMailbox
 	cmdTag      uint64
@@ -170,10 +140,10 @@ func New(conn net.Conn, options *Options) *Client {
 		options:    *options,
 		br:         br,
 		bw:         bw,
-		dec:        imapwire.NewDecoder(br),
+		dec:        imapwire.NewDecoder(br, imapwire.ConnSideClient),
 		greetingCh: make(chan struct{}),
 		decCh:      make(chan struct{}),
-		state:      StateNone,
+		state:      imap.ConnStateNone,
 	}
 	go client.read()
 	return client
@@ -227,17 +197,17 @@ func (c *Client) setWriteTimeout(dur time.Duration) {
 	}
 }
 
-// State returns the current state of the client.
-func (c *Client) State() State {
+// State returns the current connection state of the client.
+func (c *Client) State() imap.ConnState {
 	c.mutex.Lock()
 	defer c.mutex.Unlock()
 	return c.state
 }
 
-func (c *Client) setState(state State) {
+func (c *Client) setState(state imap.ConnState) {
 	c.mutex.Lock()
 	c.state = state
-	if c.state != StateSelected {
+	if c.state != imap.ConnStateSelected {
 		c.mailbox = nil
 	}
 	c.mutex.Unlock()
@@ -317,9 +287,18 @@ func (c *Client) beginCommand(name string, cmd command) *commandEncoder {
 	c.cmdTag++
 	tag := fmt.Sprintf("T%v", c.cmdTag)
 	c.pendingCmds = append(c.pendingCmds, cmd)
+	quotedUTF8 := c.caps.Has(imap.CapIMAP4rev2) || c.caps.Has(imap.CapUTF8Accept)
+	literalMinus := c.caps.Has(imap.CapLiteralMinus)
 	c.mutex.Unlock()
 
-	c.setWriteTimeout(respWriteTimeout)
+	c.setWriteTimeout(cmdWriteTimeout)
+
+	wireEnc := imapwire.NewEncoder(c.bw, imapwire.ConnSideClient)
+	wireEnc.QuotedUTF8 = quotedUTF8
+	wireEnc.LiteralMinus = literalMinus
+	wireEnc.NewContinuationRequest = func() *imapwire.ContinuationRequest {
+		return c.registerContReq(cmd)
+	}
 
 	baseCmd := cmd.base()
 	*baseCmd = Command{
@@ -327,7 +306,7 @@ func (c *Client) beginCommand(name string, cmd command) *commandEncoder {
 		done: make(chan error, 1),
 	}
 	enc := &commandEncoder{
-		Encoder: imapwire.NewEncoder(c.bw),
+		Encoder: wireEnc,
 		client:  c,
 		cmd:     baseCmd,
 	}
@@ -395,12 +374,12 @@ func (c *Client) completeCommand(cmd command, err error) {
 	switch cmd := cmd.(type) {
 	case *authenticateCommand, *loginCommand:
 		if err == nil {
-			c.setState(StateAuthenticated)
+			c.setState(imap.ConnStateAuthenticated)
 		}
 	case *SelectCommand:
 		if err == nil {
 			c.mutex.Lock()
-			c.state = StateSelected
+			c.state = imap.ConnStateSelected
 			c.mailbox = &SelectedMailbox{
 				Name:           cmd.mailbox,
 				NumMessages:    cmd.data.NumMessages,
@@ -411,11 +390,11 @@ func (c *Client) completeCommand(cmd command, err error) {
 		}
 	case *unselectCommand:
 		if err == nil {
-			c.setState(StateAuthenticated)
+			c.setState(imap.ConnStateAuthenticated)
 		}
 	case *logoutCommand:
 		if err == nil {
-			c.setState(StateLogout)
+			c.setState(imap.ConnStateLogout)
 		}
 	case *ListCommand:
 		if cmd.pendingData != nil {
@@ -467,7 +446,7 @@ func (c *Client) read() {
 		c.conn.Close()
 
 		c.mutex.Lock()
-		c.state = StateLogout
+		c.state = imap.ConnStateLogout
 		pendingCmds := c.pendingCmds
 		c.pendingCmds = nil
 		c.mutex.Unlock()
@@ -703,13 +682,13 @@ func (c *Client) readResponseData(typ string) error {
 				if !c.dec.ExpectSP() {
 					return c.dec.Err()
 				}
-				flags, err := readFlagList(c.dec)
+				flags, err := internal.ReadFlagList(c.dec)
 				if err != nil {
 					return err
 				}
 
 				c.mutex.Lock()
-				if c.state == StateSelected {
+				if c.state == imap.ConnStateSelected {
 					c.mailbox = c.mailbox.copy()
 					c.mailbox.PermanentFlags = flags
 				}
@@ -771,17 +750,17 @@ func (c *Client) readResponseData(typ string) error {
 		}
 
 		if code == "CLOSED" {
-			c.setState(StateAuthenticated)
+			c.setState(imap.ConnStateAuthenticated)
 		}
 
 		if !c.greetingRecv {
 			switch typ {
 			case "OK":
-				c.setState(StateNotAuthenticated)
+				c.setState(imap.ConnStateNotAuthenticated)
 			case "PREAUTH":
-				c.setState(StateAuthenticated)
+				c.setState(imap.ConnStateAuthenticated)
 			default:
-				c.setState(StateLogout)
+				c.setState(imap.ConnStateLogout)
 				c.greetingErr = &imap.Error{
 					Type: imap.StatusResponseType(typ),
 					Code: imap.ResponseCode(code),
@@ -987,7 +966,7 @@ type literalWriter struct {
 }
 
 func (lw literalWriter) Close() error {
-	lw.client.setWriteTimeout(respWriteTimeout)
+	lw.client.setWriteTimeout(cmdWriteTimeout)
 	return lw.WriteCloser.Close()
 }
 
@@ -1058,31 +1037,3 @@ type loginCommand struct {
 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
index f80b384..78abe99 100644
--- a/imapclient/copy.go
+++ b/imapclient/copy.go
@@ -28,32 +28,15 @@ func (c *Client) UIDCopy(seqSet imap.SeqSet, mailbox string) *CopyCommand {
 // CopyCommand is a COPY command.
 type CopyCommand struct {
 	cmd
-	data CopyData
+	data imap.CopyData
 }
 
-func (cmd *CopyCommand) Wait() (*CopyData, error) {
+func (cmd *CopyCommand) Wait() (*imap.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 {
+	if !dec.ExpectNumber(&uidValidity) || !dec.ExpectSP() || !dec.ExpectSeqSet(&srcUIDs) || !dec.ExpectSP() || !dec.ExpectSeqSet(&dstUIDs) {
 		return 0, imap.SeqSet{}, imap.SeqSet{}, dec.Err()
 	}
 	return uidValidity, srcUIDs, dstUIDs, nil
diff --git a/imapclient/expunge.go b/imapclient/expunge.go
index 8f891b0..086443c 100644
--- a/imapclient/expunge.go
+++ b/imapclient/expunge.go
@@ -24,7 +24,7 @@ func (c *Client) UIDExpunge(uids imap.SeqSet) *ExpungeCommand {
 
 func (c *Client) handleExpunge(seqNum uint32) error {
 	c.mutex.Lock()
-	if c.state == StateSelected && c.mailbox.NumMessages > 0 {
+	if c.state == imap.ConnStateSelected && c.mailbox.NumMessages > 0 {
 		c.mailbox = c.mailbox.copy()
 		c.mailbox.NumMessages--
 	}
diff --git a/imapclient/fetch.go b/imapclient/fetch.go
index 800a186..975ba0b 100644
--- a/imapclient/fetch.go
+++ b/imapclient/fetch.go
@@ -7,16 +7,17 @@ import (
 	"time"
 
 	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
 	"github.com/emersion/go-imap/v2/internal/imapwire"
 )
 
-func (c *Client) fetch(uid bool, seqSet imap.SeqSet, items []FetchItem) *FetchCommand {
+func (c *Client) fetch(uid bool, seqSet imap.SeqSet, items []imap.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}
+		itemsWithUID := []imap.FetchItem{imap.FetchItemUID}
 		for _, item := range items {
-			if item != FetchItemUID {
+			if item != imap.FetchItemUID {
 				itemsWithUID = append(itemsWithUID, item)
 			}
 		}
@@ -40,105 +41,32 @@ func (c *Client) fetch(uid bool, seqSet imap.SeqSet, items []FetchItem) *FetchCo
 //
 // 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 {
+func (c *Client) Fetch(seqSet imap.SeqSet, items []imap.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 {
+func (c *Client) UIDFetch(seqSet imap.SeqSet, items []imap.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) {
+func writeFetchItem(enc *imapwire.Encoder, item imap.FetchItem) {
 	switch item := item.(type) {
-	case FetchItemKeyword:
+	case imap.FetchItemKeyword:
 		enc.Atom(string(item))
-	case *FetchItemBodySection:
+	case *imap.FetchItemBodySection:
 		enc.Atom("BODY")
 		if item.Peek {
 			enc.Atom(".PEEK")
 		}
 		enc.Special('[')
 		writeSectionPart(enc, item.Part)
-		if len(item.Part) > 0 && item.Specifier != PartSpecifierNone {
+		if len(item.Part) > 0 && item.Specifier != imap.PartSpecifierNone {
 			enc.Special('.')
 		}
-		if item.Specifier != PartSpecifierNone {
+		if item.Specifier != imap.PartSpecifierNone {
 			enc.Atom(string(item.Specifier))
 
 			var headerList []string
@@ -158,7 +86,7 @@ func writeFetchItem(enc *imapwire.Encoder, item FetchItem) {
 		}
 		enc.Special(']')
 		writeSectionPartial(enc, item.Partial)
-	case *FetchItemBinarySection:
+	case *imap.FetchItemBinarySection:
 		enc.Atom("BINARY")
 		if item.Peek {
 			enc.Atom(".PEEK")
@@ -167,7 +95,7 @@ func writeFetchItem(enc *imapwire.Encoder, item FetchItem) {
 		writeSectionPart(enc, item.Part)
 		enc.Special(']')
 		writeSectionPartial(enc, item.Partial)
-	case *FetchItemBinarySectionSize:
+	case *imap.FetchItemBinarySectionSize:
 		enc.Atom("BINARY.SIZE")
 		enc.Special('[')
 		writeSectionPart(enc, item.Part)
@@ -189,7 +117,7 @@ func writeSectionPart(enc *imapwire.Encoder, part []int) {
 	enc.Atom(strings.Join(l, "."))
 }
 
-func writeSectionPartial(enc *imapwire.Encoder, partial *SectionPartial) {
+func writeSectionPartial(enc *imapwire.Encoder, partial *imap.SectionPartial) {
 	if partial == nil {
 		return
 	}
@@ -335,8 +263,8 @@ var (
 
 // FetchItemDataBodySection holds data returned by FETCH BODY[].
 type FetchItemDataBodySection struct {
-	Section *FetchItemBodySection
-	Literal LiteralReader
+	Section *imap.FetchItemBodySection
+	Literal imap.LiteralReader
 }
 
 func (FetchItemDataBodySection) fetchItemData() {}
@@ -347,8 +275,8 @@ func (item FetchItemDataBodySection) discard() {
 
 // FetchItemDataBinarySection holds data returned by FETCH BINARY[].
 type FetchItemDataBinarySection struct {
-	Section *FetchItemBinarySection
-	Literal LiteralReader
+	Section *imap.FetchItemBinarySection
+	Literal imap.LiteralReader
 }
 
 func (FetchItemDataBinarySection) fetchItemData() {}
@@ -366,7 +294,7 @@ func (FetchItemDataFlags) fetchItemData() {}
 
 // FetchItemDataEnvelope holds data returned by FETCH ENVELOPE.
 type FetchItemDataEnvelope struct {
-	Envelope *Envelope
+	Envelope *imap.Envelope
 }
 
 func (FetchItemDataEnvelope) fetchItemData() {}
@@ -395,7 +323,7 @@ func (FetchItemDataUID) fetchItemData() {}
 // FetchItemDataBodyStructure holds data returned by FETCH BODYSTRUCTURE or
 // FETCH BODY.
 type FetchItemDataBodyStructure struct {
-	BodyStructure BodyStructure
+	BodyStructure imap.BodyStructure
 	IsExtended    bool // True if BODYSTRUCTURE, false if BODY
 }
 
@@ -409,218 +337,19 @@ type FetchItemDataBinarySectionSize struct {
 
 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
+	Envelope          *imap.Envelope
 	InternalDate      time.Time
 	RFC822Size        int64
 	UID               uint32
-	BodyStructure     BodyStructure
-	BodySection       map[*FetchItemBodySection][]byte
-	BinarySection     map[*FetchItemBinarySection][]byte
+	BodyStructure     imap.BodyStructure
+	BodySection       map[*imap.FetchItemBodySection][]byte
+	BinarySection     map[*imap.FetchItemBinarySection][]byte
 	BinarySectionSize []FetchItemDataBinarySectionSize
 }
 
@@ -632,7 +361,7 @@ func (buf *FetchMessageBuffer) populateItemData(item FetchItemData) error {
 			return err
 		}
 		if buf.BodySection == nil {
-			buf.BodySection = make(map[*FetchItemBodySection][]byte)
+			buf.BodySection = make(map[*imap.FetchItemBodySection][]byte)
 		}
 		buf.BodySection[item.Section] = b
 	case FetchItemDataBinarySection:
@@ -641,7 +370,7 @@ func (buf *FetchMessageBuffer) populateItemData(item FetchItemData) error {
 			return err
 		}
 		if buf.BinarySection == nil {
-			buf.BinarySection = make(map[*FetchItemBinarySection][]byte)
+			buf.BinarySection = make(map[*imap.FetchItemBinarySection][]byte)
 		}
 		buf.BinarySection[item.Section] = b
 	case FetchItemDataFlags:
@@ -723,24 +452,25 @@ func (c *Client) handleFetch(seqNum uint32) error {
 		if !dec.Expect(dec.Func(&attName, isMsgAttNameChar), "msg-att name") {
 			return dec.Err()
 		}
+		attName = strings.ToUpper(attName)
 
 		var (
 			item FetchItemData
 			done chan struct{}
 		)
-		switch attName := FetchItemKeyword(attName); attName {
-		case FetchItemFlags:
+		switch attName := imap.FetchItemKeyword(attName); attName {
+		case imap.FetchItemFlags:
 			if !dec.ExpectSP() {
 				return dec.Err()
 			}
 
-			flags, err := readFlagList(dec)
+			flags, err := internal.ReadFlagList(dec)
 			if err != nil {
 				return err
 			}
 
 			item = FetchItemDataFlags{Flags: flags}
-		case FetchItemEnvelope:
+		case imap.FetchItemEnvelope:
 			if !dec.ExpectSP() {
 				return dec.Err()
 			}
@@ -751,25 +481,25 @@ func (c *Client) handleFetch(seqNum uint32) error {
 			}
 
 			item = FetchItemDataEnvelope{Envelope: envelope}
-		case FetchItemInternalDate:
+		case imap.FetchItemInternalDate:
 			if !dec.ExpectSP() {
 				return dec.Err()
 			}
 
-			t, err := readDateTime(dec)
+			t, err := internal.ExpectDateTime(dec)
 			if err != nil {
 				return err
 			}
 
 			item = FetchItemDataInternalDate{Time: t}
-		case FetchItemRFC822Size:
+		case imap.FetchItemRFC822Size:
 			var size int64
 			if !dec.ExpectSP() || !dec.ExpectNumber64(&size) {
 				return dec.Err()
 			}
 
 			item = FetchItemDataRFC822Size{Size: size}
-		case FetchItemUID:
+		case imap.FetchItemUID:
 			if !dec.ExpectSP() || !dec.ExpectNumber(&uid) {
 				return dec.Err()
 			}
@@ -777,7 +507,7 @@ func (c *Client) handleFetch(seqNum uint32) error {
 			item = FetchItemDataUID{UID: uid}
 		case "BODY", "BINARY":
 			if dec.Special('[') {
-				var section FetchItem
+				var section imap.FetchItem
 				switch attName {
 				case "BODY":
 					var err error
@@ -793,7 +523,7 @@ func (c *Client) handleFetch(seqNum uint32) error {
 					if !dec.ExpectSpecial(']') {
 						return dec.Err()
 					}
-					section = &FetchItemBinarySection{Part: part}
+					section = &imap.FetchItemBinarySection{Part: part}
 				}
 
 				if !dec.ExpectSP() {
@@ -805,7 +535,7 @@ func (c *Client) handleFetch(seqNum uint32) error {
 					return dec.Err()
 				}
 
-				var fetchLit LiteralReader
+				var fetchLit imap.LiteralReader
 				if lit != nil {
 					done = make(chan struct{})
 					fetchLit = &fetchLiteralReader{
@@ -815,12 +545,12 @@ func (c *Client) handleFetch(seqNum uint32) error {
 				}
 
 				switch section := section.(type) {
-				case *FetchItemBodySection:
+				case *imap.FetchItemBodySection:
 					item = FetchItemDataBodySection{
 						Section: section,
 						Literal: fetchLit,
 					}
-				case *FetchItemBinarySection:
+				case *imap.FetchItemBinarySection:
 					item = FetchItemDataBinarySection{
 						Section: section,
 						Literal: fetchLit,
@@ -832,7 +562,7 @@ func (c *Client) handleFetch(seqNum uint32) error {
 				return dec.Err()
 			}
 			fallthrough
-		case FetchItemBodyStructure:
+		case imap.FetchItemBodyStructure:
 			if !dec.ExpectSP() {
 				return dec.Err()
 			}
@@ -844,7 +574,7 @@ func (c *Client) handleFetch(seqNum uint32) error {
 
 			item = FetchItemDataBodyStructure{
 				BodyStructure: bodyStruct,
-				IsExtended:    attName == FetchItemBodyStructure,
+				IsExtended:    attName == imap.FetchItemBodyStructure,
 			}
 		case "BINARY.SIZE":
 			part, dot := readSectionPart(dec)
@@ -888,8 +618,8 @@ func isMsgAttNameChar(ch byte) bool {
 	return ch != '[' && imapwire.IsAtomChar(ch)
 }
 
-func readEnvelope(dec *imapwire.Decoder, options *Options) (*Envelope, error) {
-	var envelope Envelope
+func readEnvelope(dec *imapwire.Decoder, options *Options) (*imap.Envelope, error) {
+	var envelope imap.Envelope
 
 	if !dec.ExpectSpecial('(') {
 		return nil, dec.Err()
@@ -904,7 +634,7 @@ func readEnvelope(dec *imapwire.Decoder, options *Options) (*Envelope, error) {
 
 	addrLists := []struct {
 		name string
-		out  *[]Address
+		out  *[]imap.Address
 	}{
 		{"env-from", &envelope.From},
 		{"env-sender", &envelope.Sender},
@@ -933,8 +663,8 @@ func readEnvelope(dec *imapwire.Decoder, options *Options) (*Envelope, error) {
 	return &envelope, nil
 }
 
-func readAddressList(dec *imapwire.Decoder, options *Options) ([]Address, error) {
-	var l []Address
+func readAddressList(dec *imapwire.Decoder, options *Options) ([]imap.Address, error) {
+	var l []imap.Address
 	err := dec.ExpectNList(func() error {
 		addr, err := readAddress(dec, options)
 		if err != nil {
@@ -946,9 +676,9 @@ func readAddressList(dec *imapwire.Decoder, options *Options) ([]Address, error)
 	return l, err
 }
 
-func readAddress(dec *imapwire.Decoder, options *Options) (*Address, error) {
+func readAddress(dec *imapwire.Decoder, options *Options) (*imap.Address, error) {
 	var (
-		addr     Address
+		addr     imap.Address
 		name     string
 		obsRoute string
 	)
@@ -965,19 +695,7 @@ func readAddress(dec *imapwire.Decoder, options *Options) (*Address, error) {
 	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) {
+func readBody(dec *imapwire.Decoder, options *Options) (imap.BodyStructure, error) {
 	if !dec.ExpectSpecial('(') {
 		return nil, dec.Err()
 	}
@@ -985,7 +703,7 @@ func readBody(dec *imapwire.Decoder, options *Options) (BodyStructure, error) {
 	var (
 		mediaType string
 		token     string
-		bs        BodyStructure
+		bs        imap.BodyStructure
 		err       error
 	)
 	if dec.String(&mediaType) {
@@ -1012,8 +730,8 @@ func readBody(dec *imapwire.Decoder, options *Options) (BodyStructure, error) {
 	return bs, nil
 }
 
-func readBodyType1part(dec *imapwire.Decoder, typ string, options *Options) (*BodyStructureSinglePart, error) {
-	bs := BodyStructureSinglePart{Type: typ}
+func readBodyType1part(dec *imapwire.Decoder, typ string, options *Options) (*imap.BodyStructureSinglePart, error) {
+	bs := imap.BodyStructureSinglePart{Type: typ}
 
 	if !dec.ExpectSP() || !dec.ExpectString(&bs.Subtype) || !dec.ExpectSP() {
 		return nil, dec.Err()
@@ -1037,7 +755,7 @@ func readBodyType1part(dec *imapwire.Decoder, typ string, options *Options) (*Bo
 	bs.Description, _ = options.decodeText(description)
 
 	if strings.EqualFold(bs.Type, "message") && (strings.EqualFold(bs.Subtype, "rfc822") || strings.EqualFold(bs.Subtype, "global")) {
-		var msg BodyStructureMessageRFC822
+		var msg imap.BodyStructureMessageRFC822
 
 		if !dec.ExpectSP() {
 			return nil, dec.Err()
@@ -1063,7 +781,7 @@ func readBodyType1part(dec *imapwire.Decoder, typ string, options *Options) (*Bo
 
 		bs.MessageRFC822 = &msg
 	} else if strings.EqualFold(bs.Type, "text") {
-		var text BodyStructureText
+		var text imap.BodyStructureText
 
 		if !dec.ExpectSP() || !dec.ExpectNumber64(&text.NumLines) {
 			return nil, dec.Err()
@@ -1082,10 +800,11 @@ func readBodyType1part(dec *imapwire.Decoder, typ string, options *Options) (*Bo
 	return &bs, nil
 }
 
-func readBodyExt1part(dec *imapwire.Decoder, options *Options) (*BodyStructureSinglePartExt, error) {
-	var ext BodyStructureSinglePartExt
+func readBodyExt1part(dec *imapwire.Decoder, options *Options) (*imap.BodyStructureSinglePartExt, error) {
+	var ext imap.BodyStructureSinglePartExt
 
-	if !dec.ExpectNString(&ext.MD5) {
+	var md5 string
+	if !dec.ExpectNString(&md5) {
 		return nil, dec.Err()
 	}
 
@@ -1119,8 +838,8 @@ func readBodyExt1part(dec *imapwire.Decoder, options *Options) (*BodyStructureSi
 	return &ext, nil
 }
 
-func readBodyTypeMpart(dec *imapwire.Decoder, options *Options) (*BodyStructureMultiPart, error) {
-	var bs BodyStructureMultiPart
+func readBodyTypeMpart(dec *imapwire.Decoder, options *Options) (*imap.BodyStructureMultiPart, error) {
+	var bs imap.BodyStructureMultiPart
 
 	for {
 		child, err := readBody(dec, options)
@@ -1145,8 +864,8 @@ func readBodyTypeMpart(dec *imapwire.Decoder, options *Options) (*BodyStructureM
 	return &bs, nil
 }
 
-func readBodyExtMpart(dec *imapwire.Decoder, options *Options) (*BodyStructureMultiPartExt, error) {
-	var ext BodyStructureMultiPartExt
+func readBodyExtMpart(dec *imapwire.Decoder, options *Options) (*imap.BodyStructureMultiPartExt, error) {
+	var ext imap.BodyStructureMultiPartExt
 
 	var err error
 	ext.Params, err = readBodyFldParam(dec)
@@ -1183,7 +902,7 @@ func readBodyExtMpart(dec *imapwire.Decoder, options *Options) (*BodyStructureMu
 	return &ext, nil
 }
 
-func readBodyFldDsp(dec *imapwire.Decoder, options *Options) (*BodyStructureDisposition, error) {
+func readBodyFldDsp(dec *imapwire.Decoder, options *Options) (*imap.BodyStructureDisposition, error) {
 	if !dec.Special('(') {
 		if !dec.ExpectNIL() {
 			return nil, dec.Err()
@@ -1191,7 +910,7 @@ func readBodyFldDsp(dec *imapwire.Decoder, options *Options) (*BodyStructureDisp
 		return nil, nil
 	}
 
-	var disp BodyStructureDisposition
+	var disp imap.BodyStructureDisposition
 	if !dec.ExpectString(&disp.Value) || !dec.ExpectSP() {
 		return nil, dec.Err()
 	}
@@ -1268,17 +987,22 @@ func readBodyFldLang(dec *imapwire.Decoder) ([]string, error) {
 	}
 }
 
-func readSectionSpec(dec *imapwire.Decoder) (*FetchItemBodySection, error) {
-	var section FetchItemBodySection
+func readSectionSpec(dec *imapwire.Decoder) (*imap.FetchItemBodySection, error) {
+	var section imap.FetchItemBodySection
 
 	var dot bool
 	section.Part, dot = readSectionPart(dec)
-	if dot {
+	if dot || len(section.Part) == 0 {
 		var specifier string
-		if !dec.ExpectAtom(&specifier) {
-			return nil, dec.Err()
+		if dot {
+			if !dec.ExpectAtom(&specifier) {
+				return nil, dec.Err()
+			}
+		} else {
+			dec.Atom(&specifier)
 		}
-		section.Specifier = PartSpecifier(specifier)
+		specifier = strings.ToUpper(specifier)
+		section.Specifier = imap.PartSpecifier(specifier)
 
 		if specifier == "HEADER.FIELDS" || specifier == "HEADER.FIELDS.NOT" {
 			if !dec.ExpectSP() {
@@ -1289,6 +1013,7 @@ func readSectionSpec(dec *imapwire.Decoder) (*FetchItemBodySection, error) {
 			if err != nil {
 				return nil, err
 			}
+			section.Specifier = imap.PartSpecifierHeader
 			if specifier == "HEADER.FIELDS" {
 				section.HeaderFields = headerList
 			} else {
@@ -1306,7 +1031,7 @@ func readSectionSpec(dec *imapwire.Decoder) (*FetchItemBodySection, error) {
 		return nil, err
 	}
 	if offset != nil {
-		section.Partial = &SectionPartial{Offset: int64(*offset)}
+		section.Partial = &imap.SectionPartial{Offset: int64(*offset)}
 	}
 
 	return &section, nil
@@ -1338,13 +1063,14 @@ func readHeaderList(dec *imapwire.Decoder) ([]string, error) {
 
 func readSectionPart(dec *imapwire.Decoder) (part []int, dot bool) {
 	for {
-		if len(part) > 0 && !dec.Special('.') {
+		dot = len(part) > 0
+		if dot && !dec.Special('.') {
 			return part, false
 		}
 
 		var num uint32
 		if !dec.Number(&num) {
-			return part, true
+			return part, dot
 		}
 		part = append(part, int(num))
 	}
diff --git a/imapclient/idle.go b/imapclient/idle.go
index 08250f5..28dff2a 100644
--- a/imapclient/idle.go
+++ b/imapclient/idle.go
@@ -49,7 +49,7 @@ func (cmd *IdleCommand) Close() error {
 	if cmd.enc == nil {
 		return fmt.Errorf("imapclient: IDLE command closed twice")
 	}
-	cmd.enc.client.setWriteTimeout(respWriteTimeout)
+	cmd.enc.client.setWriteTimeout(cmdWriteTimeout)
 	_, err := cmd.enc.client.bw.WriteString("DONE\r\n")
 	if err == nil {
 		err = cmd.enc.client.bw.Flush()
diff --git a/imapclient/list.go b/imapclient/list.go
index 6bd6587..299e52f 100644
--- a/imapclient/list.go
+++ b/imapclient/list.go
@@ -2,24 +2,15 @@ package imapclient
 
 import (
 	"fmt"
+	"strings"
 	"unicode/utf8"
 
 	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
 	"github.com/emersion/go-imap/v2/internal/imapwire"
 )
 
-// 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 {
+func getSelectOpts(options *imap.ListOptions) []string {
 	if options == nil {
 		return nil
 	}
@@ -37,7 +28,7 @@ func (options *ListOptions) selectOpts() []string {
 	return l
 }
 
-func (options *ListOptions) returnOpts() []string {
+func getReturnOpts(options *imap.ListOptions) []string {
 	if options == nil {
 		return nil
 	}
@@ -64,19 +55,19 @@ func (options *ListOptions) returnOpts() []string {
 //
 // A non-zero options value requires support for IMAP4rev2 or the LIST-EXTENDED
 // extension.
-func (c *Client) List(ref, pattern string, options *ListOptions) *ListCommand {
+func (c *Client) List(ref, pattern string, options *imap.ListOptions) *ListCommand {
 	cmd := &ListCommand{
-		mailboxes:    make(chan *ListData, 64),
+		mailboxes:    make(chan *imap.ListData, 64),
 		returnStatus: options != nil && len(options.ReturnStatus) > 0,
 	}
 	enc := c.beginCommand("LIST", cmd)
-	if selectOpts := options.selectOpts(); len(selectOpts) > 0 {
+	if selectOpts := getSelectOpts(options); 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 {
+	if returnOpts := getReturnOpts(options); len(returnOpts) > 0 {
 		enc.SP().Atom("RETURN").SP().List(len(returnOpts), func(i int) {
 			opt := returnOpts[i]
 			enc.Atom(opt)
@@ -127,17 +118,17 @@ func (c *Client) handleList() error {
 // ListCommand is a LIST command.
 type ListCommand struct {
 	cmd
-	mailboxes chan *ListData
+	mailboxes chan *imap.ListData
 
 	returnStatus bool
-	pendingData  *ListData
+	pendingData  *imap.ListData
 }
 
 // Next advances to the next mailbox.
 //
 // On success, the mailbox LIST data is returned. On error or if there are no
 // more mailboxes, nil is returned.
-func (cmd *ListCommand) Next() *ListData {
+func (cmd *ListCommand) Next() *imap.ListData {
 	return <-cmd.mailboxes
 }
 
@@ -155,8 +146,8 @@ func (cmd *ListCommand) Close() error {
 // 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
+func (cmd *ListCommand) Collect() ([]*imap.ListData, error) {
+	var l []*imap.ListData
 	for {
 		data := cmd.Next()
 		if data == nil {
@@ -167,27 +158,11 @@ func (cmd *ListCommand) Collect() ([]*ListData, error) {
 	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
+func readList(dec *imapwire.Decoder) (*imap.ListData, error) {
+	var data imap.ListData
 
 	err := dec.ExpectList(func() error {
-		attr, err := readFlag(dec)
+		attr, err := internal.ReadFlag(dec)
 		if err != nil {
 			return err
 		}
@@ -207,14 +182,10 @@ func readList(dec *imapwire.Decoder) (*ListData, error) {
 		return nil, err
 	}
 
-	if !dec.ExpectSP() {
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&data.Mailbox) {
 		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
@@ -222,7 +193,7 @@ func readList(dec *imapwire.Decoder) (*ListData, error) {
 				return dec.Err()
 			}
 			var err error
-			switch tag {
+			switch strings.ToUpper(tag) {
 			case "CHILDINFO":
 				data.ChildInfo, err = readChildInfoExtendedItem(dec)
 				if err != nil {
@@ -248,14 +219,14 @@ func readList(dec *imapwire.Decoder) (*ListData, error) {
 	return &data, nil
 }
 
-func readChildInfoExtendedItem(dec *imapwire.Decoder) (*ListDataChildInfo, error) {
-	var childInfo ListDataChildInfo
+func readChildInfoExtendedItem(dec *imapwire.Decoder) (*imap.ListDataChildInfo, error) {
+	var childInfo imap.ListDataChildInfo
 	err := dec.ExpectList(func() error {
 		var opt string
 		if !dec.ExpectAString(&opt) {
 			return dec.Err()
 		}
-		if opt == "SUBSCRIBED" {
+		if strings.ToUpper(opt) == "SUBSCRIBED" {
 			childInfo.Subscribed = true
 		}
 		return nil
@@ -264,14 +235,8 @@ func readChildInfoExtendedItem(dec *imapwire.Decoder) (*ListDataChildInfo, error
 }
 
 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(')') {
+	var name string
+	if !dec.ExpectSpecial('(') || !dec.ExpectMailbox(&name) || !dec.ExpectSpecial(')') {
 		return "", dec.Err()
 	}
 	return name, nil
diff --git a/imapclient/metadata.go b/imapclient/metadata.go
index f4ab4d2..b331fd2 100644
--- a/imapclient/metadata.go
+++ b/imapclient/metadata.go
@@ -141,12 +141,7 @@ type GetMetadataData struct {
 func readMetadataResp(dec *imapwire.Decoder) (*GetMetadataData, error) {
 	var data GetMetadataData
 
-	var err error
-	if data.Mailbox, err = dec.ExpectMailbox(); err != nil {
-		return nil, err
-	}
-
-	if !dec.ExpectSP() {
+	if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() {
 		return nil, dec.Err()
 	}
 
diff --git a/imapclient/move.go b/imapclient/move.go
index 1152b09..3fa1d97 100644
--- a/imapclient/move.go
+++ b/imapclient/move.go
@@ -18,8 +18,8 @@ func (c *Client) move(uid bool, seqSet imap.SeqSet, mailbox string) *MoveCommand
 	enc.end()
 
 	if cmdName == "COPY" {
-		cmd.store = c.store(uid, seqSet, &StoreFlags{
-			Op:     StoreFlagsAdd,
+		cmd.store = c.store(uid, seqSet, &imap.StoreFlags{
+			Op:     imap.StoreFlagsAdd,
 			Silent: true,
 			Flags:  []imap.Flag{imap.FlagDeleted},
 		})
diff --git a/imapclient/namespace.go b/imapclient/namespace.go
index 21ec29c..50a2660 100644
--- a/imapclient/namespace.go
+++ b/imapclient/namespace.go
@@ -3,6 +3,7 @@ package imapclient
 import (
 	"fmt"
 
+	"github.com/emersion/go-imap/v2"
 	"github.com/emersion/go-imap/v2/internal/imapwire"
 )
 
@@ -29,29 +30,16 @@ func (c *Client) handleNamespace() error {
 // NamespaceCommand is a NAMESPACE command.
 type NamespaceCommand struct {
 	cmd
-	data NamespaceData
+	data imap.NamespaceData
 }
 
-func (cmd *NamespaceCommand) Wait() (*NamespaceData, error) {
+func (cmd *NamespaceCommand) Wait() (*imap.NamespaceData, error) {
 	return &cmd.data, cmd.cmd.Wait()
 }
 
-// NamespaceData is the data returned by the NAMESPACE command.
-type NamespaceData struct {
-	Personal []NamespaceDescriptor
-	Other    []NamespaceDescriptor
-	Shared   []NamespaceDescriptor
-}
-
-// NamespaceDescriptor describes a namespace.
-type NamespaceDescriptor struct {
-	Prefix string
-	Delim  rune
-}
-
-func readNamespaceResponse(dec *imapwire.Decoder) (*NamespaceData, error) {
+func readNamespaceResponse(dec *imapwire.Decoder) (*imap.NamespaceData, error) {
 	var (
-		data NamespaceData
+		data imap.NamespaceData
 		err  error
 	)
 
@@ -81,8 +69,8 @@ func readNamespaceResponse(dec *imapwire.Decoder) (*NamespaceData, error) {
 	return &data, nil
 }
 
-func readNamespace(dec *imapwire.Decoder) ([]NamespaceDescriptor, error) {
-	var l []NamespaceDescriptor
+func readNamespace(dec *imapwire.Decoder) ([]imap.NamespaceDescriptor, error) {
+	var l []imap.NamespaceDescriptor
 	err := dec.ExpectNList(func() error {
 		descr, err := readNamespaceDescr(dec)
 		if err != nil {
@@ -94,8 +82,8 @@ func readNamespace(dec *imapwire.Decoder) ([]NamespaceDescriptor, error) {
 	return l, err
 }
 
-func readNamespaceDescr(dec *imapwire.Decoder) (*NamespaceDescriptor, error) {
-	var descr NamespaceDescriptor
+func readNamespaceDescr(dec *imapwire.Decoder) (*imap.NamespaceDescriptor, error) {
+	var descr imap.NamespaceDescriptor
 
 	if !dec.ExpectSpecial('(') || !dec.ExpectString(&descr.Prefix) || !dec.ExpectSP() {
 		return nil, dec.Err()
diff --git a/imapclient/quota.go b/imapclient/quota.go
index 0b7cbc8..cf9363f 100644
--- a/imapclient/quota.go
+++ b/imapclient/quota.go
@@ -162,9 +162,8 @@ func readQuotaResponse(dec *imapwire.Decoder) (*QuotaData, error) {
 }
 
 func readQuotaRoot(dec *imapwire.Decoder) (mailbox string, roots []string, err error) {
-	mailbox, err = dec.ExpectMailbox()
-	if err != nil {
-		return "", nil, err
+	if !dec.ExpectMailbox(&mailbox) {
+		return "", nil, dec.Err()
 	}
 	for dec.SP() {
 		var root string
diff --git a/imapclient/search.go b/imapclient/search.go
index fe995ae..5ff52bc 100644
--- a/imapclient/search.go
+++ b/imapclient/search.go
@@ -6,28 +6,11 @@ import (
 	"time"
 
 	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
 	"github.com/emersion/go-imap/v2/internal/imapwire"
 )
 
-const searchDateLayout = "2-Jan-2006"
-
-// SearchReturnOption indicates what kind of results to return from a SEARCH
-// command.
-type SearchReturnOption string
-
-const (
-	SearchReturnMin   SearchReturnOption = "MIN"
-	SearchReturnMax   SearchReturnOption = "MAX"
-	SearchReturnAll   SearchReturnOption = "ALL"
-	SearchReturnCount SearchReturnOption = "COUNT"
-)
-
-// SearchOptions contains options for the SEARCH command.
-type SearchOptions struct {
-	Return []SearchReturnOption // requires IMAP4rev2 or ESEARCH
-}
-
-func (c *Client) search(uid bool, criteria *SearchCriteria, options *SearchOptions) *SearchCommand {
+func (c *Client) search(uid bool, criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand {
 	// TODO: use CHARSET UTF-8 with an US-ASCII fallback for IMAP4rev1 servers
 	// TODO: add support for SEARCHRES
 	cmd := &SearchCommand{}
@@ -44,12 +27,12 @@ func (c *Client) search(uid bool, criteria *SearchCriteria, options *SearchOptio
 }
 
 // Search sends a SEARCH command.
-func (c *Client) Search(criteria *SearchCriteria, options *SearchOptions) *SearchCommand {
+func (c *Client) Search(criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand {
 	return c.search(false, criteria, options)
 }
 
 // UIDSearch sends a UID SEARCH command.
-func (c *Client) UIDSearch(criteria *SearchCriteria, options *SearchOptions) *SearchCommand {
+func (c *Client) UIDSearch(criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand {
 	return c.search(true, criteria, options)
 }
 
@@ -96,64 +79,14 @@ func (c *Client) handleESearch() error {
 // SearchCommand is a SEARCH command.
 type SearchCommand struct {
 	cmd
-	data SearchData
+	data imap.SearchData
 }
 
-func (cmd *SearchCommand) Wait() (*SearchData, error) {
+func (cmd *SearchCommand) Wait() (*imap.SearchData, error) {
 	return &cmd.data, cmd.cmd.Wait()
 }
 
-// SearchData is the data returned by a SEARCH command.
-type SearchData struct {
-	All imap.SeqSet
-
-	// requires IMAP4rev2 or ESEARCH
-	UID   bool
-	Min   uint32
-	Max   uint32
-	Count uint32
-}
-
-// 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) {
+func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria) {
 	enc.Special('(')
 
 	firstItem := true
@@ -173,23 +106,23 @@ func writeSearchKey(enc *imapwire.Encoder, criteria *SearchCriteria) {
 	}
 
 	if !criteria.Since.IsZero() && !criteria.Before.IsZero() && criteria.Before.Sub(criteria.Since) == 24*time.Hour {
-		encodeItem("ON").SP().String(criteria.Since.Format(searchDateLayout))
+		encodeItem("ON").SP().String(criteria.Since.Format(internal.DateLayout))
 	} else {
 		if !criteria.Since.IsZero() {
-			encodeItem("SINCE").SP().String(criteria.Since.Format(searchDateLayout))
+			encodeItem("SINCE").SP().String(criteria.Since.Format(internal.DateLayout))
 		}
 		if !criteria.Before.IsZero() {
-			encodeItem("BEFORE").SP().String(criteria.Before.Format(searchDateLayout))
+			encodeItem("BEFORE").SP().String(criteria.Before.Format(internal.DateLayout))
 		}
 	}
 	if !criteria.SentSince.IsZero() && !criteria.SentBefore.IsZero() && criteria.SentBefore.Sub(criteria.SentSince) == 24*time.Hour {
-		encodeItem("SENTON").SP().String(criteria.SentSince.Format(searchDateLayout))
+		encodeItem("SENTON").SP().String(criteria.SentSince.Format(internal.DateLayout))
 	} else {
 		if !criteria.SentSince.IsZero() {
-			encodeItem("SENTSINCE").SP().String(criteria.SentSince.Format(searchDateLayout))
+			encodeItem("SENTSINCE").SP().String(criteria.SentSince.Format(internal.DateLayout))
 		}
 		if !criteria.SentBefore.IsZero() {
-			encodeItem("SENTBEFORE").SP().String(criteria.SentBefore.Format(searchDateLayout))
+			encodeItem("SENTBEFORE").SP().String(criteria.SentBefore.Format(internal.DateLayout))
 		}
 	}
 
@@ -259,8 +192,8 @@ func flagSearchKey(flag imap.Flag) string {
 	}
 }
 
-func readESearchResponse(dec *imapwire.Decoder) (tag string, data *SearchData, err error) {
-	data = &SearchData{}
+func readESearchResponse(dec *imapwire.Decoder) (tag string, data *imap.SearchData, err error) {
+	data = &imap.SearchData{}
 
 	if dec.Special('(') { // search-correlator
 		var correlator string
@@ -283,29 +216,24 @@ func readESearchResponse(dec *imapwire.Decoder) (tag string, data *SearchData, e
 		}
 	}
 	for {
-		switch returnOpt := SearchReturnOption(name); returnOpt {
-		case SearchReturnMin:
+		switch returnOpt := imap.SearchReturnOption(name); returnOpt {
+		case imap.SearchReturnMin:
 			var num uint32
 			if !dec.ExpectNumber(&num) {
 				return "", nil, dec.Err()
 			}
 			data.Min = num
-		case SearchReturnMax:
+		case imap.SearchReturnMax:
 			var num uint32
 			if !dec.ExpectNumber(&num) {
 				return "", nil, dec.Err()
 			}
 			data.Max = num
-		case SearchReturnAll:
-			var s string
-			if !dec.ExpectAtom(&s) {
+		case imap.SearchReturnAll:
+			if !dec.ExpectSeqSet(&data.All) {
 				return "", nil, dec.Err()
 			}
-			data.All, err = imap.ParseSeqSet(s)
-			if err != nil {
-				return "", nil, err
-			}
-		case SearchReturnCount:
+		case imap.SearchReturnCount:
 			var num uint32
 			if !dec.ExpectNumber(&num) {
 				return "", nil, dec.Err()
diff --git a/imapclient/select.go b/imapclient/select.go
index ff94686..98aa782 100644
--- a/imapclient/select.go
+++ b/imapclient/select.go
@@ -2,6 +2,7 @@ package imapclient
 
 import (
 	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
 )
 
 // Select sends a SELECT command.
@@ -43,13 +44,13 @@ func (c *Client) UnselectAndExpunge() *Command {
 }
 
 func (c *Client) handleFlags() error {
-	flags, err := readFlagList(c.dec)
+	flags, err := internal.ReadFlagList(c.dec)
 	if err != nil {
 		return err
 	}
 
 	c.mutex.Lock()
-	if c.state == StateSelected {
+	if c.state == imap.ConnStateSelected {
 		c.mailbox = c.mailbox.copy()
 		c.mailbox.PermanentFlags = flags
 	}
@@ -71,7 +72,7 @@ func (c *Client) handleExists(num uint32) error {
 		cmd.data.NumMessages = num
 	} else {
 		c.mutex.Lock()
-		if c.state == StateSelected {
+		if c.state == imap.ConnStateSelected {
 			c.mailbox = c.mailbox.copy()
 			c.mailbox.NumMessages = num
 		}
@@ -88,29 +89,13 @@ func (c *Client) handleExists(num uint32) error {
 type SelectCommand struct {
 	cmd
 	mailbox string
-	data    SelectData
+	data    imap.SelectData
 }
 
-func (cmd *SelectCommand) Wait() (*SelectData, error) {
+func (cmd *SelectCommand) Wait() (*imap.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/sort.go b/imapclient/sort.go
index ffe626b..2f59327 100644
--- a/imapclient/sort.go
+++ b/imapclient/sort.go
@@ -1,5 +1,9 @@
 package imapclient
 
+import (
+	"github.com/emersion/go-imap/v2"
+)
+
 type SortKey string
 
 const (
@@ -19,7 +23,7 @@ type SortCriterion struct {
 
 // SortOptions contains options for the SORT command.
 type SortOptions struct {
-	SearchCriteria *SearchCriteria
+	SearchCriteria *imap.SearchCriteria
 	SortCriteria   []SortCriterion
 }
 
diff --git a/imapclient/status.go b/imapclient/status.go
index 85e14ef..881d954 100644
--- a/imapclient/status.go
+++ b/imapclient/status.go
@@ -2,12 +2,14 @@ package imapclient
 
 import (
 	"fmt"
+	"strings"
 
+	"github.com/emersion/go-imap/v2"
 	"github.com/emersion/go-imap/v2/internal/imapwire"
 )
 
 // Status sends a STATUS command.
-func (c *Client) Status(mailbox string, items []StatusItem) *StatusCommand {
+func (c *Client) Status(mailbox string, items []imap.StatusItem) *StatusCommand {
 	cmd := &StatusCommand{mailbox: mailbox}
 	enc := c.beginCommand("STATUS", cmd)
 	enc.SP().Mailbox(mailbox).SP()
@@ -50,58 +52,21 @@ func (c *Client) handleStatus() error {
 type StatusCommand struct {
 	cmd
 	mailbox string
-	data    StatusData
+	data    imap.StatusData
 }
 
-func (cmd *StatusCommand) Wait() (*StatusData, error) {
+func (cmd *StatusCommand) Wait() (*imap.StatusData, error) {
 	return &cmd.data, cmd.cmd.Wait()
 }
 
-// StatusItem is a data item which can be requested by a STATUS command.
-type StatusItem string
+func readStatus(dec *imapwire.Decoder) (*imap.StatusData, error) {
+	var data imap.StatusData
 
-const (
-	StatusItemNumMessages StatusItem = "MESSAGES"
-	StatusItemUIDNext     StatusItem = "UIDNEXT"
-	StatusItemUIDValidity StatusItem = "UIDVALIDITY"
-	StatusItemNumUnseen   StatusItem = "UNSEEN"
-	StatusItemNumDeleted  StatusItem = "DELETED" // requires IMAP4rev2 or QUOTA
-	StatusItemSize        StatusItem = "SIZE"    // requires IMAP4rev2 or STATUS=SIZE
-
-	StatusItemAppendLimit    StatusItem = "APPENDLIMIT"     // requires APPENDLIMIT
-	StatusItemDeletedStorage StatusItem = "DELETED-STORAGE" // requires QUOTA=RES-STORAGE
-)
-
-// 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
-	DeletedStorage *int64
-}
-
-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() {
+	if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() {
 		return nil, dec.Err()
 	}
 
-	err = dec.ExpectList(func() error {
+	err := dec.ExpectList(func() error {
 		if err := readStatusAttVal(dec, &data); err != nil {
 			return fmt.Errorf("in status-att-val: %v", dec.Err())
 		}
@@ -110,35 +75,35 @@ func readStatus(dec *imapwire.Decoder) (*StatusData, error) {
 	return &data, err
 }
 
-func readStatusAttVal(dec *imapwire.Decoder, data *StatusData) error {
+func readStatusAttVal(dec *imapwire.Decoder, data *imap.StatusData) error {
 	var name string
 	if !dec.ExpectAtom(&name) || !dec.ExpectSP() {
 		return dec.Err()
 	}
 
 	var ok bool
-	switch StatusItem(name) {
-	case StatusItemNumMessages:
+	switch imap.StatusItem(strings.ToUpper(name)) {
+	case imap.StatusItemNumMessages:
 		var num uint32
 		ok = dec.ExpectNumber(&num)
 		data.NumMessages = &num
-	case StatusItemUIDNext:
+	case imap.StatusItemUIDNext:
 		ok = dec.ExpectNumber(&data.UIDNext)
-	case StatusItemUIDValidity:
+	case imap.StatusItemUIDValidity:
 		ok = dec.ExpectNumber(&data.UIDValidity)
-	case StatusItemNumUnseen:
+	case imap.StatusItemNumUnseen:
 		var num uint32
 		ok = dec.ExpectNumber(&num)
 		data.NumUnseen = &num
-	case StatusItemNumDeleted:
+	case imap.StatusItemNumDeleted:
 		var num uint32
 		ok = dec.ExpectNumber(&num)
 		data.NumDeleted = &num
-	case StatusItemSize:
+	case imap.StatusItemSize:
 		var size int64
 		ok = dec.ExpectNumber64(&size)
 		data.Size = &size
-	case StatusItemAppendLimit:
+	case imap.StatusItemAppendLimit:
 		var num uint32
 		if dec.Number(&num) {
 			ok = true
@@ -147,7 +112,7 @@ func readStatusAttVal(dec *imapwire.Decoder, data *StatusData) error {
 			num = ^uint32(0)
 		}
 		data.AppendLimit = &num
-	case StatusItemDeletedStorage:
+	case imap.StatusItemDeletedStorage:
 		var storage int64
 		ok = dec.ExpectNumber64(&storage)
 		data.DeletedStorage = &storage
diff --git a/imapclient/store.go b/imapclient/store.go
index ee7b380..6e76cc4 100644
--- a/imapclient/store.go
+++ b/imapclient/store.go
@@ -6,16 +6,16 @@ import (
 	"github.com/emersion/go-imap/v2"
 )
 
-func (c *Client) store(uid bool, seqSet imap.SeqSet, store *StoreFlags) *FetchCommand {
+func (c *Client) store(uid bool, seqSet imap.SeqSet, store *imap.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:
+	case imap.StoreFlagsSet:
 		// nothing to do
-	case StoreFlagsAdd:
+	case imap.StoreFlagsAdd:
 		enc.Special('+')
-	case StoreFlagsDel:
+	case imap.StoreFlagsDel:
 		enc.Special('-')
 	default:
 		panic(fmt.Errorf("imapclient: unknown store flags op: %v", store.Op))
@@ -34,29 +34,13 @@ func (c *Client) store(uid bool, seqSet imap.SeqSet, store *StoreFlags) *FetchCo
 // 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 {
+func (c *Client) Store(seqSet imap.SeqSet, store *imap.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 {
+func (c *Client) UIDStore(seqSet imap.SeqSet, store *imap.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/imapclient/thread.go b/imapclient/thread.go
index 5c03298..098d63b 100644
--- a/imapclient/thread.go
+++ b/imapclient/thread.go
@@ -10,7 +10,7 @@ import (
 // ThreadOptions contains options for the THREAD command.
 type ThreadOptions struct {
 	Algorithm      imap.ThreadAlgorithm
-	SearchCriteria *SearchCriteria
+	SearchCriteria *imap.SearchCriteria
 }
 
 func (c *Client) thread(uid bool, options *ThreadOptions) *ThreadCommand {
diff --git a/imapserver/append.go b/imapserver/append.go
new file mode 100644
index 0000000..20ed5f9
--- /dev/null
+++ b/imapserver/append.go
@@ -0,0 +1,102 @@
+package imapserver
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+// appendLimit is the maximum size of an APPEND payload.
+//
+// TODO: make configurable
+const appendLimit = 100 * 1024 * 1024 // 100MiB
+
+func (c *Conn) handleAppend(tag string, dec *imapwire.Decoder) error {
+	var (
+		mailbox string
+		options imap.AppendOptions
+	)
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() {
+		return dec.Err()
+	}
+
+	hasFlagList, err := dec.List(func() error {
+		flag, err := internal.ReadFlag(dec)
+		if err != nil {
+			return err
+		}
+		options.Flags = append(options.Flags, imap.Flag(flag))
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	if hasFlagList && !dec.ExpectSP() {
+		return dec.Err()
+	}
+
+	t, err := internal.DecodeDateTime(dec)
+	if err != nil {
+		return err
+	}
+	if !t.IsZero() && !dec.ExpectSP() {
+		return dec.Err()
+	}
+	options.Time = t
+
+	lit, nonSync, err := dec.ExpectLiteralReader()
+	if err != nil {
+		return err
+	}
+	if lit.Size() > appendLimit {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Code: imap.ResponseCodeTooBig,
+			Text: fmt.Sprintf("Literals are limited to %v bytes for this command", appendLimit),
+		}
+	}
+	if err := c.acceptLiteral(lit.Size(), nonSync); err != nil {
+		return err
+	}
+
+	c.setReadTimeout(literalReadTimeout)
+	defer c.setReadTimeout(cmdReadTimeout)
+
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		io.Copy(io.Discard, lit)
+		dec.CRLF()
+		return err
+	}
+
+	data, appendErr := c.session.Append(mailbox, lit, &options)
+	if _, discardErr := io.Copy(io.Discard, lit); discardErr != nil {
+		return err
+	}
+	if !dec.ExpectCRLF() {
+		return err
+	}
+	if appendErr != nil {
+		return appendErr
+	}
+	if err := c.poll("APPEND"); err != nil {
+		return err
+	}
+	return c.writeAppendOK(tag, data)
+}
+
+func (c *Conn) writeAppendOK(tag string, data *imap.AppendData) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+
+	enc.Atom(tag).SP().Atom("OK").SP()
+	if data != nil {
+		enc.Special('[')
+		enc.Atom("APPENDUID").SP().Number(data.UIDValidity).SP().Number(data.UID)
+		enc.Special(']').SP()
+	}
+	enc.Text("APPEND completed")
+	return enc.CRLF()
+}
diff --git a/imapserver/authenticate.go b/imapserver/authenticate.go
new file mode 100644
index 0000000..de0f802
--- /dev/null
+++ b/imapserver/authenticate.go
@@ -0,0 +1,118 @@
+package imapserver
+
+import (
+	"fmt"
+
+	"github.com/emersion/go-sasl"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleAuthenticate(tag string, dec *imapwire.Decoder) error {
+	var mech string
+	if !dec.ExpectSP() || !dec.ExpectAtom(&mech) {
+		return dec.Err()
+	}
+
+	var initialResp []byte
+	if dec.SP() {
+		var initialRespStr string
+		if !dec.ExpectText(&initialRespStr) {
+			return dec.Err()
+		}
+		var err error
+		initialResp, err = internal.DecodeSASL(initialRespStr)
+		if err != nil {
+			return err
+		}
+	}
+
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateNotAuthenticated); err != nil {
+		return err
+	}
+	if !c.canAuth() {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Code: imap.ResponseCodePrivacyRequired,
+			Text: "TLS is required to authenticate",
+		}
+	}
+
+	// TODO: support other SASL mechanisms
+	if mech != "PLAIN" {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "SASL mechanism not supported",
+		}
+	}
+
+	enc := newResponseEncoder(c)
+	defer enc.end()
+
+	saslServer := sasl.NewPlainServer(func(identity, username, password string) error {
+		if identity != "" && identity != username {
+			return &imap.Error{
+				Type: imap.StatusResponseTypeNo,
+				Code: imap.ResponseCodeAuthorizationFailed,
+				Text: "SASL identity not supported",
+			}
+		}
+		return c.session.Login(username, password)
+	})
+
+	resp := initialResp
+	for {
+		challenge, done, err := saslServer.Next(resp)
+		if err != nil {
+			return err
+		} else if done {
+			break
+		}
+
+		var challengeStr string
+		if challenge != nil {
+			challengeStr = internal.EncodeSASL(challenge)
+		}
+		if err := writeContReq(enc.Encoder, challengeStr); err != nil {
+			return err
+		}
+
+		respStr, isPrefix, err := c.br.ReadLine()
+		if err != nil {
+			return err
+		} else if isPrefix {
+			return fmt.Errorf("SASL response too long")
+		} else if string(respStr) == "*" {
+			return &imap.Error{
+				Type: imap.StatusResponseTypeBad,
+				Text: "AUTHENTICATE cancelled",
+			}
+		}
+
+		resp, err = decodeSASL(string(respStr))
+		if err != nil {
+			return err
+		}
+	}
+
+	c.state = imap.ConnStateAuthenticated
+	text := fmt.Sprintf("%v authentication successful", mech)
+	return writeCapabilityOK(enc.Encoder, tag, c.availableCaps(), text)
+}
+
+func decodeSASL(s string) ([]byte, error) {
+	b, err := internal.DecodeSASL(s)
+	if err != nil {
+		return nil, &imap.Error{
+			Type: imap.StatusResponseTypeBad,
+			Text: "Malformed SASL response",
+		}
+	}
+	return b, nil
+}
diff --git a/imapserver/capability.go b/imapserver/capability.go
new file mode 100644
index 0000000..53413d2
--- /dev/null
+++ b/imapserver/capability.go
@@ -0,0 +1,82 @@
+package imapserver
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleCapability(dec *imapwire.Decoder) error {
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	enc.Atom("*").SP().Atom("CAPABILITY")
+	for _, c := range c.availableCaps() {
+		enc.SP().Atom(string(c))
+	}
+	return enc.CRLF()
+}
+
+// availableCaps returns the capabilities supported by the server.
+//
+// They depend on the connection state.
+//
+// Some extensions (e.g. SASL-IR, ENABLE) don't require backend support and
+// thus are always enabled.
+func (c *Conn) availableCaps() []imap.Cap {
+	available := c.server.options.caps()
+
+	var caps []imap.Cap
+	addAvailableCaps(&caps, available, []imap.Cap{
+		imap.CapIMAP4rev2,
+		imap.CapIMAP4rev1,
+	})
+	if len(caps) == 0 {
+		panic("imapserver: must support at least IMAP4rev1 or IMAP4rev2")
+	}
+
+	if available.Has(imap.CapIMAP4rev1) {
+		caps = append(caps, []imap.Cap{
+			imap.CapSASLIR,
+			imap.CapLiteralMinus,
+		}...)
+	}
+	if c.canStartTLS() {
+		caps = append(caps, imap.CapStartTLS)
+	}
+	if c.canAuth() {
+		caps = append(caps, imap.Cap("AUTH=PLAIN"))
+	} else if c.state == imap.ConnStateNotAuthenticated {
+		caps = append(caps, imap.CapLoginDisabled)
+	}
+	if c.state == imap.ConnStateAuthenticated || c.state == imap.ConnStateSelected {
+		if available.Has(imap.CapIMAP4rev1) {
+			caps = append(caps, []imap.Cap{
+				imap.CapUnselect,
+				imap.CapEnable,
+				imap.CapIdle,
+			}...)
+			// TODO: implement imap.CapSearchRes
+			addAvailableCaps(&caps, available, []imap.Cap{
+				imap.CapNamespace,
+				imap.CapUIDPlus,
+				imap.CapESearch,
+				imap.CapListExtended,
+				imap.CapListStatus,
+				imap.CapMove,
+				imap.CapStatusSize,
+			})
+		}
+	}
+	return caps
+}
+
+func addAvailableCaps(caps *[]imap.Cap, available imap.CapSet, l []imap.Cap) {
+	for _, c := range l {
+		if available.Has(c) {
+			*caps = append(*caps, c)
+		}
+	}
+}
diff --git a/imapserver/conn.go b/imapserver/conn.go
new file mode 100644
index 0000000..c19318c
--- /dev/null
+++ b/imapserver/conn.go
@@ -0,0 +1,586 @@
+package imapserver
+
+import (
+	"bufio"
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"runtime/debug"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+const (
+	cmdReadTimeout     = 30 * time.Second
+	idleReadTimeout    = 35 * time.Minute // section 5.4 says 30min minimum
+	literalReadTimeout = 5 * time.Minute
+
+	respWriteTimeout    = 30 * time.Second
+	literalWriteTimeout = 5 * time.Minute
+)
+
+var internalServerErrorResp = &imap.StatusResponse{
+	Type: imap.StatusResponseTypeNo,
+	Code: imap.ResponseCodeServerBug,
+	Text: "Internal server error",
+}
+
+// A Conn represents an IMAP connection to the server.
+type Conn struct {
+	server   *Server
+	br       *bufio.Reader
+	bw       *bufio.Writer
+	encMutex sync.Mutex
+
+	mutex   sync.Mutex
+	conn    net.Conn
+	enabled imap.CapSet
+
+	state   imap.ConnState
+	session Session
+}
+
+func newConn(c net.Conn, server *Server) *Conn {
+	rw := server.options.wrapReadWriter(c)
+	br := bufio.NewReader(rw)
+	bw := bufio.NewWriter(rw)
+	return &Conn{
+		conn:    c,
+		server:  server,
+		br:      br,
+		bw:      bw,
+		enabled: make(imap.CapSet),
+	}
+}
+
+// NetConn returns the underlying connection that is wrapped by the IMAP
+// connection.
+//
+// Writing to or reading from this connection directly will corrupt the IMAP
+// session.
+func (c *Conn) NetConn() net.Conn {
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+	return c.conn
+}
+
+// Bye terminates the IMAP connection.
+func (c *Conn) Bye(text string) error {
+	respErr := c.writeStatusResp("", &imap.StatusResponse{
+		Type: imap.StatusResponseTypeBye,
+		Text: text,
+	})
+	closeErr := c.conn.Close()
+	if respErr != nil {
+		return respErr
+	}
+	return closeErr
+}
+
+func (c *Conn) serve() {
+	defer func() {
+		if v := recover(); v != nil {
+			c.server.logger().Printf("panic handling command: %v\n%s", v, debug.Stack())
+		}
+
+		c.conn.Close()
+	}()
+
+	c.server.mutex.Lock()
+	c.server.conns[c] = struct{}{}
+	c.server.mutex.Unlock()
+	defer func() {
+		c.server.mutex.Lock()
+		delete(c.server.conns, c)
+		c.server.mutex.Unlock()
+	}()
+
+	var err error
+	c.session, err = c.server.options.NewSession(c)
+	if err != nil {
+		var (
+			resp    *imap.StatusResponse
+			imapErr *imap.Error
+		)
+		if errors.As(err, &imapErr) {
+			resp = (*imap.StatusResponse)(imapErr)
+		} else {
+			c.server.logger().Printf("failed to create session: %v", err)
+			resp = internalServerErrorResp
+		}
+		if err := c.writeStatusResp("", resp); err != nil {
+			c.server.logger().Printf("failed to write greeting: %v", err)
+		}
+		return
+	}
+
+	defer func() {
+		if c.session != nil {
+			if err := c.session.Close(); err != nil {
+				c.server.logger().Printf("failed to close session: %v", err)
+			}
+		}
+	}()
+
+	caps := c.server.options.caps()
+	if _, ok := c.session.(SessionIMAP4rev2); !ok && caps.Has(imap.CapIMAP4rev2) {
+		panic("imapserver: server advertises IMAP4rev2 but session doesn't support it")
+	}
+	if _, ok := c.session.(SessionNamespace); !ok && caps.Has(imap.CapNamespace) {
+		panic("imapserver: server advertises NAMESPACE but session doesn't support it")
+	}
+	if _, ok := c.session.(SessionMove); !ok && caps.Has(imap.CapMove) {
+		panic("imapserver: server advertises MOVE but session doesn't support it")
+	}
+
+	c.state = imap.ConnStateNotAuthenticated
+	if err := c.writeCapabilityOK("", "IMAP server ready"); err != nil {
+		c.server.logger().Printf("failed to write greeting: %v", err)
+		return
+	}
+
+	for {
+		var readTimeout time.Duration
+		switch c.state {
+		case imap.ConnStateAuthenticated, imap.ConnStateSelected:
+			readTimeout = idleReadTimeout
+		default:
+			readTimeout = cmdReadTimeout
+		}
+		c.setReadTimeout(readTimeout)
+
+		dec := imapwire.NewDecoder(c.br, imapwire.ConnSideServer)
+		dec.CheckBufferedLiteralFunc = c.checkBufferedLiteral
+
+		if c.state == imap.ConnStateLogout || dec.EOF() {
+			break
+		}
+
+		c.setReadTimeout(cmdReadTimeout)
+		if err := c.readCommand(dec); err != nil {
+			if !errors.Is(err, net.ErrClosed) {
+				c.server.logger().Printf("failed to read command: %v", err)
+			}
+			break
+		}
+	}
+}
+
+func (c *Conn) readCommand(dec *imapwire.Decoder) error {
+	var tag, name string
+	if !dec.ExpectAtom(&tag) || !dec.ExpectSP() || !dec.ExpectAtom(&name) {
+		return fmt.Errorf("in command: %w", dec.Err())
+	}
+	name = strings.ToUpper(name)
+
+	numKind := NumKindSeq
+	if name == "UID" {
+		numKind = NumKindUID
+		var subName string
+		if !dec.ExpectSP() || !dec.ExpectAtom(&subName) {
+			return fmt.Errorf("in command: %w", dec.Err())
+		}
+		name = "UID " + strings.ToUpper(subName)
+	}
+
+	// TODO: handle multiple commands concurrently
+	sendOK := true
+	var err error
+	switch name {
+	case "NOOP", "CHECK":
+		err = c.handleNoop(dec)
+	case "LOGOUT":
+		err = c.handleLogout(dec)
+	case "CAPABILITY":
+		err = c.handleCapability(dec)
+	case "STARTTLS":
+		err = c.handleStartTLS(tag, dec)
+		sendOK = false
+	case "AUTHENTICATE":
+		err = c.handleAuthenticate(tag, dec)
+		sendOK = false
+	case "LOGIN":
+		err = c.handleLogin(tag, dec)
+		sendOK = false
+	case "ENABLE":
+		err = c.handleEnable(dec)
+	case "CREATE":
+		err = c.handleCreate(dec)
+	case "DELETE":
+		err = c.handleDelete(dec)
+	case "RENAME":
+		err = c.handleRename(dec)
+	case "SUBSCRIBE":
+		err = c.handleSubscribe(dec)
+	case "UNSUBSCRIBE":
+		err = c.handleUnsubscribe(dec)
+	case "STATUS":
+		err = c.handleStatus(dec)
+	case "LIST":
+		err = c.handleList(dec)
+	case "LSUB":
+		err = c.handleLSub(dec)
+	case "NAMESPACE":
+		err = c.handleNamespace(dec)
+	case "IDLE":
+		err = c.handleIdle(dec)
+	case "SELECT", "EXAMINE":
+		err = c.handleSelect(tag, dec, name == "EXAMINE")
+		sendOK = false
+	case "CLOSE", "UNSELECT":
+		err = c.handleUnselect(dec, name == "CLOSE")
+	case "APPEND":
+		err = c.handleAppend(tag, dec)
+		sendOK = false
+	case "FETCH", "UID FETCH":
+		err = c.handleFetch(dec, numKind)
+	case "EXPUNGE":
+		err = c.handleExpunge(dec)
+	case "UID EXPUNGE":
+		err = c.handleUIDExpunge(dec)
+	case "STORE", "UID STORE":
+		err = c.handleStore(dec, numKind)
+	case "COPY", "UID COPY":
+		err = c.handleCopy(tag, dec, numKind)
+		sendOK = false
+	case "MOVE", "UID MOVE":
+		err = c.handleMove(dec, numKind)
+	case "SEARCH", "UID SEARCH":
+		err = c.handleSearch(tag, dec, numKind)
+	default:
+		err = &imap.Error{
+			Type: imap.StatusResponseTypeBad,
+			Text: "Unknown command",
+		}
+	}
+
+	dec.DiscardLine()
+
+	var (
+		resp    *imap.StatusResponse
+		imapErr *imap.Error
+		decErr  *imapwire.DecoderExpectError
+	)
+	if errors.As(err, &imapErr) {
+		resp = (*imap.StatusResponse)(imapErr)
+	} else if errors.As(err, &decErr) {
+		resp = &imap.StatusResponse{
+			Type: imap.StatusResponseTypeBad,
+			Code: imap.ResponseCodeClientBug,
+			Text: "Syntax error: " + decErr.Message,
+		}
+	} else if err != nil {
+		c.server.logger().Printf("handling %v command: %v", name, err)
+		resp = internalServerErrorResp
+	} else {
+		if !sendOK {
+			return nil
+		}
+		if err := c.poll(name); err != nil {
+			return err
+		}
+		resp = &imap.StatusResponse{
+			Type: imap.StatusResponseTypeOK,
+			Text: fmt.Sprintf("%v completed", name),
+		}
+	}
+	return c.writeStatusResp(tag, resp)
+}
+
+func (c *Conn) handleNoop(dec *imapwire.Decoder) error {
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	return nil
+}
+
+func (c *Conn) handleLogout(dec *imapwire.Decoder) error {
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	c.state = imap.ConnStateLogout
+
+	return c.writeStatusResp("", &imap.StatusResponse{
+		Type: imap.StatusResponseTypeBye,
+		Text: "Logging out",
+	})
+}
+
+func (c *Conn) handleCreate(dec *imapwire.Decoder) error {
+	var name string
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+	return c.session.Create(name)
+}
+
+func (c *Conn) handleDelete(dec *imapwire.Decoder) error {
+	var name string
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+	return c.session.Delete(name)
+}
+
+func (c *Conn) handleRename(dec *imapwire.Decoder) error {
+	var oldName, newName string
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&oldName) || !dec.ExpectSP() || !dec.ExpectMailbox(&newName) || !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+	return c.session.Rename(oldName, newName)
+}
+
+func (c *Conn) handleSubscribe(dec *imapwire.Decoder) error {
+	var name string
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+	return c.session.Subscribe(name)
+}
+
+func (c *Conn) handleUnsubscribe(dec *imapwire.Decoder) error {
+	var name string
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+	return c.session.Unsubscribe(name)
+}
+
+func (c *Conn) checkBufferedLiteral(size int64, nonSync bool) error {
+	if size > 4096 {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Code: imap.ResponseCodeTooBig,
+			Text: "Literals are limited to 4096 bytes for this command",
+		}
+	}
+
+	return c.acceptLiteral(size, nonSync)
+}
+
+func (c *Conn) acceptLiteral(size int64, nonSync bool) error {
+	if nonSync && size > 4096 {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeBad,
+			Text: "Non-synchronizing literals are limited to 4096 bytes",
+		}
+	}
+
+	if nonSync {
+		return nil
+	}
+
+	return c.writeContReq("Ready for literal data")
+}
+
+func (c *Conn) canAuth() bool {
+	if c.state != imap.ConnStateNotAuthenticated {
+		return false
+	}
+	_, isTLS := c.conn.(*tls.Conn)
+	return isTLS || c.server.options.InsecureAuth
+}
+
+func (c *Conn) writeStatusResp(tag string, statusResp *imap.StatusResponse) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	return writeStatusResp(enc.Encoder, tag, statusResp)
+}
+
+func (c *Conn) writeContReq(text string) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	return writeContReq(enc.Encoder, text)
+}
+
+func (c *Conn) writeCapabilityOK(tag, text string) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	return writeCapabilityOK(enc.Encoder, tag, c.availableCaps(), text)
+}
+
+func (c *Conn) checkState(state imap.ConnState) error {
+	if state == imap.ConnStateAuthenticated && c.state == imap.ConnStateSelected {
+		return nil
+	}
+	if c.state != state {
+		return newClientBugError(fmt.Sprintf("This command is only valid in the %s state", state))
+	}
+	return nil
+}
+
+func (c *Conn) setReadTimeout(dur time.Duration) {
+	if dur > 0 {
+		c.conn.SetReadDeadline(time.Now().Add(dur))
+	} else {
+		c.conn.SetReadDeadline(time.Time{})
+	}
+}
+
+func (c *Conn) setWriteTimeout(dur time.Duration) {
+	if dur > 0 {
+		c.conn.SetWriteDeadline(time.Now().Add(dur))
+	} else {
+		c.conn.SetWriteDeadline(time.Time{})
+	}
+}
+
+func (c *Conn) poll(cmd string) error {
+	switch c.state {
+	case imap.ConnStateAuthenticated, imap.ConnStateSelected:
+		// nothing to do
+	default:
+		return nil
+	}
+
+	allowExpunge := true
+	switch cmd {
+	case "FETCH", "STORE", "SEARCH":
+		allowExpunge = false
+	}
+
+	w := &UpdateWriter{conn: c, allowExpunge: allowExpunge}
+	return c.session.Poll(w, allowExpunge)
+}
+
+type responseEncoder struct {
+	*imapwire.Encoder
+	conn *Conn
+}
+
+func newResponseEncoder(conn *Conn) *responseEncoder {
+	conn.mutex.Lock()
+	quotedUTF8 := conn.enabled.Has(imap.CapIMAP4rev2)
+	conn.mutex.Unlock()
+
+	wireEnc := imapwire.NewEncoder(conn.bw, imapwire.ConnSideServer)
+	wireEnc.QuotedUTF8 = quotedUTF8
+
+	conn.encMutex.Lock() // released by responseEncoder.end
+	conn.setWriteTimeout(respWriteTimeout)
+	return &responseEncoder{
+		Encoder: wireEnc,
+		conn:    conn,
+	}
+}
+
+func (enc *responseEncoder) end() {
+	if enc.Encoder == nil {
+		panic("imapserver: responseEncoder.end called twice")
+	}
+	enc.Encoder = nil
+	enc.conn.setWriteTimeout(0)
+	enc.conn.encMutex.Unlock()
+}
+
+func (enc *responseEncoder) Literal(size int64) io.WriteCloser {
+	enc.conn.setWriteTimeout(literalWriteTimeout)
+	return literalWriter{
+		WriteCloser: enc.Encoder.Literal(size, nil),
+		conn:        enc.conn,
+	}
+}
+
+type literalWriter struct {
+	io.WriteCloser
+	conn *Conn
+}
+
+func (lw literalWriter) Close() error {
+	lw.conn.setWriteTimeout(respWriteTimeout)
+	return lw.WriteCloser.Close()
+}
+
+func writeStatusResp(enc *imapwire.Encoder, tag string, statusResp *imap.StatusResponse) error {
+	if tag == "" {
+		tag = "*"
+	}
+	enc.Atom(tag).SP().Atom(string(statusResp.Type)).SP()
+	if statusResp.Code != "" {
+		enc.Atom(fmt.Sprintf("[%v]", statusResp.Code)).SP()
+	}
+	enc.Text(statusResp.Text)
+	return enc.CRLF()
+}
+
+func writeCapabilityOK(enc *imapwire.Encoder, tag string, caps []imap.Cap, text string) error {
+	if tag == "" {
+		tag = "*"
+	}
+
+	enc.Atom(tag).SP().Atom("OK").SP().Special('[').Atom("CAPABILITY")
+	for _, c := range caps {
+		enc.SP().Atom(string(c))
+	}
+	enc.Special(']').SP().Text(text)
+	return enc.CRLF()
+}
+
+func writeContReq(enc *imapwire.Encoder, text string) error {
+	return enc.Atom("+").SP().Text(text).CRLF()
+}
+
+func newClientBugError(text string) error {
+	return &imap.Error{
+		Type: imap.StatusResponseTypeBad,
+		Code: imap.ResponseCodeClientBug,
+		Text: text,
+	}
+}
+
+// UpdateWriter writes status updates.
+type UpdateWriter struct {
+	conn         *Conn
+	allowExpunge bool
+}
+
+// WriteExpunge writes an EXPUNGE response.
+func (w *UpdateWriter) WriteExpunge(seqNum uint32) error {
+	if !w.allowExpunge {
+		return fmt.Errorf("imapserver: EXPUNGE updates are not allowed in this context")
+	}
+	return w.conn.writeExpunge(seqNum)
+}
+
+// WriteNumMessages writes an EXISTS response.
+func (w *UpdateWriter) WriteNumMessages(n uint32) error {
+	return w.conn.writeExists(n)
+}
+
+// WriteMailboxFlags writes a FLAGS response.
+func (w *UpdateWriter) WriteMailboxFlags(flags []imap.Flag) error {
+	return w.conn.writeFlags(flags)
+}
+
+// WriteMessageFlags writes a FETCH response with FLAGS.
+func (w *UpdateWriter) WriteMessageFlags(seqNum, uid uint32, flags []imap.Flag) error {
+	fetchWriter := &FetchWriter{conn: w.conn}
+	respWriter := fetchWriter.CreateMessage(seqNum)
+	if uid != 0 {
+		respWriter.WriteUID(uid)
+	}
+	respWriter.WriteFlags(flags)
+	return respWriter.Close()
+}
diff --git a/imapserver/copy.go b/imapserver/copy.go
new file mode 100644
index 0000000..ea75c7b
--- /dev/null
+++ b/imapserver/copy.go
@@ -0,0 +1,55 @@
+package imapserver
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleCopy(tag string, dec *imapwire.Decoder, numKind NumKind) error {
+	seqSet, dest, err := readCopy(dec)
+	if err != nil {
+		return err
+	}
+	if err := c.checkState(imap.ConnStateSelected); err != nil {
+		return err
+	}
+	data, err := c.session.Copy(numKind, seqSet, dest)
+	if err != nil {
+		return err
+	}
+
+	cmdName := "COPY"
+	if numKind == NumKindUID {
+		cmdName = "UID COPY"
+	}
+	if err := c.poll(cmdName); err != nil {
+		return err
+	}
+
+	return c.writeCopyOK(tag, data)
+}
+
+func (c *Conn) writeCopyOK(tag string, data *imap.CopyData) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+
+	if tag == "" {
+		tag = "*"
+	}
+
+	enc.Atom(tag).SP().Atom("OK").SP()
+	if data != nil {
+		enc.Special('[')
+		enc.Atom("COPYUID").SP().Number(data.UIDValidity).SP().Atom(data.SourceUIDs.String()).SP().Atom(data.DestUIDs.String())
+		enc.Special(']').SP()
+	}
+	enc.Text("COPY completed")
+	return enc.CRLF()
+}
+
+func readCopy(dec *imapwire.Decoder) (seqSet imap.SeqSet, dest string, err error) {
+	if !dec.ExpectSP() || !dec.ExpectSeqSet(&seqSet) || !dec.ExpectSP() || !dec.ExpectMailbox(&dest) || !dec.ExpectCRLF() {
+		return nil, "", dec.Err()
+	}
+	return seqSet, dest, nil
+}
diff --git a/imapserver/enable.go b/imapserver/enable.go
new file mode 100644
index 0000000..8349785
--- /dev/null
+++ b/imapserver/enable.go
@@ -0,0 +1,46 @@
+package imapserver
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleEnable(dec *imapwire.Decoder) error {
+	var requested []imap.Cap
+	for dec.SP() {
+		var c string
+		if !dec.ExpectAtom(&c) {
+			return dec.Err()
+		}
+		requested = append(requested, imap.Cap(c))
+	}
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+
+	var enabled []imap.Cap
+	for _, req := range requested {
+		switch req {
+		case imap.CapIMAP4rev2:
+			enabled = append(enabled, req)
+		}
+	}
+
+	c.mutex.Lock()
+	for _, e := range enabled {
+		c.enabled[e] = struct{}{}
+	}
+	c.mutex.Unlock()
+
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	enc.Atom("*").SP().Atom("ENABLED")
+	for _, c := range enabled {
+		enc.SP().Atom(string(c))
+	}
+	return enc.CRLF()
+}
diff --git a/imapserver/expunge.go b/imapserver/expunge.go
new file mode 100644
index 0000000..b281493
--- /dev/null
+++ b/imapserver/expunge.go
@@ -0,0 +1,50 @@
+package imapserver
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleExpunge(dec *imapwire.Decoder) error {
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	return c.expunge(nil)
+}
+
+func (c *Conn) handleUIDExpunge(dec *imapwire.Decoder) error {
+	var seqSet imap.SeqSet
+	if !dec.ExpectSP() || !dec.ExpectSeqSet(&seqSet) || !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	return c.expunge(&seqSet)
+}
+
+func (c *Conn) expunge(uids *imap.SeqSet) error {
+	if err := c.checkState(imap.ConnStateSelected); err != nil {
+		return err
+	}
+	w := &ExpungeWriter{conn: c}
+	return c.session.Expunge(w, uids)
+}
+
+func (c *Conn) writeExpunge(seqNum uint32) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	enc.Atom("*").SP().Number(seqNum).SP().Atom("EXPUNGE")
+	return enc.CRLF()
+}
+
+// ExpungeWriter writes EXPUNGE updates.
+type ExpungeWriter struct {
+	conn *Conn
+}
+
+// WriteExpunge notifies the client that the message with the provided sequence
+// number has been deleted.
+func (w *ExpungeWriter) WriteExpunge(seqNum uint32) error {
+	if w.conn == nil {
+		return nil
+	}
+	return w.conn.writeExpunge(seqNum)
+}
diff --git a/imapserver/fetch.go b/imapserver/fetch.go
new file mode 100644
index 0000000..509337c
--- /dev/null
+++ b/imapserver/fetch.go
@@ -0,0 +1,665 @@
+package imapserver
+
+import (
+	"fmt"
+	"io"
+	"mime"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleFetch(dec *imapwire.Decoder, numKind NumKind) error {
+	var seqSet imap.SeqSet
+	if !dec.ExpectSP() || !dec.ExpectSeqSet(&seqSet) || !dec.ExpectSP() {
+		return dec.Err()
+	}
+
+	var items []imap.FetchItem
+	isList, err := dec.List(func() error {
+		item, err := readFetchAtt(dec)
+		if err != nil {
+			return err
+		}
+		switch item {
+		case imap.FetchItemAll, imap.FetchItemFast, imap.FetchItemFull:
+			return newClientBugError("FETCH macros are not allowed in a list")
+		}
+		items = append(items, item)
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	if !isList {
+		item, err := readFetchAtt(dec)
+		if err != nil {
+			return err
+		}
+
+		// Handle macros
+		switch item {
+		case imap.FetchItemAll:
+			items = append(items, imap.FetchItemFlags, imap.FetchItemInternalDate, imap.FetchItemRFC822Size, imap.FetchItemEnvelope)
+		case imap.FetchItemFast:
+			items = append(items, imap.FetchItemFlags, imap.FetchItemInternalDate, imap.FetchItemRFC822Size)
+		case imap.FetchItemFull:
+			items = append(items, imap.FetchItemFlags, imap.FetchItemInternalDate, imap.FetchItemRFC822Size, imap.FetchItemEnvelope, imap.FetchItemBody)
+		default:
+			items = append(items, item)
+		}
+	}
+
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateSelected); err != nil {
+		return err
+	}
+
+	obsolete := make(map[imap.FetchItem]imap.FetchItemKeyword)
+	for i, item := range items {
+		var repl imap.FetchItem
+		switch item {
+		case internal.FetchItemRFC822:
+			repl = &imap.FetchItemBodySection{}
+		case internal.FetchItemRFC822Header:
+			repl = &imap.FetchItemBodySection{
+				Peek:      true,
+				Specifier: imap.PartSpecifierHeader,
+			}
+		case internal.FetchItemRFC822Text:
+			repl = &imap.FetchItemBodySection{
+				Specifier: imap.PartSpecifierText,
+			}
+		}
+		if repl != nil {
+			items[i] = repl
+			obsolete[repl] = item.(imap.FetchItemKeyword)
+		}
+	}
+
+	if numKind == NumKindUID {
+		itemsWithUID := []imap.FetchItem{imap.FetchItemUID}
+		for _, item := range items {
+			if item != imap.FetchItemUID {
+				itemsWithUID = append(itemsWithUID, item)
+			}
+		}
+		items = itemsWithUID
+	}
+
+	w := &FetchWriter{conn: c, obsolete: obsolete}
+	if err := c.session.Fetch(w, numKind, seqSet, items); err != nil {
+		return err
+	}
+	return nil
+}
+
+func readFetchAtt(dec *imapwire.Decoder) (imap.FetchItem, error) {
+	var attName string
+	if !dec.Expect(dec.Func(&attName, isMsgAttNameChar), "msg-att name") {
+		return nil, dec.Err()
+	}
+	attName = strings.ToUpper(attName)
+
+	// Keyword fetch items are variables: return the variable so that it can be
+	// compared directly
+	keywords := map[imap.FetchItemKeyword]imap.FetchItem{
+		imap.FetchItemAll.(imap.FetchItemKeyword):              imap.FetchItemAll,
+		imap.FetchItemFast.(imap.FetchItemKeyword):             imap.FetchItemFast,
+		imap.FetchItemFull.(imap.FetchItemKeyword):             imap.FetchItemFull,
+		imap.FetchItemBodyStructure.(imap.FetchItemKeyword):    imap.FetchItemBodyStructure,
+		imap.FetchItemEnvelope.(imap.FetchItemKeyword):         imap.FetchItemEnvelope,
+		imap.FetchItemFlags.(imap.FetchItemKeyword):            imap.FetchItemFlags,
+		imap.FetchItemInternalDate.(imap.FetchItemKeyword):     imap.FetchItemInternalDate,
+		imap.FetchItemRFC822Size.(imap.FetchItemKeyword):       imap.FetchItemRFC822Size,
+		imap.FetchItemUID.(imap.FetchItemKeyword):              imap.FetchItemUID,
+		internal.FetchItemRFC822.(imap.FetchItemKeyword):       internal.FetchItemRFC822,
+		internal.FetchItemRFC822Header.(imap.FetchItemKeyword): internal.FetchItemRFC822Header,
+		internal.FetchItemRFC822Text.(imap.FetchItemKeyword):   internal.FetchItemRFC822Text,
+	}
+	if item, ok := keywords[imap.FetchItemKeyword(attName)]; ok {
+		return item, nil
+	}
+
+	switch attName := imap.FetchItemKeyword(attName); attName {
+	case "BINARY", "BINARY.PEEK":
+		part, err := readSectionBinary(dec)
+		if err != nil {
+			return nil, err
+		}
+		partial, err := maybeReadPartial(dec)
+		if err != nil {
+			return nil, err
+		}
+		return &imap.FetchItemBinarySection{
+			Part:    part,
+			Partial: partial,
+			Peek:    attName == "BINARY.PEEK",
+		}, nil
+	case "BINARY.SIZE":
+		part, err := readSectionBinary(dec)
+		if err != nil {
+			return nil, err
+		}
+		return &imap.FetchItemBinarySectionSize{Part: part}, nil
+	case "BODY":
+		if !dec.Special('[') {
+			return attName, nil
+		}
+		section := imap.FetchItemBodySection{}
+		err := readSection(dec, &section)
+		if err != nil {
+			return nil, err
+		}
+		section.Partial, err = maybeReadPartial(dec)
+		if err != nil {
+			return nil, err
+		}
+		return &section, nil
+	case "BODY.PEEK":
+		if !dec.ExpectSpecial('[') {
+			return nil, dec.Err()
+		}
+		section := imap.FetchItemBodySection{Peek: true}
+		err := readSection(dec, &section)
+		if err != nil {
+			return nil, err
+		}
+		section.Partial, err = maybeReadPartial(dec)
+		if err != nil {
+			return nil, err
+		}
+		return &section, nil
+	default:
+		return nil, newClientBugError("Unknown FETCH data item")
+	}
+}
+
+func isMsgAttNameChar(ch byte) bool {
+	return ch != '[' && imapwire.IsAtomChar(ch)
+}
+
+func readSection(dec *imapwire.Decoder, section *imap.FetchItemBodySection) error {
+	if dec.Special(']') {
+		return nil
+	}
+
+	var dot bool
+	section.Part, dot = readSectionPart(dec)
+	if dot || len(section.Part) == 0 {
+		var specifier string
+		if dot {
+			if !dec.ExpectAtom(&specifier) {
+				return dec.Err()
+			}
+		} else {
+			dec.Atom(&specifier)
+		}
+
+		switch specifier := imap.PartSpecifier(strings.ToUpper(specifier)); specifier {
+		case imap.PartSpecifierNone, imap.PartSpecifierHeader, imap.PartSpecifierMIME, imap.PartSpecifierText:
+			section.Specifier = specifier
+		case "HEADER.FIELDS", "HEADER.FIELDS.NOT":
+			if !dec.ExpectSP() {
+				return dec.Err()
+			}
+			var err error
+			headerList, err := readHeaderList(dec)
+			if err != nil {
+				return err
+			}
+			section.Specifier = imap.PartSpecifierHeader
+			if specifier == "HEADER.FIELDS" {
+				section.HeaderFields = headerList
+			} else {
+				section.HeaderFieldsNot = headerList
+			}
+		default:
+			return newClientBugError("unknown body section specifier")
+		}
+	}
+
+	if !dec.ExpectSpecial(']') {
+		return dec.Err()
+	}
+
+	return nil
+}
+
+func readSectionPart(dec *imapwire.Decoder) (part []int, dot bool) {
+	for {
+		dot = len(part) > 0
+		if dot && !dec.Special('.') {
+			return part, false
+		}
+
+		var num uint32
+		if !dec.Number(&num) {
+			return part, dot
+		}
+		part = append(part, int(num))
+	}
+}
+
+func readHeaderList(dec *imapwire.Decoder) ([]string, error) {
+	var l []string
+	err := dec.ExpectList(func() error {
+		var s string
+		if !dec.ExpectAString(&s) {
+			return dec.Err()
+		}
+		l = append(l, s)
+		return nil
+	})
+	return l, err
+}
+
+func readSectionBinary(dec *imapwire.Decoder) ([]int, error) {
+	if !dec.ExpectSpecial('[') {
+		return nil, dec.Err()
+	}
+	if dec.Special(']') {
+		return nil, nil
+	}
+
+	var l []int
+	for {
+		var num uint32
+		if !dec.ExpectNumber(&num) {
+			return l, dec.Err()
+		}
+		l = append(l, int(num))
+
+		if !dec.Special('.') {
+			break
+		}
+	}
+
+	if !dec.ExpectSpecial(']') {
+		return l, dec.Err()
+	}
+	return l, nil
+}
+
+func maybeReadPartial(dec *imapwire.Decoder) (*imap.SectionPartial, error) {
+	if !dec.Special('<') {
+		return nil, nil
+	}
+	var partial imap.SectionPartial
+	if !dec.ExpectNumber64(&partial.Offset) || !dec.ExpectSpecial('.') || !dec.ExpectNumber64(&partial.Size) || !dec.ExpectSpecial('>') {
+		return nil, dec.Err()
+	}
+	return &partial, nil
+}
+
+// FetchWriter writes FETCH responses.
+type FetchWriter struct {
+	conn     *Conn
+	obsolete map[imap.FetchItem]imap.FetchItemKeyword
+}
+
+// CreateMessage writes a FETCH response for a message.
+//
+// FetchResponseWriter.Close must be called.
+func (cmd *FetchWriter) CreateMessage(seqNum uint32) *FetchResponseWriter {
+	enc := newResponseEncoder(cmd.conn)
+	enc.Atom("*").SP().Number(seqNum).SP().Atom("FETCH").SP().Special('(')
+	return &FetchResponseWriter{enc: enc, obsolete: cmd.obsolete}
+}
+
+// FetchResponseWriter writes a single FETCH response for a message.
+type FetchResponseWriter struct {
+	enc      *responseEncoder
+	hasItem  bool
+	obsolete map[imap.FetchItem]imap.FetchItemKeyword
+}
+
+func (w *FetchResponseWriter) writeItemSep() {
+	if w.hasItem {
+		w.enc.SP()
+	}
+	w.hasItem = true
+}
+
+// WriteUID writes the message's UID.
+func (w *FetchResponseWriter) WriteUID(uid uint32) {
+	w.writeItemSep()
+	w.enc.Atom("UID").SP().Number(uid)
+}
+
+// WriteFlags writes the message's flags.
+func (w *FetchResponseWriter) WriteFlags(flags []imap.Flag) {
+	w.writeItemSep()
+	w.enc.Atom("FLAGS").SP().List(len(flags), func(i int) {
+		w.enc.Flag(flags[i])
+	})
+}
+
+// WriteRFC822Size writes the message's full size.
+func (w *FetchResponseWriter) WriteRFC822Size(size int64) {
+	w.writeItemSep()
+	w.enc.Atom("RFC822.SIZE").SP().Number64(size)
+}
+
+// WriteInternalDate writes the message's internal date.
+func (w *FetchResponseWriter) WriteInternalDate(t time.Time) {
+	w.writeItemSep()
+	w.enc.Atom("INTERNALDATE").SP().String(t.Format(internal.DateTimeLayout))
+}
+
+// WriteBodySection writes a body section.
+//
+// The returned io.WriteCloser must be closed before writing any more message
+// data items.
+func (w *FetchResponseWriter) WriteBodySection(section *imap.FetchItemBodySection, size int64) io.WriteCloser {
+	w.writeItemSep()
+	enc := w.enc.Encoder
+
+	if obs, ok := w.obsolete[section]; ok {
+		enc.Atom(string(obs))
+	} else {
+		writeItemBodySection(enc, section)
+	}
+
+	enc.SP()
+	return w.enc.Literal(size)
+}
+
+func writeItemBodySection(enc *imapwire.Encoder, section *imap.FetchItemBodySection) {
+	enc.Atom("BODY")
+	enc.Special('[')
+	writeSectionPart(enc, section.Part)
+	if len(section.Part) > 0 && section.Specifier != imap.PartSpecifierNone {
+		enc.Special('.')
+	}
+	if section.Specifier != imap.PartSpecifierNone {
+		enc.Atom(string(section.Specifier))
+
+		var headerList []string
+		if len(section.HeaderFields) > 0 {
+			headerList = section.HeaderFields
+			enc.Atom(".FIELDS")
+		} else if len(section.HeaderFieldsNot) > 0 {
+			headerList = section.HeaderFieldsNot
+			enc.Atom(".FIELDS.NOT")
+		}
+
+		if len(headerList) > 0 {
+			enc.SP().List(len(headerList), func(i int) {
+				enc.String(headerList[i])
+			})
+		}
+	}
+	enc.Special(']')
+	if partial := section.Partial; partial != nil {
+		enc.Special('<').Number(uint32(partial.Offset)).Special('>')
+	}
+}
+
+// WriteBinarySection writes a binary section.
+//
+// The returned io.WriteCloser must be closed before writing any more message
+// data items.
+func (w *FetchResponseWriter) WriteBinarySection(section *imap.FetchItemBinarySection, size int64) io.WriteCloser {
+	w.writeItemSep()
+	enc := w.enc.Encoder
+
+	enc.Atom("BINARY").Special('[')
+	writeSectionPart(enc, section.Part)
+	enc.Special(']').SP()
+	return w.enc.Literal(size)
+}
+
+// WriteBinarySectionSize writes a binary section size.
+func (w *FetchResponseWriter) WriteBinarySectionSize(section *imap.FetchItemBinarySection, size uint32) {
+	w.writeItemSep()
+	enc := w.enc.Encoder
+
+	enc.Atom("BINARY.SIZE").Special('[')
+	writeSectionPart(enc, section.Part)
+	enc.Special(']').SP().Number(size)
+}
+
+// WriteEnvelope writes the message's envelope.
+func (w *FetchResponseWriter) WriteEnvelope(envelope *imap.Envelope) {
+	w.writeItemSep()
+	enc := w.enc.Encoder
+	enc.Atom("ENVELOPE").SP()
+	writeEnvelope(enc, envelope)
+}
+
+// WriteBodyStructure writes the message's body structure (either BODYSTRUCTURE
+// or BODY).
+func (w *FetchResponseWriter) WriteBodyStructure(bs imap.BodyStructure) {
+	var extended bool
+	switch bs := bs.(type) {
+	case *imap.BodyStructureSinglePart:
+		extended = bs.Extended != nil
+	case *imap.BodyStructureMultiPart:
+		extended = bs.Extended != nil
+	}
+
+	item := "BODY"
+	if extended {
+		item = "BODYSTRUCTURE"
+	}
+
+	w.writeItemSep()
+	enc := w.enc.Encoder
+	enc.Atom(item).SP()
+	writeBodyStructure(enc, bs)
+}
+
+// Close closes the FETCH message writer.
+func (w *FetchResponseWriter) Close() error {
+	if w.enc == nil {
+		return fmt.Errorf("imapserver: FetchResponseWriter already closed")
+	}
+	err := w.enc.Special(')').CRLF()
+	w.enc.end()
+	w.enc = nil
+	return err
+}
+
+func writeEnvelope(enc *imapwire.Encoder, envelope *imap.Envelope) {
+	if envelope == nil {
+		envelope = new(imap.Envelope)
+	}
+
+	sender := envelope.Sender
+	if sender == nil {
+		sender = envelope.From
+	}
+	replyTo := envelope.ReplyTo
+	if replyTo == nil {
+		replyTo = envelope.From
+	}
+
+	enc.Special('(')
+	writeNString(enc, envelope.Date)
+	enc.SP()
+	writeNString(enc, mime.QEncoding.Encode("utf-8", envelope.Subject))
+	addrs := [][]imap.Address{
+		envelope.From,
+		sender,
+		replyTo,
+		envelope.To,
+		envelope.Cc,
+		envelope.Bcc,
+	}
+	for _, l := range addrs {
+		enc.SP()
+		writeAddressList(enc, l)
+	}
+	enc.SP()
+	writeNString(enc, envelope.InReplyTo)
+	enc.SP()
+	writeNString(enc, envelope.MessageID)
+	enc.Special(')')
+}
+
+func writeAddressList(enc *imapwire.Encoder, l []imap.Address) {
+	if l == nil {
+		enc.NIL()
+		return
+	}
+
+	enc.List(len(l), func(i int) {
+		addr := l[i]
+		enc.Special('(')
+		writeNString(enc, mime.QEncoding.Encode("utf-8", addr.Name))
+		enc.SP().NIL().SP()
+		writeNString(enc, addr.Mailbox)
+		enc.SP()
+		writeNString(enc, addr.Host)
+		enc.Special(')')
+	})
+}
+
+func writeNString(enc *imapwire.Encoder, s string) {
+	if s == "" {
+		enc.NIL()
+	} else {
+		enc.String(s)
+	}
+}
+
+func writeSectionPart(enc *imapwire.Encoder, part []int) {
+	if len(part) == 0 {
+		return
+	}
+
+	var l []string
+	for _, num := range part {
+		l = append(l, fmt.Sprintf("%v", num))
+	}
+	enc.Atom(strings.Join(l, "."))
+}
+
+func writeBodyStructure(enc *imapwire.Encoder, bs imap.BodyStructure) {
+	enc.Special('(')
+	switch bs := bs.(type) {
+	case *imap.BodyStructureSinglePart:
+		writeBodyType1part(enc, bs)
+	case *imap.BodyStructureMultiPart:
+		writeBodyTypeMpart(enc, bs)
+	default:
+		panic(fmt.Errorf("unknown body structure type %T", bs))
+	}
+	enc.Special(')')
+}
+
+func writeBodyType1part(enc *imapwire.Encoder, bs *imap.BodyStructureSinglePart) {
+	enc.String(bs.Type).SP().String(bs.Subtype).SP()
+	writeBodyFldParam(enc, bs.Params)
+	enc.SP()
+	writeNString(enc, bs.ID)
+	enc.SP()
+	writeNString(enc, bs.Description)
+	enc.SP()
+	if bs.Encoding == "" {
+		enc.String("7BIT")
+	} else {
+		enc.String(strings.ToUpper(bs.Encoding))
+	}
+	enc.SP().Number(bs.Size)
+
+	if msg := bs.MessageRFC822; msg != nil {
+		enc.SP()
+		writeEnvelope(enc, msg.Envelope)
+		enc.SP()
+		writeBodyStructure(enc, msg.BodyStructure)
+		enc.SP().Number64(msg.NumLines)
+	} else if text := bs.Text; text != nil {
+		enc.SP().Number64(text.NumLines)
+	}
+
+	ext := bs.Extended
+	if ext == nil {
+		return
+	}
+
+	enc.SP()
+	enc.NIL() // MD5
+	enc.SP()
+	writeBodyFldDsp(enc, ext.Disposition)
+	enc.SP()
+	writeBodyFldLang(enc, ext.Language)
+	enc.SP()
+	writeNString(enc, ext.Location)
+}
+
+func writeBodyTypeMpart(enc *imapwire.Encoder, bs *imap.BodyStructureMultiPart) {
+	if len(bs.Children) == 0 {
+		panic("imapserver: imap.BodyStructureMultiPart must have at least one child")
+	}
+	for i, child := range bs.Children {
+		if i > 0 {
+			enc.SP()
+		}
+		writeBodyStructure(enc, child)
+	}
+
+	enc.SP().String(bs.Subtype)
+
+	ext := bs.Extended
+	if ext == nil {
+		return
+	}
+
+	enc.SP()
+	writeBodyFldParam(enc, ext.Params)
+	enc.SP()
+	writeBodyFldDsp(enc, ext.Disposition)
+	enc.SP()
+	writeBodyFldLang(enc, ext.Language)
+	enc.SP()
+	writeNString(enc, ext.Location)
+}
+
+func writeBodyFldParam(enc *imapwire.Encoder, params map[string]string) {
+	if params == nil {
+		enc.NIL()
+		return
+	}
+
+	var l []string
+	for k := range params {
+		l = append(l, k)
+	}
+	sort.Strings(l)
+
+	enc.List(len(l), func(i int) {
+		k := l[i]
+		v := params[k]
+		enc.String(k).SP().String(v)
+	})
+}
+
+func writeBodyFldDsp(enc *imapwire.Encoder, disp *imap.BodyStructureDisposition) {
+	if disp == nil {
+		enc.NIL()
+		return
+	}
+
+	enc.Special('(').String(disp.Value).SP()
+	writeBodyFldParam(enc, disp.Params)
+	enc.Special(')')
+}
+
+func writeBodyFldLang(enc *imapwire.Encoder, l []string) {
+	if l == nil {
+		enc.NIL()
+	} else {
+		enc.List(len(l), func(i int) {
+			enc.String(l[i])
+		})
+	}
+}
diff --git a/imapserver/idle.go b/imapserver/idle.go
new file mode 100644
index 0000000..3b67c73
--- /dev/null
+++ b/imapserver/idle.go
@@ -0,0 +1,50 @@
+package imapserver
+
+import (
+	"fmt"
+	"io"
+	"runtime/debug"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleIdle(dec *imapwire.Decoder) error {
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+
+	if err := c.writeContReq("idling"); err != nil {
+		return err
+	}
+
+	stop := make(chan struct{})
+	done := make(chan error, 1)
+	go func() {
+		defer func() {
+			if v := recover(); v != nil {
+				c.server.logger().Printf("panic idling: %v\n%s", v, debug.Stack())
+				done <- fmt.Errorf("imapserver: panic idling")
+			}
+		}()
+		w := &UpdateWriter{conn: c, allowExpunge: true}
+		done <- c.session.Idle(w, stop)
+	}()
+
+	c.setReadTimeout(idleReadTimeout)
+	line, isPrefix, err := c.br.ReadLine()
+	close(stop)
+	if err == io.EOF {
+		return nil
+	} else if err != nil {
+		return err
+	} else if isPrefix || string(line) != "DONE" {
+		return newClientBugError("Syntax error: expected DONE to end IDLE command")
+	}
+
+	return <-done
+}
diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go
new file mode 100644
index 0000000..e3903ea
--- /dev/null
+++ b/imapserver/imapmemserver/mailbox.go
@@ -0,0 +1,429 @@
+package imapmemserver
+
+import (
+	"bytes"
+	"fmt"
+	"sort"
+	"sync"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+)
+
+const uidValidity = 1
+
+// Mailbox is an in-memory mailbox.
+//
+// The same mailbox can be shared between multiple connections and multiple
+// users.
+type Mailbox struct {
+	tracker *imapserver.MailboxTracker
+
+	mutex      sync.Mutex
+	name       string
+	subscribed bool
+	l          []*message
+	uidNext    uint32
+}
+
+// NewMailbox creates a new mailbox.
+func NewMailbox(name string) *Mailbox {
+	return &Mailbox{
+		tracker: imapserver.NewMailboxTracker(0),
+		name:    name,
+		uidNext: 1,
+	}
+}
+
+func (mbox *Mailbox) list(options *imap.ListOptions) *imap.ListData {
+	mbox.mutex.Lock()
+	defer mbox.mutex.Unlock()
+
+	if options.SelectSubscribed && !mbox.subscribed {
+		return nil
+	}
+
+	data := imap.ListData{
+		Mailbox: mbox.name,
+		Delim:   mailboxDelim,
+	}
+	if mbox.subscribed {
+		data.Attrs = append(data.Attrs, imap.MailboxAttrSubscribed)
+	}
+	if len(options.ReturnStatus) > 0 {
+		data.Status = mbox.statusDataLocked(options.ReturnStatus)
+	}
+	return &data
+}
+
+// StatusData returns data for the STATUS command.
+func (mbox *Mailbox) StatusData(items []imap.StatusItem) *imap.StatusData {
+	mbox.mutex.Lock()
+	defer mbox.mutex.Unlock()
+	return mbox.statusDataLocked(items)
+}
+
+func (mbox *Mailbox) statusDataLocked(items []imap.StatusItem) *imap.StatusData {
+	data := imap.StatusData{Mailbox: mbox.name}
+	for _, item := range items {
+		switch item {
+		case imap.StatusItemNumMessages:
+			num := uint32(len(mbox.l))
+			data.NumMessages = &num
+		case imap.StatusItemUIDNext:
+			data.UIDNext = mbox.uidNext
+		case imap.StatusItemUIDValidity:
+			data.UIDValidity = uidValidity
+		case imap.StatusItemNumUnseen:
+			num := uint32(len(mbox.l)) - mbox.countByFlagLocked(imap.FlagSeen)
+			data.NumUnseen = &num
+		case imap.StatusItemNumDeleted:
+			num := mbox.countByFlagLocked(imap.FlagDeleted)
+			data.NumUnseen = &num
+		case imap.StatusItemSize:
+			size := mbox.sizeLocked()
+			data.Size = &size
+		default:
+			panic(fmt.Errorf("unknown STATUS item: %v", item))
+		}
+	}
+	return &data
+}
+
+func (mbox *Mailbox) countByFlagLocked(flag imap.Flag) uint32 {
+	var n uint32
+	for _, msg := range mbox.l {
+		if _, ok := msg.flags[canonicalFlag(flag)]; ok {
+			n++
+		}
+	}
+	return n
+}
+
+func (mbox *Mailbox) sizeLocked() int64 {
+	var size int64
+	for _, msg := range mbox.l {
+		size += int64(len(msg.buf))
+	}
+	return size
+}
+
+func (mbox *Mailbox) appendLiteral(r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
+	var buf bytes.Buffer
+	if _, err := buf.ReadFrom(r); err != nil {
+		return nil, err
+	}
+	return mbox.appendBytes(buf.Bytes(), options), nil
+}
+
+func (mbox *Mailbox) copyMsg(msg *message) *imap.AppendData {
+	return mbox.appendBytes(msg.buf, &imap.AppendOptions{
+		Time:  msg.t,
+		Flags: msg.flagList(),
+	})
+}
+
+func (mbox *Mailbox) appendBytes(buf []byte, options *imap.AppendOptions) *imap.AppendData {
+	msg := &message{
+		flags: make(map[imap.Flag]struct{}),
+		buf:   buf,
+	}
+
+	if options.Time.IsZero() {
+		msg.t = time.Now()
+	} else {
+		msg.t = options.Time
+	}
+
+	for _, flag := range options.Flags {
+		msg.flags[canonicalFlag(flag)] = struct{}{}
+	}
+
+	mbox.mutex.Lock()
+	defer mbox.mutex.Unlock()
+
+	msg.uid = mbox.uidNext
+	mbox.uidNext++
+
+	mbox.l = append(mbox.l, msg)
+	mbox.tracker.QueueNumMessages(uint32(len(mbox.l)))
+
+	return &imap.AppendData{
+		UIDValidity: uidValidity,
+		UID:         msg.uid,
+	}
+}
+
+func (mbox *Mailbox) rename(newName string) {
+	mbox.mutex.Lock()
+	mbox.name = newName
+	mbox.mutex.Unlock()
+}
+
+// SetSubscribed changes the subscription state of this mailbox.
+func (mbox *Mailbox) SetSubscribed(subscribed bool) {
+	mbox.mutex.Lock()
+	mbox.subscribed = subscribed
+	mbox.mutex.Unlock()
+}
+
+func (mbox *Mailbox) selectDataLocked() *imap.SelectData {
+	flags := mbox.flagsLocked()
+
+	permanentFlags := make([]imap.Flag, len(flags))
+	copy(permanentFlags, flags)
+	permanentFlags = append(permanentFlags, imap.FlagWildcard)
+
+	return &imap.SelectData{
+		Flags:          flags,
+		PermanentFlags: permanentFlags,
+		NumMessages:    uint32(len(mbox.l)),
+		UIDNext:        mbox.uidNext,
+		UIDValidity:    uidValidity,
+	}
+}
+
+func (mbox *Mailbox) flagsLocked() []imap.Flag {
+	m := make(map[imap.Flag]struct{})
+	for _, msg := range mbox.l {
+		for flag := range msg.flags {
+			m[flag] = struct{}{}
+		}
+	}
+
+	var l []imap.Flag
+	for flag := range m {
+		l = append(l, flag)
+	}
+
+	sort.Slice(l, func(i, j int) bool {
+		return l[i] < l[j]
+	})
+
+	return l
+}
+
+func (mbox *Mailbox) Expunge(w *imapserver.ExpungeWriter, uids *imap.SeqSet) error {
+	expunged := make(map[*message]struct{})
+	mbox.mutex.Lock()
+	for _, msg := range mbox.l {
+		if uids != nil && !uids.Contains(msg.uid) {
+			continue
+		}
+		if _, ok := msg.flags[canonicalFlag(imap.FlagDeleted)]; ok {
+			expunged[msg] = struct{}{}
+		}
+	}
+	mbox.mutex.Unlock()
+
+	if len(expunged) == 0 {
+		return nil
+	}
+
+	mbox.mutex.Lock()
+	mbox.expungeLocked(expunged)
+	mbox.mutex.Unlock()
+
+	return nil
+}
+
+func (mbox *Mailbox) expungeLocked(expunged map[*message]struct{}) (seqNums []uint32) {
+	// TODO: optimize
+
+	// Iterate in reverse order, to keep sequence numbers consistent
+	var filtered []*message
+	for i := len(mbox.l) - 1; i >= 0; i-- {
+		msg := mbox.l[i]
+		if _, ok := expunged[msg]; ok {
+			seqNum := uint32(i) + 1
+			seqNums = append(seqNums, seqNum)
+			mbox.tracker.QueueExpunge(seqNum)
+		} else {
+			filtered = append(filtered, msg)
+		}
+	}
+
+	// Reverse filtered
+	for i := 0; i < len(filtered)/2; i++ {
+		j := len(filtered) - i - 1
+		filtered[i], filtered[j] = filtered[j], filtered[i]
+	}
+
+	mbox.l = filtered
+
+	return seqNums
+}
+
+// NewView creates a new view into this mailbox.
+//
+// Callers must call MailboxView.Close once they are done with the mailbox view.
+func (mbox *Mailbox) NewView() *MailboxView {
+	return &MailboxView{
+		Mailbox: mbox,
+		tracker: mbox.tracker.NewSession(),
+	}
+}
+
+// A MailboxView is a view into a mailbox.
+//
+// Each view has its own queue of pending unilateral updates.
+//
+// Once the mailbox view is no longer used, Close must be called.
+//
+// Typically, a new MailboxView is created for each IMAP connection in the
+// selected state.
+type MailboxView struct {
+	*Mailbox
+	tracker *imapserver.SessionTracker
+}
+
+// Close releases the resources allocated for the mailbox view.
+func (mbox *MailboxView) Close() {
+	mbox.tracker.Close()
+}
+
+func (mbox *MailboxView) Fetch(w *imapserver.FetchWriter, numKind imapserver.NumKind, seqSet imap.SeqSet, items []imap.FetchItem) error {
+	markSeen := false
+	for _, item := range items {
+		if item, ok := item.(*imap.FetchItemBodySection); ok && !item.Peek {
+			markSeen = true
+			break
+		}
+	}
+
+	var err error
+	mbox.forEach(numKind, seqSet, func(seqNum uint32, msg *message) {
+		if err != nil {
+			return
+		}
+
+		if markSeen {
+			msg.flags[canonicalFlag(imap.FlagSeen)] = struct{}{}
+			mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), nil)
+		}
+
+		respWriter := w.CreateMessage(mbox.tracker.EncodeSeqNum(seqNum))
+		err = msg.fetch(respWriter, items)
+	})
+	return err
+}
+
+func (mbox *MailboxView) Search(numKind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
+	mbox.mutex.Lock()
+	defer mbox.mutex.Unlock()
+
+	mbox.staticSeqSet(criteria.SeqNum, imapserver.NumKindSeq)
+	mbox.staticSeqSet(criteria.UID, imapserver.NumKindUID)
+
+	data := imap.SearchData{
+		UID: numKind == imapserver.NumKindUID,
+	}
+
+	for i, msg := range mbox.l {
+		seqNum := mbox.tracker.EncodeSeqNum(uint32(i) + 1)
+
+		if !msg.search(seqNum, criteria) {
+			continue
+		}
+
+		var num uint32
+		switch numKind {
+		case imapserver.NumKindSeq:
+			num = seqNum
+		case imapserver.NumKindUID:
+			num = msg.uid
+		}
+		if num == 0 {
+			continue
+		}
+		data.All.AddNum(num)
+		if data.Min == 0 || num < data.Min {
+			data.Min = num
+		}
+		if data.Max == 0 || num > data.Max {
+			data.Max = num
+		}
+		data.Count++
+	}
+
+	return &data, nil
+}
+
+func (mbox *MailboxView) Store(w *imapserver.FetchWriter, numKind imapserver.NumKind, seqSet imap.SeqSet, flags *imap.StoreFlags) error {
+	mbox.forEach(numKind, seqSet, func(seqNum uint32, msg *message) {
+		msg.store(flags)
+		mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker)
+	})
+	if !flags.Silent {
+		return mbox.Fetch(w, numKind, seqSet, []imap.FetchItem{imap.FetchItemFlags})
+	}
+	return nil
+}
+
+func (mbox *MailboxView) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
+	return mbox.tracker.Poll(w, allowExpunge)
+}
+
+func (mbox *MailboxView) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
+	return mbox.tracker.Idle(w, stop)
+}
+
+func (mbox *MailboxView) forEach(numKind imapserver.NumKind, seqSet imap.SeqSet, f func(seqNum uint32, msg *message)) {
+	mbox.mutex.Lock()
+	defer mbox.mutex.Unlock()
+	mbox.forEachLocked(numKind, seqSet, f)
+}
+
+func (mbox *MailboxView) forEachLocked(numKind imapserver.NumKind, seqSet imap.SeqSet, f func(seqNum uint32, msg *message)) {
+	// TODO: optimize
+
+	mbox.staticSeqSet(seqSet, numKind)
+
+	for i, msg := range mbox.l {
+		seqNum := uint32(i) + 1
+
+		var num uint32
+		switch numKind {
+		case imapserver.NumKindSeq:
+			num = mbox.tracker.EncodeSeqNum(seqNum)
+		case imapserver.NumKindUID:
+			num = msg.uid
+		}
+		if num == 0 || !seqSet.Contains(num) {
+			continue
+		}
+
+		f(seqNum, msg)
+	}
+}
+
+// staticSeqSet converts a dynamic sequence set into a static one.
+//
+// This is necessary to properly handle the special symbol "*", which
+// represents the maximum sequence number or UID in the mailbox.
+func (mbox *MailboxView) staticSeqSet(seqSet imap.SeqSet, numKind imapserver.NumKind) {
+	var max uint32
+	switch numKind {
+	case imapserver.NumKindSeq:
+		max = uint32(len(mbox.l))
+	case imapserver.NumKindUID:
+		max = mbox.uidNext - 1
+	}
+
+	for i := range seqSet {
+		seq := &seqSet[i]
+		dyn := false
+		if seq.Start == 0 {
+			seq.Start = max
+			dyn = true
+		}
+		if seq.Stop == 0 {
+			seq.Stop = max
+			dyn = true
+		}
+		if dyn && seq.Start > seq.Stop {
+			seq.Start, seq.Stop = seq.Stop, seq.Start
+		}
+	}
+}
diff --git a/imapserver/imapmemserver/message.go b/imapserver/imapmemserver/message.go
new file mode 100644
index 0000000..f84e3ba
--- /dev/null
+++ b/imapserver/imapmemserver/message.go
@@ -0,0 +1,489 @@
+package imapmemserver
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"io"
+	"mime"
+	"strings"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+	gomessage "github.com/emersion/go-message"
+	"github.com/emersion/go-message/mail"
+	"github.com/emersion/go-message/textproto"
+)
+
+type message struct {
+	// immutable
+	uid uint32
+	buf []byte
+	t   time.Time
+
+	// mutable, protected by Mailbox.mutex
+	flags map[imap.Flag]struct{}
+}
+
+func (msg *message) fetch(w *imapserver.FetchResponseWriter, items []imap.FetchItem) error {
+	w.WriteUID(msg.uid)
+
+	for _, item := range items {
+		if err := msg.fetchItem(w, item); err != nil {
+			return err
+		}
+	}
+
+	return w.Close()
+}
+
+func (msg *message) fetchItem(w *imapserver.FetchResponseWriter, item imap.FetchItem) error {
+	switch item := item.(type) {
+	case *imap.FetchItemBodySection:
+		buf := msg.bodySection(item)
+		wc := w.WriteBodySection(item, int64(len(buf)))
+		_, writeErr := wc.Write(buf)
+		closeErr := wc.Close()
+		if writeErr != nil {
+			return writeErr
+		}
+		return closeErr
+	case *imap.FetchItemBinarySection:
+		panic("TODO")
+	case *imap.FetchItemBinarySectionSize:
+		panic("TODO")
+	}
+
+	switch item {
+	case imap.FetchItemUID:
+		// always included
+	case imap.FetchItemFlags:
+		w.WriteFlags(msg.flagList())
+	case imap.FetchItemInternalDate:
+		w.WriteInternalDate(msg.t)
+	case imap.FetchItemRFC822Size:
+		w.WriteRFC822Size(int64(len(msg.buf)))
+	case imap.FetchItemEnvelope:
+		w.WriteEnvelope(msg.envelope())
+	case imap.FetchItemBodyStructure, imap.FetchItemBody:
+		w.WriteBodyStructure(msg.bodyStructure(item == imap.FetchItemBodyStructure))
+	default:
+		panic(fmt.Errorf("unknown FETCH item: %#v", item))
+	}
+	return nil
+}
+
+func (msg *message) envelope() *imap.Envelope {
+	br := bufio.NewReader(bytes.NewReader(msg.buf))
+	header, err := textproto.ReadHeader(br)
+	if err != nil {
+		return nil
+	}
+	return getEnvelope(header)
+}
+
+func (msg *message) bodyStructure(extended bool) imap.BodyStructure {
+	br := bufio.NewReader(bytes.NewReader(msg.buf))
+	header, _ := textproto.ReadHeader(br)
+	return getBodyStructure(header, br, extended)
+}
+
+func openMessagePart(header textproto.Header, body io.Reader, parentMediaType string) (textproto.Header, io.Reader) {
+	msgHeader := gomessage.Header{header}
+	mediaType, _, _ := msgHeader.ContentType()
+	if !msgHeader.Has("Content-Type") && parentMediaType == "multipart/digest" {
+		mediaType = "message/rfc822"
+	}
+	if mediaType == "message/rfc822" || mediaType == "message/global" {
+		br := bufio.NewReader(body)
+		header, _ = textproto.ReadHeader(br)
+		return header, br
+	}
+	return header, body
+}
+
+func (msg *message) bodySection(item *imap.FetchItemBodySection) []byte {
+	var (
+		header textproto.Header
+		body   io.Reader
+	)
+
+	br := bufio.NewReader(bytes.NewReader(msg.buf))
+	header, err := textproto.ReadHeader(br)
+	if err != nil {
+		return nil
+	}
+	body = br
+
+	// First part of non-multipart message refers to the message itself
+	msgHeader := gomessage.Header{header}
+	mediaType, _, _ := msgHeader.ContentType()
+	partPath := item.Part
+	if !strings.HasPrefix(mediaType, "multipart/") && len(partPath) > 0 && partPath[0] == 1 {
+		partPath = partPath[1:]
+	}
+
+	// Find the requested part using the provided path
+	var parentMediaType string
+	for i := 0; i < len(partPath); i++ {
+		partNum := partPath[i]
+
+		header, body = openMessagePart(header, body, parentMediaType)
+
+		msgHeader := gomessage.Header{header}
+		mediaType, typeParams, _ := msgHeader.ContentType()
+		if !strings.HasPrefix(mediaType, "multipart/") {
+			if partNum != 1 {
+				return nil
+			}
+			continue
+		}
+
+		mr := textproto.NewMultipartReader(body, typeParams["boundary"])
+		found := false
+		for j := 1; j <= partNum; j++ {
+			p, err := mr.NextPart()
+			if err != nil {
+				return nil
+			}
+
+			if j == partNum {
+				parentMediaType = mediaType
+				header = p.Header
+				body = p
+				found = true
+				break
+			}
+		}
+		if !found {
+			return nil
+		}
+	}
+
+	if len(item.Part) > 0 {
+		switch item.Specifier {
+		case imap.PartSpecifierHeader, imap.PartSpecifierText:
+			header, body = openMessagePart(header, body, parentMediaType)
+		}
+	}
+
+	// Filter header fields
+	if len(item.HeaderFields) > 0 {
+		keep := make(map[string]struct{})
+		for _, k := range item.HeaderFields {
+			keep[strings.ToLower(k)] = struct{}{}
+		}
+		for field := header.Fields(); field.Next(); {
+			if _, ok := keep[strings.ToLower(field.Key())]; !ok {
+				field.Del()
+			}
+		}
+	}
+	for _, k := range item.HeaderFieldsNot {
+		header.Del(k)
+	}
+
+	// Write the requested data to a buffer
+	var buf bytes.Buffer
+
+	writeHeader := true
+	switch item.Specifier {
+	case imap.PartSpecifierNone:
+		writeHeader = len(item.Part) == 0
+	case imap.PartSpecifierText:
+		writeHeader = false
+	}
+	if writeHeader {
+		if err := textproto.WriteHeader(&buf, header); err != nil {
+			return nil
+		}
+	}
+
+	switch item.Specifier {
+	case imap.PartSpecifierNone, imap.PartSpecifierText:
+		if _, err := io.Copy(&buf, body); err != nil {
+			return nil
+		}
+	}
+
+	// Extract partial if any
+	b := buf.Bytes()
+	if partial := item.Partial; partial != nil {
+		end := partial.Offset + partial.Size
+		if partial.Offset > int64(len(b)) {
+			return nil
+		}
+		if end > int64(len(b)) {
+			end = int64(len(b))
+		}
+		b = b[partial.Offset:end]
+	}
+	return b
+}
+
+func (msg *message) flagList() []imap.Flag {
+	var flags []imap.Flag
+	for flag := range msg.flags {
+		flags = append(flags, flag)
+	}
+	return flags
+}
+
+func (msg *message) store(store *imap.StoreFlags) {
+	switch store.Op {
+	case imap.StoreFlagsSet:
+		msg.flags = make(map[imap.Flag]struct{})
+		fallthrough
+	case imap.StoreFlagsAdd:
+		for _, flag := range store.Flags {
+			msg.flags[canonicalFlag(flag)] = struct{}{}
+		}
+	case imap.StoreFlagsDel:
+		for _, flag := range store.Flags {
+			delete(msg.flags, canonicalFlag(flag))
+		}
+	default:
+		panic(fmt.Errorf("unknown STORE flag operation: %v", store.Op))
+	}
+}
+
+func (msg *message) search(seqNum uint32, criteria *imap.SearchCriteria) bool {
+	if criteria.SeqNum != nil && (seqNum == 0 || !criteria.SeqNum.Contains(seqNum)) {
+		return false
+	}
+	if criteria.UID != nil && !criteria.UID.Contains(msg.uid) {
+		return false
+	}
+	if !matchDate(msg.t, criteria.Since, criteria.Before) {
+		return false
+	}
+
+	for _, flag := range criteria.Flag {
+		if _, ok := msg.flags[canonicalFlag(flag)]; !ok {
+			return false
+		}
+	}
+	for _, flag := range criteria.NotFlag {
+		if _, ok := msg.flags[canonicalFlag(flag)]; ok {
+			return false
+		}
+	}
+
+	if criteria.Larger != 0 && int64(len(msg.buf)) <= criteria.Larger {
+		return false
+	}
+	if criteria.Smaller != 0 && int64(len(msg.buf)) >= criteria.Smaller {
+		return false
+	}
+
+	if !matchBytes(msg.buf, criteria.Text) {
+		return false
+	}
+
+	br := bufio.NewReader(bytes.NewReader(msg.buf))
+	rawHeader, _ := textproto.ReadHeader(br)
+	header := mail.Header{gomessage.Header{rawHeader}}
+
+	for _, fieldCriteria := range criteria.Header {
+		if !header.Has(fieldCriteria.Key) {
+			return false
+		}
+		if fieldCriteria.Value == "" {
+			continue
+		}
+		found := false
+		for _, v := range header.Values(fieldCriteria.Key) {
+			found = strings.Contains(strings.ToLower(v), strings.ToLower(fieldCriteria.Value))
+			if found {
+				break
+			}
+		}
+		if !found {
+			return false
+		}
+	}
+
+	if !criteria.SentSince.IsZero() || !criteria.SentBefore.IsZero() {
+		t, err := header.Date()
+		if err != nil {
+			return false
+		} else if !matchDate(t, criteria.SentSince, criteria.SentBefore) {
+			return false
+		}
+	}
+
+	if len(criteria.Body) > 0 {
+		body, _ := io.ReadAll(br)
+		if !matchBytes(body, criteria.Body) {
+			return false
+		}
+	}
+
+	for _, not := range criteria.Not {
+		if msg.search(seqNum, &not) {
+			return false
+		}
+	}
+	for _, or := range criteria.Or {
+		if !msg.search(seqNum, &or[0]) && !msg.search(seqNum, &or[1]) {
+			return false
+		}
+	}
+
+	return true
+}
+
+func matchDate(t, since, before time.Time) bool {
+	// We discard time zone information by setting it to UTC.
+	// RFC 3501 explicitly requires zone unaware date comparison.
+	t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
+
+	if !since.IsZero() && t.Before(since) {
+		return false
+	}
+	if !before.IsZero() && !t.Before(before) {
+		return false
+	}
+	return true
+}
+
+func matchBytes(buf []byte, patterns []string) bool {
+	if len(patterns) == 0 {
+		return true
+	}
+	buf = bytes.ToLower(buf)
+	for _, s := range patterns {
+		if !bytes.Contains(buf, bytes.ToLower([]byte(s))) {
+			return false
+		}
+	}
+	return true
+}
+
+func getEnvelope(h textproto.Header) *imap.Envelope {
+	return &imap.Envelope{
+		Date:      h.Get("Date"),
+		Subject:   h.Get("Subject"),
+		From:      parseAddressList(h.Get("From")),
+		Sender:    parseAddressList(h.Get("Sender")),
+		ReplyTo:   parseAddressList(h.Get("Reply-To")),
+		To:        parseAddressList(h.Get("To")),
+		Cc:        parseAddressList(h.Get("Cc")),
+		Bcc:       parseAddressList(h.Get("Bcc")),
+		InReplyTo: h.Get("In-Reply-To"),
+		MessageID: h.Get("message-Id"),
+	}
+}
+
+func parseAddressList(s string) []imap.Address {
+	if s == "" {
+		return nil
+	}
+
+	// TODO: leave the quoted words unchanged
+	// TODO: handle groups
+	addrs, _ := mail.ParseAddressList(s)
+	var l []imap.Address
+	for _, addr := range addrs {
+		mailbox, host, ok := strings.Cut(addr.Address, "@")
+		if !ok {
+			continue
+		}
+		l = append(l, imap.Address{
+			Name:    mime.QEncoding.Encode("utf-8", addr.Name),
+			Mailbox: mailbox,
+			Host:    host,
+		})
+	}
+	return l
+}
+
+func canonicalFlag(flag imap.Flag) imap.Flag {
+	return imap.Flag(strings.ToLower(string(flag)))
+}
+
+func getBodyStructure(rawHeader textproto.Header, r io.Reader, extended bool) imap.BodyStructure {
+	header := gomessage.Header{rawHeader}
+
+	mediaType, typeParams, _ := header.ContentType()
+	primaryType, subType, _ := strings.Cut(mediaType, "/")
+
+	if primaryType == "multipart" {
+		bs := &imap.BodyStructureMultiPart{Subtype: subType}
+		mr := textproto.NewMultipartReader(r, typeParams["boundary"])
+		for {
+			part, _ := mr.NextPart()
+			if part == nil {
+				break
+			}
+			bs.Children = append(bs.Children, getBodyStructure(part.Header, part, extended))
+		}
+		if extended {
+			bs.Extended = &imap.BodyStructureMultiPartExt{
+				Params:      typeParams,
+				Disposition: getContentDisposition(header),
+				Language:    getContentLanguage(header),
+				Location:    header.Get("Content-Location"),
+			}
+		}
+		return bs
+	} else {
+		body, _ := io.ReadAll(r) // TODO: optimize
+		bs := &imap.BodyStructureSinglePart{
+			Type:        primaryType,
+			Subtype:     subType,
+			Params:      typeParams,
+			ID:          header.Get("Content-Id"),
+			Description: header.Get("Content-Description"),
+			Encoding:    header.Get("Content-Transfer-Encoding"),
+			Size:        uint32(len(body)),
+		}
+		if mediaType == "message/rfc822" || mediaType == "message/global" {
+			br := bufio.NewReader(bytes.NewReader(body))
+			childHeader, _ := textproto.ReadHeader(br)
+			bs.MessageRFC822 = &imap.BodyStructureMessageRFC822{
+				Envelope:      getEnvelope(childHeader),
+				BodyStructure: getBodyStructure(childHeader, br, extended),
+				NumLines:      int64(bytes.Count(body, []byte("\n"))),
+			}
+		}
+		if primaryType == "text" {
+			bs.Text = &imap.BodyStructureText{
+				NumLines: int64(bytes.Count(body, []byte("\n"))),
+			}
+		}
+		if extended {
+			bs.Extended = &imap.BodyStructureSinglePartExt{
+				Disposition: getContentDisposition(header),
+				Language:    getContentLanguage(header),
+				Location:    header.Get("Content-Location"),
+			}
+		}
+		return bs
+	}
+}
+
+func getContentDisposition(header gomessage.Header) *imap.BodyStructureDisposition {
+	disp, dispParams, _ := header.ContentDisposition()
+	if disp == "" {
+		return nil
+	}
+	return &imap.BodyStructureDisposition{
+		Value:  disp,
+		Params: dispParams,
+	}
+}
+
+func getContentLanguage(header gomessage.Header) []string {
+	v := header.Get("Content-Language")
+	if v == "" {
+		return nil
+	}
+	// TODO: handle CFWS
+	l := strings.Split(v, ",")
+	for i, lang := range l {
+		l[i] = strings.TrimSpace(lang)
+	}
+	return l
+}
diff --git a/imapserver/imapmemserver/server.go b/imapserver/imapmemserver/server.go
new file mode 100644
index 0000000..e31453a
--- /dev/null
+++ b/imapserver/imapmemserver/server.go
@@ -0,0 +1,61 @@
+// Package imapmemserver implements an in-memory IMAP server.
+package imapmemserver
+
+import (
+	"sync"
+
+	"github.com/emersion/go-imap/v2/imapserver"
+)
+
+// Server is a server instance.
+//
+// A server contains a list of users.
+type Server struct {
+	mutex sync.Mutex
+	users map[string]*User
+}
+
+// New creates a new server.
+func New() *Server {
+	return &Server{
+		users: make(map[string]*User),
+	}
+}
+
+// NewSession creates a new IMAP session.
+func (s *Server) NewSession() imapserver.Session {
+	return &serverSession{server: s}
+}
+
+func (s *Server) user(username string) *User {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	return s.users[username]
+}
+
+// AddUser adds a user to the server.
+func (s *Server) AddUser(user *User) {
+	s.mutex.Lock()
+	s.users[user.username] = user
+	s.mutex.Unlock()
+}
+
+type serverSession struct {
+	*UserSession // may be nil
+
+	server *Server // immutable
+}
+
+var _ imapserver.Session = (*serverSession)(nil)
+
+func (sess *serverSession) Login(username, password string) error {
+	u := sess.server.user(username)
+	if u == nil {
+		return imapserver.ErrAuthFailed
+	}
+	if err := u.Login(username, password); err != nil {
+		return err
+	}
+	sess.UserSession = NewUserSession(u)
+	return nil
+}
diff --git a/imapserver/imapmemserver/session.go b/imapserver/imapmemserver/session.go
new file mode 100644
index 0000000..8de2f22
--- /dev/null
+++ b/imapserver/imapmemserver/session.go
@@ -0,0 +1,132 @@
+package imapmemserver
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+)
+
+type (
+	user    = User
+	mailbox = MailboxView
+)
+
+// UserSession represents a session tied to a specific user.
+//
+// UserSession implements imapserver.Session. Typically, a UserSession pointer
+// is embedded into a larger struct which overrides Login.
+type UserSession struct {
+	*user    // immutable
+	*mailbox // may be nil
+}
+
+var _ imapserver.SessionIMAP4rev2 = (*UserSession)(nil)
+
+// NewUserSession creates a new user session.
+func NewUserSession(user *User) *UserSession {
+	return &UserSession{user: user}
+}
+
+func (sess *UserSession) Close() error {
+	if sess != nil && sess.mailbox != nil {
+		sess.mailbox.Close()
+	}
+	return nil
+}
+
+func (sess *UserSession) Select(name string, options *imapserver.SelectOptions) (*imap.SelectData, error) {
+	mbox, err := sess.user.mailbox(name)
+	if err != nil {
+		return nil, err
+	}
+	mbox.mutex.Lock()
+	defer mbox.mutex.Unlock()
+	sess.mailbox = mbox.NewView()
+	return mbox.selectDataLocked(), nil
+}
+
+func (sess *UserSession) Unselect() error {
+	sess.mailbox.Close()
+	sess.mailbox = nil
+	return nil
+}
+
+func (sess *UserSession) Copy(numKind imapserver.NumKind, seqSet imap.SeqSet, destName string) (*imap.CopyData, error) {
+	dest, err := sess.user.mailbox(destName)
+	if err != nil {
+		return nil, err
+	} else if sess.mailbox != nil && dest == sess.mailbox.Mailbox {
+		return nil, &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "Source and destination mailboxes are identical",
+		}
+	}
+
+	var sourceUIDs, destUIDs imap.SeqSet
+	sess.mailbox.forEach(numKind, seqSet, func(seqNum uint32, msg *message) {
+		appendData := dest.copyMsg(msg)
+		sourceUIDs.AddNum(msg.uid)
+		destUIDs.AddNum(appendData.UID)
+	})
+
+	return &imap.CopyData{
+		UIDValidity: uidValidity,
+		SourceUIDs:  sourceUIDs,
+		DestUIDs:    destUIDs,
+	}, nil
+}
+
+func (sess *UserSession) Move(w *imapserver.MoveWriter, numKind imapserver.NumKind, seqSet imap.SeqSet, destName string) error {
+	dest, err := sess.user.mailbox(destName)
+	if err != nil {
+		return err
+	} else if sess.mailbox != nil && dest == sess.mailbox.Mailbox {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "Source and destination mailboxes are identical",
+		}
+	}
+
+	sess.mailbox.mutex.Lock()
+	defer sess.mailbox.mutex.Unlock()
+
+	var sourceUIDs, destUIDs imap.SeqSet
+	expunged := make(map[*message]struct{})
+	sess.mailbox.forEachLocked(numKind, seqSet, func(seqNum uint32, msg *message) {
+		appendData := dest.copyMsg(msg)
+		sourceUIDs.AddNum(msg.uid)
+		destUIDs.AddNum(appendData.UID)
+		expunged[msg] = struct{}{}
+	})
+	seqNums := sess.mailbox.expungeLocked(expunged)
+
+	err = w.WriteCopyData(&imap.CopyData{
+		UIDValidity: uidValidity,
+		SourceUIDs:  sourceUIDs,
+		DestUIDs:    destUIDs,
+	})
+	if err != nil {
+		return err
+	}
+
+	for _, seqNum := range seqNums {
+		if err := w.WriteExpunge(sess.mailbox.tracker.EncodeSeqNum(seqNum)); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (sess *UserSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
+	if sess.mailbox == nil {
+		return nil
+	}
+	return sess.mailbox.Poll(w, allowExpunge)
+}
+
+func (sess *UserSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
+	if sess.mailbox == nil {
+		return nil // TODO
+	}
+	return sess.mailbox.Idle(w, stop)
+}
diff --git a/imapserver/imapmemserver/user.go b/imapserver/imapmemserver/user.go
new file mode 100644
index 0000000..4cebb1a
--- /dev/null
+++ b/imapserver/imapmemserver/user.go
@@ -0,0 +1,197 @@
+package imapmemserver
+
+import (
+	"crypto/subtle"
+	"sort"
+	"strings"
+	"sync"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+)
+
+const mailboxDelim rune = '/'
+
+type User struct {
+	username, password string
+
+	mutex     sync.Mutex
+	mailboxes map[string]*Mailbox
+}
+
+func NewUser(username, password string) *User {
+	return &User{
+		username:  username,
+		password:  password,
+		mailboxes: make(map[string]*Mailbox),
+	}
+}
+
+func (u *User) Login(username, password string) error {
+	if username != u.username {
+		return imapserver.ErrAuthFailed
+	}
+	if subtle.ConstantTimeCompare([]byte(password), []byte(u.password)) != 1 {
+		return imapserver.ErrAuthFailed
+	}
+	return nil
+}
+
+func (u *User) mailboxLocked(name string) (*Mailbox, error) {
+	mbox := u.mailboxes[name]
+	if mbox == nil {
+		return nil, &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "No such mailbox",
+		}
+	}
+	return mbox, nil
+}
+
+func (u *User) mailbox(name string) (*Mailbox, error) {
+	u.mutex.Lock()
+	defer u.mutex.Unlock()
+	return u.mailboxLocked(name)
+}
+
+func (u *User) Status(name string, items []imap.StatusItem) (*imap.StatusData, error) {
+	mbox, err := u.mailbox(name)
+	if err != nil {
+		return nil, err
+	}
+	return mbox.StatusData(items), nil
+}
+
+func (u *User) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
+	u.mutex.Lock()
+	defer u.mutex.Unlock()
+
+	// TODO: fail if ref doesn't exist
+
+	if len(patterns) == 0 {
+		return w.WriteList(&imap.ListData{
+			Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect},
+			Delim: mailboxDelim,
+		})
+	}
+
+	var l []imap.ListData
+	for name, mbox := range u.mailboxes {
+		match := false
+		for _, pattern := range patterns {
+			match = imapserver.MatchList(name, mailboxDelim, ref, pattern)
+			if match {
+				break
+			}
+		}
+		if !match {
+			continue
+		}
+
+		data := mbox.list(options)
+		if data != nil {
+			l = append(l, *data)
+		}
+	}
+
+	sort.Slice(l, func(i, j int) bool {
+		return l[i].Mailbox < l[j].Mailbox
+	})
+
+	for _, data := range l {
+		if err := w.WriteList(&data); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (u *User) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
+	mbox, err := u.mailbox(mailbox)
+	if err != nil {
+		return nil, &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Code: imap.ResponseCodeTryCreate,
+			Text: "No such mailbox",
+		}
+	}
+	return mbox.appendLiteral(r, options)
+}
+
+func (u *User) Create(name string) error {
+	u.mutex.Lock()
+	defer u.mutex.Unlock()
+
+	name = strings.TrimRight(name, string(mailboxDelim))
+
+	if u.mailboxes[name] != nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "Mailbox already exists",
+		}
+	}
+
+	u.mailboxes[name] = NewMailbox(name)
+	return nil
+}
+
+func (u *User) Delete(name string) error {
+	u.mutex.Lock()
+	defer u.mutex.Unlock()
+
+	if _, err := u.mailboxLocked(name); err != nil {
+		return err
+	}
+
+	delete(u.mailboxes, name)
+	return nil
+}
+
+func (u *User) Rename(oldName, newName string) error {
+	u.mutex.Lock()
+	defer u.mutex.Unlock()
+
+	newName = strings.TrimRight(newName, string(mailboxDelim))
+
+	mbox, err := u.mailboxLocked(oldName)
+	if err != nil {
+		return err
+	}
+
+	if u.mailboxes[newName] != nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "Mailbox already exists",
+		}
+	}
+
+	mbox.rename(newName)
+	u.mailboxes[newName] = mbox
+	delete(u.mailboxes, oldName)
+	return nil
+}
+
+func (u *User) Subscribe(name string) error {
+	mbox, err := u.mailbox(name)
+	if err != nil {
+		return err
+	}
+	mbox.SetSubscribed(true)
+	return nil
+}
+
+func (u *User) Unsubscribe(name string) error {
+	mbox, err := u.mailbox(name)
+	if err != nil {
+		return err
+	}
+	mbox.SetSubscribed(false)
+	return nil
+}
+
+func (u *User) Namespace() (*imap.NamespaceData, error) {
+	return &imap.NamespaceData{
+		Personal: []imap.NamespaceDescriptor{{Delim: mailboxDelim}},
+	}, nil
+}
diff --git a/imapserver/list.go b/imapserver/list.go
new file mode 100644
index 0000000..0154f56
--- /dev/null
+++ b/imapserver/list.go
@@ -0,0 +1,329 @@
+package imapserver
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+	"github.com/emersion/go-imap/v2/internal/utf7"
+)
+
+func (c *Conn) handleList(dec *imapwire.Decoder) error {
+	ref, pattern, options, err := readListCmd(dec)
+	if err != nil {
+		return err
+	}
+
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+
+	w := &ListWriter{
+		conn:    c,
+		options: options,
+	}
+	return c.session.List(w, ref, pattern, options)
+}
+
+func (c *Conn) handleLSub(dec *imapwire.Decoder) error {
+	var ref string
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&ref) || !dec.ExpectSP() {
+		return dec.Err()
+	}
+	pattern, err := readListMailbox(dec)
+	if err != nil {
+		return err
+	}
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+
+	options := &imap.ListOptions{SelectSubscribed: true}
+	w := &ListWriter{
+		conn: c,
+		lsub: true,
+	}
+	return c.session.List(w, ref, []string{pattern}, options)
+}
+
+func (c *Conn) writeList(data *imap.ListData) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+
+	enc.Atom("*").SP().Atom("LIST").SP()
+	enc.List(len(data.Attrs), func(i int) {
+		enc.Atom(string(data.Attrs[i])) // TODO: validate attr
+	})
+	enc.SP()
+	if data.Delim == 0 {
+		enc.NIL()
+	} else {
+		enc.Quoted(string(data.Delim))
+	}
+	enc.SP().Mailbox(data.Mailbox)
+
+	var ext []string
+	if data.ChildInfo != nil {
+		ext = append(ext, "CHILDINFO")
+	}
+	if data.OldName != "" {
+		ext = append(ext, "OLDNAME")
+	}
+
+	// TODO: omit extended data if the client didn't ask for it
+	if len(ext) > 0 {
+		enc.SP().List(len(ext), func(i int) {
+			name := ext[i]
+			enc.Atom(name).SP()
+			switch name {
+			case "CHILDINFO":
+				enc.Special('(')
+				if data.ChildInfo.Subscribed {
+					enc.Quoted("SUBSCRIBED")
+				}
+				enc.Special(')')
+			case "OLDNAME":
+				enc.Special('(').Mailbox(data.OldName).Special(')')
+			default:
+				panic(fmt.Errorf("imapserver: unknown LIST extended-item %v", name))
+			}
+		})
+	}
+
+	return enc.CRLF()
+}
+
+func (c *Conn) writeLSub(data *imap.ListData) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+
+	enc.Atom("*").SP().Atom("LSUB").SP()
+	enc.List(len(data.Attrs), func(i int) {
+		enc.Atom(string(data.Attrs[i])) // TODO: validate attr
+	})
+	enc.SP()
+	if data.Delim == 0 {
+		enc.NIL()
+	} else {
+		enc.Quoted(string(data.Delim))
+	}
+	enc.SP().Mailbox(data.Mailbox)
+	return enc.CRLF()
+}
+
+func readListCmd(dec *imapwire.Decoder) (ref string, patterns []string, options *imap.ListOptions, err error) {
+	options = &imap.ListOptions{}
+
+	if !dec.ExpectSP() {
+		return "", nil, nil, dec.Err()
+	}
+
+	hasSelectOpts, err := dec.List(func() error {
+		var selectOpt string
+		if !dec.ExpectAString(&selectOpt) {
+			return dec.Err()
+		}
+		switch strings.ToUpper(selectOpt) {
+		case "SUBSCRIBED":
+			options.SelectSubscribed = true
+		case "REMOTE":
+			options.SelectRemote = true
+		case "RECURSIVEMATCH":
+			options.SelectRecursiveMatch = true
+		default:
+			return newClientBugError("Unknown LIST select option")
+		}
+		return nil
+	})
+	if err != nil {
+		return "", nil, nil, fmt.Errorf("in list-select-opts: %w", err)
+	}
+	if hasSelectOpts && !dec.ExpectSP() {
+		return "", nil, nil, dec.Err()
+	}
+
+	if !dec.ExpectMailbox(&ref) || !dec.ExpectSP() {
+		return "", nil, nil, dec.Err()
+	}
+
+	hasPatterns, err := dec.List(func() error {
+		pattern, err := readListMailbox(dec)
+		if err == nil && pattern != "" {
+			patterns = append(patterns, pattern)
+		}
+		return err
+	})
+	if err != nil {
+		return "", nil, nil, err
+	} else if hasPatterns && len(patterns) == 0 {
+		return "", nil, nil, newClientBugError("LIST-EXTENDED requires a non-empty parenthesized pattern list")
+	} else if !hasPatterns {
+		pattern, err := readListMailbox(dec)
+		if err != nil {
+			return "", nil, nil, err
+		}
+		if pattern != "" {
+			patterns = append(patterns, pattern)
+		}
+	}
+
+	if dec.SP() { // list-return-opts
+		var atom string
+		if !dec.ExpectAtom(&atom) || !dec.Expect(strings.EqualFold(atom, "RETURN"), "RETURN") || !dec.ExpectSP() {
+			return "", nil, nil, dec.Err()
+		}
+
+		err := dec.ExpectList(func() error {
+			return readReturnOption(dec, options)
+		})
+		if err != nil {
+			return "", nil, nil, fmt.Errorf("in list-return-opts: %w", err)
+		}
+	}
+
+	if !dec.ExpectCRLF() {
+		return "", nil, nil, dec.Err()
+	}
+
+	if options.SelectRecursiveMatch && !options.SelectSubscribed {
+		return "", nil, nil, newClientBugError("The LIST RECURSIVEMATCH select option requires SUBSCRIBED")
+	}
+
+	return ref, patterns, options, nil
+}
+
+func readListMailbox(dec *imapwire.Decoder) (string, error) {
+	var mailbox string
+	if !dec.String(&mailbox) {
+		if !dec.Expect(dec.Func(&mailbox, isListChar), "list-char") {
+			return "", dec.Err()
+		}
+	}
+	return utf7.Encoding.NewDecoder().String(mailbox)
+}
+
+func isListChar(ch byte) bool {
+	switch ch {
+	case '%', '*': // list-wildcards
+		return true
+	case ']': // resp-specials
+		return true
+	default:
+		return imapwire.IsAtomChar(ch)
+	}
+}
+
+func readReturnOption(dec *imapwire.Decoder, options *imap.ListOptions) error {
+	var name string
+	if !dec.ExpectAtom(&name) {
+		return dec.Err()
+	}
+
+	switch strings.ToUpper(name) {
+	case "SUBSCRIBED":
+		options.ReturnSubscribed = true
+	case "CHILDREN":
+		options.ReturnChildren = true
+	case "STATUS":
+		if !dec.ExpectSP() {
+			return dec.Err()
+		}
+		return dec.ExpectList(func() error {
+			item, err := readStatusItem(dec)
+			if err != nil {
+				return err
+			}
+			options.ReturnStatus = append(options.ReturnStatus, item)
+			return nil
+		})
+	default:
+		return newClientBugError("Unknown LIST RETURN options")
+	}
+	return nil
+}
+
+// ListWriter writes LIST responses.
+type ListWriter struct {
+	conn    *Conn
+	options *imap.ListOptions
+	lsub    bool
+}
+
+// WriteList writes a single LIST response for a mailbox.
+func (w *ListWriter) WriteList(data *imap.ListData) error {
+	if w.lsub {
+		return w.conn.writeLSub(data)
+	}
+
+	if err := w.conn.writeList(data); err != nil {
+		return err
+	}
+	if len(w.options.ReturnStatus) > 0 && data.Status != nil {
+		if err := w.conn.writeStatus(data.Status, w.options.ReturnStatus); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// MatchList checks whether a reference and a pattern matches a mailbox.
+func MatchList(name string, delim rune, reference, pattern string) bool {
+	var delimStr string
+	if delim != 0 {
+		delimStr = string(delim)
+	}
+
+	if delimStr != "" && strings.HasPrefix(pattern, delimStr) {
+		reference = ""
+		pattern = strings.TrimPrefix(pattern, delimStr)
+	}
+	if reference != "" {
+		if delimStr != "" && !strings.HasSuffix(reference, delimStr) {
+			reference += delimStr
+		}
+		if !strings.HasPrefix(name, reference) {
+			return false
+		}
+		name = strings.TrimPrefix(name, reference)
+	}
+
+	return matchList(name, delimStr, pattern)
+}
+
+func matchList(name, delim, pattern string) bool {
+	// TODO: optimize
+
+	i := strings.IndexAny(pattern, "*%")
+	if i == -1 {
+		// No more wildcards
+		return name == pattern
+	}
+
+	// Get parts before and after wildcard
+	chunk, wildcard, rest := pattern[0:i], pattern[i], pattern[i+1:]
+
+	// Check that name begins with chunk
+	if len(chunk) > 0 && !strings.HasPrefix(name, chunk) {
+		return false
+	}
+	name = strings.TrimPrefix(name, chunk)
+
+	// Expand wildcard
+	var j int
+	for j = 0; j < len(name); j++ {
+		if wildcard == '%' && string(name[j]) == delim {
+			break // Stop on delimiter if wildcard is %
+		}
+		// Try to match the rest from here
+		if matchList(name[j:], delim, rest) {
+			return true
+		}
+	}
+
+	return matchList(name[j:], delim, rest)
+}
diff --git a/imapserver/login.go b/imapserver/login.go
new file mode 100644
index 0000000..ae395de
--- /dev/null
+++ b/imapserver/login.go
@@ -0,0 +1,28 @@
+package imapserver
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleLogin(tag string, dec *imapwire.Decoder) error {
+	var username, password string
+	if !dec.ExpectSP() || !dec.ExpectAString(&username) || !dec.ExpectSP() || !dec.ExpectAString(&password) || !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+	if err := c.checkState(imap.ConnStateNotAuthenticated); err != nil {
+		return err
+	}
+	if !c.canAuth() {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Code: imap.ResponseCodePrivacyRequired,
+			Text: "TLS is required to authenticate",
+		}
+	}
+	if err := c.session.Login(username, password); err != nil {
+		return err
+	}
+	c.state = imap.ConnStateAuthenticated
+	return c.writeCapabilityOK(tag, "Logged in")
+}
diff --git a/imapserver/move.go b/imapserver/move.go
new file mode 100644
index 0000000..cb29925
--- /dev/null
+++ b/imapserver/move.go
@@ -0,0 +1,40 @@
+package imapserver
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleMove(dec *imapwire.Decoder, numKind NumKind) error {
+	seqSet, dest, err := readCopy(dec)
+	if err != nil {
+		return err
+	}
+	if err := c.checkState(imap.ConnStateSelected); err != nil {
+		return err
+	}
+	session, ok := c.session.(SessionMove)
+	if !ok {
+		return newClientBugError("MOVE is not supported")
+	}
+	w := &MoveWriter{conn: c}
+	return session.Move(w, numKind, seqSet, dest)
+}
+
+// MoveWriter writes responses for the MOVE command.
+//
+// Servers must first call WriteCopyData once, then call WriteExpunge any
+// number of times.
+type MoveWriter struct {
+	conn *Conn
+}
+
+// WriteCopyData writes the untagged COPYUID response for a MOVE command.
+func (w *MoveWriter) WriteCopyData(data *imap.CopyData) error {
+	return w.conn.writeCopyOK("", data)
+}
+
+// WriteExpunge writes an EXPUNGE response for a MOVE command.
+func (w *MoveWriter) WriteExpunge(seqNum uint32) error {
+	return w.conn.writeExpunge(seqNum)
+}
diff --git a/imapserver/namespace.go b/imapserver/namespace.go
new file mode 100644
index 0000000..10e973d
--- /dev/null
+++ b/imapserver/namespace.go
@@ -0,0 +1,54 @@
+package imapserver
+
+import (
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleNamespace(dec *imapwire.Decoder) error {
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+
+	session, ok := c.session.(SessionNamespace)
+	if !ok {
+		return newClientBugError("NAMESPACE is not supported")
+	}
+
+	data, err := session.Namespace()
+	if err != nil {
+		return err
+	}
+
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	enc.Atom("*").SP().Atom("NAMESPACE").SP()
+	writeNamespace(enc.Encoder, data.Personal)
+	enc.SP()
+	writeNamespace(enc.Encoder, data.Other)
+	enc.SP()
+	writeNamespace(enc.Encoder, data.Shared)
+	return enc.CRLF()
+}
+
+func writeNamespace(enc *imapwire.Encoder, l []imap.NamespaceDescriptor) {
+	if l == nil {
+		enc.NIL()
+		return
+	}
+
+	enc.List(len(l), func(i int) {
+		descr := l[i]
+		enc.Special('(').String(descr.Prefix).SP()
+		if descr.Delim == 0 {
+			enc.NIL()
+		} else {
+			enc.Quoted(string(descr.Delim))
+		}
+		enc.Special(')')
+	})
+}
diff --git a/imapserver/search.go b/imapserver/search.go
new file mode 100644
index 0000000..2776f15
--- /dev/null
+++ b/imapserver/search.go
@@ -0,0 +1,343 @@
+package imapserver
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleSearch(tag string, dec *imapwire.Decoder, numKind NumKind) error {
+	if !dec.ExpectSP() {
+		return dec.Err()
+	}
+	var (
+		atom     string
+		options  imap.SearchOptions
+		extended bool
+	)
+	if maybeReadSearchKeyAtom(dec, &atom) && strings.EqualFold(atom, "RETURN") {
+		var err error
+		options.Return, err = readSearchReturnOpts(dec)
+		if err != nil {
+			return fmt.Errorf("in search-return-opts: %w", err)
+		}
+		if !dec.ExpectSP() {
+			return dec.Err()
+		}
+		extended = true
+		atom = ""
+		maybeReadSearchKeyAtom(dec, &atom)
+	}
+	if strings.EqualFold(atom, "CHARSET") {
+		var charset string
+		if !dec.ExpectSP() || !dec.ExpectAString(&charset) || !dec.ExpectSP() {
+			return dec.Err()
+		}
+		switch strings.ToUpper(charset) {
+		case "US-ASCII", "UTF-8":
+			// nothing to do
+		default:
+			return &imap.Error{
+				Type: imap.StatusResponseTypeNo,
+				Code: imap.ResponseCodeBadCharset, // TODO: return list of supported charsets
+				Text: "Only US-ASCII and UTF-8 are supported SEARCH charsets",
+			}
+		}
+		atom = ""
+		maybeReadSearchKeyAtom(dec, &atom)
+	}
+
+	var criteria imap.SearchCriteria
+	for {
+		var err error
+		if atom != "" {
+			err = readSearchKeyWithAtom(&criteria, dec, atom)
+			atom = ""
+		} else {
+			err = readSearchKey(&criteria, dec)
+		}
+		if err != nil {
+			return fmt.Errorf("in search-key: %w", err)
+		}
+
+		if !dec.SP() {
+			break
+		}
+	}
+
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateSelected); err != nil {
+		return err
+	}
+
+	data, err := c.session.Search(numKind, &criteria, &options)
+	if err != nil {
+		return err
+	}
+
+	if c.enabled.Has(imap.CapIMAP4rev2) || extended {
+		return c.writeESearch(tag, data, &options)
+	} else {
+		return c.writeSearch(data.All)
+	}
+}
+
+func (c *Conn) writeESearch(tag string, data *imap.SearchData, options *imap.SearchOptions) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+
+	returnOpts := make(map[imap.SearchReturnOption]bool)
+	for _, opt := range options.Return {
+		returnOpts[opt] = true
+	}
+
+	enc.Atom("*").SP().Atom("ESEARCH")
+	if tag != "" {
+		enc.SP().Special('(').Atom("TAG").SP().Atom(tag).Special(')')
+	}
+	if data.UID {
+		enc.SP().Atom("UID")
+	}
+	if (returnOpts[imap.SearchReturnAll] || len(options.Return) == 0) && len(data.All) > 0 {
+		enc.SP().Atom("ALL").SP().Atom(data.All.String())
+	}
+	if returnOpts[imap.SearchReturnMin] && data.Min > 0 {
+		enc.SP().Atom("MIN").SP().Number(data.Min)
+	}
+	if returnOpts[imap.SearchReturnMax] && data.Max > 0 {
+		enc.SP().Atom("MAX").SP().Number(data.Max)
+	}
+	if returnOpts[imap.SearchReturnCount] {
+		enc.SP().Atom("COUNT").SP().Number(data.Count)
+	}
+	return enc.CRLF()
+}
+
+func (c *Conn) writeSearch(seqSet imap.SeqSet) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+
+	nums, ok := seqSet.Nums()
+	if !ok {
+		return fmt.Errorf("imapserver: failed to enumerate message numbers in SEARCH response")
+	}
+
+	enc.Atom("*").SP().Atom("SEARCH")
+	for _, num := range nums {
+		enc.SP().Number(num)
+	}
+	return enc.CRLF()
+}
+
+func readSearchReturnOpts(dec *imapwire.Decoder) ([]imap.SearchReturnOption, error) {
+	if !dec.ExpectSP() {
+		return nil, dec.Err()
+	}
+	var l []imap.SearchReturnOption
+	err := dec.ExpectList(func() error {
+		var name string
+		if !dec.ExpectAtom(&name) {
+			return dec.Err()
+		}
+		switch opt := imap.SearchReturnOption(strings.ToUpper(name)); opt {
+		case imap.SearchReturnMin, imap.SearchReturnMax, imap.SearchReturnAll, imap.SearchReturnCount:
+			l = append(l, opt)
+		default:
+			return newClientBugError("unknown SEARCH RETURN option")
+		}
+		return nil
+	})
+	return l, err
+}
+
+func maybeReadSearchKeyAtom(dec *imapwire.Decoder, ptr *string) bool {
+	return dec.Func(ptr, func(ch byte) bool {
+		return ch == '*' || imapwire.IsAtomChar(ch)
+	})
+}
+
+func readSearchKey(criteria *imap.SearchCriteria, dec *imapwire.Decoder) error {
+	var key string
+	if maybeReadSearchKeyAtom(dec, &key) {
+		return readSearchKeyWithAtom(criteria, dec, key)
+	}
+	return dec.ExpectList(func() error {
+		return readSearchKey(criteria, dec)
+	})
+}
+
+func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, key string) error {
+	key = strings.ToUpper(key)
+	switch key {
+	case "ALL":
+		// nothing to do
+	case "UID":
+		var seqSet imap.SeqSet
+		if !dec.ExpectSP() || !dec.ExpectSeqSet(&seqSet) {
+			return dec.Err()
+		}
+		criteria.UID = seqSet // TODO: intersect
+	case "ANSWERED", "DELETED", "DRAFT", "FLAGGED", "RECENT", "SEEN":
+		criteria.Flag = append(criteria.Flag, searchKeyFlag(key))
+	case "UNANSWERED", "UNDELETED", "UNDRAFT", "UNFLAGGED", "UNSEEN":
+		notKey := strings.TrimPrefix(key, "UN")
+		criteria.NotFlag = append(criteria.NotFlag, searchKeyFlag(notKey))
+	case "NEW":
+		criteria.Flag = append(criteria.Flag, internal.FlagRecent)
+		criteria.NotFlag = append(criteria.Flag, imap.FlagSeen)
+	case "OLD":
+		criteria.NotFlag = append(criteria.NotFlag, internal.FlagRecent)
+	case "KEYWORD", "UNKEYWORD":
+		if !dec.ExpectSP() {
+			return dec.Err()
+		}
+		flagStr, err := internal.ReadFlag(dec)
+		if err != nil {
+			return err
+		}
+		flag := imap.Flag(flagStr)
+		switch key {
+		case "KEYWORD":
+			criteria.Flag = append(criteria.Flag, flag)
+		case "UNKEYWORD":
+			criteria.NotFlag = append(criteria.NotFlag, flag)
+		}
+	case "BCC", "CC", "FROM", "SUBJECT", "TO":
+		var value string
+		if !dec.ExpectSP() || !dec.ExpectAString(&value) {
+			return dec.Err()
+		}
+		criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{
+			Key:   strings.Title(strings.ToLower(key)),
+			Value: value,
+		})
+	case "HEADER":
+		var key, value string
+		if !dec.ExpectSP() || !dec.ExpectAString(&key) || !dec.ExpectSP() || !dec.ExpectAString(&value) {
+			return dec.Err()
+		}
+		criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{
+			Key:   key,
+			Value: value,
+		})
+	case "SINCE", "BEFORE", "ON", "SENTSINCE", "SENTBEFORE", "SENTON":
+		if !dec.ExpectSP() {
+			return dec.Err()
+		}
+		t, err := internal.ExpectDate(dec)
+		if err != nil {
+			return err
+		}
+		switch key {
+		case "SINCE":
+			criteria.Since = intersectSince(criteria.Since, t)
+		case "BEFORE":
+			criteria.Before = intersectBefore(criteria.Before, t)
+		case "ON":
+			criteria.Since = intersectSince(criteria.Since, t)
+			criteria.Before = intersectBefore(criteria.Before, t.Add(24*time.Hour))
+		case "SENTSINCE":
+			criteria.SentSince = intersectSince(criteria.SentSince, t)
+		case "SENTBEFORE":
+			criteria.SentBefore = intersectBefore(criteria.SentBefore, t)
+		case "SENTON":
+			criteria.SentSince = intersectSince(criteria.SentSince, t)
+			criteria.SentBefore = intersectBefore(criteria.SentBefore, t.Add(24*time.Hour))
+		}
+	case "BODY":
+		var body string
+		if !dec.ExpectSP() || !dec.ExpectAString(&body) {
+			return dec.Err()
+		}
+		criteria.Body = append(criteria.Body, body)
+	case "TEXT":
+		var text string
+		if !dec.ExpectSP() || !dec.ExpectAString(&text) {
+			return dec.Err()
+		}
+		criteria.Text = append(criteria.Text, text)
+	case "LARGER", "SMALLER":
+		var n int64
+		if !dec.ExpectSP() || !dec.ExpectNumber64(&n) {
+			return dec.Err()
+		}
+		switch key {
+		case "LARGER":
+			if criteria.Larger == 0 || n > criteria.Larger {
+				criteria.Larger = n
+			}
+		case "SMALLER":
+			if criteria.Smaller == 0 || n < criteria.Smaller {
+				criteria.Smaller = n
+			}
+		}
+	case "NOT":
+		if !dec.ExpectSP() {
+			return dec.Err()
+		}
+		var not imap.SearchCriteria
+		if err := readSearchKey(&not, dec); err != nil {
+			return nil
+		}
+		criteria.Not = append(criteria.Not, not)
+	case "OR":
+		if !dec.ExpectSP() {
+			return dec.Err()
+		}
+		var or [2]imap.SearchCriteria
+		if err := readSearchKey(&or[0], dec); err != nil {
+			return nil
+		}
+		if !dec.ExpectSP() {
+			return dec.Err()
+		}
+		if err := readSearchKey(&or[1], dec); err != nil {
+			return nil
+		}
+		criteria.Or = append(criteria.Or, or)
+	default:
+		seqSet, err := imap.ParseSeqSet(key)
+		if err != nil {
+			return err
+		}
+		criteria.SeqNum = seqSet // TODO: intersect
+	}
+	return nil
+}
+
+func searchKeyFlag(key string) imap.Flag {
+	return imap.Flag("\\" + strings.Title(strings.ToLower(key)))
+}
+
+func intersectSince(t1, t2 time.Time) time.Time {
+	switch {
+	case t1.IsZero():
+		return t2
+	case t2.IsZero():
+		return t1
+	case t1.After(t2):
+		return t1
+	default:
+		return t2
+	}
+}
+
+func intersectBefore(t1, t2 time.Time) time.Time {
+	switch {
+	case t1.IsZero():
+		return t2
+	case t2.IsZero():
+		return t1
+	case t1.Before(t2):
+		return t1
+	default:
+		return t2
+	}
+}
diff --git a/imapserver/select.go b/imapserver/select.go
new file mode 100644
index 0000000..e052dc6
--- /dev/null
+++ b/imapserver/select.go
@@ -0,0 +1,165 @@
+package imapserver
+
+import (
+	"fmt"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+// SelectOptions contains options for the SELECT or EXAMINE command.
+type SelectOptions struct {
+	ReadOnly bool
+}
+
+func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) error {
+	var mailbox string
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+
+	if c.state == imap.ConnStateSelected {
+		if err := c.session.Unselect(); err != nil {
+			return err
+		}
+		c.state = imap.ConnStateAuthenticated
+		err := c.writeStatusResp("", &imap.StatusResponse{
+			Type: imap.StatusResponseTypeOK,
+			Code: "CLOSED",
+			Text: "Previous mailbox is now closed",
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	options := SelectOptions{ReadOnly: readOnly}
+	data, err := c.session.Select(mailbox, &options)
+	if err != nil {
+		return err
+	}
+
+	if err := c.writeExists(data.NumMessages); err != nil {
+		return err
+	}
+	if !c.enabled.Has(imap.CapIMAP4rev2) {
+		if err := c.writeObsoleteRecent(); err != nil {
+			return err
+		}
+	}
+	if err := c.writeUIDValidity(data.UIDValidity); err != nil {
+		return err
+	}
+	if err := c.writeUIDNext(data.UIDNext); err != nil {
+		return err
+	}
+	if err := c.writeFlags(data.Flags); err != nil {
+		return err
+	}
+	if err := c.writePermanentFlags(data.PermanentFlags); err != nil {
+		return err
+	}
+	if data.List != nil {
+		if err := c.writeList(data.List); err != nil {
+			return err
+		}
+	}
+
+	c.state = imap.ConnStateSelected
+	// TODO: forbid write commands in read-only mode
+
+	var (
+		cmdName string
+		code    imap.ResponseCode
+	)
+	if readOnly {
+		cmdName = "EXAMINE"
+		code = "READ-ONLY"
+	} else {
+		cmdName = "SELECT"
+		code = "READ-WRITE"
+	}
+	return c.writeStatusResp(tag, &imap.StatusResponse{
+		Type: imap.StatusResponseTypeOK,
+		Code: code,
+		Text: fmt.Sprintf("%v completed", cmdName),
+	})
+}
+
+func (c *Conn) handleUnselect(dec *imapwire.Decoder, expunge bool) error {
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateSelected); err != nil {
+		return err
+	}
+
+	if expunge {
+		w := &ExpungeWriter{}
+		if err := c.session.Expunge(w, nil); err != nil {
+			return err
+		}
+	}
+
+	if err := c.session.Unselect(); err != nil {
+		return err
+	}
+
+	c.state = imap.ConnStateAuthenticated
+	return nil
+}
+
+func (c *Conn) writeExists(numMessages uint32) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	return enc.Atom("*").SP().Number(numMessages).SP().Atom("EXISTS").CRLF()
+}
+
+func (c *Conn) writeObsoleteRecent() error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	return enc.Atom("*").SP().Number(0).SP().Atom("RECENT").CRLF()
+}
+
+func (c *Conn) writeUIDValidity(uidValidity uint32) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	enc.Atom("*").SP().Atom("OK").SP()
+	enc.Special('[').Atom("UIDVALIDITY").SP().Number(uidValidity).Special(']')
+	enc.SP().Text("UIDs valid")
+	return enc.CRLF()
+}
+
+func (c *Conn) writeUIDNext(uidNext uint32) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	enc.Atom("*").SP().Atom("OK").SP()
+	enc.Special('[').Atom("UIDNEXT").SP().Number(uidNext).Special(']')
+	enc.SP().Text("Predicted next UID")
+	return enc.CRLF()
+}
+
+func (c *Conn) writeFlags(flags []imap.Flag) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	enc.Atom("*").SP().Atom("FLAGS").SP().List(len(flags), func(i int) {
+		enc.Flag(flags[i])
+	})
+	return enc.CRLF()
+}
+
+func (c *Conn) writePermanentFlags(flags []imap.Flag) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	enc.Atom("*").SP().Atom("OK").SP()
+	enc.Special('[').Atom("PERMANENTFLAGS").SP().List(len(flags), func(i int) {
+		enc.Flag(flags[i])
+	}).Special(']')
+	enc.SP().Text("Permanent flags")
+	return enc.CRLF()
+}
diff --git a/imapserver/server.go b/imapserver/server.go
new file mode 100644
index 0000000..fd6cc17
--- /dev/null
+++ b/imapserver/server.go
@@ -0,0 +1,190 @@
+// Package imapserver implements an IMAP server.
+package imapserver
+
+import (
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"sync"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+)
+
+var errClosed = errors.New("imapserver: server closed")
+
+// Logger is a facility to log error messages.
+type Logger interface {
+	Printf(format string, args ...interface{})
+}
+
+// Options contains server options.
+//
+// The only required field is NewSession.
+type Options struct {
+	// NewSession is called when a client connects.
+	NewSession func(*Conn) (Session, error)
+	// Supported capabilities. If nil, only IMAP4rev1 is advertised. This set
+	// must contain at least IMAP4rev1 or IMAP4rev2.
+	//
+	// The following capabilities are part of IMAP4rev2 and need to be
+	// explicitly enabled by IMAP4rev1-only servers:
+	//
+	//   - NAMESPACE
+	//   - UIDPLUS
+	//   - ESEARCH
+	//   - LIST-EXTENDED
+	//   - LIST-STATUS
+	//   - MOVE
+	//   - STATUS=SIZE
+	Caps imap.CapSet
+	// Logger is a logger to print error messages. If nil, log.Default is used.
+	Logger Logger
+	// TLSConfig is a TLS configuration for STARTTLS. If nil, STARTTLS is
+	// disabled.
+	TLSConfig *tls.Config
+	// InsecureAuth allows clients to authenticate without TLS. In this mode,
+	// the server is susceptible to man-in-the-middle attacks.
+	InsecureAuth bool
+	// Raw ingress and egress data will be written to this writer, if any.
+	// Note, this may include sensitive information such as credentials used
+	// during authentication.
+	DebugWriter io.Writer
+}
+
+func (options *Options) wrapReadWriter(rw io.ReadWriter) io.ReadWriter {
+	if options.DebugWriter == nil {
+		return rw
+	}
+	return struct {
+		io.Reader
+		io.Writer
+	}{
+		Reader: io.TeeReader(rw, options.DebugWriter),
+		Writer: io.MultiWriter(rw, options.DebugWriter),
+	}
+}
+
+func (options *Options) caps() imap.CapSet {
+	if options.Caps != nil {
+		return options.Caps
+	}
+	return imap.CapSet{imap.CapIMAP4rev1: {}}
+}
+
+// Server is an IMAP server.
+type Server struct {
+	options Options
+
+	listenerWaitGroup sync.WaitGroup
+
+	mutex     sync.Mutex
+	listeners map[net.Listener]struct{}
+	conns     map[*Conn]struct{}
+	closed    bool
+}
+
+// New creates a new server.
+func New(options *Options) *Server {
+	if caps := options.caps(); !caps.Has(imap.CapIMAP4rev2) && !caps.Has(imap.CapIMAP4rev1) {
+		panic("imapserver: at least IMAP4rev1 must be supported")
+	}
+	return &Server{
+		options:   *options,
+		listeners: make(map[net.Listener]struct{}),
+		conns:     make(map[*Conn]struct{}),
+	}
+}
+
+func (s *Server) logger() Logger {
+	if s.options.Logger == nil {
+		return log.Default()
+	}
+	return s.options.Logger
+}
+
+// Serve accepts incoming connections on the listener ln.
+func (s *Server) Serve(ln net.Listener) error {
+	s.mutex.Lock()
+	ok := !s.closed
+	if ok {
+		s.listeners[ln] = struct{}{}
+	}
+	s.mutex.Unlock()
+	if !ok {
+		return errClosed
+	}
+
+	defer func() {
+		s.mutex.Lock()
+		delete(s.listeners, ln)
+		s.mutex.Unlock()
+	}()
+
+	s.listenerWaitGroup.Add(1)
+	defer s.listenerWaitGroup.Done()
+
+	var delay time.Duration
+	for {
+		conn, err := ln.Accept()
+		if ne, ok := err.(net.Error); ok && ne.Temporary() {
+			if delay == 0 {
+				delay = 5 * time.Millisecond
+			} else {
+				delay *= 2
+			}
+			if max := 1 * time.Second; delay > max {
+				delay = max
+			}
+			s.logger().Printf("accept error (retrying in %v): %v", delay, err)
+			time.Sleep(delay)
+			continue
+		} else if errors.Is(err, net.ErrClosed) {
+			return nil
+		} else if err != nil {
+			return fmt.Errorf("accept error: %w", err)
+		}
+
+		delay = 0
+		go newConn(conn, s).serve()
+	}
+}
+
+// Close immediately closes all active listeners and connections.
+//
+// Close returns any error returned from closing the server's underlying
+// listeners.
+//
+// Once Close has been called on a server, it may not be reused; future calls
+// to methods such as Serve will return an error.
+func (s *Server) Close() error {
+	var err error
+
+	s.mutex.Lock()
+	ok := !s.closed
+	if ok {
+		s.closed = true
+		for l := range s.listeners {
+			if closeErr := l.Close(); closeErr != nil && err == nil {
+				err = closeErr
+			}
+		}
+	}
+	s.mutex.Unlock()
+	if !ok {
+		return errClosed
+	}
+
+	s.listenerWaitGroup.Wait()
+
+	s.mutex.Lock()
+	for c := range s.conns {
+		c.conn.Close()
+	}
+	s.mutex.Unlock()
+
+	return err
+}
diff --git a/imapserver/session.go b/imapserver/session.go
new file mode 100644
index 0000000..a220ce4
--- /dev/null
+++ b/imapserver/session.go
@@ -0,0 +1,89 @@
+package imapserver
+
+import (
+	"fmt"
+
+	"github.com/emersion/go-imap/v2"
+)
+
+var errAuthFailed = &imap.Error{
+	Type: imap.StatusResponseTypeNo,
+	Code: imap.ResponseCodeAuthenticationFailed,
+	Text: "Authentication failed",
+}
+
+// ErrAuthFailed is returned by Session.Login on authentication failure.
+var ErrAuthFailed = errAuthFailed
+
+// NumKind describes how a number should be interpreted: either as a sequence
+// number, either as a UID.
+type NumKind int
+
+const (
+	NumKindSeq NumKind = 1 + iota
+	NumKindUID
+)
+
+// String implements fmt.Stringer.
+func (kind NumKind) String() string {
+	switch kind {
+	case NumKindSeq:
+		return "seq"
+	case NumKindUID:
+		return "uid"
+	default:
+		panic(fmt.Errorf("imapserver: unknown NumKind %d", kind))
+	}
+}
+
+// Session is an IMAP session.
+type Session interface {
+	Close() error
+
+	// Not authenticated state
+	Login(username, password string) error
+
+	// Authenticated state
+	Select(mailbox string, options *SelectOptions) (*imap.SelectData, error)
+	Create(mailbox string) error
+	Delete(mailbox string) error
+	Rename(mailbox, newName string) error
+	Subscribe(mailbox string) error
+	Unsubscribe(mailbox string) error
+	List(w *ListWriter, ref string, patterns []string, options *imap.ListOptions) error
+	Status(mailbox string, items []imap.StatusItem) (*imap.StatusData, error)
+	Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error)
+	Poll(w *UpdateWriter, allowExpunge bool) error
+	Idle(w *UpdateWriter, stop <-chan struct{}) error
+
+	// Selected state
+	Unselect() error
+	Expunge(w *ExpungeWriter, uids *imap.SeqSet) error
+	Search(kind NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error)
+	Fetch(w *FetchWriter, kind NumKind, seqSet imap.SeqSet, items []imap.FetchItem) error
+	Store(w *FetchWriter, kind NumKind, seqSet imap.SeqSet, flags *imap.StoreFlags) error
+	Copy(kind NumKind, seqSet imap.SeqSet, dest string) (*imap.CopyData, error)
+}
+
+// SessionNamespace is an IMAP session which supports NAMESPACE.
+type SessionNamespace interface {
+	Session
+
+	// Authenticated state
+	Namespace() (*imap.NamespaceData, error)
+}
+
+// SessionMove is an IMAP session which supports MOVE.
+type SessionMove interface {
+	Session
+
+	// Selected state
+	Move(w *MoveWriter, kind NumKind, seqSet imap.SeqSet, dest string) error
+}
+
+// SessionIMAP4rev2 is an IMAP session which supports IMAP4rev2.
+type SessionIMAP4rev2 interface {
+	Session
+	SessionNamespace
+	SessionMove
+}
diff --git a/imapserver/starttls.go b/imapserver/starttls.go
new file mode 100644
index 0000000..d5151d7
--- /dev/null
+++ b/imapserver/starttls.go
@@ -0,0 +1,83 @@
+package imapserver
+
+import (
+	"bytes"
+	"crypto/tls"
+	"io"
+	"net"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) canStartTLS() bool {
+	_, isTLS := c.conn.(*tls.Conn)
+	return c.server.options.TLSConfig != nil && c.state == imap.ConnStateNotAuthenticated && !isTLS
+}
+
+func (c *Conn) handleStartTLS(tag string, dec *imapwire.Decoder) error {
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if c.server.options.TLSConfig == nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "STARTTLS not supported",
+		}
+	}
+	if !c.canStartTLS() {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeBad,
+			Text: "STARTTLS not available",
+		}
+	}
+
+	// Do not allow to write cleartext data past this point: keep c.encMutex
+	// locked until the end
+	enc := newResponseEncoder(c)
+	defer enc.end()
+
+	err := writeStatusResp(enc.Encoder, tag, &imap.StatusResponse{
+		Type: imap.StatusResponseTypeOK,
+		Text: "Begin TLS negotiation now",
+	})
+	if err != nil {
+		return err
+	}
+
+	// Drain buffered data from our bufio.Reader
+	var buf bytes.Buffer
+	if _, err := io.CopyN(&buf, c.br, int64(c.br.Buffered())); err != nil {
+		panic(err) // unreachable
+	}
+
+	var cleartextConn net.Conn
+	if buf.Len() > 0 {
+		r := io.MultiReader(&buf, c.conn)
+		cleartextConn = startTLSConn{c.conn, r}
+	} else {
+		cleartextConn = c.conn
+	}
+
+	tlsConn := tls.Server(cleartextConn, c.server.options.TLSConfig)
+
+	c.mutex.Lock()
+	c.conn = tlsConn
+	c.mutex.Unlock()
+
+	rw := c.server.options.wrapReadWriter(tlsConn)
+	c.br.Reset(rw)
+	c.bw.Reset(rw)
+
+	return nil
+}
+
+type startTLSConn struct {
+	net.Conn
+	r io.Reader
+}
+
+func (conn startTLSConn) Read(b []byte) (int, error) {
+	return conn.r.Read(b)
+}
diff --git a/imapserver/status.go b/imapserver/status.go
new file mode 100644
index 0000000..08ac506
--- /dev/null
+++ b/imapserver/status.go
@@ -0,0 +1,105 @@
+package imapserver
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleStatus(dec *imapwire.Decoder) error {
+	var mailbox string
+	if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() {
+		return dec.Err()
+	}
+
+	var items []imap.StatusItem
+	recent := false
+	err := dec.ExpectList(func() error {
+		item, err := readStatusItem(dec)
+		if err != nil {
+			return err
+		} else if item == internal.StatusItemRecent {
+			recent = true
+			return nil
+		}
+		items = append(items, item)
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
+		return err
+	}
+
+	data, err := c.session.Status(mailbox, items)
+	if err != nil {
+		return err
+	}
+
+	if recent {
+		items = append(items, internal.StatusItemRecent)
+	}
+	return c.writeStatus(data, items)
+}
+
+func (c *Conn) writeStatus(data *imap.StatusData, items []imap.StatusItem) error {
+	enc := newResponseEncoder(c)
+	defer enc.end()
+	return enc.Atom("*").SP().Atom("STATUS").SP().Mailbox(data.Mailbox).SP().List(len(items), func(i int) {
+		item := items[i]
+		enc.Atom(string(item)).SP()
+		switch item {
+		case imap.StatusItemNumMessages:
+			enc.Number(*data.NumMessages)
+		case imap.StatusItemUIDNext:
+			enc.Number(data.UIDNext)
+		case imap.StatusItemUIDValidity:
+			enc.Number(data.UIDValidity)
+		case imap.StatusItemNumUnseen:
+			enc.Number(*data.NumUnseen)
+		case imap.StatusItemNumDeleted:
+			enc.Number(*data.NumDeleted)
+		case imap.StatusItemSize:
+			enc.Number64(*data.Size)
+		case imap.StatusItemAppendLimit:
+			if data.AppendLimit != nil {
+				enc.Number(*data.AppendLimit)
+			} else {
+				enc.NIL()
+			}
+		case imap.StatusItemDeletedStorage:
+			enc.Number64(*data.DeletedStorage)
+		case internal.StatusItemRecent:
+			enc.Number(0)
+		default:
+			panic(fmt.Errorf("imapserver: unknown STATUS item %v", item))
+		}
+	}).CRLF()
+}
+
+func readStatusItem(dec *imapwire.Decoder) (imap.StatusItem, error) {
+	var name string
+	if !dec.ExpectAtom(&name) {
+		return "", dec.Err()
+	}
+	switch item := imap.StatusItem(strings.ToUpper(name)); item {
+	case imap.StatusItemNumMessages, imap.StatusItemUIDNext, imap.StatusItemUIDValidity, imap.StatusItemNumUnseen, imap.StatusItemNumDeleted, imap.StatusItemSize, imap.StatusItemAppendLimit, imap.StatusItemDeletedStorage:
+		return item, nil
+	case internal.StatusItemRecent:
+		return item, nil
+	default:
+		return "", &imap.Error{
+			Type: imap.StatusResponseTypeBad,
+			Text: "Unknown STATUS data item",
+		}
+	}
+}
diff --git a/imapserver/store.go b/imapserver/store.go
new file mode 100644
index 0000000..bc2eb26
--- /dev/null
+++ b/imapserver/store.go
@@ -0,0 +1,77 @@
+package imapserver
+
+import (
+	"strings"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+func (c *Conn) handleStore(dec *imapwire.Decoder, numKind NumKind) error {
+	var (
+		seqSet imap.SeqSet
+		item   string
+	)
+	if !dec.ExpectSP() || !dec.ExpectSeqSet(&seqSet) || !dec.ExpectSP() || !dec.ExpectAtom(&item) || !dec.ExpectSP() {
+		return dec.Err()
+	}
+	var flags []imap.Flag
+	isList, err := dec.List(func() error {
+		flag, err := internal.ReadFlag(dec)
+		if err != nil {
+			return err
+		}
+		flags = append(flags, imap.Flag(flag))
+		return nil
+	})
+	if err != nil {
+		return err
+	} else if !isList {
+		for {
+			flag, err := internal.ReadFlag(dec)
+			if err != nil {
+				return err
+			}
+			flags = append(flags, imap.Flag(flag))
+
+			if !dec.SP() {
+				break
+			}
+		}
+	}
+	if !dec.ExpectCRLF() {
+		return dec.Err()
+	}
+
+	item = strings.ToUpper(item)
+	silent := strings.HasSuffix(item, ".SILENT")
+	item = strings.TrimSuffix(item, ".SILENT")
+
+	var op imap.StoreFlagsOp
+	switch {
+	case strings.HasPrefix(item, "+"):
+		op = imap.StoreFlagsAdd
+		item = strings.TrimPrefix(item, "+")
+	case strings.HasPrefix(item, "-"):
+		op = imap.StoreFlagsDel
+		item = strings.TrimPrefix(item, "-")
+	default:
+		op = imap.StoreFlagsSet
+	}
+
+	if item != "FLAGS" {
+		return newClientBugError("STORE can only change FLAGS")
+	}
+
+	if err := c.checkState(imap.ConnStateSelected); err != nil {
+		return err
+	}
+
+	w := &FetchWriter{conn: c}
+	return c.session.Store(w, numKind, seqSet, &imap.StoreFlags{
+		Op:     op,
+		Silent: silent,
+		Flags:  flags,
+	})
+}
diff --git a/imapserver/tracker.go b/imapserver/tracker.go
new file mode 100644
index 0000000..55cd9f2
--- /dev/null
+++ b/imapserver/tracker.go
@@ -0,0 +1,284 @@
+package imapserver
+
+import (
+	"fmt"
+	"sync"
+
+	"github.com/emersion/go-imap/v2"
+)
+
+// MailboxTracker tracks the state of a mailbox.
+//
+// A mailbox can have multiple sessions listening for updates. Each session has
+// its own view of the mailbox, because IMAP clients asynchronously receive
+// mailbox updates.
+type MailboxTracker struct {
+	mutex       sync.Mutex
+	numMessages uint32
+	sessions    map[*SessionTracker]struct{}
+}
+
+// NewMailboxTracker creates a new mailbox tracker.
+func NewMailboxTracker(numMessages uint32) *MailboxTracker {
+	return &MailboxTracker{
+		numMessages: numMessages,
+		sessions:    make(map[*SessionTracker]struct{}),
+	}
+}
+
+// NewSession creates a new session tracker for the mailbox.
+//
+// The caller must call SessionTracker.Close once they are done with the
+// session.
+func (t *MailboxTracker) NewSession() *SessionTracker {
+	st := &SessionTracker{mailbox: t}
+	t.mutex.Lock()
+	t.sessions[st] = struct{}{}
+	t.mutex.Unlock()
+	return st
+}
+
+func (t *MailboxTracker) queueUpdate(update *trackerUpdate, source *SessionTracker) {
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+
+	if update.expunge != 0 && update.expunge > t.numMessages {
+		panic(fmt.Errorf("imapserver: expunge sequence number (%v) out of range (%v messages in mailbox)", update.expunge, t.numMessages))
+	}
+	if update.numMessages != 0 && update.numMessages < t.numMessages {
+		panic(fmt.Errorf("imapserver: cannot decrease mailbox number of messages from %v to %v", t.numMessages, update.numMessages))
+	}
+
+	for st := range t.sessions {
+		if source != nil && st == source {
+			continue
+		}
+		st.queueUpdate(update)
+	}
+
+	switch {
+	case update.expunge != 0:
+		t.numMessages--
+	case update.numMessages != 0:
+		t.numMessages = update.numMessages
+	}
+}
+
+// QueueExpunge queues a new EXPUNGE update.
+func (t *MailboxTracker) QueueExpunge(seqNum uint32) {
+	if seqNum == 0 {
+		panic("imapserver: invalid expunge message sequence number")
+	}
+	t.queueUpdate(&trackerUpdate{expunge: seqNum}, nil)
+}
+
+// QueueNumMessages queues a new EXISTS update.
+func (t *MailboxTracker) QueueNumMessages(n uint32) {
+	// TODO: merge consecutive NumMessages updates
+	t.queueUpdate(&trackerUpdate{numMessages: n}, nil)
+}
+
+// QueueMailboxFlags queues a new FLAGS update.
+func (t *MailboxTracker) QueueMailboxFlags(flags []imap.Flag) {
+	if flags == nil {
+		flags = []imap.Flag{}
+	}
+	t.queueUpdate(&trackerUpdate{mailboxFlags: flags}, nil)
+}
+
+// QueueMessageFlags queues a new FETCH FLAGS update.
+//
+// If source is not nil, the update won't be dispatched to it.
+func (t *MailboxTracker) QueueMessageFlags(seqNum, uid uint32, flags []imap.Flag, source *SessionTracker) {
+	t.queueUpdate(&trackerUpdate{fetch: &trackerUpdateFetch{
+		seqNum: seqNum,
+		uid:    uid,
+		flags:  flags,
+	}}, source)
+}
+
+type trackerUpdate struct {
+	expunge      uint32
+	numMessages  uint32
+	mailboxFlags []imap.Flag
+	fetch        *trackerUpdateFetch
+}
+
+type trackerUpdateFetch struct {
+	seqNum uint32
+	uid    uint32
+	flags  []imap.Flag
+}
+
+// SessionTracker tracks the state of a mailbox for an IMAP client.
+type SessionTracker struct {
+	mailbox *MailboxTracker
+
+	mutex   sync.Mutex
+	queue   []trackerUpdate
+	updates chan<- struct{}
+}
+
+// Close unregisters the session.
+func (t *SessionTracker) Close() {
+	t.mailbox.mutex.Lock()
+	delete(t.mailbox.sessions, t)
+	t.mailbox.mutex.Unlock()
+	t.mailbox = nil
+}
+
+func (t *SessionTracker) queueUpdate(update *trackerUpdate) {
+	var updates chan<- struct{}
+	t.mutex.Lock()
+	t.queue = append(t.queue, *update)
+	updates = t.updates
+	t.mutex.Unlock()
+
+	if updates != nil {
+		select {
+		case updates <- struct{}{}:
+			// we notified SessionTracker.Idle about the update
+		default:
+			// skip the update
+		}
+	}
+}
+
+// Poll dequeues pending mailbox updates for this session.
+func (t *SessionTracker) Poll(w *UpdateWriter, allowExpunge bool) error {
+	var updates []trackerUpdate
+	t.mutex.Lock()
+	if allowExpunge {
+		updates = t.queue
+		t.queue = nil
+	} else {
+		stopIndex := -1
+		for i, update := range t.queue {
+			if update.expunge != 0 {
+				stopIndex = i
+				break
+			}
+			updates = append(updates, update)
+		}
+		if stopIndex >= 0 {
+			t.queue = t.queue[stopIndex:]
+		} else {
+			t.queue = nil
+		}
+	}
+	t.mutex.Unlock()
+
+	for _, update := range updates {
+		var err error
+		switch {
+		case update.expunge != 0:
+			err = w.WriteExpunge(update.expunge)
+		case update.numMessages != 0:
+			err = w.WriteNumMessages(update.numMessages)
+		case update.mailboxFlags != nil:
+			err = w.WriteMailboxFlags(update.mailboxFlags)
+		case update.fetch != nil:
+			err = w.WriteMessageFlags(update.fetch.seqNum, update.fetch.uid, update.fetch.flags)
+		default:
+			panic(fmt.Errorf("imapserver: unknown tracker update %#v", update))
+		}
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Idle continuously writes mailbox updates.
+//
+// When the stop channel is closed, it returns.
+//
+// Idle cannot be invoked concurrently from two separate goroutines.
+func (t *SessionTracker) Idle(w *UpdateWriter, stop <-chan struct{}) error {
+	updates := make(chan struct{}, 64)
+	t.mutex.Lock()
+	ok := t.updates == nil
+	if ok {
+		t.updates = updates
+	}
+	t.mutex.Unlock()
+	if !ok {
+		return fmt.Errorf("imapserver: only a single SessionTracker.Idle call is allowed at a time")
+	}
+
+	defer func() {
+		t.mutex.Lock()
+		t.updates = nil
+		t.mutex.Unlock()
+	}()
+
+	for {
+		select {
+		case <-updates:
+			if err := t.Poll(w, true); err != nil {
+				return err
+			}
+		case <-stop:
+			return nil
+		}
+	}
+}
+
+// DecodeSeqNum converts a message sequence number from the client view to the
+// server view.
+//
+// Zero is returned if the message doesn't exist from the server point-of-view.
+func (t *SessionTracker) DecodeSeqNum(seqNum uint32) uint32 {
+	if seqNum == 0 {
+		return 0
+	}
+
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+
+	for _, update := range t.queue {
+		if update.expunge == 0 {
+			continue
+		}
+		if seqNum == update.expunge {
+			return 0
+		} else if seqNum > update.expunge {
+			seqNum--
+		}
+	}
+
+	if seqNum > t.mailbox.numMessages {
+		return 0
+	}
+
+	return seqNum
+}
+
+// EncodeSeqNum converts a message sequence number from the server view to the
+// client view.
+//
+// Zero is returned if the message doesn't exist from the client point-of-view.
+func (t *SessionTracker) EncodeSeqNum(seqNum uint32) uint32 {
+	if seqNum == 0 {
+		return 0
+	}
+
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+
+	if seqNum > t.mailbox.numMessages {
+		return 0
+	}
+
+	for i := len(t.queue) - 1; i >= 0; i-- {
+		update := t.queue[i]
+		// TODO: this doesn't handle increments > 1
+		if update.numMessages != 0 && seqNum == update.numMessages {
+			return 0
+		}
+		if update.expunge != 0 && seqNum >= update.expunge {
+			seqNum++
+		}
+	}
+	return seqNum
+}
diff --git a/internal/imapwire/decoder.go b/internal/imapwire/decoder.go
index 575947c..80b6a91 100644
--- a/internal/imapwire/decoder.go
+++ b/internal/imapwire/decoder.go
@@ -8,6 +8,7 @@ import (
 	"strings"
 	"unicode"
 
+	"github.com/emersion/go-imap/v2"
 	"github.com/emersion/go-imap/v2/internal/utf7"
 )
 
@@ -21,6 +22,15 @@ func IsAtomChar(ch byte) bool {
 	}
 }
 
+// DecoderExpectError is an error due to the Decoder.Expect family of methods.
+type DecoderExpectError struct {
+	Message string
+}
+
+func (err *DecoderExpectError) Error() string {
+	return fmt.Sprintf("imapwire: %v", err.Message)
+}
+
 // A Decoder reads IMAP data.
 //
 // There are multiple families of methods:
@@ -30,14 +40,20 @@ func IsAtomChar(ch byte) bool {
 //   - "Expect" methods do the same, but set the decoder error (see Err) on
 //     failure.
 type Decoder struct {
+	// CheckBufferedLiteralFunc is called when a literal is about to be decoded
+	// and needs to be fully buffered in memory.
+	CheckBufferedLiteralFunc func(size int64, nonSync bool) error
+
 	r       *bufio.Reader
+	side    ConnSide
 	err     error
 	literal bool
+	crlf    bool
 }
 
 // NewDecoder creates a new decoder.
-func NewDecoder(r *bufio.Reader) *Decoder {
-	return &Decoder{r: r}
+func NewDecoder(r *bufio.Reader, side ConnSide) *Decoder {
+	return &Decoder{r: r, side: side}
 }
 
 func (dec *Decoder) mustUnreadByte() {
@@ -62,6 +78,7 @@ func (dec *Decoder) returnErr(err error) bool {
 }
 
 func (dec *Decoder) readByte() (byte, bool) {
+	dec.crlf = false
 	if dec.literal {
 		return 0, dec.returnErr(fmt.Errorf("imapwire: cannot decode while a literal is open"))
 	}
@@ -101,12 +118,12 @@ func (dec *Decoder) EOF() bool {
 // 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)
+		msg := fmt.Sprintf("expected %v", name)
 		if dec.r.Buffered() > 0 {
 			b, _ := dec.r.Peek(1)
-			err = fmt.Errorf("%v, got '%v'", err, string(b))
+			msg += fmt.Sprintf(", got %q", b)
 		}
-		return dec.returnErr(err)
+		return dec.returnErr(&DecoderExpectError{Message: msg})
 	}
 	return true
 }
@@ -131,7 +148,11 @@ func (dec *Decoder) ExpectSP() bool {
 
 func (dec *Decoder) CRLF() bool {
 	dec.acceptByte('\r') // be liberal in what we receive and accept lone LF
-	return dec.acceptByte('\n')
+	if !dec.acceptByte('\n') {
+		return false
+	}
+	dec.crlf = true
+	return true
 }
 
 func (dec *Decoder) ExpectCRLF() bool {
@@ -216,6 +237,15 @@ func (dec *Decoder) DiscardUntilByte(untilCh byte) {
 	}
 }
 
+func (dec *Decoder) DiscardLine() {
+	if dec.crlf {
+		return
+	}
+	var text string
+	dec.Text(&text)
+	dec.CRLF()
+}
+
 func (dec *Decoder) DiscardValue() bool {
 	var s string
 	if dec.String(&s) {
@@ -356,10 +386,10 @@ func (dec *Decoder) ExpectNString(ptr *string) bool {
 func (dec *Decoder) ExpectNStringReader() (lit *LiteralReader, nonSync, ok bool) {
 	var s string
 	if dec.Atom(&s) {
-		if s == "NIL" {
-			return nil, true, true
+		if !dec.Expect(s == "NIL", "nstring") {
+			return nil, false, false
 		}
-		return newLiteralReaderFromString(s), true, true
+		return nil, true, true
 	}
 	// TODO: read quoted string as a string instead of buffering
 	if dec.Quoted(&s) {
@@ -414,19 +444,49 @@ func (dec *Decoder) ExpectNList(f func() error) error {
 	return dec.ExpectList(f)
 }
 
-func (dec *Decoder) ExpectMailbox() (string, error) {
+func (dec *Decoder) ExpectMailbox(ptr *string) bool {
 	var name string
 	if !dec.ExpectAString(&name) {
-		return "", dec.Err()
+		return false
+	}
+	if strings.EqualFold(name, "INBOX") {
+		*ptr = "INBOX"
+		return true
+	}
+	name, err := utf7.Encoding.NewDecoder().String(name)
+	if err == nil {
+		*ptr = name
 	}
-	return utf7.Encoding.NewDecoder().String(name)
+	return dec.returnErr(err)
+}
+
+func (dec *Decoder) ExpectSeqSet(ptr *imap.SeqSet) bool {
+	var s string
+	if !dec.Expect(dec.Func(&s, isSeqSetChar), "sequence-set") {
+		return false
+	}
+	seqSet, err := imap.ParseSeqSet(s)
+	if err == nil {
+		*ptr = seqSet
+	}
+	return dec.returnErr(err)
+}
+
+func isSeqSetChar(ch byte) bool {
+	return ch == '*' || IsAtomChar(ch)
 }
 
 func (dec *Decoder) Literal(ptr *string) bool {
-	lit, _, ok := dec.LiteralReader()
+	lit, nonSync, ok := dec.LiteralReader()
 	if !ok {
 		return false
 	}
+	if dec.CheckBufferedLiteralFunc != nil {
+		if err := dec.CheckBufferedLiteralFunc(lit.Size(), nonSync); err != nil {
+			lit.cancel()
+			return false
+		}
+	}
 	var sb strings.Builder
 	_, err := io.Copy(&sb, lit)
 	if err == nil {
@@ -443,7 +503,9 @@ func (dec *Decoder) LiteralReader() (lit *LiteralReader, nonSync, ok bool) {
 	if !dec.ExpectNumber64(&size) {
 		return nil, false, false
 	}
-	nonSync = dec.acceptByte('+')
+	if dec.side == ConnSideServer {
+		nonSync = dec.acceptByte('+')
+	}
 	if !dec.ExpectSpecial('}') || !dec.ExpectCRLF() {
 		return nil, false, false
 	}
@@ -456,6 +518,14 @@ func (dec *Decoder) LiteralReader() (lit *LiteralReader, nonSync, ok bool) {
 	return lit, nonSync, true
 }
 
+func (dec *Decoder) ExpectLiteralReader() (lit *LiteralReader, nonSync bool, err error) {
+	lit, nonSync, ok := dec.LiteralReader()
+	if !dec.Expect(ok, "literal") {
+		return nil, false, dec.Err()
+	}
+	return lit, nonSync, nil
+}
+
 type LiteralReader struct {
 	dec  *Decoder
 	size int64
@@ -475,9 +545,16 @@ func (lit *LiteralReader) Size() int64 {
 
 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
+	if err == io.EOF {
+		lit.cancel()
 	}
 	return n, err
 }
+
+func (lit *LiteralReader) cancel() {
+	if lit.dec == nil {
+		return
+	}
+	lit.dec.literal = false
+	lit.dec = nil
+}
diff --git a/internal/imapwire/encoder.go b/internal/imapwire/encoder.go
index 419ad3f..a0257c8 100644
--- a/internal/imapwire/encoder.go
+++ b/internal/imapwire/encoder.go
@@ -17,14 +17,32 @@ import (
 // CRLF is called. These methods return the Encoder so that calls can be
 // chained.
 type Encoder struct {
+	// QuotedUTF8 allows non-ASCII strings to be encoded as quoted strings.
+	// This requires IMAP4rev2 or UTF8=ACCEPT.
+	QuotedUTF8 bool
+	// LiteralMinus enables non-synchronizing literals for short payloads.
+	// This requires IMAP4rev2 or LITERAL-. This is only meaningful for
+	// clients.
+	LiteralMinus bool
+	// NewContinuationRequest creates a new continuation request. This is only
+	// meaningful for clients.
+	NewContinuationRequest func() *ContinuationRequest
+
 	w       *bufio.Writer
+	side    ConnSide
 	err     error
 	literal bool
 }
 
 // NewEncoder creates a new encoder.
-func NewEncoder(w *bufio.Writer) *Encoder {
-	return &Encoder{w: w}
+func NewEncoder(w *bufio.Writer, side ConnSide) *Encoder {
+	return &Encoder{w: w, side: side}
+}
+
+func (enc *Encoder) setErr(err error) {
+	if enc.err == nil {
+		enc.err = err
+	}
 }
 
 func (enc *Encoder) writeString(s string) *Encoder {
@@ -62,8 +80,7 @@ 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
+func (enc *Encoder) Quoted(s string) *Encoder {
 	var sb strings.Builder
 	sb.Grow(2 + len(s))
 	sb.WriteByte('"')
@@ -78,6 +95,56 @@ func (enc *Encoder) String(s string) *Encoder {
 	return enc.writeString(sb.String())
 }
 
+func (enc *Encoder) String(s string) *Encoder {
+	if !enc.validQuoted(s) {
+		enc.stringLiteral(s)
+		return enc
+	}
+	return enc.Quoted(s)
+}
+
+func (enc *Encoder) validQuoted(s string) bool {
+	if len(s) > 4096 {
+		return false
+	}
+
+	for i := 0; i < len(s); i++ {
+		ch := s[i]
+
+		// NUL, CR and LF are never valid
+		switch ch {
+		case 0, '\r', '\n':
+			return false
+		}
+
+		if !enc.QuotedUTF8 && ch > 0x7F {
+			return false
+		}
+	}
+	return true
+}
+
+func (enc *Encoder) stringLiteral(s string) {
+	var sync *ContinuationRequest
+	if enc.side == ConnSideClient && (!enc.LiteralMinus || len(s) > 4096) {
+		if enc.NewContinuationRequest != nil {
+			sync = enc.NewContinuationRequest()
+		}
+		if sync == nil {
+			enc.setErr(fmt.Errorf("imapwire: cannot send synchronizing literal"))
+			return
+		}
+	}
+	wc := enc.Literal(int64(len(s)), sync)
+	_, writeErr := io.WriteString(wc, s)
+	closeErr := wc.Close()
+	if writeErr != nil {
+		enc.setErr(writeErr)
+	} else if closeErr != nil {
+		enc.setErr(closeErr)
+	}
+}
+
 func (enc *Encoder) Mailbox(name string) *Encoder {
 	if strings.EqualFold(name, "INBOX") {
 		return enc.Atom("INBOX")
@@ -99,9 +166,7 @@ func (enc *Encoder) Flag(flag imap.Flag) *Encoder {
 				valid = IsAtomChar(ch)
 			}
 			if !valid {
-				if enc.err == nil {
-					enc.err = fmt.Errorf("imapwire: character '%v' not allowed in flag", string(ch))
-				}
+				enc.setErr(fmt.Errorf("imapwire: character '%v' not allowed in flag", string(ch)))
 				return enc
 			}
 		}
@@ -147,10 +212,14 @@ func (enc *Encoder) Text(s string) *Encoder {
 // nil to be sent to the channel before writing the literal data. If an error
 // is sent to the channel, the literal will be cancelled.
 func (enc *Encoder) Literal(size int64, sync *ContinuationRequest) io.WriteCloser {
+	if sync != nil && enc.side == ConnSideServer {
+		panic("imapwire: sync must be nil on a server-side Encoder.Literal")
+	}
+
 	// TODO: literal8
 	enc.writeString("{")
 	enc.Number64(size)
-	if sync == nil {
+	if sync == nil && enc.side == ConnSideClient {
 		enc.writeString("+")
 	}
 	enc.writeString("}")
@@ -162,9 +231,7 @@ func (enc *Encoder) Literal(size int64, sync *ContinuationRequest) io.WriteClose
 			return errorWriter{err}
 		}
 		if _, err := sync.Wait(); err != nil {
-			if enc.err == nil {
-				enc.err = err
-			}
+			enc.setErr(err)
 			return errorWriter{err}
 		}
 	}
diff --git a/internal/imapwire/imapwire.go b/internal/imapwire/imapwire.go
index f325f9c..716d1c2 100644
--- a/internal/imapwire/imapwire.go
+++ b/internal/imapwire/imapwire.go
@@ -7,6 +7,14 @@ import (
 	"fmt"
 )
 
+// ConnSide describes the side of a connection: client or server.
+type ConnSide int
+
+const (
+	ConnSideClient ConnSide = 1 + iota
+	ConnSideServer
+)
+
 // ContinuationRequest is a continuation request.
 //
 // The sender must call either Done or Cancel. The receiver must call Wait.
diff --git a/internal/internal.go b/internal/internal.go
new file mode 100644
index 0000000..446b7d9
--- /dev/null
+++ b/internal/internal.go
@@ -0,0 +1,88 @@
+package internal
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/internal/imapwire"
+)
+
+const (
+	DateTimeLayout = "_2-Jan-2006 15:04:05 -0700"
+	DateLayout     = "2-Jan-2006"
+)
+
+const StatusItemRecent imap.StatusItem = "RECENT" // removed in IMAP4rev2
+
+// Fetch items removed in IMAP4rev2.
+var (
+	FetchItemRFC822       imap.FetchItem = imap.FetchItemKeyword("RFC822")        // equivalent to BODY[]
+	FetchItemRFC822Header imap.FetchItem = imap.FetchItemKeyword("RFC822.HEADER") // equivalent to BODY.PEEK[HEADER]
+	FetchItemRFC822Text   imap.FetchItem = imap.FetchItemKeyword("RFC822.TEXT")   // equivalent to BODY[TEXT]
+)
+
+const FlagRecent imap.Flag = "\\Recent" // removed in IMAP4rev2
+
+func DecodeDateTime(dec *imapwire.Decoder) (time.Time, error) {
+	var s string
+	if !dec.Quoted(&s) {
+		return time.Time{}, nil
+	}
+	t, err := time.Parse(DateTimeLayout, s)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("in date-time: %v", err) // TODO: use imapwire.DecodeExpectError?
+	}
+	return t, err
+}
+
+func ExpectDateTime(dec *imapwire.Decoder) (time.Time, error) {
+	t, err := DecodeDateTime(dec)
+	if err != nil {
+		return t, err
+	}
+	if !dec.Expect(!t.IsZero(), "date-time") {
+		return t, dec.Err()
+	}
+	return t, nil
+}
+
+func ExpectDate(dec *imapwire.Decoder) (time.Time, error) {
+	var s string
+	if !dec.ExpectAString(&s) {
+		return time.Time{}, dec.Err()
+	}
+	t, err := time.Parse(DateLayout, s)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("in date: %v", err) // use imapwire.DecodeExpectError?
+	}
+	return t, nil
+}
+
+func 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: %w", dec.Err())
+	}
+	if isSystem {
+		name = "\\" + name
+	}
+	return name, nil
+}
diff --git a/internal/sasl.go b/internal/sasl.go
new file mode 100644
index 0000000..85d9f3d
--- /dev/null
+++ b/internal/sasl.go
@@ -0,0 +1,23 @@
+package internal
+
+import (
+	"encoding/base64"
+)
+
+func EncodeSASL(b []byte) string {
+	if len(b) == 0 {
+		return "="
+	} else {
+		return base64.StdEncoding.EncodeToString(b)
+	}
+}
+
+func DecodeSASL(s string) ([]byte, error) {
+	if s == "=" {
+		// go-sasl treats nil as no challenge/response, so return a non-nil
+		// empty byte slice
+		return []byte{}, nil
+	} else {
+		return base64.StdEncoding.DecodeString(s)
+	}
+}
diff --git a/list.go b/list.go
new file mode 100644
index 0000000..dcc7a3a
--- /dev/null
+++ b/list.go
@@ -0,0 +1,28 @@
+package imap
+
+// 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
+}
+
+// ListData is the mailbox data returned by a LIST command.
+type ListData struct {
+	Attrs   []MailboxAttr
+	Delim   rune
+	Mailbox string
+
+	// Extended data
+	ChildInfo *ListDataChildInfo
+	OldName   string
+	Status    *StatusData
+}
+
+type ListDataChildInfo struct {
+	Subscribed bool
+}
diff --git a/namespace.go b/namespace.go
new file mode 100644
index 0000000..e538a39
--- /dev/null
+++ b/namespace.go
@@ -0,0 +1,14 @@
+package imap
+
+// NamespaceData is the data returned by the NAMESPACE command.
+type NamespaceData struct {
+	Personal []NamespaceDescriptor
+	Other    []NamespaceDescriptor
+	Shared   []NamespaceDescriptor
+}
+
+// NamespaceDescriptor describes a namespace.
+type NamespaceDescriptor struct {
+	Prefix string
+	Delim  rune
+}
diff --git a/response.go b/response.go
new file mode 100644
index 0000000..d376a17
--- /dev/null
+++ b/response.go
@@ -0,0 +1,70 @@
+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"
+
+	// METADATA
+	ResponseCodeTooMany   ResponseCode = "TOOMANY"
+	ResponseCodeNoPrivate ResponseCode = "NOPRIVATE"
+
+	// APPENDLIMIT
+	ResponseCodeTooBig ResponseCode = "TOOBIG"
+)
+
+// StatusResponse is a generic status response.
+//
+// See RFC 9051 section 7.1.
+type StatusResponse struct {
+	Type StatusResponseType
+	Code ResponseCode
+	Text string
+}
+
+// Error is an IMAP error caused by a status response.
+type Error StatusResponse
+
+var _ error = (*Error)(nil)
+
+// Error implements the error interface.
+func (err *Error) Error() string {
+	return fmt.Sprintf("imap: %v %v", err.Type, err.Text)
+}
diff --git a/search.go b/search.go
new file mode 100644
index 0000000..6897d0b
--- /dev/null
+++ b/search.go
@@ -0,0 +1,71 @@
+package imap
+
+import (
+	"time"
+)
+
+// SearchReturnOption indicates what kind of results to return from a SEARCH
+// command.
+type SearchReturnOption string
+
+const (
+	SearchReturnMin   SearchReturnOption = "MIN"
+	SearchReturnMax   SearchReturnOption = "MAX"
+	SearchReturnAll   SearchReturnOption = "ALL"
+	SearchReturnCount SearchReturnOption = "COUNT"
+)
+
+// SearchOptions contains options for the SEARCH command.
+type SearchOptions struct {
+	Return []SearchReturnOption // requires IMAP4rev2 or ESEARCH
+}
+
+// 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 SeqSet
+	UID    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    []Flag
+	NotFlag []Flag
+
+	Larger  int64
+	Smaller int64
+
+	Not []SearchCriteria
+	Or  [][2]SearchCriteria
+}
+
+type SearchCriteriaHeaderField struct {
+	Key, Value string
+}
+
+// SearchData is the data returned by a SEARCH command.
+type SearchData struct {
+	All SeqSet
+
+	// requires IMAP4rev2 or ESEARCH
+	UID   bool
+	Min   uint32
+	Max   uint32
+	Count uint32
+}
+
+// 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
+}
diff --git a/select.go b/select.go
new file mode 100644
index 0000000..024dfd7
--- /dev/null
+++ b/select.go
@@ -0,0 +1,17 @@
+package imap
+
+// SelectData is the data returned by a SELECT command.
+//
+// In the old RFC 2060, PermanentFlags, UIDNext and UIDValidity are optional.
+type SelectData struct {
+	// Flags defined for this mailbox
+	Flags []Flag
+	// Flags that the client can change permanently
+	PermanentFlags []Flag
+	// Number of messages in this mailbox (aka. "EXISTS")
+	NumMessages uint32
+	UIDNext     uint32
+	UIDValidity uint32
+
+	List *ListData // requires IMAP4rev2
+}
diff --git a/status.go b/status.go
index 4fe84e9..e0ce033 100644
--- a/status.go
+++ b/status.go
@@ -1,67 +1,33 @@
 package imap
 
-import (
-	"fmt"
-)
-
-// StatusResponseType is a generic status response type.
-type StatusResponseType string
+// StatusItem is a data item which can be requested by a STATUS command.
+type StatusItem string
 
 const (
-	StatusResponseTypeOK      StatusResponseType = "OK"
-	StatusResponseTypeNo      StatusResponseType = "NO"
-	StatusResponseTypeBad     StatusResponseType = "BAD"
-	StatusResponseTypePreAuth StatusResponseType = "PREAUTH"
-	StatusResponseTypeBye     StatusResponseType = "BYE"
+	StatusItemNumMessages StatusItem = "MESSAGES"
+	StatusItemUIDNext     StatusItem = "UIDNEXT"
+	StatusItemUIDValidity StatusItem = "UIDVALIDITY"
+	StatusItemNumUnseen   StatusItem = "UNSEEN"
+	StatusItemNumDeleted  StatusItem = "DELETED" // requires IMAP4rev2 or QUOTA
+	StatusItemSize        StatusItem = "SIZE"    // requires IMAP4rev2 or STATUS=SIZE
+
+	StatusItemAppendLimit    StatusItem = "APPENDLIMIT"     // requires APPENDLIMIT
+	StatusItemDeletedStorage StatusItem = "DELETED-STORAGE" // requires QUOTA=RES-STORAGE
 )
 
-// ResponseCode is a response code.
-type ResponseCode string
-
-const (
-	ResponseCodeAlert                ResponseCode = "ALERT"
-	ResponseCodeAlreadyExists        ResponseCode = "ALREADYEXISTS"
-	ResponseCodeAuthenticationFailed ResponseCode = "AUTHENTICATIONFAILED"
-	ResponseCodeAuthorizationFailed  ResponseCode = "AUTHORIZATIONFAILED"
-	ResponseCodeBadCharset           ResponseCode = "BADCHARSET"
-	ResponseCodeCannot               ResponseCode = "CANNOT"
-	ResponseCodeClientBug            ResponseCode = "CLIENTBUG"
-	ResponseCodeContactAdmin         ResponseCode = "CONTACTADMIN"
-	ResponseCodeCorruption           ResponseCode = "CORRUPTION"
-	ResponseCodeExpired              ResponseCode = "EXPIRED"
-	ResponseCodeHasChildren          ResponseCode = "HASCHILDREN"
-	ResponseCodeInUse                ResponseCode = "INUSE"
-	ResponseCodeLimit                ResponseCode = "LIMIT"
-	ResponseCodeNonExistent          ResponseCode = "NONEXISTENT"
-	ResponseCodeNoPerm               ResponseCode = "NOPERM"
-	ResponseCodeOverQuota            ResponseCode = "OVERQUOTA"
-	ResponseCodeParse                ResponseCode = "PARSE"
-	ResponseCodePrivacyRequired      ResponseCode = "PRIVACYREQUIRED"
-	ResponseCodeServerBug            ResponseCode = "SERVERBUG"
-	ResponseCodeTryCreate            ResponseCode = "TRYCREATE"
-	ResponseCodeUnavailable          ResponseCode = "UNAVAILABLE"
-	ResponseCodeUnknownCTE           ResponseCode = "UNKNOWN-CTE"
-
-	// METADATA
-	ResponseCodeTooMany   ResponseCode = "TOOMANY"
-	ResponseCodeNoPrivate ResponseCode = "NOPRIVATE"
-)
-
-// StatusResponse is a generic status response.
+// StatusData is the data returned by a STATUS command.
 //
-// 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)
+// 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
+	DeletedStorage *int64
 }
diff --git a/store.go b/store.go
new file mode 100644
index 0000000..3a413a7
--- /dev/null
+++ b/store.go
@@ -0,0 +1,17 @@
+package imap
+
+// StoreFlagsOp is a flag operation: set, add or delete.
+type StoreFlagsOp int
+
+const (
+	StoreFlagsSet StoreFlagsOp = iota
+	StoreFlagsAdd
+	StoreFlagsDel
+)
+
+// StoreFlags alters message flags.
+type StoreFlags struct {
+	Op     StoreFlagsOp
+	Silent bool
+	Flags  []Flag
+}


Test patch:
diff --git a/imapclient/client_test.go b/imapclient/client_test.go
index 916a49f..3c75405 100644
--- a/imapclient/client_test.go
+++ b/imapclient/client_test.go
@@ -35,7 +35,7 @@ func ExampleClient() {
 
 	if selectedMbox.NumMessages > 0 {
 		seqSet := imap.SeqSetNum(1)
-		fetchItems := []imapclient.FetchItem{imapclient.FetchItemEnvelope}
+		fetchItems := []imap.FetchItem{imap.FetchItemEnvelope}
 		messages, err := c.Fetch(seqSet, fetchItems).Collect()
 		if err != nil {
 			log.Fatalf("failed to fetch first message in INBOX: %v", err)
diff --git a/imapserver/list_test.go b/imapserver/list_test.go
new file mode 100644
index 0000000..bf290f4
--- /dev/null
+++ b/imapserver/list_test.go
@@ -0,0 +1,51 @@
+package imapserver_test
+
+import (
+	"testing"
+
+	"github.com/emersion/go-imap/v2/imapserver"
+)
+
+var matchListTests = []struct {
+	name, ref, pattern string
+	result             bool
+}{
+	{name: "INBOX", pattern: "INBOX", result: true},
+	{name: "INBOX", pattern: "Asuka", result: false},
+	{name: "INBOX", pattern: "*", result: true},
+	{name: "INBOX", pattern: "%", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "*", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "%", result: false},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/*", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/%", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "Neo* Evangelion/Misato", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "Neo% Evangelion/Misato", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "*Eva*/Misato", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "%Eva%/Misato", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "*X*/Misato", result: false},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "%X%/Misato", result: false},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/Mi%o", result: true},
+	{name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/Mi%too", result: false},
+	{name: "Misato/Misato", pattern: "Mis*to/Misato", result: true},
+	{name: "Misato/Misato", pattern: "Mis*to", result: true},
+	{name: "Misato/Misato/Misato", pattern: "Mis*to/Mis%to", result: true},
+	{name: "Misato/Misato", pattern: "Mis**to/Misato", result: true},
+	{name: "Misato/Misato", pattern: "Misat%/Misato", result: true},
+	{name: "Misato/Misato", pattern: "Misat%Misato", result: false},
+	{name: "Misato/Misato", ref: "Misato", pattern: "Misato", result: true},
+	{name: "Misato/Misato", ref: "Misato/", pattern: "Misato", result: true},
+	{name: "Misato/Misato", ref: "Shinji", pattern: "/Misato/*", result: true},
+	{name: "Misato/Misato", ref: "Misato", pattern: "/Misato", result: false},
+	{name: "Misato/Misato", ref: "Misato", pattern: "Shinji", result: false},
+	{name: "Misato/Misato", ref: "Shinji", pattern: "Misato", result: false},
+}
+
+func TestMatchList(t *testing.T) {
+	delim := '/'
+	for _, test := range matchListTests {
+		result := imapserver.MatchList(test.name, delim, test.ref, test.pattern)
+		if result != test.result {
+			t.Errorf("matching name %q with pattern %q and reference %q returns %v, but expected %v", test.name, test.pattern, test.ref, result, test.result)
+		}
+	}
+}
diff --git a/imapserver/tracker_test.go b/imapserver/tracker_test.go
new file mode 100644
index 0000000..480bcba
--- /dev/null
+++ b/imapserver/tracker_test.go
@@ -0,0 +1,155 @@
+package imapserver_test
+
+import (
+	"testing"
+
+	"github.com/emersion/go-imap/v2/imapserver"
+)
+
+type trackerUpdate struct {
+	expunge     uint32
+	numMessages uint32
+}
+
+var sessionTrackerSeqNumTests = []struct {
+	name                       string
+	pending                    []trackerUpdate
+	clientSeqNum, serverSeqNum uint32
+}{
+	{
+		name:         "noop",
+		pending:      nil,
+		clientSeqNum: 20,
+		serverSeqNum: 20,
+	},
+	{
+		name:         "noop_last",
+		pending:      nil,
+		clientSeqNum: 42,
+		serverSeqNum: 42,
+	},
+	{
+		name:         "noop_client_oob",
+		pending:      nil,
+		clientSeqNum: 43,
+		serverSeqNum: 0,
+	},
+	{
+		name:         "noop_server_oob",
+		pending:      nil,
+		clientSeqNum: 0,
+		serverSeqNum: 43,
+	},
+	{
+		name:         "expunge_eq",
+		pending:      []trackerUpdate{{expunge: 20}},
+		clientSeqNum: 20,
+		serverSeqNum: 0,
+	},
+	{
+		name:         "expunge_lt",
+		pending:      []trackerUpdate{{expunge: 20}},
+		clientSeqNum: 10,
+		serverSeqNum: 10,
+	},
+	{
+		name:         "expunge_gt",
+		pending:      []trackerUpdate{{expunge: 10}},
+		clientSeqNum: 20,
+		serverSeqNum: 19,
+	},
+	{
+		name:         "append_eq",
+		pending:      []trackerUpdate{{numMessages: 43}},
+		clientSeqNum: 0,
+		serverSeqNum: 43,
+	},
+	{
+		name:         "append_lt",
+		pending:      []trackerUpdate{{numMessages: 43}},
+		clientSeqNum: 42,
+		serverSeqNum: 42,
+	},
+	{
+		name: "expunge_append",
+		pending: []trackerUpdate{
+			{expunge: 42},
+			{numMessages: 42},
+		},
+		clientSeqNum: 42,
+		serverSeqNum: 0,
+	},
+	{
+		name: "expunge_append",
+		pending: []trackerUpdate{
+			{expunge: 42},
+			{numMessages: 42},
+		},
+		clientSeqNum: 0,
+		serverSeqNum: 42,
+	},
+	{
+		name: "append_expunge",
+		pending: []trackerUpdate{
+			{numMessages: 43},
+			{expunge: 42},
+		},
+		clientSeqNum: 42,
+		serverSeqNum: 0,
+	},
+	{
+		name: "append_expunge",
+		pending: []trackerUpdate{
+			{numMessages: 43},
+			{expunge: 42},
+		},
+		clientSeqNum: 0,
+		serverSeqNum: 42,
+	},
+	{
+		name: "multi_expunge_middle",
+		pending: []trackerUpdate{
+			{expunge: 3},
+			{expunge: 1},
+		},
+		clientSeqNum: 2,
+		serverSeqNum: 1,
+	},
+	{
+		name: "multi_expunge_after",
+		pending: []trackerUpdate{
+			{expunge: 3},
+			{expunge: 1},
+		},
+		clientSeqNum: 4,
+		serverSeqNum: 2,
+	},
+}
+
+func TestSessionTracker(t *testing.T) {
+	for _, tc := range sessionTrackerSeqNumTests {
+		tc := tc // capture range variable
+		t.Run(tc.name, func(t *testing.T) {
+			mboxTracker := imapserver.NewMailboxTracker(42)
+			sessTracker := mboxTracker.NewSession()
+			for _, update := range tc.pending {
+				switch {
+				case update.expunge != 0:
+					mboxTracker.QueueExpunge(update.expunge)
+				case update.numMessages != 0:
+					mboxTracker.QueueNumMessages(update.numMessages)
+				}
+			}
+
+			serverSeqNum := sessTracker.DecodeSeqNum(tc.clientSeqNum)
+			if tc.clientSeqNum != 0 && serverSeqNum != tc.serverSeqNum {
+				t.Errorf("DecodeSeqNum(%v): got %v, want %v", tc.clientSeqNum, serverSeqNum, tc.serverSeqNum)
+			}
+
+			clientSeqNum := sessTracker.EncodeSeqNum(tc.serverSeqNum)
+			if tc.serverSeqNum != 0 && clientSeqNum != tc.clientSeqNum {
+				t.Errorf("EncodeSeqNum(%v): got %v, want %v", tc.serverSeqNum, clientSeqNum, tc.clientSeqNum)
+			}
+		})
+	}
+}