A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1113 lines 35 kB view raw
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![logo](%s)\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![neomd logo](https://raw.githubusercontent.com/ssp-data/neomd/main/docs/static/images/overview-email-feed.png) 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}