Repository: jhillyerd/enmime
Pull request #257 (2022-06-28 13:50:49)

feat: add option to treat multipart w/o boundary as single-part
Thit PR is a proposal to add option to treat multipart message without boundary as single-part.

For example message:
```
Content-Type: multipart/report;

I'm  multipart message without boundary
```

doesn't have boundary parameter in `Content-Type` header, but still can be parsed as single-part message.
Currently, parser returns error that it can't find boundary. With option turned on, parser will treat multipart messages without boundary as single-part.

jhillyerd: Overall looks good, only request is to change `Singlepart` to `SinglePart`, as the correct prefixed-word would be `Unipart` but I don't think that would make sense to users.
dmytrokasianenko-outreach: > Overall looks good, only request is to change `Singlepart` to `SinglePart`, as the correct prefixed-word would be `Unipart` but I don't think that would make sense to users.

updated 🙂 
jhillyerd: Thanks!

Patch:
diff --git a/detect.go b/detect.go
index afc999c..6704192 100644
--- a/detect.go
+++ b/detect.go
@@ -8,14 +8,21 @@ import (
 )
 
 // detectMultipartMessage returns true if the message has a recognized multipart Content-Type header
-func detectMultipartMessage(root *Part) bool {
+func detectMultipartMessage(root *Part, multipartWOBoundaryAsSinglepart bool) bool {
 	// Parse top-level multipart
 	ctype := root.Header.Get(hnContentType)
-	mtype, _, _, err := mediatype.Parse(ctype)
+	mtype, params, _, err := mediatype.Parse(ctype)
+	if err != nil {
+		return false
+	}
+
+	if boundary := params[hpBoundary]; multipartWOBoundaryAsSinglepart && boundary == "" {
+		return false
+	}
 
 	// According to rfc2046#section-5.1.7 all other multipart should
 	// be treated as multipart/mixed
-	return err == nil && strings.HasPrefix(mtype, ctMultipartPrefix)
+	return strings.HasPrefix(mtype, ctMultipartPrefix)
 }
 
 // detectAttachmentHeader returns true, if the given header defines an attachment. First it checks
diff --git a/envelope.go b/envelope.go
index 039524c..0b7b934 100644
--- a/envelope.go
+++ b/envelope.go
@@ -152,26 +152,31 @@ func ReadEnvelope(r io.Reader) (*Envelope, error) {
 	return defaultParser.ReadEnvelope(r)
 }
 
-// ReadEnvelope is the same as ReadEnvelope, but respects configurations.
+// ReadEnvelope is the same as ReadEnvelope, but respects parser configurations.
 func (p Parser) ReadEnvelope(r io.Reader) (*Envelope, error) {
 	// Read MIME parts from reader
 	root, err := p.ReadParts(r)
 	if err != nil {
 		return nil, errors.WithMessage(err, "Failed to ReadParts")
 	}
-	return EnvelopeFromPart(root)
+	return p.EnvelopeFromPart(root)
 }
 
 // EnvelopeFromPart uses the provided Part tree to build an Envelope, downconverting HTML to plain
 // text if needed, and sorting the attachments, inlines and other parts into their respective
 // slices.  Errors are collected from all Parts and placed into the Envelopes Errors slice.
 func EnvelopeFromPart(root *Part) (*Envelope, error) {
+	return defaultParser.EnvelopeFromPart(root)
+}
+
+// EnvelopeFromPart is the same as EnvelopeFromPart, but respects parser configurations.
+func (p Parser) EnvelopeFromPart(root *Part) (*Envelope, error) {
 	e := &Envelope{
 		Root:   root,
 		header: &root.Header,
 	}
 
-	if detectMultipartMessage(root) {
+	if detectMultipartMessage(root, p.multipartWOBoundaryAsSinglePart) {
 		// Multi-part message (message with attachments, etc)
 		if err := parseMultiPartBody(root, e); err != nil {
 			return nil, err
diff --git a/options.go b/options.go
index c45cc39..4139d7b 100644
--- a/options.go
+++ b/options.go
@@ -15,3 +15,15 @@ type skipMalformedPartsOption bool
 func (o skipMalformedPartsOption) apply(p *Parser) {
 	p.skipMalformedParts = bool(o)
 }
+
+// MultipartWOBoundaryAsSinglePart if set to true will treat a multi-part messages without boundary parameter as single-part.
+// Otherwise, will return error that boundary is not found.
+func MultipartWOBoundaryAsSinglePart(a bool) Option {
+	return multipartWOBoundaryAsSinglePartOption(a)
+}
+
+type multipartWOBoundaryAsSinglePartOption bool
+
+func (o multipartWOBoundaryAsSinglePartOption) apply(p *Parser) {
+	p.multipartWOBoundaryAsSinglePart = bool(o)
+}
diff --git a/parser.go b/parser.go
index d5f2bd8..5a32dbb 100644
--- a/parser.go
+++ b/parser.go
@@ -3,7 +3,8 @@ package enmime
 // Parser parses MIME.
 // Default parser is a valid one.
 type Parser struct {
-	skipMalformedParts bool
+	skipMalformedParts              bool
+	multipartWOBoundaryAsSinglePart bool
 }
 
 // defaultParser is a Parser with default configuration.
diff --git a/part.go b/part.go
index a7a057a..52737f5 100644
--- a/part.go
+++ b/part.go
@@ -356,7 +356,8 @@ func (p Parser) ReadParts(r io.Reader) (*Part, error) {
 	if err != nil {
 		return nil, err
 	}
-	if strings.HasPrefix(root.ContentType, ctMultipartPrefix) {
+
+	if detectMultipartMessage(root, p.multipartWOBoundaryAsSinglePart) {
 		// Content is multipart, parse it.
 		err = parseParts(root, br, p.skipMalformedParts)
 		if err != nil {


Test patch:
diff --git a/detect_test.go b/detect_test.go
index ff48090..204da21 100644
--- a/detect_test.go
+++ b/detect_test.go
@@ -14,7 +14,7 @@ func TestDetectSinglePart(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	if detectMultipartMessage(msg) {
+	if detectMultipartMessage(msg, false) {
 		t.Error("Failed to identify non-multipart message")
 	}
 }
@@ -26,7 +26,7 @@ func TestDetectMultiPart(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	if !detectMultipartMessage(msg) {
+	if !detectMultipartMessage(msg, false) {
 		t.Error("Failed to identify multipart MIME message")
 	}
 }
@@ -38,11 +38,27 @@ func TestDetectUnknownMultiPart(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	if !detectMultipartMessage(msg) {
+	if !detectMultipartMessage(msg, false) {
 		t.Error("Failed to identify multipart MIME message of unknown type")
 	}
 }
 
+func TestDetectMultipartWithoutBoundary(t *testing.T) {
+	r, _ := os.Open(filepath.Join("testdata", "mail", "multipart-wo-boundary.raw"))
+	msg, err := ReadParts(r)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if !detectMultipartMessage(msg, false) {
+		t.Error("Failed to identify multipart MIME message")
+	}
+
+	if detectMultipartMessage(msg, true) {
+		t.Error("Failed to identify multipart MIME message without boundaries as single-part")
+	}
+}
+
 func TestDetectBinaryBody(t *testing.T) {
 	ttable := []struct {
 		filename    string
diff --git a/envelope_test.go b/envelope_test.go
index c1f47ef..6ece7bc 100644
--- a/envelope_test.go
+++ b/envelope_test.go
@@ -589,6 +589,31 @@ func TestParseHTMLOnlyCharsetInHeaderOnly(t *testing.T) {
 	}
 }
 
+func TestParseMultipartWOBoundaryFails(t *testing.T) {
+	r := test.OpenTestData("mail", "multipart-wo-boundary.raw")
+	_, err := enmime.ReadEnvelope(r)
+	if err == nil {
+		t.Fatal("Expecting parsing to fail")
+	}
+
+	if !strings.Contains(err.Error(), "unable to locate boundary param in Content-Type header") {
+		t.Fatal("Expecting for unable to locate boundary error")
+	}
+}
+
+func TestParseMultipartWOBoundaryAsSinglepart(t *testing.T) {
+	r := test.OpenTestData("mail", "multipart-wo-boundary.raw")
+	p := enmime.NewParser(enmime.MultipartWOBoundaryAsSinglePart(true))
+	e, err := p.ReadEnvelope(r)
+	if err != nil {
+		t.Fatal("Failed to parse MIME:", err)
+	}
+
+	if !bytes.Contains(e.Root.Content, []byte(`I'm  multipart message without boundary`)) {
+		t.Fatal("Expecting multipart without boundary to be parsed")
+	}
+}
+
 func TestEnvelopeGetHeader(t *testing.T) {
 	// Test empty header
 	e := &enmime.Envelope{}
diff --git a/testdata/mail/multipart-wo-boundary.raw b/testdata/mail/multipart-wo-boundary.raw
new file mode 100644
index 0000000..a2f8c3b
--- /dev/null
+++ b/testdata/mail/multipart-wo-boundary.raw
@@ -0,0 +1,8 @@
+Delivered-To: test@example.com
+From: Mailer-Daemon@example.com
+Subject: Delivery Status Notification (Failure)
+To: user@example.com
+Content-Type: multipart/report;
+Message-Id: <444A6F676F-A9A1C1F7-799A-4D89-B97E-8057E132FBFB@YSHSERVER>
+
+I'm  multipart message without boundary