Repository: jhillyerd/enmime
Pull request #213 (2021-08-15 20:04:32)

Move ParseMediaType to mediatype.Parse
- Copy media type related functions to new package
- Update refs, cleanup tests
- Move ParseMediaType to mediatype.Parse


github-actions[bot]: ## ⚠ staticcheck Failed
```
header.go:32:2: const ctPlaceholder is unused (U1000)
header.go:36:2: const pvPlaceholder is unused (U1000)
```

[Checks Document](https://staticcheck.io/docs/checks)


Patch:
diff --git a/detect.go b/detect.go
index 12587d6..1628f29 100644
--- a/detect.go
+++ b/detect.go
@@ -3,17 +3,19 @@ package enmime
 import (
 	"net/textproto"
 	"strings"
+
+	"github.com/jhillyerd/enmime/internal/mediatype"
 )
 
 // detectMultipartMessage returns true if the message has a recognized multipart Content-Type header
 func detectMultipartMessage(root *Part) bool {
 	// Parse top-level multipart
 	ctype := root.Header.Get(hnContentType)
-	mediatype, _, _, err := ParseMediaType(ctype)
+	mtype, _, _, err := mediatype.Parse(ctype)
 
 	// According to rfc2046#section-5.1.7 all other multipart should
 	// be treated as multipart/mixed
-	return err == nil && strings.HasPrefix(mediatype, ctMultipartPrefix)
+	return err == nil && strings.HasPrefix(mtype, ctMultipartPrefix)
 }
 
 // detectAttachmentHeader returns true, if the given header defines an attachment. First it checks
@@ -27,14 +29,14 @@ func detectMultipartMessage(root *Part) bool {
 //  - Content-Disposition: inline; filename="frog.jpg"
 //  - Content-Type: attachment; filename="frog.jpg"
 func detectAttachmentHeader(header textproto.MIMEHeader) bool {
-	mediatype, params, _, _ := ParseMediaType(header.Get(hnContentDisposition))
-	if strings.ToLower(mediatype) == cdAttachment ||
-		(strings.ToLower(mediatype) == cdInline && len(params) > 0) {
+	mtype, params, _, _ := mediatype.Parse(header.Get(hnContentDisposition))
+	if strings.ToLower(mtype) == cdAttachment ||
+		(strings.ToLower(mtype) == cdInline && len(params) > 0) {
 		return true
 	}
 
-	mediatype, _, _, _ = ParseMediaType(header.Get(hnContentType))
-	return strings.ToLower(mediatype) == cdAttachment
+	mtype, _, _, _ = mediatype.Parse(header.Get(hnContentType))
+	return strings.ToLower(mtype) == cdAttachment
 }
 
 // detectTextHeader returns true, if the the MIME headers define a valid 'text/plain' or 'text/html'
@@ -46,8 +48,8 @@ func detectTextHeader(header textproto.MIMEHeader, emptyContentTypeIsText bool)
 		return true
 	}
 
-	if mediatype, _, _, err := ParseMediaType(ctype); err == nil {
-		switch mediatype {
+	if mtype, _, _, err := mediatype.Parse(ctype); err == nil {
+		switch mtype {
 		case ctTextPlain, ctTextHTML:
 			return true
 		}
@@ -64,8 +66,8 @@ func detectBinaryBody(root *Part) bool {
 		// Content-Disposition: attachment; filename="test.csv"
 		// Check for attachment only, or inline body is marked
 		// as attachment, too.
-		mediatype, _, _, _ := ParseMediaType(root.Header.Get(hnContentDisposition))
-		return strings.ToLower(mediatype) == cdAttachment
+		mtype, _, _, _ := mediatype.Parse(root.Header.Get(hnContentDisposition))
+		return strings.ToLower(mtype) == cdAttachment
 	}
 
 	isBin := detectAttachmentHeader(root.Header)
@@ -74,9 +76,9 @@ func detectBinaryBody(root *Part) bool {
 		// 'text/plain' or 'text/html'.
 		// Example:
 		// Content-Type: application/pdf; name="doc.pdf"
-		mediatype, _, _, _ := ParseMediaType(root.Header.Get(hnContentType))
-		mediatype = strings.ToLower(mediatype)
-		if mediatype != ctTextPlain && mediatype != ctTextHTML {
+		mtype, _, _, _ := mediatype.Parse(root.Header.Get(hnContentType))
+		mtype = strings.ToLower(mtype)
+		if mtype != ctTextPlain && mtype != ctTextHTML {
 			return true
 		}
 	}
diff --git a/envelope.go b/envelope.go
index 8c2fd5e..70eb661 100644
--- a/envelope.go
+++ b/envelope.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/jaytaylor/html2text"
 	"github.com/jhillyerd/enmime/internal/coding"
+	"github.com/jhillyerd/enmime/internal/mediatype"
 	"github.com/pkg/errors"
 )
 
@@ -227,7 +228,7 @@ func parseTextOnlyBody(root *Part, e *Envelope) error {
 	var charset string
 	var isHTML bool
 	if ctype := root.Header.Get(hnContentType); ctype != "" {
-		if mediatype, mparams, _, err := ParseMediaType(ctype); err == nil {
+		if mediatype, mparams, _, err := mediatype.Parse(ctype); err == nil {
 			isHTML = (mediatype == ctTextHTML)
 			if mparams[hpCharset] != "" {
 				charset = mparams[hpCharset]
@@ -266,7 +267,7 @@ func parseTextOnlyBody(root *Part, e *Envelope) error {
 func parseMultiPartBody(root *Part, e *Envelope) error {
 	// Parse top-level multipart
 	ctype := root.Header.Get(hnContentType)
-	mediatype, params, _, err := ParseMediaType(ctype)
+	mediatype, params, _, err := mediatype.Parse(ctype)
 	if err != nil {
 		return fmt.Errorf("unable to parse media type: %v", err)
 	}
diff --git a/header.go b/header.go
index 4103a3c..1bf98ed 100644
--- a/header.go
+++ b/header.go
@@ -7,10 +7,8 @@ import (
 	"mime"
 	"net/textproto"
 	"strings"
-	_utf8 "unicode/utf8"
 
 	"github.com/jhillyerd/enmime/internal/coding"
-	"github.com/jhillyerd/enmime/internal/stringutil"
 	"github.com/pkg/errors"
 )
 
@@ -30,13 +28,6 @@ const (
 	ctTextPlain        = "text/plain"
 	ctTextHTML         = "text/html"
 
-	// Used as a placeholder in case of malformed Content-Type headers
-	ctPlaceholder = "x-not-a-mime-type/x-not-a-mime-type"
-	// Used as a placeholder param value in case of malformed
-	// Content-Type/Content-Disposition parameters that lack values.
-	// E.g.: Content-Type: text/html;iso-8859-1
-	pvPlaceholder = "not-a-param-value"
-
 	// Standard Transfer encodings
 	cte7Bit            = "7bit"
 	cte8Bit            = "8bit"
@@ -205,445 +196,6 @@ func quotedDisplayName(s string) string {
 	return fmt.Sprintf("%s %s", s[:idx+1], s[idx+1:])
 }
 
-// ParseMediaType is a more tolerant implementation of Go's mime.ParseMediaType function.
-//
-// Tolerances accounted for:
-//   * Missing ';' between content-type and media parameters
-//   * Repeating media parameters
-//   * Unquoted values in media parameters containing 'tspecials' characters
-func ParseMediaType(ctype string) (mtype string, params map[string]string, invalidParams []string, err error) {
-	mtype, params, err = mime.ParseMediaType(
-		fixUnescapedQuotes(fixUnquotedSpecials(fixMangledMediaType(ctype, ';'))))
-	if err != nil {
-		if err.Error() == "mime: no media type" {
-			return "", nil, nil, nil
-		}
-		return "", nil, nil, errors.WithStack(err)
-	}
-
-	if mtype == ctPlaceholder {
-		mtype = ""
-	}
-	for name, value := range params {
-		if value != pvPlaceholder {
-			continue
-		}
-		invalidParams = append(invalidParams, name)
-		delete(params, name)
-	}
-
-	return mtype, params, invalidParams, err
-}
-
-// fixMangledMediaType is used to insert ; separators into media type strings that lack them, and
-// remove repeated parameters.
-func fixMangledMediaType(mtype string, sep rune) string {
-	strsep := string([]rune{sep})
-	if mtype == "" {
-		return ""
-	}
-
-	parts := stringutil.SplitQuoted(mtype, sep, '"')
-	mtype = ""
-	if strings.Contains(parts[0], "=") {
-		// A parameter pair at this position indicates we are missing a content-type.
-		parts[0] = fmt.Sprintf("%s%s %s", ctAppOctetStream, strsep, parts[0])
-		parts = strings.Split(strings.Join(parts, strsep), strsep)
-	}
-
-	for i, p := range parts {
-		switch i {
-		case 0:
-			if p == "" {
-				// The content type is completely missing. Put in a placeholder.
-				p = ctPlaceholder
-			}
-			// Check for missing token after slash.
-			if strings.HasSuffix(p, "/") {
-				switch p {
-				case ctTextPrefix:
-					p = ctTextPlain
-				case ctAppPrefix:
-					p = ctAppOctetStream
-				case ctMultipartPrefix:
-					p = ctMultipartMixed
-				default:
-					// Safe default
-					p = ctAppOctetStream
-				}
-			}
-		default:
-			if len(p) == 0 {
-				// Ignore trailing separators.
-				continue
-			}
-
-			if len(strings.TrimSpace(p)) == 0 {
-				// Ignore empty parameters.
-				continue
-			}
-
-			if !strings.Contains(p, "=") {
-				p = p + "=" + pvPlaceholder
-			}
-
-			// RFC-2047 encoded attribute name.
-			p = coding.RFC2047Decode(p)
-
-			pair := strings.SplitAfter(p, "=")
-			if strings.Contains(mtype, strings.TrimSpace(pair[0])) {
-				// Ignore repeated parameters.
-				continue
-			}
-
-			if strings.ContainsAny(pair[0], "()<>@,;:\"\\/[]?") {
-				// Attribute is a strict token and cannot be a quoted-string.  If any of the above
-				// characters are present in a token it must be quoted and is therefor an invalid
-				// attribute.  Discard the pair.
-				continue
-			}
-		}
-
-		mtype += p
-
-		// Only terminate with semicolon if not the last parameter and if it doesn't already have a
-		// semicolon.
-		if i != len(parts)-1 && !strings.HasSuffix(mtype, ";") {
-			// Remove whitespace between parameter=value and ;
-			mtype = strings.TrimRight(mtype, " \t")
-			mtype += ";"
-		}
-	}
-
-	mtype = strings.TrimSuffix(mtype, ";")
-
-	return mtype
-}
-
-// consumeParam takes the the parameter part of a Content-Type header, returns a clean version of
-// the first parameter (quoted as necessary), and the remainder of the parameter part of the
-// Content-Type header.
-//
-// Given this this header:
-//     `Content-Type: text/calendar; charset=utf-8; method=text/calendar`
-// `consumeParams` should be given this part:
-//     ` charset=utf-8; method=text/calendar`
-// And returns (first pass):
-//     `consumed = "charset=utf-8;"`
-//     `rest     = " method=text/calendar"`
-// Capture the `consumed` value (to build a clean Content-Type header value) and pass the value of
-// `rest` back to `consumeParam`. That second call will return:
-//     `consumed = " method=\"text/calendar\""`
-//     `rest     = ""`
-// Again, use the value of `consumed` to build a clean Content-Type header value. Given that `rest`
-// is empty, all of the parameters have been consumed successfully.
-//
-// If `consumed` is returned empty and `rest` is not empty, then the value of `rest` does not
-// begin with a parsable parameter. This does not necessarily indicate a problem. For example,
-// if there is trailing whitespace, it would be returned here.
-func consumeParam(s string) (consumed, rest string) {
-	i := strings.IndexByte(s, '=')
-	if i < 0 {
-		return "", s
-	}
-
-	// Write out parameter name.
-	param := strings.Builder{}
-	param.WriteString(s[:i+1])
-	s = s[i+1:]
-
-	value := strings.Builder{}
-	valueQuotedOriginally := false
-	valueQuoteAdded := false
-	valueQuoteNeeded := false
-	rfc2047Needed := false
-
-	var r rune
-findValueStart:
-	for i, r = range s {
-		switch r {
-		case ' ', '\t':
-			// Do not preserve leading whitespace.
-
-		case '"':
-			valueQuotedOriginally = true
-			valueQuoteAdded = true
-			valueQuoteNeeded = true
-			param.WriteRune(r)
-
-			break findValueStart
-
-		case ';':
-			if value.Len() == 0 {
-				// Value was empty, return immediately.
-				param.WriteString(`"";`)
-				return param.String(), s[i+1:]
-			}
-
-			break findValueStart
-
-		default:
-			if r > 127 {
-				rfc2047Needed = true
-			}
-
-			valueQuotedOriginally = false
-			valueQuoteAdded = false
-			value.WriteRune(r)
-
-			break findValueStart
-		}
-	}
-
-	quoteIfUnquoted := func() {
-		if !valueQuoteNeeded {
-			if !valueQuoteAdded {
-				param.WriteByte('"')
-
-				valueQuoteAdded = true
-			}
-
-			valueQuoteNeeded = true
-		}
-	}
-
-	if len(s)-i < 1 {
-		// Parameter value starts at the end of the string, make empty
-		// quoted string to play nice with mime.ParseMediaType.
-		param.WriteString(`""`)
-	} else {
-		// The beginning of the value is not at the end of the string.
-		for _, v := range []byte{'(', ')', '<', '>', '@', ',', ':', '/', '[', ']', '?', '='} {
-			if s[0] == v {
-				quoteIfUnquoted()
-				break
-			}
-		}
-
-		_, runeLength := _utf8.DecodeRuneInString(s[i:])
-		s = s[i+runeLength:]
-		escaped := false
-
-	findValueEnd:
-		for i, r = range s {
-			if escaped {
-				value.WriteRune(r)
-				escaped = false
-				continue
-			}
-
-			switch r {
-			case ';':
-				if valueQuotedOriginally {
-					// We're in a quoted string, so whitespace is allowed.
-					value.WriteRune(r)
-					break
-				}
-
-				// Otherwise, we've reached the end of an unquoted value.
-				rest = s[i:]
-				break findValueEnd
-
-			case ' ', '\t':
-				if valueQuotedOriginally {
-					// We're in a quoted string, so whitespace is allowed.
-					value.WriteRune(r)
-					break
-				}
-
-				// This string contains whitespace, must be quoted.
-				quoteIfUnquoted()
-				value.WriteRune(r)
-
-			case '"':
-				if valueQuotedOriginally {
-					// We're in a quoted value. This is the end of that value.
-					rest = s[i:]
-					break findValueEnd
-				}
-
-				quoteIfUnquoted()
-				value.WriteByte('\\')
-				value.WriteRune(r)
-
-			case '\\':
-				if i < len(s)-1 {
-					// If next char is present, escape it with backslash.
-					value.WriteRune(r)
-					escaped = true
-					quoteIfUnquoted()
-				}
-
-			case '(', ')', '<', '>', '@', ',', ':', '/', '[', ']', '?', '=':
-				quoteIfUnquoted()
-				fallthrough
-
-			default:
-				if r > 127 {
-					rfc2047Needed = true
-				}
-				value.WriteRune(r)
-			}
-		}
-	}
-
-	if value.Len() > 0 {
-		// Convert whole value to RFC2047 if it contains forbidden characters (ASCII > 127)
-		val := value.String()
-		if rfc2047Needed {
-			val = mime.BEncoding.Encode(utf8, val)
-			// RFC 2047 must be quoted
-			quoteIfUnquoted()
-		}
-
-		// Write the value
-		param.WriteString(val)
-	}
-
-	// Add final quote if required
-	if valueQuoteNeeded {
-		param.WriteByte('"')
-	}
-
-	// Write last parsed char if any
-	if rest != "" {
-		if rest[0] != '"' {
-			// When last char is quote, valueQuotedOriginally is surely true and the quote was already written.
-			// Otherwise output the character (; for example)
-			param.WriteByte(rest[0])
-		}
-
-		// Focus the rest of the string
-		rest = rest[1:]
-	}
-
-	return param.String(), rest
-}
-
-// fixUnquotedSpecials as defined in RFC 2045, section 5.1:
-// https://tools.ietf.org/html/rfc2045#section-5.1
-func fixUnquotedSpecials(s string) string {
-	idx := strings.IndexByte(s, ';')
-	if idx < 0 || idx == len(s) {
-		// No parameters
-		return s
-	}
-
-	clean := strings.Builder{}
-	clean.WriteString(s[:idx+1])
-	s = s[idx+1:]
-
-	for len(s) > 0 {
-		var consumed string
-		consumed, s = consumeParam(s)
-
-		if len(consumed) == 0 {
-			clean.WriteString(s)
-			return clean.String()
-		}
-
-		clean.WriteString(consumed)
-	}
-
-	return clean.String()
-}
-
-// fixUnescapedQuotes inspects for unescaped quotes inside of a quoted string and escapes them
-//
-//  Input:  application/rtf; charset=iso-8859-1; name=""V047411.rtf".rtf"
-//  Output: application/rtf; charset=iso-8859-1; name="\"V047411.rtf\".rtf"
-func fixUnescapedQuotes(hvalue string) string {
-	params := strings.SplitAfter(hvalue, ";")
-	sb := &strings.Builder{}
-
-	for i := 0; i < len(params); i++ {
-		// Inspect for "=" byte.
-		eq := strings.IndexByte(params[i], '=')
-		if eq < 0 {
-			// No "=", must be the content-type or a comment.
-			sb.WriteString(params[i])
-			continue
-		}
-		sb.WriteString(params[i][:eq])
-		param := params[i][eq:]
-		startingQuote := strings.IndexByte(param, '"')
-		closingQuote := strings.LastIndexByte(param, '"')
-
-		// Opportunity to exit early if there are no quotes.
-		if startingQuote < 0 && closingQuote < 0 {
-			// This value is not quoted, write the value and carry on.
-			sb.WriteString(param)
-			continue
-		}
-
-		// Check if only one quote was found in the string.
-		if closingQuote == startingQuote {
-			// Append the next chunk of params here in case of a semicolon mid string.
-			if len(params) > i+1 {
-				param = fmt.Sprintf("%s%s", param, params[i+1])
-			}
-			closingQuote = strings.LastIndexByte(param, '"')
-			i++
-			if closingQuote == startingQuote {
-				sb.WriteString("=\"\"")
-				return sb.String()
-			}
-		}
-
-		// Write the k/v separator back in along with everything up until the first quote.
-		sb.WriteByte('=')
-		sb.WriteByte('"') // Starting quote
-		sb.WriteString(param[1:startingQuote])
-
-		// Get the value, less the outer quotes.
-		rest := param[closingQuote+1:]
-
-		// If there is stuff after the last quote then we should escape the first quote.
-		if len(rest) > 0 && rest != ";" {
-			sb.WriteString("\\\"")
-		}
-
-		param = param[startingQuote+1 : closingQuote]
-		escaped := false
-		for strIdx := range []byte(param) {
-			switch param[strIdx] {
-			case '"':
-				// We are inside of a quoted string, so lets escape this guy if it isn't already escaped.
-				if !escaped {
-					sb.WriteByte('\\')
-					escaped = false
-				}
-				sb.WriteByte(param[strIdx])
-			case '\\':
-				// Something is getting escaped, a quote is the only char that needs
-				// this, so lets assume the following char is a double-quote.
-				escaped = true
-				sb.WriteByte('\\')
-			default:
-				escaped = false
-				sb.WriteByte(param[strIdx])
-			}
-		}
-
-		// If there is stuff after the last quote then we should escape the last quote, apply the
-		// rest and terminate with a quote.
-		switch rest {
-		case ";":
-			sb.WriteByte('"')
-			sb.WriteString(rest)
-		case "":
-			sb.WriteByte('"')
-		default:
-			sb.WriteByte('\\')
-			sb.WriteByte('"')
-			sb.WriteString(rest)
-			sb.WriteByte('"')
-		}
-	}
-
-	return sb.String()
-}
-
 // Detects a RFC-822 linear-white-space, passed to strings.FieldsFunc.
 func whiteSpaceRune(r rune) bool {
 	return r == ' ' || r == '\t' || r == '\r' || r == '\n'
diff --git a/internal/mediatype/mediatype.go b/internal/mediatype/mediatype.go
new file mode 100644
index 0000000..7c3a026
--- /dev/null
+++ b/internal/mediatype/mediatype.go
@@ -0,0 +1,473 @@
+package mediatype
+
+import (
+	"fmt"
+	"mime"
+	"strings"
+	_utf8 "unicode/utf8"
+
+	"github.com/jhillyerd/enmime/internal/coding"
+	"github.com/jhillyerd/enmime/internal/stringutil"
+	"github.com/pkg/errors"
+)
+
+const (
+	// Standard MIME content types
+	ctAppPrefix        = "application/"
+	ctAppOctetStream   = "application/octet-stream"
+	ctMultipartAltern  = "multipart/alternative"
+	ctMultipartMixed   = "multipart/mixed"
+	ctMultipartPrefix  = "multipart/"
+	ctMultipartRelated = "multipart/related"
+	ctTextPrefix       = "text/"
+	ctTextPlain        = "text/plain"
+	ctTextHTML         = "text/html"
+
+	// Used as a placeholder in case of malformed Content-Type headers
+	ctPlaceholder = "x-not-a-mime-type/x-not-a-mime-type"
+	// Used as a placeholder param value in case of malformed
+	// Content-Type/Content-Disposition parameters that lack values.
+	// E.g.: Content-Type: text/html;iso-8859-1
+	pvPlaceholder = "not-a-param-value"
+
+	utf8 = "utf-8"
+)
+
+// Parse is a more tolerant implementation of Go's mime.ParseMediaType function.
+//
+// Tolerances accounted for:
+//   * Missing ';' between content-type and media parameters
+//   * Repeating media parameters
+//   * Unquoted values in media parameters containing 'tspecials' characters
+func Parse(ctype string) (mtype string, params map[string]string, invalidParams []string, err error) {
+	mtype, params, err = mime.ParseMediaType(
+		fixUnescapedQuotes(fixUnquotedSpecials(fixMangledMediaType(ctype, ';'))))
+	if err != nil {
+		if err.Error() == "mime: no media type" {
+			return "", nil, nil, nil
+		}
+		return "", nil, nil, errors.WithStack(err)
+	}
+
+	if mtype == ctPlaceholder {
+		mtype = ""
+	}
+	for name, value := range params {
+		if value != pvPlaceholder {
+			continue
+		}
+		invalidParams = append(invalidParams, name)
+		delete(params, name)
+	}
+
+	return mtype, params, invalidParams, err
+}
+
+// fixMangledMediaType is used to insert ; separators into media type strings that lack them, and
+// remove repeated parameters.
+func fixMangledMediaType(mtype string, sep rune) string {
+	strsep := string([]rune{sep})
+	if mtype == "" {
+		return ""
+	}
+
+	parts := stringutil.SplitQuoted(mtype, sep, '"')
+	mtype = ""
+	if strings.Contains(parts[0], "=") {
+		// A parameter pair at this position indicates we are missing a content-type.
+		parts[0] = fmt.Sprintf("%s%s %s", ctAppOctetStream, strsep, parts[0])
+		parts = strings.Split(strings.Join(parts, strsep), strsep)
+	}
+
+	for i, p := range parts {
+		switch i {
+		case 0:
+			if p == "" {
+				// The content type is completely missing. Put in a placeholder.
+				p = ctPlaceholder
+			}
+			// Check for missing token after slash.
+			if strings.HasSuffix(p, "/") {
+				switch p {
+				case ctTextPrefix:
+					p = ctTextPlain
+				case ctAppPrefix:
+					p = ctAppOctetStream
+				case ctMultipartPrefix:
+					p = ctMultipartMixed
+				default:
+					// Safe default
+					p = ctAppOctetStream
+				}
+			}
+		default:
+			if len(p) == 0 {
+				// Ignore trailing separators.
+				continue
+			}
+
+			if len(strings.TrimSpace(p)) == 0 {
+				// Ignore empty parameters.
+				continue
+			}
+
+			if !strings.Contains(p, "=") {
+				p = p + "=" + pvPlaceholder
+			}
+
+			// RFC-2047 encoded attribute name.
+			p = coding.RFC2047Decode(p)
+
+			pair := strings.SplitAfter(p, "=")
+			if strings.Contains(mtype, strings.TrimSpace(pair[0])) {
+				// Ignore repeated parameters.
+				continue
+			}
+
+			if strings.ContainsAny(pair[0], "()<>@,;:\"\\/[]?") {
+				// Attribute is a strict token and cannot be a quoted-string.  If any of the above
+				// characters are present in a token it must be quoted and is therefor an invalid
+				// attribute.  Discard the pair.
+				continue
+			}
+		}
+
+		mtype += p
+
+		// Only terminate with semicolon if not the last parameter and if it doesn't already have a
+		// semicolon.
+		if i != len(parts)-1 && !strings.HasSuffix(mtype, ";") {
+			// Remove whitespace between parameter=value and ;
+			mtype = strings.TrimRight(mtype, " \t")
+			mtype += ";"
+		}
+	}
+
+	mtype = strings.TrimSuffix(mtype, ";")
+
+	return mtype
+}
+
+// consumeParam takes the the parameter part of a Content-Type header, returns a clean version of
+// the first parameter (quoted as necessary), and the remainder of the parameter part of the
+// Content-Type header.
+//
+// Given this this header:
+//     `Content-Type: text/calendar; charset=utf-8; method=text/calendar`
+// `consumeParams` should be given this part:
+//     ` charset=utf-8; method=text/calendar`
+// And returns (first pass):
+//     `consumed = "charset=utf-8;"`
+//     `rest     = " method=text/calendar"`
+// Capture the `consumed` value (to build a clean Content-Type header value) and pass the value of
+// `rest` back to `consumeParam`. That second call will return:
+//     `consumed = " method=\"text/calendar\""`
+//     `rest     = ""`
+// Again, use the value of `consumed` to build a clean Content-Type header value. Given that `rest`
+// is empty, all of the parameters have been consumed successfully.
+//
+// If `consumed` is returned empty and `rest` is not empty, then the value of `rest` does not
+// begin with a parsable parameter. This does not necessarily indicate a problem. For example,
+// if there is trailing whitespace, it would be returned here.
+func consumeParam(s string) (consumed, rest string) {
+	i := strings.IndexByte(s, '=')
+	if i < 0 {
+		return "", s
+	}
+
+	// Write out parameter name.
+	param := strings.Builder{}
+	param.WriteString(s[:i+1])
+	s = s[i+1:]
+
+	value := strings.Builder{}
+	valueQuotedOriginally := false
+	valueQuoteAdded := false
+	valueQuoteNeeded := false
+	rfc2047Needed := false
+
+	var r rune
+findValueStart:
+	for i, r = range s {
+		switch r {
+		case ' ', '\t':
+			// Do not preserve leading whitespace.
+
+		case '"':
+			valueQuotedOriginally = true
+			valueQuoteAdded = true
+			valueQuoteNeeded = true
+			param.WriteRune(r)
+
+			break findValueStart
+
+		case ';':
+			if value.Len() == 0 {
+				// Value was empty, return immediately.
+				param.WriteString(`"";`)
+				return param.String(), s[i+1:]
+			}
+
+			break findValueStart
+
+		default:
+			if r > 127 {
+				rfc2047Needed = true
+			}
+
+			valueQuotedOriginally = false
+			valueQuoteAdded = false
+			value.WriteRune(r)
+
+			break findValueStart
+		}
+	}
+
+	quoteIfUnquoted := func() {
+		if !valueQuoteNeeded {
+			if !valueQuoteAdded {
+				param.WriteByte('"')
+
+				valueQuoteAdded = true
+			}
+
+			valueQuoteNeeded = true
+		}
+	}
+
+	if len(s)-i < 1 {
+		// Parameter value starts at the end of the string, make empty
+		// quoted string to play nice with mime.ParseMediaType.
+		param.WriteString(`""`)
+	} else {
+		// The beginning of the value is not at the end of the string.
+		for _, v := range []byte{'(', ')', '<', '>', '@', ',', ':', '/', '[', ']', '?', '='} {
+			if s[0] == v {
+				quoteIfUnquoted()
+				break
+			}
+		}
+
+		_, runeLength := _utf8.DecodeRuneInString(s[i:])
+		s = s[i+runeLength:]
+		escaped := false
+
+	findValueEnd:
+		for i, r = range s {
+			if escaped {
+				value.WriteRune(r)
+				escaped = false
+				continue
+			}
+
+			switch r {
+			case ';':
+				if valueQuotedOriginally {
+					// We're in a quoted string, so whitespace is allowed.
+					value.WriteRune(r)
+					break
+				}
+
+				// Otherwise, we've reached the end of an unquoted value.
+				rest = s[i:]
+				break findValueEnd
+
+			case ' ', '\t':
+				if valueQuotedOriginally {
+					// We're in a quoted string, so whitespace is allowed.
+					value.WriteRune(r)
+					break
+				}
+
+				// This string contains whitespace, must be quoted.
+				quoteIfUnquoted()
+				value.WriteRune(r)
+
+			case '"':
+				if valueQuotedOriginally {
+					// We're in a quoted value. This is the end of that value.
+					rest = s[i:]
+					break findValueEnd
+				}
+
+				quoteIfUnquoted()
+				value.WriteByte('\\')
+				value.WriteRune(r)
+
+			case '\\':
+				if i < len(s)-1 {
+					// If next char is present, escape it with backslash.
+					value.WriteRune(r)
+					escaped = true
+					quoteIfUnquoted()
+				}
+
+			case '(', ')', '<', '>', '@', ',', ':', '/', '[', ']', '?', '=':
+				quoteIfUnquoted()
+				fallthrough
+
+			default:
+				if r > 127 {
+					rfc2047Needed = true
+				}
+				value.WriteRune(r)
+			}
+		}
+	}
+
+	if value.Len() > 0 {
+		// Convert whole value to RFC2047 if it contains forbidden characters (ASCII > 127)
+		val := value.String()
+		if rfc2047Needed {
+			val = mime.BEncoding.Encode(utf8, val)
+			// RFC 2047 must be quoted
+			quoteIfUnquoted()
+		}
+
+		// Write the value
+		param.WriteString(val)
+	}
+
+	// Add final quote if required
+	if valueQuoteNeeded {
+		param.WriteByte('"')
+	}
+
+	// Write last parsed char if any
+	if rest != "" {
+		if rest[0] != '"' {
+			// When last char is quote, valueQuotedOriginally is surely true and the quote was already written.
+			// Otherwise output the character (; for example)
+			param.WriteByte(rest[0])
+		}
+
+		// Focus the rest of the string
+		rest = rest[1:]
+	}
+
+	return param.String(), rest
+}
+
+// fixUnquotedSpecials as defined in RFC 2045, section 5.1:
+// https://tools.ietf.org/html/rfc2045#section-5.1
+func fixUnquotedSpecials(s string) string {
+	idx := strings.IndexByte(s, ';')
+	if idx < 0 || idx == len(s) {
+		// No parameters
+		return s
+	}
+
+	clean := strings.Builder{}
+	clean.WriteString(s[:idx+1])
+	s = s[idx+1:]
+
+	for len(s) > 0 {
+		var consumed string
+		consumed, s = consumeParam(s)
+
+		if len(consumed) == 0 {
+			clean.WriteString(s)
+			return clean.String()
+		}
+
+		clean.WriteString(consumed)
+	}
+
+	return clean.String()
+}
+
+// fixUnescapedQuotes inspects for unescaped quotes inside of a quoted string and escapes them
+//
+//  Input:  application/rtf; charset=iso-8859-1; name=""V047411.rtf".rtf"
+//  Output: application/rtf; charset=iso-8859-1; name="\"V047411.rtf\".rtf"
+func fixUnescapedQuotes(hvalue string) string {
+	params := strings.SplitAfter(hvalue, ";")
+	sb := &strings.Builder{}
+
+	for i := 0; i < len(params); i++ {
+		// Inspect for "=" byte.
+		eq := strings.IndexByte(params[i], '=')
+		if eq < 0 {
+			// No "=", must be the content-type or a comment.
+			sb.WriteString(params[i])
+			continue
+		}
+		sb.WriteString(params[i][:eq])
+		param := params[i][eq:]
+		startingQuote := strings.IndexByte(param, '"')
+		closingQuote := strings.LastIndexByte(param, '"')
+
+		// Opportunity to exit early if there are no quotes.
+		if startingQuote < 0 && closingQuote < 0 {
+			// This value is not quoted, write the value and carry on.
+			sb.WriteString(param)
+			continue
+		}
+
+		// Check if only one quote was found in the string.
+		if closingQuote == startingQuote {
+			// Append the next chunk of params here in case of a semicolon mid string.
+			if len(params) > i+1 {
+				param = fmt.Sprintf("%s%s", param, params[i+1])
+			}
+			closingQuote = strings.LastIndexByte(param, '"')
+			i++
+			if closingQuote == startingQuote {
+				sb.WriteString("=\"\"")
+				return sb.String()
+			}
+		}
+
+		// Write the k/v separator back in along with everything up until the first quote.
+		sb.WriteByte('=')
+		sb.WriteByte('"') // Starting quote
+		sb.WriteString(param[1:startingQuote])
+
+		// Get the value, less the outer quotes.
+		rest := param[closingQuote+1:]
+
+		// If there is stuff after the last quote then we should escape the first quote.
+		if len(rest) > 0 && rest != ";" {
+			sb.WriteString("\\\"")
+		}
+
+		param = param[startingQuote+1 : closingQuote]
+		escaped := false
+		for strIdx := range []byte(param) {
+			switch param[strIdx] {
+			case '"':
+				// We are inside of a quoted string, so lets escape this guy if it isn't already escaped.
+				if !escaped {
+					sb.WriteByte('\\')
+					escaped = false
+				}
+				sb.WriteByte(param[strIdx])
+			case '\\':
+				// Something is getting escaped, a quote is the only char that needs
+				// this, so lets assume the following char is a double-quote.
+				escaped = true
+				sb.WriteByte('\\')
+			default:
+				escaped = false
+				sb.WriteByte(param[strIdx])
+			}
+		}
+
+		// If there is stuff after the last quote then we should escape the last quote, apply the
+		// rest and terminate with a quote.
+		switch rest {
+		case ";":
+			sb.WriteByte('"')
+			sb.WriteString(rest)
+		case "":
+			sb.WriteByte('"')
+		default:
+			sb.WriteByte('\\')
+			sb.WriteByte('"')
+			sb.WriteString(rest)
+			sb.WriteByte('"')
+		}
+	}
+
+	return sb.String()
+}
diff --git a/part.go b/part.go
index 4a9db2f..36ead38 100644
--- a/part.go
+++ b/part.go
@@ -14,6 +14,7 @@ import (
 
 	"github.com/gogs/chardet"
 	"github.com/jhillyerd/enmime/internal/coding"
+	"github.com/jhillyerd/enmime/internal/mediatype"
 	"github.com/pkg/errors"
 )
 
@@ -117,7 +118,7 @@ func (p *Part) setupHeaders(r *bufio.Reader, defaultContentType string) error {
 		ctype = defaultContentType
 	}
 	// Parse Content-Type header.
-	mtype, mparams, minvalidParams, err := ParseMediaType(ctype)
+	mtype, mparams, minvalidParams, err := mediatype.Parse(ctype)
 	if err != nil {
 		return err
 	}
@@ -139,7 +140,7 @@ func (p *Part) setupHeaders(r *bufio.Reader, defaultContentType string) error {
 // the disposition, filename, and charset fields.
 func (p *Part) setupContentHeaders(mediaParams map[string]string) {
 	// Determine content disposition, filename, character set.
-	disposition, dparams, _, err := ParseMediaType(p.Header.Get(hnContentDisposition))
+	disposition, dparams, _, err := mediatype.Parse(p.Header.Get(hnContentDisposition))
 	if err == nil {
 		// Disposition is optional
 		p.Disposition = disposition


Test patch:
diff --git a/header_test.go b/header_test.go
index 4c4cb57..01055be 100644
--- a/header_test.go
+++ b/header_test.go
@@ -32,351 +32,6 @@ func TestDecodeToUTF8Base64Header(t *testing.T) {
 	}
 }
 
-func TestFixMangledMediaType(t *testing.T) {
-	testCases := []struct {
-		input string
-		sep   rune
-		want  string
-	}{
-		{
-			input: "",
-			sep:   ';',
-			want:  "",
-		},
-		{
-			input: `text/HTML; charset=UTF-8; format=flowed; content-transfer-encoding: 7bit=`,
-			sep:   ';',
-			want:  "text/HTML; charset=UTF-8; format=flowed",
-		},
-		{
-			input: "text/html;charset=",
-			sep:   ';',
-			want:  "text/html;charset=",
-		},
-		{
-			input: "text/;charset=",
-			sep:   ';',
-			want:  "text/plain;charset=",
-		},
-		{
-			input: "multipart/;charset=",
-			sep:   ';',
-			want:  "multipart/mixed;charset=",
-		},
-		{
-			input: "text/plain;",
-			sep:   ';',
-			want:  "text/plain",
-		},
-		{
-			// Removes empty parameters.
-			input: `image/png; name="abc.png"; =""`,
-			sep:   ';',
-			want:  `image/png; name="abc.png"`,
-		},
-		{
-			input: "application/octet-stream;=?UTF-8?B?bmFtZT0iw7DCn8KUwoo=?=You've got a new voice miss call.msg",
-			sep:   ';',
-			want:  "application/octet-stream;name=\"ð\u009f\u0094\u008aYou've got a new voice miss call.msg\"",
-		},
-		{
-			input: `application/; name="Voice message from =?UTF-8?B?4piOICsxIDI1MS0yNDUtODA0NC5tc2c=?=";`,
-			sep:   ';',
-			want:  `application/octet-stream; name="Voice message from ☎ +1 251-245-8044.msg"`,
-		},
-		{
-			input: `application/pdf name="file.pdf"`,
-			sep:   ' ',
-			want:  `application/pdf;name="file.pdf"`,
-		},
-		{
-			// Removes duplicate parameters.
-			input: `one/two; name="file.two"; name="file.two"`,
-			sep:   ';',
-			want:  `one/two; name="file.two"`,
-		},
-		{
-			// Removes duplicate parameters.
-			input: `one/nosp;name="file.two"; name="file.two"`,
-			sep:   ';',
-			want:  `one/nosp;name="file.two"`,
-		},
-		{
-			// Removes duplicate parameters.
-			input: `one/; name="file.two"; name="file.two"`,
-			sep:   ';',
-			want:  `application/octet-stream; name="file.two"`,
-		},
-		{
-			input: `application/octet-stream; =?UTF-8?B?bmFtZT3DsMKfwpTCii5tc2c=?=`,
-			sep:   ' ',
-			want:  "application/octet-stream;name=\"ð\u009f\u0094\u008a.msg\"",
-		},
-		{
-			// Removes duplicate parameters.
-			input: `one/two name="file.two" name="file.two"`,
-			sep:   ' ',
-			want:  `one/two;name="file.two"`,
-		},
-		{
-			input: `; name="file.two"`,
-			sep:   ';',
-			want:  ctPlaceholder + `; name="file.two"`,
-		},
-		{
-			input: `charset=binary; name="logoleft.jpg"`,
-			sep:   ';',
-			want:  `application/octet-stream; charset=binary; name="logoleft.jpg"`,
-		},
-		{
-			input: `one/two;iso-8859-1`,
-			sep:   ';',
-			want:  `one/two;iso-8859-1=` + pvPlaceholder,
-		},
-		{
-			input: `one/two; name="file.two"; iso-8859-1`,
-			sep:   ';',
-			want:  `one/two; name="file.two"; iso-8859-1=` + pvPlaceholder,
-		},
-		{
-			input: `one/two; ; name="file.two"`,
-			sep:   ';',
-			want:  `one/two; name="file.two"`,
-		},
-	}
-	for _, tc := range testCases {
-		t.Run(tc.input, func(t *testing.T) {
-			got := fixMangledMediaType(tc.input, tc.sep)
-			if got != tc.want {
-				t.Errorf("got %q, want %q", got, tc.want)
-			}
-		})
-	}
-}
-
-func TestFixUnquotedSpecials(t *testing.T) {
-	testCases := []struct {
-		input, want string
-	}{
-		{
-			input: "",
-			want:  "",
-		},
-		{
-			input: "application/octet-stream",
-			want:  "application/octet-stream",
-		},
-		{
-			input: "application/octet-stream;",
-			want:  "application/octet-stream;",
-		},
-		{
-			input: `application/octet-stream; param1="value1"`,
-			want:  `application/octet-stream; param1="value1"`,
-		},
-		{
-			input: `application/octet-stream; param1="value1"\`,
-			want:  `application/octet-stream; param1="value1"\`,
-		},
-		{
-			input: "application/octet-stream; param1=value1",
-			want:  "application/octet-stream; param1=value1",
-		},
-		{
-			input: `application/octet-stream; param1=value1\`,
-			want:  "application/octet-stream; param1=value1",
-		},
-		{
-			input: `application/octet-stream; param1=value1\"`,
-			want:  `application/octet-stream; param1="value1\""`,
-		},
-		{
-			input: `application/octet-stream; param1=value"1"`,
-			want:  `application/octet-stream; param1="value\"1\""`,
-		},
-		{
-			input: `application/octet-stream; param1="value\"1\""`,
-			want:  `application/octet-stream; param1="value\"1\""`,
-		},
-		{
-			// Do not preserve unqoted whitespace.
-			input: "application/octet-stream; param1= value1",
-			want:  "application/octet-stream; param1=value1",
-		},
-		{
-			// Do not preserve unqoted whitespace.
-			input: "application/octet-stream; param1=\tvalue1",
-			want:  "application/octet-stream; param1=value1",
-		},
-		{
-			input: `application/octet-stream; param1="value1;"`,
-			want:  `application/octet-stream; param1="value1;"`,
-		},
-		{
-			input: `application/octet-stream; param1="value1;2.txt"`,
-			want:  `application/octet-stream; param1="value1;2.txt"`,
-		},
-		{
-			input: `application/octet-stream; param1="value 1"`,
-			want:  `application/octet-stream; param1="value 1"`,
-		},
-		{
-			// Preserve quoted whitespace.
-			input: `application/octet-stream; param1=" value 1"`,
-			want:  `application/octet-stream; param1=" value 1"`,
-		},
-		{
-			// Preserve quoted whitespace.
-			input: "application/octet-stream; param1=\"\tvalue 1\"",
-			want:  "application/octet-stream; param1=\"\tvalue 1\"",
-		},
-		{
-			// Preserve quoted whitespace.
-			input: "application/octet-stream; param1=\"value\t1\"",
-			want:  "application/octet-stream; param1=\"value\t1\"",
-		},
-		{
-			input: `application/octet-stream; param1="value(1).pdf"`,
-			want:  `application/octet-stream; param1="value(1).pdf"`,
-		},
-		{
-			input: `application/octet-stream; param1=value(1).pdf`,
-			want:  `application/octet-stream; param1="value(1).pdf"`,
-		},
-		{
-			input: `application/octet-stream; param1=value(1).pdf; param2=value(2).pdf`,
-			want:  `application/octet-stream; param1="value(1).pdf"; param2="value(2).pdf"`,
-		},
-		{
-			input: "application/octet-stream; param1=value(1).pdf;\tparam2=value2.pdf;",
-			want:  "application/octet-stream; param1=\"value(1).pdf\";\tparam2=value2.pdf;",
-		},
-		{
-			input: `application/octet-stream; param1=value(1).pdf;param2=value2.pdf;`,
-			want:  `application/octet-stream; param1="value(1).pdf";param2=value2.pdf;`,
-		},
-		{
-			input: `application/octet-stream; param1=value/1`,
-			want:  `application/octet-stream; param1="value/1"`,
-		},
-		{
-			input: `multipart/alternative; boundary=?UOAwFjScLp1is-162467503201177404728935166502-`,
-			want:  `multipart/alternative; boundary="?UOAwFjScLp1is-162467503201177404728935166502-"`,
-		},
-		{
-			input: `text/HTML; charset="UTF-8Return-Path: bounce-810_HTML-1070564-43@example.com`,
-			want:  `text/HTML; charset="UTF-8Return-Path: bounce-810_HTML-1070564-43@example.com"`,
-		},
-		{
-			input: `text/html;charset=`,
-			want:  `text/html;charset=""`,
-		},
-		{
-			input: `text/html; charset=; format=flowed`,
-			want:  `text/html; charset=""; format=flowed`,
-		},
-		{
-			input: `text/html;charset="`,
-			want:  `text/html;charset=""`,
-		},
-		{
-			// Check unquoted 8bit is encoded
-			input: `application/msword;name=管理.doc`,
-			want:  `application/msword;name="=?utf-8?b?566h55CGLmRvYw==?="`,
-		},
-		{
-			// Check mix of ascii and unquoted 8bit is encoded
-			input: `application/msword;name=15管理.doc`,
-			want:  `application/msword;name="=?utf-8?b?MTXnrqHnkIYuZG9j?="`,
-		},
-		{
-			// Check quoted 8bit is encoded
-			input: `application/msword;name="15管理.doc"`,
-			want:  `application/msword;name="=?utf-8?b?MTXnrqHnkIYuZG9j?="`,
-		},
-		{
-			// Check quoted 8bit with missing closing quote is encoded
-			input: `application/msword;name="15管理.doc`,
-			want:  `application/msword;name="=?utf-8?b?MTXnrqHnkIYuZG9j?="`,
-		},
-		{
-			// Trailing quote without starting quote is considered as part of param text for simplicity
-			input: `application/msword;name=15管理.doc"`,
-			want:  `application/msword;name="=?utf-8?b?MTXnrqHnkIYuZG9jXCI=?="`,
-		},
-		{
-			// Invalid UTF-8 sequence does not cause any fatal error
-			input: "application/msword;name=\xe2\x28\xa1.doc",
-			want:  `application/msword;name="=?utf-8?b?77+9KO+/vS5kb2M=?="`,
-		},
-		{
-			// Value with spaces is surrounded with quotes.
-			input: `text/plain; name=Untitled document.txt`,
-			want:  `text/plain; name="Untitled document.txt"`,
-		},
-		{
-			// Value with spaces is surrounded with quotes.
-			input: `text/plain; name=Untitled document.txt; disposition=inline`,
-			want:  `text/plain; name="Untitled document.txt"; disposition=inline`,
-		},
-	}
-	for _, tc := range testCases {
-		t.Run(tc.input, func(t *testing.T) {
-			got := fixUnquotedSpecials(tc.input)
-			if got != tc.want {
-				t.Errorf("\ngot  : %s\nwant : %s\ninput: %s", got, tc.want, tc.input)
-			}
-		})
-	}
-}
-
-func TestFixUnEscapedQuotes(t *testing.T) {
-	testCases := []struct {
-		input, want string
-	}{
-		{
-			input: `application/rtf; charset=iso-8859-1; name=""V047411.rtf".rtf"`,
-			want:  `application/rtf; charset=iso-8859-1; name="\"V047411.rtf\".rtf"`,
-		},
-		{
-			input: `application/octet-stream; param1="`,
-			want:  `application/octet-stream; param1=""`,
-		},
-		{
-			input: `application/octet-stream; param1="\""`,
-			want:  `application/octet-stream; param1="\""`,
-		},
-		{
-			input: `application/rtf; charset=iso-8859-1; name=b"V047411.rtf".rtf`,
-			want:  `application/rtf; charset=iso-8859-1; name="b\"V047411.rtf\".rtf"`,
-		},
-		{
-			input: `application/rtf; charset=iso-8859-1; name="V047411.rtf".rtf`,
-			want:  `application/rtf; charset=iso-8859-1; name="\"V047411.rtf\".rtf"`,
-		},
-		{
-			input: `application/rtf; charset=iso-8859-1; name="V047411.rtf;".rtf`,
-			want:  `application/rtf; charset=iso-8859-1; name="\"V047411.rtf;\".rtf"`,
-		},
-		{
-			input: `application/rtf; charset=utf-8; name="žába.jpg"`,
-			want:  `application/rtf; charset=utf-8; name="žába.jpg"`,
-		},
-		{
-			input: `application/rtf; charset=utf-8; name=""žába".jpg"`,
-			want:  `application/rtf; charset=utf-8; name="\"žába\".jpg"`,
-		},
-	}
-	for _, tc := range testCases {
-		t.Run(tc.input, func(t *testing.T) {
-			got := fixUnescapedQuotes(tc.input)
-			if got != tc.want {
-				t.Errorf("\ngot:  %s\nwant: %s", got, tc.want)
-			}
-		})
-	}
-}
-
 func TestReadHeader(t *testing.T) {
 	// These values will surround the test table input string.
 	prefix := "From: hooman\n \n being\n"
@@ -678,140 +333,3 @@ func TestCommaDelimitedAddressLists(t *testing.T) {
 		}
 	}
 }
-
-func TestParseMediaType(t *testing.T) {
-	testCases := []struct {
-		label  string            // Test case label.
-		input  string            // Content type to parse.
-		mtype  string            // Expected media type returned.
-		params map[string]string // Expected params returned.
-	}{
-		{
-			label:  "basic filename",
-			input:  "text/html; name=index.html",
-			mtype:  "text/html",
-			params: map[string]string{"name": "index.html"},
-		},
-		{
-			label:  "quoted filename",
-			input:  `text/html; name="index.html"`,
-			mtype:  "text/html",
-			params: map[string]string{"name": "index.html"},
-		},
-		{
-			label:  "basic filename trailing separator",
-			input:  "text/html; name=index.html;",
-			mtype:  "text/html",
-			params: map[string]string{"name": "index.html"},
-		},
-		{
-			label:  "quoted filename trailing separator",
-			input:  `text/html; name="index.html";`,
-			mtype:  "text/html",
-			params: map[string]string{"name": "index.html"},
-		},
-		{
-			label:  "unclosed quoted filename",
-			input:  `text/html; name="index.html`,
-			mtype:  "text/html",
-			params: map[string]string{"name": "index.html"},
-		},
-		{
-			label:  "quoted filename with separator",
-			input:  `text/html; name="index;a.html"`,
-			mtype:  "text/html",
-			params: map[string]string{"name": "index;a.html"},
-		},
-		{
-			label:  "quoted separator mid-string",
-			input:  `text/html; name="index;a.html"; hash=8675309`,
-			mtype:  "text/html",
-			params: map[string]string{"name": "index;a.html", "hash": "8675309"},
-		},
-		{
-			label:  "with prefix whitespace",
-			input:  `text/plain; charset= "UTF-8"; format=flowed`,
-			mtype:  "text/plain",
-			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
-		},
-		{
-			label:  "with double prefix whitespace",
-			input:  `text/plain; charset = "UTF-8"; format=flowed`,
-			mtype:  "text/plain",
-			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
-		},
-		{
-			label:  "with postfix whitespace",
-			input:  `text/plain; charset="UTF-8" ; format=flowed`,
-			mtype:  "text/plain",
-			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
-		},
-		{
-			label:  "with whitespace tab",
-			input:  "text/plain; charset=\"UTF-8\"\t; format=flowed",
-			mtype:  "text/plain",
-			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
-		},
-		{
-			label:  "with newline and tab",
-			input:  "text/plain; charset=\"UTF-8\"\n\t; format=flowed",
-			mtype:  "text/plain",
-			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
-		},
-		{
-			label:  "with newline and space",
-			input:  "application/pdf; name=foo\n ; format=flowed",
-			mtype:  "application/pdf",
-			params: map[string]string{"name": "foo", "format": "flowed"},
-		},
-		{
-			label:  "with more spaces",
-			input:  "application/pdf; name=foo      ; format=flowed",
-			mtype:  "application/pdf",
-			params: map[string]string{"name": "foo", "format": "flowed"},
-		},
-		{
-			label:  "with more tabs",
-			input:  "application/pdf; name=foo \t\t; format=flowed",
-			mtype:  "application/pdf",
-			params: map[string]string{"name": "foo", "format": "flowed"},
-		},
-		{
-			label:  "with more newlines",
-			input:  "application/pdf; name=foo \n\n; format=flowed",
-			mtype:  "application/pdf",
-			params: map[string]string{"name": "foo", "format": "flowed"},
-		},
-		{
-			label:  "unquoted with spaces",
-			input:  "application/pdf; x-unix-mode=0644; name=File name with spaces.pdf",
-			mtype:  "application/pdf",
-			params: map[string]string{"x-unix-mode": "0644", "name": "File name with spaces.pdf"},
-		},
-	}
-	for _, tc := range testCases {
-		t.Run(tc.label, func(t *testing.T) {
-			mtype, params, _, err := ParseMediaType(tc.input)
-
-			if err != nil {
-				t.Errorf("got err %v, want nil", err)
-				return
-			}
-
-			if mtype != tc.mtype {
-				t.Errorf("mtype got %q, want %q", mtype, tc.mtype)
-			}
-
-			for k, v := range tc.params {
-				if params[k] != v {
-					t.Errorf("params[%q] got %q, want %q", k, params[k], v)
-				}
-				// Delete param to allow check for unexpected below.
-				delete(params, k)
-			}
-			for pname := range params {
-				t.Errorf("Found unexpected param: %q=%q", pname, params[pname])
-			}
-		})
-	}
-}
diff --git a/internal/mediatype/mediatype_test.go b/internal/mediatype/mediatype_test.go
new file mode 100644
index 0000000..2df7914
--- /dev/null
+++ b/internal/mediatype/mediatype_test.go
@@ -0,0 +1,487 @@
+package mediatype
+
+import (
+	"testing"
+)
+
+func TestFixMangledMediaType(t *testing.T) {
+	testCases := []struct {
+		input string
+		sep   rune
+		want  string
+	}{
+		{
+			input: "",
+			sep:   ';',
+			want:  "",
+		},
+		{
+			input: `text/HTML; charset=UTF-8; format=flowed; content-transfer-encoding: 7bit=`,
+			sep:   ';',
+			want:  "text/HTML; charset=UTF-8; format=flowed",
+		},
+		{
+			input: "text/html;charset=",
+			sep:   ';',
+			want:  "text/html;charset=",
+		},
+		{
+			input: "text/;charset=",
+			sep:   ';',
+			want:  "text/plain;charset=",
+		},
+		{
+			input: "multipart/;charset=",
+			sep:   ';',
+			want:  "multipart/mixed;charset=",
+		},
+		{
+			input: "text/plain;",
+			sep:   ';',
+			want:  "text/plain",
+		},
+		{
+			// Removes empty parameters.
+			input: `image/png; name="abc.png"; =""`,
+			sep:   ';',
+			want:  `image/png; name="abc.png"`,
+		},
+		{
+			input: "application/octet-stream;=?UTF-8?B?bmFtZT0iw7DCn8KUwoo=?=You've got a new voice miss call.msg",
+			sep:   ';',
+			want:  "application/octet-stream;name=\"ð\u009f\u0094\u008aYou've got a new voice miss call.msg\"",
+		},
+		{
+			input: `application/; name="Voice message from =?UTF-8?B?4piOICsxIDI1MS0yNDUtODA0NC5tc2c=?=";`,
+			sep:   ';',
+			want:  `application/octet-stream; name="Voice message from ☎ +1 251-245-8044.msg"`,
+		},
+		{
+			input: `application/pdf name="file.pdf"`,
+			sep:   ' ',
+			want:  `application/pdf;name="file.pdf"`,
+		},
+		{
+			// Removes duplicate parameters.
+			input: `one/two; name="file.two"; name="file.two"`,
+			sep:   ';',
+			want:  `one/two; name="file.two"`,
+		},
+		{
+			// Removes duplicate parameters.
+			input: `one/nosp;name="file.two"; name="file.two"`,
+			sep:   ';',
+			want:  `one/nosp;name="file.two"`,
+		},
+		{
+			// Removes duplicate parameters.
+			input: `one/; name="file.two"; name="file.two"`,
+			sep:   ';',
+			want:  `application/octet-stream; name="file.two"`,
+		},
+		{
+			input: `application/octet-stream; =?UTF-8?B?bmFtZT3DsMKfwpTCii5tc2c=?=`,
+			sep:   ' ',
+			want:  "application/octet-stream;name=\"ð\u009f\u0094\u008a.msg\"",
+		},
+		{
+			// Removes duplicate parameters.
+			input: `one/two name="file.two" name="file.two"`,
+			sep:   ' ',
+			want:  `one/two;name="file.two"`,
+		},
+		{
+			input: `; name="file.two"`,
+			sep:   ';',
+			want:  ctPlaceholder + `; name="file.two"`,
+		},
+		{
+			input: `charset=binary; name="logoleft.jpg"`,
+			sep:   ';',
+			want:  `application/octet-stream; charset=binary; name="logoleft.jpg"`,
+		},
+		{
+			input: `one/two;iso-8859-1`,
+			sep:   ';',
+			want:  `one/two;iso-8859-1=` + pvPlaceholder,
+		},
+		{
+			input: `one/two; name="file.two"; iso-8859-1`,
+			sep:   ';',
+			want:  `one/two; name="file.two"; iso-8859-1=` + pvPlaceholder,
+		},
+		{
+			input: `one/two; ; name="file.two"`,
+			sep:   ';',
+			want:  `one/two; name="file.two"`,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.input, func(t *testing.T) {
+			got := fixMangledMediaType(tc.input, tc.sep)
+			if got != tc.want {
+				t.Errorf("got %q, want %q", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestFixUnquotedSpecials(t *testing.T) {
+	testCases := []struct {
+		input, want string
+	}{
+		{
+			input: "",
+			want:  "",
+		},
+		{
+			input: "application/octet-stream",
+			want:  "application/octet-stream",
+		},
+		{
+			input: "application/octet-stream;",
+			want:  "application/octet-stream;",
+		},
+		{
+			input: `application/octet-stream; param1="value1"`,
+			want:  `application/octet-stream; param1="value1"`,
+		},
+		{
+			input: `application/octet-stream; param1="value1"\`,
+			want:  `application/octet-stream; param1="value1"\`,
+		},
+		{
+			input: "application/octet-stream; param1=value1",
+			want:  "application/octet-stream; param1=value1",
+		},
+		{
+			input: `application/octet-stream; param1=value1\`,
+			want:  "application/octet-stream; param1=value1",
+		},
+		{
+			input: `application/octet-stream; param1=value1\"`,
+			want:  `application/octet-stream; param1="value1\""`,
+		},
+		{
+			input: `application/octet-stream; param1=value"1"`,
+			want:  `application/octet-stream; param1="value\"1\""`,
+		},
+		{
+			input: `application/octet-stream; param1="value\"1\""`,
+			want:  `application/octet-stream; param1="value\"1\""`,
+		},
+		{
+			// Do not preserve unqoted whitespace.
+			input: "application/octet-stream; param1= value1",
+			want:  "application/octet-stream; param1=value1",
+		},
+		{
+			// Do not preserve unqoted whitespace.
+			input: "application/octet-stream; param1=\tvalue1",
+			want:  "application/octet-stream; param1=value1",
+		},
+		{
+			input: `application/octet-stream; param1="value1;"`,
+			want:  `application/octet-stream; param1="value1;"`,
+		},
+		{
+			input: `application/octet-stream; param1="value1;2.txt"`,
+			want:  `application/octet-stream; param1="value1;2.txt"`,
+		},
+		{
+			input: `application/octet-stream; param1="value 1"`,
+			want:  `application/octet-stream; param1="value 1"`,
+		},
+		{
+			// Preserve quoted whitespace.
+			input: `application/octet-stream; param1=" value 1"`,
+			want:  `application/octet-stream; param1=" value 1"`,
+		},
+		{
+			// Preserve quoted whitespace.
+			input: "application/octet-stream; param1=\"\tvalue 1\"",
+			want:  "application/octet-stream; param1=\"\tvalue 1\"",
+		},
+		{
+			// Preserve quoted whitespace.
+			input: "application/octet-stream; param1=\"value\t1\"",
+			want:  "application/octet-stream; param1=\"value\t1\"",
+		},
+		{
+			input: `application/octet-stream; param1="value(1).pdf"`,
+			want:  `application/octet-stream; param1="value(1).pdf"`,
+		},
+		{
+			input: `application/octet-stream; param1=value(1).pdf`,
+			want:  `application/octet-stream; param1="value(1).pdf"`,
+		},
+		{
+			input: `application/octet-stream; param1=value(1).pdf; param2=value(2).pdf`,
+			want:  `application/octet-stream; param1="value(1).pdf"; param2="value(2).pdf"`,
+		},
+		{
+			input: "application/octet-stream; param1=value(1).pdf;\tparam2=value2.pdf;",
+			want:  "application/octet-stream; param1=\"value(1).pdf\";\tparam2=value2.pdf;",
+		},
+		{
+			input: `application/octet-stream; param1=value(1).pdf;param2=value2.pdf;`,
+			want:  `application/octet-stream; param1="value(1).pdf";param2=value2.pdf;`,
+		},
+		{
+			input: `application/octet-stream; param1=value/1`,
+			want:  `application/octet-stream; param1="value/1"`,
+		},
+		{
+			input: `multipart/alternative; boundary=?UOAwFjScLp1is-162467503201177404728935166502-`,
+			want:  `multipart/alternative; boundary="?UOAwFjScLp1is-162467503201177404728935166502-"`,
+		},
+		{
+			input: `text/HTML; charset="UTF-8Return-Path: bounce-810_HTML-1070564-43@example.com`,
+			want:  `text/HTML; charset="UTF-8Return-Path: bounce-810_HTML-1070564-43@example.com"`,
+		},
+		{
+			input: `text/html;charset=`,
+			want:  `text/html;charset=""`,
+		},
+		{
+			input: `text/html; charset=; format=flowed`,
+			want:  `text/html; charset=""; format=flowed`,
+		},
+		{
+			input: `text/html;charset="`,
+			want:  `text/html;charset=""`,
+		},
+		{
+			// Check unquoted 8bit is encoded
+			input: `application/msword;name=管理.doc`,
+			want:  `application/msword;name="=?utf-8?b?566h55CGLmRvYw==?="`,
+		},
+		{
+			// Check mix of ascii and unquoted 8bit is encoded
+			input: `application/msword;name=15管理.doc`,
+			want:  `application/msword;name="=?utf-8?b?MTXnrqHnkIYuZG9j?="`,
+		},
+		{
+			// Check quoted 8bit is encoded
+			input: `application/msword;name="15管理.doc"`,
+			want:  `application/msword;name="=?utf-8?b?MTXnrqHnkIYuZG9j?="`,
+		},
+		{
+			// Check quoted 8bit with missing closing quote is encoded
+			input: `application/msword;name="15管理.doc`,
+			want:  `application/msword;name="=?utf-8?b?MTXnrqHnkIYuZG9j?="`,
+		},
+		{
+			// Trailing quote without starting quote is considered as part of param text for simplicity
+			input: `application/msword;name=15管理.doc"`,
+			want:  `application/msword;name="=?utf-8?b?MTXnrqHnkIYuZG9jXCI=?="`,
+		},
+		{
+			// Invalid UTF-8 sequence does not cause any fatal error
+			input: "application/msword;name=\xe2\x28\xa1.doc",
+			want:  `application/msword;name="=?utf-8?b?77+9KO+/vS5kb2M=?="`,
+		},
+		{
+			// Value with spaces is surrounded with quotes.
+			input: `text/plain; name=Untitled document.txt`,
+			want:  `text/plain; name="Untitled document.txt"`,
+		},
+		{
+			// Value with spaces is surrounded with quotes.
+			input: `text/plain; name=Untitled document.txt; disposition=inline`,
+			want:  `text/plain; name="Untitled document.txt"; disposition=inline`,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.input, func(t *testing.T) {
+			got := fixUnquotedSpecials(tc.input)
+			if got != tc.want {
+				t.Errorf("\ngot  : %s\nwant : %s\ninput: %s", got, tc.want, tc.input)
+			}
+		})
+	}
+}
+
+func TestFixUnEscapedQuotes(t *testing.T) {
+	testCases := []struct {
+		input, want string
+	}{
+		{
+			input: `application/rtf; charset=iso-8859-1; name=""V047411.rtf".rtf"`,
+			want:  `application/rtf; charset=iso-8859-1; name="\"V047411.rtf\".rtf"`,
+		},
+		{
+			input: `application/octet-stream; param1="`,
+			want:  `application/octet-stream; param1=""`,
+		},
+		{
+			input: `application/octet-stream; param1="\""`,
+			want:  `application/octet-stream; param1="\""`,
+		},
+		{
+			input: `application/rtf; charset=iso-8859-1; name=b"V047411.rtf".rtf`,
+			want:  `application/rtf; charset=iso-8859-1; name="b\"V047411.rtf\".rtf"`,
+		},
+		{
+			input: `application/rtf; charset=iso-8859-1; name="V047411.rtf".rtf`,
+			want:  `application/rtf; charset=iso-8859-1; name="\"V047411.rtf\".rtf"`,
+		},
+		{
+			input: `application/rtf; charset=iso-8859-1; name="V047411.rtf;".rtf`,
+			want:  `application/rtf; charset=iso-8859-1; name="\"V047411.rtf;\".rtf"`,
+		},
+		{
+			input: `application/rtf; charset=utf-8; name="žába.jpg"`,
+			want:  `application/rtf; charset=utf-8; name="žába.jpg"`,
+		},
+		{
+			input: `application/rtf; charset=utf-8; name=""žába".jpg"`,
+			want:  `application/rtf; charset=utf-8; name="\"žába\".jpg"`,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.input, func(t *testing.T) {
+			got := fixUnescapedQuotes(tc.input)
+			if got != tc.want {
+				t.Errorf("\ngot:  %s\nwant: %s", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestParseMediaType(t *testing.T) {
+	testCases := []struct {
+		label  string            // Test case label.
+		input  string            // Content type to parse.
+		mtype  string            // Expected media type returned.
+		params map[string]string // Expected params returned.
+	}{
+		{
+			label:  "basic filename",
+			input:  "text/html; name=index.html",
+			mtype:  "text/html",
+			params: map[string]string{"name": "index.html"},
+		},
+		{
+			label:  "quoted filename",
+			input:  `text/html; name="index.html"`,
+			mtype:  "text/html",
+			params: map[string]string{"name": "index.html"},
+		},
+		{
+			label:  "basic filename trailing separator",
+			input:  "text/html; name=index.html;",
+			mtype:  "text/html",
+			params: map[string]string{"name": "index.html"},
+		},
+		{
+			label:  "quoted filename trailing separator",
+			input:  `text/html; name="index.html";`,
+			mtype:  "text/html",
+			params: map[string]string{"name": "index.html"},
+		},
+		{
+			label:  "unclosed quoted filename",
+			input:  `text/html; name="index.html`,
+			mtype:  "text/html",
+			params: map[string]string{"name": "index.html"},
+		},
+		{
+			label:  "quoted filename with separator",
+			input:  `text/html; name="index;a.html"`,
+			mtype:  "text/html",
+			params: map[string]string{"name": "index;a.html"},
+		},
+		{
+			label:  "quoted separator mid-string",
+			input:  `text/html; name="index;a.html"; hash=8675309`,
+			mtype:  "text/html",
+			params: map[string]string{"name": "index;a.html", "hash": "8675309"},
+		},
+		{
+			label:  "with prefix whitespace",
+			input:  `text/plain; charset= "UTF-8"; format=flowed`,
+			mtype:  "text/plain",
+			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
+		},
+		{
+			label:  "with double prefix whitespace",
+			input:  `text/plain; charset = "UTF-8"; format=flowed`,
+			mtype:  "text/plain",
+			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
+		},
+		{
+			label:  "with postfix whitespace",
+			input:  `text/plain; charset="UTF-8" ; format=flowed`,
+			mtype:  "text/plain",
+			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
+		},
+		{
+			label:  "with whitespace tab",
+			input:  "text/plain; charset=\"UTF-8\"\t; format=flowed",
+			mtype:  "text/plain",
+			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
+		},
+		{
+			label:  "with newline and tab",
+			input:  "text/plain; charset=\"UTF-8\"\n\t; format=flowed",
+			mtype:  "text/plain",
+			params: map[string]string{"charset": "UTF-8", "format": "flowed"},
+		},
+		{
+			label:  "with newline and space",
+			input:  "application/pdf; name=foo\n ; format=flowed",
+			mtype:  "application/pdf",
+			params: map[string]string{"name": "foo", "format": "flowed"},
+		},
+		{
+			label:  "with more spaces",
+			input:  "application/pdf; name=foo      ; format=flowed",
+			mtype:  "application/pdf",
+			params: map[string]string{"name": "foo", "format": "flowed"},
+		},
+		{
+			label:  "with more tabs",
+			input:  "application/pdf; name=foo \t\t; format=flowed",
+			mtype:  "application/pdf",
+			params: map[string]string{"name": "foo", "format": "flowed"},
+		},
+		{
+			label:  "with more newlines",
+			input:  "application/pdf; name=foo \n\n; format=flowed",
+			mtype:  "application/pdf",
+			params: map[string]string{"name": "foo", "format": "flowed"},
+		},
+		{
+			label:  "unquoted with spaces",
+			input:  "application/pdf; x-unix-mode=0644; name=File name with spaces.pdf",
+			mtype:  "application/pdf",
+			params: map[string]string{"x-unix-mode": "0644", "name": "File name with spaces.pdf"},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.label, func(t *testing.T) {
+			mtype, params, _, err := Parse(tc.input)
+
+			if err != nil {
+				t.Errorf("got err %v, want nil", err)
+				return
+			}
+
+			if mtype != tc.mtype {
+				t.Errorf("mtype got %q, want %q", mtype, tc.mtype)
+			}
+
+			for k, v := range tc.params {
+				if params[k] != v {
+					t.Errorf("params[%q] got %q, want %q", k, params[k], v)
+				}
+				// Delete param to allow check for unexpected below.
+				delete(params, k)
+			}
+			for pname := range params {
+				t.Errorf("Found unexpected param: %q=%q", pname, params[pname])
+			}
+		})
+	}
+}