1package fetcher
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/floatpane/matcha/config"
13)
14
15// End-to-end coverage for the maildir dispatch path in the fetcher package.
16// These tests exercise the public entry points main.go calls (FetchFolders,
17// FetchMailboxEmails, FetchEmailBodyFromMailbox, MarkEmailAsReadInMailbox)
18// against an on-disk Maildir, mirroring the real TUI flow without an IMAP
19// server.
20
21func seenSuffix() string {
22 if runtime.GOOS == "windows" {
23 return ";2,S"
24 }
25 return ":2,S"
26}
27
28func makeMaildirRoot(t *testing.T, subfolders ...string) string {
29 t.Helper()
30 root := t.TempDir()
31 for _, sub := range []string{"cur", "new", "tmp"} {
32 if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil {
33 t.Fatalf("mkdir %s: %v", sub, err)
34 }
35 }
36 for _, folder := range subfolders {
37 for _, sub := range []string{"cur", "new", "tmp"} {
38 if err := os.MkdirAll(filepath.Join(root, folder, sub), 0o755); err != nil {
39 t.Fatalf("mkdir subfolder %s/%s: %v", folder, sub, err)
40 }
41 }
42 }
43 return root
44}
45
46func dropNewMessage(t *testing.T, folderDir, key, subject, body string, deliveredAt time.Time) {
47 t.Helper()
48 contents := fmt.Sprintf(
49 "From: alice@example.com\r\n"+
50 "To: me@local\r\n"+
51 "Subject: %s\r\n"+
52 "Date: %s\r\n"+
53 "Message-ID: <%s@local>\r\n"+
54 "Content-Type: text/plain; charset=utf-8\r\n"+
55 "\r\n"+
56 "%s\r\n",
57 subject, deliveredAt.Format(time.RFC1123Z), key, body,
58 )
59 path := filepath.Join(folderDir, "new", key)
60 if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
61 t.Fatalf("write message: %v", err)
62 }
63 if err := os.Chtimes(path, deliveredAt, deliveredAt); err != nil {
64 t.Fatalf("chtimes: %v", err)
65 }
66}
67
68func maildirAccount(root string) *config.Account {
69 return &config.Account{
70 ID: "maildir-acct",
71 Protocol: "maildir",
72 MaildirPath: root,
73 }
74}
75
76func TestFetcherFetchFoldersMaildir(t *testing.T) {
77 root := makeMaildirRoot(t, ".Sent", ".Archive")
78 acct := maildirAccount(root)
79
80 folders, err := FetchFolders(acct)
81 if err != nil {
82 t.Fatalf("FetchFolders: %v", err)
83 }
84
85 names := make(map[string]bool, len(folders))
86 for _, f := range folders {
87 names[f.Name] = true
88 }
89
90 for _, want := range []string{"INBOX", "Sent", "Archive"} {
91 if !names[want] {
92 t.Errorf("FetchFolders missing %q; got %v", want, names)
93 }
94 }
95}
96
97func TestFetcherFetchMailboxEmailsMaildir(t *testing.T) {
98 root := makeMaildirRoot(t)
99 acct := maildirAccount(root)
100
101 dropNewMessage(t, root, "1700000000.M1.host", "Hello", "body one", time.Unix(1700000000, 0))
102 dropNewMessage(t, root, "1700000100.M1.host", "Second", "body two", time.Unix(1700000100, 0))
103
104 emails, err := FetchMailboxEmails(acct, "INBOX", 10, 0)
105 if err != nil {
106 t.Fatalf("FetchMailboxEmails: %v", err)
107 }
108 if len(emails) != 2 {
109 t.Fatalf("expected 2 emails, got %d", len(emails))
110 }
111
112 // Newest-first ordering — Second was delivered later.
113 if emails[0].Subject != "Second" {
114 t.Errorf("expected first email subject %q, got %q", "Second", emails[0].Subject)
115 }
116 if emails[0].AccountID != acct.ID {
117 t.Errorf("AccountID not propagated: got %q", emails[0].AccountID)
118 }
119 if emails[0].From == "" {
120 t.Errorf("From not parsed")
121 }
122}
123
124func TestFetcherFetchEmailBodyMaildir(t *testing.T) {
125 root := makeMaildirRoot(t)
126 acct := maildirAccount(root)
127
128 dropNewMessage(t, root, "1700000200.M1.host", "Body Test", "the body contents", time.Unix(1700000200, 0))
129
130 emails, err := FetchMailboxEmails(acct, "INBOX", 10, 0)
131 if err != nil {
132 t.Fatalf("FetchMailboxEmails: %v", err)
133 }
134 if len(emails) != 1 {
135 t.Fatalf("expected 1 email, got %d", len(emails))
136 }
137
138 body, _, _, err := FetchEmailBodyFromMailbox(acct, "INBOX", emails[0].UID)
139 if err != nil {
140 t.Fatalf("FetchEmailBodyFromMailbox: %v", err)
141 }
142 if !strings.Contains(body, "the body contents") {
143 t.Errorf("body missing expected text; got %q", body)
144 }
145}
146
147func TestFetcherMarkAsReadMaildir(t *testing.T) {
148 root := makeMaildirRoot(t)
149 acct := maildirAccount(root)
150
151 dropNewMessage(t, root, "1700000300.M1.host", "Mark Me", "x", time.Unix(1700000300, 0))
152
153 // First fetch promotes new/ → cur/ and returns an unread message.
154 emails, err := FetchMailboxEmails(acct, "INBOX", 10, 0)
155 if err != nil {
156 t.Fatalf("first FetchMailboxEmails: %v", err)
157 }
158 if len(emails) != 1 || emails[0].IsRead {
159 t.Fatalf("expected one unread email, got %+v", emails)
160 }
161
162 if err := MarkEmailAsReadInMailbox(acct, "INBOX", emails[0].UID); err != nil {
163 t.Fatalf("MarkEmailAsReadInMailbox: %v", err)
164 }
165
166 // Verify the on-disk filename now carries the Seen flag suffix.
167 curDir := filepath.Join(root, "cur")
168 entries, err := os.ReadDir(curDir)
169 if err != nil {
170 t.Fatalf("read cur/: %v", err)
171 }
172 suffix := seenSuffix()
173 found := false
174 for _, e := range entries {
175 if strings.HasSuffix(e.Name(), suffix) {
176 found = true
177 break
178 }
179 }
180 if !found {
181 t.Errorf("expected a cur/ entry with suffix %q; got %v", suffix, entries)
182 }
183
184 // Re-fetch and confirm IsRead is true now.
185 emails2, err := FetchMailboxEmails(acct, "INBOX", 10, 0)
186 if err != nil {
187 t.Fatalf("second FetchMailboxEmails: %v", err)
188 }
189 if len(emails2) != 1 || !emails2[0].IsRead {
190 t.Fatalf("expected one read email after mark, got %+v", emails2)
191 }
192}
193
194func TestFetcherIMAPPathUnaffected(t *testing.T) {
195 // An account with Protocol="" (IMAP default) and no IMAP server should
196 // still fail with the original IMAP error, proving dispatch only fires
197 // for maildir.
198 acct := &config.Account{ID: "x", Protocol: ""}
199 _, err := FetchFolders(acct)
200 if err == nil {
201 t.Fatal("expected error for empty IMAP server")
202 }
203 if !strings.Contains(err.Error(), "unsupported service_provider") {
204 t.Errorf("expected IMAP-path error, got %v", err)
205 }
206}