mime_test.go raw
1 package smtp
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 func TestParseMIME_SimplePlainText(t *testing.T) {
9 raw := "From: alice@example.com\r\nTo: bob@example.com\r\nSubject: Hello\r\nContent-Type: text/plain\r\n\r\nHello Bob!"
10
11 parsed, err := ParseMIME([]byte(raw))
12 if err != nil {
13 t.Fatalf("ParseMIME: %v", err)
14 }
15
16 if !strings.Contains(parsed.From, "alice@example.com") {
17 t.Errorf("From = %q", parsed.From)
18 }
19 if parsed.Subject != "Hello" {
20 t.Errorf("Subject = %q", parsed.Subject)
21 }
22 if parsed.TextPlain != "Hello Bob!" {
23 t.Errorf("TextPlain = %q", parsed.TextPlain)
24 }
25 }
26
27 func TestParseMIME_MultipartAlternative(t *testing.T) {
28 raw := "From: alice@example.com\r\n" +
29 "To: bob@example.com\r\n" +
30 "Subject: Multi\r\n" +
31 "MIME-Version: 1.0\r\n" +
32 "Content-Type: multipart/alternative; boundary=\"boundary1\"\r\n" +
33 "\r\n" +
34 "--boundary1\r\n" +
35 "Content-Type: text/plain\r\n" +
36 "\r\n" +
37 "Plain text version\r\n" +
38 "--boundary1\r\n" +
39 "Content-Type: text/html\r\n" +
40 "\r\n" +
41 "<html><body>HTML version</body></html>\r\n" +
42 "--boundary1--\r\n"
43
44 parsed, err := ParseMIME([]byte(raw))
45 if err != nil {
46 t.Fatalf("ParseMIME: %v", err)
47 }
48
49 if !strings.Contains(parsed.TextPlain, "Plain text version") {
50 t.Errorf("TextPlain = %q", parsed.TextPlain)
51 }
52 if !strings.Contains(parsed.TextHTML, "HTML version") {
53 t.Errorf("TextHTML = %q", parsed.TextHTML)
54 }
55 }
56
57 func TestParseMIME_WithAttachment(t *testing.T) {
58 raw := "From: alice@example.com\r\n" +
59 "To: bob@example.com\r\n" +
60 "Subject: With file\r\n" +
61 "MIME-Version: 1.0\r\n" +
62 "Content-Type: multipart/mixed; boundary=\"boundary2\"\r\n" +
63 "\r\n" +
64 "--boundary2\r\n" +
65 "Content-Type: text/plain\r\n" +
66 "\r\n" +
67 "See attached.\r\n" +
68 "--boundary2\r\n" +
69 "Content-Type: application/pdf\r\n" +
70 "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" +
71 "\r\n" +
72 "fake-pdf-content\r\n" +
73 "--boundary2--\r\n"
74
75 parsed, err := ParseMIME([]byte(raw))
76 if err != nil {
77 t.Fatalf("ParseMIME: %v", err)
78 }
79
80 if !strings.Contains(parsed.TextPlain, "See attached") {
81 t.Errorf("TextPlain = %q", parsed.TextPlain)
82 }
83
84 if len(parsed.Attachments) != 1 {
85 t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments))
86 }
87
88 att := parsed.Attachments[0]
89 if att.Filename != "test.pdf" {
90 t.Errorf("Filename = %q", att.Filename)
91 }
92 if att.ContentType != "application/pdf" {
93 t.Errorf("ContentType = %q", att.ContentType)
94 }
95 if !strings.Contains(string(att.Data), "fake-pdf-content") {
96 t.Errorf("Data = %q", string(att.Data))
97 }
98 }
99
100 func TestParseMIME_MessageID_InReplyTo(t *testing.T) {
101 raw := "From: alice@example.com\r\n" +
102 "To: bob@example.com\r\n" +
103 "Subject: Reply\r\n" +
104 "Message-Id: <abc123@example.com>\r\n" +
105 "In-Reply-To: <parent456@example.com>\r\n" +
106 "Content-Type: text/plain\r\n" +
107 "\r\n" +
108 "Replying."
109
110 parsed, err := ParseMIME([]byte(raw))
111 if err != nil {
112 t.Fatalf("ParseMIME: %v", err)
113 }
114
115 if parsed.MessageID != "<abc123@example.com>" {
116 t.Errorf("MessageID = %q", parsed.MessageID)
117 }
118 if parsed.InReplyTo != "<parent456@example.com>" {
119 t.Errorf("InReplyTo = %q", parsed.InReplyTo)
120 }
121 }
122
123 func TestParseMIME_MultipleTo(t *testing.T) {
124 raw := "From: alice@example.com\r\n" +
125 "To: bob@example.com, carol@example.com\r\n" +
126 "Cc: dave@example.com\r\n" +
127 "Subject: Group\r\n" +
128 "Content-Type: text/plain\r\n" +
129 "\r\n" +
130 "Group message."
131
132 parsed, err := ParseMIME([]byte(raw))
133 if err != nil {
134 t.Fatalf("ParseMIME: %v", err)
135 }
136
137 if len(parsed.To) != 2 {
138 t.Errorf("To has %d addresses, want 2", len(parsed.To))
139 }
140 if len(parsed.Cc) != 1 {
141 t.Errorf("Cc has %d addresses, want 1", len(parsed.Cc))
142 }
143 }
144
145 func TestParseMIME_EmptyMessage(t *testing.T) {
146 raw := "From: alice@example.com\r\nTo: bob@example.com\r\nSubject: Empty\r\n\r\n"
147
148 parsed, err := ParseMIME([]byte(raw))
149 if err != nil {
150 t.Fatalf("ParseMIME: %v", err)
151 }
152
153 if parsed.Subject != "Empty" {
154 t.Errorf("Subject = %q", parsed.Subject)
155 }
156 }
157
158 func TestParseMIME_HTMLOnly(t *testing.T) {
159 raw := "From: alice@example.com\r\n" +
160 "To: bob@example.com\r\n" +
161 "Subject: HTML\r\n" +
162 "Content-Type: text/html\r\n\r\n" +
163 "<html><body>Hello</body></html>"
164
165 parsed, err := ParseMIME([]byte(raw))
166 if err != nil {
167 t.Fatalf("ParseMIME: %v", err)
168 }
169
170 if parsed.TextPlain != "" {
171 t.Errorf("TextPlain should be empty, got %q", parsed.TextPlain)
172 }
173 if !strings.Contains(parsed.TextHTML, "Hello") {
174 t.Errorf("TextHTML = %q", parsed.TextHTML)
175 }
176 }
177
178 func TestParseMIME_NoContentType(t *testing.T) {
179 raw := "From: alice@example.com\r\n" +
180 "To: bob@example.com\r\n" +
181 "Subject: No CT\r\n\r\n" +
182 "Body without content type"
183
184 parsed, err := ParseMIME([]byte(raw))
185 if err != nil {
186 t.Fatalf("ParseMIME: %v", err)
187 }
188
189 // Without content-type, should default to text/plain
190 if !strings.Contains(parsed.TextPlain, "Body without content type") {
191 t.Errorf("TextPlain = %q", parsed.TextPlain)
192 }
193 }
194
195 func TestParseMIME_AttachmentWithoutDisposition(t *testing.T) {
196 // Non-text part without Content-Disposition should be treated as attachment
197 raw := "From: alice@example.com\r\n" +
198 "To: bob@example.com\r\n" +
199 "Subject: Inline attachment\r\n" +
200 "MIME-Version: 1.0\r\n" +
201 "Content-Type: multipart/mixed; boundary=\"b1\"\r\n\r\n" +
202 "--b1\r\n" +
203 "Content-Type: text/plain\r\n\r\n" +
204 "Body text\r\n" +
205 "--b1\r\n" +
206 "Content-Type: image/jpeg\r\n\r\n" +
207 "JPEGDATA\r\n" +
208 "--b1--\r\n"
209
210 parsed, err := ParseMIME([]byte(raw))
211 if err != nil {
212 t.Fatalf("ParseMIME: %v", err)
213 }
214
215 if len(parsed.Attachments) != 1 {
216 t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments))
217 }
218 if parsed.Attachments[0].ContentType != "image/jpeg" {
219 t.Errorf("ContentType = %q", parsed.Attachments[0].ContentType)
220 }
221 }
222
223 func TestParseMIME_AttachmentWithNameParam(t *testing.T) {
224 // Filename from Content-Type name param when no Content-Disposition filename
225 raw := "From: alice@example.com\r\n" +
226 "To: bob@example.com\r\n" +
227 "Subject: Name param\r\n" +
228 "MIME-Version: 1.0\r\n" +
229 "Content-Type: multipart/mixed; boundary=\"b2\"\r\n\r\n" +
230 "--b2\r\n" +
231 "Content-Type: text/plain\r\n\r\n" +
232 "Body\r\n" +
233 "--b2\r\n" +
234 "Content-Type: application/pdf; name=\"report.pdf\"\r\n\r\n" +
235 "PDFDATA\r\n" +
236 "--b2--\r\n"
237
238 parsed, err := ParseMIME([]byte(raw))
239 if err != nil {
240 t.Fatalf("ParseMIME: %v", err)
241 }
242
243 if len(parsed.Attachments) != 1 {
244 t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments))
245 }
246 if parsed.Attachments[0].Filename != "report.pdf" {
247 t.Errorf("Filename = %q, want report.pdf", parsed.Attachments[0].Filename)
248 }
249 }
250
251 func TestSplitAddresses_Various(t *testing.T) {
252 tests := []struct {
253 input string
254 want int
255 }{
256 {"alice@example.com, bob@example.com", 2},
257 {"single@example.com", 1},
258 {" spaces@example.com , tabs@example.com ", 2},
259 {"", 0},
260 }
261 for _, tt := range tests {
262 got := splitAddresses(tt.input)
263 if len(got) != tt.want {
264 t.Errorf("splitAddresses(%q) returned %d, want %d", tt.input, len(got), tt.want)
265 }
266 }
267 }
268
269 func TestParseMIME_InlineDisposition(t *testing.T) {
270 // inline disposition for text/html — should be treated as text content, not attachment
271 raw := "From: alice@example.com\r\n" +
272 "To: bob@example.com\r\n" +
273 "Subject: Inline\r\n" +
274 "MIME-Version: 1.0\r\n" +
275 "Content-Type: multipart/mixed; boundary=\"b3\"\r\n\r\n" +
276 "--b3\r\n" +
277 "Content-Type: text/plain\r\n" +
278 "Content-Disposition: inline\r\n\r\n" +
279 "Plain text\r\n" +
280 "--b3\r\n" +
281 "Content-Type: text/html\r\n" +
282 "Content-Disposition: inline\r\n\r\n" +
283 "<p>HTML text</p>\r\n" +
284 "--b3--\r\n"
285
286 parsed, err := ParseMIME([]byte(raw))
287 if err != nil {
288 t.Fatalf("ParseMIME: %v", err)
289 }
290
291 if !strings.Contains(parsed.TextPlain, "Plain text") {
292 t.Errorf("TextPlain = %q", parsed.TextPlain)
293 }
294 if !strings.Contains(parsed.TextHTML, "HTML text") {
295 t.Errorf("TextHTML = %q", parsed.TextHTML)
296 }
297 // Should NOT have attachments (both are inline text)
298 if len(parsed.Attachments) != 0 {
299 t.Errorf("expected 0 attachments for inline text, got %d", len(parsed.Attachments))
300 }
301 }
302
303 func TestParseMIME_DuplicateTextParts(t *testing.T) {
304 // Two text/plain parts — only the first should be used
305 raw := "From: alice@example.com\r\n" +
306 "To: bob@example.com\r\n" +
307 "Subject: Dup\r\n" +
308 "MIME-Version: 1.0\r\n" +
309 "Content-Type: multipart/mixed; boundary=\"dup\"\r\n\r\n" +
310 "--dup\r\n" +
311 "Content-Type: text/plain\r\n\r\n" +
312 "First plain\r\n" +
313 "--dup\r\n" +
314 "Content-Type: text/plain\r\n\r\n" +
315 "Second plain\r\n" +
316 "--dup--\r\n"
317
318 parsed, err := ParseMIME([]byte(raw))
319 if err != nil {
320 t.Fatalf("ParseMIME: %v", err)
321 }
322
323 if !strings.Contains(parsed.TextPlain, "First plain") {
324 t.Errorf("TextPlain should be first part: %q", parsed.TextPlain)
325 }
326 if strings.Contains(parsed.TextPlain, "Second plain") {
327 t.Errorf("TextPlain should not contain second part: %q", parsed.TextPlain)
328 }
329 }
330
331 func TestParseMIME_ExplicitAttachmentDisposition(t *testing.T) {
332 // text/plain with disposition=attachment should be treated as attachment
333 raw := "From: alice@example.com\r\n" +
334 "To: bob@example.com\r\n" +
335 "Subject: Attached Text\r\n" +
336 "MIME-Version: 1.0\r\n" +
337 "Content-Type: multipart/mixed; boundary=\"att\"\r\n\r\n" +
338 "--att\r\n" +
339 "Content-Type: text/plain\r\n\r\n" +
340 "Body text\r\n" +
341 "--att\r\n" +
342 "Content-Type: text/plain\r\n" +
343 "Content-Disposition: attachment; filename=\"readme.txt\"\r\n\r\n" +
344 "Attached text file content\r\n" +
345 "--att--\r\n"
346
347 parsed, err := ParseMIME([]byte(raw))
348 if err != nil {
349 t.Fatalf("ParseMIME: %v", err)
350 }
351
352 if !strings.Contains(parsed.TextPlain, "Body text") {
353 t.Errorf("TextPlain = %q", parsed.TextPlain)
354 }
355 if len(parsed.Attachments) != 1 {
356 t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments))
357 }
358 if parsed.Attachments[0].Filename != "readme.txt" {
359 t.Errorf("attachment filename = %q", parsed.Attachments[0].Filename)
360 }
361 }
362
363 func TestParseMIME_TrueParseError(t *testing.T) {
364 // Completely empty input with no headers at all
365 // message.Read with empty bytes may return an error
366 _, err := ParseMIME([]byte{})
367 // Empty input: message.Read may or may not error depending on implementation
368 // If it doesn't error, that's fine too — this tests the code path
369 _ = err
370 }
371
372 func TestParseMIME_MalformedMultipart(t *testing.T) {
373 // Mismatched boundary — multipart reader will return error
374 raw := "From: alice@example.com\r\n" +
375 "To: bob@example.com\r\n" +
376 "Subject: Malformed\r\n" +
377 "MIME-Version: 1.0\r\n" +
378 "Content-Type: multipart/mixed; boundary=\"expected\"\r\n" +
379 "\r\n" +
380 "--wrong\r\n" +
381 "Content-Type: text/plain\r\n" +
382 "\r\n" +
383 "Body\r\n" +
384 "--wrong--\r\n"
385
386 parsed, err := ParseMIME([]byte(raw))
387 // go-message handles mismatched boundaries gracefully (returns empty parts)
388 // so this may not error, but should return no text
389 if err != nil {
390 t.Logf("ParseMIME error (may be expected): %v", err)
391 return
392 }
393 // No parts should have been found since boundary doesn't match
394 if parsed.TextPlain != "" {
395 t.Errorf("expected empty TextPlain for mismatched boundary, got %q", parsed.TextPlain)
396 }
397 }
398
399 func TestParseMIME_NestedMultipart(t *testing.T) {
400 raw := "From: alice@example.com\r\n" +
401 "To: bob@example.com\r\n" +
402 "Subject: Nested\r\n" +
403 "MIME-Version: 1.0\r\n" +
404 "Content-Type: multipart/mixed; boundary=\"outer\"\r\n" +
405 "\r\n" +
406 "--outer\r\n" +
407 "Content-Type: multipart/alternative; boundary=\"inner\"\r\n" +
408 "\r\n" +
409 "--inner\r\n" +
410 "Content-Type: text/plain\r\n" +
411 "\r\n" +
412 "Nested plain\r\n" +
413 "--inner\r\n" +
414 "Content-Type: text/html\r\n" +
415 "\r\n" +
416 "<p>Nested HTML</p>\r\n" +
417 "--inner--\r\n" +
418 "--outer\r\n" +
419 "Content-Type: image/png\r\n" +
420 "Content-Disposition: attachment; filename=\"img.png\"\r\n" +
421 "\r\n" +
422 "PNG-DATA\r\n" +
423 "--outer--\r\n"
424
425 parsed, err := ParseMIME([]byte(raw))
426 if err != nil {
427 t.Fatalf("ParseMIME: %v", err)
428 }
429
430 if !strings.Contains(parsed.TextPlain, "Nested plain") {
431 t.Errorf("TextPlain = %q", parsed.TextPlain)
432 }
433 if !strings.Contains(parsed.TextHTML, "Nested HTML") {
434 t.Errorf("TextHTML = %q", parsed.TextHTML)
435 }
436 if len(parsed.Attachments) != 1 {
437 t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments))
438 }
439 if parsed.Attachments[0].Filename != "img.png" {
440 t.Errorf("attachment filename = %q", parsed.Attachments[0].Filename)
441 }
442 }
443