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// MarkAsUnread removes the Seen flag while preserving the others.
233func (p *Provider) MarkAsUnread(_ context.Context, folder string, uid uint32) error {
234	msg, err := p.findMessageByUID(folder, uid)
235	if err != nil {
236		return err
237	}
238	flags := msg.Flags()
239	filtered := flags[:0]
240	for _, fl := range flags {
241		if fl != emaildir.FlagSeen {
242			filtered = append(filtered, fl)
243		}
244	}
245	if len(filtered) == len(flags) {
246		return nil // already unread
247	}
248	return msg.SetFlags(filtered)
249}
250
251// DeleteEmail removes the message file from disk.
252func (p *Provider) DeleteEmail(_ context.Context, folder string, uid uint32) error {
253	msg, err := p.findMessageByUID(folder, uid)
254	if err != nil {
255		return err
256	}
257	return msg.Remove()
258}
259
260// ArchiveEmail moves the message to the ".Archive" subfolder if one exists.
261func (p *Provider) ArchiveEmail(ctx context.Context, folder string, uid uint32) error {
262	if _, err := os.Stat(filepath.Join(p.root, ".Archive", "cur")); err != nil {
263		return backend.ErrNotSupported
264	}
265	return p.MoveEmail(ctx, uid, folder, "Archive")
266}
267
268// MoveEmail relocates a message between two Maildir folders.
269func (p *Provider) MoveEmail(_ context.Context, uid uint32, srcFolder, dstFolder string) error {
270	msg, err := p.findMessageByUID(srcFolder, uid)
271	if err != nil {
272		return err
273	}
274	dst := p.dirForFolder(dstFolder)
275	return msg.MoveTo(dst)
276}
277
278// DeleteEmails removes the listed messages from the folder.
279func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
280	for _, uid := range uids {
281		if err := p.DeleteEmail(ctx, folder, uid); err != nil {
282			return err
283		}
284	}
285	return nil
286}
287
288// ArchiveEmails archives the listed messages.
289func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error {
290	if _, err := os.Stat(filepath.Join(p.root, ".Archive", "cur")); err != nil {
291		return backend.ErrNotSupported
292	}
293	for _, uid := range uids {
294		if err := p.MoveEmail(ctx, uid, folder, "Archive"); err != nil {
295			return err
296		}
297	}
298	return nil
299}
300
301// MoveEmails relocates the listed messages between folders.
302func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error {
303	for _, uid := range uids {
304		if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil {
305			return err
306		}
307	}
308	return nil
309}
310
311// SendEmail is not supported by the Maildir backend.
312func (p *Provider) SendEmail(_ context.Context, _ *backend.OutgoingEmail) error {
313	return backend.ErrNotSupported
314}
315
316// Search filters messages in a folder by the given query, parsing headers
317// locally. Body matching scans the decoded body parts.
318func (p *Provider) Search(_ context.Context, folder string, query backend.SearchQuery) ([]backend.Email, error) {
319	dir := p.dirForFolder(folder)
320	if _, err := dir.Unseen(); err != nil && !os.IsNotExist(err) {
321		return nil, fmt.Errorf("maildir promote new/: %w", err)
322	}
323	msgs, err := dir.Messages()
324	if err != nil {
325		return nil, fmt.Errorf("maildir messages: %w", err)
326	}
327
328	results := make([]backend.Email, 0)
329	for _, m := range msgs {
330		if query.Limit > 0 && uint32(len(results)) >= query.Limit {
331			break
332		}
333		email, body, err := p.matchOpen(m)
334		if err != nil {
335			continue
336		}
337		if !matchesQuery(email, body, query) {
338			continue
339		}
340		results = append(results, email)
341	}
342	return results, nil
343}
344
345// matchOpen returns the email metadata and a plain-text body slice for search.
346func (p *Provider) matchOpen(msg *emaildir.Message) (backend.Email, string, error) {
347	rc, err := msg.Open()
348	if err != nil {
349		return backend.Email{}, "", err
350	}
351	defer rc.Close()
352
353	entity, err := message.Read(rc)
354	if err != nil && entity == nil {
355		return backend.Email{}, "", err
356	}
357	email := headerToEmail(&entity.Header, msg.Key(), p.account.ID)
358
359	for _, fl := range msg.Flags() {
360		if fl == emaildir.FlagSeen {
361			email.IsRead = true
362			break
363		}
364	}
365
366	// Lightweight body read: only needed if query asks for it.
367	var body string
368	if b, err := io.ReadAll(entity.Body); err == nil {
369		body = string(b)
370	}
371
372	return email, body, nil
373}
374
375// matchesQuery applies the parsed search filters to an email + body.
376func matchesQuery(email backend.Email, body string, query backend.SearchQuery) bool {
377	containsCI := func(haystack, needle string) bool {
378		if needle == "" {
379			return true
380		}
381		return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
382	}
383	if !containsCI(email.From, query.From) {
384		return false
385	}
386	if query.To != "" {
387		match := false
388		for _, addr := range email.To {
389			if containsCI(addr, query.To) {
390				match = true
391				break
392			}
393		}
394		if !match {
395			return false
396		}
397	}
398	if !containsCI(email.Subject, query.Subject) {
399		return false
400	}
401	if !containsCI(body, query.Body) {
402		return false
403	}
404	if !query.Since.IsZero() && email.Date.Before(query.Since) {
405		return false
406	}
407	if !query.Before.IsZero() && email.Date.After(query.Before) {
408		return false
409	}
410	return true
411}
412
413// Watch is not supported. Future: fsnotify on new/ to emit NotifyNewEmail.
414func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
415	return nil, nil, backend.ErrNotSupported
416}
417
418// Close releases any provider-held resources. None for Maildir.
419func (p *Provider) Close() error { return nil }
420
421// Capabilities reports what the Maildir backend can do.
422func (p *Provider) Capabilities() backend.Capabilities {
423	_, hasArchive := os.Stat(filepath.Join(p.root, ".Archive", "cur"))
424	return backend.Capabilities{
425		CanSend:         false,
426		CanMove:         true,
427		CanArchive:      hasArchive == nil,
428		CanPush:         false,
429		CanSearchServer: true,
430		CanFetchFolders: true,
431		SupportsSMIME:   false,
432	}
433}
434
435// findMessageByUID locates a Maildir message by its UID hash.
436func (p *Provider) findMessageByUID(folder string, uid uint32) (*emaildir.Message, error) {
437	dir := p.dirForFolder(folder)
438	msgs, err := dir.Messages()
439	if err != nil {
440		return nil, fmt.Errorf("maildir messages: %w", err)
441	}
442	for _, m := range msgs {
443		if hashUID(m.Key()) == uid {
444			return m, nil
445		}
446	}
447	return nil, fmt.Errorf("maildir: message with UID %d not found in %q", uid, folder)
448}
449
450// hashUID converts a Maildir base filename (the part before the flag suffix)
451// into a stable uint32 identifier. Same FNV-style hash as the POP3 backend.
452func hashUID(key string) uint32 {
453	var hash uint32
454	for _, c := range key {
455		hash = hash*31 + uint32(c)
456	}
457	if hash == 0 {
458		hash = 1
459	}
460	return hash
461}
462
463// headerToEmail converts a parsed message Header into a backend.Email.
464func headerToEmail(header *message.Header, key, accountID string) backend.Email {
465	from := header.Get("From")
466	subject := header.Get("Subject")
467	dateStr := header.Get("Date")
468	messageID := header.Get("Message-ID")
469	inReplyTo := firstMessageID(header.Get("In-Reply-To"))
470	references := messageIDList(header.Get("References"))
471
472	var to []string
473	if toHeader := header.Get("To"); toHeader != "" {
474		if addrs, err := mail.ParseAddressList(toHeader); err == nil {
475			for _, addr := range addrs {
476				to = append(to, addr.Address)
477			}
478		}
479	}
480
481	var replyTo []string
482	if replyToHeader := header.Get("Reply-To"); replyToHeader != "" {
483		if addrs, err := mail.ParseAddressList(replyToHeader); err == nil {
484			for _, addr := range addrs {
485				replyTo = append(replyTo, addr.Address)
486			}
487		}
488	}
489
490	var date time.Time
491	if dateStr != "" {
492		if parsed, err := mail.ParseDate(dateStr); err == nil {
493			date = parsed
494		}
495	}
496
497	dec := new(mime.WordDecoder)
498	if decoded, err := dec.DecodeHeader(subject); err == nil {
499		subject = decoded
500	}
501	if decoded, err := dec.DecodeHeader(from); err == nil {
502		from = decoded
503	}
504
505	return backend.Email{
506		UID:        hashUID(key),
507		From:       from,
508		To:         to,
509		ReplyTo:    replyTo,
510		Subject:    subject,
511		Date:       date,
512		MessageID:  messageID,
513		InReplyTo:  inReplyTo,
514		References: references,
515		AccountID:  accountID,
516	}
517}
518
519func firstMessageID(value string) string {
520	ids := messageIDList(value)
521	if len(ids) == 0 {
522		return ""
523	}
524	return ids[0]
525}
526
527func messageIDList(value string) []string {
528	matches := messageIDRE.FindAllString(value, -1)
529	if len(matches) == 0 {
530		return strings.Fields(value)
531	}
532	return matches
533}
534
535// parseMessageBody extracts the body text and attachments from a raw message.
536// Mirrors the POP3 backend's logic since the on-wire representation is the
537// same RFC822 stream.
538func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) {
539	mr, err := gomail.CreateReader(r)
540	if err != nil {
541		body, rerr := io.ReadAll(r)
542		if rerr != nil {
543			return "", "", nil, rerr
544		}
545		return string(body), "", nil, nil
546	}
547
548	var bodyText string
549	var htmlBody string
550	var attachments []backend.Attachment
551	partIdx := 0
552
553	for {
554		part, err := mr.NextPart()
555		if err == io.EOF {
556			break
557		}
558		if err != nil {
559			break
560		}
561		partIdx++
562
563		contentType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
564		disposition, dParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
565
566		data, readErr := io.ReadAll(part.Body)
567		if readErr != nil {
568			continue
569		}
570
571		if disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")) {
572			filename := dParams["filename"]
573			if filename == "" {
574				_, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
575				filename = cp["name"]
576			}
577			att := backend.Attachment{
578				Filename: filename,
579				PartID:   fmt.Sprintf("%d", partIdx),
580				Data:     data,
581				MIMEType: contentType,
582				Inline:   disposition == "inline",
583			}
584			if cid := part.Header.Get("Content-ID"); cid != "" {
585				att.ContentID = strings.Trim(cid, "<>")
586			}
587			attachments = append(attachments, att)
588		} else if contentType == "text/html" {
589			htmlBody = string(data)
590		} else if contentType == "text/plain" && bodyText == "" {
591			bodyText = string(data)
592		}
593	}
594
595	if htmlBody != "" {
596		return htmlBody, "text/html", attachments, nil
597	}
598	return bodyText, "text/plain", attachments, nil
599}
600
601// findAttachmentData walks a raw message to find attachment data by partID.
602func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) {
603	mr, err := gomail.CreateReader(r)
604	if err != nil {
605		return nil, fmt.Errorf("not a multipart message")
606	}
607
608	partIdx := 0
609	for {
610		part, err := mr.NextPart()
611		if err == io.EOF {
612			break
613		}
614		if err != nil {
615			break
616		}
617		partIdx++
618
619		if fmt.Sprintf("%d", partIdx) == targetPartID {
620			return io.ReadAll(part.Body)
621		}
622	}
623
624	return nil, fmt.Errorf("maildir: attachment part %s not found", targetPartID)
625}
626
627// Verify interface compliance at compile time.
628var _ backend.Provider = (*Provider)(nil)