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.
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>
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(-)
@@ -213,6 +213,7 @@ type Folder struct {
Name string
Delimiter string
Attributes []string
+ Unread uint32
}
// OutgoingEmail contains everything needed to send an email.
@@ -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
@@ -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,
@@ -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")
+ }
+}
@@ -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{},
}
@@ -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
+}
@@ -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
@@ -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()
@@ -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)
+ }
+}
@@ -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