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}