packages feed

purebred-email 0.6.0.2 → 0.7

raw patch · 20 files changed

+320/−128 lines, 20 filesdep ~basedep ~bytestringPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: base, bytestring

API changes (from Hackage documentation)

- Data.IMF: instance Control.DeepSeq.NFData a => Control.DeepSeq.NFData (Data.IMF.Message s a)
- Data.IMF: instance Data.IMF.EqMessage a => GHC.Classes.Eq (Data.IMF.Message s a)
- Data.IMF: instance Data.IMF.HasHeaders (Data.IMF.Message s a)
- Data.IMF: instance GHC.Base.Functor (Data.IMF.Message s)
- Data.IMF: instance GHC.Generics.Generic (Data.IMF.Message s a)
- Data.IMF: instance GHC.Show.Show a => GHC.Show.Show (Data.IMF.Message s a)
- Data.IMF.Syntax: instance Data.IMF.Syntax.CharParsing Data.Attoparsec.Internal.Types.Parser Data.ByteString.Internal.ByteString GHC.Word.Word8
+ Data.IMF: ($dmeqMessage) :: forall {k} (s :: k). (EqMessage a, Eq a) => Message s a -> Message s a -> Bool
+ Data.IMF: addressSpec :: Parser AddrSpec
+ Data.IMF: headerAuthor :: HasHeaders a => CharsetLookup -> Lens' a [Address]
+ Data.IMF: instance forall k (s :: k) a. Control.DeepSeq.NFData a => Control.DeepSeq.NFData (Data.IMF.Message s a)
+ Data.IMF: instance forall k (s :: k) a. Data.IMF.HasHeaders (Data.IMF.Message s a)
+ Data.IMF: instance forall k (s :: k) a. GHC.Generics.Generic (Data.IMF.Message s a)
+ Data.IMF: instance forall k (s :: k) a. GHC.Show.Show a => GHC.Show.Show (Data.IMF.Message s a)
+ Data.IMF: instance forall k (s :: k). GHC.Base.Functor (Data.IMF.Message s)
+ Data.IMF: instance forall k a (s :: k). Data.IMF.EqMessage a => GHC.Classes.Eq (Data.IMF.Message s a)
+ Data.IMF.Syntax: dquote :: CharParsing f s a => f s a
+ Data.IMF.Syntax: instance Data.IMF.Syntax.CharParsing Data.Attoparsec.Internal.Types.Parser Data.ByteString.Internal.Type.ByteString GHC.Word.Word8
+ Data.IMF.Syntax: vcharUtf8 :: Parser ByteString [Word8]
+ Data.MIME: PartNoBody :: MIME
+ Data.MIME: contentTypeMultipart :: MultipartSubtype -> Boundary -> ContentType
+ Data.MIME: entities' :: Traversal' MIMEMessage (Message EncStateWire (Maybe ByteString))
- Data.IMF: Message :: Headers -> a -> Message s a
+ Data.IMF: Message :: Headers -> a -> Message (s :: k) a
- Data.IMF: body :: Lens (Message ctx a) (Message ctx' b) a b
+ Data.IMF: body :: forall {k1} {k2} (ctx :: k1) a (ctx' :: k2) b f. Functor f => (a -> f b) -> Message ctx a -> f (Message ctx' b)
- Data.IMF: buildMessage :: forall ctx a. RenderMessage a => Message ctx a -> Builder
+ Data.IMF: buildMessage :: forall {k} (ctx :: k) a. RenderMessage a => Message ctx a -> Builder
- Data.IMF: crlf :: Alternative (f s) => CharParsing f s a => f s ()
+ Data.IMF: crlf :: (Alternative (f s), CharParsing f s a) => f s ()
- Data.IMF: data Message s a
+ Data.IMF: data Message (s :: k) a
- Data.IMF: eqMessage :: (EqMessage a, Eq a) => Message s a -> Message s a -> Bool
+ Data.IMF: eqMessage :: forall {k} (s :: k). EqMessage a => Message s a -> Message s a -> Bool
- Data.IMF: renderMessage :: RenderMessage a => Message ctx a -> ByteString
+ Data.IMF: renderMessage :: forall {k} a (ctx :: k). RenderMessage a => Message ctx a -> ByteString
- Data.IMF: reply :: CharsetLookup -> ReplySettings -> Message ctx a -> Message ctx ()
+ Data.IMF: reply :: forall {k} (ctx :: k) a. CharsetLookup -> ReplySettings -> Message ctx a -> Message ctx ()
- Data.IMF.Syntax: class IsChar a => CharParsing f s a | s -> a, a -> f s
+ Data.IMF.Syntax: class IsChar a => CharParsing (f :: Type -> Type -> Type) s a | s -> a, a -> f s
- Data.IMF.Syntax: crlf :: Alternative (f s) => CharParsing f s a => f s ()
+ Data.IMF.Syntax: crlf :: (Alternative (f s), CharParsing f s a) => f s ()
- Data.MIME: charsetDecoded :: (HasCharset a, AsCharsetError e) => CharsetLookup -> forall p f. (Profunctor p, Contravariant f) => Optic' p f a (Either e (Decoded a))
+ Data.MIME: charsetDecoded :: (HasCharset a, AsCharsetError e) => CharsetLookup -> forall (p :: Type -> Type -> Type) (f :: Type -> Type). (Profunctor p, Contravariant f) => Optic' p f a (Either e (Decoded a))
- Data.MIME: charsetDecoded' :: HasCharset a => CharsetLookup -> forall p f. (Profunctor p, Contravariant f) => Optic' p f a (Either CharsetError (Decoded a))
+ Data.MIME: charsetDecoded' :: HasCharset a => CharsetLookup -> forall (p :: Type -> Type -> Type) (f :: Type -> Type). (Profunctor p, Contravariant f) => Optic' p f a (Either CharsetError (Decoded a))
- Data.MIME: ctSubtype :: Lens' (ContentTypeWith a) (CI ByteString)
+ Data.MIME: ctSubtype :: forall a f. Functor f => (CI ByteString -> f (CI ByteString)) -> ContentTypeWith a -> f (ContentTypeWith a)
- Data.MIME: ctType :: Lens' (ContentTypeWith a) (CI ByteString)
+ Data.MIME: ctType :: forall a f. Functor f => (CI ByteString -> f (CI ByteString)) -> ContentTypeWith a -> f (ContentTypeWith a)
- Data.MIME: setTextPlainBody :: Text -> Message ctx a -> MIMEMessage
+ Data.MIME: setTextPlainBody :: forall {k} (ctx :: k) a. Text -> Message ctx a -> MIMEMessage
- Data.MIME.Charset: charsetDecoded :: (HasCharset a, AsCharsetError e) => CharsetLookup -> forall p f. (Profunctor p, Contravariant f) => Optic' p f a (Either e (Decoded a))
+ Data.MIME.Charset: charsetDecoded :: (HasCharset a, AsCharsetError e) => CharsetLookup -> forall (p :: Type -> Type -> Type) (f :: Type -> Type). (Profunctor p, Contravariant f) => Optic' p f a (Either e (Decoded a))
- Data.MIME.Charset: charsetDecoded' :: HasCharset a => CharsetLookup -> forall p f. (Profunctor p, Contravariant f) => Optic' p f a (Either CharsetError (Decoded a))
+ Data.MIME.Charset: charsetDecoded' :: HasCharset a => CharsetLookup -> forall (p :: Type -> Type -> Type) (f :: Type -> Type). (Profunctor p, Contravariant f) => Optic' p f a (Either CharsetError (Decoded a))
- Data.MIME.Charset: charsetPrism :: forall a. HasCharset a => CharsetLookup -> Prism' a (Decoded a)
+ Data.MIME.Charset: charsetPrism :: HasCharset a => CharsetLookup -> Prism' a (Decoded a)
- Data.MIME.Parameter: value :: Lens (ParameterValue cs a) (ParameterValue cs b) a b
+ Data.MIME.Parameter: value :: forall cs a b f. Functor f => (a -> f b) -> ParameterValue cs a -> f (ParameterValue cs b)

Files

CHANGELOG.md view
@@ -1,3 +1,30 @@+## Version 0.7 (2025-08-31)++- Support non-ASCII UTF-8 in `Subject` and other unstructured+  headers.  ([#82])++- Support non-ASCII UTF-8 in display name in the `Data.IMF.Text`+  parser (the `ByteString` parser still only supports non-ASCII+  input via encoded-word).  ([#87])++- Export `contentTypeMultipart` so that users can create all kinds+  of multipart messages. ([#81])++- Add `headerAuthor`, for and reading the `Author:` header field+  defined in [RFC 9057]. ([#77])++- Add support for parsing MIME subparts without bodies.  The new+  `entities'` traversal visits all parts, projecting `Just body`+  where there is a body otherwise `Nothing`.  The `MIME` data type+  gets the new `PartNoBody` constructor.  ([#90])++[#77]: https://github.com/purebred-mua/purebred-email/issues/77+[#81]: https://github.com/purebred-mua/purebred-email/issues/81+[#82]: https://github.com/purebred-mua/purebred-email/issues/82+[#87]: https://github.com/purebred-mua/purebred-email/issues/87+[#90]: https://github.com/purebred-mua/purebred-email/issues/90++ ## Version 0.6 (2022-09-13)  - Parameterise the `ContentType` data type over the "parameters"
purebred-email.cabal view
@@ -1,6 +1,6 @@ cabal-version:       2.2 name:                purebred-email-version:             0.6.0.2+version:             0.7 synopsis:            types and parser for email messages (including MIME) description:   The purebred email library.  RFC 5322, MIME, etc.@@ -23,6 +23,7 @@   - MIME parameter value and encoded word extensions (<https://tools.ietf.org/html/rfc2231 RFC 2231>)   - @Content-Disposition@ header field (<https://tools.ietf.org/html/rfc2183 RFC 2183>)   - Address syntax in @From@ and @Sender@ fields (<https://tools.ietf.org/html/rfc6854 RFC 6854>)+  - @Author@ header field (<https://tools.ietf.org/html/rfc9057 RFC 9057>)  license:             AGPL-3.0-or-later license-file:        LICENSE@@ -31,13 +32,14 @@ copyright:           Copyright 2017-2021  Fraser Tweedale category:            Data, Email build-type:          Simple-extra-source-files:+extra-doc-files:   CHANGELOG.md   README.rst+extra-source-files:   test-vectors/*.eml   tests/golden/*.golden tested-with:-  GHC ==8.8.4 || ==8.10.7 || ==9.0.2 || ==9.2.7 || ==9.4.7 || ==9.6.3 || ==9.8.1+  GHC ==9.6.7 || ==9.8.4 || ==9.10.2 || ==9.12.2 || ==9.14.1  homepage:            https://github.com/purebred-mua/purebred-email bug-reports:         https://github.com/purebred-mua/purebred-email/issues@@ -49,8 +51,9 @@   description: Build demonstration programs   default: False -common common-  default-language: Haskell2010+common language+  default-language: GHC2021+  default-extensions: LambdaCase   ghc-options:     -Wall     -Wcompat@@ -63,24 +66,17 @@     -Wpartial-fields     -Wredundant-constraints     -fhide-source-paths-  if impl(ghc >= 8.10)-    ghc-options:-      -Wunused-packages-  if impl(ghc >= 9.0)-    ghc-options:-      -Winvalid-haddock-      -Werror=unicode-bidirectional-format-characters-  if impl(ghc >= 9.2)-    ghc-options:-      -Wimplicit-lift-      -Woperator-whitespace-      -Wredundant-bang-patterns-  if impl(ghc >= 9.4)-    ghc-options:-      -Wredundant-strictness-flags+    -Wunused-packages+    -Winvalid-haddock+    -Werror=unicode-bidirectional-format-characters+    -Wimplicit-lift+    -Woperator-whitespace+    -Wredundant-bang-patterns+    -Wredundant-strictness-flags +common deps   build-depends:-    base >= 4.11 && < 5+    base >= 4.18 && < 5     , attoparsec >= 0.13 && < 0.15     , bytestring >= 0.10 && < 0.13     , case-insensitive >= 1.2 && < 1.3@@ -89,7 +85,8 @@     , time >= 1.9  library-  import: common+  import: language+  import: deps   exposed-modules:     Data.IMF     , Data.IMF.Syntax@@ -113,17 +110,16 @@     , Data.IMF.DateTime   build-depends:     , base64-bytestring >= 1 && < 2-    , case-insensitive >= 1.2 && < 1.3     , concise >= 0.1.0.1 && < 1     , deepseq >= 1.4.2     , random >= 1.2.0     , semigroupoids >= 5 && < 7     , stringsearch >= 0.3   hs-source-dirs:      src-  default-language:    Haskell2010  test-suite tests-  import: common+  import: language+  import: deps   type: exitcode-stdio-1.0   hs-source-dirs: tests   main-is: Test.hs@@ -147,10 +143,12 @@     , quickcheck-instances  executable purebred-email-parse-  import: common+  import: language   if !flag(demos)     buildable: False   hs-source-dirs: tools   main-is: Parse.hs   build-depends:+    base+    , bytestring     , purebred-email
src/Data/IMF.hs view
@@ -14,16 +14,10 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE DefaultSignatures #-} {-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeFamilies #-}  {- |@@ -119,6 +113,7 @@    -- ** Originator   , headerFrom+  , headerAuthor   , headerReplyTo    -- ** Destination Address@@ -150,6 +145,7 @@   , Address(..)   , address   , addressList+  , addressSpec   , AddrSpec(..)   , Domain(..)   , Mailbox(..)@@ -211,7 +207,7 @@   ( CI, ci, mk, original   , (<<>>), foldMany, foldMany1Sep   , char, fromChar, isAtext, isQtext, isVchar, isWsp-  , optionalCFWS, word, wsp, vchar, optionalFWS, crlf+  , optionalCFWS, word, wsp, vcharUtf8, optionalFWS, crlf   , domainLiteral, dotAtom, dotAtomText, localPart, quotedString   ) import {-# SOURCE #-} Data.IMF.Text (readMailbox)@@ -222,7 +218,8 @@  type Header = (CI B.ByteString, B.ByteString) newtype Headers = Headers [Header]-  deriving (Eq, Show, Generic, NFData)+  deriving (Eq, Show, Generic)+  deriving anyclass (NFData)  class HasHeaders a where   headers :: Lens' a Headers@@ -513,6 +510,26 @@ headerCC = headerAddressList "Cc" headerBCC = headerAddressList "Bcc" +-- | @Author:@ field (<https://tools.ietf.org/html/rfc9057 RFC 9057>).+--+-- Rules for this field:+--+-- * If an Author: field already exists, a new one MUST NOT be+--   created, and the existing one MUST NOT be modified.+-- * An author's MUA or MSA MAY create an Author: field, and its+--   value MUST be identical to the value in the From: field.+-- * A Mediator MAY create an Author: field if one does not already+--   exist, and this new field's value MUST be identical to the value+--   of the From: field at the time the Mediator received the message+--   (and before the Mediator causes any changes to the From: field).+--+-- This implementation follows RFC 6854 in allowing group syntax+-- (RFC 9057 defines a more strict syntax, which is incompatible+-- with RFC 6854).+--+headerAuthor :: (HasHeaders a) => CharsetLookup -> Lens' a [Address]+headerAuthor = headerAddressList "Author"+ data MessageID = MessageID   (NonEmpty B.ByteString)   (Either (NonEmpty B.ByteString) B.ByteString)@@ -681,10 +698,10 @@   inRep = view headerInReplyTo msg  replySubject :: CharsetLookup -> Message ctx a -> T.Text-replySubject charsets msg = if prefixed then orig else "Re: " <> orig+replySubject charsets msg = if hasPrefix then orig else "Re: " <> orig   where   orig = fold $ view (headerSubject charsets) msg-  prefixed = mk (T.take 3 orig) == "Re:"+  hasPrefix = mk (T.take 3 orig) == "Re:"   -- | Construct a reply to a 'Message', according to the specified@@ -744,7 +761,7 @@     getAddrSpec :: Mailbox -> AddrSpec     getAddrSpec (Mailbox _ addr) = addr -    -- | Find a mailbox matching the given address.  If no match is+    -- Find a mailbox matching the given address.  If no match is     -- found, return @Nothing@.  If match is found, return the value     -- from the candidates collection if 'ReplyFromRewriteOn',     -- otherwise return the input value.@@ -899,9 +916,10 @@   <*  char8 ':' <* many wsp   <*> unstructured <* crlf +-- | UTF-8 unstructured text unstructured :: Parser B.ByteString unstructured =-  foldMany (optionalFWS <<>> (B.singleton <$> vchar))+  foldMany (optionalFWS <<>> fmap B.pack vcharUtf8)   <<>> A.takeWhile isWsp  
src/Data/IMF/Syntax.hs view
@@ -14,11 +14,8 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE ConstraintKinds #-}-{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE FunctionalDependencies #-} {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE ScopedTypeVariables #-}  {- | @@ -43,6 +40,7 @@   , crlf   , vchar   , word+  , dquote   , quotedString   , dotAtomText   , dotAtom@@ -53,6 +51,8 @@   , CharParsing(..)   , SM +  , vcharUtf8+   -- ** Helpers for building parsers   , isAtext   , isQtext@@ -76,7 +76,7 @@   ) where  import Prelude hiding (takeWhile)-import Control.Applicative ((<|>), Alternative, liftA2, many, optional, some)+import Control.Applicative ((<|>), Alternative, many, optional, some) import Control.Monad (void) import qualified Data.Attoparsec.ByteString as A import qualified Data.Attoparsec.Internal as A@@ -145,6 +145,30 @@  vchar :: CharParsing f s a => (f s) a vchar = satisfy isVchar++vcharUtf8 :: AT.Parser B.ByteString [Word8]+vcharUtf8 =+  pure <$> satisfy isVchar+  <|> utf8NonAscii++-- | Parse a multi-byte UTF-8 character, returning the list of+-- bytes.+utf8NonAscii :: A.Parser [Word8]+utf8NonAscii =+  list2 <$> fromTo 0xC2 0xDF <*> utf8_tail+  <|> list3 <$> A.word8 0xE0 <*> fromTo 0xA0 0xBF <*> utf8_tail+  <|> list3 <$> fromTo 0xE1 0xEC <*> utf8_tail <*> utf8_tail+  <|> list3 <$> A.word8 0xED <*> fromTo 0x80 0x9F <*> utf8_tail+  <|> list3 <$> fromTo 0xEE 0xEF <*> utf8_tail <*> utf8_tail+  <|> list4 <$> A.word8 0xF0 <*> fromTo 0x90 0xBF <*> utf8_tail <*> utf8_tail+  <|> list4 <$> fromTo 0xF1 0xF3 <*> utf8_tail <*> utf8_tail <*> utf8_tail+  <|> list4 <$> fromTo 0xF1 0xF3 <*> utf8_tail <*> utf8_tail <*> utf8_tail+  where+    fromTo lo hi = A.satisfy (\c -> c >= lo && c <= hi)+    utf8_tail = fromTo 0x80 0xBF+    list2 c1 c2 = [c1, c2]+    list3 c1 c2 c3 = [c1, c2, c3]+    list4 c1 c2 c3 c4 = [c1, c2, c3, c4]  dquote :: CharParsing f s a => (f s) a dquote = char '"'
src/Data/IMF/Text.hs view
@@ -49,7 +49,7 @@  import Data.MIME.Charset (decodeLenient) import Data.IMF (Mailbox(..), Address(..), AddrSpec(..), Domain(..))-import Data.IMF.Syntax+import Data.IMF.Syntax hiding (quotedString, word)   renderMailboxes :: [Mailbox] -> T.Text@@ -98,6 +98,28 @@ -- | Parse a (whole) string, returning an error @String@ or a 'Mailbox'. readMailbox :: String -> Either String Mailbox readMailbox = parseOnly (mailbox <* endOfInput) . T.pack++isUtf8NonAscii :: Char -> Bool+isUtf8NonAscii = (> '\127')++quotedString :: Parser T.Text+quotedString =+  optionalCFWS *> dquote+  *> foldMany (optionalFWS <<>> qcontent) <<>> optionalFWS+  <* dquote <* optionalCFWS+  where+    qcontent =+      T.singleton <$> A.satisfy (\c -> isQtext c || isUtf8NonAscii c)+      <|> T.singleton <$> quotedPair++quotedPair :: Parser Char+quotedPair = char '\\' *> (vchar <|> A.satisfy isUtf8NonAscii <|> wsp)++atom :: Parser T.Text+atom = optionalCFWS *> A.takeWhile1 (\c -> isAtext c || isUtf8NonAscii c) <* optionalCFWS++word :: Parser T.Text+word = atom <|> quotedString  -- | Version of 'phrase' that does not process encoded-word -- (we are parsing Text so will assume that the input does not
src/Data/MIME.hs view
@@ -15,11 +15,7 @@ -- along with this program.  If not, see <http://www.gnu.org/licenses/>.  {-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-} {-# LANGUAGE TypeFamilies #-}  {- |@@ -58,6 +54,7 @@    -- *** Accessing and processing entities   , entities+  , entities'   , attachments   , isAttachment   , transferDecoded@@ -91,6 +88,7 @@   -- *** Content-Type values   , contentTypeTextPlain   , contentTypeApplicationOctetStream+  , contentTypeMultipart   , contentTypeMultipartMixed   , defaultContentType @@ -497,6 +495,7 @@ -- data MIME   = Part B.ByteString+  | PartNoBody   | Encapsulated MIMEMessage   | Multipart MultipartSubtype Boundary (NonEmpty MIMEMessage)   | FailedParse MIMEParseError B.ByteString@@ -509,16 +508,35 @@     where     stripVer = set (headers . at "MIME-Version") Nothing --- | Get all leaf entities from the MIME message.--- Entities that failed to parse are skipped.+-- | Traverse all leaf entities in the MIME message.+-- Skips parts without a body and parts that failed to parse. --+-- Use 'entities'' to also traverse parts that do not have a body.+-- entities :: Traversal' MIMEMessage WireEntity-entities f (Message h a) = case a of+entities = entities' . l+  where+  l f (Message h mb) = case mb of+    Nothing -> pure (Message h mb)+    Just b  -> fmap Just <$> f (Message h b)++-- | Traverse all leaf parts in the MIME message, projecting+-- @Just body@ where there is a body and @Nothing@ where there is+-- no body.  Skips parts that failed to parse.+--+-- Use 'entities' to traverse only the parts with bodies.+--+entities' :: Traversal' MIMEMessage (Message EncStateWire (Maybe B.ByteString))+entities' f (Message h a) = case a of   Part b ->-    (\(Message h' b') -> Message h' (Part b')) <$> f (Message h b)-  Encapsulated msg -> Message h . Encapsulated <$> entities f msg+    (\(Message h' b') -> Message h' (maybe PartNoBody Part b'))+    <$> f (Message h (Just b))+  PartNoBody ->+    (\(Message h' b') -> Message h' (maybe PartNoBody Part b'))+    <$> f (Message h Nothing)+  Encapsulated msg -> Message h . Encapsulated <$> entities' f msg   Multipart sub b bs ->-    Message h . Multipart sub b <$> traverse (entities f) bs+    Message h . Multipart sub b <$> traverse (entities' f) bs   FailedParse _ _ -> pure (Message h a)  -- | Leaf entities with @Content-Disposition: attachment@@@ -675,6 +693,9 @@ --   Decoding RTF is outside the scope of this library. --   See <https://www.iana.org/assignments/media-types/text/rtf>. --+-- For encoding, uses @us-ascii@ if the body is entierly ASCII+-- otherwise @utf-8@, and sets the @charset@ parameter accordingly.+-- instance HasCharset ByteEntity where   type Decoded ByteEntity = TextEntity   charsetName = to $ \ent ->@@ -693,12 +714,6 @@   charsetData = body   charsetDecoded m = to $ \a -> (\t -> set body t a) <$> view (charsetText m) a -  -- | Encode (@utf-8@) and add/set charset parameter.  If consisting-  -- entirely of ASCII characters, the @charset@ parameter gets set to-  -- @us-ascii@ instead of @utf-8@.-  ---  -- Ignores Content-Type (which is not correct for all content types).-  --   charsetEncode (Message h a) =     let       b = T.encodeUtf8 a@@ -961,30 +976,54 @@ -- mime :: Headers -> BodyHandler MIME mime h-  | nullOf (header "MIME-Version") h = RequiredBody (Part <$> takeByteString)-  | otherwise = mime' takeByteString h+  | nullOf (header "MIME-Version") h = OptionalBody (Part <$> takeByteString, PartNoBody)+  | otherwise = mime' Top h  type instance MessageContext MIME = EncStateWire +data MIMELevel = Top | Nest B.ByteString {- delimiter -}+ mime'-  :: Parser B.ByteString-  -- ^ Parser FOR A TAKE to the part delimiter.  If this part is-  -- multipart, we pass it on to the 'multipart' parser.  If this-  -- part is not multipart, we just do the take.+  :: MIMELevel -- ^ current level of message   -> Headers   -> BodyHandler MIME-mime' takeTillEnd h = RequiredBody $ case view contentType h of-  ct | view ctType ct == "multipart" ->+mime' level h = case view contentType h of+  ct | view ctType ct == "multipart"+     -> RequiredBody $         case prepMultipart ct of-          Left err              -> FailedParse err <$> takeTillEnd+          Left err              -> FailedParse err <$> takeCRLF           Right (sub, boundary) ->-            Multipart sub boundary <$> multipart takeTillEnd boundary-            <|> FailedParse MultipartParseFail <$> takeTillEnd-     | matchContentType "message" (Just "rfc822") ct ->-        (Encapsulated <$> message (mime' takeTillEnd))-        <|> (FailedParse EncapsulatedMessageParseFail <$> takeTillEnd)-  _ -> Part <$> takeTillEnd+            Multipart sub boundary <$> multipart takeCRLF boundary+            <|> FailedParse MultipartParseFail <$> takeCRLF+     | matchContentType "message" (Just "rfc822") ct+     -> RequiredBody $+          Encapsulated <$> message (mime' level)+          <|> FailedParse EncapsulatedMessageParseFail <$> takeCRLF+  _ -> OptionalBody+        ( case level of+            Top ->+              Part <$> takeByteString+            Nest boundary ->+              takeTillString boundary >>= \case+                -- special case: we are already at the boundary!+                -- this is not an empty body; it is NO BODY+                "" -> pure PartNoBody+                -- we took to the boundary, but need to trim the CRLF+                s -> pure $ Part (trimCRLF s)++        , PartNoBody+        )   where+    trimCRLF s+      | C8.takeEnd 2 s == "\r\n" = B.dropEnd 2 s+      | C8.takeEnd 1 s ==   "\n" = B.dropEnd 1 s+      | otherwise                =             s+    trimCR s+      | C8.takeEnd 1 s == "\r" = B.dropEnd 1 s+      | otherwise              =             s+    takeCRLF = case level of+      Top -> takeByteString+      Nest boundary -> trimCR <$> takeTillString ("\n" <> boundary)     prepMultipart ct =       (,) <$> parseSubtype ct <*> parseBoundary ct     parseBoundary ct =@@ -1037,13 +1076,8 @@   *> fmap fromList (part `sepBy1` crlf)   <* string "--" <* takeTillEnd   where-    delimiter = "\n--" <> unBoundary boundary-    dashBoundary = B.tail delimiter-    part = message (mime' (trim <$> takeTillString delimiter))-    trim s  -- trim trailing CR, because we only searched for LF-      | B.null s = s-      | C8.last s == '\r' = B.init s-      | otherwise = s+    dashBoundary = "--" <> unBoundary boundary+    part = message (mime' (Nest dashBoundary))  -- | Sets the @MIME-Version: 1.0@ header. --@@ -1057,17 +1091,18 @@         Multipart sub boundary _  -> set contentType (contentTypeMultipart sub boundary)         Encapsulated _msg         -> set contentType "message/rfc822"         _                         -> id-  buildBody _h z = Just $ case z of-    Part partbody -> Builder.byteString partbody-    Encapsulated msg -> buildMessage msg-    Multipart _sub b xs ->+  buildBody _h z = case z of+    Part partbody       -> Just $ Builder.byteString partbody+    PartNoBody          -> Nothing+    Encapsulated msg    -> Just $ buildMessage msg+    FailedParse _ bs    -> Just $ Builder.byteString bs+    Multipart _sub b xs -> Just $       let         boundary = "--" <> Builder.byteString (unBoundary b)       in         boundary <> "\r\n"         <> fold (intersperse ("\r\n" <> boundary <> "\r\n") (fmap buildMessage xs))         <> "\r\n" <> boundary <> "--\r\n"-    FailedParse _ bs -> Builder.byteString bs  -- | Create a mixed `MIMEMessage` with an inline text/plain part and multiple -- `attachments`
src/Data/MIME/Charset.hs view
@@ -14,10 +14,7 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeFamilies #-}  {- |
src/Data/MIME/EncodedWord.hs view
@@ -34,7 +34,7 @@   , chooseEncodedWordEncoding   ) where -import Control.Applicative ((<|>), liftA2, optional)+import Control.Applicative ((<|>), optional) import Data.Maybe (fromMaybe) import Data.Monoid (Sum(Sum), Any(Any)) 
src/Data/MIME/Error.hs view
@@ -14,8 +14,6 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE LambdaCase #-}- module Data.MIME.Error where  import Control.Lens (Prism', prism')
src/Data/MIME/Parameter.hs view
@@ -14,14 +14,9 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeFamilies #-}  {- |@@ -85,7 +80,8 @@ -- and optional charset and language information (RFC 2231). -- newtype Parameters = Parameters [(CI B.ByteString, B.ByteString)]-  deriving (Eq, Show, Generic, NFData)+  deriving (Eq, Show, Generic)+  deriving anyclass (NFData)  instance Semigroup Parameters where   Parameters a <> Parameters b = Parameters (a <> b)@@ -226,12 +222,12 @@   -- | The default charset @us-ascii@ is implied by the abstract of--- RFC 2231 which states: /This memo defines … a means to specify--- parameter values in character sets other than US-ASCII/.+-- RFC 2231 which states: /This memo defines … a means to specify/+-- /parameter values in character sets other than US-ASCII/. ----- When encoding, 'utf-8' is always used, but if the whole string--- contains only ASCII characters then the charset declaration is--- omitted (so that it can be encoded as a non-extended parameter).+-- Always encodes values in @utf-8@, but omits the @charset@+-- declaration when the string contains only ASCII characters+-- (so that it can be encoded as a non-extended parameter). -- instance HasCharset EncodedParameterValue where   type Decoded EncodedParameterValue = DecodedParameterValue
src/Data/MIME/QuotedPrintable.hs view
@@ -14,7 +14,6 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE BangPatterns #-} {-# LANGUAGE OverloadedStrings #-}  {- |
src/Data/MIME/TransferEncoding.hs view
@@ -14,7 +14,6 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} 
src/Data/MIME/Types.hs view
@@ -14,8 +14,6 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE RankNTypes #-}- module Data.MIME.Types   (     ContentTransferEncoding
+ test-vectors/multipart-no-body.eml view
@@ -0,0 +1,18 @@+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="------------VGV4VA5ahWZIEkhxyIbpi2vp"
+To: test@example.com
+From: test@example.com
+Subject: Test
+
+This is a multi-part message in MIME format.
+--------------VGV4VA5ahWZIEkhxyIbpi2vp
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+--------------VGV4VA5ahWZIEkhxyIbpi2vp
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+Hello World
+
+--------------VGV4VA5ahWZIEkhxyIbpi2vp--
tests/ContentTransferEncodings.hs view
@@ -14,9 +14,7 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}  module ContentTransferEncodings where 
tests/Generator.hs view
@@ -14,8 +14,9 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}+ {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE FlexibleInstances #-}  module Generator where 
tests/Headers.hs view
@@ -14,8 +14,11 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. -{-# OPTIONS_GHC -fno-warn-type-defaults #-}+{-# OPTIONS_GHC -Wno-type-defaults #-}+{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}+ {-# LANGUAGE OverloadedStrings #-}+ module Headers where  import Control.Lens@@ -23,6 +26,7 @@ import Data.String (IsString) import Data.Word (Word8) +import qualified Data.Text as T import qualified Data.ByteString as B import qualified Data.ByteString.Lazy as L import qualified Data.ByteString.Lazy.Char8 as L8@@ -47,6 +51,7 @@ unittests :: TestTree unittests = testGroup "Headers"   [ parsesMailboxesSuccessfully+  , parsesMailboxesNonASCIISuccessfully   , parsesTextMailboxesSuccessfully   , parsesAddressesSuccessfully   , parsesTextAddressesSuccessfully@@ -176,21 +181,56 @@       , assertBool "Parse error expected" . isLeft       , "foo@,bar,com")     , ( "displayName without quotes but with spaces"-      , (Right (Mailbox (Just "John Doe") (AddrSpec "jdoe" (DomainDotAtom $ "machine" :| ["example"]))) @=?)-      , "John Doe <jdoe@machine.example>"+      , (Right (Mailbox (Just "John. (Doe)") (AddrSpec "jdoe" (DomainDotAtom $ "machine" :| ["example"]))) @=?)+      , "\"John. (Doe)\" <jdoe@machine.example>"       )     ]  parsesMailboxesSuccessfully :: TestTree parsesMailboxesSuccessfully =-    testGroup "parsing mailboxes" $+    testGroup "parsing mailboxes (text)" $     (\(desc,f,input) ->           testCase desc $ f (AText.parseOnly AddressText.mailbox input)) <$>     mailboxFixtures +nonASCIIDisplayNameFixtures :: [(String, T.Text, T.Text)]+nonASCIIDisplayNameFixtures =+  [ ( "Czech (bare)"+    , "Luděk Tiberiu"+    , "Luděk Tiberiu" )+  , ( "Czech (quoted-string)"+    , "Luděk Tiberiu"+    , "\"Luděk Tiberiu\"" )+  , ( "Czech (quoted-string with quoted-pair)"+    , "Luděk Tiberiu"+    , "\"Lud\\ěk Tiberiu\"" )+  , ( "Chinese"+    , "佐藤 直樹"+    , "佐藤 直樹" )+  , ("Japanese"+    , "鈴木 一郎"+    , "鈴木 一郎" )+  , ("Korean"+    , "김철수"+    , "김철수" )+  , ("Apostrophe"+    , "O'Neill Mc Carthy"+    , "O'Neill Mc Carthy" )+  ]++parsesMailboxesNonASCIISuccessfully :: TestTree+parsesMailboxesNonASCIISuccessfully =+  testGroup "parsing mailboxes (non-ASCII)" $+  (\(desc, expect, input) ->+     testCase desc $ Right (Mailbox (Just expect) addrSpecCommon) @=? AText.parseOnly AddressText.mailbox (input <> " <foo@bar.test>")) <$>+  nonASCIIDisplayNameFixtures+  where+    addrSpecCommon = AddrSpec "foo" (DomainDotAtom $ "bar" :| ["test"])++ parsesTextMailboxesSuccessfully :: TestTree parsesTextMailboxesSuccessfully =-    testGroup "parsing mailboxes (text)" $+    testGroup "parsing mailboxes" $     (\(desc,f,input) ->           testCase desc $ f (parseOnly (mailbox defaultCharsets) input)) <$>     mailboxFixtures
tests/MIME.hs view
@@ -14,13 +14,14 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program.  If not, see <http://www.gnu.org/licenses/>. +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}+ {-# LANGUAGE NoMonomorphismRestriction #-} {-# LANGUAGE OverloadedStrings #-}  module MIME where  import Control.Exception (ErrorCall, evaluate, try)-import Control.Monad ((<=<), void) import Data.Bifunctor (first) import Data.Char (toUpper) import Data.Either (isLeft)@@ -33,7 +34,7 @@ import Data.Time (ZonedTime(ZonedTime), timeZoneMinutes)  import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit ((@?=), Assertion, assertBool, assertFailure, testCase)+import Test.Tasty.HUnit import Test.Tasty.QuickCheck import Test.QuickCheck.Instances () @@ -159,11 +160,22 @@ testParse = testGroup "parsing tests"   [ testCase "nested multipart" $       testParseFile "test-vectors/nested-multipart.eml"+  , testCase "multipart with an empty (no body) part" $+      testParseFileAndAssert "test-vectors/multipart-no-body.eml" $ \msg ->+        assertEqual "parsed the expected sub-entities"+          [ Nothing, Just "Hello World\r\n" ]+          ( toListOf (entities' . body) msg )   ] +testParseFileAndAssert :: FilePath -> (MIMEMessage -> Assertion) -> Assertion+testParseFileAndAssert path go = do+  input <- B.readFile path+  case parse (message mime) input of+    Left s    -> assertFailure s+    Right msg -> go msg+ testParseFile :: FilePath -> Assertion-testParseFile =-  either assertFailure (void . pure) . parse (message mime) <=< B.readFile+testParseFile path = testParseFileAndAssert path (const $ pure ())  testParameterValueOverloadedStrings :: TestTree testParameterValueOverloadedStrings = testGroup "ParameterValue IsString instances"
tests/Message.hs view
@@ -68,8 +68,12 @@ asciiText1 = Gen.text (Range.linear 1 100) printableAsciiChar unicodeText1 = Gen.text (Range.linear 1 100) printableUnicodeChar -genTextPlain, genMultipart, genMessage :: Gen MIMEMessage+genTextPlain, genMultipart, genNoBody, genMessage :: Gen MIMEMessage+ genTextPlain = createTextPlainMessage <$> Gen.choice [asciiText1, unicodeText1]++genNoBody = pure $ set body PartNoBody $ createTextPlainMessage ""+ genMultipart = depths >>= go   where   -- Generate a 50 character multipart boundary.  These MUST be unique@@ -81,8 +85,11 @@   go n = createMultipartMixedMessage       <$> genBoundary       <*> ( Gen.nonEmpty (Range.linear 1 10) $-            -- 75% plain, 25% nested multipart-            maybeAp encapsulate 5 $ Gen.frequency [(3, genTextPlain), (1, go (n - 1))]+            maybeAp encapsulate 5 $ Gen.frequency+              [ (70, genTextPlain)+              , (10, genNoBody)+              , (20, go (n - 1))+              ]           )    -- max depth of 4@@ -97,7 +104,12 @@   -- Apply the function to the generated value with probability 1-in-/n/.   maybeAp f n g = Gen.frequency [(n - 1, g), (1, f <$> g)] -genMessage = Gen.choice [ genTextPlain, genMultipart, encapsulate <$> genMessage ]+genMessage = Gen.choice+  [ genTextPlain+  , genNoBody+  , genMultipart+  , encapsulate <$> genMessage+  ]  prop_messageRoundTrip :: Property prop_messageRoundTrip = property $ do
tools/Parse.hs view
@@ -20,10 +20,10 @@   traverse_ parseMail (fileFilter files)  parseMail :: FilePath -> IO ()-parseMail filename = do-  msgData <- B.readFile filename+parseMail path = do+  msgData <- B.readFile path   case parse (message mime) msgData of-    Left e -> putStrLn (filename <> " (" <> e <> ") " <> analyse msgData)+    Left e -> putStrLn (path <> " (" <> e <> ") " <> analyse msgData)     Right _ -> pure ()  analyse :: B.ByteString -> String