1package sender
2
3import (
4 "errors"
5 "io"
6 "strings"
7 "testing"
8)
9
10type failingReader struct{}
11
12func (failingReader) Read(p []byte) (int, error) {
13 return 0, errors.New("simulated crypto/rand failure")
14}
15
16type failingWriter struct{}
17
18func (failingWriter) Write(p []byte) (int, error) {
19 return 0, errors.New("simulated write failure")
20}
21
22func TestWriteQuotedPrintablePropagatesFlushError(t *testing.T) {
23 err := writeQuotedPrintable(failingWriter{}, "hello")
24 if err == nil {
25 t.Fatal("expected quoted-printable write error, got nil")
26 }
27 if !strings.Contains(err.Error(), "quoted-printable encoding failed") {
28 t.Fatalf("expected quoted-printable context, got %v", err)
29 }
30}
31
32// TestSMIMEOuterBoundary_RandFailure ensures that a crypto/rand failure surfaces
33// as an error rather than silently producing a predictable, time-based
34// boundary that an attacker could collide with (issue #1127).
35func TestSMIMEOuterBoundary_RandFailure(t *testing.T) {
36 orig := randReader
37 t.Cleanup(func() { randReader = orig })
38 randReader = failingReader{}
39
40 got, err := smimeOuterBoundary()
41 if err == nil {
42 t.Fatalf("expected error when crypto/rand fails, got boundary %q", got)
43 }
44 if got != "" {
45 t.Errorf("expected empty boundary on error, got %q", got)
46 }
47}
48
49// TestSMIMEOuterBoundary_Success ensures the happy path returns a non-empty,
50// random-looking boundary with the expected prefix.
51func TestSMIMEOuterBoundary_Success(t *testing.T) {
52 b1, err := smimeOuterBoundary()
53 if err != nil {
54 t.Fatalf("unexpected error: %v", err)
55 }
56 if !strings.HasPrefix(b1, "signed-") {
57 t.Errorf("boundary should start with 'signed-', got %q", b1)
58 }
59 // 12 random bytes => 24 hex chars; total length 7 + 24 = 31.
60 if len(b1) != len("signed-")+24 {
61 t.Errorf("unexpected boundary length: got %d (%q)", len(b1), b1)
62 }
63 b2, err := smimeOuterBoundary()
64 if err != nil {
65 t.Fatalf("unexpected error on second call: %v", err)
66 }
67 if b1 == b2 {
68 t.Errorf("two consecutive boundaries should differ, both got %q", b1)
69 }
70}
71
72// Ensure io is referenced even if a future refactor removes it indirectly.
73var _ io.Reader = failingReader{}
74
75func TestSMTPHelloHostname(t *testing.T) {
76 orig := osHostname
77 t.Cleanup(func() { osHostname = orig })
78
79 osHostname = func() (string, error) { return "mail.example.com", nil }
80 if got := smtpHelloHostname(); got != "mail.example.com" {
81 t.Fatalf("expected hostname, got %q", got)
82 }
83
84 osHostname = func() (string, error) { return "", nil }
85 if got := smtpHelloHostname(); got != "localhost" {
86 t.Fatalf("expected localhost fallback for empty hostname, got %q", got)
87 }
88
89 osHostname = func() (string, error) { return "ignored", errors.New("hostname unavailable") }
90 if got := smtpHelloHostname(); got != "localhost" {
91 t.Fatalf("expected localhost fallback on error, got %q", got)
92 }
93}
94
95// TestGenerateMessageID ensures the Message-ID has the correct format.
96func TestGenerateMessageID(t *testing.T) {
97 from := "test@example.com"
98 msgID := generateMessageID(from)
99
100 // Check if the message ID is enclosed in angle brackets.
101 if !strings.HasPrefix(msgID, "<") || !strings.HasSuffix(msgID, ">") {
102 t.Errorf("Message-ID should be enclosed in angle brackets, got %s", msgID)
103 }
104
105 // Check if the 'from' address is part of the message ID.
106 if !strings.Contains(msgID, from) {
107 t.Errorf("Message-ID should contain the from address, got %s", msgID)
108 }
109
110 // The original check was too simple and failed because the 'from' address itself contains an '@'.
111 // A Message-ID is generally <unique-part@domain>. The current implementation uses the full 'from' address as the domain part.
112 // This revised check validates that structure correctly.
113 unwrappedID := strings.Trim(msgID, "<>")
114
115 // Ensure there's at least one '@' symbol.
116 if !strings.Contains(unwrappedID, "@") {
117 t.Errorf("Message-ID should contain an '@' symbol, got %s", msgID)
118 }
119
120 // Check that the ID ends with the full 'from' address, preceded by an '@'.
121 // This confirms the structure is <random_part>@<from_address>.
122 expectedSuffix := "@" + from
123 if !strings.HasSuffix(unwrappedID, expectedSuffix) {
124 t.Errorf("Message-ID should end with '@' + from address. Got %s, expected suffix %s", unwrappedID, expectedSuffix)
125 }
126
127 // Check that the part before the suffix is not empty.
128 randomPart := strings.TrimSuffix(unwrappedID, expectedSuffix)
129 if randomPart == "" {
130 t.Errorf("Message-ID has an empty random part, got %s", msgID)
131 }
132}
133
134func TestExtractBareEmail(t *testing.T) {
135 tests := []struct {
136 name string
137 input string
138 expected string
139 }{
140 {
141 name: "bare email",
142 input: "user@example.com",
143 expected: "user@example.com",
144 },
145 {
146 name: "formatted with name",
147 input: "John Doe <user@example.com>",
148 expected: "user@example.com",
149 },
150 {
151 name: "formatted with quoted name",
152 input: "\"John Doe\" <user@example.com>",
153 expected: "user@example.com",
154 },
155 {
156 name: "empty string",
157 input: "",
158 expected: "",
159 },
160 {
161 name: "invalid format returns as-is",
162 input: "not-an-email",
163 expected: "not-an-email",
164 },
165 }
166
167 for _, tt := range tests {
168 t.Run(tt.name, func(t *testing.T) {
169 got := extractBareEmail(tt.input)
170 if got != tt.expected {
171 t.Errorf("extractBareEmail(%q) = %q, want %q", tt.input, got, tt.expected)
172 }
173 })
174 }
175}