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.

Fix Message-ID to use sender's domain

- Extract domain from From address instead of hardcoded @neomd
- Add extractDomain() function with fallback to localhost
- Update all 3 Message-ID generation points (send/draft/reply)
- Add comprehensive tests and RFC compliance integration test
- Document email standards in docs/email-standards.md

Fixes RFC 5322 recommendation for Message-ID to contain sender's
domain. Improves spam filter compatibility and email threading.

+545 -5
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 # 2026-04-21 4 + - **RFC 5322 compliant Message-ID** — Message-IDs now use the sender's domain instead of hardcoded `@neomd`; ensures proper email threading, spam filter compatibility, and domain reputation consistency; uses `net/mail.ParseAddress()` for robust RFC 5322 address parsing; validates From address before sending and rejects invalid addresses that would result in `@localhost` Message-IDs; added comprehensive test coverage for BuildMessage, BuildDraftMessage, and BuildReactionMessage paths; documented email standards compliance in `docs/email-standards.md` 5 + - **Fix: From validation allows local-only addresses** — `extractDomain()` now returns `(domain, ok bool)` to distinguish between parsing failures (invalid address) and valid `user@localhost` addresses; validation only rejects unparseable addresses, not legitimate local mail system configurations; prevents regression that would have blocked valid RFC 5322 addresses 6 + - **Fix: test nil dereference guard** — `TestBuildMessage_InvalidFrom` now uses `t.Fatalf()` when validation incorrectly succeeds, preventing nil pointer panic on `err.Error()` if test regresses 4 7 - **Fix: potential memory leak in background sync** — fixed infinite loop that occurred when IMAP errors (e.g., after suspend/resume) triggered immediate retry instead of waiting for next scheduled interval; `bgFetchInboxCmd()` now returns nil on error instead of `bgSyncTickMsg{}`, preventing tight loop that consumes large amounts of RAM; added `bgSyncInProgress` flag that covers the entire fetch-and-screen cycle (kept set until `bgScreenDoneMsg`), preventing concurrent background syncs from piling up during slow network or long screening operations 5 8 - **Fix: reply-all excludes all own addresses** — `ctrl+r` reply-all now excludes both IMAP login addresses (`account.User`) and send-as addresses (`account.From`, `sender.From`) from the CC field; fixes edge cases where `user != from` (e.g., login as `user123@provider.com` but send as `simon@domain.com`) would still leak the login address into CC; previously only excluded `From` addresses. Added test suite added covering single/multi-account, sender aliases, case sensitivity, and named addresses 6 9
+1
README.md
··· 156 156 - **Headless daemon mode** — run `neomd --headless` on a server to continuously screen emails in the background without the TUI; watches screener list files for changes via Syncthing; emails are auto-screened every `bg_sync_interval` minutes so mobile apps see correctly filtered IMAP folders; perfect for running on a NAS while using the TUI on laptop/Android [[more](https://ssp-data.github.io/neomd/docs/configurations/headless/)] 157 157 - **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette 158 158 - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required and keeps it in sync if you use it on mobile or different device [[more](https://ssp-data.github.io/neomd/docs/configuration/)] 159 + - **RFC 5322 compliant email delivery** — Message-IDs use sender's domain, proper MIME multipart/alternative structure (text/plain before text/html), quoted-printable encoding, and all required headers; ensures deliverability across all providers, spam filter compatibility, and correct email threading [[more](https://ssp-data.github.io/neomd/docs/configurations/email-standards/)] 159 160 160 161 > [!NOTE] 161 162 > neomd's **speed** depends entirely on your IMAP provider. On Hostpoint (the provider I use), a folder switch takes **~33ms** which feels instant. On Gmail, the same operation takes **~570ms** which is noticeably slow. See [Benchmark](#benchmark) for full details and how to test your provider.
+1
docs/content/docs/_index.md
··· 159 159 - **Headless daemon mode** — run `neomd --headless` on a server to continuously screen emails in the background without the TUI; watches screener list files for changes via Syncthing; emails are auto-screened every `bg_sync_interval` minutes so mobile apps see correctly filtered IMAP folders; perfect for running on a NAS while using the TUI on laptop/Android [[more](https://ssp-data.github.io/neomd/docs/configurations/headless/)] 160 160 - **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette 161 161 - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required and keeps it in sync if you use it on mobile or different device [[more](https://ssp-data.github.io/neomd/docs/configuration/)] 162 + - **RFC 5322 compliant email delivery** — Message-IDs use sender's domain, proper MIME multipart/alternative structure (text/plain before text/html), quoted-printable encoding, and all required headers; ensures deliverability across all providers, spam filter compatibility, and correct email threading [[more](https://ssp-data.github.io/neomd/docs/configurations/email-standards/)] 162 163 163 164 {{< callout type="info" >}} 164 165 neomd's **speed** depends entirely on your IMAP provider. On Hostpoint (the provider I use), a folder switch takes **~33ms** which feels instant. On Gmail, the same operation takes **~570ms** which is noticeably slow. See [Benchmark](#benchmark) for full details and how to test your provider.
+155
docs/content/docs/configurations/email-standards.md
··· 1 + # Email Standards Compliance 2 + 3 + neomd implements modern email standards to tries for maximum deliverability and compatibility across all email clients and spam filters. 4 + 5 + ## RFC Compliance 6 + 7 + ### MIME Structure (RFC 2045, 2046) 8 + 9 + **multipart/alternative** - All sent emails include both plain text and HTML versions: 10 + - **Plain text first, HTML second** (RFC 2046 requirement for backwards compatibility) 11 + - Plain text uses formatted callouts (emoji + text) for readability 12 + - HTML uses full goldmark rendering with styled callout boxes 13 + 14 + **Encoding**: Quoted-printable (RFC 2045) for both text/plain and text/html parts 15 + - Human-readable for ASCII content 16 + - Efficient encoding for international characters 17 + - Spam-filter friendly (modern filters decode before analysis) 18 + 19 + **MIME Structure Patterns**: 20 + ``` 21 + No attachments → multipart/alternative (text/plain + text/html) 22 + File attachments → multipart/mixed > multipart/alternative + files 23 + Inline images only → multipart/related > (multipart/alternative + images) 24 + Both → multipart/mixed > (multipart/related > alt+images) + files 25 + ``` 26 + 27 + ### Required Headers (RFC 5322) 28 + 29 + **Always Present**: 30 + - `From:` - Sender address from config 31 + - `To:` - Recipient(s) 32 + - `Cc:` - Carbon copy recipients (when applicable) 33 + - `Subject:` - Q-encoded for international characters 34 + - `Date:` - RFC1123Z format (e.g., `Tue, 21 Apr 2026 20:05:09 +0200`) 35 + - `Message-ID:` - **Uses sender's domain** (e.g., `<hex@ssp.sh>`, not `@neomd`) 36 + - `MIME-Version: 1.0` - Required for multipart emails 37 + - `Content-Type:` - Specifies MIME structure and boundaries 38 + - `X-Mailer: neomd` - Identifies the client (minimal spam impact) 39 + 40 + **Threading Headers** (when replying/forwarding): 41 + - `In-Reply-To:` - Message-ID of the email being replied to 42 + - `References:` - Full thread chain (preserves conversation context) 43 + 44 + **Never Included**: 45 + - `Bcc:` - Intentionally excluded from headers (RFC 5322 privacy requirement) 46 + - BCC recipients receive the email via SMTP RCPT TO 47 + - They never appear in message headers (standard BCC behavior) 48 + 49 + ### Message-ID Best Practice 50 + 51 + Message-IDs use the **sender's domain** extracted from the `From:` address: 52 + 53 + ``` 54 + From: Simon Späti <simon@ssp.sh> 55 + Message-ID: <4f6bfc2ad10d7787a822295d@ssp.sh> 56 + ^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^ 57 + random hex sender's domain 58 + ``` 59 + 60 + **Why this matters:** 61 + - RFC 5322 recommends Message-IDs contain a fully qualified domain name you control 62 + - Some spam filters check for domain consistency 63 + - Improves email threading across clients 64 + 65 + **Implementation:** `internal/smtp/sender.go:400,431,463,706-723` 66 + 67 + ## Authentication Requirements (2026 Standards) 68 + 69 + ### SPF (Sender Policy Framework) 70 + 71 + **What neomd does:** Nothing - SPF is configured in DNS by the domain owner. 72 + 73 + **What you must do:** 74 + 1. Add SPF TXT record to your domain's DNS 75 + 2. Include your SMTP server's IP or domain 76 + 3. Example: `v=spf1 include:amazonses.com redirect=spf.mail.hostpoint.ch` 77 + 78 + **Verification:** `dig your-domain.com TXT +short | grep spf` 79 + 80 + ### DKIM (DomainKeys Identified Mail) 81 + 82 + **What neomd does:** Nothing - DKIM signing is done by your SMTP server. 83 + 84 + **What you must do:** 85 + 1. Enable DKIM signing on your SMTP provider (Hostpoint, Gmail, AWS SES, etc.) 86 + 2. Add DKIM public key TXT record to DNS 87 + 3. Verify signing by checking raw email headers for `DKIM-Signature:` 88 + 89 + **Example (Hostpoint signs with 3 algorithms):** 90 + ``` 91 + DKIM-Signature: v=1; a=rsa-sha256; d=ssp.sh; s=20241021-rsa1024-... 92 + DKIM-Signature: v=1; a=rsa-sha256; d=ssp.sh; s=20241021-rsa2048-... 93 + DKIM-Signature: v=1; a=ed25519-sha256; d=ssp.sh; s=20241021-ed25519-... 94 + ``` 95 + 96 + ### DMARC (Domain-based Message Authentication) 97 + 98 + **What neomd does:** Nothing - DMARC is a DNS policy record. 99 + 100 + **What you must do:** 101 + 1. Add `_dmarc` TXT record to DNS 102 + 2. Start with `p=quarantine` for monitoring 103 + 3. After 2-4 weeks, escalate to `p=reject` for full protection 104 + 4. Add `rua=` for daily reports 105 + 5. Example: `v=DMARC1; p=quarantine; rua=mailto:dmarc@your-domain.com; pct=100; adkim=s; aspf=s;` 106 + 107 + **Verification:** `dig _dmarc.your-domain.com TXT +short` 108 + 109 + **DMARC Policy Changes:** 110 + - DMARC policies are set in **DNS by the domain owner** 111 + - **Not controlled by your SMTP provider** (Hostpoint, Gmail, etc.) 112 + - You can change DMARC policy independent of your email provider 113 + - Changes propagate according to DNS TTL (usually within minutes to hours) 114 + 115 + ## Testing Your Configuration 116 + 117 + After setting up DNS records, verify deliverability: 118 + 119 + 1. **mail-tester.com** - Comprehensive spam score (aim for 10/10) 120 + 2. **mxtoolbox.com/deliverability** - Check SPF, DKIM, DMARC 121 + 3. **Google Admin Toolbox** - Test Gmail delivery 122 + 4. **Send test emails** - Check raw headers for authentication results 123 + 124 + **Looking for in raw email:** 125 + ``` 126 + Authentication-Results: receiving-server.com; 127 + dkim=pass header.d=your-domain.com 128 + spf=pass smtp.mailfrom=you@your-domain.com 129 + dmarc=pass (policy=quarantine) header.from=your-domain.com 130 + ``` 131 + 132 + All three must show `pass` for optimal deliverability. 133 + 134 + ## Architecture Notes 135 + 136 + **Separation of Concerns:** 137 + - **neomd (this client):** Generates RFC-compliant MIME messages 138 + - **Your SMTP server:** Signs with DKIM, enforces SPF/DMARC 139 + - **Your DNS:** Publishes SPF, DKIM keys, DMARC policy 140 + 141 + **Why the split?** 142 + - Email clients should not manage cryptographic keys 143 + - DKIM signing requires server-side infrastructure 144 + - DNS configuration is provider-independent 145 + 146 + **Result:** neomd focuses on correctness of MIME generation; your SMTP provider handles authentication. 147 + 148 + ## Further Reading 149 + 150 + - [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322) - Internet Message Format 151 + - [RFC 2045-2049](https://www.rfc-editor.org/rfc/rfc2045) - MIME specification 152 + - [RFC 6376](https://www.rfc-editor.org/rfc/rfc6376) - DKIM Signatures 153 + - [RFC 7208](https://www.rfc-editor.org/rfc/rfc7208) - SPF 154 + - [RFC 7489](https://www.rfc-editor.org/rfc/rfc7489) - DMARC 155 + - [Google/Yahoo 2026 Requirements](https://dmarcly.com/blog/how-to-implement-dmarc-dkim-spf-to-stop-email-spoofing-phishing-the-definitive-guide) - Current best practices
+146
internal/integration_test.go
··· 779 779 t.Logf("Mark-as-read round-trip successful: UID=%d", email.UID) 780 780 } 781 781 782 + func TestIntegration_EmailStandardsCompliance(t *testing.T) { 783 + env := loadEnv(t) 784 + cli := env.imapClient() 785 + defer cli.Close() 786 + 787 + subject := uniqueSubject("standards-check") 788 + 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" 789 + 790 + // Build the message to inspect its structure before sending 791 + raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil, "") 792 + if err != nil { 793 + t.Fatalf("BuildMessage: %v", err) 794 + } 795 + 796 + rawStr := string(raw) 797 + 798 + // 1. Message-ID MUST use sender's domain (not @neomd or @localhost) 799 + msgIDIdx := strings.Index(rawStr, "Message-ID:") 800 + if msgIDIdx == -1 { 801 + t.Fatal("Message-ID header missing") 802 + } 803 + msgIDLine := rawStr[msgIDIdx : msgIDIdx+strings.Index(rawStr[msgIDIdx:], "\n")] 804 + 805 + // Extract domain from From address for validation 806 + fromAddr := extractUser(env.from) 807 + if fromAddr == "" { 808 + fromAddr = env.user 809 + } 810 + domainIdx := strings.LastIndex(fromAddr, "@") 811 + if domainIdx == -1 { 812 + t.Fatalf("Cannot extract domain from From: %s", fromAddr) 813 + } 814 + expectedDomain := fromAddr[domainIdx+1:] 815 + 816 + if !strings.Contains(msgIDLine, "@"+expectedDomain+">") { 817 + t.Errorf("Message-ID should use sender's domain @%s, got: %s", expectedDomain, msgIDLine) 818 + } 819 + if strings.Contains(msgIDLine, "@neomd>") { 820 + t.Errorf("Message-ID should not use hardcoded @neomd, got: %s", msgIDLine) 821 + } 822 + if strings.Contains(msgIDLine, "@localhost>") { 823 + t.Errorf("Message-ID should not use @localhost fallback, got: %s", msgIDLine) 824 + } 825 + t.Logf("✓ Message-ID uses sender's domain: %s", msgIDLine) 826 + 827 + // 2. Required RFC 5322 headers 828 + requiredHeaders := []string{ 829 + "From:", 830 + "To:", 831 + "Subject:", 832 + "Date:", 833 + "Message-ID:", 834 + "MIME-Version:", 835 + "Content-Type:", 836 + "X-Mailer:", 837 + } 838 + for _, hdr := range requiredHeaders { 839 + if !strings.Contains(rawStr, hdr) { 840 + t.Errorf("Required header missing: %s", hdr) 841 + } 842 + } 843 + t.Logf("✓ All required headers present") 844 + 845 + // 3. Verify multipart/alternative structure 846 + if !strings.Contains(rawStr, "Content-Type: multipart/alternative") { 847 + t.Error("Expected multipart/alternative content type") 848 + } 849 + t.Logf("✓ Uses multipart/alternative structure") 850 + 851 + // 4. Verify text/plain comes before text/html (RFC 2046 requirement) 852 + plainIdx := strings.Index(rawStr, "Content-Type: text/plain") 853 + htmlIdx := strings.Index(rawStr, "Content-Type: text/html") 854 + if plainIdx == -1 { 855 + t.Error("text/plain part missing") 856 + } 857 + if htmlIdx == -1 { 858 + t.Error("text/html part missing") 859 + } 860 + if plainIdx >= htmlIdx { 861 + t.Errorf("text/plain must come before text/html (RFC 2046), got plain at %d, html at %d", plainIdx, htmlIdx) 862 + } 863 + t.Logf("✓ Correct part ordering: text/plain first, text/html second") 864 + 865 + // 5. Verify quoted-printable encoding is used 866 + if !strings.Contains(rawStr, "Content-Transfer-Encoding: quoted-printable") { 867 + t.Error("Expected quoted-printable encoding") 868 + } 869 + t.Logf("✓ Uses quoted-printable encoding") 870 + 871 + // 6. Verify X-Mailer header identifies neomd 872 + if !strings.Contains(rawStr, "X-Mailer: neomd") { 873 + t.Error("X-Mailer header should identify 'neomd'") 874 + } 875 + t.Logf("✓ X-Mailer header present") 876 + 877 + // 7. Verify BCC header is NOT present (RFC 5322 privacy requirement) 878 + if strings.Contains(rawStr, "\nBcc:") || strings.HasPrefix(rawStr, "Bcc:") { 879 + t.Error("BCC header should never appear in message headers") 880 + } 881 + t.Logf("✓ BCC header correctly excluded") 882 + 883 + // 8. Verify HTML part is valid (contains basic tags) 884 + if !strings.Contains(rawStr, "<!DOCTYPE html>") { 885 + t.Error("HTML part missing DOCTYPE declaration") 886 + } 887 + if !strings.Contains(rawStr, "<body>") || !strings.Contains(rawStr, "</body>") { 888 + t.Error("HTML part missing body tags") 889 + } 890 + t.Logf("✓ HTML part is well-formed") 891 + 892 + // Now actually send the email to verify end-to-end delivery 893 + err = smtp.Send(env.smtpConfig(), env.user, "", "", subject, body, nil) 894 + if err != nil { 895 + t.Fatalf("Send: %v", err) 896 + } 897 + 898 + // Wait for delivery and verify it arrives correctly 899 + email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) 900 + defer cleanupEmail(t, cli, "INBOX", email.UID) 901 + 902 + // Fetch body to verify content survived delivery 903 + ctx := context.Background() 904 + markdown, rawHTML, _, _, _, err := cli.FetchBody(ctx, "INBOX", email.UID) 905 + if err != nil { 906 + t.Fatalf("FetchBody: %v", err) 907 + } 908 + 909 + if !strings.Contains(markdown, "RFC 5322") { 910 + t.Errorf("Plain text part missing expected content after delivery, got: %s", truncate(markdown, 200)) 911 + } 912 + t.Logf("✓ Plain text part is readable after delivery") 913 + 914 + if !strings.Contains(rawHTML, "<!DOCTYPE html>") { 915 + t.Error("HTML part missing DOCTYPE after delivery") 916 + } 917 + t.Logf("✓ HTML part survived delivery intact") 918 + 919 + t.Log("\n=== Email Standards Compliance: ALL CHECKS PASSED ===") 920 + t.Logf("Message-ID: Uses sender's domain @%s", expectedDomain) 921 + t.Log("Headers: All required headers present") 922 + t.Log("MIME: multipart/alternative with correct ordering") 923 + t.Log("Encoding: quoted-printable") 924 + t.Log("Privacy: BCC correctly excluded") 925 + t.Log("Delivery: Email sent and received successfully") 926 + } 927 + 782 928 // --- Helpers --- 783 929 784 930 func extractUser(from string) string {
+47 -3
internal/smtp/sender.go
··· 18 18 "fmt" 19 19 "mime" 20 20 "net" 21 + "net/mail" 21 22 "net/smtp" 22 23 "os" 23 24 "path/filepath" ··· 361 362 } 362 363 363 364 func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string, inReplyTo, references string) ([]byte, error) { 365 + // Validate From address can be parsed successfully 366 + domain, ok := extractDomain(from) 367 + if !ok { 368 + return nil, fmt.Errorf("invalid From address %q: cannot parse address for Message-ID (ensure address format is valid)", from) 369 + } 370 + 364 371 // Find local image paths in htmlBody (<img src="/abs/path">), assign CIDs. 365 372 var inlines []inlineImage 366 373 processedHTML := imgSrcRe.ReplaceAllStringFunc(htmlBody, func(match string) string { ··· 397 404 } 398 405 hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 399 406 hdr("Date", time.Now().Format(time.RFC1123Z)) 400 - hdr("Message-ID", "<"+msgID+"@neomd>") 407 + hdr("Message-ID", "<"+msgID+"@"+domain+">") 401 408 // Threading headers for replies 402 409 if inReplyTo != "" { 403 410 hdr("In-Reply-To", inReplyTo) ··· 428 435 } 429 436 hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 430 437 hdr("Date", time.Now().Format(time.RFC1123Z)) 431 - hdr("Message-ID", "<"+msgID+"@neomd>") 438 + hdr("Message-ID", "<"+msgID+"@"+domain+">") 432 439 // Threading headers for replies 433 440 if inReplyTo != "" { 434 441 hdr("In-Reply-To", inReplyTo) ··· 460 467 } 461 468 hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 462 469 hdr("Date", time.Now().Format(time.RFC1123Z)) 463 - hdr("Message-ID", "<"+msgID+"@neomd>") 470 + hdr("Message-ID", "<"+msgID+"@"+domain+">") 464 471 hdr("MIME-Version", "1.0") 465 472 hdr("Content-Type", `multipart/mixed; boundary="`+mixedBoundary+`"`) 466 473 hdr("X-Mailer", "neomd") ··· 698 705 } 699 706 return s 700 707 } 708 + 709 + // extractDomain extracts the domain part from a "Name <user@domain>" or "user@domain" address. 710 + // Returns "localhost" as a safe fallback if no valid domain is found, though this should never 711 + // happen in practice since the From address always comes from validated config.toml accounts. 712 + // 713 + // This is used for generating RFC-compliant Message-IDs with the sender's domain. 714 + // RFC 5322 recommends (but does not require) that Message-IDs contain a fully qualified 715 + // domain name controlled by the sender. Using the sender's domain ensures: 716 + // - Better spam filter compatibility 717 + // - Proper email threading across clients 718 + // - Domain reputation consistency 719 + // 720 + // Uses net/mail.ParseAddress for RFC 5322 compliant parsing. 721 + // 722 + // Examples: 723 + // "Simon Späti <simon@ssp.sh>" → "ssp.sh" 724 + // "alice@example.com" → "example.com" 725 + // "invalid" → "localhost" (should never happen) 726 + func extractDomain(from string) (string, bool) { 727 + // Use net/mail for RFC 5322 compliant address parsing 728 + addr, err := mail.ParseAddress(strings.TrimSpace(from)) 729 + if err != nil { 730 + // Parsing failed - invalid From address 731 + return "localhost", false 732 + } 733 + 734 + // Extract domain from the parsed address (user@domain) 735 + if idx := strings.LastIndex(addr.Address, "@"); idx >= 0 && idx < len(addr.Address)-1 { 736 + domain := addr.Address[idx+1:] 737 + if domain != "" { 738 + return domain, true 739 + } 740 + } 741 + 742 + // Parsed but no domain found (e.g., bare username without @) 743 + return "localhost", false 744 + }
+192 -2
internal/smtp/sender_test.go
··· 289 289 t.Error("Date header missing") 290 290 } 291 291 292 - // Message-ID must be present 293 - if msg.Header.Get("Message-Id") == "" { 292 + // Message-ID must be present and use the sender's domain 293 + msgID := msg.Header.Get("Message-Id") 294 + if msgID == "" { 294 295 t.Error("Message-ID header missing") 296 + } 297 + // Message-ID should contain @example.com (sender's domain), not @neomd 298 + if !strings.Contains(msgID, "@example.com>") { 299 + t.Errorf("Message-ID should contain sender's domain @example.com, got: %s", msgID) 300 + } 301 + if strings.Contains(msgID, "@neomd>") { 302 + t.Errorf("Message-ID should not contain hardcoded @neomd, got: %s", msgID) 295 303 } 296 304 } 297 305 ··· 343 351 } 344 352 } 345 353 354 + func TestBuildMessage_InvalidFrom(t *testing.T) { 355 + // Test that buildMessage rejects invalid From addresses that would result in @localhost Message-IDs 356 + invalidFromAddresses := []string{ 357 + "invalid", // no @ sign 358 + "user@", // @ at end 359 + "", // empty 360 + "@domain.com", // no user part 361 + "user domain.com", // missing @ 362 + } 363 + 364 + for _, from := range invalidFromAddresses { 365 + t.Run(from, func(t *testing.T) { 366 + _, err := buildMessage( 367 + from, 368 + "bob@example.com", 369 + "", 370 + "Test", 371 + "body", 372 + "<p>body</p>", 373 + nil, 374 + ) 375 + if err == nil { 376 + t.Fatalf("buildMessage should fail for invalid From %q, but succeeded", from) 377 + } 378 + if !strings.Contains(err.Error(), "invalid From address") { 379 + t.Errorf("error should mention 'invalid From address', got: %v", err) 380 + } 381 + }) 382 + } 383 + 384 + // Also test BuildMessageWithThreading and BuildReactionMessage paths 385 + t.Run("BuildMessageWithThreading", func(t *testing.T) { 386 + _, err := BuildMessageWithThreading( 387 + "invalid", 388 + "bob@example.com", 389 + "", 390 + "Test", 391 + "body", 392 + nil, // attachments 393 + "", // htmlSignature 394 + "<msg@example.com>", 395 + "<ref@example.com>", 396 + ) 397 + if err == nil { 398 + t.Error("BuildMessageWithThreading should fail for invalid From") 399 + } 400 + }) 401 + 402 + t.Run("BuildReactionMessage", func(t *testing.T) { 403 + _, err := BuildReactionMessage( 404 + "invalid", 405 + "bob@example.com", 406 + "", 407 + "Re: Test", 408 + "👍", 409 + "<msg@example.com>", 410 + "<ref@example.com>", 411 + ) 412 + if err == nil { 413 + t.Error("BuildReactionMessage should fail for invalid From") 414 + } 415 + }) 416 + 417 + t.Run("BuildDraftMessage", func(t *testing.T) { 418 + _, err := BuildDraftMessage( 419 + "invalid", 420 + "bob@example.com", 421 + "", 422 + "", 423 + "Test", 424 + "body", 425 + nil, 426 + ) 427 + if err == nil { 428 + t.Error("BuildDraftMessage should fail for invalid From") 429 + } 430 + }) 431 + } 432 + 346 433 func TestExtractAddr(t *testing.T) { 347 434 tests := []struct { 348 435 name string ··· 386 473 } 387 474 } 388 475 476 + func TestExtractDomain(t *testing.T) { 477 + tests := []struct { 478 + name string 479 + input string 480 + want string 481 + wantOk bool 482 + }{ 483 + { 484 + name: "name and angle brackets", 485 + input: "Simon Späti <simon@ssp.sh>", 486 + want: "ssp.sh", 487 + wantOk: true, 488 + }, 489 + { 490 + name: "bare address", 491 + input: "alice@example.com", 492 + want: "example.com", 493 + wantOk: true, 494 + }, 495 + { 496 + name: "subdomain", 497 + input: "Bob <bob@mail.company.org>", 498 + want: "mail.company.org", 499 + wantOk: true, 500 + }, 501 + { 502 + name: "with leading space", 503 + input: " test@domain.net", 504 + want: "domain.net", 505 + wantOk: true, 506 + }, 507 + { 508 + name: "angle brackets no name", 509 + input: "<user@test.io>", 510 + want: "test.io", 511 + wantOk: true, 512 + }, 513 + { 514 + name: "localhost is valid", 515 + input: "user@localhost", 516 + want: "localhost", 517 + wantOk: true, 518 + }, 519 + { 520 + name: "empty string fallback", 521 + input: "", 522 + want: "localhost", 523 + wantOk: false, 524 + }, 525 + { 526 + name: "no @ sign fallback", 527 + input: "invalid", 528 + want: "localhost", 529 + wantOk: false, 530 + }, 531 + { 532 + name: "@ at end fallback", 533 + input: "user@", 534 + want: "localhost", 535 + wantOk: false, 536 + }, 537 + } 538 + 539 + for _, tt := range tests { 540 + t.Run(tt.name, func(t *testing.T) { 541 + got, ok := extractDomain(tt.input) 542 + if got != tt.want { 543 + t.Errorf("extractDomain(%q) domain = %q, want %q", tt.input, got, tt.want) 544 + } 545 + if ok != tt.wantOk { 546 + t.Errorf("extractDomain(%q) ok = %v, want %v", tt.input, ok, tt.wantOk) 547 + } 548 + }) 549 + } 550 + } 551 + 389 552 func TestBuildDraftMessage_PlainTextOnly(t *testing.T) { 390 553 // Test that drafts are stored as plain text only, no HTML conversion. 391 554 // This ensures markdown formatting survives the save/load cycle. ··· 447 610 if strings.Contains(bodyStr, "<p>") || strings.Contains(bodyStr, "<strong>") { 448 611 t.Error("draft body contains HTML tags - should be plain text only") 449 612 } 613 + 614 + // Verify Message-ID uses sender's domain (not @neomd or @localhost) 615 + msgID := msg.Header.Get("Message-ID") 616 + if !strings.Contains(msgID, "@sspaeti.com>") { 617 + t.Errorf("Draft Message-ID should contain sender's domain @sspaeti.com, got: %s", msgID) 618 + } 619 + if strings.Contains(msgID, "@neomd>") || strings.Contains(msgID, "@localhost>") { 620 + t.Errorf("Draft Message-ID should not contain @neomd or @localhost, got: %s", msgID) 621 + } 450 622 } 451 623 452 624 func TestBuildDraftMessage_WithAttachment(t *testing.T) { ··· 495 667 ct1, _, _ := mime.ParseMediaType(part1.Header.Get("Content-Type")) 496 668 if ct1 != "text/plain" { 497 669 t.Errorf("attachment content-type: expected text/plain, got %s", ct1) 670 + } 671 + 672 + // Verify Message-ID uses sender's domain (not @neomd or @localhost) 673 + msgID := msg.Header.Get("Message-ID") 674 + if !strings.Contains(msgID, "@example.com>") { 675 + t.Errorf("Draft Message-ID should contain sender's domain @example.com, got: %s", msgID) 676 + } 677 + if strings.Contains(msgID, "@neomd>") || strings.Contains(msgID, "@localhost>") { 678 + t.Errorf("Draft Message-ID should not contain @neomd or @localhost, got: %s", msgID) 498 679 } 499 680 } 500 681 ··· 772 953 } 773 954 if !foundHTML { 774 955 t.Error("Missing text/html part") 956 + } 957 + 958 + // Verify Message-ID uses sender's domain (not @neomd or @localhost) 959 + msgID := msg.Header.Get("Message-ID") 960 + if !strings.Contains(msgID, "@example.com>") { 961 + t.Errorf("Reaction Message-ID should contain sender's domain @example.com, got: %s", msgID) 962 + } 963 + if strings.Contains(msgID, "@neomd>") || strings.Contains(msgID, "@localhost>") { 964 + t.Errorf("Reaction Message-ID should not contain @neomd or @localhost, got: %s", msgID) 775 965 } 776 966 } 777 967