~sircmpwn/aerc

Add maildir support (#16) v2 PROPOSED

For this reroll, I've updated the error handling for malformed messages,
added support for maildir sources relative to the user's home directory,
and added a previously-overlooked mention of aerc-maildir to
aerc-config.

Ben Burwell (5):
  Create UIDStore package
  Add maildir backend worker
  Handle the invalid "utf8" encoding
  Implement maildir copy
  Add maildir docs

 Makefile                    |   3 +
 doc/aerc-config.5.scd       |   3 +-
 doc/aerc-maildir.5.scd      |  40 ++++
 doc/aerc.1.scd              |   3 +-
 go.mod                      |   3 +-
 go.sum                      |   4 +
 lib/uidstore/uidstore.go    |  62 +++++++
 worker/maildir/container.go | 143 +++++++++++++++
 worker/maildir/message.go   | 322 ++++++++++++++++++++++++++++++++
 worker/maildir/worker.go    | 354 ++++++++++++++++++++++++++++++++++++
 worker/worker.go            |   3 +
 11 files changed, 937 insertions(+), 3 deletions(-)
 create mode 100644 doc/aerc-maildir.5.scd
 create mode 100644 lib/uidstore/uidstore.go
 create mode 100644 worker/maildir/container.go
 create mode 100644 worker/maildir/message.go
 create mode 100644 worker/maildir/worker.go

-- 
2.22.0
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~sircmpwn/aerc/patches/6688/mbox | git am -3
Learn more about email & git

[PATCH v2 1/5] Create UIDStore package Export this patch

This package can be used to provide a source for mapping mock UIDs back
to relevant keys for alternate backends. For example, for the Maildir
backend, we need to map between UID and message file names.
---
 lib/uidstore/uidstore.go | 62 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 62 insertions(+)
 create mode 100644 lib/uidstore/uidstore.go

diff --git a/lib/uidstore/uidstore.go b/lib/uidstore/uidstore.go
new file mode 100644
index 0000000..11c5e47
--- /dev/null
+++ b/lib/uidstore/uidstore.go
@@ -0,0 +1,62 @@
// Package uidstore provides a concurrency-safe two-way mapping between UIDs
// used by the UI and arbitrary string keys as used by different mail backends.
//
// Multiple Store instances can safely be created and the UIDs that they
// generate will be globally unique.
package uidstore

import (
	"sync"
	"sync/atomic"
)

var nextUID uint32 = 1

// Store holds a mapping between application keys and globally-unique UIDs.
type Store struct {
	keyByUID map[uint32]string
	uidByKey map[string]uint32
	m        sync.Mutex
}

// NewStore creates a new, empty Store.
func NewStore() *Store {
	return &Store{
		keyByUID: make(map[uint32]string),
		uidByKey: make(map[string]uint32),
	}
}

// GetOrInsert returns the UID for the provided key. If the key was already
// present in the store, the same UID value is returned. Otherwise, the key is
// inserted and the newly generated UID is returned.
func (s *Store) GetOrInsert(key string) uint32 {
	s.m.Lock()
	defer s.m.Unlock()
	if uid, ok := s.uidByKey[key]; ok {
		return uid
	}
	uid := atomic.AddUint32(&nextUID, 1)
	s.keyByUID[uid] = key
	s.uidByKey[key] = uid
	return uid
}

// GetKey returns the key for the provided UID, if available.
func (s *Store) GetKey(uid uint32) (string, bool) {
	s.m.Lock()
	defer s.m.Unlock()
	key, ok := s.keyByUID[uid]
	return key, ok
}

// RemoveUID removes the specified UID from the store.
func (s *Store) RemoveUID(uid uint32) {
	s.m.Lock()
	defer s.m.Unlock()
	key, ok := s.keyByUID[uid]
	if ok {
		delete(s.uidByKey, key)
	}
	delete(s.keyByUID, uid)
}
-- 
2.22.0

[PATCH v2 2/5] Add maildir backend worker Export this patch

Add the initial implementation of a backend for Maildir accounts. Much
of the functionality required is implemented in the go-message and
go-maildir libraries, so we use them as much as possible.

The maildir worker hooks into a new maildir:// URL scheme in the
accounts.conf file which points to a container of several maildir
directories. From there, the OpenDirectory, FetchDirectoryContents, etc
messages work on subdirectories. This is implemented as a Container
struct which handles mapping between the symbolic email folder names and
UIDs to the concrete directories and file names.
---
From v1, I updated the MessageInfo method to return an error. In order
that a single malformed message not affect the ability to view all other
messages requested in the same FetchMessageHeaders message, I removed
the MessageInfos method from the Container struct. Now, each requested
message gets an independent response.

Also, the Configure procedure learns how to handle ~ home directories.

 go.mod                      |   1 +
 go.sum                      |   2 +
 worker/maildir/container.go | 105 +++++++++++
 worker/maildir/message.go   | 322 ++++++++++++++++++++++++++++++++
 worker/maildir/worker.go    | 353 ++++++++++++++++++++++++++++++++++++
 worker/worker.go            |   3 +
 6 files changed, 786 insertions(+)
 create mode 100644 worker/maildir/container.go
 create mode 100644 worker/maildir/message.go
 create mode 100644 worker/maildir/worker.go

diff --git a/go.mod b/go.mod
index 1c3d156..40c4df2 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
	github.com/ddevault/go-libvterm v0.0.0-20190526194226-b7d861da3810
	github.com/emersion/go-imap v1.0.0-beta.6
	github.com/emersion/go-imap-idle v0.0.0-20190519112320-2704abd7050e
	github.com/emersion/go-maildir v0.0.0-20190505155239-cec913e0802c
	github.com/emersion/go-message v0.10.3
	github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317
	github.com/emersion/go-smtp v0.11.1
diff --git a/go.sum b/go.sum
index 6c33d94..41b28a1 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@ github.com/emersion/go-imap v1.0.0-beta.6 h1:x1Mco8GTkkw2+1/YHasok0pWrQLpgwJVG07
github.com/emersion/go-imap v1.0.0-beta.6/go.mod h1:ORBuwFXdwt9QrAOecJPpirG6j9mao9wMfHIkd0EZfdo=
github.com/emersion/go-imap-idle v0.0.0-20190519112320-2704abd7050e h1:L7bswVJZcf2YHofgom49oFRwVqmBj/qZqDy9/SJpZMY=
github.com/emersion/go-imap-idle v0.0.0-20190519112320-2704abd7050e/go.mod h1:o14zPKCmEH5WC1vU5SdPoZGgNvQx7zzKSnxPQlobo78=
github.com/emersion/go-maildir v0.0.0-20190505155239-cec913e0802c h1:Rx3zrFK2haYD5dHh2kF937k6psgyLB6u8GgLjdDv+hw=
github.com/emersion/go-maildir v0.0.0-20190505155239-cec913e0802c/go.mod h1:GnCg8DiGPgjPjAW4qqrCJDTHYflFCe5bvLE+lJ6TLwI=
github.com/emersion/go-message v0.10.3 h1:4pajGb3Rq+gHLfRcWysgcwtGRNgLpB8LC6X/vRZ89d0=
github.com/emersion/go-message v0.10.3/go.mod h1:3h+HsGTCFHmk4ngJ2IV/YPhdlaOcR6hcgqM3yca9v7c=
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197 h1:rDJPbyliyym8ZL/Wt71kdolp6yaD4fLIQz638E6JEt0=
diff --git a/worker/maildir/container.go b/worker/maildir/container.go
new file mode 100644
index 0000000..351afed
--- /dev/null
+++ b/worker/maildir/container.go
@@ -0,0 +1,105 @@
package maildir

import (
	"fmt"
	"io/ioutil"
	"log"
	"path/filepath"
	"sort"

	"github.com/emersion/go-maildir"

	"git.sr.ht/~sircmpwn/aerc/lib/uidstore"
)

// A Container is a directory which contains other directories which adhere to
// the Maildir spec
type Container struct {
	dir  string
	log  *log.Logger
	uids *uidstore.Store
}

// NewContainer creates a new container at the specified directory
// TODO: return an error if the provided directory is not accessible
func NewContainer(dir string, l *log.Logger) *Container {
	return &Container{dir: dir, uids: uidstore.NewStore(), log: l}
}

// ListFolders returns a list of maildir folders in the container
func (c *Container) ListFolders() ([]string, error) {
	files, err := ioutil.ReadDir(c.dir)
	if err != nil {
		return nil, fmt.Errorf("error reading folders: %v", err)
	}
	dirnames := []string{}
	for _, f := range files {
		if f.IsDir() {
			dirnames = append(dirnames, f.Name())
		}
	}
	return dirnames, nil
}

// OpenDirectory opens an existing maildir in the container by name, moves new
// messages into cur, and registers the new keys in the UIDStore.
func (c *Container) OpenDirectory(name string) (maildir.Dir, error) {
	dir := c.Dir(name)
	keys, err := dir.Unseen()
	if err != nil {
		return dir, err
	}
	for _, key := range keys {
		c.uids.GetOrInsert(key)
	}
	return dir, nil
}

// Dir returns a maildir.Dir with the specified name inside the container
func (c *Container) Dir(name string) maildir.Dir {
	return maildir.Dir(filepath.Join(c.dir, name))
}

// UIDs fetches the unique message identifiers for the maildir
func (c *Container) UIDs(d maildir.Dir) ([]uint32, error) {
	keys, err := d.Keys()
	if err != nil {
		return nil, fmt.Errorf("could not get keys for %s: %v", d, err)
	}
	sort.Strings(keys)
	var uids []uint32
	for _, key := range keys {
		uids = append(uids, c.uids.GetOrInsert(key))
	}
	return uids, nil
}

// Message returns a Message struct for the given UID and maildir
func (c *Container) Message(d maildir.Dir, uid uint32) (*Message, error) {
	if key, ok := c.uids.GetKey(uid); ok {
		return &Message{
			dir: d,
			uid: uid,
			key: key,
		}, nil
	}
	return nil, fmt.Errorf("could not find message with uid %d in maildir %s",
		uid, d)
}

// DeleteAll deletes a set of messages by UID and returns the subset of UIDs
// which were successfully deleted, stopping upon the first error.
func (c *Container) DeleteAll(d maildir.Dir, uids []uint32) ([]uint32, error) {
	var success []uint32
	for _, uid := range uids {
		msg, err := c.Message(d, uid)
		if err != nil {
			return success, err
		}
		if err := msg.Remove(); err != nil {
			return success, err
		}
		success = append(success, uid)
	}
	return success, nil
}
diff --git a/worker/maildir/message.go b/worker/maildir/message.go
new file mode 100644
index 0000000..b95ec98
--- /dev/null
+++ b/worker/maildir/message.go
@@ -0,0 +1,322 @@
package maildir

import (
	"bytes"
	"encoding/base64"
	"fmt"
	"io"
	"io/ioutil"
	"mime/quotedprintable"
	gomail "net/mail"
	"strings"

	"github.com/emersion/go-maildir"
	"github.com/emersion/go-message"
	_ "github.com/emersion/go-message/charset"
	"github.com/emersion/go-message/mail"

	"git.sr.ht/~sircmpwn/aerc/models"
)

// A Message is an individual email inside of a maildir.Dir.
type Message struct {
	dir maildir.Dir
	uid uint32
	key string
}

// NewReader reads a message into memory and returns an io.Reader for it.
func (m Message) NewReader() (io.Reader, error) {
	f, err := m.dir.Open(m.key)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	b, err := ioutil.ReadAll(f)
	if err != nil {
		return nil, err
	}
	return bytes.NewReader(b), nil
}

// Flags fetches the set of flags currently applied to the message.
func (m Message) Flags() ([]maildir.Flag, error) {
	return m.dir.Flags(m.key)
}

// SetFlags replaces the message's flags with a new set.
func (m Message) SetFlags(flags []maildir.Flag) error {
	return m.dir.SetFlags(m.key, flags)
}

// MarkRead either adds or removes the maildir.FlagSeen flag from the message.
func (m Message) MarkRead(seen bool) error {
	flags, err := m.Flags()
	if err != nil {
		return fmt.Errorf("could not read previous flags: %v", err)
	}
	if seen {
		flags = append(flags, maildir.FlagSeen)
		return m.SetFlags(flags)
	}
	var newFlags []maildir.Flag
	for _, flag := range flags {
		if flag != maildir.FlagSeen {
			newFlags = append(newFlags, flag)
		}
	}
	return m.SetFlags(newFlags)
}

// Remove deletes the email immediately.
func (m Message) Remove() error {
	return m.dir.Remove(m.key)
}

// MessageInfo populates a models.MessageInfo struct for the message.
func (m Message) MessageInfo() (*models.MessageInfo, error) {
	f, err := m.dir.Open(m.key)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	msg, err := message.Read(f)
	if err != nil {
		return nil, fmt.Errorf("could not read message: %v", err)
	}
	bs, err := parseEntityStructure(msg)
	if err != nil {
		return nil, fmt.Errorf("could not get structure: %v", err)
	}
	env, err := parseEnvelope(&msg.Header)
	if err != nil {
		return nil, fmt.Errorf("could not get envelope: %v", err)
	}
	flags, err := m.Flags()
	if err != nil {
		return nil, fmt.Errorf("could not read flags: %v", err)
	}
	return &models.MessageInfo{
		BodyStructure: bs,
		Envelope:      env,
		Flags:         translateFlags(flags),
		InternalDate:  env.Date,
		RFC822Headers: &mail.Header{msg.Header},
		Size:          0,
		Uid:           m.uid,
	}, nil
}

// NewBodyPartReader creates a new io.Reader for the requested body part(s) of
// the message.
func (m Message) NewBodyPartReader(requestedParts []int) (io.Reader, error) {
	f, err := m.dir.Open(m.key)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	msg, err := message.Read(f)
	if err != nil {
		return nil, fmt.Errorf("could not read message: %v", err)
	}
	return fetchEntityPartReader(msg, requestedParts)
}

func fetchEntityPartReader(e *message.Entity, index []int) (io.Reader, error) {
	if len(index) < 1 {
		return nil, fmt.Errorf("no part to read")
	}
	if mpr := e.MultipartReader(); mpr != nil {
		idx := 0
		for {
			idx++
			part, err := mpr.NextPart()
			if err != nil {
				return nil, err
			}
			if idx == index[0] {
				rest := index[1:]
				if len(rest) < 1 {
					return fetchEntityReader(part)
				}
				return fetchEntityPartReader(part, index[1:])
			}
		}
	}
	if index[0] != 1 {
		return nil, fmt.Errorf("cannont return non-first part of non-multipart")
	}
	return fetchEntityReader(e)
}

// fetchEntityReader makes an io.Reader for the given entity. Since the
// go-message package decodes the body for us, and the UI expects to deal with
// a reader whose bytes are encoded with the part's encoding, we are in the
// interesting position of needing to re-encode the reader before sending it
// off to the UI layer.
//
// TODO: probably change the UI to expect an already-decoded reader and decode
// in the IMAP worker.
func fetchEntityReader(e *message.Entity) (io.Reader, error) {
	enc := e.Header.Get("content-transfer-encoding")
	var buf bytes.Buffer

	// base64
	if strings.EqualFold(enc, "base64") {
		wc := base64.NewEncoder(base64.StdEncoding, &buf)
		defer wc.Close()
		if _, err := io.Copy(wc, e.Body); err != nil {
			return nil, fmt.Errorf("could not base64 encode: %v", err)
		}
		return &buf, nil
	}

	// quoted-printable
	if strings.EqualFold(enc, "quoted-printable") {
		wc := quotedprintable.NewWriter(&buf)
		defer wc.Close()
		if _, err := io.Copy(wc, e.Body); err != nil {
			return nil, fmt.Errorf("could not quoted-printable encode: %v", err)
		}
		return &buf, nil
	}

	// other general encoding
	if _, err := io.Copy(&buf, e.Body); err != nil {
		return nil, err
	}

	return &buf, nil
}

// split a MIME type into its major and minor parts
func splitMIME(m string) (string, string) {
	parts := strings.Split(m, "/")
	if len(parts) != 2 {
		return parts[0], ""
	}
	return parts[0], parts[1]
}

func parseEntityStructure(e *message.Entity) (*models.BodyStructure, error) {
	var body models.BodyStructure
	contentType, ctParams, err := e.Header.ContentType()
	if err != nil {
		return nil, fmt.Errorf("could not parse content type: %v", err)
	}
	mimeType, mimeSubType := splitMIME(contentType)
	body.MIMEType = mimeType
	body.MIMESubType = mimeSubType
	body.Params = ctParams
	body.Description = e.Header.Get("content-description")
	body.Encoding = e.Header.Get("content-transfer-encoding")
	if cd := e.Header.Get("content-disposition"); cd != "" {
		contentDisposition, cdParams, err := e.Header.ContentDisposition()
		if err != nil {
			return nil, fmt.Errorf("could not parse content disposition: %v", err)
		}
		body.Disposition = contentDisposition
		body.DispositionParams = cdParams
	}
	body.Parts = []*models.BodyStructure{}
	if mpr := e.MultipartReader(); mpr != nil {
		for {
			part, err := mpr.NextPart()
			if err == io.EOF {
				return &body, nil
			} else if err != nil {
				return nil, err
			}
			ps, err := parseEntityStructure(part)
			if err != nil {
				return nil, fmt.Errorf("could not parse child entity structure: %v", err)
			}
			body.Parts = append(body.Parts, ps)
		}
	}
	return &body, nil
}

func parseEnvelope(h *message.Header) (*models.Envelope, error) {
	date, err := gomail.ParseDate(h.Get("date"))
	if err != nil {
		return nil, fmt.Errorf("could not parse date header: %v", err)
	}
	from, err := parseAddressList(h, "from")
	if err != nil {
		return nil, fmt.Errorf("could not read from address: %v", err)
	}
	to, err := parseAddressList(h, "to")
	if err != nil {
		return nil, fmt.Errorf("could not read to address: %v", err)
	}
	cc, err := parseAddressList(h, "cc")
	if err != nil {
		return nil, fmt.Errorf("could not read cc address: %v", err)
	}
	bcc, err := parseAddressList(h, "bcc")
	if err != nil {
		return nil, fmt.Errorf("could not read bcc address: %v", err)
	}
	return &models.Envelope{
		Date:      date,
		Subject:   h.Get("subject"),
		MessageId: h.Get("message-id"),
		From:      from,
		To:        to,
		Cc:        cc,
		Bcc:       bcc,
	}, nil
}

func parseAddressList(h *message.Header, key string) ([]*models.Address, error) {
	var converted []*models.Address
	hdr := h.Get(key)
	if strings.TrimSpace(hdr) == "" {
		return converted, nil
	}
	addrs, err := gomail.ParseAddressList(hdr)
	if err != nil {
		if strings.Index(hdr, "@") < 0 {
			return []*models.Address{&models.Address{
				Name: hdr,
			}}, nil
		}
		return nil, err
	}
	for _, addr := range addrs {
		parts := strings.Split(addr.Address, "@")
		var mbox, host string
		if len(parts) > 1 {
			mbox = strings.Join(parts[0:len(parts)-1], "@")
			host = parts[len(parts)-1]
		} else {
			mbox = addr.Address
		}
		converted = append(converted, &models.Address{
			Name:    addr.Name,
			Mailbox: mbox,
			Host:    host,
		})
	}
	return converted, nil
}

var flagMap = map[maildir.Flag]models.Flag{
	maildir.FlagReplied: models.AnsweredFlag,
	maildir.FlagSeen:    models.SeenFlag,
	maildir.FlagTrashed: models.DeletedFlag,
	maildir.FlagFlagged: models.FlaggedFlag,
	// maildir.FlagDraft Flag = 'D'
	// maildir.FlagPassed Flag = 'P'
}

func translateFlags(maildirFlags []maildir.Flag) []models.Flag {
	var flags []models.Flag
	for _, maildirFlag := range maildirFlags {
		if flag, ok := flagMap[maildirFlag]; ok {
			flags = append(flags, flag)
		}
	}
	return flags
}
diff --git a/worker/maildir/worker.go b/worker/maildir/worker.go
new file mode 100644
index 0000000..f0c92ed
--- /dev/null
+++ b/worker/maildir/worker.go
@@ -0,0 +1,353 @@
package maildir

import (
	"fmt"
	"io"
	"net/url"
	"os"
	"path/filepath"

	"github.com/emersion/go-maildir"

	"git.sr.ht/~sircmpwn/aerc/models"
	"git.sr.ht/~sircmpwn/aerc/worker/types"
)

var errUnsupported = fmt.Errorf("unsupported command")

// A Worker handles interfacing between aerc's UI and a group of maildirs.
type Worker struct {
	c        *Container
	selected *maildir.Dir
	worker   *types.Worker
}

// NewWorker creates a new maildir worker with the provided worker.
func NewWorker(worker *types.Worker) *Worker {
	return &Worker{worker: worker}
}

// Run starts the worker's message handling loop.
func (w *Worker) Run() {
	for {
		action := <-w.worker.Actions
		msg := w.worker.ProcessAction(action)
		if err := w.handleMessage(msg); err == errUnsupported {
			w.worker.PostMessage(&types.Unsupported{
				Message: types.RespondTo(msg),
			}, nil)
		} else if err != nil {
			w.worker.PostMessage(&types.Error{
				Message: types.RespondTo(msg),
				Error:   err,
			}, nil)
		}
	}
}

func (w *Worker) done(msg types.WorkerMessage) {
	w.worker.PostMessage(&types.Done{types.RespondTo(msg)}, nil)
}

func (w *Worker) err(msg types.WorkerMessage, err error) {
	w.worker.PostMessage(&types.Error{
		Message: types.RespondTo(msg),
		Error:   err,
	}, nil)
}

func (w *Worker) handleMessage(msg types.WorkerMessage) error {
	switch msg := msg.(type) {
	case *types.Unsupported:
		// No-op
	case *types.Configure:
		return w.handleConfigure(msg)
	case *types.Connect:
		return w.handleConnect(msg)
	case *types.ListDirectories:
		return w.handleListDirectories(msg)
	case *types.OpenDirectory:
		return w.handleOpenDirectory(msg)
	case *types.FetchDirectoryContents:
		return w.handleFetchDirectoryContents(msg)
	case *types.CreateDirectory:
		return w.handleCreateDirectory(msg)
	case *types.FetchMessageHeaders:
		return w.handleFetchMessageHeaders(msg)
	case *types.FetchMessageBodyPart:
		return w.handleFetchMessageBodyPart(msg)
	case *types.FetchFullMessages:
		return w.handleFetchFullMessages(msg)
	case *types.DeleteMessages:
		return w.handleDeleteMessages(msg)
	case *types.ReadMessages:
		return w.handleReadMessages(msg)
	case *types.CopyMessages:
		return w.handleCopyMessages(msg)
	case *types.AppendMessage:
		return w.handleAppendMessage(msg)
	case *types.SearchDirectory:
		return w.handleSearchDirectory(msg)
	}
	return errUnsupported
}

func (w *Worker) handleConfigure(msg *types.Configure) error {
	defer w.done(msg)
	u, err := url.Parse(msg.Config.Source)
	if err != nil {
		w.worker.Logger.Printf("error configuring maildir worker: %v", err)
		return err
	}
	dir := u.Path
	if u.Host == "~" {
		home, err := os.UserHomeDir()
		if err != nil {
			return fmt.Errorf("could not resolve home directory: %v", err)
		}
		dir = filepath.Join(home, u.Path)
	}
	w.c = NewContainer(dir, w.worker.Logger)
	w.worker.Logger.Printf("configured base maildir: %s", dir)
	return nil
}

func (w *Worker) handleConnect(msg *types.Connect) error {
	w.done(msg)
	return nil
}

func (w *Worker) handleListDirectories(msg *types.ListDirectories) error {
	defer w.done(msg)
	dirs, err := w.c.ListFolders()
	if err != nil {
		w.worker.Logger.Printf("error listing directories: %v", err)
		return err
	}
	for _, name := range dirs {
		w.worker.PostMessage(&types.Directory{
			Message: types.RespondTo(msg),
			Dir: &models.Directory{
				Name:       name,
				Attributes: []string{},
			},
		}, nil)
	}
	return nil
}

func (w *Worker) handleOpenDirectory(msg *types.OpenDirectory) error {
	defer w.done(msg)
	w.worker.Logger.Printf("opening %s", msg.Directory)
	dir, err := w.c.OpenDirectory(msg.Directory)
	if err != nil {
		return err
	}
	w.selected = &dir
	// TODO: why does this need to be sent twice??
	info := &types.DirectoryInfo{
		Info: &models.DirectoryInfo{
			Name:     msg.Directory,
			Flags:    []string{},
			ReadOnly: false,
			// total messages
			Exists: 0,
			// new messages since mailbox was last opened
			Recent: 0,
			// total unread
			Unseen: 0,
		},
	}
	w.worker.PostMessage(info, nil)
	w.worker.PostMessage(info, nil)
	return nil
}

func (w *Worker) handleFetchDirectoryContents(
	msg *types.FetchDirectoryContents) error {
	defer w.done(msg)
	uids, err := w.c.UIDs(*w.selected)
	if err != nil {
		w.worker.Logger.Printf("error scanning uids: %v", err)
		return err
	}
	w.worker.PostMessage(&types.DirectoryContents{
		Message: types.RespondTo(msg),
		Uids:    uids,
	}, nil)
	return nil
}

func (w *Worker) handleCreateDirectory(msg *types.CreateDirectory) error {
	dir := w.c.Dir(msg.Directory)
	defer w.done(msg)
	if err := dir.Create(); err != nil {
		w.worker.Logger.Printf("could not create directory %s: %v",
			msg.Directory, err)
		return err
	}
	return nil
}

func (w *Worker) handleFetchMessageHeaders(
	msg *types.FetchMessageHeaders) error {
	defer w.done(msg)
	for _, uid := range msg.Uids {
		m, err := w.c.Message(*w.selected, uid)
		if err != nil {
			w.worker.Logger.Printf("could not get message: %v", err)
			w.err(msg, err)
			continue
		}
		info, err := m.MessageInfo()
		if err != nil {
			w.worker.Logger.Printf("could not get message info: %v", err)
			w.err(msg, err)
			continue
		}
		w.worker.PostMessage(&types.MessageInfo{
			Message: types.RespondTo(msg),
			Info:    info,
		}, nil)
	}
	return nil
}

func (w *Worker) handleFetchMessageBodyPart(
	msg *types.FetchMessageBodyPart) error {
	defer w.done(msg)

	// get reader
	m, err := w.c.Message(*w.selected, msg.Uid)
	if err != nil {
		w.worker.Logger.Printf("could not get message %d: %v", msg.Uid, err)
		return err
	}
	r, err := m.NewBodyPartReader(msg.Part)
	if err != nil {
		w.worker.Logger.Printf(
			"could not get body part reader for message=%d, parts=%#v: %v",
			msg.Uid, msg.Part, err)
		return err
	}
	w.worker.PostMessage(&types.MessageBodyPart{
		Message: types.RespondTo(msg),
		Part: &models.MessageBodyPart{
			Reader: r,
			Uid:    msg.Uid,
		},
	}, nil)

	// mark message as read
	if err := m.MarkRead(true); err != nil {
		w.worker.Logger.Printf("could not mark message as read: %v", err)
		return err
	}

	// send updated flags to ui
	info, err := m.MessageInfo()
	if err != nil {
		w.worker.Logger.Printf("could not fetch message info: %v", err)
		return err
	}
	w.worker.PostMessage(&types.MessageInfo{
		Message: types.RespondTo(msg),
		Info:    info,
	}, nil)

	return nil
}

func (w *Worker) handleFetchFullMessages(msg *types.FetchFullMessages) error {
	defer w.done(msg)
	for _, uid := range msg.Uids {
		m, err := w.c.Message(*w.selected, uid)
		if err != nil {
			w.worker.Logger.Printf("could not get message %d: %v", uid, err)
			return err
		}
		r, err := m.NewReader()
		if err != nil {
			w.worker.Logger.Printf("could not get message reader: %v", err)
			return err
		}
		w.worker.PostMessage(&types.FullMessage{
			Message: types.RespondTo(msg),
			Content: &models.FullMessage{
				Uid:    uid,
				Reader: r,
			},
		}, nil)
	}
	return nil
}

func (w *Worker) handleDeleteMessages(msg *types.DeleteMessages) error {
	defer w.done(msg)
	deleted, err := w.c.DeleteAll(*w.selected, msg.Uids)
	if len(deleted) > 0 {
		w.worker.PostMessage(&types.MessagesDeleted{
			Message: types.RespondTo(msg),
			Uids:    deleted,
		}, nil)
	}
	if err != nil {
		w.worker.Logger.Printf("error removing some messages: %v", err)
		return err
	}
	return nil
}

func (w *Worker) handleReadMessages(msg *types.ReadMessages) error {
	defer w.done(msg)
	for _, uid := range msg.Uids {
		m, err := w.c.Message(*w.selected, uid)
		if err != nil {
			w.worker.Logger.Printf("could not get message: %v", err)
			w.err(msg, err)
			continue
		}
		if err := m.MarkRead(msg.Read); err != nil {
			w.worker.Logger.Printf("could not mark message as read: %v", err)
			w.err(msg, err)
			continue
		}
		info, err := m.MessageInfo()
		if err != nil {
			w.worker.Logger.Printf("could not get message info: %v", err)
			w.err(msg, err)
			continue
		}
		w.worker.PostMessage(&types.MessageInfo{
			Message: types.RespondTo(msg),
			Info:    info,
		}, nil)
	}
	return nil
}

func (w *Worker) handleCopyMessages(msg *types.CopyMessages) error {
	// TODO: this.
	return nil
}

func (w *Worker) handleAppendMessage(msg *types.AppendMessage) error {
	defer w.done(msg)
	dest := w.c.Dir(msg.Destination)
	delivery, err := dest.NewDelivery()
	if err != nil {
		w.worker.Logger.Printf("could not deliver message to %s: %v",
			msg.Destination, err)
		return err
	}
	defer delivery.Close()
	if _, err := io.Copy(delivery, msg.Reader); err != nil {
		w.worker.Logger.Printf("could not write message to destination: %v", err)
		return err
	}
	return nil
}

func (w *Worker) handleSearchDirectory(msg *types.SearchDirectory) error {
	return errUnsupported
}
diff --git a/worker/worker.go b/worker/worker.go
index 7db7be5..dd14a23 100644
--- a/worker/worker.go
+++ b/worker/worker.go
@@ -2,6 +2,7 @@ package worker

import (
	"git.sr.ht/~sircmpwn/aerc/worker/imap"
	"git.sr.ht/~sircmpwn/aerc/worker/maildir"
	"git.sr.ht/~sircmpwn/aerc/worker/types"

	"fmt"
@@ -27,6 +28,8 @@ func NewWorker(source string, logger *log.Logger) (*types.Worker, error) {
		fallthrough
	case "imaps":
		worker.Backend = imap.NewIMAPWorker(worker)
	case "maildir":
		worker.Backend = maildir.NewWorker(worker)
	default:
		return nil, fmt.Errorf("Unknown backend %s", u.Scheme)
	}
-- 
2.22.0

[PATCH v2 3/5] Handle the invalid "utf8" encoding Export this patch

See commit 0bfc369eb68a1d34ea0ee983f218e97a14099959 in the go-message
package.
---
 go.mod | 2 +-
 go.sum | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/go.mod b/go.mod
index 40c4df2..a8a25ea 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
	github.com/emersion/go-imap v1.0.0-beta.6
	github.com/emersion/go-imap-idle v0.0.0-20190519112320-2704abd7050e
	github.com/emersion/go-maildir v0.0.0-20190505155239-cec913e0802c
	github.com/emersion/go-message v0.10.3
	github.com/emersion/go-message v0.10.4
	github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317
	github.com/emersion/go-smtp v0.11.1
	github.com/gdamore/tcell v1.1.2
diff --git a/go.sum b/go.sum
index 41b28a1..9ef4818 100644
--- a/go.sum
+++ b/go.sum
@@ -26,6 +26,8 @@ github.com/emersion/go-maildir v0.0.0-20190505155239-cec913e0802c h1:Rx3zrFK2haY
github.com/emersion/go-maildir v0.0.0-20190505155239-cec913e0802c/go.mod h1:GnCg8DiGPgjPjAW4qqrCJDTHYflFCe5bvLE+lJ6TLwI=
github.com/emersion/go-message v0.10.3 h1:4pajGb3Rq+gHLfRcWysgcwtGRNgLpB8LC6X/vRZ89d0=
github.com/emersion/go-message v0.10.3/go.mod h1:3h+HsGTCFHmk4ngJ2IV/YPhdlaOcR6hcgqM3yca9v7c=
github.com/emersion/go-message v0.10.4 h1:G2hQCx7lE1IFTRAKkDbolJy0d3EVQDxbwpDYcxjJocI=
github.com/emersion/go-message v0.10.4/go.mod h1:3h+HsGTCFHmk4ngJ2IV/YPhdlaOcR6hcgqM3yca9v7c=
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197 h1:rDJPbyliyym8ZL/Wt71kdolp6yaD4fLIQz638E6JEt0=
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317 h1:tYZxAY8nu3JJQKios9f27Sbvbkfm4XHXT476gVtszu0=
-- 
2.22.0

[PATCH v2 4/5] Implement maildir copy Export this patch

Create a delivery in the destination directory with the content of the
source message.
---
 worker/maildir/container.go | 38 +++++++++++++++++++++++++++++++++++++
 worker/maildir/worker.go    |  5 +++--
 2 files changed, 41 insertions(+), 2 deletions(-)

diff --git a/worker/maildir/container.go b/worker/maildir/container.go
index 351afed..aa14575 100644
--- a/worker/maildir/container.go
+++ b/worker/maildir/container.go
@@ -2,6 +2,7 @@ package maildir

import (
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"path/filepath"
@@ -103,3 +104,40 @@ func (c *Container) DeleteAll(d maildir.Dir, uids []uint32) ([]uint32, error) {
	}
	return success, nil
}

func (c *Container) CopyAll(
	dest maildir.Dir, src maildir.Dir, uids []uint32) error {
	for _, uid := range uids {
		if err := c.copyMessage(dest, src, uid); err != nil {
			return fmt.Errorf("could not copy message %d: %v", uid, err)
		}
	}
	return nil
}

func (c *Container) copyMessage(
	dest maildir.Dir, src maildir.Dir, uid uint32) error {
	key, ok := c.uids.GetKey(uid)
	if !ok {
		return fmt.Errorf("could not find key for message id %d", uid)
	}

	f, err := src.Open(key)
	if err != nil {
		return fmt.Errorf("could not open source message: %v", err)
	}

	del, err := dest.NewDelivery()
	if err != nil {
		return fmt.Errorf("could not initialize delivery: %v")
	}
	defer del.Close()

	if _, err = io.Copy(del, f); err != nil {
		return fmt.Errorf("could not copy message to delivery: %v")
	}

	// TODO: preserve flags

	return nil
}
diff --git a/worker/maildir/worker.go b/worker/maildir/worker.go
index f0c92ed..a5416cc 100644
--- a/worker/maildir/worker.go
+++ b/worker/maildir/worker.go
@@ -327,8 +327,9 @@ func (w *Worker) handleReadMessages(msg *types.ReadMessages) error {
}

func (w *Worker) handleCopyMessages(msg *types.CopyMessages) error {
	// TODO: this.
	return nil
	defer w.done(msg)
	dest := w.c.Dir(msg.Destination)
	return w.c.CopyAll(dest, *w.selected, msg.Uids)
}

func (w *Worker) handleAppendMessage(msg *types.AppendMessage) error {
-- 
2.22.0

[PATCH v2 5/5] Add maildir docs Export this patch

---
From v1, added a note about aerc-maildir to aerc-config which I had
previously overlooked.

 Makefile               |  3 +++
 doc/aerc-config.5.scd  |  3 ++-
 doc/aerc-maildir.5.scd | 40 ++++++++++++++++++++++++++++++++++++++++
 doc/aerc.1.scd         |  3 ++-
 4 files changed, 47 insertions(+), 2 deletions(-)
 create mode 100644 doc/aerc-maildir.5.scd

diff --git a/Makefile b/Makefile
index 4ff1aae..6609640 100644
--- a/Makefile
+++ b/Makefile
@@ -29,6 +29,7 @@ DOCS := \
	aerc.1 \
	aerc-config.5 \
	aerc-imap.5 \
	aerc-maildir.5 \
	aerc-smtp.5 \
	aerc-tutorial.7

@@ -58,6 +59,7 @@ install: all
	install -m644 aerc.1 $(MANDIR)/man1/aerc.1
	install -m644 aerc-config.5 $(MANDIR)/man5/aerc-config.5
	install -m644 aerc-imap.5 $(MANDIR)/man5/aerc-imap.5
	install -m644 aerc-maildir.5 $(MANDIR)/man5/aerc-maildir.5
	install -m644 aerc-smtp.5 $(MANDIR)/man5/aerc-smtp.5
	install -m644 aerc-tutorial.7 $(MANDIR)/man7/aerc-tutorial.7
	install -m644 config/accounts.conf $(SHAREDIR)/accounts.conf
@@ -77,6 +79,7 @@ uninstall:
	$(RM) $(MANDIR)/man1/aerc.1
	$(RM) $(MANDIR)/man5/aerc-config.5
	$(RM) $(MANDIR)/man5/aerc-imap.5
	$(RM) $(MANDIR)/man5/aerc-maildir.5
	$(RM) $(MANDIR)/man5/aerc-smtp.5
	$(RM) $(MANDIR)/man7/aerc-tutorial.7
	$(RM) -r $(SHAREDIR)
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index db69aff..2f4f993 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -201,6 +201,7 @@ Note that many of these configuration options are written for you, such as
	See each protocol's man page for more details:

	- *aerc-imap*(5)
	- *aerc-maildir*(5)

	Default: none

@@ -366,7 +367,7 @@ following special keys are supported:

# SEE ALSO

*aerc*(1) *aerc-imap*(5) *aerc-smtp*(5)
*aerc*(1) *aerc-imap*(5) *aerc-smtp*(5) *aerc-maildir*(5)

# AUTHORS

diff --git a/doc/aerc-maildir.5.scd b/doc/aerc-maildir.5.scd
new file mode 100644
index 0000000..5765bf8
--- /dev/null
+++ b/doc/aerc-maildir.5.scd
@@ -0,0 +1,40 @@
aerc-maildir(5)

# NAME

aerc-maildir - maildir configuration for *aerc*(1)

# SYNOPSIS

aerc implements the maildir format.

# CONFIGURATION

Maildir accounts currently are not supported with the :new-account command and
must be added manually to the *aerc-config*(5) file.

The following maildir-specific options are available:

*source*
	maildir://path

	The *source* indicates the path to the directory containing your maildirs
	rather than one maildir specifically.

	The path portion of the URL following _maildir://_ must be either an absolute
	path prefixed by */* or a path relative to your home directory prefixed with
	*~*. For example:

		source = maildir:///home/me/mail

		source = maildir://~/mail

# SEE ALSO

*aerc*(1) *aerc-config*(5) *aerc-smtp*(5)

# AUTHORS

Maintained by Drew DeVault <sir@cmpwn.com>, who is assisted by other open
source contributors. For more information about aerc development, see
https://git.sr.ht/~sircmpwn/aerc.
diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd
index ba5c5c7..050396f 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -169,7 +169,8 @@ write log messages to that file:

# SEE ALSO

*aerc-config*(5) *aerc-imap*(5) *aerc-smtp*(5) *aerc-tutorial*(7)
*aerc-config*(5) *aerc-imap*(5) *aerc-smtp*(5) *aerc-maildir*(5)
*aerc-tutorial*(7)

# AUTHORS

-- 
2.22.0