sender_test.go

  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}