feat: maildir support (#1283)

Drew Smirnoff created

## What?

Adds a new `maildir` protocol to matcha's backend registry so accounts
can point at a local Maildir tree on disk instead of an IMAP/JMAP/POP3
server. After picking the protocol in the add-account wizard and
supplying a path (e.g. `~/Mail` or `/var/mail/user`), the inbox renders
messages parsed directly from the filesystem, alongside any existing
remote accounts. The implementation honors the standard Maildir layout —
top-level `cur/new/tmp` is treated as INBOX, and Maildir++ dot-prefixed
siblings (`.Sent`, `.Archive`, `.Drafts`, …) surface as additional
folders. Reading a folder promotes anything sitting in `new/` over to
`cur/` (matching mutt's semantics), marking a message as read appends
the `S` flag to its filename, deletion unlinks the file, and
move/archive shuffle entries between Maildir folders using the
emersion/go-maildir library's atomic operations. Server-side search is
implemented as a local header/body scan, which is plenty fast on a local
FS. Sending email and IDLE-style push notifications are unsupported and
return `backend.ErrNotSupported`, since a Maildir is just a directory —
no transport, no event channel. The `Capabilities()` report reflects
this so the UI can dim send actions, and a `CanArchive` bit flips on
automatically when a `.Archive` folder exists in the tree.

## Why?

Closes #1183 
Closes #1140

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

backend/maildir/maildir.go      | 609 +++++++++++++++++++++++++++++++++++
backend/maildir/maildir_test.go | 289 ++++++++++++++++
config/config.go                |   1 
go.mod                          |   1 
go.sum                          |   2 
main.go                         |   5 
tui/login.go                    |  30 +
tui/messages.go                 |   2 
tui/settings_accounts.go        |   1 
9 files changed, 936 insertions(+), 4 deletions(-)

Detailed changes

backend/maildir/maildir.go 🔗

@@ -0,0 +1,609 @@
+// Package maildir implements the backend.Provider interface for local
+// Maildir mailboxes (the `mutt -f Maildir` style). It is read/edit only —
+// there is no SMTP transport, so SendEmail returns ErrNotSupported.
+//
+// Folder layout follows Maildir++:
+//   - The configured root path is "INBOX".
+//   - Sibling directories prefixed with "." (e.g. ".Sent", ".Archive") are
+//     additional folders. Inner dots map to a "/" hierarchy.
+package maildir
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"mime"
+	"net/mail"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"time"
+
+	emaildir "github.com/emersion/go-maildir"
+	"github.com/emersion/go-message"
+	gomail "github.com/emersion/go-message/mail"
+
+	"github.com/floatpane/matcha/backend"
+	"github.com/floatpane/matcha/config"
+)
+
+var messageIDRE = regexp.MustCompile(`<[^>]+>`)
+
+func init() {
+	backend.RegisterBackend("maildir", func(account *config.Account) (backend.Provider, error) {
+		return New(account)
+	})
+}
+
+// Provider implements backend.Provider against a local Maildir tree.
+type Provider struct {
+	account *config.Account
+	root    string
+}
+
+// New creates a new Maildir provider for the given account.
+func New(account *config.Account) (*Provider, error) {
+	root := strings.TrimSpace(account.MaildirPath)
+	if root == "" {
+		return nil, fmt.Errorf("maildir path not configured")
+	}
+
+	root = os.ExpandEnv(root)
+	if strings.HasPrefix(root, "~/") {
+		home, err := os.UserHomeDir()
+		if err == nil {
+			root = filepath.Join(home, root[2:])
+		}
+	}
+	root = filepath.Clean(root)
+
+	info, err := os.Stat(root)
+	if err != nil {
+		return nil, fmt.Errorf("maildir path %q: %w", root, err)
+	}
+	if !info.IsDir() {
+		return nil, fmt.Errorf("maildir path %q is not a directory", root)
+	}
+
+	return &Provider{account: account, root: root}, nil
+}
+
+// dirForFolder resolves a logical folder name to the on-disk Maildir directory.
+// "" and "INBOX" map to the configured root; anything else is treated as a
+// Maildir++ subfolder. "/" in the folder name is converted to "." per spec.
+func (p *Provider) dirForFolder(folder string) emaildir.Dir {
+	if folder == "" || strings.EqualFold(folder, "INBOX") {
+		return emaildir.Dir(p.root)
+	}
+	subdir := "." + strings.ReplaceAll(folder, "/", ".")
+	return emaildir.Dir(filepath.Join(p.root, subdir))
+}
+
+// FetchFolders returns INBOX plus any Maildir++ subfolders found at the root.
+func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
+	folders := []backend.Folder{{Name: "INBOX", Delimiter: "/"}}
+
+	entries, err := os.ReadDir(p.root)
+	if err != nil {
+		return nil, fmt.Errorf("maildir read root: %w", err)
+	}
+
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+		name := entry.Name()
+		if !strings.HasPrefix(name, ".") || name == "." || name == ".." {
+			continue
+		}
+		// Sanity check: a Maildir folder has a cur/ subdir.
+		if _, err := os.Stat(filepath.Join(p.root, name, "cur")); err != nil {
+			continue
+		}
+		// Strip leading dot, map "." → "/" for nested folders.
+		logical := strings.ReplaceAll(strings.TrimPrefix(name, "."), ".", "/")
+		folders = append(folders, backend.Folder{Name: logical, Delimiter: "/"})
+	}
+
+	return folders, nil
+}
+
+// FetchEmails returns messages from the folder, newest first. Any messages
+// sitting in new/ are first promoted to cur/ (same semantics as mutt opening
+// a Maildir): they remain unread (no Seen flag) but become trackable.
+func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset uint32) ([]backend.Email, error) {
+	dir := p.dirForFolder(folder)
+	if _, err := dir.Unseen(); err != nil && !os.IsNotExist(err) {
+		return nil, fmt.Errorf("maildir promote new/: %w", err)
+	}
+	msgs, err := dir.Messages()
+	if err != nil {
+		return nil, fmt.Errorf("maildir messages: %w", err)
+	}
+
+	type entry struct {
+		msg     *emaildir.Message
+		modTime time.Time
+	}
+	entries := make([]entry, 0, len(msgs))
+	for _, m := range msgs {
+		info, err := os.Stat(m.Filename())
+		if err != nil {
+			continue
+		}
+		entries = append(entries, entry{msg: m, modTime: info.ModTime()})
+	}
+	sort.Slice(entries, func(i, j int) bool {
+		return entries[i].modTime.After(entries[j].modTime)
+	})
+
+	if int(offset) >= len(entries) {
+		return []backend.Email{}, nil
+	}
+	end := int(offset) + int(limit)
+	if end > len(entries) || limit == 0 {
+		end = len(entries)
+	}
+	entries = entries[offset:end]
+
+	emails := make([]backend.Email, 0, len(entries))
+	for _, e := range entries {
+		email, err := p.readHeader(e.msg)
+		if err != nil {
+			continue
+		}
+		emails = append(emails, email)
+	}
+	return emails, nil
+}
+
+// readHeader opens the message file and parses just enough to fill an Email.
+func (p *Provider) readHeader(msg *emaildir.Message) (backend.Email, error) {
+	rc, err := msg.Open()
+	if err != nil {
+		return backend.Email{}, err
+	}
+	defer rc.Close()
+
+	entity, err := message.Read(rc)
+	if err != nil && entity == nil {
+		return backend.Email{}, err
+	}
+
+	email := headerToEmail(&entity.Header, msg.Key(), p.account.ID)
+
+	for _, fl := range msg.Flags() {
+		if fl == emaildir.FlagSeen {
+			email.IsRead = true
+			break
+		}
+	}
+
+	return email, nil
+}
+
+// FetchEmailBody returns the chosen body, MIME type, and attachments.
+func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, string, []backend.Attachment, error) {
+	msg, err := p.findMessageByUID(folder, uid)
+	if err != nil {
+		return "", "", nil, err
+	}
+	rc, err := msg.Open()
+	if err != nil {
+		return "", "", nil, fmt.Errorf("maildir open: %w", err)
+	}
+	defer rc.Close()
+
+	return parseMessageBody(rc)
+}
+
+// FetchAttachment returns the raw bytes of an attachment part.
+func (p *Provider) FetchAttachment(_ context.Context, folder string, uid uint32, partID, _ string) ([]byte, error) {
+	msg, err := p.findMessageByUID(folder, uid)
+	if err != nil {
+		return nil, err
+	}
+	rc, err := msg.Open()
+	if err != nil {
+		return nil, fmt.Errorf("maildir open: %w", err)
+	}
+	defer rc.Close()
+
+	return findAttachmentData(rc, partID)
+}
+
+// MarkAsRead sets the Seen flag while preserving the others.
+func (p *Provider) MarkAsRead(_ context.Context, folder string, uid uint32) error {
+	msg, err := p.findMessageByUID(folder, uid)
+	if err != nil {
+		return err
+	}
+	flags := msg.Flags()
+	for _, fl := range flags {
+		if fl == emaildir.FlagSeen {
+			return nil
+		}
+	}
+	return msg.SetFlags(append(flags, emaildir.FlagSeen))
+}
+
+// DeleteEmail removes the message file from disk.
+func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) error {
+	msg, err := p.findMessageByUID(folder, uid)
+	if err != nil {
+		return err
+	}
+	return msg.Remove()
+}
+
+// ArchiveEmail moves the message to the ".Archive" subfolder if one exists.
+func (p *Provider) ArchiveEmail(ctx context.Context, folder string, uid uint32) error {
+	if _, err := os.Stat(filepath.Join(p.root, ".Archive", "cur")); err != nil {
+		return backend.ErrNotSupported
+	}
+	return p.MoveEmail(ctx, uid, folder, "Archive")
+}
+
+// MoveEmail relocates a message between two Maildir folders.
+func (p *Provider) MoveEmail(_ context.Context, uid uint32, srcFolder, dstFolder string) error {
+	msg, err := p.findMessageByUID(srcFolder, uid)
+	if err != nil {
+		return err
+	}
+	dst := p.dirForFolder(dstFolder)
+	return msg.MoveTo(dst)
+}
+
+// DeleteEmails removes the listed messages from the folder.
+func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
+	for _, uid := range uids {
+		if err := p.DeleteEmail(ctx, folder, uid); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ArchiveEmails archives the listed messages.
+func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error {
+	if _, err := os.Stat(filepath.Join(p.root, ".Archive", "cur")); err != nil {
+		return backend.ErrNotSupported
+	}
+	for _, uid := range uids {
+		if err := p.MoveEmail(ctx, uid, folder, "Archive"); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// MoveEmails relocates the listed messages between folders.
+func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error {
+	for _, uid := range uids {
+		if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// SendEmail is not supported by the Maildir backend.
+func (p *Provider) SendEmail(_ context.Context, _ *backend.OutgoingEmail) error {
+	return backend.ErrNotSupported
+}
+
+// Search filters messages in a folder by the given query, parsing headers
+// locally. Body matching scans the decoded body parts.
+func (p *Provider) Search(_ context.Context, folder string, query backend.SearchQuery) ([]backend.Email, error) {
+	dir := p.dirForFolder(folder)
+	if _, err := dir.Unseen(); err != nil && !os.IsNotExist(err) {
+		return nil, fmt.Errorf("maildir promote new/: %w", err)
+	}
+	msgs, err := dir.Messages()
+	if err != nil {
+		return nil, fmt.Errorf("maildir messages: %w", err)
+	}
+
+	results := make([]backend.Email, 0)
+	for _, m := range msgs {
+		if query.Limit > 0 && uint32(len(results)) >= query.Limit {
+			break
+		}
+		email, body, err := p.matchOpen(m)
+		if err != nil {
+			continue
+		}
+		if !matchesQuery(email, body, query) {
+			continue
+		}
+		results = append(results, email)
+	}
+	return results, nil
+}
+
+// matchOpen returns the email metadata and a plain-text body slice for search.
+func (p *Provider) matchOpen(msg *emaildir.Message) (backend.Email, string, error) {
+	rc, err := msg.Open()
+	if err != nil {
+		return backend.Email{}, "", err
+	}
+	defer rc.Close()
+
+	entity, err := message.Read(rc)
+	if err != nil && entity == nil {
+		return backend.Email{}, "", err
+	}
+	email := headerToEmail(&entity.Header, msg.Key(), p.account.ID)
+
+	for _, fl := range msg.Flags() {
+		if fl == emaildir.FlagSeen {
+			email.IsRead = true
+			break
+		}
+	}
+
+	// Lightweight body read: only needed if query asks for it.
+	var body string
+	if b, err := io.ReadAll(entity.Body); err == nil {
+		body = string(b)
+	}
+
+	return email, body, nil
+}
+
+// matchesQuery applies the parsed search filters to an email + body.
+func matchesQuery(email backend.Email, body string, query backend.SearchQuery) bool {
+	containsCI := func(haystack, needle string) bool {
+		if needle == "" {
+			return true
+		}
+		return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
+	}
+	if !containsCI(email.From, query.From) {
+		return false
+	}
+	if query.To != "" {
+		match := false
+		for _, addr := range email.To {
+			if containsCI(addr, query.To) {
+				match = true
+				break
+			}
+		}
+		if !match {
+			return false
+		}
+	}
+	if !containsCI(email.Subject, query.Subject) {
+		return false
+	}
+	if !containsCI(body, query.Body) {
+		return false
+	}
+	if !query.Since.IsZero() && email.Date.Before(query.Since) {
+		return false
+	}
+	if !query.Before.IsZero() && email.Date.After(query.Before) {
+		return false
+	}
+	return true
+}
+
+// Watch is not supported. Future: fsnotify on new/ to emit NotifyNewEmail.
+func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
+	return nil, nil, backend.ErrNotSupported
+}
+
+// Close releases any provider-held resources. None for Maildir.
+func (p *Provider) Close() error { return nil }
+
+// Capabilities reports what the Maildir backend can do.
+func (p *Provider) Capabilities() backend.Capabilities {
+	_, hasArchive := os.Stat(filepath.Join(p.root, ".Archive", "cur"))
+	return backend.Capabilities{
+		CanSend:         false,
+		CanMove:         true,
+		CanArchive:      hasArchive == nil,
+		CanPush:         false,
+		CanSearchServer: true,
+		CanFetchFolders: true,
+		SupportsSMIME:   false,
+	}
+}
+
+// findMessageByUID locates a Maildir message by its UID hash.
+func (p *Provider) findMessageByUID(folder string, uid uint32) (*emaildir.Message, error) {
+	dir := p.dirForFolder(folder)
+	msgs, err := dir.Messages()
+	if err != nil {
+		return nil, fmt.Errorf("maildir messages: %w", err)
+	}
+	for _, m := range msgs {
+		if hashUID(m.Key()) == uid {
+			return m, nil
+		}
+	}
+	return nil, fmt.Errorf("maildir: message with UID %d not found in %q", uid, folder)
+}
+
+// hashUID converts a Maildir base filename (the part before the flag suffix)
+// into a stable uint32 identifier. Same FNV-style hash as the POP3 backend.
+func hashUID(key string) uint32 {
+	var hash uint32
+	for _, c := range key {
+		hash = hash*31 + uint32(c)
+	}
+	if hash == 0 {
+		hash = 1
+	}
+	return hash
+}
+
+// headerToEmail converts a parsed message Header into a backend.Email.
+func headerToEmail(header *message.Header, key, accountID string) backend.Email {
+	from := header.Get("From")
+	subject := header.Get("Subject")
+	dateStr := header.Get("Date")
+	messageID := header.Get("Message-ID")
+	inReplyTo := firstMessageID(header.Get("In-Reply-To"))
+	references := messageIDList(header.Get("References"))
+
+	var to []string
+	if toHeader := header.Get("To"); toHeader != "" {
+		if addrs, err := mail.ParseAddressList(toHeader); err == nil {
+			for _, addr := range addrs {
+				to = append(to, addr.Address)
+			}
+		}
+	}
+
+	var replyTo []string
+	if replyToHeader := header.Get("Reply-To"); replyToHeader != "" {
+		if addrs, err := mail.ParseAddressList(replyToHeader); err == nil {
+			for _, addr := range addrs {
+				replyTo = append(replyTo, addr.Address)
+			}
+		}
+	}
+
+	var date time.Time
+	if dateStr != "" {
+		if parsed, err := mail.ParseDate(dateStr); err == nil {
+			date = parsed
+		}
+	}
+
+	dec := new(mime.WordDecoder)
+	if decoded, err := dec.DecodeHeader(subject); err == nil {
+		subject = decoded
+	}
+	if decoded, err := dec.DecodeHeader(from); err == nil {
+		from = decoded
+	}
+
+	return backend.Email{
+		UID:        hashUID(key),
+		From:       from,
+		To:         to,
+		ReplyTo:    replyTo,
+		Subject:    subject,
+		Date:       date,
+		MessageID:  messageID,
+		InReplyTo:  inReplyTo,
+		References: references,
+		AccountID:  accountID,
+	}
+}
+
+func firstMessageID(value string) string {
+	ids := messageIDList(value)
+	if len(ids) == 0 {
+		return ""
+	}
+	return ids[0]
+}
+
+func messageIDList(value string) []string {
+	matches := messageIDRE.FindAllString(value, -1)
+	if len(matches) == 0 {
+		return strings.Fields(value)
+	}
+	return matches
+}
+
+// parseMessageBody extracts the body text and attachments from a raw message.
+// Mirrors the POP3 backend's logic since the on-wire representation is the
+// same RFC822 stream.
+func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) {
+	mr, err := gomail.CreateReader(r)
+	if err != nil {
+		body, rerr := io.ReadAll(r)
+		if rerr != nil {
+			return "", "", nil, rerr
+		}
+		return string(body), "", nil, nil
+	}
+
+	var bodyText string
+	var htmlBody string
+	var attachments []backend.Attachment
+	partIdx := 0
+
+	for {
+		part, err := mr.NextPart()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			break
+		}
+		partIdx++
+
+		contentType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
+		disposition, dParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
+
+		data, readErr := io.ReadAll(part.Body)
+		if readErr != nil {
+			continue
+		}
+
+		if disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")) {
+			filename := dParams["filename"]
+			if filename == "" {
+				_, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
+				filename = cp["name"]
+			}
+			att := backend.Attachment{
+				Filename: filename,
+				PartID:   fmt.Sprintf("%d", partIdx),
+				Data:     data,
+				MIMEType: contentType,
+				Inline:   disposition == "inline",
+			}
+			if cid := part.Header.Get("Content-ID"); cid != "" {
+				att.ContentID = strings.Trim(cid, "<>")
+			}
+			attachments = append(attachments, att)
+		} else if contentType == "text/html" {
+			htmlBody = string(data)
+		} else if contentType == "text/plain" && bodyText == "" {
+			bodyText = string(data)
+		}
+	}
+
+	if htmlBody != "" {
+		return htmlBody, "text/html", attachments, nil
+	}
+	return bodyText, "text/plain", attachments, nil
+}
+
+// findAttachmentData walks a raw message to find attachment data by partID.
+func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) {
+	mr, err := gomail.CreateReader(r)
+	if err != nil {
+		return nil, fmt.Errorf("not a multipart message")
+	}
+
+	partIdx := 0
+	for {
+		part, err := mr.NextPart()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			break
+		}
+		partIdx++
+
+		if fmt.Sprintf("%d", partIdx) == targetPartID {
+			return io.ReadAll(part.Body)
+		}
+	}
+
+	return nil, fmt.Errorf("maildir: attachment part %s not found", targetPartID)
+}
+
+// Verify interface compliance at compile time.
+var _ backend.Provider = (*Provider)(nil)

