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)