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