backend/maildir/maildir_test.go 🔗

@@ -0,0 +1,289 @@
+package maildir
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/floatpane/matcha/backend"
+	"github.com/floatpane/matcha/config"
+)
+
+// seenSuffix returns the on-disk suffix go-maildir appends for a message that
+// carries only the Seen flag. Windows uses ';' instead of ':' because ':' is
+// reserved in NTFS filenames.
+func seenSuffix() string {
+	if runtime.GOOS == "windows" {
+		return ";2,S"
+	}
+	return ":2,S"
+}
+
+// makeMaildir creates a root + the named Maildir++ subfolders.
+func makeMaildir(t *testing.T, subfolders ...string) string {
+	t.Helper()
+	root := t.TempDir()
+	for _, sub := range []string{"cur", "new", "tmp"} {
+		if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil {
+			t.Fatalf("mkdir %s: %v", sub, err)
+		}
+	}
+	for _, folder := range subfolders {
+		for _, sub := range []string{"cur", "new", "tmp"} {
+			if err := os.MkdirAll(filepath.Join(root, folder, sub), 0o755); err != nil {
+				t.Fatalf("mkdir subfolder %s/%s: %v", folder, sub, err)
+			}
+		}
+	}
+	return root
+}
+
+// dropMessage writes a fake delivered message into the new/ dir of a Maildir.
+// The filename intentionally has no flag suffix (delivered state).
+func dropMessage(t *testing.T, dir, key, subject, body string, deliveredAt time.Time) {
+	t.Helper()
+	contents := fmt.Sprintf(
+		"From: alice@example.com\r\n"+
+			"To: me@local\r\n"+
+			"Subject: %s\r\n"+
+			"Date: %s\r\n"+
+			"Message-ID: <%s@local>\r\n"+
+			"\r\n"+
+			"%s\r\n",
+		subject, deliveredAt.Format(time.RFC1123Z), key, body,
+	)
+	path := filepath.Join(dir, "new", key)
+	if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
+		t.Fatalf("write message: %v", err)
+	}
+	// Match deliveredAt so sort-by-mtime is deterministic.
+	if err := os.Chtimes(path, deliveredAt, deliveredAt); err != nil {
+		t.Fatalf("chtimes: %v", err)
+	}
+}
+
+func newProvider(t *testing.T, root string) *Provider {
+	t.Helper()
+	p, err := New(&config.Account{ID: "acct1", MaildirPath: root})
+	if err != nil {
+		t.Fatalf("New: %v", err)
+	}
+	return p
+}
+
+func TestNewRejectsMissingPath(t *testing.T) {
+	if _, err := New(&config.Account{ID: "x"}); err == nil {
+		t.Fatal("expected error for empty MaildirPath")
+	}
+	if _, err := New(&config.Account{ID: "x", MaildirPath: "/this/does/not/exist"}); err == nil {
+		t.Fatal("expected error for nonexistent path")
+	}
+}
+
+func TestFetchFoldersListsInboxAndSubfolders(t *testing.T) {
+	root := makeMaildir(t, ".Sent", ".Archive")
+	p := newProvider(t, root)
+
+	folders, err := p.FetchFolders(context.Background())
+	if err != nil {
+		t.Fatalf("FetchFolders: %v", err)
+	}
+
+	names := make(map[string]bool, len(folders))
+	for _, f := range folders {
+		names[f.Name] = true
+	}
+	for _, want := range []string{"INBOX", "Sent", "Archive"} {
+		if !names[want] {
+			t.Errorf("expected folder %q in %v", want, names)
+		}
+	}
+}
+
+func TestFetchEmailsNewestFirst(t *testing.T) {
+	root := makeMaildir(t)
+	t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+	dropMessage(t, root, "1700000000.older.host", "first", "old body", t0)
+	dropMessage(t, root, "1700000100.newer.host", "second", "new body", t0.Add(time.Hour))
+
+	p := newProvider(t, root)
+	emails, err := p.FetchEmails(context.Background(), "INBOX", 50, 0)
+	if err != nil {
+		t.Fatalf("FetchEmails: %v", err)
+	}
+	if len(emails) != 2 {
+		t.Fatalf("want 2 emails, got %d", len(emails))
+	}
+	if emails[0].Subject != "second" {
+		t.Errorf("want newest first, got %q", emails[0].Subject)
+	}
+	if emails[1].Subject != "first" {
+		t.Errorf("want oldest second, got %q", emails[1].Subject)
+	}
+	if emails[0].UID == 0 || emails[0].UID == emails[1].UID {
+		t.Errorf("UIDs must be nonzero and distinct: %d vs %d", emails[0].UID, emails[1].UID)
+	}
+	if emails[0].IsRead {
+		t.Error("freshly delivered message should not be read")
+	}
+}
+
+func TestFetchEmailsRespectsLimitOffset(t *testing.T) {
+	root := makeMaildir(t)
+	base := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
+	for i := 0; i < 5; i++ {
+		key := fmt.Sprintf("1700000%03d.M%dP1.host", i, i)
+		dropMessage(t, root, key, fmt.Sprintf("msg%d", i), "body", base.Add(time.Duration(i)*time.Minute))
+	}
+
+	p := newProvider(t, root)
+	page, err := p.FetchEmails(context.Background(), "INBOX", 2, 1)
+	if err != nil {
+		t.Fatalf("FetchEmails: %v", err)
+	}
+	if len(page) != 2 {
+		t.Fatalf("want 2, got %d", len(page))
+	}
+	if page[0].Subject != "msg3" || page[1].Subject != "msg2" {
+		t.Errorf("want msg3,msg2 — got %q,%q", page[0].Subject, page[1].Subject)
+	}
+}
+
+func TestMarkAsReadAddsSeenFlag(t *testing.T) {
+	root := makeMaildir(t)
+	dropMessage(t, root, "1700000000.x.host", "subj", "body", time.Now())
+
+	p := newProvider(t, root)
+	emails, err := p.FetchEmails(context.Background(), "INBOX", 10, 0)
+	if err != nil || len(emails) != 1 {
+		t.Fatalf("FetchEmails setup: %v / %d", err, len(emails))
+	}
+
+	if err := p.MarkAsRead(context.Background(), "INBOX", emails[0].UID); err != nil {
+		t.Fatalf("MarkAsRead: %v", err)
+	}
+
+	curFiles, _ := os.ReadDir(filepath.Join(root, "cur"))
+	if len(curFiles) != 1 {
+		t.Fatalf("want 1 file in cur/, got %d", len(curFiles))
+	}
+	if !strings.HasSuffix(curFiles[0].Name(), seenSuffix()) {
+		t.Errorf("want %s suffix, got %q", seenSuffix(), curFiles[0].Name())
+	}
+
+	emails, err = p.FetchEmails(context.Background(), "INBOX", 10, 0)
+	if err != nil || len(emails) != 1 {
+		t.Fatalf("FetchEmails post-flag: %v / %d", err, len(emails))
+	}
+	if !emails[0].IsRead {
+		t.Error("email should report IsRead=true after MarkAsRead")
+	}
+}
+
+func TestDeleteEmailRemovesFile(t *testing.T) {
+	root := makeMaildir(t)
+	dropMessage(t, root, "1700000000.del.host", "del", "body", time.Now())
+
+	p := newProvider(t, root)
+	emails, _ := p.FetchEmails(context.Background(), "INBOX", 10, 0)
+	if len(emails) != 1 {
+		t.Fatalf("setup: want 1 email, got %d", len(emails))
+	}
+
+	if err := p.DeleteEmail(context.Background(), "INBOX", emails[0].UID); err != nil {
+		t.Fatalf("DeleteEmail: %v", err)
+	}
+
+	newFiles, _ := os.ReadDir(filepath.Join(root, "new"))
+	curFiles, _ := os.ReadDir(filepath.Join(root, "cur"))
+	if len(newFiles)+len(curFiles) != 0 {
+		t.Errorf("expected no files left, got new=%d cur=%d", len(newFiles), len(curFiles))
+	}
+}
+
+func TestMoveEmailRelocates(t *testing.T) {
+	root := makeMaildir(t, ".Archive")
+	dropMessage(t, root, "1700000000.mv.host", "mv", "body", time.Now())
+
+	p := newProvider(t, root)
+	emails, _ := p.FetchEmails(context.Background(), "INBOX", 10, 0)
+	if len(emails) != 1 {
+		t.Fatalf("setup: want 1 email, got %d", len(emails))
+	}
+
+	if err := p.MoveEmail(context.Background(), emails[0].UID, "INBOX", "Archive"); err != nil {
+		t.Fatalf("MoveEmail: %v", err)
+	}
+
+	inboxFiles, _ := os.ReadDir(filepath.Join(root, "new"))
+	if len(inboxFiles) != 0 {
+		t.Errorf("expected INBOX empty, got %d files", len(inboxFiles))
+	}
+	archiveCur, _ := os.ReadDir(filepath.Join(root, ".Archive", "cur"))
+	archiveNew, _ := os.ReadDir(filepath.Join(root, ".Archive", "new"))
+	if len(archiveCur)+len(archiveNew) != 1 {
+		t.Errorf("expected 1 file in .Archive, got cur=%d new=%d", len(archiveCur), len(archiveNew))
+	}
+}
+
+func TestArchiveEmailRequiresArchiveFolder(t *testing.T) {
+	root := makeMaildir(t) // no .Archive
+	dropMessage(t, root, "1700000000.a.host", "a", "body", time.Now())
+
+	p := newProvider(t, root)
+	emails, _ := p.FetchEmails(context.Background(), "INBOX", 10, 0)
+	err := p.ArchiveEmail(context.Background(), "INBOX", emails[0].UID)
+	if err != backend.ErrNotSupported {
+		t.Errorf("want ErrNotSupported, got %v", err)
+	}
+}
+
+func TestSendEmailNotSupported(t *testing.T) {
+	root := makeMaildir(t)
+	p := newProvider(t, root)
+	if err := p.SendEmail(context.Background(), &backend.OutgoingEmail{}); err != backend.ErrNotSupported {
+		t.Errorf("want ErrNotSupported, got %v", err)
+	}
+}
+
+func TestSearchFiltersBySubject(t *testing.T) {
+	root := makeMaildir(t)
+	t0 := time.Now()
+	dropMessage(t, root, "k1.host", "alpha report", "x", t0)
+	dropMessage(t, root, "k2.host", "beta notice", "y", t0)
+
+	p := newProvider(t, root)
+	results, err := p.Search(context.Background(), "INBOX", backend.SearchQuery{Subject: "alpha"})
+	if err != nil {
+		t.Fatalf("Search: %v", err)
+	}
+	if len(results) != 1 || !strings.Contains(results[0].Subject, "alpha") {
+		t.Errorf("want one alpha result, got %+v", results)
+	}
+}
+
+func TestCapabilitiesReflectsArchivePresence(t *testing.T) {
+	root := makeMaildir(t)
+	pNoArchive := newProvider(t, root)
+	if pNoArchive.Capabilities().CanArchive {
+		t.Error("CanArchive should be false without .Archive subfolder")
+	}
+
+	rootWithArchive := makeMaildir(t, ".Archive")
+	pArchive := newProvider(t, rootWithArchive)
+	caps := pArchive.Capabilities()
+	if !caps.CanArchive {
+		t.Error("CanArchive should be true when .Archive exists")
+	}
+	if caps.CanSend {
+		t.Error("CanSend must be false for Maildir")
+	}
+	if !caps.CanFetchFolders {
+		t.Error("CanFetchFolders must be true")
+	}
+}

