fix: maildir fetch errors (#1375)

Drew Smirnoff created

## What?

Routes all per-account fetch sites in main.go through
`m.providers[acct.ID]`.

## Why?

Fixes #1309

---------

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

Change summary

backend/backend.go               |   1 
backend/imap/imap.go             |   1 
backend/maildir/maildir.go       |  84 +++++++++++--
backend/maildir/maildir_test.go  |  55 +++++++++
config/config.go                 |   4 
fetcher/dispatch.go              |  77 ++++++++++++
fetcher/fetcher.go               | 133 +++++++++++++++++++++
fetcher/idle.go                  |   7 +
fetcher/maildir_dispatch_test.go | 206 ++++++++++++++++++++++++++++++++++
fetcher/search.go                |  14 ++
10 files changed, 564 insertions(+), 18 deletions(-)

Detailed changes

backend/backend.go πŸ”—

@@ -213,6 +213,7 @@ type Folder struct {
 	Name       string
 	Delimiter  string
 	Attributes []string
+	Unread     uint32
 }
 
 // OutgoingEmail contains everything needed to send an email.

backend/imap/imap.go πŸ”—

@@ -183,6 +183,7 @@ func toBackendFolders(folders []fetcher.Folder) []backend.Folder {
 			Name:       f.Name,
 			Delimiter:  f.Delimiter,
 			Attributes: f.Attributes,
+			Unread:     f.Unread,
 		}
 	}
 	return result

backend/maildir/maildir.go πŸ”—

@@ -30,6 +30,8 @@ import (
 	"github.com/floatpane/matcha/config"
 )
 
