mime 0.3.4 → 0.4.0
raw patch · 4 files changed
+205/−196 lines, 4 filesdep +textdep ~base
Dependencies added: text
Dependency ranges changed: base
Files
- Codec/MIME/Parse.hs +151/−144
- Codec/MIME/Type.hs +49/−49
- Codec/MIME/Utils.hs +2/−1
- mime.cabal +3/−2
Codec/MIME/Parse.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE OverloadedStrings #-} -------------------------------------------------------------------- -- | -- Module : Codec.MIME.Pare@@ -12,14 +13,15 @@ -- -------------------------------------------------------------------- module Codec.MIME.Parse- ( parseMIMEBody -- :: [(String,String)] -> String -> MIMEValue- , parseMIMEType -- :: String -> Maybe Type- , parseMIMEMessage -- :: String -> MIMEValue+ ( parseMIMEBody -- :: [(T.Text,T.Text)] -> T.Text -> MIMEValue+ , parseMIMEType -- :: T.Text -> Maybe Type+ , parseMIMEMessage -- :: T.Text -> MIMEValue - , parseHeaders -- :: String -> ([(String,String)], String)- , parseMultipart -- :: Type -> String -> (MIMEValue, String)- , parseContentType -- :: String -> Maybe Type- , splitMulti -- :: String -> String -> ([MIMEValue], String)+ , parseHeaders -- :: T.Text -> ([(T.Text,T.Text)], T.Text)+ , parseMultipart -- :: Type -> T.Text -> (MIMEValue, T.Text)+ , parseContentType -- :: T.Text -> Maybe Type+ , splitMulti -- :: T.Text -> T.Text -> ([MIMEValue], T.Text)+ , normalizeCRLF ) where import Codec.MIME.Type@@ -27,8 +29,10 @@ import Data.Char import Data.Maybe-import Data.List+import qualified Data.List as L import Debug.Trace ( trace )+import qualified Data.Text as T+import Data.Monoid(Monoid(..), (<>)) enableTrace :: Bool enableTrace = False@@ -38,7 +42,7 @@ | otherwise = \_ x -> x -parseMIMEBody :: [(String,String)] -> String -> MIMEValue+parseMIMEBody :: [MIMEParam] -> T.Text -> MIMEValue parseMIMEBody headers_in body = result { mime_val_headers = headers } where result = case mimeType mty of@@ -48,84 +52,89 @@ , mime_val_disp = parseContentDisp headers , mime_val_content = Single (processBody headers body) }- headers = [ (map toLower k,v) | (k,v) <- headers_in ]+ headers = [ MIMEParam (T.toLower k) v | (MIMEParam k v) <- headers_in ] mty = fromMaybe defaultType- (parseContentType =<< lookupField "content-type" headers)+ (parseContentType =<< lookupField "content-type" (paramPairs headers)) defaultType :: Type defaultType = Type { mimeType = Text "plain"- , mimeParams = [("charset", "us-ascii")]+ , mimeParams = [MIMEParam "charset" "us-ascii"] } -parseContentDisp :: [(String,String)] -> Maybe Disposition+parseContentDisp :: [MIMEParam] -> Maybe Disposition parseContentDisp headers =- (processDisp . dropFoldingWSP) =<< lookupField "content-disposition" headers+ (processDisp . dropFoldingWSP) =<< lookupField "content-disposition" (paramPairs headers) where- processDisp "" = Nothing- processDisp xs = Just $- case break (\ch -> isSpace ch || ch == ';') xs of- (as,"") -> Disposition { dispType = toDispType (map toLower as)- , dispParams = []- }- (as,bs) -> Disposition { dispType = toDispType (map toLower as)- , dispParams = processParams (parseParams bs)- }+ processDisp t | T.null t = Nothing+ | T.null bs = Just $ Disposition { dispType = toDispType (T.toLower as)+ , dispParams = []+ }+ | otherwise = Just $ Disposition { dispType = toDispType (T.toLower as)+ , dispParams = processParams (parseParams bs)+ } + where (as,bs) = T.break (\ch -> isSpace ch || ch == ';') t - processParams = map procP- where- procP (as,val)- | "name" == asl = Name val- | "filename" == asl = Filename val- | "creation-date" == asl = CreationDate val- | "modification-date" == asl = ModDate val- | "read-date" == asl = ReadDate val- | "size" == asl = Size val- | otherwise = OtherParam (map toLower as) val- where asl = map toLower as+ processParams = map procP+ where+ procP (MIMEParam as val)+ | "name" == asl = Name val+ | "filename" == asl = Filename val+ | "creation-date" == asl = CreationDate val+ | "modification-date" == asl = ModDate val+ | "read-date" == asl = ReadDate val+ | "size" == asl = Size val+ | otherwise = OtherParam asl val+ where asl = T.toLower as - toDispType t = case t of- "inline" -> DispInline- "attachment" -> DispAttachment- "form-data" -> DispFormData- _ -> DispOther t+ toDispType t = if t == "inline" then DispInline+ else if t == "attachment" then DispAttachment+ else if t == "form-data" then DispFormData+ else DispOther t -processBody :: [(String,String)] -> String -> String+paramPairs :: [MIMEParam] -> [(T.Text, T.Text)]+paramPairs = map paramPair+ where+ paramPair (MIMEParam a b) = (a,b)++processBody :: [MIMEParam] -> T.Text -> T.Text processBody headers body =- case lookupField "content-transfer-encoding" headers of+ case lookupField "content-transfer-encoding" $ paramPairs headers of Nothing -> body- Just v -> decodeBody v body+ Just v -> T.pack $ decodeBody (T.unpack v) $ T.unpack body -normalizeCRLF :: String -> String-normalizeCRLF ('\r':'\n':xs) = '\r':'\n':normalizeCRLF xs-normalizeCRLF ('\r':xs) = '\r':'\n':normalizeCRLF xs-normalizeCRLF ('\n':xs) = '\r':'\n':normalizeCRLF xs-normalizeCRLF (x:xs) = x:normalizeCRLF xs-normalizeCRLF [] = []+normalizeCRLF :: T.Text -> T.Text+normalizeCRLF t+ | T.null t = ""+ | "\r\n" `T.isPrefixOf` t = "\r\n" <> normalizeCRLF (T.drop 2 t)+ | any (`T.isPrefixOf` t) ["\r", "\n"] = "\r\n" <> normalizeCRLF (T.drop 1 t)+ | otherwise = let (a,b) = T.break (`elem` "\r\n") t in a <> normalizeCRLF b -parseMIMEMessage :: String -> MIMEValue+parseMIMEMessage :: T.Text -> MIMEValue parseMIMEMessage entity = case parseHeaders (normalizeCRLF entity) of (as,bs) -> parseMIMEBody as bs -parseHeaders :: String -> ([(String,String)], String)+parseHeaders :: T.Text -> ([MIMEParam], T.Text) parseHeaders str = case findFieldName "" str of Left (nm, rs) -> parseFieldValue nm (dropFoldingWSP rs) Right body -> ([],body) where- findFieldName _acc "" = Right ""- findFieldName _acc ('\r':'\n':xs) = Right xs- findFieldName acc (':':xs) = Left (reverse (dropWhile isHSpace acc), xs)- findFieldName acc (x:xs) = findFieldName (x:acc) xs+ findFieldName acc t + | T.null t = Right ""+ | "\r\n" `T.isPrefixOf` t = Right $ T.drop 2 t+ | ":" `T.isPrefixOf` t = Left (T.reverse $ T.dropWhile isHSpace acc, T.drop 1 t)+ | otherwise = findFieldName (T.take 1 t <> acc) $ T.drop 1 t - parseFieldValue nm xs =- case takeUntilCRLF xs of- (as,"") -> ([(nm,as)],"")- (as,bs) -> let (zs,ys) = parseHeaders bs in ((nm,as):zs,ys)+ parseFieldValue nm xs + | T.null bs = ([MIMEParam nm as], "")+ | otherwise = let (zs,ys) = parseHeaders bs in (MIMEParam nm as :zs, ys)+ where + (as,bs) = takeUntilCRLF xs -parseMultipart :: Type -> String -> (MIMEValue, String)+parseMultipart :: Type -> T.Text -> (MIMEValue, T.Text) parseMultipart mty body =- case lookupField "boundary" (mimeParams mty) of- Nothing -> doTrace ("Multipart mime type, " ++ showType mty +++ case lookupField "boundary" (paramPairs $ mimeParams mty) of+ Nothing -> doTrace ("Multipart mime type, " ++ T.unpack (showType mty) ++ ", has no required boundary parameter. Defaulting to text/plain") $ (nullMIMEValue{ mime_val_type = defaultType , mime_val_disp = Nothing@@ -137,71 +146,71 @@ }, rs) where (vals,rs) = splitMulti bnd body -splitMulti :: String -> String -> ([MIMEValue], String)+splitMulti :: T.Text -> T.Text -> ([MIMEValue], T.Text) splitMulti bnd body_in = -- Note: we insert a CRLF if it looks as if the boundary string starts -- right off the bat. No harm done if this turns out to be incorrect.- let body = case body_in of- '-':'-':_ -> ('\r':'\n':body_in)- _ -> body_in+ let body | "--" `T.isPrefixOf` body_in = "\r\n" <> body_in+ | otherwise = body_in in case untilMatch dashBoundary body of- Nothing -> ([],"")- Just ('-':'-':xs) -> ([],xs)- Just xs -> splitMulti1 (dropTrailer xs)+ Nothing -> mempty+ Just xs | "--" `T.isPrefixOf` xs -> ([], T.drop 2 xs)+ | otherwise -> splitMulti1 (dropTrailer xs) where- dashBoundary = ("\r\n--" ++ bnd)+ dashBoundary = ("\r\n--" <> bnd) - splitMulti1 xs =- case matchUntil dashBoundary xs of- ("","") -> ([],"")- (as,"") -> ([parseMIMEMessage as],"")- (as,'-':'-':bs) -> ([parseMIMEMessage as], dropTrailer bs)- (as,bs) -> let (zs,ys) = splitMulti1 (dropTrailer bs)- in ((parseMIMEMessage as) : zs,ys)+ splitMulti1 xs + | T.null as && T.null bs = ([], "")+ | T.null bs = ([parseMIMEMessage as],"")+ | T.isPrefixOf "--" bs = ([parseMIMEMessage as], dropTrailer bs)+ | otherwise = let (zs,ys) = splitMulti1 (dropTrailer bs)+ in ((parseMIMEMessage as) : zs,ys) - dropTrailer xs =- case dropWhile isHSpace xs of- '\r':'\n':xs1 -> xs1- xs1 -> xs1 -- hmm, flag an error?+ where+ (as,bs) = matchUntil dashBoundary xs -parseMIMEType :: String -> Maybe Type-parseMIMEType = parseContentType+ dropTrailer xs + | "\r\n" `T.isPrefixOf` xs1 = T.drop 2 xs1+ | otherwise = xs1 -- hmm, flag an error?+ where+ xs1 = T.dropWhile isHSpace xs -parseContentType :: String -> Maybe Type-parseContentType str =- case break (=='/') (dropFoldingWSP str) of- (maj,_:minor) ->- case break (\ ch -> isHSpace ch || isTSpecial ch) minor of- (as,bs) -> - Just Type { mimeType = toType maj as- , mimeParams = parseParams (dropWhile isHSpace bs)- }- _ -> doTrace ("unable to parse content-type: " ++ show str) $ Nothing- where- toType a b = case lookupField (map toLower a) mediaTypes of- Just ctor -> ctor b- _ -> Other a b+parseMIMEType :: T.Text -> Maybe Type+parseMIMEType = parseContentType -parseParams :: String -> [(String,String)]-parseParams "" = []-parseParams (';':xs) =- case break (=='=') (dropFoldingWSP xs) of- (_,[]) -> []- (nm_raw,_:vs) ->- case vs of- '"':vs1 ->- case break (=='"') vs1 of- (val,"") -> [(nm,val)]- (val,_:zs) -> (nm,val):parseParams (dropWhile isHSpace zs)- _ -> case break (\ ch -> isHSpace ch || isTSpecial ch) vs of- (val,zs) -> (nm,val):parseParams (dropWhile isHSpace zs)- where- nm = map toLower nm_raw+parseContentType :: T.Text -> Maybe Type+parseContentType str+ | T.null minor0 = doTrace ("unable to parse content-type: " ++ show str) $ Nothing+ | otherwise = Just Type { mimeType = toType maj as+ , mimeParams = parseParams (T.dropWhile isHSpace bs)+ }+ where+ (maj, minor0) = T.break (=='/') (dropFoldingWSP str)+ minor = T.drop 1 minor0+ (as, bs) = T.break (\ ch -> isHSpace ch || isTSpecial ch) minor + toType a b = case lookupField (T.toLower a) mediaTypes of+ Just ctor -> ctor b+ _ -> Other a b -parseParams cs = doTrace ("Codec.MIME.Parse.parseParams: curious param value -- " ++ show cs) []+parseParams :: T.Text -> [MIMEParam]+parseParams t | T.null t = []+ | ';' == T.head t = let (nm_raw, vs0) = T.break (=='=') (dropFoldingWSP $ T.tail t) + nm = T.toLower nm_raw in+ if T.null vs0 + then []+ else let vs = T.tail vs0 in+ if not (T.null vs) && T.head vs == '"' + then let vs1 = T.tail vs + (val, zs0) = T.break (=='"') vs1 in+ if T.null zs0 + then [MIMEParam nm val]+ else MIMEParam nm val : parseParams (T.dropWhile isHSpace $ T.tail zs0)+ else let (val, zs) = T.break (\ch -> isHSpace ch || isTSpecial ch) vs in+ MIMEParam nm val : parseParams (T.dropWhile isHSpace zs)+ | otherwise = doTrace ("Codec.MIME.Parse.parseParams: curious param value -- " ++ show t) [] -mediaTypes :: [(String, String -> MIMEType)]+mediaTypes :: [(T.Text, T.Text -> MIMEType)] mediaTypes = [ ("multipart", (Multipart . toMultipart)) , ("application", Application)@@ -212,12 +221,11 @@ , ("text", Text) , ("video", Video) ]- where toMultipart b = fromMaybe other (lookupField (map toLower b) multipartTypes)- where other = case b of- 'x':'-':_ -> Extension b- _ -> OtherMulti b+ where toMultipart b = fromMaybe other (lookupField (T.toLower b) multipartTypes)+ where other | T.isPrefixOf "x-" b = Extension b+ | otherwise = OtherMulti b -multipartTypes :: [(String, Multipart)]+multipartTypes :: [(T.Text, Multipart)] multipartTypes = [ ("alternative", Alternative) , ("byteranges", Byteranges)@@ -230,18 +238,19 @@ , ("signed", Signed) ] -untilMatch :: String -> String -> Maybe String-untilMatch "" a = Just a-untilMatch _ "" = Nothing-untilMatch a b | a `isPrefixOf` b = Just $ drop (length a) b-untilMatch a (_:bs) = untilMatch a bs+untilMatch :: T.Text -> T.Text -> Maybe T.Text+untilMatch a b | T.null a = Just b+ | T.null b = Nothing+ | a `T.isPrefixOf` b = Just $ T.drop (T.length a) b+ | otherwise = untilMatch a $ T.tail b -matchUntil :: String -> String -> (String, String)+matchUntil :: T.Text -> T.Text -> (T.Text, T.Text) matchUntil _ "" = ("", "") matchUntil str xs+ | T.null xs = mempty -- slow, but it'll do for now.- | str `isPrefixOf` xs = ("", drop (length str) xs)-matchUntil str (x:xs) = let (as,bs) = matchUntil str xs in (x:as,bs)+ | str `T.isPrefixOf` xs = ("", T.drop (T.length str) xs)+ | otherwise = let (as,bs) = matchUntil str $ T.tail xs in (T.take 1 xs <> as, bs) isHSpace :: Char -> Bool isHSpace c = c == ' ' || c == '\t'@@ -249,25 +258,25 @@ isTSpecial :: Char -> Bool isTSpecial x = x `elem` "()<>@,;:\\\"/[]?=" -- " -dropFoldingWSP :: String -> String-dropFoldingWSP "" = ""-dropFoldingWSP (x:xs)- | isHSpace x = dropFoldingWSP xs-dropFoldingWSP ('\r':'\n':x:xs)- | isHSpace x = dropFoldingWSP xs-dropFoldingWSP (x:xs) = x:xs+dropFoldingWSP :: T.Text -> T.Text+dropFoldingWSP t | T.null t = ""+ | isHSpace (T.head t) = dropFoldingWSP $ T.tail t+ | "\r\n" `T.isPrefixOf` t && not (T.null $ T.drop 2 t) && isHSpace (T.head $ T.drop 2 t) + = dropFoldingWSP $ T.drop 3 t+ | otherwise = t -takeUntilCRLF :: String -> (String, String)+takeUntilCRLF :: T.Text -> (T.Text, T.Text) takeUntilCRLF str = go "" str where- go acc "" = (reverse (dropWhile isHSpace acc), "")- go acc ('\r':'\n':x:xs)- | isHSpace x = go (' ':acc) xs- | otherwise = (reverse (dropWhile isHSpace acc), x:xs)- go acc (x:xs) = go (x:acc) xs+ go acc t | T.null t = (T.reverse (T.dropWhile isHSpace acc), "")+ | "\r\n" `T.isPrefixOf` t && not (T.null $ T.drop 2 t) && isHSpace (T.head $ T.drop 2 t) + = go (" " <> acc) (T.drop 3 t)+ | "\r\n" `T.isPrefixOf` t && not (T.null $ T.drop 2 t) + = (T.reverse (T.dropWhile isHSpace acc), T.drop 2 t)+ | otherwise = go (T.take 1 t <> acc) $ T.tail t -- case in-sensitive lookup of field names or attributes\/parameters.-lookupField :: String -> [(String,a)] -> Maybe a+lookupField :: T.Text -> [(T.Text,a)] -> Maybe a lookupField n ns = -- assume that inputs have been mostly normalized already -- (i.e., lower-cased), but should the lookup fail fall back@@ -275,8 +284,6 @@ case lookup n ns of x@Just{} -> x Nothing -> - let nl = map toLower n in- case find (\ (y,_) -> nl == map toLower y) ns of- Nothing -> Nothing- Just (_,x) -> Just x+ let nl = T.toLower n in+ fmap snd $ L.find ((nl==) . T.toLower . fst) ns
Codec/MIME/Type.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE OverloadedStrings #-} -------------------------------------------------------------------- -- | -- Module : Codec.MIME.Type@@ -14,12 +15,16 @@ -------------------------------------------------------------------- module Codec.MIME.Type where -import Data.List ( isSuffixOf )+import qualified Data.Text as T+import Data.Monoid ((<>)) -data Type- = Type+data MIMEParam = MIMEParam { paramName :: T.Text+ , paramValue :: T.Text }+ deriving (Show, Ord, Eq)++data Type = Type { mimeType :: MIMEType- , mimeParams :: [(String,String)]+ , mimeParams :: [MIMEParam] } deriving ( Show, Ord, Eq ) -- | The @null@ MIME record type value; currently a @text/plain@.@@ -29,13 +34,13 @@ , mimeParams = [] } -showType :: Type -> String-showType t = showMIMEType (mimeType t) ++ showMIMEParams (mimeParams t)+showType :: Type -> T.Text+showType t = showMIMEType (mimeType t) <> showMIMEParams (mimeParams t) -showMIMEParams :: [(String,String)] -> String-showMIMEParams ps = concatMap showP ps- where - showP (a,b) = ';':' ':a ++ '=':'"':b ++ "\""+showMIMEParams :: [MIMEParam] -> T.Text+showMIMEParams ps = T.concat $ map showP ps+ where + showP (MIMEParam a b) = "; " <> a <> "=\"" <> b <> "\"" data MIMEType@@ -47,46 +52,41 @@ | Multipart Multipart | Text TextType | Video SubType- | Other String SubType+ | Other {otherType :: T.Text, otherSubType :: SubType} deriving ( Show, Ord, Eq ) -showMIMEType :: MIMEType -> String+showMIMEType :: MIMEType -> T.Text showMIMEType t = case t of- Application s -> "application/"++s- Audio s -> "audio/"++s- Image s -> "image/"++s- Message s -> "message/"++s- Model s -> "model/"++s- Multipart s -> "multipart/"++showMultipart s- Text s -> "text/"++s- Video s -> "video/"++s- Other a b -> a ++ '/':b+ Application s -> "application/"<>s+ Audio s -> "audio/"<>s+ Image s -> "image/"<>s+ Message s -> "message/"<>s+ Model s -> "model/"<>s+ Multipart s -> "multipart/"<>showMultipart s+ Text s -> "text/"<>s+ Video s -> "video/"<>s+ Other a b -> a <> "/" <> b -- | a (type, subtype) MIME pair. data MIMEPair- = MIMEPair String SubType+ = MIMEPair T.Text SubType deriving ( Eq ) -showMIMEPair :: MIMEPair -> String-showMIMEPair (MIMEPair a b) = a ++ '/':b+showMIMEPair :: MIMEPair -> T.Text+showMIMEPair (MIMEPair a b) = a <> "/" <> b -- | default subtype representation.-type SubType = String+type SubType = T.Text -- | subtype for text content; currently just a string. type TextType = SubType -subTypeString :: Type -> String-subTypeString t = - case break (=='/') (showMIMEType (mimeType t)) of- (_,"") -> ""- (_,_:bs) -> bs+subTypeString :: Type -> T.Text+subTypeString t = T.drop 1 $ snd $ T.break (=='/') (showMIMEType (mimeType t)) -majTypeString :: Type -> String-majTypeString t = - case break (=='/') (showMIMEType (mimeType t)) of- (as,_) -> as+majTypeString :: Type -> T.Text+majTypeString t = fst $ T.break (=='/') (showMIMEType (mimeType t)) data Multipart = Alternative@@ -98,8 +98,8 @@ | Parallel | Related | Signed- | Extension String -- ^ e.g., 'x-foo' (i.e., includes the 'x-' bit)- | OtherMulti String -- unrecognized\/uninterpreted.+ | Extension T.Text -- ^ e.g., 'x-foo' (i.e., includes the 'x-' bit)+ | OtherMulti T.Text -- unrecognized\/uninterpreted. -- (e.g., appledouble, voice-message, etc.) deriving ( Show, Ord, Eq ) @@ -107,7 +107,7 @@ isXmlBased t = case mimeType t of Multipart{} -> False- _ -> "+xml" `isSuffixOf` subTypeString t+ _ -> "+xml" `T.isSuffixOf` subTypeString t isXmlType :: Type -> Bool isXmlType t = isXmlBased t ||@@ -117,14 +117,14 @@ _ -> False where -- Note: xml-dtd isn't considered an XML type here.- xml_media_types :: [String]+ xml_media_types :: [T.Text] xml_media_types = [ "xml" , "xml-external-parsed-entity" ] -showMultipart :: Multipart -> String+showMultipart :: Multipart -> T.Text showMultipart m = case m of Alternative -> "alternative"@@ -139,13 +139,13 @@ Extension e -> e OtherMulti e -> e -type Content = String+type Content = T.Text data MIMEValue = MIMEValue { mime_val_type :: Type , mime_val_disp :: Maybe Disposition , mime_val_content :: MIMEContent- , mime_val_headers :: [(String,String)]+ , mime_val_headers :: [MIMEParam] , mime_val_inc_type :: Bool } deriving ( Show, Eq ) @@ -173,15 +173,15 @@ = DispInline | DispAttachment | DispFormData- | DispOther String+ | DispOther T.Text deriving ( Show, Eq) data DispParam- = Name String- | Filename String- | CreationDate String- | ModDate String- | ReadDate String- | Size String- | OtherParam String String+ = Name T.Text+ | Filename T.Text+ | CreationDate T.Text+ | ModDate T.Text+ | ReadDate T.Text+ | Size T.Text+ | OtherParam T.Text T.Text deriving ( Show, Eq)
Codec/MIME/Utils.hs view
@@ -18,10 +18,11 @@ import Codec.MIME.Type import Data.List ( find ) import Control.Monad ( msum )+import Data.Text(Text) -- | Given a parameter name, locate it within a MIME value, -- returning the corresponding (sub) MIME value.-findMultipartNamed :: String -> MIMEValue -> Maybe MIMEValue+findMultipartNamed :: Text -> MIMEValue -> Maybe MIMEValue findMultipartNamed nm mv = case mime_val_content mv of Multi ms -> msum (map (findMultipartNamed nm) ms)
mime.cabal view
@@ -1,5 +1,5 @@ name: mime-version: 0.3.4+version: 0.4.0 synopsis: Working with MIME types. description: Working with MIME types. category: Codec@@ -14,7 +14,8 @@ Extra-Source-Files: CHANGES library- build-depends: base >= 3 && < 5+ build-depends: base >= 4 && < 5+ , text >= 0.9 exposed-modules: Codec.MIME.Type Codec.MIME.Parse