config/config.go 🔗

@@ -73,6 +73,7 @@ type Account struct {
 	JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
 	POP3Server   string `json:"pop3_server,omitempty"`   // POP3 server hostname (for protocol=pop3)
 	POP3Port     int    `json:"pop3_port,omitempty"`     // POP3 server port (for protocol=pop3)
+	MaildirPath  string `json:"maildir_path,omitempty"`  // Local Maildir root (for protocol=maildir)
 
 	// Per-account signature (overrides global signature)
 	Signature string `json:"signature,omitempty"`

go.mod 🔗

@@ -14,6 +14,7 @@ require (
 	github.com/arran4/golang-ical v0.3.5
 	github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25
 	github.com/emersion/go-imap/v2 v2.0.0-beta.8
+	github.com/emersion/go-maildir v0.6.0
 	github.com/emersion/go-message v0.18.2
 	github.com/emersion/go-pgpmail v0.2.2
 	github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6

go.sum 🔗

@@ -56,6 +56,8 @@ github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 h1:vXmXuiy1tgifTqWAAaU+
 github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25/go.mod h1:BkYEeWL6FbT4Ek+TcOBnPzEKnL7kOq2g19tTQXkorHY=
 github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
 github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
+github.com/emersion/go-maildir v0.6.0 h1:MPx2RSS1Xq8j1cNOzfq7YyF+5Leoeif1XqSeuytdET8=
+github.com/emersion/go-maildir v0.6.0/go.mod h1:Wpgtt9EOIJWe++WKa+JRvDwv+qIV7MeFdvZu/VbsXN4=
 github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw=
 github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
 github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=

main.go 🔗

@@ -30,6 +30,7 @@ import (
 	"github.com/floatpane/matcha/backend"
 	_ "github.com/floatpane/matcha/backend/imap"
 	_ "github.com/floatpane/matcha/backend/jmap"
+	_ "github.com/floatpane/matcha/backend/maildir"
 	_ "github.com/floatpane/matcha/backend/pop3"
 	"github.com/floatpane/matcha/calendar"
 	matchaCli "github.com/floatpane/matcha/cli"
@@ -378,6 +379,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				JMAPEndpoint:    msg.JMAPEndpoint,
 				POP3Server:      msg.POP3Server,
 				POP3Port:        msg.POP3Port,
+				MaildirPath:     msg.MaildirPath,
 			}
 
 			if msg.Provider == "custom" || msg.Protocol == "pop3" {
@@ -422,6 +424,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					JMAPEndpoint:    msg.JMAPEndpoint,
 					POP3Server:      msg.POP3Server,
 					POP3Port:        msg.POP3Port,
+					MaildirPath:     msg.MaildirPath,
 				}
 
 				if msg.Provider == "custom" || msg.Protocol == "pop3" {
@@ -1138,7 +1141,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			hideTips = m.config.HideTips
 		}
 		login := tui.NewLogin(hideTips)
-		login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll)
+		login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll, msg.MaildirPath)
 		m.current = login
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()