+const inboxFolder = "INBOX"
+
 var messageIDRE = regexp.MustCompile(`<[^>]+>`)
 
 func init() {
@@ -39,9 +41,18 @@ func init() {
 }
 
 // Provider implements backend.Provider against a local Maildir tree.
+// Two on-disk layouts are supported:
+//   - Maildir++ (dovecot style): the root itself is INBOX (has cur/new/tmp),
+//     and subfolders are sibling directories prefixed with "." (e.g. ".Sent").
+//   - Nested (mbsync/isync/fastmail style): the root contains one directory
+//     per folder, each holding its own cur/new/tmp. INBOX is the child
+//     directory named "INBOX".
+//
+// The layout is auto-detected at New() time by probing for `<root>/cur`.
 type Provider struct {
 	account *config.Account
 	root    string
+	nested  bool
 }
 
 // New creates a new Maildir provider for the given account.
@@ -68,29 +79,77 @@ func New(account *config.Account) (*Provider, error) {
 		return nil, fmt.Errorf("maildir path %q is not a directory", root)
 	}
 
-	return &Provider{account: account, root: root}, nil
+	nested := false
+	if _, err := os.Stat(filepath.Join(root, "cur")); err != nil {
+		nested = true
+	}
+
+	return &Provider{account: account, root: root, nested: nested}, 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.
+// Maildir++ layout: "" and "INBOX" map to the root; other names become
+// ".Sub.Folder" siblings. Nested layout: every folder is a child directory
+// named verbatim, with "/" preserved as a path separator.
 func (p *Provider) dirForFolder(folder string) emaildir.Dir {
-	if folder == "" || strings.EqualFold(folder, "INBOX") {
+	if p.nested {
+		if folder == "" {
+			folder = inboxFolder
+		}
+		return emaildir.Dir(filepath.Join(p.root, filepath.FromSlash(folder)))
+	}
+	if folder == "" || strings.EqualFold(folder, inboxFolder) {
 		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: "/"}}
+// archiveDir returns the on-disk path of the Archive folder for the active
+// layout (".Archive" under Maildir++, "Archive" under nested).
+func (p *Provider) archiveDir() string {
+	if p.nested {
+		return filepath.Join(p.root, "Archive")
+	}
+	return filepath.Join(p.root, ".Archive")
+}
 
+// FetchFolders returns INBOX plus any subfolders found at the root, using
+// whichever on-disk layout the provider detected.
+func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
 	entries, err := os.ReadDir(p.root)
 	if err != nil {
 		return nil, fmt.Errorf("maildir read root: %w", err)
 	}
 
+	if p.nested {
+		var folders []backend.Folder
+		seenInbox := false
+		for _, entry := range entries {
+			if !entry.IsDir() {
+				continue
+			}
+			name := entry.Name()
+			if name == "." || name == ".." {
+				continue
+			}
+			if _, err := os.Stat(filepath.Join(p.root, name, "cur")); err != nil {
+				continue
+			}
+			if strings.EqualFold(name, inboxFolder) {
+				seenInbox = true
+				folders = append([]backend.Folder{{Name: inboxFolder, Delimiter: "/"}}, folders...)
+				continue
+			}
+			folders = append(folders, backend.Folder{Name: name, Delimiter: "/"})
+		}
+		if !seenInbox {
+			folders = append([]backend.Folder{{Name: inboxFolder, Delimiter: "/"}}, folders...)
+		}
+		return folders, nil
+	}
+
+	folders := []backend.Folder{{Name: inboxFolder, Delimiter: "/"}}
 	for _, entry := range entries {
 		if !entry.IsDir() {
 			continue
@@ -99,15 +158,12 @@ func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
 		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
 }
 
@@ -258,9 +314,9 @@ func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) err
 	return msg.Remove()
 }
 
-// ArchiveEmail moves the message to the ".Archive" subfolder if one exists.
+// 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 {
+	if _, err := os.Stat(filepath.Join(p.archiveDir(), "cur")); err != nil {
 		return backend.ErrNotSupported
 	}
 	return p.MoveEmail(ctx, uid, folder, "Archive")
@@ -288,7 +344,7 @@ func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint3
 
 // 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 {
+	if _, err := os.Stat(filepath.Join(p.archiveDir(), "cur")); err != nil {
 		return backend.ErrNotSupported
 	}
 	for _, uid := range uids {
@@ -421,7 +477,7 @@ 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"))
+	_, hasArchive := os.Stat(filepath.Join(p.archiveDir(), "cur"))
 	return backend.Capabilities{
 		CanSend:         false,
 		CanMove:         true,

backend/maildir/maildir_test.go πŸ”—

@@ -288,3 +288,58 @@ func TestCapabilitiesReflectsArchivePresence(t *testing.T) {
 		t.Error("CanFetchFolders must be true")
 	}
 }
+
+// makeNestedMaildir creates an mbsync/isync-style tree: the root has no
+// cur/new/tmp of its own; each named subdirectory is a self-contained
+// Maildir folder.
+func makeNestedMaildir(t *testing.T, folders ...string) string {
+	t.Helper()
+	root := t.TempDir()
+	for _, folder := range folders {
+		for _, sub := range []string{"cur", "new", "tmp"} {
+			if err := os.MkdirAll(filepath.Join(root, folder, sub), 0o755); err != nil {
+				t.Fatalf("mkdir %s/%s: %v", folder, sub, err)
+			}
+		}
+	}
+	return root
+}
+
+func TestNestedLayoutListsFoldersAndFetchesInbox(t *testing.T) {
+	root := makeNestedMaildir(t, "INBOX", "Sent", "Archive", "Drafts")
+	dropMessage(t, filepath.Join(root, "INBOX"), "1700000000.n.host", "nested hi", "body", time.Now())
+
+	p := newProvider(t, root)
+	if !p.nested {
+		t.Fatal("expected nested layout to be detected")
+	}
+
+	folders, err := p.FetchFolders(context.Background())
+	if err != nil {
+		t.Fatalf("FetchFolders: %v", err)
+	}
+	if len(folders) == 0 || folders[0].Name != "INBOX" {
+		t.Errorf("INBOX should be listed first, got %+v", folders)
+	}
+	names := map[string]bool{}
+	for _, f := range folders {
+		names[f.Name] = true
+	}
+	for _, want := range []string{"INBOX", "Sent", "Archive", "Drafts"} {
+		if !names[want] {
+			t.Errorf("missing folder %q in %v", want, names)
+		}
+	}
+
+	emails, err := p.FetchEmails(context.Background(), "INBOX", 10, 0)
+	if err != nil {
+		t.Fatalf("FetchEmails: %v", err)
+	}
+	if len(emails) != 1 || emails[0].Subject != "nested hi" {
+		t.Errorf("want 1 message with subject 'nested hi', got %+v", emails)
+	}
+
+	if !p.Capabilities().CanArchive {
+		t.Error("CanArchive should be true when Archive subfolder exists in nested layout")
+	}
+}

config/config.go πŸ”—

@@ -435,6 +435,7 @@ type secureDiskAccount struct {
 	JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
 	POP3Server         string `json:"pop3_server,omitempty"`
 	POP3Port           int    `json:"pop3_port,omitempty"`
+	MaildirPath        string `json:"maildir_path,omitempty"`
 	CatchAll           bool   `json:"catch_all,omitempty"`
 }
 
@@ -530,6 +531,7 @@ func SaveConfig(config *Config) error {
 				JMAPEndpoint:       acc.JMAPEndpoint,
 				POP3Server:         acc.POP3Server,
 				POP3Port:           acc.POP3Port,
+				MaildirPath:        acc.MaildirPath,
 				CatchAll:           acc.CatchAll,
 			})
 		}
