imap_test.go

  1//go:build integration
  2
  3package integration
  4
  5import (
  6	"context"
  7	"fmt"
  8	"net/http"
  9	"net/smtp"
 10	"net/textproto"
 11	"os"
 12	"strconv"
 13	"strings"
 14	"testing"
 15	"time"
 16
 17	"github.com/floatpane/matcha/backend"
 18	_ "github.com/floatpane/matcha/backend/imap"
 19	"github.com/floatpane/matcha/config"
 20)
 21
 22// testEnv resolves the integration test environment. Greenmail exposes the
 23// following ports by default — we read them from env to allow remapping:
 24//
 25//	MATCHA_TEST_IMAP_HOST       default: 127.0.0.1
 26//	MATCHA_TEST_IMAP_PORT       default: 3993 (implicit TLS)
 27//	MATCHA_TEST_SMTP_PORT       default: 3465 (implicit TLS, used by matcha sender)
 28//	MATCHA_TEST_SMTP_PLAIN_PORT default: 3025 (plain SMTP, used by deliverViaSMTP)
 29//	MATCHA_TEST_API_PORT        default: 8080 (Greenmail REST API)
 30type testEnv struct {
 31	host          string
 32	imapPort      int
 33	smtpPort      int
 34	smtpPlainPort int
 35	apiPort       int
 36}
 37
 38func loadEnv(t *testing.T) testEnv {
 39	t.Helper()
 40	env := testEnv{
 41		host:          getenv("MATCHA_TEST_IMAP_HOST", "127.0.0.1"),
 42		imapPort:      getenvInt(t, "MATCHA_TEST_IMAP_PORT", 3993),
 43		smtpPort:      getenvInt(t, "MATCHA_TEST_SMTP_PORT", 3465),
 44		smtpPlainPort: getenvInt(t, "MATCHA_TEST_SMTP_PLAIN_PORT", 3025),
 45		apiPort:       getenvInt(t, "MATCHA_TEST_API_PORT", 8080),
 46	}
 47	return env
 48}
 49
 50func getenv(key, fallback string) string {
 51	if v := os.Getenv(key); v != "" {
 52		return v
 53	}
 54	return fallback
 55}
 56
 57func getenvInt(t *testing.T, key string, fallback int) int {
 58	t.Helper()
 59	v := os.Getenv(key)
 60	if v == "" {
 61		return fallback
 62	}
 63	n, err := strconv.Atoi(v)
 64	if err != nil {
 65		t.Fatalf("invalid %s: %v", key, err)
 66	}
 67	return n
 68}
 69
 70func waitForGreenmail(t *testing.T, env testEnv) {
 71	t.Helper()
 72	deadline := time.Now().Add(60 * time.Second)
 73	url := fmt.Sprintf("http://%s:%d/api/configuration", env.host, env.apiPort)
 74	for time.Now().Before(deadline) {
 75		resp, err := http.Get(url)
 76		if err == nil && resp.StatusCode == http.StatusOK {
 77			resp.Body.Close()
 78			return
 79		}
 80		if resp != nil {
 81			resp.Body.Close()
 82		}
 83		time.Sleep(500 * time.Millisecond)
 84	}
 85	t.Fatalf("greenmail not ready after 60s at %s", url)
 86}
 87
 88func resetGreenmail(t *testing.T, env testEnv) {
 89	t.Helper()
 90	url := fmt.Sprintf("http://%s:%d/api/mail/purge", env.host, env.apiPort)
 91	req, _ := http.NewRequest(http.MethodPost, url, nil)
 92	resp, err := http.DefaultClient.Do(req)
 93	if err != nil {
 94		t.Fatalf("reset greenmail: %v", err)
 95	}
 96	defer resp.Body.Close()
 97	if resp.StatusCode >= 400 {
 98		t.Fatalf("reset greenmail: status %d", resp.StatusCode)
 99	}
100}
101
102// deliverViaSMTP injects a message into the IMAP store by speaking SMTP to
103// Greenmail directly. Greenmail's REST API only supports reading and purging;
104// SMTP is the only documented way to inject mail.
105func deliverViaSMTP(t *testing.T, env testEnv, from, to, subject, body string) {
106	t.Helper()
107	addr := fmt.Sprintf("%s:%d", env.host, env.smtpPlainPort)
108
109	hdr := textproto.MIMEHeader{}
110	hdr.Set("From", from)
111	hdr.Set("To", to)
112	hdr.Set("Subject", subject)
113	hdr.Set("Date", time.Now().UTC().Format(time.RFC1123Z))
114	hdr.Set("MIME-Version", "1.0")
115	hdr.Set("Content-Type", "text/plain; charset=UTF-8")
116
117	var msg strings.Builder
118	for k, vs := range hdr {
119		for _, v := range vs {
120			fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
121		}
122	}
123	msg.WriteString("\r\n")
124	msg.WriteString(body)
125
126	if err := smtp.SendMail(addr, nil, from, []string{to}, []byte(msg.String())); err != nil {
127		t.Fatalf("deliver via smtp: %v", err)
128	}
129	// Greenmail delivers asynchronously; wait briefly so the next IMAP read
130	// sees the message.
131	time.Sleep(300 * time.Millisecond)
132}
133
134func newTestAccount(env testEnv, user, pass string) *config.Account {
135	return &config.Account{
136		ID:              "test-account",
137		Name:            "Test User",
138		Email:           user,
139		Password:        pass,
140		ServiceProvider: "custom",
141		IMAPServer:      env.host,
142		IMAPPort:        env.imapPort,
143		SMTPServer:      env.host,
144		SMTPPort:        env.smtpPort,
145		Insecure:        true,
146		Protocol:        "imap",
147		SC:              &config.SessionCache{},
148	}
149}
150
151func TestIntegration_FetchInbox(t *testing.T) {
152	env := loadEnv(t)
153	waitForGreenmail(t, env)
154	resetGreenmail(t, env)
155
156	const user = "alice@example.com"
157	const pass = "secret"
158
159	deliverViaSMTP(t, env, "bob@example.com", user, "Hello Alice", "first message")
160	deliverViaSMTP(t, env, "carol@example.com", user, "Invoice Q4", "please pay")
161
162	acct := newTestAccount(env, user, pass)
163	provider, err := backend.New(acct)
164	if err != nil {
165		t.Fatalf("backend.New: %v", err)
166	}
167	defer provider.Close()
168
169	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
170	defer cancel()
171
172	emails, err := provider.FetchEmails(ctx, "INBOX", 50, 0)
173	if err != nil {
174		t.Fatalf("FetchEmails: %v", err)
175	}
176	if len(emails) != 2 {
177		t.Fatalf("FetchEmails returned %d, want 2", len(emails))
178	}
179
180	subjects := map[string]bool{}
181	for _, e := range emails {
182		subjects[e.Subject] = true
183	}
184	for _, want := range []string{"Hello Alice", "Invoice Q4"} {
185		if !subjects[want] {
186			t.Errorf("missing subject %q in %v", want, subjects)
187		}
188	}
189}
190
191func TestIntegration_SearchSubject(t *testing.T) {
192	env := loadEnv(t)
193	waitForGreenmail(t, env)
194	resetGreenmail(t, env)
195
196	const user = "alice@example.com"
197	const pass = "secret"
198
199	deliverViaSMTP(t, env, "bob@example.com", user, "Invoice Q4", "")
200	deliverViaSMTP(t, env, "bob@example.com", user, "Random update", "")
201	deliverViaSMTP(t, env, "bob@example.com", user, "Invoice Q1", "")
202
203	acct := newTestAccount(env, user, pass)
204	provider, err := backend.New(acct)
205	if err != nil {
206		t.Fatalf("backend.New: %v", err)
207	}
208	defer provider.Close()
209
210	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
211	defer cancel()
212
213	results, err := provider.Search(ctx, "INBOX", backend.SearchQuery{Subject: "Invoice"})
214	if err != nil {
215		t.Fatalf("Search: %v", err)
216	}
217	if len(results) != 2 {
218		t.Fatalf("Search returned %d, want 2", len(results))
219	}
220	for _, r := range results {
221		if !strings.Contains(r.Subject, "Invoice") {
222			t.Errorf("unexpected subject %q in search results", r.Subject)
223		}
224	}
225}
226
227func TestIntegration_MarkAsRead(t *testing.T) {
228	env := loadEnv(t)
229	waitForGreenmail(t, env)
230	resetGreenmail(t, env)
231
232	const user = "alice@example.com"
233	const pass = "secret"
234
235	deliverViaSMTP(t, env, "bob@example.com", user, "Toggle me", "")
236
237	acct := newTestAccount(env, user, pass)
238	provider, err := backend.New(acct)
239	if err != nil {
240		t.Fatalf("backend.New: %v", err)
241	}
242	defer provider.Close()
243
244	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
245	defer cancel()
246
247	emails, err := provider.FetchEmails(ctx, "INBOX", 10, 0)
248	if err != nil || len(emails) != 1 {
249		t.Fatalf("FetchEmails: err=%v len=%d", err, len(emails))
250	}
251	uid := emails[0].UID
252	if emails[0].IsRead {
253		t.Fatal("email unexpectedly marked read before MarkAsRead")
254	}
255
256	if err := provider.MarkAsRead(ctx, "INBOX", uid); err != nil {
257		t.Fatalf("MarkAsRead: %v", err)
258	}
259
260	emails, err = provider.FetchEmails(ctx, "INBOX", 10, 0)
261	if err != nil {
262		t.Fatalf("re-fetch: %v", err)
263	}
264	if !emails[0].IsRead {
265		t.Error("email not marked read after MarkAsRead")
266	}
267}