tui/login.go 🔗

@@ -39,6 +39,7 @@ const (
 	inputJMAPEndpoint // JMAP session URL
 	inputPOP3Server
 	inputPOP3Port
+	inputMaildirPath // Local Maildir root path
 	inputCount
 )
 
@@ -58,7 +59,7 @@ func NewLogin(hideTips bool) *Login {
 
 		switch i {
 		case inputProtocol:
-			t.Placeholder = "Protocol (imap, jmap, or pop3)"
+			t.Placeholder = "Protocol (imap, jmap, pop3, or maildir)"
 			t.Focus()
 			t.Prompt = "🌐 > "
 		case inputProvider:
@@ -110,6 +111,9 @@ func NewLogin(hideTips bool) *Login {
 		case inputPOP3Port:
 			t.Placeholder = "POP3 Port (default: 995)"
 			t.Prompt = "🔢 > "
+		case inputMaildirPath:
+			t.Placeholder = "Maildir Path (e.g., ~/Mail or /var/mail/user)"
+			t.Prompt = "📁 > "
 		}
 		m.inputs[i] = t
 	}
@@ -148,6 +152,9 @@ func (m *Login) visibleFields() []int {
 		// POP3: custom server fields + SMTP for sending
 		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputPassword,
 			inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort, inputInsecure)
+	case "maildir":
+		// Maildir: local filesystem only — no auth, no network.
+		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputMaildirPath)
 	default:
 		// IMAP (default): existing flow
 		fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll)
