maildir.go

  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)