1package maildir
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/floatpane/matcha/backend"
15 "github.com/floatpane/matcha/config"
16)
17
18// seenSuffix returns the on-disk suffix go-maildir appends for a message that
19// carries only the Seen flag. Windows uses ';' instead of ':' because ':' is
20// reserved in NTFS filenames.
21func seenSuffix() string {
22 if runtime.GOOS == "windows" {
23 return ";2,S"
24 }
25 return ":2,S"
26}
27
28// makeMaildir creates a root + the named Maildir++ subfolders.
29func makeMaildir(t *testing.T, subfolders ...string) string {
30 t.Helper()
31 root := t.TempDir()
32 for _, sub := range []string{"cur", "new", "tmp"} {
33 if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil {
34 t.Fatalf("mkdir %s: %v", sub, err)
35 }
36 }
37 for _, folder := range subfolders {
38 for _, sub := range []string{"cur", "new", "tmp"} {
39 if err := os.MkdirAll(filepath.Join(root, folder, sub), 0o755); err != nil {
40 t.Fatalf("mkdir subfolder %s/%s: %v", folder, sub, err)
41 }
42 }
43 }
44 return root
45}
46
47// dropMessage writes a fake delivered message into the new/ dir of a Maildir.
48// The filename intentionally has no flag suffix (delivered state).
49func dropMessage(t *testing.T, dir, key, subject, body string, deliveredAt time.Time) {
50 t.Helper()
51 contents := fmt.Sprintf(
52 "From: alice@example.com\r\n"+
53 "To: me@local\r\n"+
54 "Subject: %s\r\n"+
55 "Date: %s\r\n"+
56 "Message-ID: <%s@local>\r\n"+
57 "\r\n"+
58 "%s\r\n",
59 subject, deliveredAt.Format(time.RFC1123Z), key, body,
60 )
61 path := filepath.Join(dir, "new", key)
62 if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
63 t.Fatalf("write message: %v", err)
64 }
65 // Match deliveredAt so sort-by-mtime is deterministic.
66 if err := os.Chtimes(path, deliveredAt, deliveredAt); err != nil {
67 t.Fatalf("chtimes: %v", err)
68 }
69}
70
71func newProvider(t *testing.T, root string) *Provider {
72 t.Helper()
73 p, err := New(&config.Account{ID: "acct1", MaildirPath: root})
74 if err != nil {
75 t.Fatalf("New: %v", err)
76 }
77 return p
78}
79
80func TestNewRejectsMissingPath(t *testing.T) {
81 if _, err := New(&config.Account{ID: "x"}); err == nil {
82 t.Fatal("expected error for empty MaildirPath")
83 }
84 if _, err := New(&config.Account{ID: "x", MaildirPath: "/this/does/not/exist"}); err == nil {
85 t.Fatal("expected error for nonexistent path")
86 }
87}
88
89func TestFetchFoldersListsInboxAndSubfolders(t *testing.T) {
90 root := makeMaildir(t, ".Sent", ".Archive")
91 p := newProvider(t, root)
92
93 folders, err := p.FetchFolders(context.Background())
94 if err != nil {
95 t.Fatalf("FetchFolders: %v", err)
96 }
97
98 names := make(map[string]bool, len(folders))
99 for _, f := range folders {
100 names[f.Name] = true
101 }
102 for _, want := range []string{"INBOX", "Sent", "Archive"} {
103 if !names[want] {
104 t.Errorf("expected folder %q in %v", want, names)
105 }
106 }
107}
108
109func TestFetchEmailsNewestFirst(t *testing.T) {
110 root := makeMaildir(t)
111 t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
112 dropMessage(t, root, "1700000000.older.host", "first", "old body", t0)
113 dropMessage(t, root, "1700000100.newer.host", "second", "new body", t0.Add(time.Hour))
114
115 p := newProvider(t, root)
116 emails, err := p.FetchEmails(context.Background(), "INBOX", 50, 0)
117 if err != nil {
118 t.Fatalf("FetchEmails: %v", err)
119 }
120 if len(emails) != 2 {
121 t.Fatalf("want 2 emails, got %d", len(emails))
122 }
123 if emails[0].Subject != "second" {
124 t.Errorf("want newest first, got %q", emails[0].Subject)
125 }
126 if emails[1].Subject != "first" {
127 t.Errorf("want oldest second, got %q", emails[1].Subject)
128 }
129 if emails[0].UID == 0 || emails[0].UID == emails[1].UID {
130 t.Errorf("UIDs must be nonzero and distinct: %d vs %d", emails[0].UID, emails[1].UID)
131 }
132 if emails[0].IsRead {
133 t.Error("freshly delivered message should not be read")
134 }
135}
136
137func TestFetchEmailsRespectsLimitOffset(t *testing.T) {
138 root := makeMaildir(t)
139 base := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
140 for i := 0; i < 5; i++ {
141 key := fmt.Sprintf("1700000%03d.M%dP1.host", i, i)
142 dropMessage(t, root, key, fmt.Sprintf("msg%d", i), "body", base.Add(time.Duration(i)*time.Minute))
143 }
144
145 p := newProvider(t, root)
146 page, err := p.FetchEmails(context.Background(), "INBOX", 2, 1)
147 if err != nil {
148 t.Fatalf("FetchEmails: %v", err)
149 }
150 if len(page) != 2 {
151 t.Fatalf("want 2, got %d", len(page))
152 }
153 if page[0].Subject != "msg3" || page[1].Subject != "msg2" {
154 t.Errorf("want msg3,msg2 — got %q,%q", page[0].Subject, page[1].Subject)
155 }
156}
157
158func TestMarkAsReadAddsSeenFlag(t *testing.T) {
159 root := makeMaildir(t)
160 dropMessage(t, root, "1700000000.x.host", "subj", "body", time.Now())
161
162 p := newProvider(t, root)
163 emails, err := p.FetchEmails(context.Background(), "INBOX", 10, 0)
164 if err != nil || len(emails) != 1 {
165 t.Fatalf("FetchEmails setup: %v / %d", err, len(emails))
166 }
167
168 if err := p.MarkAsRead(context.Background(), "INBOX", emails[0].UID); err != nil {
169 t.Fatalf("MarkAsRead: %v", err)
170 }
171
172 curFiles, _ := os.ReadDir(filepath.Join(root, "cur"))
173 if len(curFiles) != 1 {
174 t.Fatalf("want 1 file in cur/, got %d", len(curFiles))
175 }
176 if !strings.HasSuffix(curFiles[0].Name(), seenSuffix()) {
177 t.Errorf("want %s suffix, got %q", seenSuffix(), curFiles[0].Name())
178 }
179
180 emails, err = p.FetchEmails(context.Background(), "INBOX", 10, 0)
181 if err != nil || len(emails) != 1 {
182 t.Fatalf("FetchEmails post-flag: %v / %d", err, len(emails))
183 }
184 if !emails[0].IsRead {
185 t.Error("email should report IsRead=true after MarkAsRead")
186 }
187}
188
189func TestDeleteEmailRemovesFile(t *testing.T) {
190 root := makeMaildir(t)
191 dropMessage(t, root, "1700000000.del.host", "del", "body", time.Now())
192
193 p := newProvider(t, root)
194 emails, _ := p.FetchEmails(context.Background(), "INBOX", 10, 0)
195 if len(emails) != 1 {
196 t.Fatalf("setup: want 1 email, got %d", len(emails))
197 }
198
199 if err := p.DeleteEmail(context.Background(), "INBOX", emails[0].UID); err != nil {
200 t.Fatalf("DeleteEmail: %v", err)
201 }
202
203 newFiles, _ := os.ReadDir(filepath.Join(root, "new"))
204 curFiles, _ := os.ReadDir(filepath.Join(root, "cur"))
205 if len(newFiles)+len(curFiles) != 0 {
206 t.Errorf("expected no files left, got new=%d cur=%d", len(newFiles), len(curFiles))
207 }
208}
209
210func TestMoveEmailRelocates(t *testing.T) {
211 root := makeMaildir(t, ".Archive")
212 dropMessage(t, root, "1700000000.mv.host", "mv", "body", time.Now())
213
214 p := newProvider(t, root)
215 emails, _ := p.FetchEmails(context.Background(), "INBOX", 10, 0)
216 if len(emails) != 1 {
217 t.Fatalf("setup: want 1 email, got %d", len(emails))
218 }
219
220 if err := p.MoveEmail(context.Background(), emails[0].UID, "INBOX", "Archive"); err != nil {
221 t.Fatalf("MoveEmail: %v", err)
222 }
223
224 inboxFiles, _ := os.ReadDir(filepath.Join(root, "new"))
225 if len(inboxFiles) != 0 {
226 t.Errorf("expected INBOX empty, got %d files", len(inboxFiles))
227 }
228 archiveCur, _ := os.ReadDir(filepath.Join(root, ".Archive", "cur"))
229 archiveNew, _ := os.ReadDir(filepath.Join(root, ".Archive", "new"))
230 if len(archiveCur)+len(archiveNew) != 1 {
231 t.Errorf("expected 1 file in .Archive, got cur=%d new=%d", len(archiveCur), len(archiveNew))
232 }
233}
234
235func TestArchiveEmailRequiresArchiveFolder(t *testing.T) {
236 root := makeMaildir(t) // no .Archive
237 dropMessage(t, root, "1700000000.a.host", "a", "body", time.Now())
238
239 p := newProvider(t, root)
240 emails, _ := p.FetchEmails(context.Background(), "INBOX", 10, 0)
241 err := p.ArchiveEmail(context.Background(), "INBOX", emails[0].UID)
242 if !errors.Is(err, backend.ErrNotSupported) {
243 t.Errorf("want ErrNotSupported, got %v", err)
244 }
245}
246
247func TestSendEmailNotSupported(t *testing.T) {
248 root := makeMaildir(t)
249 p := newProvider(t, root)
250 if err := p.SendEmail(context.Background(), &backend.OutgoingEmail{}); !errors.Is(err, backend.ErrNotSupported) {
251 t.Errorf("want ErrNotSupported, got %v", err)
252 }
253}
254
255func TestSearchFiltersBySubject(t *testing.T) {
256 root := makeMaildir(t)
257 t0 := time.Now()
258 dropMessage(t, root, "k1.host", "alpha report", "x", t0)
259 dropMessage(t, root, "k2.host", "beta notice", "y", t0)
260
261 p := newProvider(t, root)
262 results, err := p.Search(context.Background(), "INBOX", backend.SearchQuery{Subject: "alpha"})
263 if err != nil {
264 t.Fatalf("Search: %v", err)
265 }
266 if len(results) != 1 || !strings.Contains(results[0].Subject, "alpha") {
267 t.Errorf("want one alpha result, got %+v", results)
268 }
269}
270
271func TestCapabilitiesReflectsArchivePresence(t *testing.T) {
272 root := makeMaildir(t)
273 pNoArchive := newProvider(t, root)
274 if pNoArchive.Capabilities().CanArchive {
275 t.Error("CanArchive should be false without .Archive subfolder")
276 }
277
278 rootWithArchive := makeMaildir(t, ".Archive")
279 pArchive := newProvider(t, rootWithArchive)
280 caps := pArchive.Capabilities()
281 if !caps.CanArchive {
282 t.Error("CanArchive should be true when .Archive exists")
283 }
284 if caps.CanSend {
285 t.Error("CanSend must be false for Maildir")
286 }
287 if !caps.CanFetchFolders {
288 t.Error("CanFetchFolders must be true")
289 }
290}
291
292// makeNestedMaildir creates an mbsync/isync-style tree: the root has no
293// cur/new/tmp of its own; each named subdirectory is a self-contained
294// Maildir folder.
295func makeNestedMaildir(t *testing.T, folders ...string) string {
296 t.Helper()
297 root := t.TempDir()
298 for _, folder := range folders {
299 for _, sub := range []string{"cur", "new", "tmp"} {
300 if err := os.MkdirAll(filepath.Join(root, folder, sub), 0o755); err != nil {
301 t.Fatalf("mkdir %s/%s: %v", folder, sub, err)
302 }
303 }
304 }
305 return root
306}
307
308func TestNestedLayoutListsFoldersAndFetchesInbox(t *testing.T) {
309 root := makeNestedMaildir(t, "INBOX", "Sent", "Archive", "Drafts")
310 dropMessage(t, filepath.Join(root, "INBOX"), "1700000000.n.host", "nested hi", "body", time.Now())
311
312 p := newProvider(t, root)
313 if !p.nested {
314 t.Fatal("expected nested layout to be detected")
315 }
316
317 folders, err := p.FetchFolders(context.Background())
318 if err != nil {
319 t.Fatalf("FetchFolders: %v", err)
320 }
321 if len(folders) == 0 || folders[0].Name != "INBOX" {
322 t.Errorf("INBOX should be listed first, got %+v", folders)
323 }
324 names := map[string]bool{}
325 for _, f := range folders {
326 names[f.Name] = true
327 }
328 for _, want := range []string{"INBOX", "Sent", "Archive", "Drafts"} {
329 if !names[want] {
330 t.Errorf("missing folder %q in %v", want, names)
331 }
332 }
333
334 emails, err := p.FetchEmails(context.Background(), "INBOX", 10, 0)
335 if err != nil {
336 t.Fatalf("FetchEmails: %v", err)
337 }
338 if len(emails) != 1 || emails[0].Subject != "nested hi" {
339 t.Errorf("want 1 message with subject 'nested hi', got %+v", emails)
340 }
341
342 if !p.Capabilities().CanArchive {
343 t.Error("CanArchive should be true when Archive subfolder exists in nested layout")
344 }
345}