@@ -309,6 +316,7 @@ func (m *Login) submitForm() func() tea.Msg {
 			JMAPEndpoint: m.inputs[inputJMAPEndpoint].Value(),
 			POP3Server:   m.inputs[inputPOP3Server].Value(),
 			POP3Port:     pop3Port,
+			MaildirPath:  m.inputs[inputMaildirPath].Value(),
 		}
 	}
 }
@@ -325,7 +333,7 @@ func (m *Login) View() tea.View {
 	tip := ""
 	switch m.focusIndex {
 	case inputProtocol:
-		tip = "Choose the protocol: imap (default), jmap, or pop3."
+		tip = "Choose the protocol: imap (default), jmap, pop3, or maildir."
 	case inputProvider:
 		tip = "Enter your email provider (e.g., gmail, outlook, icloud) or 'custom'."
 	case inputName:
@@ -358,6 +366,8 @@ func (m *Login) View() tea.View {
 		tip = "The POP3 server address for receiving emails."
 	case inputPOP3Port:
 		tip = "The port for the POP3 server (usually 995 for SSL)."
+	case inputMaildirPath:
+		tip = "Local path to a Maildir directory (cur/new/tmp). Subfolders use .Foldername (Maildir++)."
 	}
 
 	views := []string{
@@ -398,6 +408,17 @@ func (m *Login) View() tea.View {
 			m.inputs[inputSMTPPort].View(),
 			m.inputs[inputInsecure].View(),
 		)
+	case "maildir":
+		views = append(views,
+			m.inputs[inputName].View(),
+			m.inputs[inputEmail].View(),
+			m.inputs[inputFetchEmail].View(),
+			m.inputs[inputSendAsEmail].View(),
+			m.inputs[inputCatchAll].View(),
+			"",
+			listHeader.Render("Maildir Settings:"),
+			m.inputs[inputMaildirPath].View(),
+		)
 	default:
 		// IMAP flow
 		provider := m.inputs[inputProvider].Value()
@@ -449,7 +470,7 @@ func (m *Login) View() tea.View {
 }
 
 // SetEditMode sets the login form to edit an existing account.
-func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, insecure bool, jmapEndpoint, pop3Server string, pop3Port int, catchAll bool) {
+func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, insecure bool, jmapEndpoint, pop3Server string, pop3Port int, catchAll bool, maildirPath string) {
 	m.isEditMode = true
 	m.accountID = accountID
 
@@ -501,6 +522,9 @@ func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEma
 			m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
 		}
 	}
+	if maildirPath != "" {
+		m.inputs[inputMaildirPath].SetValue(maildirPath)
+	}
 }
 
 // GetAccountID returns the account ID being edited (if in edit mode).

tui/messages.go 🔗

@@ -61,6 +61,7 @@ type Credentials struct {
 	JMAPEndpoint string // JMAP session URL
 	POP3Server   string // POP3 server hostname
 	POP3Port     int    // POP3 server port
+	MaildirPath  string // Local Maildir root
 }
 
 // StartOAuth2Msg is sent when the user requests OAuth2 authorization for a Gmail account.
@@ -291,6 +292,7 @@ type GoToEditAccountMsg struct {
 	JMAPEndpoint string
 	POP3Server   string
 	POP3Port     int
+	MaildirPath  string
 }
 
 // GoToEditMailingListMsg signals navigation to edit an existing mailing list.

tui/settings_accounts.go 🔗

@@ -67,6 +67,7 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 					JMAPEndpoint: acc.JMAPEndpoint,
 					POP3Server:   acc.POP3Server,
 					POP3Port:     acc.POP3Port,
+					MaildirPath:  acc.MaildirPath,
 				}
 			}
 		}