@@ -592,6 +594,7 @@ func LoadConfig() (*Config, error) {
 		JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
 		POP3Server         string `json:"pop3_server,omitempty"`
 		POP3Port           int    `json:"pop3_port,omitempty"`
+		MaildirPath        string `json:"maildir_path,omitempty"`
 		CatchAll           bool   `json:"catch_all,omitempty"`
 	}
 	type diskConfig struct {
@@ -678,6 +681,7 @@ func LoadConfig() (*Config, error) {
 			JMAPEndpoint:       rawAcc.JMAPEndpoint,
 			POP3Server:         rawAcc.POP3Server,
 			POP3Port:           rawAcc.POP3Port,
+			MaildirPath:        rawAcc.MaildirPath,
 			CatchAll:           rawAcc.CatchAll,
 			SC:                 &SessionCache{},
 		}

fetcher/dispatch.go πŸ”—

@@ -0,0 +1,77 @@
+package fetcher
+
+import (
+	"github.com/floatpane/matcha/backend"
+	_ "github.com/floatpane/matcha/backend/maildir" // register maildir backend
+	"github.com/floatpane/matcha/config"
+)
+
+// hasBackendProvider reports whether the account is served by a non-IMAP
+// backend (currently only "maildir") and should be routed through the
+// backend.Provider abstraction instead of the legacy IMAP code path.
+func hasBackendProvider(account *config.Account) bool {
+	return account != nil && account.Protocol == "maildir"
+}
+
+// newBackendProvider builds the backend.Provider for the account. Callers
+// must guard with hasBackendProvider before invoking it.
+func newBackendProvider(account *config.Account) (backend.Provider, error) {
+	return backend.New(account)
+}
+
+func backendFoldersToFetcher(in []backend.Folder) []Folder {
+	out := make([]Folder, len(in))
+	for i, f := range in {
+		out[i] = Folder{
+			Name:       f.Name,
+			Delimiter:  f.Delimiter,
+			Attributes: f.Attributes,
+			Unread:     f.Unread,
+		}
+	}
+	return out
+}
+
+func backendEmailsToFetcher(in []backend.Email) []Email {
+	out := make([]Email, len(in))
+	for i, e := range in {
+		out[i] = Email{
+			UID:         e.UID,
+			From:        e.From,
+			To:          e.To,
+			ReplyTo:     e.ReplyTo,
+			Subject:     e.Subject,
+			Body:        e.Body,
+			Date:        e.Date,
+			IsRead:      e.IsRead,
+			MessageID:   e.MessageID,
+			InReplyTo:   e.InReplyTo,
+			References:  e.References,
+			Attachments: backendAttachmentsToFetcher(e.Attachments),
+			AccountID:   e.AccountID,
+		}
+	}
+	return out
+}
+
+func backendAttachmentsToFetcher(in []backend.Attachment) []Attachment {
+	out := make([]Attachment, len(in))
+	for i, a := range in {
+		out[i] = Attachment{
+			Filename:         a.Filename,
+			PartID:           a.PartID,
+			Data:             a.Data,
+			Encoding:         a.Encoding,
+			MIMEType:         a.MIMEType,
+			ContentID:        a.ContentID,
+			Inline:           a.Inline,
+			IsSMIMESignature: a.IsSMIMESignature,
+			SMIMEVerified:    a.SMIMEVerified,
+			IsSMIMEEncrypted: a.IsSMIMEEncrypted,
+			IsPGPSignature:   a.IsPGPSignature,
+			PGPVerified:      a.PGPVerified,
+			IsPGPEncrypted:   a.IsPGPEncrypted,
+		}
+	}
+	return out
+}

fetcher/fetcher.go πŸ”—

@@ -3,6 +3,7 @@ package fetcher
 import (
 	"bufio"
 	"bytes"
+	"context"
 	"crypto/tls"
 	"crypto/x509"
 	"encoding/base64"
@@ -47,6 +48,10 @@ const (
 	mimeTextPlain = "text/plain"
 	mimeTextHTML  = "text/html"
 	partExtracted = "extracted"
+	// defaultArchiveMailbox is the IMAP folder name used as the archive
+	// destination for any provider that does not have a custom mapping
+	// (e.g. Gmail's "[Gmail]/All Mail").
+	defaultArchiveMailbox = "Archive"
 )
 
 func getDebugIMAPWriter() io.Writer {
@@ -492,6 +497,19 @@ func getMailboxByAttr(c *imapclient.Client, attr imap.MailboxAttr) (string, erro
 }
 
 func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return nil, err
+		}
+		defer p.Close() //nolint:errcheck
+		emails, err := p.FetchEmails(context.Background(), mailbox, limit, offset)
+		if err != nil {
+			return nil, err
+		}
+		return backendEmailsToFetcher(emails), nil
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return nil, err
@@ -646,6 +664,19 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 // parsed attachments, and any error. The MIME type lets the renderer
 // skip the markdown→HTML pre-pass for already-HTML bodies.
 func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, string, []Attachment, error) { //nolint:gocyclo
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return "", "", nil, err
+		}
+		defer p.Close() //nolint:errcheck
+		body, mimeType, atts, err := p.FetchEmailBody(context.Background(), mailbox, uid)
+		if err != nil {
+			return "", "", nil, err
+		}
+		return body, mimeType, backendAttachmentsToFetcher(atts), nil
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return "", "", nil, err
@@ -1218,6 +1249,15 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
 }
 
 func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return nil, err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.FetchAttachment(context.Background(), mailbox, uid, partID, encoding)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return nil, err
