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