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