@@ -1260,6 +1300,15 @@ func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uin
 }
 
 func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.MoveEmail(context.Background(), uid, sourceMailbox, destMailbox)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return err
@@ -1276,6 +1325,15 @@ func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox s
 }
 
 func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.MarkAsRead(context.Background(), mailbox, uid)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return err
@@ -1295,6 +1353,15 @@ func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint3
 }
 
 func MarkEmailAsUnreadInMailbox(account *config.Account, mailbox string, uid uint32) error {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.MarkAsUnread(context.Background(), mailbox, uid)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return err
@@ -1314,6 +1381,15 @@ func MarkEmailAsUnreadInMailbox(account *config.Account, mailbox string, uid uin
 }
 
 func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.DeleteEmail(context.Background(), mailbox, uid)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return err
@@ -1337,6 +1413,15 @@ func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32)
 }
 
 func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.ArchiveEmail(context.Background(), mailbox, uid)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return err
@@ -1353,7 +1438,7 @@ func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32
 			archiveMailbox = "[Gmail]/All Mail"
 		}
 	default:
-		archiveMailbox = "Archive"
+		archiveMailbox = defaultArchiveMailbox
 	}
 
 	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
@@ -1373,6 +1458,15 @@ func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uin
 		return nil
 	}
 
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.DeleteEmails(context.Background(), mailbox, uids)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return err
@@ -1401,6 +1495,15 @@ func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []ui
 		return nil
 	}
 
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.ArchiveEmails(context.Background(), mailbox, uids)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return err
@@ -1415,7 +1518,7 @@ func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []ui
 			archiveMailbox = "[Gmail]/All Mail"
 		}
 	default:
-		archiveMailbox = "Archive"
+		archiveMailbox = defaultArchiveMailbox
 	}
 
 	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
@@ -1433,6 +1536,15 @@ func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, de
 		return nil
 	}
 
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return err
+		}
+		defer p.Close() //nolint:errcheck
+		return p.MoveEmails(context.Background(), uids, sourceFolder, destFolder)
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return err
@@ -1533,9 +1645,9 @@ func getArchiveMailbox(account *config.Account) string {
 	case config.ProviderGmail:
 		return "[Gmail]/All Mail"
 	case "outlook", "icloud":
-		return "Archive"
+		return defaultArchiveMailbox
 	default:
-		return "Archive"
+		return defaultArchiveMailbox
 	}
 }
 
