maildir_test.go

  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}