A minimal email TUI where you read with Markdown and write in Neovim.
neomd.ssp.sh/docs
email
markdown
neovim
tui
1// Package integration_test runs end-to-end tests against a real IMAP/SMTP server.
2//
3// Skipped unless NEOMD_TEST_IMAP_HOST is set. Run with:
4//
5// make test-integration
6//
7// These tests send real emails to the test account (sends to itself) and
8// clean up after. They require network access and valid credentials.
9package integration_test
10
11import (
12 "context"
13 "fmt"
14 "net/http"
15 "os"
16 "path/filepath"
17 "strings"
18 "testing"
19 "time"
20
21 goIMAP "github.com/sspaeti/neomd/internal/imap"
22 "github.com/sspaeti/neomd/internal/smtp"
23)
24
25// testEnv holds credentials loaded from environment variables.
26type testEnv struct {
27 imapHost string
28 imapPort string
29 smtpHost string
30 smtpPort string
31 user string
32 password string
33 from string
34}
35
36func loadEnv(t *testing.T) testEnv {
37 t.Helper()
38 host := os.Getenv("NEOMD_TEST_IMAP_HOST")
39 if host == "" {
40 t.Skip("set NEOMD_TEST_IMAP_HOST to run integration tests")
41 }
42 env := testEnv{
43 imapHost: host,
44 imapPort: getEnvOr("NEOMD_TEST_IMAP_PORT", "993"),
45 smtpHost: getEnvOr("NEOMD_TEST_SMTP_HOST", host),
46 smtpPort: getEnvOr("NEOMD_TEST_SMTP_PORT", "587"),
47 user: os.Getenv("NEOMD_TEST_USER"),
48 password: os.Getenv("NEOMD_TEST_PASS"),
49 from: os.Getenv("NEOMD_TEST_FROM"),
50 }
51 if env.user == "" || env.password == "" {
52 t.Skip("set NEOMD_TEST_USER and NEOMD_TEST_PASS")
53 }
54 if env.from == "" {
55 env.from = env.user
56 }
57 return env
58}
59
60func getEnvOr(key, fallback string) string {
61 if v := os.Getenv(key); v != "" {
62 return v
63 }
64 return fallback
65}
66
67func (e testEnv) imapClient() *goIMAP.Client {
68 return goIMAP.New(goIMAP.Config{
69 Host: e.imapHost,
70 Port: e.imapPort,
71 User: e.user,
72 Password: e.password,
73 TLS: e.imapPort == "993",
74 STARTTLS: e.imapPort == "143",
75 })
76}
77
78// ccRecipient returns ", addr" if NEOMD_TEST_CC is set, empty string otherwise.
79// Used to optionally CC test emails to a live inbox for manual inspection.
80func (e testEnv) ccRecipient() string {
81 if cc := os.Getenv("NEOMD_TEST_CC"); cc != "" {
82 return ", " + cc
83 }
84 return ""
85}
86
87func (e testEnv) smtpConfig() smtp.Config {
88 return smtp.Config{
89 Host: e.smtpHost,
90 Port: e.smtpPort,
91 User: e.user,
92 Password: e.password,
93 From: e.from,
94 }
95}
96
97// uniqueSubject returns a unique subject for test isolation.
98func uniqueSubject(name string) string {
99 return fmt.Sprintf("[neomd-test] %s %d", name, time.Now().UnixNano())
100}
101
102// waitForEmail polls IMAP until an email with the given subject substring appears, or times out.
103// Uses FetchHeaders (not SEARCH) to avoid IMAP SEARCH substring quirks with special chars.
104func waitForEmail(t *testing.T, cli *goIMAP.Client, folder, subject string, timeout time.Duration) *goIMAP.Email {
105 t.Helper()
106 ctx := context.Background()
107 deadline := time.Now().Add(timeout)
108 for time.Now().Before(deadline) {
109 emails, err := cli.FetchHeaders(ctx, folder, 20)
110 if err == nil {
111 for i := range emails {
112 if strings.Contains(emails[i].Subject, subject) {
113 return &emails[i]
114 }
115 }
116 }
117 time.Sleep(2 * time.Second)
118 }
119 t.Fatalf("email with subject %q not found in %s after %v", subject, folder, timeout)
120 return nil
121}
122
123// cleanupEmail permanently deletes a test email.
124func cleanupEmail(t *testing.T, cli *goIMAP.Client, folder string, uid uint32) {
125 t.Helper()
126 ctx := context.Background()
127 if err := cli.ExpungeAll(ctx, folder, []uint32{uid}); err != nil {
128 t.Logf("cleanup warning: %v", err)
129 }
130}
131
132// --- Tests ---
133
134func TestIntegration_IMAPConnect(t *testing.T) {
135 env := loadEnv(t)
136 cli := env.imapClient()
137 defer cli.Close()
138
139 if err := cli.Ping(context.Background()); err != nil {
140 t.Fatalf("IMAP ping failed: %v", err)
141 }
142}
143
144func TestIntegration_IMAPFetchHeaders(t *testing.T) {
145 env := loadEnv(t)
146 cli := env.imapClient()
147 defer cli.Close()
148
149 emails, err := cli.FetchHeaders(context.Background(), "INBOX", 5)
150 if err != nil {
151 t.Fatalf("FetchHeaders: %v", err)
152 }
153 // Just verify it returns without error and emails have basic fields
154 for _, e := range emails {
155 if e.UID == 0 {
156 t.Error("email has UID 0")
157 }
158 if e.Subject == "" && e.From == "" {
159 t.Error("email has no subject and no from")
160 }
161 }
162}
163
164func TestIntegration_SendPlainEmail(t *testing.T) {
165 env := loadEnv(t)
166 cli := env.imapClient()
167 defer cli.Close()
168
169 subject := uniqueSubject("plain")
170 body := "Hello from neomd integration test.\n\nThis is **bold** and this is a [link](https://ssp.sh)."
171
172 // Send to self
173 err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
174 if err != nil {
175 t.Fatalf("Send: %v", err)
176 }
177
178 // Wait for delivery and fetch
179 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
180 defer cleanupEmail(t, cli, "INBOX", email.UID)
181
182 // Verify headers
183 if !strings.Contains(email.From, env.user) && !strings.Contains(email.From, extractUser(env.from)) {
184 t.Errorf("From = %q, expected to contain %q", email.From, env.user)
185 }
186 if email.Subject != subject {
187 t.Errorf("Subject = %q, want %q", email.Subject, subject)
188 }
189
190 // Fetch body and verify content
191 markdown, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
192 if err != nil {
193 t.Fatalf("FetchBody: %v", err)
194 }
195 if !strings.Contains(markdown, "neomd integration test") {
196 t.Errorf("body missing expected text, got: %s", truncate(markdown, 200))
197 }
198 if rawHTML == "" {
199 t.Error("expected HTML part in multipart/alternative, got empty")
200 }
201 if !strings.Contains(rawHTML, "<strong>bold</strong>") {
202 t.Errorf("HTML part missing <strong>bold</strong>, got: %s", truncate(rawHTML, 200))
203 }
204 if !strings.Contains(rawHTML, `href="https://ssp.sh"`) {
205 t.Errorf("HTML part missing link href, got: %s", truncate(rawHTML, 200))
206 }
207}
208
209func TestIntegration_SendWithCC(t *testing.T) {
210 env := loadEnv(t)
211 cli := env.imapClient()
212 defer cli.Close()
213
214 subject := uniqueSubject("cc")
215 body := "Testing CC header."
216
217 // CC to self (same address, just verifying the header round-trips)
218 err := smtp.Send(env.smtpConfig(), env.user, env.user, "", subject, body, nil)
219 if err != nil {
220 t.Fatalf("Send: %v", err)
221 }
222
223 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
224 defer cleanupEmail(t, cli, "INBOX", email.UID)
225
226 // Fetch raw body to check CC header
227 markdown, _, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
228 if err != nil {
229 t.Fatalf("FetchBody: %v", err)
230 }
231 _ = markdown // CC is in envelope, not body — verify via headers if available
232 // The email arrived with CC set; IMAP envelope should have it
233 if email.CC == "" {
234 t.Logf("Note: CC not populated in Email struct (CC field may not be fetched by FetchHeaders)")
235 }
236}
237
238func TestIntegration_SendWithAttachment(t *testing.T) {
239 env := loadEnv(t)
240 cli := env.imapClient()
241 defer cli.Close()
242
243 subject := uniqueSubject("attach")
244 body := "Email with attachment."
245
246 // Create a test file to attach
247 dir := t.TempDir()
248 attachPath := filepath.Join(dir, "test-document.txt")
249 if err := os.WriteFile(attachPath, []byte("This is the attachment content from neomd test."), 0600); err != nil {
250 t.Fatal(err)
251 }
252
253 err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, []string{attachPath})
254 if err != nil {
255 t.Fatalf("Send: %v", err)
256 }
257
258 email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second)
259 defer cleanupEmail(t, cli, "INBOX", email.UID)
260
261 // Fetch body — attachments should be listed
262 _, _, _, attachments, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
263 if err != nil {
264 t.Fatalf("FetchBody: %v", err)
265 }
266 if len(attachments) == 0 {
267 t.Fatal("expected at least 1 attachment, got 0")
268 }
269
270 found := false
271 for _, a := range attachments {
272 if strings.Contains(a.Filename, "test-document") {
273 found = true
274 if len(a.Data) == 0 {
275 t.Error("attachment data is empty")
276 }
277 if !strings.Contains(string(a.Data), "attachment content from neomd test") {
278 t.Errorf("attachment content mismatch, got %d bytes", len(a.Data))
279 }
280 }
281 }
282 if !found {
283 names := make([]string, len(attachments))
284 for i, a := range attachments {
285 names[i] = a.Filename
286 }
287 t.Errorf("attachment 'test-document.txt' not found, got: %v", names)
288 }
289}
290
291func TestIntegration_SendNonASCIISubject(t *testing.T) {
292 env := loadEnv(t)
293 cli := env.imapClient()
294 defer cli.Close()
295
296 subject := uniqueSubject("Ünïcödé Tëst 🚀")
297 body := "Testing non-ASCII subject encoding."
298
299 err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
300 if err != nil {
301 t.Fatalf("Send: %v", err)
302 }
303
304 email := waitForEmail(t, cli, "INBOX", "Tëst", 30*time.Second)
305 defer cleanupEmail(t, cli, "INBOX", email.UID)
306
307 // Subject should survive Q-encoding round-trip
308 if !strings.Contains(email.Subject, "Ünïcödé") {
309 t.Errorf("Subject = %q, expected to contain 'Ünïcödé'", email.Subject)
310 }
311 if !strings.Contains(email.Subject, "🚀") {
312 t.Errorf("Subject = %q, expected to contain emoji", email.Subject)
313 }
314}
315
316func TestIntegration_IMAPSearch(t *testing.T) {
317 env := loadEnv(t)
318 cli := env.imapClient()
319 defer cli.Close()
320
321 // Send a unique email to search for
322 subject := uniqueSubject("search-target")
323 body := "This email exists to be found by IMAP SEARCH."
324
325 err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
326 if err != nil {
327 t.Fatalf("Send: %v", err)
328 }
329
330 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
331 defer cleanupEmail(t, cli, "INBOX", email.UID)
332
333 // Test subject: prefix search
334 results, err := cli.SearchMessages(context.Background(), "INBOX", "subject:"+subject)
335 if err != nil {
336 t.Fatalf("SearchMessages: %v", err)
337 }
338 if len(results) == 0 {
339 t.Fatal("subject: search returned no results")
340 }
341
342 // Test from: prefix search
343 results, err = cli.SearchMessages(context.Background(), "INBOX", "from:"+env.user)
344 if err != nil {
345 t.Fatalf("SearchMessages from: %v", err)
346 }
347 if len(results) == 0 {
348 t.Fatal("from: search returned no results")
349 }
350}
351
352func TestIntegration_IMAPMoveAndUndo(t *testing.T) {
353 env := loadEnv(t)
354 cli := env.imapClient()
355 defer cli.Close()
356
357 // Ensure test folder exists
358 testFolder := "NeomdTest"
359 _, err := cli.EnsureFolders(context.Background(), []string{testFolder})
360 if err != nil {
361 t.Fatalf("EnsureFolders: %v", err)
362 }
363
364 // Send an email to move
365 subject := uniqueSubject("move-test")
366 body := "This email will be moved."
367
368 err = smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
369 if err != nil {
370 t.Fatalf("Send: %v", err)
371 }
372
373 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
374
375 // Move to test folder
376 destUID, err := cli.MoveMessage(context.Background(), "INBOX", email.UID, testFolder)
377 if err != nil {
378 cleanupEmail(t, cli, "INBOX", email.UID)
379 t.Fatalf("MoveMessage: %v", err)
380 }
381 if destUID == 0 {
382 t.Error("MoveMessage returned destUID 0")
383 }
384
385 // Verify it's in the test folder
386 moved := waitForEmail(t, cli, testFolder, subject, 10*time.Second)
387
388 // Move back (undo)
389 _, err = cli.MoveMessage(context.Background(), testFolder, moved.UID, "INBOX")
390 if err != nil {
391 cleanupEmail(t, cli, testFolder, moved.UID)
392 t.Fatalf("MoveMessage (undo): %v", err)
393 }
394
395 // Verify back in INBOX and cleanup
396 restored := waitForEmail(t, cli, "INBOX", subject, 10*time.Second)
397 cleanupEmail(t, cli, "INBOX", restored.UID)
398}
399
400func TestIntegration_SendWithInlineImage(t *testing.T) {
401 env := loadEnv(t)
402 cli := env.imapClient()
403 defer cli.Close()
404
405 subject := uniqueSubject("inline-img")
406
407 // Create a minimal 1x1 PNG in a temp dir
408 dir := t.TempDir()
409 imgPath := filepath.Join(dir, "test-logo.png")
410 // Minimal valid PNG: 1x1 red pixel
411 png := []byte{
412 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
413 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
414 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
415 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
416 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk
417 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
418 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
419 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk
420 0x44, 0xae, 0x42, 0x60, 0x82,
421 }
422 if err := os.WriteFile(imgPath, png, 0600); err != nil {
423 t.Fatal(err)
424 }
425
426 // Markdown with image reference — goldmark produces <img src="/path">
427 // which buildMessage rewrites to cid: for inline embedding.
428 body := fmt.Sprintf("Here is an inline image:\n\n\n\nEnd of email.", imgPath)
429
430 err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
431 if err != nil {
432 t.Fatalf("Send: %v", err)
433 }
434
435 email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second)
436 defer cleanupEmail(t, cli, "INBOX", email.UID)
437
438 // Fetch body — inline image should appear as attachment with image content type
439 _, rawHTML, _, attachments, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
440 if err != nil {
441 t.Fatalf("FetchBody: %v", err)
442 }
443
444 // HTML should contain cid: reference (inline image)
445 if !strings.Contains(rawHTML, "cid:") {
446 t.Logf("HTML body (truncated): %s", truncate(rawHTML, 500))
447 t.Error("expected cid: reference in HTML for inline image")
448 }
449
450 // Should have at least one image attachment
451 foundImage := false
452 for _, a := range attachments {
453 if strings.HasPrefix(a.ContentType, "image/") {
454 foundImage = true
455 if len(a.Data) == 0 {
456 t.Error("inline image data is empty")
457 }
458 }
459 }
460 if !foundImage {
461 names := make([]string, len(attachments))
462 for i, a := range attachments {
463 names[i] = fmt.Sprintf("%s (%s)", a.Filename, a.ContentType)
464 }
465 t.Errorf("no image attachment found, got: %v", names)
466 }
467}
468
469func TestIntegration_SignatureRenderedInHTML(t *testing.T) {
470 env := loadEnv(t)
471 cli := env.imapClient()
472 defer cli.Close()
473
474 subject := uniqueSubject("signature")
475 // Simulate a compose with signature and callouts (same format as editor.Prelude adds)
476 body := "Hi team,\n\n" +
477 "Here's the update on the project:\n\n" +
478 "> [!tip] Good News\n" +
479 "> We're ahead of schedule! The new feature shipped yesterday.\n\n" +
480 "> [!warning] Action Required\n" +
481 "> Please review the security audit by Friday.\n\n" +
482 "> [!note] note\n" +
483 "> Please read\n\n" +
484 "Thanks,\n" +
485 "Simon\n\n" +
486 "-- \n" +
487 "**Simon Späti**\n" +
488 "Data Engineer, [SSP Data](https://ssp.sh/)\n"
489
490 err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
491 if err != nil {
492 t.Fatalf("Send: %v", err)
493 }
494
495 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
496 defer cleanupEmail(t, cli, "INBOX", email.UID)
497
498 markdown, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
499 if err != nil {
500 t.Fatalf("FetchBody: %v", err)
501 }
502
503 // Plain text part should contain the signature as-is
504 if !strings.Contains(markdown, "Simon Späti") {
505 t.Errorf("plain text missing signature name, got: %s", truncate(markdown, 300))
506 }
507
508 // HTML part should render signature with formatting
509 if !strings.Contains(rawHTML, "<strong>Simon Späti</strong>") {
510 t.Errorf("HTML missing bold signature name, got: %s", truncate(rawHTML, 500))
511 }
512 if !strings.Contains(rawHTML, `href="https://ssp.sh/"`) {
513 t.Errorf("HTML missing signature link, got: %s", truncate(rawHTML, 500))
514 }
515
516 // Body content before signature should also be rendered
517 if !strings.Contains(rawHTML, "update on the project") {
518 t.Errorf("HTML missing email body text, got: %s", truncate(rawHTML, 500))
519 }
520
521 // Callout rendering verification
522 if !strings.Contains(rawHTML, "callout callout-tip") {
523 t.Errorf("HTML missing tip callout class, got: %s", truncate(rawHTML, 800))
524 }
525 if !strings.Contains(rawHTML, "callout callout-warning") {
526 t.Errorf("HTML missing warning callout class, got: %s", truncate(rawHTML, 800))
527 }
528 if !strings.Contains(rawHTML, "callout callout-note") {
529 t.Errorf("HTML missing note callout class, got: %s", truncate(rawHTML, 800))
530 }
531 if !strings.Contains(rawHTML, "💡") { // Light bulb emoji for tip
532 t.Errorf("HTML missing tip callout icon, got: %s", truncate(rawHTML, 800))
533 }
534 if !strings.Contains(rawHTML, "⚠️") { // Warning sign emoji
535 t.Errorf("HTML missing warning callout icon, got: %s", truncate(rawHTML, 800))
536 }
537 if !strings.Contains(rawHTML, "Good News") {
538 t.Errorf("HTML missing custom callout title, got: %s", truncate(rawHTML, 800))
539 }
540 if !strings.Contains(rawHTML, "ahead of schedule") {
541 t.Errorf("HTML missing callout content, got: %s", truncate(rawHTML, 800))
542 }
543}
544
545func TestIntegration_SaveSent(t *testing.T) {
546 env := loadEnv(t)
547 cli := env.imapClient()
548 defer cli.Close()
549
550 subject := uniqueSubject("save-sent")
551 body := "This email tests SaveSent IMAP APPEND."
552
553 // Build the message (same as neomd does before sending)
554 raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil, "")
555 if err != nil {
556 t.Fatalf("BuildMessage: %v", err)
557 }
558
559 // Save to Sent via IMAP APPEND (no actual SMTP send needed)
560 err = cli.SaveSent(context.Background(), "Sent", raw)
561 if err != nil {
562 t.Fatalf("SaveSent: %v", err)
563 }
564
565 // Verify it appears in the Sent folder
566 email := waitForEmail(t, cli, "Sent", subject, 15*time.Second)
567 defer cleanupEmail(t, cli, "Sent", email.UID)
568
569 if email.Subject != subject {
570 t.Errorf("Sent email subject = %q, want %q", email.Subject, subject)
571 }
572
573 // Verify it's marked as read (\Seen flag)
574 if !email.Seen {
575 t.Error("SaveSent email should have \\Seen flag")
576 }
577}
578
579func TestIntegration_MultipleRecipients(t *testing.T) {
580 env := loadEnv(t)
581 cli := env.imapClient()
582 defer cli.Close()
583
584 // Use a second address for the test. NEOMD_TEST_USER2 can be set to a
585 // real second account; falls back to the same address (still tests parsing).
586 user2 := getEnvOr("NEOMD_TEST_USER2", "simu@sspaeti.com")
587
588 subject := uniqueSubject("multi-rcpt")
589 body := "Testing comma-separated To, CC, and BCC."
590
591 // Comma-separated To: two different addresses
592 // CC: the test account itself
593 // This exercises the bug we fixed: Send() must split To by comma.
594 to := env.user + ", " + user2
595 cc := env.user
596
597 err := smtp.Send(env.smtpConfig(), to, cc, "", subject, body, nil)
598 if err != nil {
599 t.Fatalf("Send with comma-separated To: %v", err)
600 }
601
602 // Verify delivery to primary test account
603 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
604 defer cleanupEmail(t, cli, "INBOX", email.UID)
605
606 // Verify To field contains both addresses (not just the first)
607 if !strings.Contains(email.To, env.user) {
608 t.Errorf("To field missing primary address, got: %q", email.To)
609 }
610 if !strings.Contains(email.To, user2) {
611 t.Errorf("To field missing second address %q, got: %q", user2, email.To)
612 }
613
614 // Verify CC is populated
615 if email.CC == "" {
616 t.Logf("Note: CC not populated in envelope (fetch path may not include it)")
617 } else if !strings.Contains(email.CC, env.user) {
618 t.Errorf("CC field missing %q, got: %q", env.user, email.CC)
619 }
620
621 t.Logf("Email delivered with To: %s, CC: %s", email.To, email.CC)
622}
623
624func TestIntegration_ReplyAllPreservesRecipients(t *testing.T) {
625 env := loadEnv(t)
626 cli := env.imapClient()
627 defer cli.Close()
628
629 // Three distinct addresses to properly test reply-all.
630 // demo sends to simu + simon, then reply-all should CC both back.
631 user2 := getEnvOr("NEOMD_TEST_USER2", "simu@sspaeti.com")
632 user3 := getEnvOr("NEOMD_TEST_USER3", "simon@ssp.sh")
633
634 // Step 1: Send a group email from demo to user2, CC user3
635 origSubject := uniqueSubject("reply-all-orig")
636 origBody := "Original group email for reply-all test."
637
638 err := smtp.Send(env.smtpConfig(), user2, user3, "", origSubject, origBody, nil)
639 if err != nil {
640 t.Fatalf("Send original: %v", err)
641 }
642
643 // The email lands in demo's Sent (via SaveSent) but also in demo's INBOX
644 // if demo is in CC. Since demo is not in To/CC here, we save to Sent to
645 // have a copy to inspect.
646 raw, err := smtp.BuildMessage(env.from, user2, user3, origSubject, origBody, nil, "")
647 if err != nil {
648 t.Fatalf("BuildMessage: %v", err)
649 }
650 err = cli.SaveSent(context.Background(), "Sent", raw)
651 if err != nil {
652 t.Fatalf("SaveSent: %v", err)
653 }
654
655 original := waitForEmail(t, cli, "Sent", origSubject, 15*time.Second)
656 defer cleanupEmail(t, cli, "Sent", original.UID)
657
658 // Step 2: Simulate reply-all from user2's perspective.
659 // Reply-all logic: To = original sender, CC = all To + CC minus self.
660 replySubject := "Re: " + origSubject
661 replyBody := "Reply-all response.\n\n> " + origBody
662
663 // To = original sender (demo)
664 replyTo := env.user
665
666 // CC = original To + CC, minus the replier (user2)
667 allRecipients := original.To
668 if original.CC != "" {
669 allRecipients += ", " + original.CC
670 }
671 var replyCC []string
672 user2Lower := strings.ToLower(user2)
673 for _, addr := range strings.Split(allRecipients, ",") {
674 a := strings.TrimSpace(addr)
675 if a != "" && strings.ToLower(a) != user2Lower {
676 replyCC = append(replyCC, a)
677 }
678 }
679 replyCCStr := strings.Join(replyCC, ", ")
680
681 t.Logf("Reply-all: To=%s CC=%s", replyTo, replyCCStr)
682
683 err = smtp.Send(env.smtpConfig(), replyTo, replyCCStr, "", replySubject, replyBody, nil)
684 if err != nil {
685 t.Fatalf("Send reply-all: %v", err)
686 }
687
688 // Step 3: Verify the reply arrives at demo (the To recipient)
689 reply := waitForEmail(t, cli, "INBOX", replySubject, 30*time.Second)
690 defer cleanupEmail(t, cli, "INBOX", reply.UID)
691
692 if !strings.Contains(reply.Subject, "Re:") {
693 t.Errorf("Reply subject missing Re: prefix, got: %q", reply.Subject)
694 }
695
696 // To should be the demo account (original sender)
697 if !strings.Contains(reply.To, env.user) {
698 t.Errorf("Reply To missing demo address, got: %q", reply.To)
699 }
700
701 // CC should contain user3 (simon@ssp.sh)
702 if !strings.Contains(reply.CC, user3) {
703 t.Errorf("Reply CC missing %q, got: %q", user3, reply.CC)
704 }
705
706 t.Logf("Reply-all delivered: To=%s CC=%s", reply.To, reply.CC)
707}
708
709func TestIntegration_MarkAsRead(t *testing.T) {
710 env := loadEnv(t)
711 cli := env.imapClient()
712 defer cli.Close()
713
714 subject := uniqueSubject("mark-as-read")
715 body := "Testing mark-as-read functionality."
716
717 // Send test email to self
718 err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
719 if err != nil {
720 t.Fatalf("Send: %v", err)
721 }
722
723 // Wait for delivery
724 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
725 defer cleanupEmail(t, cli, "INBOX", email.UID)
726
727 // Initially unread
728 if email.Seen {
729 t.Error("newly delivered email should be unread (Seen=false)")
730 }
731
732 // Mark as seen
733 ctx := context.Background()
734 err = cli.MarkSeen(ctx, "INBOX", email.UID)
735 if err != nil {
736 t.Fatalf("MarkSeen: %v", err)
737 }
738
739 // Re-fetch to verify flag changed
740 emails, err := cli.FetchHeaders(ctx, "INBOX", 20)
741 if err != nil {
742 t.Fatalf("FetchHeaders after MarkSeen: %v", err)
743 }
744
745 var found *goIMAP.Email
746 for i := range emails {
747 if emails[i].UID == email.UID {
748 found = &emails[i]
749 break
750 }
751 }
752
753 if found == nil {
754 t.Fatal("email not found after MarkSeen")
755 }
756
757 if !found.Seen {
758 t.Error("email still unread after MarkSeen call")
759 }
760
761 // Test MarkUnseen
762 err = cli.MarkUnseen(ctx, "INBOX", email.UID)
763 if err != nil {
764 t.Fatalf("MarkUnseen: %v", err)
765 }
766
767 // Re-fetch to verify flag cleared
768 emails, err = cli.FetchHeaders(ctx, "INBOX", 20)
769 if err != nil {
770 t.Fatalf("FetchHeaders after MarkUnseen: %v", err)
771 }
772
773 found = nil
774 for i := range emails {
775 if emails[i].UID == email.UID {
776 found = &emails[i]
777 break
778 }
779 }
780
781 if found == nil {
782 t.Fatal("email not found after MarkUnseen")
783 }
784
785 if found.Seen {
786 t.Error("email still marked as read after MarkUnseen call")
787 }
788
789 t.Logf("Mark-as-read round-trip successful: UID=%d", email.UID)
790}
791
792func TestIntegration_EmailStandardsCompliance(t *testing.T) {
793 env := loadEnv(t)
794 cli := env.imapClient()
795 defer cli.Close()
796
797 subject := uniqueSubject("standards-check")
798 body := "Testing RFC 5322 email standards compliance.\n\nThis email validates:\n- Message-ID uses sender's domain\n- multipart/alternative structure\n- Proper MIME encoding"
799
800 // Build the message to inspect its structure before sending
801 raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil, "")
802 if err != nil {
803 t.Fatalf("BuildMessage: %v", err)
804 }
805
806 rawStr := string(raw)
807
808 // 1. Message-ID MUST use sender's domain (not @neomd or @localhost)
809 msgIDIdx := strings.Index(rawStr, "Message-ID:")
810 if msgIDIdx == -1 {
811 t.Fatal("Message-ID header missing")
812 }
813 msgIDLine := rawStr[msgIDIdx : msgIDIdx+strings.Index(rawStr[msgIDIdx:], "\n")]
814
815 // Extract domain from From address for validation
816 fromAddr := extractUser(env.from)
817 if fromAddr == "" {
818 fromAddr = env.user
819 }
820 domainIdx := strings.LastIndex(fromAddr, "@")
821 if domainIdx == -1 {
822 t.Fatalf("Cannot extract domain from From: %s", fromAddr)
823 }
824 expectedDomain := fromAddr[domainIdx+1:]
825
826 if !strings.Contains(msgIDLine, "@"+expectedDomain+">") {
827 t.Errorf("Message-ID should use sender's domain @%s, got: %s", expectedDomain, msgIDLine)
828 }
829 if strings.Contains(msgIDLine, "@neomd>") {
830 t.Errorf("Message-ID should not use hardcoded @neomd, got: %s", msgIDLine)
831 }
832 if strings.Contains(msgIDLine, "@localhost>") {
833 t.Errorf("Message-ID should not use @localhost fallback, got: %s", msgIDLine)
834 }
835 t.Logf("✓ Message-ID uses sender's domain: %s", msgIDLine)
836
837 // 2. Required RFC 5322 headers
838 requiredHeaders := []string{
839 "From:",
840 "To:",
841 "Subject:",
842 "Date:",
843 "Message-ID:",
844 "MIME-Version:",
845 "Content-Type:",
846 "X-Mailer:",
847 }
848 for _, hdr := range requiredHeaders {
849 if !strings.Contains(rawStr, hdr) {
850 t.Errorf("Required header missing: %s", hdr)
851 }
852 }
853 t.Logf("✓ All required headers present")
854
855 // 3. Verify multipart/alternative structure
856 if !strings.Contains(rawStr, "Content-Type: multipart/alternative") {
857 t.Error("Expected multipart/alternative content type")
858 }
859 t.Logf("✓ Uses multipart/alternative structure")
860
861 // 4. Verify text/plain comes before text/html (RFC 2046 requirement)
862 plainIdx := strings.Index(rawStr, "Content-Type: text/plain")
863 htmlIdx := strings.Index(rawStr, "Content-Type: text/html")
864 if plainIdx == -1 {
865 t.Error("text/plain part missing")
866 }
867 if htmlIdx == -1 {
868 t.Error("text/html part missing")
869 }
870 if plainIdx >= htmlIdx {
871 t.Errorf("text/plain must come before text/html (RFC 2046), got plain at %d, html at %d", plainIdx, htmlIdx)
872 }
873 t.Logf("✓ Correct part ordering: text/plain first, text/html second")
874
875 // 5. Verify quoted-printable encoding is used
876 if !strings.Contains(rawStr, "Content-Transfer-Encoding: quoted-printable") {
877 t.Error("Expected quoted-printable encoding")
878 }
879 t.Logf("✓ Uses quoted-printable encoding")
880
881 // 6. Verify X-Mailer header identifies neomd
882 if !strings.Contains(rawStr, "X-Mailer: neomd") {
883 t.Error("X-Mailer header should identify 'neomd'")
884 }
885 t.Logf("✓ X-Mailer header present")
886
887 // 7. Verify BCC header is NOT present (RFC 5322 privacy requirement)
888 if strings.Contains(rawStr, "\nBcc:") || strings.HasPrefix(rawStr, "Bcc:") {
889 t.Error("BCC header should never appear in message headers")
890 }
891 t.Logf("✓ BCC header correctly excluded")
892
893 // 8. Verify HTML part is valid (contains basic tags)
894 if !strings.Contains(rawStr, "<!DOCTYPE html>") {
895 t.Error("HTML part missing DOCTYPE declaration")
896 }
897 if !strings.Contains(rawStr, "<body>") || !strings.Contains(rawStr, "</body>") {
898 t.Error("HTML part missing body tags")
899 }
900 t.Logf("✓ HTML part is well-formed")
901
902 // Now actually send the email to verify end-to-end delivery
903 err = smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil)
904 if err != nil {
905 t.Fatalf("Send: %v", err)
906 }
907
908 // Wait for delivery and verify it arrives correctly
909 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
910 defer cleanupEmail(t, cli, "INBOX", email.UID)
911
912 // Fetch body to verify content survived delivery
913 ctx := context.Background()
914 markdown, rawHTML, _, _, _, _, err := cli.FetchBody(ctx, "INBOX", email.UID)
915 if err != nil {
916 t.Fatalf("FetchBody: %v", err)
917 }
918
919 if !strings.Contains(markdown, "RFC 5322") {
920 t.Errorf("Plain text part missing expected content after delivery, got: %s", truncate(markdown, 200))
921 }
922 t.Logf("✓ Plain text part is readable after delivery")
923
924 if !strings.Contains(rawHTML, "<!DOCTYPE html>") {
925 t.Error("HTML part missing DOCTYPE after delivery")
926 }
927 t.Logf("✓ HTML part survived delivery intact")
928
929 t.Log("\n=== Email Standards Compliance: ALL CHECKS PASSED ===")
930 t.Logf("Message-ID: Uses sender's domain @%s", expectedDomain)
931 t.Log("Headers: All required headers present")
932 t.Log("MIME: multipart/alternative with correct ordering")
933 t.Log("Encoding: quoted-printable")
934 t.Log("Privacy: BCC correctly excluded")
935 t.Log("Delivery: Email sent and received successfully")
936}
937
938// --- Helpers ---
939
940// TestIntegration_SecurityFeatures sends an email with a real attachment, a
941// disguised script (.sh content saved as .png), a callout, and an HTML signature.
942// The email arrives in the test inbox so you can verify attachment safety live in neomd.
943func TestIntegration_SecurityFeatures(t *testing.T) {
944 env := loadEnv(t)
945 cli := env.imapClient()
946 defer cli.Close()
947
948 subject := uniqueSubject("security-attach-callout")
949
950 // Create temp dir for attachments
951 dir := t.TempDir()
952
953 // 1. Real text attachment
954 realDoc := filepath.Join(dir, "meeting-notes.txt")
955 if err := os.WriteFile(realDoc, []byte("Meeting notes from 2026-04-28.\n\n- Discussed spy pixel blocking\n- Reviewed attachment safety"), 0600); err != nil {
956 t.Fatal(err)
957 }
958
959 // 2. Disguised script: bash content saved as .png
960 fakeImg := filepath.Join(dir, "totally-legit-photo.png")
961 if err := os.WriteFile(fakeImg, []byte("#!/bin/bash\necho 'this is not a real image'\n"), 0600); err != nil {
962 t.Fatal(err)
963 }
964
965 // Body with callout
966 body := `# Security Features Test
967
968This email tests neomd's security features.
969
970> [!warning] Attachment Safety Test
971> This email contains a disguised script (bash content saved as .png) alongside
972> a real text document. neomd should block the fake image from auto-opening.
973
974## Attachments included:
9751. **meeting-notes.txt** — real text file (safe)
9762. **totally-legit-photo.png** — actually a bash script (should be blocked by magic-byte check)
977
978*sent from [neomd](https://neomd.ssp.sh)*`
979
980 err := smtp.Send(env.smtpConfig(), env.user+env.ccRecipient(), "", "", subject, body, []string{realDoc, fakeImg})
981 if err != nil {
982 t.Fatalf("Send: %v", err)
983 }
984
985 email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second)
986 // Intentionally NOT cleaned up — kept in demo inbox for manual testing.
987
988 // Fetch and verify
989 markdown, rawHTML, _, attachments, _, spyPixels, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
990 if err != nil {
991 t.Fatalf("FetchBody: %v", err)
992 }
993
994 // Verify callout rendered in HTML
995 if !strings.Contains(rawHTML, "callout") || !strings.Contains(rawHTML, "warning") {
996 t.Logf("HTML (truncated): %s", truncate(rawHTML, 300))
997 t.Error("expected callout markup in HTML body")
998 }
999
1000 // Verify at least 2 attachments arrived
1001 if len(attachments) < 2 {
1002 t.Errorf("expected at least 2 attachments, got %d", len(attachments))
1003 }
1004 for _, a := range attachments {
1005 t.Logf("Attachment: %s (%s, %d bytes)", a.Filename, a.ContentType, len(a.Data))
1006 }
1007
1008 // Verify the disguised script would be caught by magic-byte check
1009 for _, a := range attachments {
1010 if strings.Contains(a.Filename, "totally-legit") {
1011 detected := http.DetectContentType(a.Data)
1012 if strings.HasPrefix(detected, "image/") {
1013 t.Errorf("disguised script detected as image — magic bytes failed: %s", detected)
1014 } else {
1015 t.Logf("Correctly detected disguised script as: %s (not image/)", detected)
1016 }
1017 }
1018 }
1019
1020 t.Logf("Spy pixels: %d (expected 0 for self-sent)", spyPixels.Count)
1021 t.Logf("Markdown preview: %s", truncate(markdown, 200))
1022}
1023
1024// TestIntegration_BrowserSanitization sends an email with inline script tags,
1025// an iframe, and an event handler to verify that SanitizeForBrowser blocks them
1026// when opened with O in neomd. Also sent to simon@ssp.sh for live inspection.
1027func TestIntegration_BrowserSanitization(t *testing.T) {
1028 env := loadEnv(t)
1029 cli := env.imapClient()
1030 defer cli.Close()
1031
1032 subject := uniqueSubject("browser-csp-test")
1033
1034 body := `# Browser Sanitization Test
1035
1036Open this email with **O** in neomd to test CSP protection.
1037
1038## What to check in the browser:
1039
10401. The **script alert should NOT fire** — if you see a popup saying "XSS worked", the CSP failed
10412. The **iframe should NOT load** — you should see an empty space, not an embedded page
10423. The **image should load normally** — the neomd logo below should be visible
10434. The **onload handler should NOT fire** — no "event handler" alert
1044
1045If everything works: you see the image, no popups, no iframe content.
1046
1047
1048
1049*sent from [neomd](https://neomd.ssp.sh) — CSP test*`
1050
1051 // Send normally — the HTML will contain the markdown-rendered content.
1052 // To also test raw HTML injection, we build a custom message with injected tags.
1053 raw, err := smtp.BuildMessage(env.from, env.user+env.ccRecipient(), "", subject, body, nil, "")
1054 if err != nil {
1055 t.Fatalf("BuildMessage: %v", err)
1056 }
1057
1058 // Inject malicious HTML into the raw MIME before sending.
1059 // These should all be blocked by the CSP when opened with O.
1060 injection := `<script>alert('XSS worked! CSP is broken!')</script>` +
1061 `<iframe src="https://example.com" width="400" height="200"></iframe>` +
1062 `<img src="https://raw.githubusercontent.com/ssp-data/neomd/main/docs/static/images/overview-email-feed.png" onload="alert('event handler fired! CSP broken!')" alt="test image">` +
1063 `<p style="color:green;font-size:20px;font-weight:bold;">If you see this text but NO popups and NO iframe, the CSP is working correctly.</p>`
1064
1065 // Insert injection before </body> in the HTML part
1066 rawStr := string(raw)
1067 if idx := strings.LastIndex(rawStr, "</body>"); idx >= 0 {
1068 rawStr = rawStr[:idx] + injection + rawStr[idx:]
1069 }
1070
1071 allRecipients := []string{env.user}
1072 if cc := env.ccRecipient(); cc != "" {
1073 allRecipients = append(allRecipients, strings.TrimPrefix(cc, ", "))
1074 }
1075 if err := smtp.SendRaw(env.smtpConfig(), allRecipients, []byte(rawStr)); err != nil {
1076 t.Fatalf("SendRaw: %v", err)
1077 }
1078
1079 email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second)
1080 // Intentionally NOT cleaned up — kept in demo inbox for manual testing.
1081
1082 _, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
1083 if err != nil {
1084 t.Fatalf("FetchBody: %v", err)
1085 }
1086
1087 // Verify the malicious content is present in raw HTML (it should be — CSP blocks execution, not content)
1088 if !strings.Contains(rawHTML, "<script>") {
1089 t.Error("expected <script> tag in raw HTML (CSP should block execution, not strip content)")
1090 }
1091 if !strings.Contains(rawHTML, "<iframe") {
1092 t.Error("expected <iframe> tag in raw HTML (CSP should block loading, not strip content)")
1093 }
1094
1095 t.Log("Email sent with script/iframe/onload injection.")
1096 t.Log("Open with O in neomd — you should see the image and green text, but NO popups and NO iframe content.")
1097}
1098
1099func extractUser(from string) string {
1100 if i := strings.Index(from, "<"); i >= 0 {
1101 if j := strings.Index(from, ">"); j > i {
1102 return from[i+1 : j]
1103 }
1104 }
1105 return from
1106}
1107
1108func truncate(s string, n int) string {
1109 if len(s) <= n {
1110 return s
1111 }
1112 return s[:n] + "…"
1113}