@@ -1788,6 +1900,19 @@ func DeleteArchiveEmail(account *config.Account, uid uint32) error {
 
 // FetchFolders lists all IMAP folders/mailboxes for an account.
 func FetchFolders(account *config.Account) ([]Folder, error) {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return nil, err
+		}
+		defer p.Close() //nolint:errcheck
+		folders, err := p.FetchFolders(context.Background())
+		if err != nil {
+			return nil, err
+		}
+		return backendFoldersToFetcher(folders), nil
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return nil, err

fetcher/idle.go πŸ”—

@@ -47,6 +47,13 @@ func NewIdleWatcher(notify chan<- IdleUpdate) *IdleWatcher {
 
 // Watch starts (or restarts) an IDLE connection for the given account and folder.
 func (w *IdleWatcher) Watch(account *config.Account, folder string) {
+	// IDLE is an IMAP-only concept; non-IMAP backends (maildir, etc.) have
+	// no remote socket to keep open. Skip silently rather than spinning the
+	// reconnect loop forever.
+	if account != nil && account.Protocol != "" && account.Protocol != "imap" {
+		return
+	}
+
 	w.mu.Lock()
 	defer w.mu.Unlock()
 

fetcher/maildir_dispatch_test.go πŸ”—

@@ -0,0 +1,206 @@
+package fetcher
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/floatpane/matcha/config"
+)
+
+// End-to-end coverage for the maildir dispatch path in the fetcher package.
+// These tests exercise the public entry points main.go calls (FetchFolders,
+// FetchMailboxEmails, FetchEmailBodyFromMailbox, MarkEmailAsReadInMailbox)
+// against an on-disk Maildir, mirroring the real TUI flow without an IMAP
+// server.
+
+func seenSuffix() string {
+	if runtime.GOOS == "windows" {
+		return ";2,S"
+	}
+	return ":2,S"
+}
+
+func makeMaildirRoot(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
+}
+
+func dropNewMessage(t *testing.T, folderDir, 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"+
+			"Content-Type: text/plain; charset=utf-8\r\n"+
+			"\r\n"+
+			"%s\r\n",
+		subject, deliveredAt.Format(time.RFC1123Z), key, body,
+	)
+	path := filepath.Join(folderDir, "new", key)
+	if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
+		t.Fatalf("write message: %v", err)
+	}
+	if err := os.Chtimes(path, deliveredAt, deliveredAt); err != nil {
+		t.Fatalf("chtimes: %v", err)
+	}
+}
+
+func maildirAccount(root string) *config.Account {
+	return &config.Account{
+		ID:          "maildir-acct",
+		Protocol:    "maildir",
+		MaildirPath: root,
+	}
+}
+
+func TestFetcherFetchFoldersMaildir(t *testing.T) {
+	root := makeMaildirRoot(t, ".Sent", ".Archive")
+	acct := maildirAccount(root)
+
+	folders, err := FetchFolders(acct)
+	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("FetchFolders missing %q; got %v", want, names)
+		}
+	}
+}
+
+func TestFetcherFetchMailboxEmailsMaildir(t *testing.T) {
+	root := makeMaildirRoot(t)
+	acct := maildirAccount(root)
+
+	dropNewMessage(t, root, "1700000000.M1.host", "Hello", "body one", time.Unix(1700000000, 0))
+	dropNewMessage(t, root, "1700000100.M1.host", "Second", "body two", time.Unix(1700000100, 0))
+
+	emails, err := FetchMailboxEmails(acct, "INBOX", 10, 0)
+	if err != nil {
+		t.Fatalf("FetchMailboxEmails: %v", err)
+	}
+	if len(emails) != 2 {
+		t.Fatalf("expected 2 emails, got %d", len(emails))
+	}
+
+	// Newest-first ordering β€” Second was delivered later.
+	if emails[0].Subject != "Second" {
+		t.Errorf("expected first email subject %q, got %q", "Second", emails[0].Subject)
+	}
+	if emails[0].AccountID != acct.ID {
+		t.Errorf("AccountID not propagated: got %q", emails[0].AccountID)
+	}
+	if emails[0].From == "" {
+		t.Errorf("From not parsed")
+	}
+}
+
+func TestFetcherFetchEmailBodyMaildir(t *testing.T) {
+	root := makeMaildirRoot(t)
+	acct := maildirAccount(root)
+
+	dropNewMessage(t, root, "1700000200.M1.host", "Body Test", "the body contents", time.Unix(1700000200, 0))
+
+	emails, err := FetchMailboxEmails(acct, "INBOX", 10, 0)
+	if err != nil {
+		t.Fatalf("FetchMailboxEmails: %v", err)
+	}
+	if len(emails) != 1 {
+		t.Fatalf("expected 1 email, got %d", len(emails))
+	}
+
+	body, _, _, err := FetchEmailBodyFromMailbox(acct, "INBOX", emails[0].UID)
+	if err != nil {
+		t.Fatalf("FetchEmailBodyFromMailbox: %v", err)
+	}
+	if !strings.Contains(body, "the body contents") {
+		t.Errorf("body missing expected text; got %q", body)
+	}
+}
+
+func TestFetcherMarkAsReadMaildir(t *testing.T) {
+	root := makeMaildirRoot(t)
+	acct := maildirAccount(root)
+
+	dropNewMessage(t, root, "1700000300.M1.host", "Mark Me", "x", time.Unix(1700000300, 0))
+
+	// First fetch promotes new/ β†’ cur/ and returns an unread message.
+	emails, err := FetchMailboxEmails(acct, "INBOX", 10, 0)
+	if err != nil {
+		t.Fatalf("first FetchMailboxEmails: %v", err)
+	}
+	if len(emails) != 1 || emails[0].IsRead {
+		t.Fatalf("expected one unread email, got %+v", emails)
+	}
+
+	if err := MarkEmailAsReadInMailbox(acct, "INBOX", emails[0].UID); err != nil {
+		t.Fatalf("MarkEmailAsReadInMailbox: %v", err)
+	}
+
+	// Verify the on-disk filename now carries the Seen flag suffix.
+	curDir := filepath.Join(root, "cur")
+	entries, err := os.ReadDir(curDir)
+	if err != nil {
+		t.Fatalf("read cur/: %v", err)
+	}
+	suffix := seenSuffix()
+	found := false
+	for _, e := range entries {
+		if strings.HasSuffix(e.Name(), suffix) {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Errorf("expected a cur/ entry with suffix %q; got %v", suffix, entries)
+	}
+
+	// Re-fetch and confirm IsRead is true now.
+	emails2, err := FetchMailboxEmails(acct, "INBOX", 10, 0)
+	if err != nil {
+		t.Fatalf("second FetchMailboxEmails: %v", err)
+	}
+	if len(emails2) != 1 || !emails2[0].IsRead {
+		t.Fatalf("expected one read email after mark, got %+v", emails2)
+	}
+}
+
+func TestFetcherIMAPPathUnaffected(t *testing.T) {
+	// An account with Protocol="" (IMAP default) and no IMAP server should
+	// still fail with the original IMAP error, proving dispatch only fires
+	// for maildir.
+	acct := &config.Account{ID: "x", Protocol: ""}
+	_, err := FetchFolders(acct)
+	if err == nil {
+		t.Fatal("expected error for empty IMAP server")
+	}
+	if !strings.Contains(err.Error(), "unsupported service_provider") {
+		t.Errorf("expected IMAP-path error, got %v", err)
+	}
+}

fetcher/search.go πŸ”—

@@ -1,6 +1,7 @@
 package fetcher
 
 import (
+	"context"
 	"fmt"
 	"sort"
 
@@ -11,6 +12,19 @@ import (
 
 // SearchMailbox searches a mailbox server-side and fetches matching envelopes.
 func SearchMailbox(account *config.Account, folder string, query backend.SearchQuery) ([]Email, error) {
+	if hasBackendProvider(account) {
+		p, err := newBackendProvider(account)
+		if err != nil {
+			return nil, err
+		}
+		defer p.Close() //nolint:errcheck
+		emails, err := p.Search(context.Background(), folder, query)
+		if err != nil {
+			return nil, err
+		}
+		return backendEmailsToFetcher(emails), nil
+	}
+
 	c, err := connect(account)
 	if err != nil {
 		return nil, err