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)