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