1// Package maildir implements the backend.Provider interface for local
2// Maildir mailboxes (the `mutt -f Maildir` style). It is read/edit only —
3// there is no SMTP transport, so SendEmail returns ErrNotSupported.
4//
5// Folder layout follows Maildir++:
6// - The configured root path is "INBOX".
7// - Sibling directories prefixed with "." (e.g. ".Sent", ".Archive") are
8// additional folders. Inner dots map to a "/" hierarchy.
9package maildir
10
11import (
12 "context"
13 "errors"
14 "fmt"
15 "io"
16 "mime"
17 "net/mail"
18 "os"
19 "path/filepath"
20 "regexp"
21 "sort"
22 "strings"
23 "time"
24
25 emaildir "github.com/emersion/go-maildir"
26 "github.com/emersion/go-message"
27 gomail "github.com/emersion/go-message/mail"
28
29 "github.com/floatpane/matcha/backend"
30 "github.com/floatpane/matcha/config"
31)
32
33const inboxFolder = "INBOX"
34
35var messageIDRE = regexp.MustCompile(`<[^>]+>`)
36
37func init() {
38 backend.RegisterBackend("maildir", func(account *config.Account) (backend.Provider, error) {
39 return New(account)
40 })
41}
42
43// Provider implements backend.Provider against a local Maildir tree.
44// Two on-disk layouts are supported:
45// - Maildir++ (dovecot style): the root itself is INBOX (has cur/new/tmp),
46// and subfolders are sibling directories prefixed with "." (e.g. ".Sent").
47// - Nested (mbsync/isync/fastmail style): the root contains one directory
48// per folder, each holding its own cur/new/tmp. INBOX is the child
49// directory named "INBOX".
50//
51// The layout is auto-detected at New() time by probing for `<root>/cur`.
52type Provider struct {
53 account *config.Account
54 root string
55 nested bool
56}
57
58// New creates a new Maildir provider for the given account.
59func New(account *config.Account) (*Provider, error) {
60 root := strings.TrimSpace(account.MaildirPath)
61 if root == "" {
62 return nil, fmt.Errorf("maildir path not configured")
63 }
64
65 root = os.ExpandEnv(root)
66 if strings.HasPrefix(root, "~/") {
67 home, err := os.UserHomeDir()
68 if err == nil {
69 root = filepath.Join(home, root[2:])
70 }
71 }
72 root = filepath.Clean(root)
73
74 info, err := os.Stat(root)
75 if err != nil {
76 return nil, fmt.Errorf("maildir path %q: %w", root, err)
77 }
78 if !info.IsDir() {
79 return nil, fmt.Errorf("maildir path %q is not a directory", root)
80 }
81
82 nested := false
83 if _, err := os.Stat(filepath.Join(root, "cur")); err != nil {
84 nested = true
85 }
86
87 return &Provider{account: account, root: root, nested: nested}, nil
88}
89
90// dirForFolder resolves a logical folder name to the on-disk Maildir directory.
91// Maildir++ layout: "" and "INBOX" map to the root; other names become
92// ".Sub.Folder" siblings. Nested layout: every folder is a child directory
93// named verbatim, with "/" preserved as a path separator.
94func (p *Provider) dirForFolder(folder string) emaildir.Dir {
95 if p.nested {
96 if folder == "" {
97 folder = inboxFolder
98 }
99 return emaildir.Dir(filepath.Join(p.root, filepath.FromSlash(folder)))
100 }
101 if folder == "" || strings.EqualFold(folder, inboxFolder) {
102 return emaildir.Dir(p.root)
103 }
104 subdir := "." + strings.ReplaceAll(folder, "/", ".")
105 return emaildir.Dir(filepath.Join(p.root, subdir))
106}
107
108// archiveDir returns the on-disk path of the Archive folder for the active
109// layout (".Archive" under Maildir++, "Archive" under nested).
110func (p *Provider) archiveDir() string {
111 if p.nested {
112 return filepath.Join(p.root, "Archive")
113 }
114 return filepath.Join(p.root, ".Archive")
115}
116
117// FetchFolders returns INBOX plus any subfolders found at the root, using
118// whichever on-disk layout the provider detected.
119func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
120 entries, err := os.ReadDir(p.root)
121 if err != nil {
122 return nil, fmt.Errorf("maildir read root: %w", err)
123 }
124
125 if p.nested {
126 var folders []backend.Folder
127 seenInbox := false
128 for _, entry := range entries {
129 if !entry.IsDir() {
130 continue
131 }
132 name := entry.Name()
133 if name == "." || name == ".." {
134 continue
135 }
136 if _, err := os.Stat(filepath.Join(p.root, name, "cur")); err != nil {
137 continue
138 }
139 if strings.EqualFold(name, inboxFolder) {
140 seenInbox = true
141 folders = append([]backend.Folder{{Name: inboxFolder, Delimiter: "/"}}, folders...)
142 continue
143 }
144 folders = append(folders, backend.Folder{Name: name, Delimiter: "/"})
145 }
146 if !seenInbox {
147 folders = append([]backend.Folder{{Name: inboxFolder, Delimiter: "/"}}, folders...)
148 }
149 return folders, nil
150 }
151
152 folders := []backend.Folder{{Name: inboxFolder, Delimiter: "/"}}
153 for _, entry := range entries {
154 if !entry.IsDir() {
155 continue
156 }
157 name := entry.Name()
158 if !strings.HasPrefix(name, ".") || name == "." || name == ".." {
159 continue
160 }
161 if _, err := os.Stat(filepath.Join(p.root, name, "cur")); err != nil {
162 continue
163 }
164 logical := strings.ReplaceAll(strings.TrimPrefix(name, "."), ".", "/")
165 folders = append(folders, backend.Folder{Name: logical, Delimiter: "/"})
166 }
167 return folders, nil
168}
169
170// FetchEmails returns messages from the folder, newest first. Any messages
171// sitting in new/ are first promoted to cur/ (same semantics as mutt opening
172// a Maildir): they remain unread (no Seen flag) but become trackable.
173func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset uint32) ([]backend.Email, error) {
174 dir := p.dirForFolder(folder)
175 if _, err := dir.Unseen(); err != nil && !os.IsNotExist(err) {
176 return nil, fmt.Errorf("maildir promote new/: %w", err)
177 }
178 msgs, err := dir.Messages()
179 if err != nil {
180 return nil, fmt.Errorf("maildir messages: %w", err)
181 }
182
183 type entry struct {
184 msg *emaildir.Message
185 modTime time.Time
186 }
187 entries := make([]entry, 0, len(msgs))
188 for _, m := range msgs {
189 info, err := os.Stat(m.Filename())
190 if err != nil {
191 continue
192 }
193 entries = append(entries, entry{msg: m, modTime: info.ModTime()})
194 }
195 sort.Slice(entries, func(i, j int) bool {
196 return entries[i].modTime.After(entries[j].modTime)
197 })
198
199 if int(offset) >= len(entries) {
200 return []backend.Email{}, nil
201 }
202 end := int(offset) + int(limit)
203 if end > len(entries) || limit == 0 {
204 end = len(entries)
205 }
206 entries = entries[offset:end]
207
208 emails := make([]backend.Email, 0, len(entries))
209 for _, e := range entries {
210 email, err := p.readHeader(e.msg)
211 if err != nil {
212 continue
213 }
214 emails = append(emails, email)
215 }
216 return emails, nil
217}
218
219// readHeader opens the message file and parses just enough to fill an Email.
220func (p *Provider) readHeader(msg *emaildir.Message) (backend.Email, error) {
221 rc, err := msg.Open()
222 if err != nil {
223 return backend.Email{}, err
224 }
225 defer rc.Close() //nolint:errcheck
226
227 entity, err := message.Read(rc)
228 if err != nil && entity == nil {
229 return backend.Email{}, err
230 }
231
232 email := headerToEmail(&entity.Header, msg.Key(), p.account.ID)
233
234 for _, fl := range msg.Flags() {
235 if fl == emaildir.FlagSeen {
236 email.IsRead = true
237 break
238 }
239 }
240
241 return email, nil
242}
243
244// FetchEmailBody returns the chosen body, MIME type, and attachments.
245func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, string, []backend.Attachment, error) {
246 msg, err := p.findMessageByUID(folder, uid)
247 if err != nil {
248 return "", "", nil, err
249 }
250 rc, err := msg.Open()
251 if err != nil {
252 return "", "", nil, fmt.Errorf("maildir open: %w", err)
253 }
254 defer rc.Close() //nolint:errcheck
255
256 return parseMessageBody(rc)
257}
258
259// FetchAttachment returns the raw bytes of an attachment part.
260func (p *Provider) FetchAttachment(_ context.Context, folder string, uid uint32, partID, _ string) ([]byte, error) {
261 msg, err := p.findMessageByUID(folder, uid)
262 if err != nil {
263 return nil, err
264 }
265 rc, err := msg.Open()
266 if err != nil {
267 return nil, fmt.Errorf("maildir open: %w", err)
268 }
269 defer rc.Close() //nolint:errcheck
270
271 return findAttachmentData(rc, partID)
272}
273
274// MarkAsRead sets the Seen flag while preserving the others.
275func (p *Provider) MarkAsRead(_ context.Context, folder string, uid uint32) error {
276 msg, err := p.findMessageByUID(folder, uid)
277 if err != nil {
278 return err
279 }
280 flags := msg.Flags()
281 for _, fl := range flags {
282 if fl == emaildir.FlagSeen {
283 return nil
284 }
285 }
286 return msg.SetFlags(append(flags, emaildir.FlagSeen))
287}
288
289// MarkAsUnread removes the Seen flag while preserving the others.
290func (p *Provider) MarkAsUnread(_ context.Context, folder string, uid uint32) error {
291 msg, err := p.findMessageByUID(folder, uid)
292 if err != nil {
293 return err
294 }
295 flags := msg.Flags()
296 filtered := flags[:0]
297 for _, fl := range flags {
298 if fl != emaildir.FlagSeen {
299 filtered = append(filtered, fl)
300 }
301 }
302 if len(filtered) == len(flags) {
303 return nil // already unread
304 }
305 return msg.SetFlags(filtered)
306}
307
308// DeleteEmail removes the message file from disk.
309func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) error {
310 msg, err := p.findMessageByUID(folder, uid)
311 if err != nil {
312 return err
313 }
314 return msg.Remove()
315}
316
317// ArchiveEmail moves the message to the Archive subfolder if one exists.
318func (p *Provider) ArchiveEmail(ctx context.Context, folder string, uid uint32) error {
319 if _, err := os.Stat(filepath.Join(p.archiveDir(), "cur")); err != nil {
320 return backend.ErrNotSupported
321 }
322 return p.MoveEmail(ctx, uid, folder, "Archive")
323}
324
325// MoveEmail relocates a message between two Maildir folders.
326func (p *Provider) MoveEmail(_ context.Context, uid uint32, srcFolder, dstFolder string) error {
327 msg, err := p.findMessageByUID(srcFolder, uid)
328 if err != nil {
329 return err
330 }
331 dst := p.dirForFolder(dstFolder)
332 return msg.MoveTo(dst)
333}
334
335// DeleteEmails removes the listed messages from the folder.
336func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
337 for _, uid := range uids {
338 if err := p.DeleteEmail(ctx, folder, uid); err != nil {
339 return err
340 }
341 }
342 return nil
343}
344
345// ArchiveEmails archives the listed messages.
346func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error {
347 if _, err := os.Stat(filepath.Join(p.archiveDir(), "cur")); err != nil {
348 return backend.ErrNotSupported
349 }
350 for _, uid := range uids {
351 if err := p.MoveEmail(ctx, uid, folder, "Archive"); err != nil {
352 return err
353 }
354 }
355 return nil
356}
357
358// MoveEmails relocates the listed messages between folders.
359func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error {
360 for _, uid := range uids {
361 if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil {
362 return err
363 }
364 }
365 return nil
366}
367
368// SendEmail is not supported by the Maildir backend.
369func (p *Provider) SendEmail(_ context.Context, _ *backend.OutgoingEmail) error {
370 return backend.ErrNotSupported
371}
372
373// Search filters messages in a folder by the given query, parsing headers
374// locally. Body matching scans the decoded body parts.
375func (p *Provider) Search(_ context.Context, folder string, query backend.SearchQuery) ([]backend.Email, error) {
376 dir := p.dirForFolder(folder)
377 if _, err := dir.Unseen(); err != nil && !os.IsNotExist(err) {
378 return nil, fmt.Errorf("maildir promote new/: %w", err)
379 }
380 msgs, err := dir.Messages()
381 if err != nil {
382 return nil, fmt.Errorf("maildir messages: %w", err)
383 }
384
385 results := make([]backend.Email, 0)
386 for _, m := range msgs {
387 if query.Limit > 0 && uint32(len(results)) >= query.Limit {
388 break
389 }
390 email, body, err := p.matchOpen(m)
391 if err != nil {
392 continue
393 }
394 if !matchesQuery(email, body, query) {
395 continue
396 }
397 results = append(results, email)
398 }
399 return results, nil
400}
401
402// matchOpen returns the email metadata and a plain-text body slice for search.
403func (p *Provider) matchOpen(msg *emaildir.Message) (backend.Email, string, error) {
404 rc, err := msg.Open()
405 if err != nil {
406 return backend.Email{}, "", err
407 }
408 defer rc.Close() //nolint:errcheck
409
410 entity, err := message.Read(rc)
411 if err != nil && entity == nil {
412 return backend.Email{}, "", err
413 }
414 email := headerToEmail(&entity.Header, msg.Key(), p.account.ID)
415
416 for _, fl := range msg.Flags() {
417 if fl == emaildir.FlagSeen {
418 email.IsRead = true
419 break
420 }
421 }
422
423 // Lightweight body read: only needed if query asks for it.
424 var body string
425 if b, err := io.ReadAll(entity.Body); err == nil {
426 body = string(b)
427 }
428
429 return email, body, nil
430}
431
432// matchesQuery applies the parsed search filters to an email + body.
433func matchesQuery(email backend.Email, body string, query backend.SearchQuery) bool {
434 containsCI := func(haystack, needle string) bool {
435 if needle == "" {
436 return true
437 }
438 return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
439 }
440 if !containsCI(email.From, query.From) {
441 return false
442 }
443 if query.To != "" {
444 match := false
445 for _, addr := range email.To {
446 if containsCI(addr, query.To) {
447 match = true
448 break
449 }
450 }
451 if !match {
452 return false
453 }
454 }
455 if !containsCI(email.Subject, query.Subject) {
456 return false
457 }
458 if !containsCI(body, query.Body) {
459 return false
460 }
461 if !query.Since.IsZero() && email.Date.Before(query.Since) {
462 return false
463 }
464 if !query.Before.IsZero() && email.Date.After(query.Before) {
465 return false
466 }
467 return true
468}
469
470// Watch is not supported. Future: fsnotify on new/ to emit NotifyNewEmail.
471func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
472 return nil, nil, backend.ErrNotSupported
473}
474
475// Close releases any provider-held resources. None for Maildir.
476func (p *Provider) Close() error { return nil }
477
478// Capabilities reports what the Maildir backend can do.
479func (p *Provider) Capabilities() backend.Capabilities {
480 _, hasArchive := os.Stat(filepath.Join(p.archiveDir(), "cur"))
481 return backend.Capabilities{
482 CanSend: false,
483 CanMove: true,
484 CanArchive: hasArchive == nil,
485 CanPush: false,
486 CanSearchServer: true,
487 CanFetchFolders: true,
488 SupportsSMIME: false,
489 }
490}
491
492// findMessageByUID locates a Maildir message by its UID hash.
493func (p *Provider) findMessageByUID(folder string, uid uint32) (*emaildir.Message, error) {
494 dir := p.dirForFolder(folder)
495 msgs, err := dir.Messages()
496 if err != nil {
497 return nil, fmt.Errorf("maildir messages: %w", err)
498 }
499 for _, m := range msgs {
500 if hashUID(m.Key()) == uid {
501 return m, nil
502 }
503 }
504 return nil, fmt.Errorf("maildir: message with UID %d not found in %q", uid, folder)
505}
506
507// hashUID converts a Maildir base filename (the part before the flag suffix)
508// into a stable uint32 identifier. Same FNV-style hash as the POP3 backend.
509func hashUID(key string) uint32 {
510 var hash uint32
511 for _, c := range key {
512 hash = hash*31 + uint32(c)
513 }
514 if hash == 0 {
515 hash = 1
516 }
517 return hash
518}
519
520// headerToEmail converts a parsed message Header into a backend.Email.
521func headerToEmail(header *message.Header, key, accountID string) backend.Email {
522 from := header.Get("From")
523 subject := header.Get("Subject")
524 dateStr := header.Get("Date")
525 messageID := header.Get("Message-ID")
526 inReplyTo := firstMessageID(header.Get("In-Reply-To"))
527 references := messageIDList(header.Get("References"))
528
529 var to []string
530 if toHeader := header.Get("To"); toHeader != "" {
531 if addrs, err := mail.ParseAddressList(toHeader); err == nil {
532 for _, addr := range addrs {
533 to = append(to, addr.Address)
534 }
535 }
536 }
537
538 var replyTo []string
539 if replyToHeader := header.Get("Reply-To"); replyToHeader != "" {
540 if addrs, err := mail.ParseAddressList(replyToHeader); err == nil {
541 for _, addr := range addrs {
542 replyTo = append(replyTo, addr.Address)
543 }
544 }
545 }
546
547 var date time.Time
548 if dateStr != "" {
549 if parsed, err := mail.ParseDate(dateStr); err == nil {
550 date = parsed
551 }
552 }
553
554 dec := new(mime.WordDecoder)
555 if decoded, err := dec.DecodeHeader(subject); err == nil {
556 subject = decoded
557 }
558 if decoded, err := dec.DecodeHeader(from); err == nil {
559 from = decoded
560 }
561
562 return backend.Email{
563 UID: hashUID(key),
564 From: from,
565 To: to,
566 ReplyTo: replyTo,
567 Subject: subject,
568 Date: date,
569 MessageID: messageID,
570 InReplyTo: inReplyTo,
571 References: references,
572 AccountID: accountID,
573 }
574}
575
576func firstMessageID(value string) string {
577 ids := messageIDList(value)
578 if len(ids) == 0 {
579 return ""
580 }
581 return ids[0]
582}
583
584func messageIDList(value string) []string {
585 matches := messageIDRE.FindAllString(value, -1)
586 if len(matches) == 0 {
587 return strings.Fields(value)
588 }
589 return matches
590}
591
592// parseMessageBody extracts the body text and attachments from a raw message.
593// Mirrors the POP3 backend's logic since the on-wire representation is the
594// same RFC822 stream.
595func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) {
596 mr, err := gomail.CreateReader(r)
597 if err != nil {
598 body, rerr := io.ReadAll(r)
599 if rerr != nil {
600 return "", "", nil, rerr
601 }
602 return string(body), "", nil, nil
603 }
604
605 var bodyText string
606 var htmlBody string
607 var attachments []backend.Attachment
608 partIdx := 0
609
610 for {
611 part, err := mr.NextPart()
612 if errors.Is(err, io.EOF) {
613 break
614 }
615 if err != nil {
616 break
617 }
618 partIdx++
619
620 contentType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
621 disposition, dParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
622
623 data, readErr := io.ReadAll(part.Body)
624 if readErr != nil {
625 continue
626 }
627
628 switch {
629 case disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")):
630 filename := dParams["filename"]
631 if filename == "" {
632 _, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
633 filename = cp["name"]
634 }
635 att := backend.Attachment{
636 Filename: filename,
637 PartID: fmt.Sprintf("%d", partIdx),
638 Data: data,
639 MIMEType: contentType,
640 Inline: disposition == "inline",
641 }
642 if cid := part.Header.Get("Content-ID"); cid != "" {
643 att.ContentID = strings.Trim(cid, "<>")
644 }
645 attachments = append(attachments, att)
646 case contentType == "text/html":
647 htmlBody = string(data)
648 case contentType == "text/plain" && bodyText == "":
649 bodyText = string(data)
650 }
651 }
652
653 if htmlBody != "" {
654 return htmlBody, "text/html", attachments, nil
655 }
656 return bodyText, "text/plain", attachments, nil
657}
658
659// findAttachmentData walks a raw message to find attachment data by partID.
660func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) {
661 mr, err := gomail.CreateReader(r)
662 if err != nil {
663 return nil, fmt.Errorf("not a multipart message")
664 }
665
666 partIdx := 0
667 for {
668 part, err := mr.NextPart()
669 if errors.Is(err, io.EOF) {
670 break
671 }
672 if err != nil {
673 break
674 }
675 partIdx++
676
677 if fmt.Sprintf("%d", partIdx) == targetPartID {
678 return io.ReadAll(part.Body)
679 }
680 }
681
682 return nil, fmt.Errorf("maildir: attachment part %s not found", targetPartID)
683}
684
685// Verify interface compliance at compile time.
686var _ backend.Provider = (*Provider)(nil)