Detailed changes
@@ -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)
@@ -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")
+ }
+}
@@ -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"`
@@ -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
@@ -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=
@@ -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()
@@ -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).
@@ -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.
@@ -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,
}
}
}