maildir_test.go

  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}