Something went wrong. Try again.
Keyboard-first TUI email: write in Neovim, render as Markdown, screen senders first, organize emails once. neomd.ssp.sh
email markdown neovim tui
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114// Package integration_test runs end-to-end tests against a real IMAP/SMTP server.//// Skipped unless NEOMD_TEST_IMAP_HOST is set. Run with://// make test-integration//// These tests send real emails to the test account (sends to itself) and// clean up after. They require network access and valid credentials.package integration_test
import ( "context" "fmt" "net/http" "os" "path/filepath" "strings" "testing" "time"
goIMAP "github.com/sspaeti/neomd/internal/imap" "github.com/sspaeti/neomd/internal/smtp")
// testEnv holds credentials loaded from environment variables.type testEnv struct { imapHost string imapPort string smtpHost string smtpPort string user string password string from string}
func loadEnv(t *testing.T) testEnv { t.Helper() host := os.Getenv("NEOMD_TEST_IMAP_HOST") if host == "" { t.Skip("set NEOMD_TEST_IMAP_HOST to run integration tests") } env := testEnv{ imapHost: host, imapPort: getEnvOr("NEOMD_TEST_IMAP_PORT", "993"), smtpHost: getEnvOr("NEOMD_TEST_SMTP_HOST", host), smtpPort: getEnvOr("NEOMD_TEST_SMTP_PORT", "587"), user: os.Getenv("NEOMD_TEST_USER"), password: os.Getenv("NEOMD_TEST_PASS"), from: os.Getenv("NEOMD_TEST_FROM"), } if env.user == "" || env.password == "" { t.Skip("set NEOMD_TEST_USER and NEOMD_TEST_PASS") } if env.from == "" { env.from = env.user } return env}
func getEnvOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback}
func (e testEnv) imapClient() *goIMAP.Client { return goIMAP.New(goIMAP.Config{ Host: e.imapHost, Port: e.imapPort, User: e.user, Password: e.password, TLS: e.imapPort == "993", STARTTLS: e.imapPort == "143", })}
// ccRecipient returns ", addr" if NEOMD_TEST_CC is set, empty string otherwise.// Used to optionally CC test emails to a live inbox for manual inspection.func (e testEnv) ccRecipient() string { if cc := os.Getenv("NEOMD_TEST_CC"); cc != "" { return ", " + cc } return ""}
func (e testEnv) smtpConfig() smtp.Config { return smtp.Config{ Host: e.smtpHost, Port: e.smtpPort, User: e.user, Password: e.password, From: e.from, }}
// uniqueSubject returns a unique subject for test isolation.func uniqueSubject(name string) string { return fmt.Sprintf("[neomd-test] %s %d", name, time.Now().UnixNano())}
// waitForEmail polls IMAP until an email with the given subject substring appears, or times out.// Uses FetchHeaders (not SEARCH) to avoid IMAP SEARCH substring quirks with special chars.func waitForEmail(t *testing.T, cli *goIMAP.Client, folder, subject string, timeout time.Duration) *goIMAP.Email { t.Helper() ctx := context.Background() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { emails, err := cli.FetchHeaders(ctx, folder, 20) if err == nil { for i := range emails { if strings.Contains(emails[i].Subject, subject) { return &emails[i] } } } time.Sleep(2 * time.Second) } t.Fatalf("email with subject %q not found in %s after %v", subject, folder, timeout) return nil}
// cleanupEmail permanently deletes a test email.func cleanupEmail(t *testing.T, cli *goIMAP.Client, folder string, uid uint32) { t.Helper() ctx := context.Background() if err := cli.ExpungeAll(ctx, folder, []uint32{uid}); err != nil { t.Logf("cleanup warning: %v", err) }}
// --- Tests ---
func TestIntegration_IMAPConnect(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
if err := cli.Ping(context.Background()); err != nil { t.Fatalf("IMAP ping failed: %v", err) }}
func TestIntegration_IMAPFetchHeaders(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
emails, err := cli.FetchHeaders(context.Background(), "INBOX", 5) if err != nil { t.Fatalf("FetchHeaders: %v", err) } // Just verify it returns without error and emails have basic fields for _, e := range emails { if e.UID == 0 { t.Error("email has UID 0") } if e.Subject == "" && e.From == "" { t.Error("email has no subject and no from") } }}
func TestIntegration_SendPlainEmail(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("plain") body := "Hello from neomd integration test.\n\nThis is **bold** and this is a [link](https://ssp.sh)."
// Send to self err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
// Wait for delivery and fetch email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Verify headers if !strings.Contains(email.From, env.user) && !strings.Contains(email.From, extractUser(env.from)) { t.Errorf("From = %q, expected to contain %q", email.From, env.user) } if email.Subject != subject { t.Errorf("Subject = %q, want %q", email.Subject, subject) }
// Fetch body and verify content markdown, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) if err != nil { t.Fatalf("FetchBody: %v", err) } if !strings.Contains(markdown, "neomd integration test") { t.Errorf("body missing expected text, got: %s", truncate(markdown, 200)) } if rawHTML == "" { t.Error("expected HTML part in multipart/alternative, got empty") } if !strings.Contains(rawHTML, "<strong>bold</strong>") { t.Errorf("HTML part missing <strong>bold</strong>, got: %s", truncate(rawHTML, 200)) } if !strings.Contains(rawHTML, `href="https://ssp.sh"`) { t.Errorf("HTML part missing link href, got: %s", truncate(rawHTML, 200)) }}
func TestIntegration_SendWithCC(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("cc") body := "Testing CC header."
// CC to self (same address, just verifying the header round-trips) err := smtp.Send(env.smtpConfig(), env.user, env.user, "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Fetch raw body to check CC header markdown, _, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) if err != nil { t.Fatalf("FetchBody: %v", err) } _ = markdown // CC is in envelope, not body — verify via headers if available // The email arrived with CC set; IMAP envelope should have it if email.CC == "" { t.Logf("Note: CC not populated in Email struct (CC field may not be fetched by FetchHeaders)") }}
func TestIntegration_SendWithAttachment(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("attach") body := "Email with attachment."
// Create a test file to attach dir := t.TempDir() attachPath := filepath.Join(dir, "test-document.txt") if err := os.WriteFile(attachPath, []byte("This is the attachment content from neomd test."), 0600); err != nil { t.Fatal(err) }
err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, []string{attachPath}) if err != nil { t.Fatalf("Send: %v", err) }
email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Fetch body — attachments should be listed _, _, _, attachments, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) if err != nil { t.Fatalf("FetchBody: %v", err) } if len(attachments) == 0 { t.Fatal("expected at least 1 attachment, got 0") }
found := false for _, a := range attachments { if strings.Contains(a.Filename, "test-document") { found = true if len(a.Data) == 0 { t.Error("attachment data is empty") } if !strings.Contains(string(a.Data), "attachment content from neomd test") { t.Errorf("attachment content mismatch, got %d bytes", len(a.Data)) } } } if !found { names := make([]string, len(attachments)) for i, a := range attachments { names[i] = a.Filename } t.Errorf("attachment 'test-document.txt' not found, got: %v", names) }}
func TestIntegration_SendNonASCIISubject(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("Ünïcödé Tëst 🚀") body := "Testing non-ASCII subject encoding."
err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
email := waitForEmail(t, cli, "INBOX", "Tëst", 30*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Subject should survive Q-encoding round-trip if !strings.Contains(email.Subject, "Ünïcödé") { t.Errorf("Subject = %q, expected to contain 'Ünïcödé'", email.Subject) } if !strings.Contains(email.Subject, "🚀") { t.Errorf("Subject = %q, expected to contain emoji", email.Subject) }}
func TestIntegration_IMAPSearch(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
// Send a unique email to search for subject := uniqueSubject("search-target") body := "This email exists to be found by IMAP SEARCH."
err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Test subject: prefix search results, err := cli.SearchMessages(context.Background(), "INBOX", "subject:"+subject) if err != nil { t.Fatalf("SearchMessages: %v", err) } if len(results) == 0 { t.Fatal("subject: search returned no results") }
// Test from: prefix search results, err = cli.SearchMessages(context.Background(), "INBOX", "from:"+env.user) if err != nil { t.Fatalf("SearchMessages from: %v", err) } if len(results) == 0 { t.Fatal("from: search returned no results") }}
func TestIntegration_IMAPMoveAndUndo(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
// Ensure test folder exists testFolder := "NeomdTest" _, err := cli.EnsureFolders(context.Background(), []string{testFolder}) if err != nil { t.Fatalf("EnsureFolders: %v", err) }
// Send an email to move subject := uniqueSubject("move-test") body := "This email will be moved."
err = smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
// Move to test folder destUID, err := cli.MoveMessage(context.Background(), "INBOX", email.UID, testFolder) if err != nil { cleanupEmail(t, cli, "INBOX", email.UID) t.Fatalf("MoveMessage: %v", err) } if destUID == 0 { t.Error("MoveMessage returned destUID 0") }
// Verify it's in the test folder moved := waitForEmail(t, cli, testFolder, subject, 10*time.Second)
// Move back (undo) _, err = cli.MoveMessage(context.Background(), testFolder, moved.UID, "INBOX") if err != nil { cleanupEmail(t, cli, testFolder, moved.UID) t.Fatalf("MoveMessage (undo): %v", err) }
// Verify back in INBOX and cleanup restored := waitForEmail(t, cli, "INBOX", subject, 10*time.Second) cleanupEmail(t, cli, "INBOX", restored.UID)}
func TestIntegration_SendWithInlineImage(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("inline-img")
// Create a minimal 1x1 PNG in a temp dir dir := t.TempDir() imgPath := filepath.Join(dir, "test-logo.png") // Minimal valid PNG: 1x1 red pixel png := []byte{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk 0x44, 0xae, 0x42, 0x60, 0x82, } if err := os.WriteFile(imgPath, png, 0600); err != nil { t.Fatal(err) }
// Markdown with image reference — goldmark produces <img src="/path"> // which buildMessage rewrites to cid: for inline embedding. body := fmt.Sprintf("Here is an inline image:\n\n\n\nEnd of email.", imgPath)
err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Fetch body — inline image should appear as attachment with image content type _, rawHTML, _, attachments, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) if err != nil { t.Fatalf("FetchBody: %v", err) }
// HTML should contain cid: reference (inline image) if !strings.Contains(rawHTML, "cid:") { t.Logf("HTML body (truncated): %s", truncate(rawHTML, 500)) t.Error("expected cid: reference in HTML for inline image") }
// Should have at least one image attachment foundImage := false for _, a := range attachments { if strings.HasPrefix(a.ContentType, "image/") { foundImage = true if len(a.Data) == 0 { t.Error("inline image data is empty") } } } if !foundImage { names := make([]string, len(attachments)) for i, a := range attachments { names[i] = fmt.Sprintf("%s (%s)", a.Filename, a.ContentType) } t.Errorf("no image attachment found, got: %v", names) }}
func TestIntegration_SignatureRenderedInHTML(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("signature") // Simulate a compose with signature and callouts (same format as editor.Prelude adds) body := "Hi team,\n\n" + "Here's the update on the project:\n\n" + "> [!tip] Good News\n" + "> We're ahead of schedule! The new feature shipped yesterday.\n\n" + "> [!warning] Action Required\n" + "> Please review the security audit by Friday.\n\n" + "> [!note] note\n" + "> Please read\n\n" + "Thanks,\n" + "Simon\n\n" + "-- \n" + "**Simon Späti**\n" + "Data Engineer, [SSP Data](https://ssp.sh/)\n"
err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
markdown, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) if err != nil { t.Fatalf("FetchBody: %v", err) }
// Plain text part should contain the signature as-is if !strings.Contains(markdown, "Simon Späti") { t.Errorf("plain text missing signature name, got: %s", truncate(markdown, 300)) }
// HTML part should render signature with formatting if !strings.Contains(rawHTML, "<strong>Simon Späti</strong>") { t.Errorf("HTML missing bold signature name, got: %s", truncate(rawHTML, 500)) } if !strings.Contains(rawHTML, `href="https://ssp.sh/"`) { t.Errorf("HTML missing signature link, got: %s", truncate(rawHTML, 500)) }
// Body content before signature should also be rendered if !strings.Contains(rawHTML, "update on the project") { t.Errorf("HTML missing email body text, got: %s", truncate(rawHTML, 500)) }
// Callout rendering verification if !strings.Contains(rawHTML, "callout callout-tip") { t.Errorf("HTML missing tip callout class, got: %s", truncate(rawHTML, 800)) } if !strings.Contains(rawHTML, "callout callout-warning") { t.Errorf("HTML missing warning callout class, got: %s", truncate(rawHTML, 800)) } if !strings.Contains(rawHTML, "callout callout-note") { t.Errorf("HTML missing note callout class, got: %s", truncate(rawHTML, 800)) } if !strings.Contains(rawHTML, "💡") { // Light bulb emoji for tip t.Errorf("HTML missing tip callout icon, got: %s", truncate(rawHTML, 800)) } if !strings.Contains(rawHTML, "⚠️") { // Warning sign emoji t.Errorf("HTML missing warning callout icon, got: %s", truncate(rawHTML, 800)) } if !strings.Contains(rawHTML, "Good News") { t.Errorf("HTML missing custom callout title, got: %s", truncate(rawHTML, 800)) } if !strings.Contains(rawHTML, "ahead of schedule") { t.Errorf("HTML missing callout content, got: %s", truncate(rawHTML, 800)) }}
func TestIntegration_SaveSent(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("save-sent") body := "This email tests SaveSent IMAP APPEND."
// Build the message (same as neomd does before sending) raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil, "") if err != nil { t.Fatalf("BuildMessage: %v", err) }
// Save to Sent via IMAP APPEND (no actual SMTP send needed) err = cli.SaveSent(context.Background(), "Sent", raw) if err != nil { t.Fatalf("SaveSent: %v", err) }
// Verify it appears in the Sent folder email := waitForEmail(t, cli, "Sent", subject, 15*time.Second) defer cleanupEmail(t, cli, "Sent", email.UID)
if email.Subject != subject { t.Errorf("Sent email subject = %q, want %q", email.Subject, subject) }
// Verify it's marked as read (\Seen flag) if !email.Seen { t.Error("SaveSent email should have \\Seen flag") }}
func TestIntegration_MultipleRecipients(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
// Use a second address for the test. NEOMD_TEST_USER2 can be set to a // real second account; falls back to the same address (still tests parsing). user2 := getEnvOr("NEOMD_TEST_USER2", "simu@sspaeti.com")
subject := uniqueSubject("multi-rcpt") body := "Testing comma-separated To, CC, and BCC."
// Comma-separated To: two different addresses // CC: the test account itself // This exercises the bug we fixed: Send() must split To by comma. to := env.user + ", " + user2 cc := env.user
err := smtp.Send(env.smtpConfig(), to, cc, "", subject, body, nil) if err != nil { t.Fatalf("Send with comma-separated To: %v", err) }
// Verify delivery to primary test account email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Verify To field contains both addresses (not just the first) if !strings.Contains(email.To, env.user) { t.Errorf("To field missing primary address, got: %q", email.To) } if !strings.Contains(email.To, user2) { t.Errorf("To field missing second address %q, got: %q", user2, email.To) }
// Verify CC is populated if email.CC == "" { t.Logf("Note: CC not populated in envelope (fetch path may not include it)") } else if !strings.Contains(email.CC, env.user) { t.Errorf("CC field missing %q, got: %q", env.user, email.CC) }
t.Logf("Email delivered with To: %s, CC: %s", email.To, email.CC)}
func TestIntegration_ReplyAllPreservesRecipients(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
// Three distinct addresses to properly test reply-all. // demo sends to simu + simon, then reply-all should CC both back. user2 := getEnvOr("NEOMD_TEST_USER2", "simu@sspaeti.com") user3 := getEnvOr("NEOMD_TEST_USER3", "simon@ssp.sh")
// Step 1: Send a group email from demo to user2, CC user3 origSubject := uniqueSubject("reply-all-orig") origBody := "Original group email for reply-all test."
err := smtp.Send(env.smtpConfig(), user2, user3, "", origSubject, origBody, nil) if err != nil { t.Fatalf("Send original: %v", err) }
// The email lands in demo's Sent (via SaveSent) but also in demo's INBOX // if demo is in CC. Since demo is not in To/CC here, we save to Sent to // have a copy to inspect. raw, err := smtp.BuildMessage(env.from, user2, user3, origSubject, origBody, nil, "") if err != nil { t.Fatalf("BuildMessage: %v", err) } err = cli.SaveSent(context.Background(), "Sent", raw) if err != nil { t.Fatalf("SaveSent: %v", err) }
original := waitForEmail(t, cli, "Sent", origSubject, 15*time.Second) defer cleanupEmail(t, cli, "Sent", original.UID)
// Step 2: Simulate reply-all from user2's perspective. // Reply-all logic: To = original sender, CC = all To + CC minus self. replySubject := "Re: " + origSubject replyBody := "Reply-all response.\n\n> " + origBody
// To = original sender (demo) replyTo := env.user
// CC = original To + CC, minus the replier (user2) allRecipients := original.To if original.CC != "" { allRecipients += ", " + original.CC } var replyCC []string user2Lower := strings.ToLower(user2) for _, addr := range strings.Split(allRecipients, ",") { a := strings.TrimSpace(addr) if a != "" && strings.ToLower(a) != user2Lower { replyCC = append(replyCC, a) } } replyCCStr := strings.Join(replyCC, ", ")
t.Logf("Reply-all: To=%s CC=%s", replyTo, replyCCStr)
err = smtp.Send(env.smtpConfig(), replyTo, replyCCStr, "", replySubject, replyBody, nil) if err != nil { t.Fatalf("Send reply-all: %v", err) }
// Step 3: Verify the reply arrives at demo (the To recipient) reply := waitForEmail(t, cli, "INBOX", replySubject, 30*time.Second) defer cleanupEmail(t, cli, "INBOX", reply.UID)
if !strings.Contains(reply.Subject, "Re:") { t.Errorf("Reply subject missing Re: prefix, got: %q", reply.Subject) }
// To should be the demo account (original sender) if !strings.Contains(reply.To, env.user) { t.Errorf("Reply To missing demo address, got: %q", reply.To) }
// CC should contain user3 (simon@ssp.sh) if !strings.Contains(reply.CC, user3) { t.Errorf("Reply CC missing %q, got: %q", user3, reply.CC) }
t.Logf("Reply-all delivered: To=%s CC=%s", reply.To, reply.CC)}
func TestIntegration_MarkAsRead(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("mark-as-read") body := "Testing mark-as-read functionality."
// Send test email to self err := smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
// Wait for delivery email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Initially unread if email.Seen { t.Error("newly delivered email should be unread (Seen=false)") }
// Mark as seen ctx := context.Background() err = cli.MarkSeen(ctx, "INBOX", email.UID) if err != nil { t.Fatalf("MarkSeen: %v", err) }
// Re-fetch to verify flag changed emails, err := cli.FetchHeaders(ctx, "INBOX", 20) if err != nil { t.Fatalf("FetchHeaders after MarkSeen: %v", err) }
var found *goIMAP.Email for i := range emails { if emails[i].UID == email.UID { found = &emails[i] break } }
if found == nil { t.Fatal("email not found after MarkSeen") }
if !found.Seen { t.Error("email still unread after MarkSeen call") }
// Test MarkUnseen err = cli.MarkUnseen(ctx, "INBOX", email.UID) if err != nil { t.Fatalf("MarkUnseen: %v", err) }
// Re-fetch to verify flag cleared emails, err = cli.FetchHeaders(ctx, "INBOX", 20) if err != nil { t.Fatalf("FetchHeaders after MarkUnseen: %v", err) }
found = nil for i := range emails { if emails[i].UID == email.UID { found = &emails[i] break } }
if found == nil { t.Fatal("email not found after MarkUnseen") }
if found.Seen { t.Error("email still marked as read after MarkUnseen call") }
t.Logf("Mark-as-read round-trip successful: UID=%d", email.UID)}
func TestIntegration_EmailStandardsCompliance(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("standards-check") 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"
// Build the message to inspect its structure before sending raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil, "") if err != nil { t.Fatalf("BuildMessage: %v", err) }
rawStr := string(raw)
// 1. Message-ID MUST use sender's domain (not @neomd or @localhost) msgIDIdx := strings.Index(rawStr, "Message-ID:") if msgIDIdx == -1 { t.Fatal("Message-ID header missing") } msgIDLine := rawStr[msgIDIdx : msgIDIdx+strings.Index(rawStr[msgIDIdx:], "\n")]
// Extract domain from From address for validation fromAddr := extractUser(env.from) if fromAddr == "" { fromAddr = env.user } domainIdx := strings.LastIndex(fromAddr, "@") if domainIdx == -1 { t.Fatalf("Cannot extract domain from From: %s", fromAddr) } expectedDomain := fromAddr[domainIdx+1:]
if !strings.Contains(msgIDLine, "@"+expectedDomain+">") { t.Errorf("Message-ID should use sender's domain @%s, got: %s", expectedDomain, msgIDLine) } if strings.Contains(msgIDLine, "@neomd>") { t.Errorf("Message-ID should not use hardcoded @neomd, got: %s", msgIDLine) } if strings.Contains(msgIDLine, "@localhost>") { t.Errorf("Message-ID should not use @localhost fallback, got: %s", msgIDLine) } t.Logf("✓ Message-ID uses sender's domain: %s", msgIDLine)
// 2. Required RFC 5322 headers requiredHeaders := []string{ "From:", "To:", "Subject:", "Date:", "Message-ID:", "MIME-Version:", "Content-Type:", "X-Mailer:", } for _, hdr := range requiredHeaders { if !strings.Contains(rawStr, hdr) { t.Errorf("Required header missing: %s", hdr) } } t.Logf("✓ All required headers present")
// 3. Verify multipart/alternative structure if !strings.Contains(rawStr, "Content-Type: multipart/alternative") { t.Error("Expected multipart/alternative content type") } t.Logf("✓ Uses multipart/alternative structure")
// 4. Verify text/plain comes before text/html (RFC 2046 requirement) plainIdx := strings.Index(rawStr, "Content-Type: text/plain") htmlIdx := strings.Index(rawStr, "Content-Type: text/html") if plainIdx == -1 { t.Error("text/plain part missing") } if htmlIdx == -1 { t.Error("text/html part missing") } if plainIdx >= htmlIdx { t.Errorf("text/plain must come before text/html (RFC 2046), got plain at %d, html at %d", plainIdx, htmlIdx) } t.Logf("✓ Correct part ordering: text/plain first, text/html second")
// 5. Verify quoted-printable encoding is used if !strings.Contains(rawStr, "Content-Transfer-Encoding: quoted-printable") { t.Error("Expected quoted-printable encoding") } t.Logf("✓ Uses quoted-printable encoding")
// 6. Verify X-Mailer header identifies neomd if !strings.Contains(rawStr, "X-Mailer: neomd") { t.Error("X-Mailer header should identify 'neomd'") } t.Logf("✓ X-Mailer header present")
// 7. Verify BCC header is NOT present (RFC 5322 privacy requirement) if strings.Contains(rawStr, "\nBcc:") || strings.HasPrefix(rawStr, "Bcc:") { t.Error("BCC header should never appear in message headers") } t.Logf("✓ BCC header correctly excluded")
// 8. Verify HTML part is valid (contains basic tags) if !strings.Contains(rawStr, "<!DOCTYPE html>") { t.Error("HTML part missing DOCTYPE declaration") } if !strings.Contains(rawStr, "<body>") || !strings.Contains(rawStr, "</body>") { t.Error("HTML part missing body tags") } t.Logf("✓ HTML part is well-formed")
// Now actually send the email to verify end-to-end delivery err = smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) if err != nil { t.Fatalf("Send: %v", err) }
// Wait for delivery and verify it arrives correctly email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) defer cleanupEmail(t, cli, "INBOX", email.UID)
// Fetch body to verify content survived delivery ctx := context.Background() markdown, rawHTML, _, _, _, _, err := cli.FetchBody(ctx, "INBOX", email.UID) if err != nil { t.Fatalf("FetchBody: %v", err) }
if !strings.Contains(markdown, "RFC 5322") { t.Errorf("Plain text part missing expected content after delivery, got: %s", truncate(markdown, 200)) } t.Logf("✓ Plain text part is readable after delivery")
if !strings.Contains(rawHTML, "<!DOCTYPE html>") { t.Error("HTML part missing DOCTYPE after delivery") } t.Logf("✓ HTML part survived delivery intact")
t.Log("\n=== Email Standards Compliance: ALL CHECKS PASSED ===") t.Logf("Message-ID: Uses sender's domain @%s", expectedDomain) t.Log("Headers: All required headers present") t.Log("MIME: multipart/alternative with correct ordering") t.Log("Encoding: quoted-printable") t.Log("Privacy: BCC correctly excluded") t.Log("Delivery: Email sent and received successfully")}
// --- Helpers ---
// TestIntegration_SecurityFeatures sends an email with a real attachment, a// disguised script (.sh content saved as .png), a callout, and an HTML signature.// The email arrives in the test inbox so you can verify attachment safety live in neomd.func TestIntegration_SecurityFeatures(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("security-attach-callout")
// Create temp dir for attachments dir := t.TempDir()
// 1. Real text attachment realDoc := filepath.Join(dir, "meeting-notes.txt") 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 { t.Fatal(err) }
// 2. Disguised script: bash content saved as .png fakeImg := filepath.Join(dir, "totally-legit-photo.png") if err := os.WriteFile(fakeImg, []byte("#!/bin/bash\necho 'this is not a real image'\n"), 0600); err != nil { t.Fatal(err) }
// Body with callout body := `# Security Features Test
This email tests neomd's security features.
> [!warning] Attachment Safety Test> This email contains a disguised script (bash content saved as .png) alongside> a real text document. neomd should block the fake image from auto-opening.
## Attachments included:1. **meeting-notes.txt** — real text file (safe)2. **totally-legit-photo.png** — actually a bash script (should be blocked by magic-byte check)
*sent from [neomd](https://neomd.ssp.sh)*`
err := smtp.Send(env.smtpConfig(), env.user+env.ccRecipient(), "", "", subject, body, []string{realDoc, fakeImg}) if err != nil { t.Fatalf("Send: %v", err) }
email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second) // Intentionally NOT cleaned up — kept in demo inbox for manual testing.
// Fetch and verify markdown, rawHTML, _, attachments, _, spyPixels, err := cli.FetchBody(context.Background(), "INBOX", email.UID) if err != nil { t.Fatalf("FetchBody: %v", err) }
// Verify callout rendered in HTML if !strings.Contains(rawHTML, "callout") || !strings.Contains(rawHTML, "warning") { t.Logf("HTML (truncated): %s", truncate(rawHTML, 300)) t.Error("expected callout markup in HTML body") }
// Verify at least 2 attachments arrived if len(attachments) < 2 { t.Errorf("expected at least 2 attachments, got %d", len(attachments)) } for _, a := range attachments { t.Logf("Attachment: %s (%s, %d bytes)", a.Filename, a.ContentType, len(a.Data)) }
// Verify the disguised script would be caught by magic-byte check for _, a := range attachments { if strings.Contains(a.Filename, "totally-legit") { detected := http.DetectContentType(a.Data) if strings.HasPrefix(detected, "image/") { t.Errorf("disguised script detected as image — magic bytes failed: %s", detected) } else { t.Logf("Correctly detected disguised script as: %s (not image/)", detected) } } }
t.Logf("Spy pixels: %d (expected 0 for self-sent)", spyPixels.Count) t.Logf("Markdown preview: %s", truncate(markdown, 200))}
// TestIntegration_BrowserSanitization sends an email with inline script tags,// an iframe, and an event handler to verify that SanitizeForBrowser blocks them// when opened with O in neomd. Also sent to simon@ssp.sh for live inspection.func TestIntegration_BrowserSanitization(t *testing.T) { env := loadEnv(t) cli := env.imapClient() defer cli.Close()
subject := uniqueSubject("browser-csp-test")
body := `# Browser Sanitization Test
Open this email with **O** in neomd to test CSP protection.
## What to check in the browser:
1. The **script alert should NOT fire** — if you see a popup saying "XSS worked", the CSP failed2. The **iframe should NOT load** — you should see an empty space, not an embedded page3. The **image should load normally** — the neomd logo below should be visible4. The **onload handler should NOT fire** — no "event handler" alert
If everything works: you see the image, no popups, no iframe content.

*sent from [neomd](https://neomd.ssp.sh) — CSP test*`
// Send normally — the HTML will contain the markdown-rendered content. // To also test raw HTML injection, we build a custom message with injected tags. raw, err := smtp.BuildMessage(env.from, env.user+env.ccRecipient(), "", subject, body, nil, "") if err != nil { t.Fatalf("BuildMessage: %v", err) }
// Inject malicious HTML into the raw MIME before sending. // These should all be blocked by the CSP when opened with O. injection := `<script>alert('XSS worked! CSP is broken!')</script>` + `<iframe src="https://example.com" width="400" height="200"></iframe>` + `<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">` + `<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>`
// Insert injection before </body> in the HTML part rawStr := string(raw) if idx := strings.LastIndex(rawStr, "</body>"); idx >= 0 { rawStr = rawStr[:idx] + injection + rawStr[idx:] }
allRecipients := []string{env.user} if cc := env.ccRecipient(); cc != "" { allRecipients = append(allRecipients, strings.TrimPrefix(cc, ", ")) } if err := smtp.SendRaw(env.smtpConfig(), allRecipients, []byte(rawStr)); err != nil { t.Fatalf("SendRaw: %v", err) }
email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second) // Intentionally NOT cleaned up — kept in demo inbox for manual testing.
_, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) if err != nil { t.Fatalf("FetchBody: %v", err) }
// Verify the malicious content is present in raw HTML (it should be — CSP blocks execution, not content) if !strings.Contains(rawHTML, "<script>") { t.Error("expected <script> tag in raw HTML (CSP should block execution, not strip content)") } if !strings.Contains(rawHTML, "<iframe") { t.Error("expected <iframe> tag in raw HTML (CSP should block loading, not strip content)") }
t.Log("Email sent with script/iframe/onload injection.") t.Log("Open with O in neomd — you should see the image and green text, but NO popups and NO iframe content.")}
func extractUser(from string) string { if i := strings.Index(from, "<"); i >= 0 { if j := strings.Index(from, ">"); j > i { return from[i+1 : j] } } return from}
func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…"}