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)