maildir_dispatch_test.go

  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}