packages feed

purebred-email 0.4.3 → 0.5

raw patch · 31 files changed

+2724/−1324 lines, 31 filesdep +randomdep ~timePVP ok

version bump matches the API change (PVP)

Dependencies added: random

Dependency ranges changed: time

API changes (from Hackage documentation)

- Data.MIME: ctEq :: ContentType -> ContentType -> Bool
- Data.MIME: headerBCC :: HasHeaders a => CharsetLookup -> Lens' a [Address]
- Data.MIME: headerCC :: HasHeaders a => CharsetLookup -> Lens' a [Address]
- Data.MIME: headerDate :: HasHeaders a => Lens' a (Maybe UTCTime)
- Data.MIME: headerFrom :: HasHeaders a => CharsetLookup -> Lens' a [Mailbox]
- Data.MIME: headerSubject :: HasHeaders a => CharsetLookup -> Lens' a (Maybe Text)
- Data.MIME: headerText :: HasHeaders a => CharsetLookup -> CI ByteString -> Lens' a (Maybe Text)
- Data.MIME: headerTo :: HasHeaders a => CharsetLookup -> Lens' a [Address]
- Data.MIME: instance Data.RFC5322.EqMessage Data.MIME.MIME
- Data.MIME: instance Data.RFC5322.RenderMessage Data.MIME.MIME
- Data.MIME: replyHeaderReferences :: HasHeaders a => Getter a (Maybe ByteString)
- Data.RFC5322: AddrSpec :: ByteString -> Domain -> AddrSpec
- Data.RFC5322: DomainDotAtom :: NonEmpty ByteString -> Domain
- Data.RFC5322: DomainLiteral :: ByteString -> Domain
- Data.RFC5322: Group :: Text -> [Mailbox] -> Address
- Data.RFC5322: Headers :: [Header] -> Headers
- Data.RFC5322: Mailbox :: Maybe Text -> AddrSpec -> Mailbox
- Data.RFC5322: Message :: Headers -> a -> Message s a
- Data.RFC5322: NoBody :: a -> BodyHandler a
- Data.RFC5322: OptionalBody :: (Parser a, a) -> BodyHandler a
- Data.RFC5322: RequiredBody :: Parser a -> BodyHandler a
- Data.RFC5322: Single :: Mailbox -> Address
- Data.RFC5322: address :: CharsetLookup -> Parser Address
- Data.RFC5322: addressList :: CharsetLookup -> Parser [Address]
- Data.RFC5322: body :: Lens (Message ctx a) (Message ctx' b) a b
- Data.RFC5322: buildBody :: RenderMessage a => Headers -> a -> Maybe Builder
- Data.RFC5322: buildField :: (CI ByteString, ByteString) -> Builder
- Data.RFC5322: buildFields :: Headers -> Builder
- Data.RFC5322: buildMessage :: forall ctx a. RenderMessage a => Message ctx a -> Builder
- Data.RFC5322: class EqMessage a
- Data.RFC5322: class HasHeaders a
- Data.RFC5322: class RenderMessage a
- Data.RFC5322: crlf :: Alternative (f s) => CharParsing f s a => f s ()
- Data.RFC5322: data AddrSpec
- Data.RFC5322: data Address
- Data.RFC5322: data BodyHandler a
- Data.RFC5322: data Domain
- Data.RFC5322: data Mailbox
- Data.RFC5322: data Message s a
- Data.RFC5322: eqMessage :: (EqMessage a, Eq a) => Message s a -> Message s a -> Bool
- Data.RFC5322: field :: Parser (CI ByteString, ByteString)
- Data.RFC5322: header :: HasHeaders a => CI ByteString -> Traversal' a ByteString
- Data.RFC5322: headerList :: HasHeaders a => Lens' a [(CI ByteString, ByteString)]
- Data.RFC5322: headers :: HasHeaders a => Lens' a Headers
- Data.RFC5322: instance Control.DeepSeq.NFData Data.RFC5322.Headers
- Data.RFC5322: instance Control.DeepSeq.NFData a => Control.DeepSeq.NFData (Data.RFC5322.Message s a)
- Data.RFC5322: instance Control.Lens.At.At Data.RFC5322.Headers
- Data.RFC5322: instance Control.Lens.At.Ixed Data.RFC5322.Headers
- Data.RFC5322: instance Data.RFC5322.EqMessage a => GHC.Classes.Eq (Data.RFC5322.Message s a)
- Data.RFC5322: instance Data.RFC5322.HasHeaders (Data.RFC5322.Message s a)
- Data.RFC5322: instance Data.RFC5322.HasHeaders Data.RFC5322.Headers
- Data.RFC5322: instance GHC.Base.Functor (Data.RFC5322.Message s)
- Data.RFC5322: instance GHC.Base.Monoid Data.RFC5322.Headers
- Data.RFC5322: instance GHC.Base.Monoid Data.RFC5322.PhraseEscapeRequirement
- Data.RFC5322: instance GHC.Base.Semigroup Data.RFC5322.Headers
- Data.RFC5322: instance GHC.Base.Semigroup Data.RFC5322.PhraseEscapeRequirement
- Data.RFC5322: instance GHC.Classes.Eq Data.RFC5322.Headers
- Data.RFC5322: instance GHC.Classes.Eq Data.RFC5322.PhraseEscapeRequirement
- Data.RFC5322: instance GHC.Classes.Ord Data.RFC5322.PhraseEscapeRequirement
- Data.RFC5322: instance GHC.Generics.Generic (Data.RFC5322.Message s a)
- Data.RFC5322: instance GHC.Generics.Generic Data.RFC5322.Headers
- Data.RFC5322: instance GHC.Show.Show Data.RFC5322.Headers
- Data.RFC5322: instance GHC.Show.Show a => GHC.Show.Show (Data.RFC5322.Message s a)
- Data.RFC5322: mailbox :: CharsetLookup -> Parser Mailbox
- Data.RFC5322: mailboxList :: CharsetLookup -> Parser [Mailbox]
- Data.RFC5322: message :: (Headers -> BodyHandler a) -> Parser (Message (MessageContext a) a)
- Data.RFC5322: newtype Headers
- Data.RFC5322: parse :: Cons s s Word8 Word8 => Parser a -> s -> Either String a
- Data.RFC5322: parsePrint :: Parser a -> (a -> ByteString) -> Prism' ByteString a
- Data.RFC5322: parsed :: Cons s s Word8 Word8 => Parser a -> Fold s a
- Data.RFC5322: quotedString :: (Alternative (f s), CharParsing f s a, SM s) => f s s
- Data.RFC5322: renderAddress :: Address -> ByteString
- Data.RFC5322: renderAddressSpec :: AddrSpec -> ByteString
- Data.RFC5322: renderAddresses :: [Address] -> ByteString
- Data.RFC5322: renderMailbox :: Mailbox -> ByteString
- Data.RFC5322: renderMailboxes :: [Mailbox] -> ByteString
- Data.RFC5322: renderMessage :: RenderMessage a => Message ctx a -> ByteString
- Data.RFC5322: renderRFC5422Date :: UTCTime -> ByteString
- Data.RFC5322: rfc5422DateTimeFormat :: String
- Data.RFC5322: rfc5422DateTimeFormatLax :: String
- Data.RFC5322: tweakHeaders :: RenderMessage a => Headers -> Headers
- Data.RFC5322: type Header = (CI ByteString, ByteString)
- Data.RFC5322: type family MessageContext a
- Data.RFC5322.Address.Text: address :: Parser Address
- Data.RFC5322.Address.Text: addressList :: Parser [Address]
- Data.RFC5322.Address.Text: mailbox :: Parser Mailbox
- Data.RFC5322.Address.Text: mailboxList :: Parser [Mailbox]
- Data.RFC5322.Address.Text: renderAddress :: Address -> Text
- Data.RFC5322.Address.Text: renderAddressSpec :: AddrSpec -> Text
- Data.RFC5322.Address.Text: renderAddresses :: [Address] -> Text
- Data.RFC5322.Address.Text: renderMailbox :: Mailbox -> Text
- Data.RFC5322.Address.Text: renderMailboxes :: [Mailbox] -> Text
- Data.RFC5322.Address.Types: instance Control.DeepSeq.NFData Data.RFC5322.Address.Types.AddrSpec
- Data.RFC5322.Address.Types: instance Control.DeepSeq.NFData Data.RFC5322.Address.Types.Address
- Data.RFC5322.Address.Types: instance Control.DeepSeq.NFData Data.RFC5322.Address.Types.Domain
- Data.RFC5322.Address.Types: instance Control.DeepSeq.NFData Data.RFC5322.Address.Types.Mailbox
- Data.RFC5322.Address.Types: instance GHC.Classes.Eq Data.RFC5322.Address.Types.AddrSpec
- Data.RFC5322.Address.Types: instance GHC.Classes.Eq Data.RFC5322.Address.Types.Address
- Data.RFC5322.Address.Types: instance GHC.Classes.Eq Data.RFC5322.Address.Types.Domain
- Data.RFC5322.Address.Types: instance GHC.Classes.Eq Data.RFC5322.Address.Types.Mailbox
- Data.RFC5322.Address.Types: instance GHC.Generics.Generic Data.RFC5322.Address.Types.AddrSpec
- Data.RFC5322.Address.Types: instance GHC.Generics.Generic Data.RFC5322.Address.Types.Address
- Data.RFC5322.Address.Types: instance GHC.Generics.Generic Data.RFC5322.Address.Types.Domain
- Data.RFC5322.Address.Types: instance GHC.Generics.Generic Data.RFC5322.Address.Types.Mailbox
- Data.RFC5322.Address.Types: instance GHC.Show.Show Data.RFC5322.Address.Types.AddrSpec
- Data.RFC5322.Address.Types: instance GHC.Show.Show Data.RFC5322.Address.Types.Address
- Data.RFC5322.Address.Types: instance GHC.Show.Show Data.RFC5322.Address.Types.Domain
- Data.RFC5322.Address.Types: instance GHC.Show.Show Data.RFC5322.Address.Types.Mailbox
- Data.RFC5322.Internal: (<<>>) :: (Semigroup m, Applicative f) => f m -> f m -> f m
- Data.RFC5322.Internal: ci :: FoldCase s => Parser s -> Parser (CI s)
- Data.RFC5322.Internal: class IsChar a => CharParsing f s a | s -> a, a -> f s
- Data.RFC5322.Internal: class IsChar a
- Data.RFC5322.Internal: crlf :: Alternative (f s) => CharParsing f s a => f s ()
- Data.RFC5322.Internal: data CI s
- Data.RFC5322.Internal: domainLiteral :: (Alternative (f s), CharParsing f s a, SM s) => f s s
- Data.RFC5322.Internal: dotAtom :: (Alternative (f s), CharParsing f s a, SM s) => f s (NonEmpty s)
- Data.RFC5322.Internal: foldMany :: (Monoid m, Alternative f) => f m -> f m
- Data.RFC5322.Internal: foldMany1 :: (Semigroup m, Alternative f) => f m -> f m
- Data.RFC5322.Internal: foldMany1Sep :: (Semigroup m, Alternative f) => m -> f m -> f m
- Data.RFC5322.Internal: fromChar :: IsChar a => Char -> a
- Data.RFC5322.Internal: instance Data.RFC5322.Internal.CharParsing Data.Attoparsec.Internal.Types.Parser Data.ByteString.Internal.ByteString GHC.Word.Word8
- Data.RFC5322.Internal: instance Data.RFC5322.Internal.CharParsing Data.Attoparsec.Internal.Types.Parser Data.Text.Internal.Text GHC.Types.Char
- Data.RFC5322.Internal: instance Data.RFC5322.Internal.IsChar GHC.Types.Char
- Data.RFC5322.Internal: instance Data.RFC5322.Internal.IsChar GHC.Word.Word8
- Data.RFC5322.Internal: isAtext :: IsChar c => c -> Bool
- Data.RFC5322.Internal: isQtext :: IsChar c => c -> Bool
- Data.RFC5322.Internal: isVchar :: IsChar c => c -> Bool
- Data.RFC5322.Internal: isWsp :: IsChar c => c -> Bool
- Data.RFC5322.Internal: localPart :: (Alternative (f s), CharParsing f s a, SM s) => f s s
- Data.RFC5322.Internal: optionalCFWS :: (Alternative (f s), CharParsing f s a, SM s) => f s s
- Data.RFC5322.Internal: optionalFWS :: (Alternative (f s), CharParsing f s a, Monoid s) => f s s
- Data.RFC5322.Internal: original :: CI s -> s
- Data.RFC5322.Internal: quotedString :: (Alternative (f s), CharParsing f s a, SM s) => f s s
- Data.RFC5322.Internal: satisfy :: CharParsing f s a => (Char -> Bool) -> f s a
- Data.RFC5322.Internal: singleton :: CharParsing f s a => Char -> s
- Data.RFC5322.Internal: skipTill :: Parser a -> Parser ()
- Data.RFC5322.Internal: skipTillString :: ByteString -> Parser ()
- Data.RFC5322.Internal: takeTill' :: Parser a -> Parser ByteString
- Data.RFC5322.Internal: takeTillString :: ByteString -> Parser ByteString
- Data.RFC5322.Internal: takeWhile :: CharParsing f s a => (Char -> Bool) -> f s s
- Data.RFC5322.Internal: takeWhile1 :: CharParsing f s a => (Char -> Bool) -> f s s
- Data.RFC5322.Internal: toChar :: IsChar a => a -> Char
- Data.RFC5322.Internal: type SM a = Monoid a
- Data.RFC5322.Internal: vchar :: CharParsing f s a => f s a
- Data.RFC5322.Internal: word :: (Alternative (f s), CharParsing f s a, SM s) => f s s
- Data.RFC5322.Internal: wsp :: CharParsing f s a => f s a
+ Data.IMF: AddrSpec :: ByteString -> Domain -> AddrSpec
+ Data.IMF: DomainDotAtom :: NonEmpty (CI ByteString) -> Domain
+ Data.IMF: DomainLiteral :: ByteString -> Domain
+ Data.IMF: Group :: Text -> [Mailbox] -> Address
+ Data.IMF: Headers :: [Header] -> Headers
+ Data.IMF: Mailbox :: Maybe Text -> AddrSpec -> Mailbox
+ Data.IMF: Message :: Headers -> a -> Message s a
+ Data.IMF: NoBody :: a -> BodyHandler a
+ Data.IMF: OptionalBody :: (Parser a, a) -> BodyHandler a
+ Data.IMF: ReplyFromMatchingMailbox :: ReplyFromMode
+ Data.IMF: ReplyFromPreferredMailbox :: ReplyFromMode
+ Data.IMF: ReplyFromRewriteOff :: ReplyFromRewriteMode
+ Data.IMF: ReplyFromRewriteOn :: ReplyFromRewriteMode
+ Data.IMF: ReplySettings :: ReplyMode -> ReplyFromMode -> ReplyFromRewriteMode -> SelfInRecipientsMode -> AuthorMailboxes -> ReplySettings
+ Data.IMF: ReplyToGroup :: ReplyMode
+ Data.IMF: ReplyToSender :: ReplyMode
+ Data.IMF: RequiredBody :: Parser a -> BodyHandler a
+ Data.IMF: SelfInRecipientsIgnore :: SelfInRecipientsMode
+ Data.IMF: SelfInRecipientsRemove :: SelfInRecipientsMode
+ Data.IMF: Single :: Mailbox -> Address
+ Data.IMF: address :: CharsetLookup -> Parser Address
+ Data.IMF: addressList :: CharsetLookup -> Parser [Address]
+ Data.IMF: authorMailboxes :: Lens' ReplySettings AuthorMailboxes
+ Data.IMF: body :: Lens (Message ctx a) (Message ctx' b) a b
+ Data.IMF: buildBody :: RenderMessage a => Headers -> a -> Maybe Builder
+ Data.IMF: buildField :: (CI ByteString, ByteString) -> Builder
+ Data.IMF: buildFields :: Headers -> Builder
+ Data.IMF: buildMessage :: forall ctx a. RenderMessage a => Message ctx a -> Builder
+ Data.IMF: buildMessageID :: MessageID -> Builder
+ Data.IMF: class EqMessage a
+ Data.IMF: class HasHeaders a
+ Data.IMF: class RenderMessage a
+ Data.IMF: crlf :: Alternative (f s) => CharParsing f s a => f s ()
+ Data.IMF: data AddrSpec
+ Data.IMF: data Address
+ Data.IMF: data BodyHandler a
+ Data.IMF: data Domain
+ Data.IMF: data Mailbox
+ Data.IMF: data Message s a
+ Data.IMF: data MessageID
+ Data.IMF: data ReplyFromMode
+ Data.IMF: data ReplyFromRewriteMode
+ Data.IMF: data ReplyMode
+ Data.IMF: data ReplySettings
+ Data.IMF: data SelfInRecipientsMode
+ Data.IMF: dateTime :: Parser ZonedTime
+ Data.IMF: defaultReplySettings :: AuthorMailboxes -> ReplySettings
+ Data.IMF: eqMessage :: (EqMessage a, Eq a) => Message s a -> Message s a -> Bool
+ Data.IMF: field :: Parser (CI ByteString, ByteString)
+ Data.IMF: header :: HasHeaders a => CI ByteString -> Traversal' a ByteString
+ Data.IMF: headerBCC :: HasHeaders a => CharsetLookup -> Lens' a [Address]
+ Data.IMF: headerCC :: HasHeaders a => CharsetLookup -> Lens' a [Address]
+ Data.IMF: headerDate :: HasHeaders a => Lens' a (Maybe ZonedTime)
+ Data.IMF: headerFrom :: HasHeaders a => CharsetLookup -> Lens' a [Address]
+ Data.IMF: headerInReplyTo :: HasHeaders a => Lens' a [MessageID]
+ Data.IMF: headerList :: HasHeaders a => Lens' a [(CI ByteString, ByteString)]
+ Data.IMF: headerMessageID :: HasHeaders a => Lens' a (Maybe MessageID)
+ Data.IMF: headerReferences :: HasHeaders a => Lens' a [MessageID]
+ Data.IMF: headerReplyTo :: HasHeaders a => CharsetLookup -> Lens' a [Address]
+ Data.IMF: headerSubject :: HasHeaders a => CharsetLookup -> Lens' a (Maybe Text)
+ Data.IMF: headerText :: HasHeaders a => CharsetLookup -> CI ByteString -> Lens' a (Maybe Text)
+ Data.IMF: headerTo :: HasHeaders a => CharsetLookup -> Lens' a [Address]
+ Data.IMF: headers :: HasHeaders a => Lens' a Headers
+ Data.IMF: instance Control.DeepSeq.NFData Data.IMF.AddrSpec
+ Data.IMF: instance Control.DeepSeq.NFData Data.IMF.Address
+ Data.IMF: instance Control.DeepSeq.NFData Data.IMF.Domain
+ Data.IMF: instance Control.DeepSeq.NFData Data.IMF.Headers
+ Data.IMF: instance Control.DeepSeq.NFData Data.IMF.Mailbox
+ Data.IMF: instance Control.DeepSeq.NFData a => Control.DeepSeq.NFData (Data.IMF.Message s a)
+ Data.IMF: instance Control.Lens.At.At Data.IMF.Headers
+ Data.IMF: instance Control.Lens.At.Ixed Data.IMF.Headers
+ 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 Data.IMF.HasHeaders Data.IMF.Headers
+ Data.IMF: instance Data.String.IsString Data.IMF.Mailbox
+ Data.IMF: instance GHC.Base.Functor (Data.IMF.Message s)
+ Data.IMF: instance GHC.Base.Monoid Data.IMF.PhraseEscapeRequirement
+ Data.IMF: instance GHC.Base.Semigroup Data.IMF.PhraseEscapeRequirement
+ Data.IMF: instance GHC.Classes.Eq Data.IMF.AddrSpec
+ Data.IMF: instance GHC.Classes.Eq Data.IMF.Address
+ Data.IMF: instance GHC.Classes.Eq Data.IMF.Domain
+ Data.IMF: instance GHC.Classes.Eq Data.IMF.Headers
+ Data.IMF: instance GHC.Classes.Eq Data.IMF.Mailbox
+ Data.IMF: instance GHC.Classes.Eq Data.IMF.MessageID
+ Data.IMF: instance GHC.Classes.Eq Data.IMF.PhraseEscapeRequirement
+ Data.IMF: instance GHC.Classes.Ord Data.IMF.MessageID
+ Data.IMF: instance GHC.Classes.Ord Data.IMF.PhraseEscapeRequirement
+ Data.IMF: instance GHC.Generics.Generic (Data.IMF.Message s a)
+ Data.IMF: instance GHC.Generics.Generic Data.IMF.AddrSpec
+ Data.IMF: instance GHC.Generics.Generic Data.IMF.Address
+ Data.IMF: instance GHC.Generics.Generic Data.IMF.Domain
+ Data.IMF: instance GHC.Generics.Generic Data.IMF.Headers
+ Data.IMF: instance GHC.Generics.Generic Data.IMF.Mailbox
+ Data.IMF: instance GHC.Show.Show Data.IMF.AddrSpec
+ Data.IMF: instance GHC.Show.Show Data.IMF.Address
+ Data.IMF: instance GHC.Show.Show Data.IMF.Domain
+ Data.IMF: instance GHC.Show.Show Data.IMF.Headers
+ Data.IMF: instance GHC.Show.Show Data.IMF.Mailbox
+ Data.IMF: instance GHC.Show.Show Data.IMF.MessageID
+ Data.IMF: instance GHC.Show.Show a => GHC.Show.Show (Data.IMF.Message s a)
+ Data.IMF: mailbox :: CharsetLookup -> Parser Mailbox
+ Data.IMF: mailboxList :: CharsetLookup -> Parser [Mailbox]
+ Data.IMF: message :: (Headers -> BodyHandler a) -> Parser (Message (MessageContext a) a)
+ Data.IMF: newtype Headers
+ Data.IMF: parse :: Cons s s Word8 Word8 => Parser a -> s -> Either String a
+ Data.IMF: parseMessageID :: Parser MessageID
+ Data.IMF: parsePrint :: Parser a -> (a -> ByteString) -> Prism' ByteString a
+ Data.IMF: parsed :: Cons s s Word8 Word8 => Parser a -> Fold s a
+ Data.IMF: quotedString :: (Alternative (f s), CharParsing f s a, SM s) => f s s
+ Data.IMF: renderAddress :: Address -> ByteString
+ Data.IMF: renderAddressSpec :: AddrSpec -> ByteString
+ Data.IMF: renderAddresses :: [Address] -> ByteString
+ Data.IMF: renderMailbox :: Mailbox -> ByteString
+ Data.IMF: renderMailboxes :: [Mailbox] -> ByteString
+ Data.IMF: renderMessage :: RenderMessage a => Message ctx a -> ByteString
+ Data.IMF: renderMessageID :: MessageID -> ByteString
+ Data.IMF: renderRFC5322Date :: ZonedTime -> ByteString
+ Data.IMF: reply :: CharsetLookup -> ReplySettings -> Message ctx a -> Message ctx ()
+ Data.IMF: replyFromMode :: Lens' ReplySettings ReplyFromMode
+ Data.IMF: replyFromRewriteMode :: Lens' ReplySettings ReplyFromRewriteMode
+ Data.IMF: replyMode :: Lens' ReplySettings ReplyMode
+ Data.IMF: selfInRecipientsMode :: Lens' ReplySettings SelfInRecipientsMode
+ Data.IMF: tweakHeaders :: RenderMessage a => a -> Headers -> Headers
+ Data.IMF: type AuthorMailboxes = NonEmpty Mailbox
+ Data.IMF: type Header = (CI ByteString, ByteString)
+ Data.IMF: type family MessageContext a
+ Data.IMF.Syntax: (<<>>) :: (Semigroup m, Applicative f) => f m -> f m -> f m
+ Data.IMF.Syntax: char :: CharParsing f s a => Char -> f s a
+ Data.IMF.Syntax: ci :: FoldCase s => Parser s -> Parser (CI s)
+ Data.IMF.Syntax: class IsChar a => CharParsing f s a | s -> a, a -> f s
+ Data.IMF.Syntax: class IsChar a
+ Data.IMF.Syntax: crlf :: Alternative (f s) => CharParsing f s a => f s ()
+ Data.IMF.Syntax: data CI s
+ Data.IMF.Syntax: domainLiteral :: (Alternative (f s), CharParsing f s a, SM s) => f s s
+ Data.IMF.Syntax: dotAtom :: (Alternative (f s), CharParsing f s a, SM s) => f s (NonEmpty s)
+ Data.IMF.Syntax: dotAtomText :: (Alternative (f s), CharParsing f s a) => f s (NonEmpty s)
+ Data.IMF.Syntax: foldMany :: (Monoid m, Alternative f) => f m -> f m
+ Data.IMF.Syntax: foldMany1 :: (Semigroup m, Alternative f) => f m -> f m
+ Data.IMF.Syntax: foldMany1Sep :: (Semigroup m, Alternative f) => m -> f m -> f m
+ Data.IMF.Syntax: fromChar :: IsChar a => Char -> a
+ Data.IMF.Syntax: fws :: (Alternative (f s), CharParsing f s a) => f s s
+ Data.IMF.Syntax: instance Data.IMF.Syntax.CharParsing Data.Attoparsec.Internal.Types.Parser Data.ByteString.Internal.ByteString GHC.Word.Word8
+ Data.IMF.Syntax: instance Data.IMF.Syntax.CharParsing Data.Attoparsec.Internal.Types.Parser Data.Text.Internal.Text GHC.Types.Char
+ Data.IMF.Syntax: instance Data.IMF.Syntax.IsChar GHC.Types.Char
+ Data.IMF.Syntax: instance Data.IMF.Syntax.IsChar GHC.Word.Word8
+ Data.IMF.Syntax: isAtext :: IsChar c => c -> Bool
+ Data.IMF.Syntax: isQtext :: IsChar c => c -> Bool
+ Data.IMF.Syntax: isVchar :: IsChar c => c -> Bool
+ Data.IMF.Syntax: isWsp :: IsChar c => c -> Bool
+ Data.IMF.Syntax: localPart :: (Alternative (f s), CharParsing f s a, SM s) => f s s
+ Data.IMF.Syntax: mk :: FoldCase s => s -> CI s
+ Data.IMF.Syntax: optionalCFWS :: (Alternative (f s), CharParsing f s a, SM s) => f s s
+ Data.IMF.Syntax: optionalFWS :: (Alternative (f s), CharParsing f s a, Monoid s) => f s s
+ Data.IMF.Syntax: original :: CI s -> s
+ Data.IMF.Syntax: quotedString :: (Alternative (f s), CharParsing f s a, SM s) => f s s
+ Data.IMF.Syntax: satisfy :: CharParsing f s a => (Char -> Bool) -> f s a
+ Data.IMF.Syntax: singleton :: CharParsing f s a => Char -> s
+ Data.IMF.Syntax: skipTill :: Parser a -> Parser ()
+ Data.IMF.Syntax: skipTillString :: ByteString -> Parser ()
+ Data.IMF.Syntax: takeTill' :: Parser a -> Parser ByteString
+ Data.IMF.Syntax: takeTillString :: ByteString -> Parser ByteString
+ Data.IMF.Syntax: takeWhile :: CharParsing f s a => (Char -> Bool) -> f s s
+ Data.IMF.Syntax: takeWhile1 :: CharParsing f s a => (Char -> Bool) -> f s s
+ Data.IMF.Syntax: toChar :: IsChar a => a -> Char
+ Data.IMF.Syntax: type SM a = Monoid a
+ Data.IMF.Syntax: vchar :: CharParsing f s a => f s a
+ Data.IMF.Syntax: word :: (Alternative (f s), CharParsing f s a, SM s) => f s s
+ Data.IMF.Syntax: wsp :: CharParsing f s a => f s a
+ Data.IMF.Text: address :: Parser Address
+ Data.IMF.Text: addressList :: Parser [Address]
+ Data.IMF.Text: mailbox :: Parser Mailbox
+ Data.IMF.Text: mailboxList :: Parser [Mailbox]
+ Data.IMF.Text: readMailbox :: String -> Either String Mailbox
+ Data.IMF.Text: renderAddress :: Address -> Text
+ Data.IMF.Text: renderAddressSpec :: AddrSpec -> Text
+ Data.IMF.Text: renderAddresses :: [Address] -> Text
+ Data.IMF.Text: renderMailbox :: Mailbox -> Text
+ Data.IMF.Text: renderMailboxes :: [Mailbox] -> Text
+ Data.MIME: Alternative :: MultipartSubtype
+ Data.MIME: Digest :: MultipartSubtype
+ Data.MIME: Encrypted :: ByteString -> MultipartSubtype
+ Data.MIME: Mixed :: MultipartSubtype
+ Data.MIME: Multilingual :: MultipartSubtype
+ Data.MIME: Parallel :: MultipartSubtype
+ Data.MIME: Related :: ContentType -> Maybe ByteString -> Maybe ByteString -> MultipartSubtype
+ Data.MIME: Report :: ByteString -> MultipartSubtype
+ Data.MIME: Signed :: ByteString -> ByteString -> MultipartSubtype
+ Data.MIME: Unrecognised :: CI ByteString -> MultipartSubtype
+ Data.MIME: data Boundary
+ Data.MIME: data MultipartSubtype
+ Data.MIME: instance Data.IMF.EqMessage Data.MIME.MIME
+ Data.MIME: instance Data.IMF.RenderMessage Data.MIME.MIME
+ Data.MIME: instance GHC.Classes.Eq Data.MIME.MultipartSubtype
+ Data.MIME: instance GHC.Show.Show Data.MIME.MultipartSubtype
+ Data.MIME: makeBoundary :: ByteString -> Either ByteString Boundary
+ Data.MIME: setTextPlainBody :: Text -> Message ctx a -> MIMEMessage
+ Data.MIME: unBoundary :: Boundary -> ByteString
- Data.MIME: Multipart :: NonEmpty MIMEMessage -> MIME
+ Data.MIME: Multipart :: MultipartSubtype -> Boundary -> NonEmpty MIMEMessage -> MIME
- Data.MIME: contentTypeMultipartMixed :: ByteString -> ContentType
+ Data.MIME: contentTypeMultipartMixed :: Boundary -> ContentType
- Data.MIME: createMultipartMixedMessage :: ByteString -> NonEmpty MIMEMessage -> MIMEMessage
+ Data.MIME: createMultipartMixedMessage :: Boundary -> NonEmpty MIMEMessage -> MIMEMessage
- Data.RFC5322.Address.Types: DomainDotAtom :: NonEmpty ByteString -> Domain
+ Data.RFC5322.Address.Types: DomainDotAtom :: NonEmpty (CI ByteString) -> Domain

Files

README.rst view
@@ -1,1 +1,2 @@-Email handling library.  RFC 5322, MIME and more.+Email handling library.  RFC 5322, MIME and more.  See+`purebred-email.cabal` and the Haddock documentation for details.
purebred-email.cabal view
@@ -1,29 +1,34 @@ cabal-version:       2.2 name:                purebred-email-version:             0.4.3+version:             0.5 synopsis:            types and parser for email messages (including MIME) description:   The purebred email library.  RFC 5322, MIME, etc.-  .-  Things that are currently implemented:+  See "Data.MIME" for usage, examples and API documentation.   .-  - RFC 5322 message parsing and serialisation-  - MIME multipart parsing (RFC 2046) and serialisation-  - MIME message header extensions for non-ASCII text (RFC 2047)-  - MIME parameter value and encoded word extensions (RFC 2231)-  - Content-Disposition header field (RFC 2183)-  - Message encapsulation (forward/bounce)+  This is a general-purpose library for processing and constructing+  email messages, originally written to meet the needs of+  <https://github.com/purebred-mua/purebred purebred MUA>.+  Transmission and delivery of mail are not part of this library,+  but /purebred-email/ could be a useful building block for such+  systems.   .-  Things that are not yet implemented / need improvement:+  Features and implemented specifications include:   .-  - Improve handling of dates-  - Probably lots of other things+  - <https://tools.ietf.org/html/rfc5322 RFC 5322> message parsing and serialisation+  - MIME multipart messages (<https://tools.ietf.org/html/rfc2046 RFC 2046>)+  - Convenient APIs for replying and forward/bounce+  - Content transfer and charset decoding/encoding+  - MIME message header extensions for non-ASCII text (<https://tools.ietf.org/html/rfc2047 RFC 2047>)+  - 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>)  license:             AGPL-3.0-or-later license-file:        LICENSE author:              Fraser Tweedale maintainer:          frase@frase.id.au-copyright:           Copyright 2017-2019  Fraser Tweedale+copyright:           Copyright 2017-2021  Fraser Tweedale category:            Data, Email build-type:          Simple extra-source-files:@@ -31,7 +36,7 @@   test-vectors/*.eml   tests/golden/*.golden tested-with:-  GHC ==8.4.4 || ==8.6.5 || ==8.8.4 || ==8.10.4 || ==9.0.1+  GHC ==8.8.4 || ==8.10.4 || ==9.0.1  homepage:            https://github.com/purebred-mua/purebred-email bug-reports:         https://github.com/purebred-mua/purebred-email/issues@@ -55,12 +60,15 @@     , lens >= 4 && < 6     , semigroups >= 0.16     , text >= 1.2-    , time >= 1.8+    , time >= 1.9  library   import: common   exposed-modules:-    Data.RFC5322+    Data.IMF+    , Data.IMF.Syntax+    , Data.IMF.Text+    , Data.RFC5322     , Data.RFC5322.Address.Text     , Data.RFC5322.Address.Types     , Data.RFC5322.Internal@@ -74,13 +82,15 @@     , Data.MIME.Base64     , Data.MIME.QuotedPrintable   other-modules:-    Data.MIME.Internal-  -- other-extensions:    +    Data.MIME.Boundary+    , Data.MIME.Internal+    , 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 && < 6     , stringsearch >= 0.3   hs-source-dirs:      src@@ -101,6 +111,7 @@     , Message   build-depends:     , purebred-email+    , random     , tasty     , tasty-hedgehog     , tasty-quickcheck
+ src/Data/IMF.hs view
@@ -0,0 +1,950 @@+-- This file is part of purebred-email+-- Copyright (C) 2017-2021  Fraser Tweedale and Róman Joost+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE TypeFamilies #-}++{- |++Internet Message Format (IMF), which is used for electronic mail+(email), as specified by+<https://tools.ietf.org/html/rfc5322 RFC 5322> and updated by+<https://tools.ietf.org/html/rfc6854 RFC 6854>.++The parser allows LF line endings in addition to CRLF.  RFC 5322+specifies CRLF line endings but LF-only is common in on-disk+formats.  Serialisation functions produces CRLF line endings.++The main parsing function is 'message'.  It takes a second function+that can inspect the headers to determine how to parse the body.++@+'message' :: ('Headers' -> 'BodyHandler' a) -> Parser ('Message' ctx a)+@++The 'Message' type is parameterised over the body type, and a+phantom type that can be used for context.++@+data 'Message' ctx a = 'Message' 'Headers' a+@++Headers and body can be accessed via the 'headers', 'header' and+'body' optics.++@+'headers' :: 'HasHeaders' a => Lens'       a         Headers+headers ::                 Lens' ('Message' ctx b) Headers++'header' :: 'HasHeaders' a => CI B.ByteString -> Traversal'        a        B.ByteString+header ::                 CI B.ByteString -> Traversal' ('Message' ctx b) B.ByteString+header ::                 CI B.ByteString -> Traversal'     'Headers'     B.ByteString++'body' :: Lens ('Message' ctx a) (Message ctx' b) a b+@++The following example program parses an input, interpreting the body+as a raw @ByteString@, and prints the subject (if present), the+number of headers and the body length.  The message context type is+@()@.++@+analyse :: B.ByteString -> IO ()+analyse input =+  case 'parse' ('message' (const takeByteString)) of+    Left errMsg -> hPutStrLn stderr errMsg *> exitFailure+    Right (msg :: Message () B.ByteString) -> do+      T.putStrLn $ "subject: " <> foldOf ('headerSubject' 'defaultCharsets') msg+      putStrLn $ "num headers: " <> show (length (view 'headers' msg))+      putStrLn $ "body length: " <> show (B.length (view 'body' msg))+@++-}+module Data.IMF+  (+  -- * Message types+    Message(..)+  , message+  , MessageContext+  , BodyHandler(..)+  , body+  , EqMessage(..)++  -- * Replying+  , reply+  , ReplySettings(ReplySettings)+  , defaultReplySettings+  , ReplyMode(..)+  , ReplyFromMode(..)+  , ReplyFromRewriteMode(..)+  , SelfInRecipientsMode(..)+  , AuthorMailboxes+  , replyMode+  , replyFromMode+  , replyFromRewriteMode+  , selfInRecipientsMode+  , authorMailboxes++  -- * Headers+  , Header+  , HasHeaders(..)+  , headerList+  , Headers(..)++  -- ** Date and Time+  , headerDate+  , dateTime++  -- ** Originator+  , headerFrom+  , headerReplyTo++  -- ** Destination Address+  , headerTo+  , headerCC+  , headerBCC++  -- ** Identification+  , headerMessageID+  , headerInReplyTo+  , headerReferences++  -- ** Informational+  , headerSubject++  -- ** Arbitrary headers+  , header+  , headerText++  -- * Types++  -- ** Message ID+  , MessageID+  , parseMessageID+  , buildMessageID+  , renderMessageID++  -- ** Address types+  , Address(..)+  , address+  , addressList+  , AddrSpec(..)+  , Domain(..)+  , Mailbox(..)+  , mailbox+  , mailboxList++  -- * Parsers+  , parse+  , parsed+  , parsePrint+  , crlf+  , quotedString++  -- * Helpers+  , field++  -- * Serialisation+  , buildMessage+  , renderMessage+  , RenderMessage(..)+  , renderRFC5322Date+  , buildFields+  , buildField+  , renderAddressSpec+  , renderMailbox+  , renderMailboxes+  , renderAddress+  , renderAddresses+  ) where++import Control.Applicative+import Data.Either (fromRight)+import Data.Foldable (fold, toList)+import Data.Function (on)+import Data.List (find, findIndex, intersperse)+import Data.List.NonEmpty (NonEmpty, head, intersperse)+import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)+import Data.Monoid (First(..))+import Data.String (IsString(..))+import Data.Word (Word8)+import GHC.Generics (Generic)++import Control.DeepSeq (NFData)+import Control.Lens+import Control.Lens.Cons.Extras (recons)+import Data.Attoparsec.ByteString as A hiding (parse, take)+import Data.Attoparsec.ByteString.Char8 (char8)+import qualified Data.Attoparsec.ByteString.Lazy as AL+import qualified Data.ByteString as B+import qualified Data.ByteString.Lazy as L+import qualified Data.ByteString.Char8 as Char8+import qualified Data.ByteString.Builder as Builder+import qualified Data.ByteString.Builder.Prim as Prim+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import Data.Time (ZonedTime, defaultTimeLocale, formatTime)++import Data.IMF.Syntax+  ( CI, ci, mk, original+  , (<<>>), foldMany, foldMany1Sep+  , char, fromChar, isAtext, isQtext, isVchar, isWsp+  , optionalCFWS, word, wsp, vchar, optionalFWS, crlf+  , domainLiteral, dotAtom, dotAtomText, localPart, quotedString+  )+import {-# SOURCE #-} Data.IMF.Text (readMailbox)+import Data.IMF.DateTime (dateTime)+import Data.MIME.Charset+import Data.MIME.EncodedWord+import Data.MIME.TransferEncoding (transferEncode)++type Header = (CI B.ByteString, B.ByteString)+newtype Headers = Headers [Header]+  deriving (Eq, Show, Generic, NFData)++class HasHeaders a where+  headers :: Lens' a Headers++instance HasHeaders Headers where+  headers = id++type instance Index Headers = CI B.ByteString+type instance IxValue Headers = B.ByteString++instance Ixed Headers where+  ix = header++hdriso :: Iso' Headers [(CI B.ByteString, B.ByteString)]+hdriso = iso (\(Headers xs) -> xs) Headers++-- | Acts upon the first occurrence of the header only.+--+instance At Headers where+  at k = hdriso . l+    where+    l :: Lens' [(CI B.ByteString, B.ByteString)] (Maybe B.ByteString)+    l f kv =+      let+        i = findIndex ((== k) . fst) kv+        g Nothing = maybe kv (\j -> take j kv <> drop (j + 1) kv) i+        g (Just v) = maybe ((k,v):kv) (\j -> set (ix j) (k,v) kv) i+      in+        g <$> f (lookup k kv)+++-- | Target all values of the given header+header :: HasHeaders a => CI B.ByteString -> Traversal' a B.ByteString+header k = headerList . traversed . filtered ((k ==) . fst) . _2++-- | Message type, parameterised over context and body type.  The+-- context type is not used in this module but is provided for uses+-- such as tracking the transfer/charset encoding state in MIME+-- messages.+--+data Message s a = Message Headers a+  deriving (Show, Generic, NFData)++instance HasHeaders (Message s a) where+  headers f (Message h b) = fmap (`Message` b) (f h)++instance Functor (Message s) where+  fmap f (Message h a) = Message h (f a)++-- | How to compare messages with this body type.+--+-- This class arises because we may want to tweak the headers,+-- possibly in response to body data, or vice-versa, when+-- comparing messages.+--+-- The default implementation compares headers and body using (==).+--+class EqMessage a where+  eqMessage :: Message s a -> Message s a -> Bool++  default eqMessage :: (Eq a) => Message s a -> Message s a -> Bool+  eqMessage (Message h1 b1) (Message h2 b2) = h1 == h2 && b1 == b2+++instance EqMessage a => Eq (Message s a) where+  (==) = eqMessage++-- | Access headers as a list of key/value pairs.+headerList :: HasHeaders a => Lens' a [(CI B.ByteString, B.ByteString)]+headerList = headers . coerced++body :: Lens (Message ctx a) (Message ctx' b) a b+body f (Message h b) = fmap (\b' -> Message h b') (f b)+{-# ANN body ("HLint: ignore Avoid lambda" :: String) #-}+++-- §3.3  Date and Time Specification+-- Sat, 29 Sep 2018 12:51:05 +1000+rfc5322DateTimeFormat :: String+rfc5322DateTimeFormat = "%a, %d %b %Y %T %z"++renderRFC5322Date :: ZonedTime -> B.ByteString+renderRFC5322Date = Char8.pack . formatTime defaultTimeLocale rfc5322DateTimeFormat++headerDate :: HasHeaders a => Lens' a (Maybe ZonedTime)+headerDate = headers . at "Date" . iso (>>= p) (fmap renderRFC5322Date)+  where+  p = either (const Nothing) Just . parseOnly (dateTime <* endOfInput)++-- §3.4 Address Specification+buildMailbox :: Mailbox -> Builder.Builder+buildMailbox (Mailbox n a) =+  maybe a' (\n' -> buildPhrase n' <> " <" <> a' <> ">") n+  where+    a' = buildAddressSpec a++-- Encode a phrase.+--+-- * Empty string is special case; must be in quotes+-- * If valid as an atom, use as-is (ideally, but we don't do this yet)+-- * If it can be in a quoted-string, do so.+-- * Otherwise make it an encoded-word+--+buildPhrase :: T.Text -> Builder.Builder+buildPhrase "" = "\"\""+buildPhrase s =+  case enc s of+    PhraseAtom -> T.encodeUtf8Builder s+    PhraseQuotedString -> qsBuilder False+    PhraseQuotedStringEscapeSpace -> qsBuilder True+    PhraseEncodedWord -> buildEncodedWord . transferEncode . charsetEncode $ s+  where+    enc = snd . T.foldr (\c (prev, req) -> (c, encChar prev c <> req)) ('\0', mempty)+    encChar prev c+      | isAtext c = PhraseAtom+      | isQtext c = PhraseQuotedString+      | isVchar c = PhraseQuotedString+      | c == ' ' =+          if prev == ' '  -- two spaces in a row; need to avoid FWS+          then PhraseQuotedStringEscapeSpace+          else PhraseQuotedString+      | otherwise = PhraseEncodedWord++    qsBuilder escSpace = "\"" <> T.encodeUtf8BuilderEscaped (escPrim escSpace) s <> "\""+    escPrim escSpace = Prim.condB (\c -> isQtext c || not escSpace && c == 32)+      (Prim.liftFixedToBounded Prim.word8)+      (Prim.liftFixedToBounded $ (fromChar '\\',) Prim.>$< Prim.word8 Prim.>*< Prim.word8)++-- | Data type used to compute escaping requirement of a Text 'phrase'+--+data PhraseEscapeRequirement+  = PhraseAtom+  | PhraseQuotedString+  | PhraseQuotedStringEscapeSpace+  | PhraseEncodedWord+  deriving (Eq, Ord)++instance Semigroup PhraseEscapeRequirement where+  PhraseEncodedWord <> _ =+    -- allows early termination of folds+    PhraseEncodedWord+  l <> r = max l r++instance Monoid PhraseEscapeRequirement where+  mempty = PhraseAtom++++renderMailboxes :: [Mailbox] -> B.ByteString+renderMailboxes = L.toStrict . Builder.toLazyByteString . buildMailboxes++buildMailboxes :: [Mailbox] -> Builder.Builder+buildMailboxes = fold . Data.List.intersperse ", " . fmap buildMailbox++renderMailbox :: Mailbox -> B.ByteString+renderMailbox = L.toStrict . Builder.toLazyByteString . buildMailbox++mailbox :: CharsetLookup -> Parser Mailbox+mailbox charsets =+  Mailbox <$> optional (displayName charsets) <*> angleAddr+  <|> Mailbox Nothing <$> addressSpec++phrase :: CharsetLookup -> Parser T.Text+phrase charsets = foldMany1Sep " " $+  fmap (decodeEncodedWord charsets) ("=?" *> encodedWord)+  <|> fmap decodeLenient word++displayName :: CharsetLookup -> Parser T.Text+displayName = phrase++angleAddr :: Parser AddrSpec+angleAddr = optionalCFWS *>+  char8 '<' *> addressSpec <* char8 '>'+  <* optionalCFWS++buildAddressSpec :: AddrSpec -> Builder.Builder+buildAddressSpec (AddrSpec lp (DomainDotAtom b))+  | " " `B.isInfixOf` lp = "\"" <> buildLP <> "\"" <> rest+  | otherwise = buildLP <> rest+  where+    buildLP = Builder.byteString lp+    rest = "@" <> foldMap (Builder.byteString . original)+                          (Data.List.NonEmpty.intersperse "." b)+buildAddressSpec (AddrSpec lp (DomainLiteral b)) =+  foldMap Builder.byteString [lp, "@", b]++renderAddressSpec :: AddrSpec -> B.ByteString+renderAddressSpec = L.toStrict . Builder.toLazyByteString . buildAddressSpec++addressSpec :: Parser AddrSpec+addressSpec = AddrSpec <$> localPart <*> (char8 '@' *> domain)++-- | Printable US-ASCII excl "[", "]", or "\"+isDtext :: Word8 -> Bool+isDtext c = (c >= 33 && c <= 90) || (c >= 94 && c <= 126)++domain :: Parser Domain+domain = (DomainDotAtom . fmap mk <$> dotAtom)+         <|> (DomainLiteral <$> domainLiteral)++mailboxList :: CharsetLookup -> Parser [Mailbox]+mailboxList charsets = mailbox charsets `sepBy` char8 ','++renderAddresses :: [Address] -> B.ByteString+renderAddresses xs = B.intercalate ", " $ renderAddress <$> xs++renderAddress :: Address -> B.ByteString+renderAddress (Single m) = renderMailbox m+renderAddress (Group name xs) = T.encodeUtf8 name <> ":" <> renderMailboxes xs <> ";"++addressList :: CharsetLookup -> Parser [Address]+addressList charsets = address charsets `sepBy` char8 ','++group :: CharsetLookup -> Parser Address+group charsets =+  Group <$> displayName charsets <* char8 ':'+        <*> mailboxList charsets <* char8 ';' <* optionalCFWS++address :: CharsetLookup -> Parser Address+address charsets =+  group charsets <|> Single <$> mailbox charsets++-- | Map a single-occurrence header to a list value.+-- On read, absent header is mapped to empty list.+-- On write, empty list results in absent header.+--+headerSingleToList+  :: (HasHeaders s)+  => (B.ByteString -> [a])+  -> ([a] -> B.ByteString)+  -> CI B.ByteString+  -> Lens' s [a]+headerSingleToList f g k =+  headers . at k . iso (maybe [] f) (\l -> if null l then Nothing else Just (g l))++-- | Map a single-occurrence header to a Maybe value.+-- On read, absent header or parse failure maps to Nothing.+-- On write, Nothing results in absent header.+headerSingleToMaybe+  :: (HasHeaders s)+  => (B.ByteString -> Maybe a)+  -> (a -> B.ByteString)+  -> CI B.ByteString+  -> Lens' s (Maybe a)+headerSingleToMaybe f g k = headers . at k . iso (>>= f) (fmap g)++headerAddressList :: (HasHeaders a) => CI B.ByteString -> CharsetLookup -> Lens' a [Address]+headerAddressList k charsets = headerSingleToList+  (fromRight [] . parseOnly (addressList charsets))+  renderAddresses+  k++headerFrom, headerReplyTo, headerTo, headerCC, headerBCC+  :: (HasHeaders a)+  => CharsetLookup -> Lens' a [Address]+headerFrom = headerAddressList "From"+headerReplyTo = headerAddressList "Reply-To"+headerTo = headerAddressList "To"+headerCC = headerAddressList "Cc"+headerBCC = headerAddressList "Bcc"++data MessageID = MessageID+  (NonEmpty B.ByteString)+  (Either (NonEmpty B.ByteString) B.ByteString)+  deriving (Eq, Ord)++instance Show MessageID where+  show = Char8.unpack . renderMessageID++parseMessageID :: Parser MessageID+parseMessageID =+  MessageID+    <$> (optionalCFWS *> char '<'  *> idLeft) <* char '@'+    <*> idRight <* char '>' <* optionalCFWS+  where+  idLeft = dotAtomText+  idRight = Left <$> dotAtomText <|> Right <$> noFoldLiteral+  noFoldLiteral = char '[' *> A.takeWhile1 isDtext <* char ']'++buildMessageID :: MessageID -> Builder.Builder+buildMessageID (MessageID l r) =+  "<" <> buildDotAtom l <> "@" <> either buildDotAtom buildNoFoldLit r <> ">"+  where+  buildDotAtom =+    fold . Data.List.NonEmpty.intersperse "." . fmap Builder.byteString+  buildNoFoldLit s =+    "[" <> Builder.byteString s <> "]"++renderMessageID :: MessageID -> B.ByteString+renderMessageID = L.toStrict . Builder.toLazyByteString . buildMessageID++headerMessageID :: (HasHeaders a) => Lens' a (Maybe MessageID)+headerMessageID = headerSingleToMaybe+  (either (const Nothing) Just . parseOnly (parseMessageID <* endOfInput))+  renderMessageID+  "Message-ID"++headerMessageIDList :: (HasHeaders a) => CI B.ByteString -> Lens' a [MessageID]+headerMessageIDList = headerSingleToList+  (fromRight [] . parseOnly (many parseMessageID <* endOfInput))+  ( L.toStrict . Builder.toLazyByteString+    . fold . Data.List.intersperse " " . fmap buildMessageID )++headerInReplyTo, headerReferences :: (HasHeaders a) => Lens' a [MessageID]+headerInReplyTo = headerMessageIDList "In-Reply-To"+headerReferences = headerMessageIDList "References"+++-- | Single-valued header with @Text@ value via encoded-words.+-- The conversion to/from Text is total (encoded-words that failed to be+-- decoded are passed through unchanged).  Therefore @Nothing@ means that+-- the header was not present.+--+headerText :: (HasHeaders a) => CharsetLookup -> CI B.ByteString -> Lens' a (Maybe T.Text)+headerText charsets k =+  headers . at k . iso (fmap (decodeEncodedWords charsets)) (fmap encodeEncodedWords)++-- | Subject header.  See 'headerText' for details of conversion to @Text@.+headerSubject :: (HasHeaders a) => CharsetLookup -> Lens' a (Maybe T.Text)+headerSubject charsets = headerText charsets "Subject"+++{- Replying -}++-- | Specify how to choose recipients when replying.+--+-- __TODO__: "list reply" mode+--+data ReplyMode+  = ReplyToSender+  -- ^ Reply to the sender of the email only, or @Reply-To@ header if set.+  | ReplyToGroup+  -- ^ Reply to sender and @Cc@ all other recipients of the original message.++-- | The mailboxes of the entity authoring the reply.+-- The first mailbox is the "preferred" mailbox.+type AuthorMailboxes = NonEmpty Mailbox++-- | How to choose the @From@ address.+data ReplyFromMode+  = ReplyFromPreferredMailbox+  -- ^ Always reply @From@ the preferred mailbox+  | ReplyFromMatchingMailbox+  -- ^ Reply from whichever author mailbox is a recipient of the+  -- parent message, or the preferred mailbox if none of the author+  -- mailboxes is a visible recipient of the parent message.++-- | Whether to use the @From@ address as it appears in the parent+-- message, or as it appears in the 'AuthorMailboxes'.+--+data ReplyFromRewriteMode+  = ReplyFromRewriteOff+  -- ^ Use the @From@ mailbox as it appears in the original message.+  | ReplyFromRewriteOn+  -- ^ Use the @From@ mailbox as it appears in the author mailboxes.++data SelfInRecipientsMode+  = SelfInRecipientsRemove+  -- ^ Remove author mailbox from list of recipients when replying.+  | SelfInRecipientsIgnore+  -- ^ If author mailbox appears in list of recipients, leave it there.++-- | All the settings to control how to construct a reply to a message.+data ReplySettings = ReplySettings+  { _replyMode            :: ReplyMode+  , _replyFromMode        :: ReplyFromMode+  , _replyFromRewriteMode :: ReplyFromRewriteMode+  , _selfInRecipientsMode :: SelfInRecipientsMode+  , _authorMailboxes      :: AuthorMailboxes+  }++-- | Given author mailboxes, get a default 'ReplySettings'.  The default+-- settings are: 'ReplyToSender', 'ReplyFromMatchingMailbox',+-- 'ReplyFromRewriteOn', and 'SelfInRecipientsRemove'.+--+defaultReplySettings :: AuthorMailboxes -> ReplySettings+defaultReplySettings = ReplySettings+  ReplyToSender+  ReplyFromMatchingMailbox+  ReplyFromRewriteOn+  SelfInRecipientsRemove++replyMode :: Lens' ReplySettings ReplyMode+replyMode = lens _replyMode (\s a -> s { _replyMode = a })++replyFromMode :: Lens' ReplySettings ReplyFromMode+replyFromMode = lens _replyFromMode (\s a -> s { _replyFromMode = a })++replyFromRewriteMode :: Lens' ReplySettings ReplyFromRewriteMode+replyFromRewriteMode =+  lens _replyFromRewriteMode (\s a -> s { _replyFromRewriteMode = a })++selfInRecipientsMode :: Lens' ReplySettings SelfInRecipientsMode+selfInRecipientsMode =+  lens _selfInRecipientsMode (\s a -> s { _selfInRecipientsMode = a })++authorMailboxes :: Lens' ReplySettings AuthorMailboxes+authorMailboxes = lens _authorMailboxes (\s a -> s { _authorMailboxes = a })+++replyRecipients+  :: CharsetLookup -> ReplySettings -> Message ctx a -> ([Address], [Address])+replyRecipients charsets settings msg =+  let+    mode = view replyMode settings+    rt = view (headerReplyTo charsets) msg+    f = view (headerFrom charsets) msg+    t = view (headerTo charsets) msg+    c = view (headerCC charsets) msg+  in case mode of+    ReplyToSender+      | not (null rt) -> (rt, [])+      | otherwise     -> (f, [])+    ReplyToGroup+      | length (t <> c) <= 1+      -> replyRecipients charsets (set replyMode ReplyToSender settings) msg+      | otherwise+      -> (f, t <> c)++replyReferences :: Message ctx a -> [MessageID]+replyReferences msg+  | null refer, length inRep == 1 = inRep <> msgId+  | otherwise                     = refer <> msgId+  where+  msgId = toList $ view headerMessageID msg+  refer = view headerReferences msg+  inRep = view headerInReplyTo msg++replySubject :: CharsetLookup -> Message ctx a -> T.Text+replySubject charsets msg = if prefixed then orig else "Re: " <> orig+  where+  orig = fold $ view (headerSubject charsets) msg+  prefixed = mk (T.take 3 orig) == "Re:"+++-- | Construct a reply to a 'Message', according to the specified+-- 'ReplySettings' and following the requirements and suggestions of+-- RFC 5322.  In particular:+--+-- * Sets @In-Reply-To@ to the @Message-ID@ of the parent message.+--+-- * Sets the @References@ header, following the requirements in RFC+-- 5322 §3.6.4.+--+-- * Sets the @Subject@ by prepending @"Re: "@ to the parent+-- subject, unless it already has such a prefix (case-insensitive+-- match).  This is the scheme suggested in RFC 5322 §3.6.5.+--+-- * Sets the @From@ header.  If the 'ReplyFromMode' is+-- 'ReplyFromMatchingMailbox' and one of the 'authorMailboxes' is a+-- recipient of the parent message, that address will be used as the+-- @From@ address.  Also, if 'ReplyFromRewriteMode' is+-- 'ReplyFromRewriteOn', the matching value in 'authorMailboxes'+-- replaces the value from the parent message.  This can be used to+-- rewrite a bare address to one with a display name (or+-- vice-versa).  In all other cases the @From@ address will be the+-- /preferred/ (first) author mailbox.+--+-- * Sets @To@ and @Cc@ according to 'ReplyMode' and+-- 'SelfInRecipientsMode'.  These headers are described in RFC 5322+-- §3.6.3.+--+--     * In 'ReplyToSender' mode, the @To@ header of the reply will+--     contain the addresses from the @Reply-To@ header if it is+--     present, otherwise it will contain the addresses from the+--     @From@ header.+--+--     * In 'ReplyToGroup' mode, if the parent message has only one+--     recipient (across the @To@ and @Cc@ headers), the behaviour+--     is the same as 'ReplyToSender' mode (@Reply-To@ is respected).+--     If the parent message has multiple recipients, the+--     @Reply-To@ header is ignored, the @To@ header of the reply+--     will contain the addresses from the @From@ header, and the+--     @Cc@ header of the reply will contain the addresses from the+--     @To@ and @Cc@ headers.+--+--     * If the 'SelfInRecipientsMode' is 'SelfInRecipientsRemove',+--     any of the 'authorMailboxes' will be removed from the @To@+--     and @Cc@ headers.+--+reply+  :: CharsetLookup+  -> ReplySettings+  -> Message ctx a+  -> Message ctx ()+reply charsets settings msg =+  let+    self = view authorMailboxes settings++    getAddrSpec :: Mailbox -> AddrSpec+    getAddrSpec (Mailbox _ addr) = addr++    -- | 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.+    findMatchingMailbox+      :: (Foldable t)+      => t Mailbox -> Address -> Maybe Mailbox+    findMatchingMailbox xs (Single addr) =+      f <$> find (on (==) getAddrSpec addr) xs+      where+        f = case view replyFromRewriteMode settings of+          ReplyFromRewriteOn  -> id+          ReplyFromRewriteOff -> const addr+    findMatchingMailbox _ _ = Nothing++    getSelf :: Address -> Maybe Mailbox+    getSelf = findMatchingMailbox self++    isSelf :: Address -> Bool+    isSelf = isJust . getSelf++    findSelf =+      let+        parentTo = view (headerTo charsets) msg+        parentCc = view (headerCC charsets) msg+      in+        getFirst $ foldMap (First . getSelf) (parentTo <> parentCc)++    filterSelf = case view selfInRecipientsMode settings of+      SelfInRecipientsRemove -> filter (not . isSelf)+      SelfInRecipientsIgnore -> id++    (t, c) = replyRecipients charsets settings msg+    _To = filterSelf t+    _To_mailboxes = mapMaybe (\case Single a -> Just a ; _ -> Nothing) _To+    _Cc = c+          & filterSelf+          & filter (isNothing . findMatchingMailbox _To_mailboxes)+    _From =+      let preferred = Data.List.NonEmpty.head self+      in+        case view replyFromMode settings of+          ReplyFromPreferredMailbox -> preferred+          ReplyFromMatchingMailbox  -> fromMaybe preferred findSelf++    hdrs = Headers []+      & set (headerFrom charsets) [Single _From]+      & set (headerTo charsets) (filterSelf _To)+      & set (headerCC charsets) (filterSelf _Cc)+      & set headerInReplyTo (toList $ view headerMessageID msg)+      & set headerReferences (replyReferences msg)+      & set (headerSubject charsets) (Just $ replySubject charsets msg)+  in+    Message hdrs ()+++-- §3.5.  Overall Message Syntax+++-- | Specify how to handle a message body, including the possibility+-- of optional bodies and no body (which is distinct from empty body).+data BodyHandler a+  = RequiredBody (Parser a)+  | OptionalBody (Parser a, a)+  -- ^ If body is present run parser, otherwise use constant value+  | NoBody a++-- | Parse a message.  The function argument receives the headers and+-- yields a handler for the message body.+--+message :: (Headers -> BodyHandler a) -> Parser (Message (MessageContext a) a)+message f = fields >>= \hdrs -> Message hdrs <$> case f hdrs of+  RequiredBody b -> crlf *> b+  OptionalBody (b, a) -> optional crlf >>= maybe (pure a) (const b)+  NoBody b -> pure b++type family MessageContext a+++fields :: Parser Headers+fields = Headers <$> many field++-- | Define how to render an RFC 5322 message with given payload type.+--+class RenderMessage a where+  -- | Build the body.  If there should be no body (as distinct from+  -- /empty body/) return Nothing+  buildBody :: Headers -> a -> Maybe Builder.Builder++  -- | Allows tweaking the headers before rendering.  Default+  -- implementation is a no-op.+  tweakHeaders :: a -> Headers -> Headers+  tweakHeaders _ = id++-- | Construct a 'Builder.Builder' for the message.  This allows efficient+-- streaming to IO handles.+--+buildMessage :: forall ctx a. (RenderMessage a) => Message ctx a -> Builder.Builder+buildMessage (Message h b) =+  buildFields (tweakHeaders b h)+  <> maybe mempty ("\r\n" <>) (buildBody h b)++-- | Render a message to a lazy 'L.ByteString'.  (You will probably not+-- need a strict @ByteString@ and it is inefficient for most use cases.)+--+renderMessage :: (RenderMessage a) => Message ctx a -> L.ByteString+renderMessage = Builder.toLazyByteString . buildMessage++-- Header serialisation+buildFields :: Headers -> Builder.Builder+buildFields = foldMapOf (hdriso . traversed) buildField++buildField :: (CI B.ByteString, B.ByteString) -> Builder.Builder+buildField (k,v) =+  let key = original k+  in+    Builder.byteString key+    <> ":"+    <> foldUnstructured v (B.length key + 1)+    <> "\r\n"++-- | Render a field body with proper folding+--+-- Folds on whitespace (and only whitespace).  Sequential whitespace+-- chars are folded.  That's OK because the grammar says it is+-- folding whitespace.+--+foldUnstructured :: B.ByteString -> Int -> Builder.Builder+foldUnstructured s i = case Char8.words s of+  [] -> mempty+  (h:t) ->+    -- Special case to prevent wrapping of first word;+    -- see 6dbc04fb1863e845699b1cef50f4edaf1326bdae for info.+    " " <> Builder.byteString h <> go t (i + 1 + B.length h)+  where+  limit = 76  -- could be 78, but this preserves old behaviour+  go [] _ = mempty+  go (chunk:chunks) col+    | col <= 1 || col + 1 + B.length chunk < limit =+        -- either there is room for the chunk, or we are at the+        -- beginning of a line so add it here anyway (otherwise+        -- we will add "\r\n" and recurse forever)+        " " <> Builder.byteString chunk <> go chunks (col + 1 + B.length chunk)+    | otherwise = "\r\n" <> go (chunk:chunks) 0  -- fold++-- | Printable ASCII excl. ':'+isFtext :: Word8 -> Bool+isFtext c = (c >= 33 && c <= 57) || (c >= 59 && c <= 126)++field :: Parser (CI B.ByteString, B.ByteString)+field = (,)+  <$> ci (takeWhile1 isFtext)+  <*  char8 ':' <* many wsp+  <*> unstructured <* crlf++unstructured :: Parser B.ByteString+unstructured =+  foldMany (optionalFWS <<>> (B.singleton <$> vchar))+  <<>> A.takeWhile isWsp+++-- | Given a parser, construct a 'Fold'+--+-- See 'parse' for discussion of performance.+--+parsed :: (Cons s s Word8 Word8) => Parser a -> Fold s a+parsed p = to (parse p) . folded+{-# INLINE parsed #-}++-- | Construct a prism from a parser and a printer+parsePrint :: Parser a -> (a -> B.ByteString) -> Prism' B.ByteString a+parsePrint fwd rev = prism' rev (AL.maybeResult . AL.parse fwd . view recons)++-- | Parse an @a@.+--+-- The input is convered to a /lazy/ @ByteString@.+-- Build with rewrite rules enabled (@-O@, cabal's default)+-- to achieve the following conversion overheads:+--+-- * Lazy @ByteString@: no conversion+-- * Strict @ByteString@: /O(1)/ conversion+-- * @[Word8]@: /O(n)/ conversion+--+-- It is __recommended to use strict bytestring__ input.  Parsing a+-- lazy bytestring will cause numerous parser buffer resizes.  The+-- lazy chunks in the input can be GC'd but the buffer keeps growing+-- so you don't actually keep the memory usage low by using a lazy+-- bytestring.+--+parse :: (Cons s s Word8 Word8) => Parser a -> s -> Either String a+parse p = AL.eitherResult . AL.parse p . view recons+{-# INLINE parse #-}+++-- | Email address with optional display name.+-- The @Eq@ instance compares the display name case+-- sensitively and the address as described at 'AddrSpec'.+--+data Mailbox =+    Mailbox (Maybe T.Text {- display name -})+             AddrSpec+    deriving (Show, Eq, Generic, NFData)++instance IsString Mailbox where+  fromString =+    either (error . mappend "Failed to parse Mailbox: ") id . readMailbox++-- | Email address.  The @Eq@ instances compares the local part+-- case sensitively, and the domain part as described at 'Domain'.+--+-- Address "detail" (section of local part after a @'+'@ character;+-- also called "extension" or "subaddress") is part of the local+-- part.  Therefore addresses that differ in this aspect, for+-- example @alice+bank\@example.com@ and @alice+spam\@example.com@,+-- are unequal.+--+data AddrSpec =+    AddrSpec B.ByteString {- local part -}+             Domain+    deriving (Show, Eq, Generic, NFData)++data Address+    = Single Mailbox+    | Group T.Text {- display name -}+            [Mailbox]+    deriving (Show, Eq, Generic, NFData)++-- | A DNS name or "domain literal" (address literal).+-- DNS names are compared case-insensitively.+data Domain+    = DomainDotAtom (NonEmpty (CI B.ByteString) {- printable ascii -})+    | DomainLiteral B.ByteString+    deriving (Show, Eq, Generic, NFData)
+ src/Data/IMF.hs-boot view
@@ -0,0 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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/>.++module Data.IMF where++data Mailbox
+ src/Data/IMF/DateTime.hs view
@@ -0,0 +1,175 @@+-- This file is part of purebred-email+-- Copyright (C) 2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 OverloadedStrings #-}++module Data.IMF.DateTime+  ( dateTime+  ) where++import Control.Applicative ((<|>), optional)+import Control.Monad (guard)+import Data.Functor (($>))++import Data.Attoparsec.ByteString as A+import Data.Attoparsec.ByteString.Char8 (char8, isDigit_w8)+import qualified Data.ByteString as B+import qualified Data.Time+import Data.Time+  ( Day, DayOfWeek(..), LocalTime(LocalTime), TimeOfDay, TimeZone(TimeZone)+  , ZonedTime(ZonedTime), fromGregorianValid, makeTimeOfDayValid+  , minutesToTimeZone, hoursToTimeZone, utc+  )+import Data.IMF.Syntax (fws, optionalCFWS, optionalFWS)++dateTime :: Parser ZonedTime+dateTime = do+  dow <- optional (dayOfWeek <* char8 ',')+  theDate <- date++  -- ensure day of week matches date+  case dow of+    Just dow' | Data.Time.dayOfWeek theDate /= dow'+      -> fail "day of week inconsistent with date"+    _ -> pure ()++  tod <- timeOfDay+  z <- zone+  _ <- optionalCFWS++  pure $ ZonedTime (LocalTime theDate tod) z+++dayOfWeek :: Parser DayOfWeek+dayOfWeek = optionalFWS *> dayName++dayName :: Parser DayOfWeek+dayName =+  string "Mon" $> Monday+  <|> string "Tue" $> Tuesday+  <|> string "Wed" $> Wednesday+  <|> string "Thu" $> Thursday+  <|> string "Fri" $> Friday+  <|> string "Sat" $> Saturday+  <|> string "Sun" $> Sunday+  <|> fail "invalid day-of-week"++date :: Parser Day+date = do+  d <- day+  m <- month+  y <- year+  case fromGregorianValid y m d of+    Just r -> pure r+    Nothing -> fail "invalid date"++day :: Parser Int+day = optionalFWS *> go <* fws+  where+  go = (twoDigit <|> digit) >>= check (\n -> n > 0 && n <= 31) "day out of range"++month :: Parser Int+month =+  string "Jan" $> 1+  <|> string "Feb" $> 2+  <|> string "Mar" $> 3+  <|> string "Apr" $> 4+  <|> string "May" $> 5+  <|> string "Jun" $> 6+  <|> string "Jul" $> 7+  <|> string "Aug" $> 8+  <|> string "Sep" $> 9+  <|> string "Oct" $> 10+  <|> string "Nov" $> 11+  <|> string "Dec" $> 12+  <|> fail "invalid month"++year :: Parser Integer+year = fws *> (go >>= check (>= 1900) "year cannot be < 1900") <* fws+  where+  go = fourOrMoreDigit <|> obsYear <|> fail "too few digits in year"+  fourOrMoreDigit = do+    digits <- A.takeWhile isDigit_w8+    guard (B.length digits >= 4)+    pure (B.foldl' step 0 digits)+  step r a = r * 10 + fromIntegral (a - 48)+  obsYear = do+    yy <- twoDigit+    fromIntegral+      . maybe (yy + if yy <= 49 then 2000 else 1900) (1900 + yy * 10 +)+      <$> optional digit++timeOfDay :: Parser TimeOfDay+timeOfDay = do+  hour <- twoDigit+  _ <- char8 ':'+  minute <- twoDigit+  second <- char8 ':' *> twoDigit <|> pure 0+  case makeTimeOfDayValid hour minute (fromIntegral second) of+    Nothing -> fail "invalid time-of-day"+    Just tod -> pure tod++zone :: Parser TimeZone+zone = fws *> (go <|> obsZone)+  where+  go = do+    posNeg <- char8 '+' $> id <|> char8 '-' $> negate+    h <- twoDigit+    m <- twoDigit+    guardFail (m <= 59) "zone minutes must be in range 0..59"+    pure $ minutesToTimeZone (posNeg (h * 60 + m))++obsZone :: Parser TimeZone+obsZone =+  utc <$ (string "GMT" <|> string "UT")+  <|> usZone+  <|> milZone+  where+  usZone = do+    (off, c1) <-+      charVal (-5) 'E'      -- eastern+      <|> charVal (-6) 'C'  -- central+      <|> charVal (-7) 'M'  -- mountain+      <|> charVal (-8) 'P'  -- pacific+    (dst, c2) <- charVal 0 'S' <|> charVal 1 'D'  -- standard / dst+    _ <- char8 'T'+    pure $ TimeZone ((off + dst) * 60) (dst == 1) (c1:c2:"T")+  charVal a c = (a, c) <$ char8 c+  milZone =+    utc <$ (char8 'Z' <|> char8 'z')+    <|> go     id 0x40 0x41 0x49  -- A..I+    <|> go     id 0x41 0x4b 0x4d  -- K..M+    <|> go negate 0x4d 0x4c 0x59  -- N..Y+    <|> go     id 0x60 0x61 0x69  -- a..i+    <|> go     id 0x61 0x6b 0x6d  -- k..m+    <|> go negate 0x6d 0x6e 0x79  -- n..y+  go f off lo hi =+    hoursToTimeZone . f . subtract off . fromIntegral+    <$> satisfy (\c -> c >= lo && c <= hi)+++guardFail :: Bool -> String -> Parser ()+guardFail True _ = pure ()+guardFail False s = fail s++check :: (a -> Bool) -> String -> a -> Parser a+check f s a = guardFail (f a) s $> a++digit :: Parser Int+digit = (\c -> fromIntegral (c - 48)) <$> satisfy isDigit_w8++twoDigit :: Parser Int+twoDigit = (\hi lo -> hi * 10 + lo) <$> digit <*> digit
+ src/Data/IMF/Syntax.hs view
@@ -0,0 +1,407 @@+-- This file is part of purebred-email+-- Copyright (C) 2017-2021  Fraser Tweedale and Róman Joost+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 ConstraintKinds #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++{- |++Parsers for low-level productions in the Internet Message Format.+These parsers are used throughout this library and may be useful+to other programs.++-}+module Data.IMF.Syntax+  (+  -- * Case-insensitive value parsers+    ci+  , CI+  , mk+  , original++  -- * Abstract character parsers+  , wsp+  , fws+  , optionalFWS+  , optionalCFWS+  , crlf+  , vchar+  , word+  , quotedString+  , dotAtomText+  , dotAtom+  , localPart+  , domainLiteral+  , IsChar(..)+  , char+  , CharParsing(..)+  , SM++  -- ** Helpers for building parsers+  , isAtext+  , isQtext+  , isVchar+  , isWsp++  -- * Semigroup and monoid folding combinators+  , (<<>>)+  , foldMany+  , foldMany1+  , foldMany1Sep++  -- * General parsers and combinators+  , skipTill+  , takeTill'++  -- * Efficient string search+  , skipTillString+  , takeTillString++  ) where++import Prelude hiding (takeWhile)+import Control.Applicative ((<|>), Alternative, liftA2, many, optional)+import Control.Monad (void)+import qualified Data.Attoparsec.ByteString as A+import qualified Data.Attoparsec.Internal as A+import qualified Data.Attoparsec.Internal.Types as AT+import qualified Data.Attoparsec.Text as AText+import qualified Data.ByteString as B+import Data.ByteString.Internal (c2w, w2c)+import Data.ByteString.Search (indices)+import Data.CaseInsensitive (CI, FoldCase, mk, original)+import Data.Char (chr)+import Data.Foldable (fold)+import Data.Functor (($>))+import Data.List.NonEmpty (NonEmpty, fromList, intersperse)+import Data.Semigroup.Foldable (fold1)+import qualified Data.Text as T+import Data.Word (Word8)+++-- | Constraint synonym to handle the Semigroup Monoid Proposal+-- transition gracefully.+#if MIN_VERSION_base(4,11,0)+type SM a = Monoid a+#else+type SM a = (Semigroup a, Monoid a)+#endif++class IsChar a where+  toChar :: a -> Char+  fromChar :: Char -> a++instance IsChar Char where+  toChar = id+  fromChar = id++instance IsChar Word8 where+  toChar = w2c+  fromChar = c2w++class IsChar a => CharParsing f s a | s -> a, a -> f s where+  singleton :: Char -> s+  satisfy :: (Char -> Bool) -> (f s) a+  takeWhile :: (Char -> Bool) -> (f s) s+  takeWhile1 :: (Char -> Bool) -> (f s) s++instance CharParsing AT.Parser B.ByteString Word8 where+  singleton = B.singleton . c2w+  satisfy f = A.satisfy (f . w2c)+  takeWhile f = A.takeWhile (f . w2c)+  takeWhile1 f = A.takeWhile1 (f . w2c)++instance CharParsing AT.Parser T.Text Char where+  singleton = T.singleton+  satisfy = AText.satisfy+  takeWhile = AText.takeWhile+  takeWhile1 = AText.takeWhile1++char :: CharParsing f s a => Char -> (f s) a+char c = satisfy (== c)++isWsp :: IsChar c => c -> Bool+isWsp = AText.inClass "\t " . toChar++wsp :: CharParsing f s a => (f s) a+wsp = satisfy isWsp++isVchar :: IsChar c => c -> Bool+isVchar c =+  let c' = toChar c+  in c' >= chr 0x21 && c' <= chr 0x7e++vchar :: CharParsing f s a => (f s) a+vchar = satisfy isVchar++dquote :: CharParsing f s a => (f s) a+dquote = char '"'++quotedPair :: (Alternative (f s)) => CharParsing f s a => (f s) a+quotedPair = char '\\' *> (vchar <|> wsp)++-- §3.2.4.  Quoted Strings++isQtext :: IsChar c => c -> Bool+isQtext c' =+  let c = toChar c'+  in+    c == chr 33+    || (c >= chr 35 && c <= chr 91)+    || (c >= chr 93 && c <= chr 126)++quotedString :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+quotedString =+  optionalCFWS *> dquote+  *> foldMany (optionalFWS <<>> qcontent) <<>> optionalFWS+  <* dquote <* optionalCFWS+  where+    qcontent =+      (singleton . toChar <$> satisfy isQtext)+      <|> (singleton . toChar <$> quotedPair)++isAtext :: IsChar c => c -> Bool+isAtext = AText.inClass "-A-Za-z0-9!#$%&'*+/=?^_`{|}~" . toChar++atext :: CharParsing f s a => (f s) a+atext = satisfy isAtext++-- | Either CRLF or LF (lots of mail programs transform CRLF to LF)+crlf :: (Alternative (f s)) => CharParsing f s a => (f s) ()+crlf = void ((char '\r' *> char '\n') <|> char '\n')++-- §3.2.2.  Folding White Space and Comments+--+-- "The general rule is that wherever this specification allows for folding+-- white space (not simply WSP characters), a CRLF may be inserted before any+-- WSP."++-- | Folding white space (FWS).  A run of one or more whitespace+-- characters.  Returns a single SPACE character.+fws :: (Alternative (f s), CharParsing f s a) => (f s) s+fws = ( optional (takeWhile isWsp *> crlf) *> takeWhile1 isWsp )+      $> singleton ' '++-- | FWS collapsed to a single SPACE character, or empty string+--+optionalFWS :: (Alternative (f s), CharParsing f s a, Monoid s) => (f s) s+optionalFWS = fws <|> pure mempty++-- | Printable ASCII excl. '(', ')', '\'+isCtext :: Char -> Bool+isCtext c =+  c >= chr 33 && c <= chr 39+  || c >= chr 42 && c <= chr 91+  || c >= chr 93 && c <= chr 126++ccontent :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+ccontent = (singleton . toChar <$> satisfy isCtext) <|> comment++comment :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+comment =+  char '(' *> foldMany (optionalFWS <<>> ccontent) <* optionalFWS <* char ')'++cfws :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+cfws =+  ((foldMany1 (optionalFWS <<>> comment) *> optionalFWS) $> singleton ' ')+  <|> fws++-- | CFWS collapsed to a single SPACE character, or empty string+--+optionalCFWS :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+optionalCFWS = cfws <|> pure mempty++atom :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+atom = optionalCFWS *> foldMany1 (singleton . toChar <$> atext) <* optionalCFWS++word :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+word = atom <|> quotedString++dotAtomText :: (Alternative (f s), CharParsing f s a) => (f s) (NonEmpty s)+dotAtomText = fromList <$> (takeWhile1 isAtext `A.sepBy1` char '.')++dotAtom :: (Alternative (f s), CharParsing f s a, SM s) => (f s) (NonEmpty s)+dotAtom = optionalCFWS *> dotAtomText <* optionalCFWS++localPart :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+localPart = (fold . intersperse (singleton '.') <$> dotAtom) <|> quotedString++-- | Printable US-ASCII excl "[", "]", or "\"+isDtext :: Char -> Bool+isDtext c = (c >= chr 33 && c <= chr 90) || (c >= chr 94 && c <= chr 126)++dText :: CharParsing f s a => (f s) a+dText = satisfy isDtext++domainLiteral :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s+domainLiteral =+  optionalCFWS *> char '['+  *> foldMany (optionalFWS <<>> (singleton . toChar <$> dText) <<>> optionalFWS)+  <* char ']' <* optionalFWS+++-- | Modify a parser to produce a case-insensitive value+--+ci :: FoldCase s => A.Parser s -> A.Parser (CI s)+ci = fmap mk+++-- | Combine two semigroup parsers into one+(<<>>) :: (Semigroup m, Applicative f) => f m -> f m -> f m+(<<>>) = liftA2 (<>)++-- | Parse zero or more values and fold them+foldMany :: (Monoid m, Alternative f) => f m -> f m+foldMany = fmap fold . many++-- | Parse one or more values and fold them+foldMany1 :: (Semigroup m, Alternative f) => f m -> f m+foldMany1 = fmap (fold1 . fromList) . A.many1++-- | Parse one or more values and fold them with a separating element+foldMany1Sep :: (Semigroup m, Alternative f) => m -> f m -> f m+foldMany1Sep sep = fmap (fold1 . intersperse sep . fromList) . A.many1++-- | Skip until the given parser succeeds+--+-- @@+-- λ> parseOnly (string "foo" *> skipTill (string ".") *> endOfInput) "foobar."+-- Right ()+-- @@+--+skipTill :: A.Parser a -> A.Parser ()+skipTill = void . spanTill++-- | Current offset in the input+position :: AT.Parser i Int+position = AT.Parser $ \t pos more _lose suc -> suc t pos more (AT.fromPos pos)++-- | Number of elements between current position and first position+-- at which parser matches (fails if it never matches).  Also+-- consumes the input on which the parser succeeds.+--+-- @@+-- λ> parseOnly (string "foo" *> spanTill (string ".")) "foobar."+-- Right 3+-- λ> parseOnly (string "foo" *> spanTill (string ".")) "foobar"+-- Left "not enough input"+-- @@+--+spanTill :: A.Parser a -> A.Parser Int+spanTill p = liftA2 (flip (-)) position q+  where+  q = position <* p <|> A.anyWord8 *> q++-- | Run the parser from the specified offset.+--+-- Should only be used to seek backwards, otherwise+-- you could seek beyond the buffer.  User beware.+--+-- @@+-- λ> parseOnly (seek 3 *> takeByteString) "foobar"+-- Right "bar"+-- @@+--+seek :: Int -> A.Parser ()+seek pos = AT.Parser $ \t _pos more _lose win -> win t (AT.Pos pos) more ()++-- | Take until the parser matches (fails if it never matches).+--+-- @@+-- λ> parseOnly (takeTill' (string "bar") <* endOfInput) "foobar"+-- Right "foo"+-- @@+--+takeTill' :: A.Parser a -> A.Parser B.ByteString+takeTill' p = do+  pos <- position+  off <- spanTill p+  newPos <- position+  seek pos *> A.take off <* seek newPos++-- | Number of elements between current position and first position+-- at which the pattern matches (fails if it never matches).  Also+-- consumes the pattern.+--+-- Uses Boyer-Moore algorithm to efficiently search the input.+--+-- @@+-- λ> parseOnly (string "foo" *> spanTillString ".") "foobar."+-- Right 3+-- λ> parseOnly (string "foo" *> spanTillString ".") "foobar"+-- Left "not enough input"+-- @@+--+spanTillString :: B.ByteString -> A.Parser Int+spanTillString pat+  | B.null pat = position+  | otherwise = position >>= go+  where+  search = indices pat+  go start = do+    pos <- position+    buf <- takeBuffer+    case search buf of+      (offset:_) ->+        -- Pattern was found.  Seek to end of pattern and return the span+        seek (pos + offset + B.length pat) $> pos + offset - start+      _ ->+        -- We hit the end of the buffer without a match.  Seek to+        -- (length buf - length pat), demand more input and go again.+        seek (max start (B.length buf - B.length pat)) *> A.demandInput *> go start++-- | Efficient skip, using Boyer-Moore to locate the pattern.+--+-- @@+-- λ> parseOnly (string "foo" *> skipTillString "." *> endOfInput) "foobar."+-- Right ()+-- @@+--+skipTillString :: B.ByteString -> A.Parser ()+skipTillString = void . spanTillString++-- | Efficient take, using Boyer-Moore to locate the pattern.+--+-- @@+-- λ> parseOnly (takeTillString "bar" <* endOfInput) "foobar"+-- Right "foo"+-- @@+--+takeTillString :: B.ByteString -> A.Parser B.ByteString+takeTillString pat = do+  pos <- position+  off <- spanTillString pat+  newPos <- position+  seek pos *> A.take off <* seek newPos++-- | /O(1)/ Take the rest of the buffer, but do not demand+-- any more input.+--+takeBuffer :: A.Parser B.ByteString+takeBuffer = do+  start <- position+  end <- bufSize+  A.take (end - start)++bufSize :: forall t. AT.Chunk t => AT.Parser t Int+bufSize = AT.Parser $+  \t pos more _lose win ->+    win t pos more+      (AT.fromPos $ AT.atBufferEnd (undefined :: t) t)
+ src/Data/IMF/Text.hs view
@@ -0,0 +1,133 @@+-- This file is part of purebred-email+-- Copyright (C) 2018-2021  Róman Joost and Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 OverloadedStrings #-}+{- |++@Text@ parsers and printers for mailbox and address types.++-}+module Data.IMF.Text+  (+    mailbox+  , mailboxList+  , readMailbox+  , address+  , addressList+  -- * Pretty printing+  , renderMailbox+  , renderMailboxes+  , renderAddress+  , renderAddresses+  , renderAddressSpec+  ) where++import Control.Applicative ((<|>), optional)+import Data.CaseInsensitive+import Data.Foldable (fold)+import Data.List (intersperse)+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Data.Text.Lazy as LT+import qualified Data.Text.Internal.Builder as Builder+import qualified Data.ByteString as B+import Data.Attoparsec.Text as A hiding (char, parse, take)+import Data.List.NonEmpty (intersperse)++import Data.MIME.Charset (decodeLenient)+import Data.IMF (Mailbox(..), Address(..), AddrSpec(..), Domain(..))+import Data.IMF.Syntax+++renderMailboxes :: [Mailbox] -> T.Text+renderMailboxes = LT.toStrict . Builder.toLazyText . buildMailboxes++buildMailboxes :: [Mailbox] -> Builder.Builder+buildMailboxes = fold . Data.List.intersperse ", " . fmap buildMailbox++renderMailbox :: Mailbox -> T.Text+renderMailbox = LT.toStrict . Builder.toLazyText . buildMailbox++-- | Printing function to "pretty print" the mailbox for display purposes+buildMailbox :: Mailbox -> Builder.Builder+buildMailbox (Mailbox n a) =+  maybe a' (\n' -> "\"" <> Builder.fromText n' <> "\" " <> "<" <> a' <> ">") n+  where+    a' = buildAddressSpec a++renderAddresses :: [Address] -> T.Text+renderAddresses xs = T.intercalate ", " $ renderAddress <$> xs++renderAddress :: Address -> T.Text+renderAddress (Single m) = renderMailbox m+renderAddress (Group name xs) = name <> ":" <> renderMailboxes xs <> ";"++buildAddressSpec :: AddrSpec -> Builder.Builder+buildAddressSpec (AddrSpec lp (DomainDotAtom b))+  | " " `B.isInfixOf` lp = "\"" <> buildLP <> "\"" <> rest+  | otherwise = buildLP <> rest+  where+    buildLP = Builder.fromText $ decodeLenient lp+    rest = "@" <> foldMap (Builder.fromText . decodeLenient . original)+                          (Data.List.NonEmpty.intersperse "." b)+buildAddressSpec (AddrSpec lp (DomainLiteral b)) =+  foldMap Builder.fromText [decodeLenient lp, "@", decodeLenient b]++renderAddressSpec :: AddrSpec -> T.Text+renderAddressSpec = LT.toStrict . Builder.toLazyText . buildAddressSpec+++-- §3.4 Address Specification+mailbox :: Parser Mailbox+mailbox = Mailbox <$> optional displayName <*> angleAddr+          <|> Mailbox Nothing <$> addressSpec++-- | Parse a (whole) string, returning an error @String@ or a 'Mailbox'.+readMailbox :: String -> Either String Mailbox+readMailbox = parseOnly (mailbox <* endOfInput) . T.pack++-- | Version of 'phrase' that does not process encoded-word+-- (we are parsing Text so will assume that the input does not+-- contain encoded words.  TODO this is probably wrong :)+phrase :: Parser T.Text+phrase = foldMany1Sep (singleton ' ') word++displayName :: Parser T.Text+displayName = phrase++mailboxList :: Parser [Mailbox]+mailboxList = mailbox `sepBy` char ','++addressList :: Parser [Address]+addressList = address `sepBy` char ','++group :: Parser Address+group = Group <$> displayName <* char ':' <*> mailboxList <* char ';' <* optionalCFWS++address :: Parser Address+address = group <|> Single <$> mailbox++angleAddr :: Parser AddrSpec+angleAddr = optionalCFWS *>+  char '<' *> addressSpec <* char '>'+  <* optionalCFWS++addressSpec :: Parser AddrSpec+addressSpec = AddrSpec <$> (T.encodeUtf8 <$> localPart) <*> (char '@' *> domain)++domain :: Parser Domain+domain = (DomainDotAtom . fmap (mk . T.encodeUtf8) <$> dotAtom)+         <|> (DomainLiteral . T.encodeUtf8 <$> domainLiteral)
+ src/Data/IMF/Text.hs-boot view
@@ -0,0 +1,21 @@+-- This file is part of purebred-email+-- Copyright (C) 2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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/>.++module Data.IMF.Text where++import {-# SOURCE #-} Data.IMF++readMailbox :: String -> Either String Mailbox
src/Data/MIME.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2017-2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE FlexibleInstances #-}@@ -8,11 +24,8 @@  {- | -MIME messages (RFC 2045, RFC 2046, RFC 2183 and friends).--This module extends "Data.RFC5322" with types for handling MIME-messages.  It provides the 'mime' parsing helper function for-use with 'message'.+This module extends "Data.IMF" with types for handling MIME messages+(RFC 2045, 2046, 2183 and others).  -} module Data.MIME@@ -61,10 +74,17 @@   , ctType   , ctSubtype   , matchContentType-  , ctEq   , parseContentType   , renderContentType   , showContentType++  -- *** @multipart@ media type+  , MultipartSubtype(..)++  -- **** @boundary@ parameter+  , Boundary+  , makeBoundary+  , unBoundary   , mimeBoundary    -- *** Content-Type values@@ -88,30 +108,23 @@   , createAttachment   , createAttachmentFromFile   , createMultipartMixedMessage+  , setTextPlainBody++  -- *** Forward   , encapsulate-  -- *** Setting headers-  , headerFrom-  , headerTo-  , headerCC-  , headerBCC-  , headerDate-  , headerSubject-  , headerText-  , replyHeaderReferences    -- * Re-exports   , CharsetLookup   , defaultCharsets-  , module Data.RFC5322+  , module Data.IMF   , module Data.MIME.Parameter   , module Data.MIME.Error   ) where  import Control.Applicative-import Data.Either (fromRight) import Data.Foldable (fold) import Data.List.NonEmpty (NonEmpty, fromList, intersperse)-import Data.Maybe (fromMaybe, catMaybes)+import Data.Maybe (fromMaybe) import Data.String (IsString(fromString)) import GHC.Generics (Generic) @@ -125,11 +138,10 @@ import qualified Data.CaseInsensitive as CI import qualified Data.Text as T import qualified Data.Text.Encoding as T-import Data.Time.Clock (UTCTime)-import Data.Time.Format (defaultTimeLocale, parseTimeM) -import Data.RFC5322-import Data.RFC5322.Internal hiding (takeWhile1)+import Data.IMF+import Data.IMF.Syntax hiding (takeWhile1)+import Data.MIME.Boundary import Data.MIME.Error import Data.MIME.Charset import Data.MIME.EncodedWord@@ -141,10 +153,11 @@ Create an __inline, plain text message__ and __render__ it:  @+λ> :set -XOverloadedStrings λ> import Data.MIME λ> msg = 'createTextPlainMessage' "Hello, world!" λ> s = 'renderMessage' msg-λ> L.putStrLn s+λ> Data.ByteString.Lazy.Char8.putStrLn s MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Content-Disposition: inline@@ -153,16 +166,27 @@ Hello, world! @ -Set the __@From@__ and __@To@__ headers:+Optics are provided for getting and setting the __sender and recipient__+fields:  @-λ> alice = Mailbox Nothing (AddrSpec "alice" (DomainDotAtom ("example" :| ["com"])))-λ> bob = Mailbox Nothing (AddrSpec "bob" (DomainDotAtom ("example" :| ["net"])))-λ> msgFromAliceToBob = set ('headerFrom' 'defaultCharsets' [alice] . set ('headerTo' defaultCharsets) [Single bob] $ msg-λ> L.putStrLn (renderMessage msgFromAliceToBob)+'headerFrom', 'headerReplyTo', 'headerTo', 'headerCC', 'headerBCC'+  :: ('HasHeaders' a)+  => 'CharsetLookup' -> Lens' a ['Address']+@++Example:++@+λ> alice = 'Single' "alice\@example.com"+λ> :t alice+alice :: 'Address'+λ> bob = 'Single' "bob\@example.net"+λ> msgFromAliceToBob = set ('headerFrom' 'defaultCharsets') [alice] . set ('headerTo' defaultCharsets) [bob] $ msg+λ> Data.ByteString.Lazy.Char8.putStrLn ('renderMessage' msgFromAliceToBob) MIME-Version: 1.0-From: alice@example.com-To: bob@example.net+From: alice\@example.com+To: bob\@example.net Content-Transfer-Encoding: 7bit Content-Disposition: inline Content-Type: text/plain; charset=us-ascii@@ -170,19 +194,23 @@ Hello, world! @ -The 'headerFrom', 'headerTo', 'headerCC' and 'headerBCC' lenses are the most-convenient interface for reading and setting the __sender and recipient addresses__.-Note that you would usually not manually construct email addresses-manually as was done above.  Instead you would usually read it from another-email or configuration, or parse addresses from user input.+__NOTE__: the values @alice@ and @bob@ in the above example make use+of the __non-total__ @instance 'IsString' 'Mailbox'@.  This instance+is provided as convenience for static values.  For parsing mailboxes,+use one of: +@+Data.IMF.'Data.IMF.mailbox'      :: 'CharsetLookup' -> Parser ByteString Mailbox+Data.IMF.Text.'Data.IMF.Text.mailbox' ::                  Parser       Text Mailbox+@+ The __@Subject@__ header is set via 'headerSubject'.  __Other single-valued headers__ can be set via 'headerText'.  @ λ> :{-| L.putStrLn . renderMessage $-|   set ('headerText' defaultCharsets "Comments") (Just "와")+| Data.ByteString.Lazy.Char8.putStrLn . 'renderMessage' $+|   set ('headerText' defaultCharsets \"Comments\") (Just "와") |   . set ('headerSubject' defaultCharsets) (Just "Hi from Alice") |   $ msgFromAliceToBob | :}@@ -190,8 +218,8 @@ MIME-Version: 1.0 Comments: =?utf-8?B?7JmA?= Subject: Hi from Alice-From: alice@example.com-To: bob@example.net+From: alice\@example.com+To: bob\@example.net Content-Transfer-Encoding: 7bit Content-Disposition: inline Content-Type: text/plain; charset=us-ascii@@ -199,33 +227,49 @@ Hello, world! @ +To create __multipart messages__ you need to construct a 'Boundary' value.+Boundary values should be unique (not appearing elsewhere in a message).+High-entropy random values are good.  You can use 'mkBoundary' to construct a+value (checking that the input is a legal value).  Or you can ask+/purebred-email/ to generate a conformant value, as below.++@+λ> import System.Random+λ> boundary <- getStdRandom uniform :: IO Boundary+λ> boundary+Boundary "MEgno8wUdTT\/g8vB,vj.3K8sjU6i_r=CFf1jqrAmnxrv0a\/9PA'G4hQ8oE,u016w"+@+ Create a __multipart message with attachment__:  @-λ> attachment = 'createAttachment' "application/json" (Just "data.json") "{\"foo\":42}"-λ> msg2 = 'createMultipartMixedMessage' "boundary" [msg, attachment]+λ> attachment = 'createAttachment' "application/json" (Just "data.json") "{\\"foo\\":42}"+λ> msg2 = 'createMultipartMixedMessage' boundary (msg :| [attachment]) λ> s2 = 'renderMessage' msg2-λ> L.putStrLn s2+λ> Data.ByteString.Lazy.Char8.putStrLn s2 MIME-Version: 1.0-Content-Type: multipart/mixed; boundary=boundary+Content-Type: multipart/mixed;+ boundary="MEgno8wUdTT\/g8vB,vj.3K8sjU6i_r=CFf1jqrAmnxrv0a\/9PA'G4hQ8oE,u016w" ---boundary+--MEgno8wUdTT\/g8vB,vj.3K8sjU6i_r=CFf1jqrAmnxrv0a\/9PA'G4hQ8oE,u016w+MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Content-Disposition: inline Content-Type: text/plain; charset=us-ascii  Hello, world!---boundary+--MEgno8wUdTT\/g8vB,vj.3K8sjU6i_r=CFf1jqrAmnxrv0a\/9PA'G4hQ8oE,u016w+MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename=data.json Content-Type: application/json  {"foo":42}---boundary--+--MEgno8wUdTT\/g8vB,vj.3K8sjU6i_r=CFf1jqrAmnxrv0a\/9PA'G4hQ8oE,u016w--  @ -__NOTE:__ if you only need to write a serialised 'Message' to an +__NOTE:__ for writing a 'Message' to an  IO handle, 'buildMessage' is more efficient than 'renderMessage'.  -}@@ -253,11 +297,12 @@ {- $inspect  Parsing an email is nice, but your normally want to get at the-content inside.  One of the most important tasks is __finding entities__-of interest, e.g. attachments, plain text or HTML bodies.  The-'entities' optic is a fold over all /leaf/ entities in the message.-That is, all the non-multipart bodies.  You can use 'filtered' to-refine the query.+content inside.  One of the most important tasks is __finding+entities__ of interest, e.g.  attachments, plain text or HTML+bodies.  The 'entities' optic is a fold over all /leaf/ entities in+the message.  That is, the leaves of (possibly nested) multipart+messages and "message/rfc822" encapsulations.  You can use+'filtered' to refine the query.  For example, let's say you want to find the first @text/plain@ entity in a message.  Define a predicate with the help of the@@ -266,11 +311,9 @@ @ λ> isTextPlain = 'matchContentType' "text" (Just "plain") . view 'contentType' λ> :t isTextPlain-isTextPlain :: HasHeaders s => s -> Bool-λ> isTextPlain msg-True-λ> isTextPlain msg2-False+isTextPlain :: 'HasHeaders' s => s -> Bool+λ> (isTextPlain msg, isTextPlain msg2)+(True,False) @  Now we can use the predicate to construct a fold and retrieve the@@ -281,24 +324,21 @@ Just "Hello, world!" @ -For __attachments__ you are normally interested in the binary data-and possibly the filename (if specified).  In the following example-we retrieve all attachments, and their filenames, as a list of-tuples (although there is only one in the message).  Note that--Get the (optional) filenames and (decoded) body of all attachments,-as a list of tuples.  The 'attachments' optic selects non-multipart-entities with @Content-Disposition: attachment@.  The 'attachments'-fold targets all entities with @Content-Disposition: attachment@.-The 'transferDecoded'' optic undoes the @Content-Transfer-Encoding@-of the entity.+For __attachments__, you may be interested in the binary data and+the filename (if specified).  In the following example we get the+(optional) filenames and (decoded) body of all attachments, as a+list of tuples.  The 'attachments' traversal targets non-multipart+entities with @Content-Disposition: attachment@.  The+'transferDecoded'' optic undoes the @Content-Transfer-Encoding@ of+the entity.  @+λ> :set -XTypeFamilies λ> getFilename = preview ('contentDisposition' . _Just . 'filename' 'defaultCharsets') λ> getBody = preview ('transferDecoded'' . _Right . 'body') λ> getAttachment = liftA2 (,) getFilename getBody λ> toListOf ('attachments' . to getAttachment) msg2-[(Just "data.json",Just "{\"foo\":42}")]+[(Just "data.json",Just "{\\"foo\\":42}")] @  Finally, note that the 'filename' optic takes an argument: it is a@@ -325,15 +365,14 @@ In Australia we say "Hello world" upside down:  @-λ> msg3 = createTextPlainMessage "ɥǝןןo ʍoɹןp"-λ> L.putStrLn $ renderMessage msg3+λ> msg3 = 'createTextPlainMessage' "ɥǝןןo ʍoɹןp"+λ> Data.ByteString.Lazy.Char8.putStrLn $ 'renderMessage' msg3 MIME-Version: 1.0 Content-Transfer-Encoding: base64 Content-Disposition: inline Content-Type: text/plain; charset=utf-8  yaXHndef159vIMqNb8m5159w- @  Charset set and transfer encoding are handled automatically.  If the@@ -356,7 +395,7 @@ λ> text = preview ('transferDecoded'' . _Right . 'charsetText'' 'defaultCharsets' . _Right) ent λ> :t text text :: Maybe T.Text-λ> traverse_ T.putStrLn text+λ> traverse_ Data.Text.IO.putStrLn text ɥǝןןo ʍoɹןp @ @@ -382,13 +421,52 @@ type ByteEntity = Message EncStateByte B.ByteString type TextEntity = Message () T.Text +data MultipartSubtype+  = Mixed+  -- ^ <https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.3 RFC 2046 §5.1.3.>+  -- Independent body parts, bundled in a particular order.+  | Alternative+  -- ^ <https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4 RFC 2046 §5.1.4.>+  -- Each part is an alternative version of the same content+  -- (e.g. plain text and HTML), in order of increasing faithfulness+  -- to the original content.+  | Digest+  -- ^ <https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.5 RFC 2046 §5.1.5.>+  -- Collection of messages. Parts should have @Content-Type: message/rfc822@.+  | Parallel+  -- ^ <https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.6 RFC 2046 §5.1.6.>+  -- Independent body parts, order not significants.  Parts may be+  -- displayed in parallel if the system supports it.+  | Related+      ContentType {- ^ @type@ -}+      (Maybe B.ByteString) {- ^ @start@ -}+      (Maybe B.ByteString) {- ^ @start-info@ -}+  -- ^ <https://www.rfc-editor.org/rfc/rfc2387.html RFC 2387.>+  -- Aggregate or compound objects.+  | Signed B.ByteString {- ^ protocol -} B.ByteString {- ^ micalg -}+  -- ^ <https://www.rfc-editor.org/rfc/rfc1847.html#section-2.1 RFC 1847 §2.1.>+  -- Signed messages.+  | Encrypted B.ByteString {- protocol -}+  -- ^ <https://www.rfc-editor.org/rfc/rfc1847.html#section-2.2 RFC 1847 §2.2.>+  | Report B.ByteString {- report-type -}+  -- ^ <https://www.rfc-editor.org/rfc/rfc6522.html RFC 6522>.+  -- Electronic mail reports.+  | Multilingual+  -- ^ <https://www.rfc-editor.org/rfc/rfc8255.html RFC 8255>.+  -- Multilingual messages.  The first part should be a multilingual+  -- explanatory preface.  Subsequent parts MUST have a+  -- @Content-Language@ and a @Content-Type@ field, and MAY have a+  -- @Content-Translation-Type@ field.+  | Unrecognised (CI B.ByteString)+  deriving (Eq, Show)+ -- | MIME message body.  Either a single @Part@, or @Multipart@. -- Only the body is represented; preamble and epilogue are not. -- data MIME   = Part B.ByteString   | Encapsulated MIMEMessage-  | Multipart (NonEmpty MIMEMessage)+  | Multipart MultipartSubtype Boundary (NonEmpty MIMEMessage)   | FailedParse MIMEParseError B.ByteString   deriving (Eq, Show) @@ -407,8 +485,8 @@   Part b ->     (\(Message h' b') -> Message h' (Part b')) <$> f (Message h b)   Encapsulated msg -> Message h . Encapsulated <$> entities f msg-  Multipart bs ->-    Message h . Multipart <$> sequenceA (entities f <$> bs)+  Multipart sub b bs ->+    Message h . Multipart sub b <$> sequenceA (entities f <$> bs)   FailedParse _ _ -> pure (Message h a)  -- | Leaf entities with @Content-Disposition: attachment@@@ -494,12 +572,6 @@ printParameters (Parameters xs) =   foldMap (\(k,v) -> "; " <> CI.original k <> "=" <> v) xs --- | Are the type and subtype the same? (parameters are ignored)----ctEq :: ContentType -> ContentType -> Bool-ctEq (ContentType typ1 sub1 _) = matchContentType typ1 (Just sub1)-{-# DEPRECATED ctEq "Use 'matchContentType' instead" #-}- ctType :: Lens' ContentType (CI B.ByteString) ctType f (ContentType a b c) = fmap (\a' -> ContentType a' b c) (f a) @@ -541,8 +613,26 @@ token =   takeWhile1 (\c -> c >= 33 && c <= 126 && notInClass "()<>@,;:\\\"/[]?=" c) --- | RFC 2046 §4.1.2. defines the default character set to be US-ASCII.+-- | <https://www.rfc-editor.org/rfc/rfc6657.html RFC 6657>+-- specifies that each subtype of the @text@ media type can define+-- its own default value for the @charset@ parameter, including the+-- absense of any default.  It can also specify that the charset+-- information is transported inside the payload (such as in+-- @text/xml@.  Behaviour for common media types includes: --+-- [@text/plain@] Default: @us-ascii@+--   (<https://www.rfc-editor.org/rfc/rfc6657.html#section-4 RFC 6657>)+-- [@text/csv@] Default: @utf-8@+--   (<https://www.rfc-editor.org/rfc/rfc7111.html#section-5.1 RFC 7111>)+-- [@text/markdown@] No default; @charset@ parameter is REQUIRED+--   (<https://www.rfc-editor.org/rfc/rfc7763.html#section-2 RFC 7763>)+-- [@text/enriched@] Default: @us-ascii@+--   (<https://www.rfc-editor.org/rfc/rfc1896.html RFC 1896>)+-- [@text/rtf@] Decoded as @us-ascii@.  Serialised RTF must be 7-bit+--   ASCII, with the character set declared in the payload.+--   Decoding RTF is outside the scope of this library.+--   See <https://www.iana.org/assignments/media-types/text/rtf>.+-- instance HasCharset ByteEntity where   type Decoded ByteEntity = TextEntity   charsetName = to $ \ent ->@@ -553,12 +643,12 @@     in       if typ == "text"       then case source sub of-        InBand f -> f (view body ent)+        InPayload f -> f (view body ent)         InParameter def -> preview l params <|> def-        InBandOrParameter f def -> f (view body ent) <|> preview l params <|> def+        InPayloadOrParameter f -> f (preview l params) (view body ent)       else         preview l params <|> Just "us-ascii"-  charsetData = body -- XXX: do we need to drop the BOM / encoding decl?+  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@@ -578,15 +668,18 @@ -- charset should be determined for some media type. -- data EntityCharsetSource-  = InBand (B.ByteString -> Maybe CharsetName)-  -- ^ Charset should be declared within payload (e.g. xml, rtf).-  --   The given function reads it from the payload.+  = InPayload (B.ByteString -> Maybe CharsetName)+  -- ^ Charset should be declared within payload (e.g. rtf).+  --   The given function reads the payload and returns the charset,+  --   or @Nothing@ if the charset cannot be determined or defaulted.   | InParameter (Maybe CharsetName)-  -- ^ Charset should be declared in the @charset@ parameter,+  -- ^ Charset may be declared in the @charset@ parameter,   --   with optional fallback to the given default.-  | InBandOrParameter (B.ByteString -> Maybe CharsetName) (Maybe CharsetName)-  -- ^ Check in-band first, fall back to @charset@ parameter,-  --   and further optionally fall back to a default.+  | InPayloadOrParameter (Maybe CharsetName -> B.ByteString -> Maybe CharsetName)+  -- ^ Charset could be specified in payload or parameter.  The function+  -- parameter takes the value of the charset parameter (which may be @Nothing@+  -- and the payload, and returns the character set that should be used (or+  -- @Nothing@ if a character set cannot be determined or defaulted.  -- | Charset sources for text/* media types.  IANA registry: -- https://www.iana.org/assignments/media-types/media-types.xhtml#text@@ -595,18 +688,21 @@ textCharsetSources =   [ ("plain", InParameter (Just "us-ascii"))   , ("csv", InParameter (Just "utf-8"))-  , ("rtf", InBand (const (Just "us-ascii" {- TODO -})))+  , ("rtf", InPayload (const (Just "us-ascii")))    -- https://tools.ietf.org/html/rfc2854   -- The default is ambiguous; using us-ascii for now-  , ("html", InBandOrParameter (const Nothing {-TODO-}) (Just "us-ascii"))+  , ("html", InPayloadOrParameter (\_param _payload -> Just "us-ascii")) -- FIXME    -- https://tools.ietf.org/html/rfc7763   , ("markdown", InParameter Nothing)    -- https://tools.ietf.org/html/rfc7303#section-3.2 and   -- https://www.w3.org/TR/2008/REC-xml-20081126/#charencoding-  , ("xml", InBand (const (Just "utf-8") {-TODO-}))+  , ("xml", InPayloadOrParameter (\_param _payload -> Just "utf-8")) -- FIXME++  -- https://tools.ietf.org/html/rfc1896.html+  , ("enriched", InParameter (Just "us-ascii"))   ]  -- | @text/plain; charset=us-ascii@@@ -623,11 +719,34 @@ contentTypeApplicationOctetStream =   ContentType "application" "octet-stream" mempty +-- | @multipart/...; boundary=asdf@+contentTypeMultipart :: MultipartSubtype -> Boundary -> ContentType+contentTypeMultipart subtype boundary =+  ContentType "multipart" sub mempty+    & setParam "boundary" (unBoundary boundary)+    & appendParams+  where+    setParam k v = set (parameter k) (Just $ ParameterValue Nothing Nothing v)+    (sub, appendParams) = case subtype of+      Mixed -> ("mixed", id)+      Alternative -> ("alternative", id)+      Digest -> ("digest", id)+      Parallel -> ("parallel", id)+      Multilingual -> ("multilingual", id)+      Report typ -> ("report", setParam "report-type" typ)+      Signed proto micalg -> ("signed", setParam "protocol" proto . setParam "micalg" micalg)+      Encrypted proto -> ("encrypted", setParam "protocol" proto)+      Related typ start startInfo ->+        ( "related"+        , maybe id (setParam "start") start+          . maybe id (setParam "start-info") startInfo+          . setParam "type" (renderContentType typ)+        )+      Unrecognised sub' -> (sub', id)+ -- | @multipart/mixed; boundary=asdf@-contentTypeMultipartMixed :: B.ByteString -> ContentType-contentTypeMultipartMixed boundary =-  set (parameter "boundary") (Just (ParameterValue Nothing Nothing boundary))-  $ ContentType "multipart" "mixed" mempty+contentTypeMultipartMixed :: Boundary -> ContentType+contentTypeMultipartMixed = contentTypeMultipart Mixed  -- | Lens to the content-type header.  Probably not a lawful lens. --@@ -644,6 +763,13 @@ -- otherwise it is added.  Unrecognised Content-Transfer-Encoding -- is ignored when setting. --+-- __Note__: when dealing with 'Multipart' or 'Encapsulated'+-- messages, the @Content-Type@ header will be overridden when+-- serialising the message.  This avoids scenarios where the+-- @Content-Type@ does not match the structure of the message.  In+-- general, the @Content-Type@ header should be treated as "read+-- only" for multipart or encapsulated message.+-- contentType :: HasHeaders a => Lens' a ContentType contentType = headers . lens sa sbt where   sa s = case view cte s of@@ -713,7 +839,7 @@ -- contentDisposition :: HasHeaders a => Lens' a (Maybe ContentDisposition) contentDisposition = headers . at "Content-Disposition" . dimap-  (>>= either (const Nothing) Just . Data.RFC5322.parse parseContentDisposition)+  (>>= either (const Nothing) Just . Data.IMF.parse parseContentDisposition)   (fmap . fmap $ renderContentDisposition)  -- | Traverse the value of the filename parameter (if present).@@ -735,7 +861,7 @@ filenameParameter = parameter "filename"  --- | Get the boundary, if specified+-- | Traversal of @boundary@ parameter (which may be unspecified) mimeBoundary :: Traversal' ContentType B.ByteString mimeBoundary = parameters . rawParameter "boundary" @@ -769,20 +895,48 @@   -> BodyHandler MIME mime' takeTillEnd h = RequiredBody $ case view contentType h of   ct | view ctType ct == "multipart" ->-    case preview (rawParameter "boundary") ct of-      Nothing -> FailedParse MultipartBoundaryNotSpecified <$> takeTillEnd-      Just boundary ->-        (Multipart <$> multipart takeTillEnd boundary)-        <|> (FailedParse MultipartParseFail <$> takeTillEnd)+        case prepMultipart ct of+          Left err              -> FailedParse err <$> takeTillEnd+          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+  _ -> Part <$> takeTillEnd   where-    part = Part <$> takeTillEnd+    prepMultipart ct =+      (,) <$> parseSubtype ct <*> parseBoundary ct+    parseBoundary ct =+      getRequiredParam "boundary" ct+      >>= over _Left (InvalidParameterValue "boundary") . makeBoundary+    getRequiredParam k =+      maybe (Left $ RequiredParameterMissing k) Right . preview (rawParameter k)+    getOptionalParam k =+      Right . preview (rawParameter k)+    parseSubtype ct = case view ctSubtype ct of+      "mixed"         -> pure Mixed+      "alternative"   -> pure Alternative+      "digest"        -> pure Digest+      "parallel"      -> pure Parallel+      "multilingual"  -> pure Multilingual+      "report"        -> Report <$> getRequiredParam "report-type" ct+      "signed"        -> Signed+                          <$> getRequiredParam "protocol" ct+                          <*> getRequiredParam "micalg" ct+      "encrypted"     -> Encrypted <$> getRequiredParam "protocol" ct+      "related"       -> Related+                          <$> ( getRequiredParam "type" ct+                              >>= \s -> maybe (Left $ InvalidParameterValue "type" s) Right+                                          (preview (parsed parseContentType) s)+                              )+                          <*> getOptionalParam "start" ct+                          <*> getOptionalParam "start-info" ct+      unrecognised    -> pure $ Unrecognised unrecognised  data MIMEParseError-  = MultipartBoundaryNotSpecified+  = RequiredParameterMissing (CI B.ByteString)+  | InvalidParameterValue (CI B.ByteString) B.ByteString   | MultipartParseFail   | EncapsulatedMessageParseFail   deriving (Eq, Show)@@ -792,14 +946,14 @@ -- multipart   :: Parser B.ByteString  -- ^ parser to the end of the part-  -> B.ByteString         -- ^ boundary, sans leading "--"+  -> Boundary             -- ^ boundary, sans leading "--"   -> Parser (NonEmpty MIMEMessage) multipart takeTillEnd boundary =   skipTillString dashBoundary *> crlf -- FIXME transport-padding   *> fmap fromList (part `sepBy1` crlf)   <* string "--" <* takeTillEnd   where-    delimiter = "\n--" <> boundary+    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@@ -810,116 +964,54 @@ -- | Sets the @MIME-Version: 1.0@ header. -- instance RenderMessage MIME where-  tweakHeaders = set (headers . at "MIME-Version") (Just "1.0")-  buildBody h z = Just $ case z of+  tweakHeaders b h =+    h+    & set (headers . at "MIME-Version") (Just "1.0")+    & setContentType+    where+      setContentType = case b of+        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 xs ->-      let b = firstOf (contentType . mimeBoundary) h-          boundary = maybe mempty (\b' -> "--" <> Builder.byteString b') b+    Multipart _sub b xs ->+      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 ----- | Map a single-occurrence header to a list value.--- On read, absent header is mapped to empty list.--- On write, empty list results in absent header.----headerSingleToList-  :: (HasHeaders s)-  => (B.ByteString -> [a])-  -> ([a] -> B.ByteString)-  -> CI B.ByteString-  -> Lens' s [a]-headerSingleToList f g k =-  headers . at k . iso (maybe [] f) (\l -> if null l then Nothing else Just (g l))--headerFrom :: HasHeaders a => CharsetLookup -> Lens' a [Mailbox]-headerFrom charsets = headerSingleToList-  (fromRight [] . parseOnly (mailboxList charsets))-  renderMailboxes-  "From"--headerAddressList :: (HasHeaders a) => CI B.ByteString -> CharsetLookup -> Lens' a [Address]-headerAddressList k charsets = headerSingleToList-  (fromRight [] . parseOnly (addressList charsets))-  renderAddresses-  k--headerTo, headerCC, headerBCC :: (HasHeaders a) => CharsetLookup -> Lens' a [Address]-headerTo = headerAddressList "To"-headerCC = headerAddressList "Cc"-headerBCC = headerAddressList "Bcc"--headerDate :: HasHeaders a => Lens' a (Maybe UTCTime)-headerDate = headers . at "Date" . iso (parseDate =<<) (fmap renderRFC5422Date)-  where-    parseDate =-        parseTimeM True defaultTimeLocale rfc5422DateTimeFormatLax . C8.unpack---- | Single-valued header with @Text@ value via encoded-words.--- The conversion to/from Text is total (encoded-words that failed to be--- decoded are passed through unchanged).  Therefore @Nothing@ means that--- the header was not present.------ This function is suitable for the @Subject@ header.----headerText :: (HasHeaders a) => CharsetLookup -> CI B.ByteString -> Lens' a (Maybe T.Text)-headerText charsets k =-  headers . at k . iso (fmap (decodeEncodedWords charsets)) (fmap encodeEncodedWords)---- | Subject header.  See 'headerText' for details of conversion to @Text@.-headerSubject :: (HasHeaders a) => CharsetLookup -> Lens' a (Maybe T.Text)-headerSubject charsets = headerText charsets "Subject"----- | Returns a space delimited `B.ByteString` with values from identification--- fields from the parents message `Headers`. Rules to gather the values are in--- accordance to RFC5322 - 3.6.4 as follows sorted by priority (first has--- precedence):------ * Values from @References@ and @Message-ID@ (if any)--- * Values from @In-Reply-To@ and @Message-ID@ (if any)--- * Value from @Message-ID@ (in case it's the first reply to a parent mail)--- * Otherwise @Nothing@ is returned indicating that the replying mail should---   not have a @References@ field.----replyHeaderReferences :: HasHeaders a => Getter a (Maybe C8.ByteString)-replyHeaderReferences = (.) headers $ to $ \hdrs ->-  let xs = catMaybes-        [preview (header "references") hdrs-         <|> preview (header "in-reply-to") hdrs-        , preview (header "message-id") hdrs-        ]-  in if null xs then Nothing else Just (B.intercalate " " xs)- -- | Create a mixed `MIMEMessage` with an inline text/plain part and multiple -- `attachments` -- createMultipartMixedMessage-    :: B.ByteString -- ^ Boundary+    :: Boundary     -> NonEmpty MIMEMessage -- ^ parts     -> MIMEMessage createMultipartMixedMessage b attachments' =-    let hdrs = mempty &-                set contentType (contentTypeMultipartMixed b)-    in Message hdrs (Multipart attachments')+  let hdrs = Headers [] & set contentType (contentTypeMultipartMixed b)+  in Message hdrs (Multipart Mixed b attachments')  -- | Create an inline, text/plain, utf-8 encoded message -- createTextPlainMessage :: T.Text -> MIMEMessage-createTextPlainMessage s = fmap Part $ transferEncode $ charsetEncode msg-  where-  msg = Message hdrs s :: TextEntity-  cd = ContentDisposition Inline mempty-  hdrs = mempty-          & set contentType contentTypeTextPlain-          & set contentDisposition (Just cd)+createTextPlainMessage s = setTextPlainBody s (Message (Headers []) ()) +-- | Set an inline, @text/plain@, utf-8 encoded message body+--+setTextPlainBody :: T.Text -> Message ctx a -> MIMEMessage+setTextPlainBody s =+  fmap Part+  . transferEncode+  . charsetEncode+  . set contentDisposition (Just $ ContentDisposition Inline mempty)+  . set contentType contentTypeTextPlain+  . set body s+ -- | Create an attachment from a given file path. -- Note: The filename content disposition is set to the given `FilePath`. For -- privacy reasons, you can unset/change it. See `filename` for examples.@@ -936,7 +1028,7 @@   msg = Message hdrs s   cd = ContentDisposition Attachment cdParams   cdParams = mempty & set filenameParameter (newParameter <$> fp)-  hdrs = mempty+  hdrs = Headers []           & set contentType ct           & set contentDisposition (Just cd) @@ -946,4 +1038,4 @@ encapsulate :: MIMEMessage -> MIMEMessage encapsulate = Message hdrs . Encapsulated   where-  hdrs = mempty & set contentType "message/rfc822"+  hdrs = Headers [] & set contentType "message/rfc822"
src/Data/MIME/Base64.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2017-2020  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 OverloadedStrings #-}  {- |@@ -46,7 +62,7 @@ contentTransferEncodeBase64 = L.toStrict . wrap . L64.encode . L.fromStrict   where   wrap s = case L.splitAt 76 s of-    ("", _) -> ""+    (l, "") -> l     (l, s') -> l <> "\r\n" <> wrap s'  {-
+ src/Data/MIME/Boundary.hs view
@@ -0,0 +1,79 @@+-- This file is part of purebred-email+-- Copyright (C) 2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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/>.++module Data.MIME.Boundary+  (+    Boundary+  , unBoundary+  , makeBoundary+  ) where++import Control.Monad (replicateM)++import qualified Data.ByteString as B+import qualified Data.ByteString.Internal as B+import Data.ByteString.Char8 as C8+import System.Random.Stateful++-- | MIME boundary.  Use 'makeBoundary' to construct, and 'unBoundary'+-- to unwrap.+--+-- Use the 'Uniform' instance to generate a random @Boundary@ to use+-- when constructing messages.  For example:+--+-- @+-- 'getStdRandom' 'uniform' :: MonadIO m =>  m Boundary+-- 'getStdRandom' 'uniform' ::              IO Boundary+-- @+--+newtype Boundary = Boundary B.ByteString+  deriving (Eq, Show)++unBoundary :: Boundary -> B.ByteString+unBoundary (Boundary s) = s++-- Boundary smart constructor that checks validity+makeBoundary :: B.ByteString -> Either B.ByteString Boundary+makeBoundary s+  | B.null s                    = Left s+  | B.length s > 70             = Left s+  | B.any (not . validBchar) s  = Left s+  | B.last s == 0x20            = Left s+  | otherwise                   = Right $ Boundary s+  where+    validBchar c =+      c >= 0x2c && c <= 0x3a -- ',', '-', '.', '/', '0'..'9', ':'+      || c >= 0x41 && c <= 0x5a -- 'A'..'Z'+      || c >= 0x61 && c <= 0x7a -- 'a'..'z'+      || c >= 0x27 && c <= 0x29 -- '\'', '(', ')'+      || c == 0x2b {- '+' -}+      || c == 0x5f {- '_' -}+      || c == 0x3d {- '=' -}+      || c == 0x3f {- '?' -}+      || c == 0x20 {- ' ' -}++-- | Generate a random 'Boundary'+genBoundary :: (StatefulGen g m) => g -> m Boundary+genBoundary g = do+  let+    blen = 64+    bchars = C8.pack $ ['0'..'9'] <> ['a'..'z'] <> ['A'..'Z'] <> "'()+_,-./:=?"+  chars <-+    replicateM blen $ B.index bchars <$> uniformRM (0, B.length bchars - 1) g+  pure . Boundary $ B.unsafePackLenBytes blen chars++instance Uniform Boundary where+  uniformM = genBoundary
src/Data/MIME/Charset.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2018-2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 #-}@@ -129,16 +145,52 @@   , ("utf-8", utf_8)   , ("iso-8859-1", iso_8859_1) -  -- uncommon aliases+  -- us-ascii aliases   , ("ISO646-US", us_ascii)   , ("ANSI_X3.4-1968", us_ascii)+  , ("iso-ir-6", us_ascii)+  , ("ANSI_X3.4-1986", us_ascii)+  , ("ISO_646.irv:1991", us_ascii)+  , ("us", us_ascii)+  , ("IBM367", us_ascii)+  , ("cp367", us_ascii)+  , ("csASCII.4-1968", us_ascii) +  -- iso-8859-1 aliases+  , ("iso-ir-100", iso_8859_1)+  , ("ISO_8859-1", iso_8859_1)+  , ("latin1", iso_8859_1)+  , ("l1", iso_8859_1)+  , ("IBM819", iso_8859_1)+  , ("CP819", iso_8859_1)+  , ("csISOLatin1", iso_8859_1)++  -- utf-8 aliases+  , ("csUTF8", utf_8)++  -- utf-16+  , ("UTF-16BE", utf16be)+  , ("UTF-16LE", utf16le)+  , ("UTF-16", utf16)+  , ("csUTF16BE", utf16be)+  , ("csUTF16LE", utf16le)+  , ("csUTF16", utf16)++  -- utf-32+  , ("UTF-32BE", utf32be)+  , ("UTF-32LE", utf32le)+  , ("UTF-32", utf32)+  , ("csUTF32BE", utf32be)+  , ("csUTF32LE", utf32le)+  , ("csUTF32", utf32)++  -- Other charsets I observed in my mail corpus.   -- , ("iso-8859-2", ...)   -- , ("iso-8859-15", ...)   -- , ("iso-2022-jp", ...)    (common)   -- , ("windows-1252", ...)   (common)   -- , ("windows-1256", ...)-  -- , ("cp1252", ...)         (same as windows-1256?)+  -- , ("cp1252", ...)         (alias of windows-1252)   -- , ("big5", ...)           (common)   -- , ("euc-kr", ...)   -- , ("cp932", ...)@@ -150,10 +202,29 @@ utf_8 = decodeLenient iso_8859_1 = T.decodeLatin1 +utf16be, utf16le, utf16 :: Charset+utf16be = T.decodeUtf16BEWith T.lenientDecode+utf16le = T.decodeUtf16LEWith T.lenientDecode+-- https://www.rfc-editor.org/rfc/rfc2781.html#section-4.3+utf16 b = case B.take 2 b of+  "\xff\xfe"  -> utf16le b+  _           -> utf16be b +utf32be, utf32le, utf32 :: Charset+utf32be = T.decodeUtf32BEWith T.lenientDecode+utf32le = T.decodeUtf32LEWith T.lenientDecode+-- Unicode 4.0, Section 3.10, D45+-- https://www.unicode.org/versions/Unicode4.0.0/ch03.pdf#G7404+utf32 b = case B.take 4 b of+  "\xff\xfe\x00\x00"  -> utf32le b+  _                   -> utf32be b++ type CharsetLookup = CI.CI B.ByteString -> Maybe Charset --- | Supports US-ASCII, UTF-8 and ISO-8859-1.+-- | Supports US-ASCII, UTF-8 and ISO-8859-1, UTF-16[BE|LE]+-- and UTF-32[BE|LE].  The /purebred-icu/ package provides+-- support for more charsets. -- defaultCharsets :: CharsetLookup defaultCharsets k = lookup k charsets
src/Data/MIME/EncodedWord.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2018-2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} @@ -38,7 +54,7 @@ import Data.MIME.TransferEncoding import Data.MIME.Base64 import Data.MIME.QuotedPrintable-import Data.RFC5322.Internal (ci, takeTillString)+import Data.IMF.Syntax (ci, takeTillString)  data EncodedWord = EncodedWord   { _encodedWordCharset :: CI.CI B.ByteString
src/Data/MIME/Error.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2018-2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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
src/Data/MIME/Internal.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2018  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 OverloadedStrings #-}  module Data.MIME.Internal
src/Data/MIME/Parameter.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2018-2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 #-}@@ -60,7 +76,7 @@  import Data.MIME.Charset import Data.MIME.Internal-import Data.RFC5322.Internal (ci, isQtext, isVchar)+import Data.IMF.Syntax (ci, isQtext, isVchar)  type RawParameters = [(CI B.ByteString, B.ByteString)] -- | Header parameters.  Used for some headers including Content-Type
src/Data/MIME/QuotedPrintable.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2017-2020  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2018-2020  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2017-2019  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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
src/Data/RFC5322.hs view
@@ -1,523 +1,21 @@-{-# LANGUAGE AllowAmbiguousTypes #-}-{-# LANGUAGE DefaultSignatures #-}-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE TypeApplications #-}-{-# LANGUAGE TypeFamilies #-}--{- |--Email messages.  Deals specifically with RFC 5322, which is stricter-than RFC 822 or RFC 2822.  If you have to deal with messages that-comply with the older specifications but not RFC 5322, preprocess-the input and massage it to be RFC 5322 compliant.--This parser allows LF line endings in addition to CRLF (RFC 5322-demands CRLF but LF-only is common in on-disk formats).--The main parsing function is 'message'.  It takes a second function-that can inspect the headers to determine how to parse the body.--@-'message' :: ('Headers' -> 'BodyHandler' a) -> Parser (Message ctx a)-@--The 'Message' type is parameterised over the body type, and a-phantom type that can be used for context.--@-data 'Message' ctx a = Message 'Headers' a-@--Headers and body can be accessed via the 'headers', 'header' and-'body' optics.--@-'headers' :: Lens' (Message ctx a) Headers-'header' :: CI B.ByteString -> Fold Headers B.ByteString-'body' :: Lens (Message ctx a) (Message ctx' b) a b-@--The following example program parses an input, interpreting the body-as a raw @ByteString@, and prints the subject (if present), the-number of headers and the body length.  The message context type is-@()@.--@-analyse :: B.ByteString -> IO ()-analyse input =-  case 'parse' ('message' (const takeByteString)) of-    Left errMsg -> hPutStrLn stderr errMsg *> exitFailure-    Right (msg :: Message () B.ByteString) -> do-      B.putStrLn $ "subject: " <> foldOf ('headers' . 'header' "subject") msg-      putStrLn $ "num headers: " <> show (length (view 'headers' msg))-      putStrLn $ "body length: " <> show (B.length (view 'body' msg))-@---}-module Data.RFC5322-  (-  -- * Message types-    Message(..)-  , message-  , MessageContext-  , BodyHandler(..)-  , body-  , EqMessage(..)--  -- ** Headers-  , Header-  , HasHeaders(..)-  , header-  , headerList-  , Headers(..)--  -- ** Addresses-  , Address(..)-  , address-  , addressList-  , AddrSpec(..)-  , Domain(..)-  , Mailbox(..)-  , mailbox-  , mailboxList--  -- * Parsers-  , parse-  , parsed-  , parsePrint-  , crlf-  , quotedString--  -- * Helpers-  , field-  , rfc5422DateTimeFormat-  , rfc5422DateTimeFormatLax--  -- * Serialisation-  , buildMessage-  , renderMessage-  , RenderMessage(..)-  , renderRFC5422Date-  , buildFields-  , buildField-  , renderAddressSpec-  , renderMailbox-  , renderMailboxes-  , renderAddress-  , renderAddresses-  ) where--import Control.Applicative-import Data.Foldable (fold)-import Data.List (findIndex, intersperse)-import Data.List.NonEmpty (intersperse)-import Data.Word (Word8)-import GHC.Generics (Generic)--import Control.DeepSeq (NFData)-import Control.Lens-import Control.Lens.Cons.Extras (recons)-import Data.Attoparsec.ByteString as A hiding (parse, take)-import Data.Attoparsec.ByteString.Char8 (char8)-import qualified Data.Attoparsec.ByteString.Lazy as AL-import qualified Data.ByteString as B-import qualified Data.ByteString.Lazy as L-import qualified Data.ByteString.Char8 as Char8-import qualified Data.ByteString.Builder as Builder-import qualified Data.ByteString.Builder.Prim as Prim-import qualified Data.Text as T-import qualified Data.Text.Encoding as T-import Data.Time.Clock (UTCTime)-import Data.Time.Format (defaultTimeLocale, formatTime)--import Data.RFC5322.Internal-  ( CI, ci, original-  , (<<>>), foldMany, foldMany1Sep-  , fromChar, isAtext, isQtext, isVchar, isWsp-  , optionalCFWS, word, wsp, vchar, optionalFWS, crlf-  , domainLiteral, dotAtom, localPart, quotedString-  )-import Data.RFC5322.Address.Types-import Data.MIME.Charset-import Data.MIME.EncodedWord (encodedWord, decodeEncodedWord, buildEncodedWord)-import Data.MIME.TransferEncoding (transferEncode)--type Header = (CI B.ByteString, B.ByteString)-newtype Headers = Headers [Header]-  deriving (Eq, Show, Generic, NFData)--instance Semigroup Headers where-  Headers a <> Headers b = Headers (a <> b)--instance Monoid Headers where-  mempty = Headers []--class HasHeaders a where-  headers :: Lens' a Headers--instance HasHeaders Headers where-  headers = id--type instance Index Headers = CI B.ByteString-type instance IxValue Headers = B.ByteString--instance Ixed Headers where-  ix = header--hdriso :: Iso' Headers [(CI B.ByteString, B.ByteString)]-hdriso = iso (\(Headers xs) -> xs) Headers---- | Acts upon the first occurrence of the header only.----instance At Headers where-  at k = hdriso . l-    where-    l :: Lens' [(CI B.ByteString, B.ByteString)] (Maybe B.ByteString)-    l f kv =-      let-        i = findIndex ((== k) . fst) kv-        g Nothing = maybe kv (\j -> take j kv <> drop (j + 1) kv) i-        g (Just v) = maybe ((k,v):kv) (\j -> set (ix j) (k,v) kv) i-      in-        g <$> f (lookup k kv)----- | Target all values of the given header-header :: HasHeaders a => CI B.ByteString -> Traversal' a B.ByteString-header k = headerList . traversed . filtered ((k ==) . fst) . _2---- | Message type, parameterised over context and body type.  The--- context type is not used in this module but is provided for uses--- such as tracking the transfer/charset encoding state in MIME--- messages.----data Message s a = Message Headers a-  deriving (Show, Generic, NFData)--instance HasHeaders (Message s a) where-  headers f (Message h b) = fmap (`Message` b) (f h)--instance Functor (Message s) where-  fmap f (Message h a) = Message h (f a)---- | How to compare messages with this body type.------ This class arises because we may want to tweak the headers,--- possibly in response to body data, or vice-versa, when--- comparing messages.------ The default implementation compares headers and body using (==).----class EqMessage a where-  eqMessage :: Message s a -> Message s a -> Bool--  default eqMessage :: (Eq a) => Message s a -> Message s a -> Bool-  eqMessage (Message h1 b1) (Message h2 b2) = h1 == h2 && b1 == b2---instance EqMessage a => Eq (Message s a) where-  (==) = eqMessage---- | Access headers as a list of key/value pairs.-headerList :: HasHeaders a => Lens' a [(CI B.ByteString, B.ByteString)]-headerList = headers . coerced--body :: Lens (Message ctx a) (Message ctx' b) a b-body f (Message h b) = fmap (\b' -> Message h b') (f b)-{-# ANN body ("HLint: ignore Avoid lambda" :: String) #-}--isSpecial :: Word8 -> Bool-isSpecial = inClass "()<>[]:;@\\,.\""--special :: Parser Word8-special = satisfy isSpecial----- §3.3  Date and Time Specification--- Sat, 29 Sep 2018 12:51:05 +1000-rfc5422DateTimeFormat :: String-rfc5422DateTimeFormat = "%a, %d %b %Y %T %z"--rfc5422DateTimeFormatLax :: String-rfc5422DateTimeFormatLax = "%a, %-d %b %Y %-H:%-M:%-S %z"--renderRFC5422Date :: UTCTime -> B.ByteString-renderRFC5422Date = Char8.pack . formatTime defaultTimeLocale rfc5422DateTimeFormat---- §3.4 Address Specification-buildMailbox :: Mailbox -> Builder.Builder-buildMailbox (Mailbox n a) =-  maybe a' (\n' -> buildPhrase n' <> " <" <> a' <> ">") n-  where-    a' = buildAddressSpec a---- Encode a phrase.------ * Empty string is special case; must be in quotes--- * If valid as an atom, use as-is (ideally, but we don't do this yet)--- * If it can be in a quoted-string, do so.--- * Otherwise make it an encoded-word----buildPhrase :: T.Text -> Builder.Builder-buildPhrase "" = "\"\""-buildPhrase s =-  case enc s of-    PhraseAtom -> T.encodeUtf8Builder s-    PhraseQuotedString -> qsBuilder False-    PhraseQuotedStringEscapeSpace -> qsBuilder True-    PhraseEncodedWord -> buildEncodedWord . transferEncode . charsetEncode $ s-  where-    enc = snd . T.foldr (\c (prev, req) -> (c, encChar prev c <> req)) ('\0', mempty)-    encChar prev c-      | isAtext c = PhraseAtom-      | isQtext c = PhraseQuotedString-      | isVchar c = PhraseQuotedString-      | c == ' ' =-          if prev == ' '  -- two spaces in a row; need to avoid FWS-          then PhraseQuotedStringEscapeSpace-          else PhraseQuotedString-      | otherwise = PhraseEncodedWord--    qsBuilder escSpace = "\"" <> T.encodeUtf8BuilderEscaped (escPrim escSpace) s <> "\""-    escPrim escSpace = Prim.condB (\c -> isQtext c || not escSpace && c == 32)-      (Prim.liftFixedToBounded Prim.word8)-      (Prim.liftFixedToBounded $ (fromChar '\\',) Prim.>$< Prim.word8 Prim.>*< Prim.word8)---- | Data type used to compute escaping requirement of a Text 'phrase'----data PhraseEscapeRequirement-  = PhraseAtom-  | PhraseQuotedString-  | PhraseQuotedStringEscapeSpace-  | PhraseEncodedWord-  deriving (Eq, Ord)--instance Semigroup PhraseEscapeRequirement where-  PhraseEncodedWord <> _ =-    -- allows early termination of folds-    PhraseEncodedWord-  l <> r = max l r--instance Monoid PhraseEscapeRequirement where-  mempty = PhraseAtom----renderMailboxes :: [Mailbox] -> B.ByteString-renderMailboxes = L.toStrict . Builder.toLazyByteString . buildMailboxes--buildMailboxes :: [Mailbox] -> Builder.Builder-buildMailboxes = fold . Data.List.intersperse ", " . fmap buildMailbox--renderMailbox :: Mailbox -> B.ByteString-renderMailbox = L.toStrict . Builder.toLazyByteString . buildMailbox--mailbox :: CharsetLookup -> Parser Mailbox-mailbox charsets =-  Mailbox <$> optional (displayName charsets) <*> angleAddr-  <|> Mailbox Nothing <$> addressSpec--phrase :: CharsetLookup -> Parser T.Text-phrase charsets = foldMany1Sep " " $-  fmap (decodeEncodedWord charsets) ("=?" *> encodedWord)-  <|> fmap decodeLenient word--displayName :: CharsetLookup -> Parser T.Text-displayName = phrase--angleAddr :: Parser AddrSpec-angleAddr = optionalCFWS *>-  char8 '<' *> addressSpec <* char8 '>'-  <* optionalCFWS--buildAddressSpec :: AddrSpec -> Builder.Builder-buildAddressSpec (AddrSpec lp (DomainDotAtom b))-  | " " `B.isInfixOf` lp = "\"" <> buildLP <> "\"" <> rest-  | otherwise = buildLP <> rest-  where-    buildLP = Builder.byteString lp-    rest = "@" <> foldMap Builder.byteString (Data.List.NonEmpty.intersperse "." b)-buildAddressSpec (AddrSpec lp (DomainLiteral b)) =-  foldMap Builder.byteString [lp, "@", b]--renderAddressSpec :: AddrSpec -> B.ByteString-renderAddressSpec = L.toStrict . Builder.toLazyByteString . buildAddressSpec--addressSpec :: Parser AddrSpec-addressSpec = AddrSpec <$> localPart <*> (char8 '@' *> domain)---- | Printable US-ASCII excl "[", "]", or "\"-isDtext :: Word8 -> Bool-isDtext c = (c >= 33 && c <= 90) || (c >= 94 && c <= 126)--domain :: Parser Domain-domain = (DomainDotAtom <$> dotAtom)-         <|> (DomainLiteral <$> domainLiteral)--mailboxList :: CharsetLookup -> Parser [Mailbox]-mailboxList charsets = mailbox charsets `sepBy` char8 ','--renderAddresses :: [Address] -> B.ByteString-renderAddresses xs = B.intercalate ", " $ renderAddress <$> xs--renderAddress :: Address -> B.ByteString-renderAddress (Single m) = renderMailbox m-renderAddress (Group name xs) = T.encodeUtf8 name <> ":" <> renderMailboxes xs <> ";"--addressList :: CharsetLookup -> Parser [Address]-addressList charsets = address charsets `sepBy` char8 ','--group :: CharsetLookup -> Parser Address-group charsets =-  Group <$> displayName charsets <* char8 ':'-        <*> mailboxList charsets <* char8 ';' <* optionalCFWS--address :: CharsetLookup -> Parser Address-address charsets =-  group charsets <|> Single <$> mailbox charsets---- §3.5.  Overall Message Syntax----- | Specify how to handle a message body, including the possibility--- of optional bodies and no body (which is distinct from empty body).-data BodyHandler a-  = RequiredBody (Parser a)-  | OptionalBody (Parser a, a)-  -- ^ If body is present run parser, otherwise use constant value-  | NoBody a---- | Parse a message.  The function argument receives the headers and--- yields a handler for the message body.----message :: (Headers -> BodyHandler a) -> Parser (Message (MessageContext a) a)-message f = fields >>= \hdrs -> Message hdrs <$> case f hdrs of-  RequiredBody b -> crlf *> b-  OptionalBody (b, a) -> optional crlf >>= maybe (pure a) (const b)-  NoBody b -> pure b--type family MessageContext a---fields :: Parser Headers-fields = Headers <$> many field---- | Define how to render an RFC 5322 message with given payload type.----class RenderMessage a where-  -- | Build the body.  If there should be no body (as distinct from-  -- /empty body/) return Nothing-  buildBody :: Headers -> a -> Maybe Builder.Builder--  -- | Allows tweaking the headers before rendering.  Default-  -- implementation is a no-op.-  tweakHeaders :: Headers -> Headers-  tweakHeaders = id---- | Construct a 'Builder.Builder' for the message.  This allows efficient--- streaming to IO handles.----buildMessage :: forall ctx a. (RenderMessage a) => Message ctx a -> Builder.Builder-buildMessage (Message h b) =-  buildFields (tweakHeaders @a h)-  <> maybe mempty ("\r\n" <>) (buildBody h b)---- | Render a message to a lazy 'L.ByteString'.  (You will probably not--- need a strict @ByteString@ and it is inefficient for most use cases.)----renderMessage :: (RenderMessage a) => Message ctx a -> L.ByteString-renderMessage = Builder.toLazyByteString . buildMessage---- Header serialisation-buildFields :: Headers -> Builder.Builder-buildFields = foldMapOf (hdriso . traversed) buildField--buildField :: (CI B.ByteString, B.ByteString) -> Builder.Builder-buildField (k,v) =-  let key = original k-  in-    Builder.byteString key-    <> ":"-    <> foldUnstructured v (B.length key + 1)-    <> "\r\n"---- | Render a field body with proper folding------ Folds on whitespace (and only whitespace).  Sequential whitespace--- chars are folded.  That's OK because the grammar says it is--- folding whitespace.+-- This file is part of purebred-email+-- Copyright (C) 2021  Fraser Tweedale ---foldUnstructured :: B.ByteString -> Int -> Builder.Builder-foldUnstructured s i = case Char8.words s of-  [] -> mempty-  (h:t) ->-    -- Special case to prevent wrapping of first word;-    -- see 6dbc04fb1863e845699b1cef50f4edaf1326bdae for info.-    " " <> Builder.byteString h <> go t (i + 1 + B.length h)-  where-  limit = 76  -- could be 78, but this preserves old behaviour-  go [] _ = mempty-  go (chunk:chunks) col-    | col + 1 + B.length chunk < limit =-        -- there is room for the chunk-        " " <> Builder.byteString chunk <> go chunks (col + 1 + B.length chunk)-    | col <= 1 =-        -- there isn't room for the chunk, but we are at the-        -- beginning of the line so add it here anyway (otherwise-        -- we will add "\r\n" and recurse forever-        " " <> Builder.byteString chunk <> "\r\n" <> go chunks 0-    | otherwise = "\r\n" <> go (chunk:chunks) 0  -- fold---- | Printable ASCII excl. ':'-isFtext :: Word8 -> Bool-isFtext c = (c >= 33 && c <= 57) || (c >= 59 && c <= 126)--field :: Parser (CI B.ByteString, B.ByteString)-field = (,)-  <$> ci (takeWhile1 isFtext)-  <*  char8 ':' <* many wsp-  <*> unstructured <* crlf--unstructured :: Parser B.ByteString-unstructured =-  foldMany (optionalFWS <<>> (B.singleton <$> vchar))-  <<>> A.takeWhile isWsp----- | Given a parser, construct a 'Fold'+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version. ----- See 'parse' for discussion of performance.+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details. ---parsed :: (Cons s s Word8 Word8) => Parser a -> Fold s a-parsed p = to (parse p) . folded-{-# INLINE parsed #-}+-- 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/>. --- | Construct a prism from a parser and a printer-parsePrint :: Parser a -> (a -> B.ByteString) -> Prism' B.ByteString a-parsePrint fwd rev = prism' rev (AL.maybeResult . AL.parse fwd . view recons)+module Data.RFC5322+  {-# DEPRECATED "Use \"Data.IMF\" instead" #-}+  ( module X ) where --- | Parse an @a@.------ The input is convered to a /lazy/ @ByteString@.--- Build with rewrite rules enabled (@-O@, cabal's default)--- to achieve the following conversion overheads:------ * Lazy @ByteString@: no conversion--- * Strict @ByteString@: /O(1)/ conversion--- * @[Word8]@: /O(n)/ conversion------ It is __recommended to use strict bytestring__ input.  Parsing a--- lazy bytestring will cause numerous parser buffer resizes.  The--- lazy chunks in the input can be GC'd but the buffer keeps growing--- so you don't actually keep the memory usage low by using a lazy--- bytestring.----parse :: (Cons s s Word8 Word8) => Parser a -> s -> Either String a-parse p = AL.eitherResult . AL.parse p . view recons-{-# INLINE parse #-}+import Data.IMF as X
src/Data/RFC5322/Address/Text.hs view
@@ -1,110 +1,21 @@-{-# LANGUAGE OverloadedStrings #-}-{- |--Parser for roundtripping Text based `Mailbox`es and addresses.+-- This file is part of purebred-email+-- Copyright (C) 2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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/>. --} module Data.RFC5322.Address.Text-  (-    mailbox-  , mailboxList-  , address-  , addressList-  -- * Pretty printing-  , renderMailbox-  , renderMailboxes-  , renderAddress-  , renderAddresses-  , renderAddressSpec-  ) where--import Control.Applicative ((<|>), optional)-import Data.Foldable (fold)-import Data.List (intersperse)-import qualified Data.Text as T-import qualified Data.Text.Encoding as T-import qualified Data.Text.Lazy as LT-import qualified Data.Text.Internal.Builder as Builder-import qualified Data.ByteString as B-import Data.Attoparsec.Text as A hiding (parse, take)-import Data.List.NonEmpty (intersperse)--import Data.MIME.Charset (decodeLenient)-import Data.RFC5322.Address.Types-import Data.RFC5322.Internal---renderMailboxes :: [Mailbox] -> T.Text-renderMailboxes = LT.toStrict . Builder.toLazyText . buildMailboxes--buildMailboxes :: [Mailbox] -> Builder.Builder-buildMailboxes = fold . Data.List.intersperse ", " . fmap buildMailbox--renderMailbox :: Mailbox -> T.Text-renderMailbox = LT.toStrict . Builder.toLazyText . buildMailbox---- | Printing function to "pretty print" the mailbox for display purposes-buildMailbox :: Mailbox -> Builder.Builder-buildMailbox (Mailbox n a) =-  maybe a' (\n' -> "\"" <> Builder.fromText n' <> "\" " <> "<" <> a' <> ">") n-  where-    a' = buildAddressSpec a--renderAddresses :: [Address] -> T.Text-renderAddresses xs = T.intercalate ", " $ renderAddress <$> xs--renderAddress :: Address -> T.Text-renderAddress (Single m) = renderMailbox m-renderAddress (Group name xs) = name <> ":" <> renderMailboxes xs <> ";"--buildAddressSpec :: AddrSpec -> Builder.Builder-buildAddressSpec (AddrSpec lp (DomainDotAtom b))-  | " " `B.isInfixOf` lp = "\"" <> buildLP <> "\"" <> rest-  | otherwise = buildLP <> rest-  where-    buildLP = Builder.fromText $ decodeLenient lp-    rest = "@" <> foldMap Builder.fromText (decodeLenient <$> Data.List.NonEmpty.intersperse "." b)-buildAddressSpec (AddrSpec lp (DomainLiteral b)) =-  foldMap Builder.fromText [decodeLenient lp, "@", decodeLenient b]--renderAddressSpec :: AddrSpec -> T.Text-renderAddressSpec = LT.toStrict . Builder.toLazyText . buildAddressSpec----- §3.4 Address Specification-mailbox :: Parser Mailbox-mailbox = Mailbox <$> optional displayName <*> angleAddr-          <|> Mailbox Nothing <$> addressSpec---- | Version of 'phrase' that does not process encoded-word--- (we are parsing Text so will assume that the input does not--- contain encoded words.  TODO this is probably wrong :)-phrase :: Parser T.Text-phrase = foldMany1Sep (singleton ' ') word--displayName :: Parser T.Text-displayName = phrase--mailboxList :: Parser [Mailbox]-mailboxList = mailbox `sepBy` char ','--addressList :: Parser [Address]-addressList = address `sepBy` char ','--group :: Parser Address-group = Group <$> displayName <* char ':' <*> mailboxList <* char ';' <* optionalCFWS--address :: Parser Address-address = group <|> Single <$> mailbox--angleAddr :: Parser AddrSpec-angleAddr = optionalCFWS *>-  char '<' *> addressSpec <* char '>'-  <* optionalCFWS--addressSpec :: Parser AddrSpec-addressSpec = AddrSpec <$> (T.encodeUtf8 <$> localPart) <*> (char '@' *> domain)+  {-# DEPRECATED "Use \"Data.IMF.Text\" instead" #-}+  ( module X ) where -domain :: Parser Domain-domain = (DomainDotAtom . fmap T.encodeUtf8 <$> dotAtom)-         <|> (DomainLiteral . T.encodeUtf8 <$> domainLiteral)+import Data.IMF.Text as X
src/Data/RFC5322/Address/Types.hs view
@@ -1,7 +1,21 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}+-- This file is part of purebred-email+-- Copyright (C) 2021  Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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/>.  module Data.RFC5322.Address.Types+  {-# DEPRECATED "Use \"Data.IMF\" instead" #-}   (     Mailbox(..)   , Address(..)@@ -9,29 +23,4 @@   , Domain(..)   ) where -import Control.DeepSeq (NFData)-import qualified Data.Text as T-import qualified Data.ByteString as B-import Data.List.NonEmpty (NonEmpty)-import GHC.Generics (Generic)--data Mailbox =-    Mailbox (Maybe T.Text {- display name -})-             AddrSpec-    deriving (Show, Eq, Generic, NFData)--data AddrSpec =-    AddrSpec B.ByteString {- local part -}-             Domain-    deriving (Show, Eq, Generic, NFData)--data Address-    = Single Mailbox-    | Group T.Text {- display name -}-            [Mailbox]-    deriving (Show, Eq, Generic, NFData)--data Domain-    = DomainDotAtom (NonEmpty B.ByteString {- printable ascii -})-    | DomainLiteral B.ByteString-    deriving (Show, Eq, Generic, NFData)+import Data.IMF
src/Data/RFC5322/Internal.hs view
@@ -1,378 +1,21 @@-{-# LANGUAGE CPP #-}-{-# LANGUAGE ConstraintKinds #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE FunctionalDependencies #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE ScopedTypeVariables #-}--module Data.RFC5322.Internal-  (-  -- * Case-insensitive value parsers-    ci-  , CI-  , original--  -- * Abstract character parsers-  , wsp-  , optionalFWS-  , optionalCFWS-  , crlf-  , vchar-  , word-  , quotedString-  , dotAtom-  , localPart-  , domainLiteral-  , IsChar(..)-  , CharParsing(..)-  , SM--  -- ** Helpers for building parsers-  , isAtext-  , isQtext-  , isVchar-  , isWsp--  -- * Semigroup and monoid folding combinators-  , (<<>>)-  , foldMany-  , foldMany1-  , foldMany1Sep--  -- * General parsers and combinators-  , skipTill-  , takeTill'--  -- * Efficient string search-  , skipTillString-  , takeTillString--  ) where--import Prelude hiding (takeWhile)-import Control.Applicative ((<|>), Alternative, liftA2, many, optional)-import Control.Monad (void)-import qualified Data.Attoparsec.ByteString as A-import qualified Data.Attoparsec.Internal as A-import qualified Data.Attoparsec.Internal.Types as AT-import qualified Data.Attoparsec.Text as AText-import qualified Data.ByteString as B-import Data.ByteString.Internal (c2w, w2c)-import Data.ByteString.Search (indices)-import Data.CaseInsensitive (CI, FoldCase, mk, original)-import Data.Char (chr)-import Data.Foldable (fold)-import Data.Functor (($>))-import Data.List.NonEmpty (NonEmpty, fromList, intersperse)-import Data.Semigroup.Foldable (fold1)-import qualified Data.Text as T-import Data.Word (Word8)----- | Constraint synonym to handle the Semigroup Monoid Proposal--- transition gracefully.-#if MIN_VERSION_base(4,11,0)-type SM a = Monoid a-#else-type SM a = (Semigroup a, Monoid a)-#endif--class IsChar a where-  toChar :: a -> Char-  fromChar :: Char -> a--instance IsChar Char where-  toChar = id-  fromChar = id--instance IsChar Word8 where-  toChar = w2c-  fromChar = c2w--class IsChar a => CharParsing f s a | s -> a, a -> f s where-  singleton :: Char -> s-  satisfy :: (Char -> Bool) -> (f s) a-  takeWhile :: (Char -> Bool) -> (f s) s-  takeWhile1 :: (Char -> Bool) -> (f s) s--instance CharParsing AT.Parser B.ByteString Word8 where-  singleton = B.singleton . c2w-  satisfy f = A.satisfy (f . w2c)-  takeWhile f = A.takeWhile (f . w2c)-  takeWhile1 f = A.takeWhile1 (f . w2c)--instance CharParsing AT.Parser T.Text Char where-  singleton = T.singleton-  satisfy = AText.satisfy-  takeWhile = AText.takeWhile-  takeWhile1 = AText.takeWhile1--char :: CharParsing f s a => Char -> (f s) a-char c = satisfy (== c)--isWsp :: IsChar c => c -> Bool-isWsp = AText.inClass "\t " . toChar--wsp :: CharParsing f s a => (f s) a-wsp = satisfy isWsp--isVchar :: IsChar c => c -> Bool-isVchar c =-  let c' = toChar c-  in c' >= chr 0x21 && c' <= chr 0x7e--vchar :: CharParsing f s a => (f s) a-vchar = satisfy isVchar--dquote :: CharParsing f s a => (f s) a-dquote = char '"'--quotedPair :: (Alternative (f s)) => CharParsing f s a => (f s) a-quotedPair = char '\\' *> (vchar <|> wsp)---- §3.2.4.  Quoted Strings--isQtext :: IsChar c => c -> Bool-isQtext c' =-  let c = toChar c'-  in-    c == chr 33-    || (c >= chr 35 && c <= chr 91)-    || (c >= chr 93 && c <= chr 126)--quotedString :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-quotedString =-  optionalCFWS *> dquote-  *> foldMany (optionalFWS <<>> qcontent) <<>> optionalFWS-  <* dquote <* optionalCFWS-  where-    qcontent =-      (singleton . toChar <$> satisfy isQtext)-      <|> (singleton . toChar <$> quotedPair)--isAtext :: IsChar c => c -> Bool-isAtext = AText.inClass "-A-Za-z0-9!#$%&'*+/=?^_`{|}~" . toChar--atext :: CharParsing f s a => (f s) a-atext = satisfy isAtext---- | Either CRLF or LF (lots of mail programs transform CRLF to LF)-crlf :: (Alternative (f s)) => CharParsing f s a => (f s) ()-crlf = void ((char '\r' *> char '\n') <|> char '\n')---- §3.2.2.  Folding White Space and Comments------ "The general rule is that wherever this specification allows for folding--- white space (not simply WSP characters), a CRLF may be inserted before any--- WSP."--fws :: (Alternative (f s), CharParsing f s a) => (f s) s-fws = ( optional (takeWhile isWsp *> crlf) *> takeWhile1 isWsp )-      $> singleton ' '---- | FWS collapsed to a single SPACE character, or empty string----optionalFWS :: (Alternative (f s), CharParsing f s a, Monoid s) => (f s) s-optionalFWS = fws <|> pure mempty---- | Printable ASCII excl. '(', ')', '\'-isCtext :: Char -> Bool-isCtext c =-  c >= chr 33 && c <= chr 39-  || c >= chr 42 && c <= chr 91-  || c >= chr 93 && c <= chr 126--ccontent :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-ccontent = (singleton . toChar <$> satisfy isCtext) <|> comment--comment :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-comment =-  char '(' *> foldMany (optionalFWS <<>> ccontent) <* optionalFWS <* char ')'--cfws :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-cfws =-  ((foldMany1 (optionalFWS <<>> comment) *> optionalFWS) $> singleton ' ')-  <|> fws---- | CFWS collapsed to a single SPACE character, or empty string----optionalCFWS :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-optionalCFWS = cfws <|> pure mempty--atom :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-atom = optionalCFWS *> foldMany1 (singleton . toChar <$> atext) <* optionalCFWS--word :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-word = atom <|> quotedString--dotAtomText :: (Alternative (f s), CharParsing f s a) => (f s) (NonEmpty s)-dotAtomText = fromList <$> (takeWhile1 isAtext `A.sepBy1` char '.')--dotAtom :: (Alternative (f s), CharParsing f s a, SM s) => (f s) (NonEmpty s)-dotAtom = optionalCFWS *> dotAtomText <* optionalCFWS--localPart :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-localPart = (fold . intersperse (singleton '.') <$> dotAtom) <|> quotedString---- | Printable US-ASCII excl "[", "]", or "\"-isDtext :: Char -> Bool-isDtext c = (c >= chr 33 && c <= chr 90) || (c >= chr 94 && c <= chr 126)--dText :: CharParsing f s a => (f s) a-dText = satisfy isDtext--domainLiteral :: (Alternative (f s), CharParsing f s a, SM s) => (f s) s-domainLiteral =-  optionalCFWS *> char '['-  *> foldMany (optionalFWS <<>> (singleton . toChar <$> dText) <<>> optionalFWS)-  <* char ']' <* optionalFWS----- | Modify a parser to produce a case-insensitive value----ci :: FoldCase s => A.Parser s -> A.Parser (CI s)-ci = fmap mk----- | Combine two semigroup parsers into one-(<<>>) :: (Semigroup m, Applicative f) => f m -> f m -> f m-(<<>>) = liftA2 (<>)---- | Parse zero or more values and fold them-foldMany :: (Monoid m, Alternative f) => f m -> f m-foldMany = fmap fold . many---- | Parse one or more values and fold them-foldMany1 :: (Semigroup m, Alternative f) => f m -> f m-foldMany1 = fmap (fold1 . fromList) . A.many1---- | Parse one or more values and fold them with a separating element-foldMany1Sep :: (Semigroup m, Alternative f) => m -> f m -> f m-foldMany1Sep sep = fmap (fold1 . intersperse sep . fromList) . A.many1---- | Skip until the given parser succeeds------ @@--- λ> parseOnly (string "foo" *> skipTill (string ".") *> endOfInput) "foobar."--- Right ()--- @@----skipTill :: A.Parser a -> A.Parser ()-skipTill = void . spanTill---- | Current offset in the input-position :: AT.Parser i Int-position = AT.Parser $ \t pos more _lose suc -> suc t pos more (AT.fromPos pos)---- | Number of elements between current position and first position--- at which parser matches (fails if it never matches).  Also--- consumes the input on which the parser succeeds.------ @@--- λ> parseOnly (string "foo" *> spanTill (string ".")) "foobar."--- Right 3--- λ> parseOnly (string "foo" *> spanTill (string ".")) "foobar"--- Left "not enough input"--- @@----spanTill :: A.Parser a -> A.Parser Int-spanTill p = liftA2 (flip (-)) position q-  where-  q = position <* p <|> A.anyWord8 *> q---- | Run the parser from the specified offset.------ Should only be used to seek backwards, otherwise--- you could seek beyond the buffer.  User beware.------ @@--- λ> parseOnly (seek 3 *> takeByteString) "foobar"--- Right "bar"--- @@----seek :: Int -> A.Parser ()-seek pos = AT.Parser $ \t _pos more _lose win -> win t (AT.Pos pos) more ()---- | Take until the parser matches (fails if it never matches).------ @@--- λ> parseOnly (takeTill' (string "bar") <* endOfInput) "foobar"--- Right "foo"--- @@----takeTill' :: A.Parser a -> A.Parser B.ByteString-takeTill' p = do-  pos <- position-  off <- spanTill p-  newPos <- position-  seek pos *> A.take off <* seek newPos---- | Number of elements between current position and first position--- at which the pattern matches (fails if it never matches).  Also--- consumes the pattern.------ Uses Boyer-Moore algorithm to efficiently search the input.------ @@--- λ> parseOnly (string "foo" *> spanTillString ".") "foobar."--- Right 3--- λ> parseOnly (string "foo" *> spanTillString ".") "foobar"--- Left "not enough input"--- @@----spanTillString :: B.ByteString -> A.Parser Int-spanTillString pat-  | B.null pat = position-  | otherwise = position >>= go-  where-  search = indices pat-  go start = do-    pos <- position-    buf <- takeBuffer-    case search buf of-      (offset:_) ->-        -- Pattern was found.  Seek to end of pattern and return the span-        seek (pos + offset + B.length pat) $> pos + offset - start-      _ ->-        -- We hit the end of the buffer without a match.  Seek to-        -- (length buf - length pat), demand more input and go again.-        seek (max start (B.length buf - B.length pat)) *> A.demandInput *> go start---- | Efficient skip, using Boyer-Moore to locate the pattern.------ @@--- λ> parseOnly (string "foo" *> skipTillString "." *> endOfInput) "foobar."--- Right ()--- @@+-- This file is part of purebred-email+-- Copyright (C) 2021  Fraser Tweedale ---skipTillString :: B.ByteString -> A.Parser ()-skipTillString = void . spanTillString---- | Efficient take, using Boyer-Moore to locate the pattern.+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version. ----- @@--- λ> parseOnly (takeTillString "bar" <* endOfInput) "foobar"--- Right "foo"--- @@+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details. ---takeTillString :: B.ByteString -> A.Parser B.ByteString-takeTillString pat = do-  pos <- position-  off <- spanTillString pat-  newPos <- position-  seek pos *> A.take off <* seek newPos+-- 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/>. --- | /O(1)/ Take the rest of the buffer, but do not demand--- any more input.----takeBuffer :: A.Parser B.ByteString-takeBuffer = do-  start <- position-  end <- bufSize-  A.take (end - start)+module Data.RFC5322.Internal+  {-# DEPRECATED "Use \"Data.IMF.Syntax\" instead" #-}+  ( module X ) where -bufSize :: forall t. AT.Chunk t => AT.Parser t Int-bufSize = AT.Parser $-  \t pos more _lose win ->-    win t pos more-      (AT.fromPos $ AT.atBufferEnd (undefined :: t) t)+import Data.IMF.Syntax as X
tests/ContentTransferEncodings.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2017-2018  Róman Joost and Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 #-}
tests/EncodedWord.hs view
@@ -1,7 +1,20 @@-module EncodedWord where+-- This file is part of purebred-email+-- Copyright (C) 2020  Stephen Paul Weber and Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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/>. -import qualified Data.Text as T-import qualified Data.Text.Encoding as T+module EncodedWord where  import Data.MIME.Charset import Data.MIME.EncodedWord
tests/Generator.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2018-2021  Róman Joost and Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 OverloadedStrings #-} {-# LANGUAGE FlexibleInstances #-} @@ -11,9 +27,7 @@ import Test.Tasty.Golden (goldenVsStringDiff) import Control.Lens (over, (&), set, at) import qualified Data.Text.Encoding as T-import qualified Data.ByteString.Lazy as LB-import Data.Time.Clock (UTCTime(..), secondsToDiffTime)-import Data.Time.Calendar (Day(..))+import Data.Time (Day(..), UTCTime(..), secondsToDiffTime, utc, utcToZonedTime)  import qualified Data.CaseInsensitive as CI @@ -76,9 +90,11 @@                 contentTypeApplicationOctetStream                 (Just "foo.bin")                 "fileContentsASDF"-        now = UTCTime (ModifiedJulianDay 123) (secondsToDiffTime 123)-    in createMultipartMixedMessage "asdf" (fromList [p, a])+        nowUTC = UTCTime (ModifiedJulianDay 123) (secondsToDiffTime 123)+        now = utcToZonedTime utc nowUTC+        Right boundary = makeBoundary "asdf"+    in createMultipartMixedMessage boundary (fromList [p, a])        & set (headers . at "From") (Just $ renderMailboxes [from'])        . set (headers . at "To") (Just $ renderAddresses [to'])-       . set (headers . at "Date") (Just $ renderRFC5422Date now)+       . set (headers . at "Date") (Just $ renderRFC5322Date now)        . set (headers . at "Subject") (Just $ T.encodeUtf8 subject)
tests/Headers.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2018-2021  Fraser Tweedale and Róman Joost+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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 #-} {-# LANGUAGE OverloadedStrings #-} module Headers where@@ -22,7 +38,7 @@ import Test.QuickCheck.Instances ()  import Data.MIME-import qualified Data.RFC5322.Address.Text as AddressText+import qualified Data.IMF.Text as AddressText   (mailbox, address, renderAddress)  renderField :: (CI.CI B.ByteString, B.ByteString) -> L.ByteString@@ -40,7 +56,7 @@   , ixAndAt   , contentTypeTests   , parameterTests-  , testReferencesField+  , testReply   , testFromToCcBccOptics   , testProperty "field rendering round-trip" prop_renderHeadersRoundtrip   , testProperty "folded fields no longer than 78 chars" prop_foldedUnstructuredLimited@@ -49,17 +65,14 @@ testFromToCcBccOptics :: TestTree testFromToCcBccOptics = testGroup "headerFrom/To/Cc/Bcc tests" $   let-    alice = Mailbox Nothing (AddrSpec "alice" (DomainDotAtom ("example" :| ["com"])))-    bob = Mailbox Nothing (AddrSpec "bob" (DomainDotAtom ("example" :| ["com"])))-    carol = Mailbox Nothing (AddrSpec "carol" (DomainDotAtom ("example" :| ["com"])))     msg = createTextPlainMessage "hi"-    fromAlice = set (headerFrom defaultCharsets) [alice] msg+    fromAlice = set (headerFrom defaultCharsets) [Single alice] msg     fromAliceToBob = set (headerTo defaultCharsets) [Single bob] fromAlice     fromAliceToCarolAndBob = over (headerTo defaultCharsets) (Single carol :) fromAliceToBob   in     [ testCase "From empty" $ view (headerFrom defaultCharsets) msg @?= []     , testCase "To empty" $ view (headerTo defaultCharsets) msg @?= []-    , testCase "set From alice" $ view (headerFrom defaultCharsets) fromAlice @?= [alice]+    , testCase "set From alice" $ view (headerFrom defaultCharsets) fromAlice @?= [Single alice]     , testCase "set To bob" $ view (headerTo defaultCharsets) fromAliceToBob @?= [Single bob]     , testCase "add To carol" $ view (headerTo defaultCharsets) fromAliceToCarolAndBob @?= [Single carol, Single bob]     , testCase "removing header" $ has (header "From") (set (headerFrom defaultCharsets) [] fromAlice) @?= False@@ -99,8 +112,8 @@ rendersAddressesToTextSuccessfully :: TestTree rendersAddressesToTextSuccessfully =   testGroup "renders addresses to text" $-  (\(desc, address, expected) ->-     testCase desc $ expected @=? AddressText.renderAddress address) <$>+  (\(desc, addr, expected) ->+     testCase desc $ expected @=? AddressText.renderAddress addr) <$>   xs   where     xs =@@ -285,43 +298,124 @@       @?= Headers [("Content-Disposition", "attachment; foo=bar")]   ] --- RFC5322 - 3.6.4. Identification Fields-testReferencesField :: TestTree-testReferencesField =-  testGroup "references field in reply" $-  (\(desc, hdrs, expected) ->-     testCase desc $ expected @=? view replyHeaderReferences hdrs) <$>-  fixtures+-- Test the 'reply' function+testReply :: TestTree+testReply =+  testGroup "test 'reply' function" $+    [ testCase "Message-ID -> In-Reply-To" $+        view headerInReplyTo rep1 @?= [msg1ID]+    , testCase "Message-Id -> References" $+        view headerReferences rep1 @?= [msg1ID]+    , testCase "References + Message-Id -> References" $+        view headerReferences rep2 @?= [msg1ID, rep1ID]+    , testCase "In-Reply-To (no References) + Message-Id -> References" $+        view headerReferences rep_noRef_IRT @?= [msg1ID, rep1ID]+    , testCase "multi In-Reply-To (no Ref) + Message-Id -> Ref = [msgid]" $+        view headerReferences rep_noRef_2IRT @?= [rep1ID]++    , testCase "prepends 'Re: ' to Subject" $+        view (headerSubject defaultCharsets) rep1+          @?= Just "Re: Hello, world!"+    , testCase "doesn't prepend 'Re: ' if already a prefix" $+        view (headerSubject defaultCharsets) rep2+          @?= Just "Re: Hello, world!"++    , testCase "GroupReply (remove self)" $ do+        view (headerFrom defaultCharsets) msg1+          @=? view (headerTo defaultCharsets) rep1+        view (headerTo defaultCharsets) rep1+          @?= [Single alice]+        view (headerCC defaultCharsets) rep1+          @?= [Single carol, Single frank]++    , testCase "GroupReply (ignore self)" $ do+        view (headerFrom defaultCharsets) msg1+          @=? view (headerTo defaultCharsets) rep1'+        view (headerTo defaultCharsets) rep1'+          @?= [Single alice]+        view (headerCC defaultCharsets) rep1'+          @?= [Single bob, Single carol, Single frank]++    , testCase "SingleReply" $ do+        view (headerFrom defaultCharsets) rep1+          @=? view (headerTo defaultCharsets) rep2+        view (headerCC defaultCharsets) rep2 @=? []++    , testCase "ReplyFromMatchingMailbox, ReplyFromRewriteOn" $ do+        view (headerFrom defaultCharsets) rep2+          @?= [Single carolWithDisplayName]++    , testCase "ReplyFromMatchingMailbox, ReplyFromRewriteOff" $ do+        view (headerFrom defaultCharsets) rep2'+          @?= [Single carol]++    , testCase "ReplyFromPreferredMailbox" $ do+        view (headerFrom defaultCharsets) rep2''+          @?= [Single "carol@unknown.example.org"]++    , testCase "Reply-To -> To" $+        view (headerTo defaultCharsets) rep_ReplyTo @?= [Single frank]+    ]   where-    fixtures =-      [ ("no ident fields", empty, Nothing)-      , ( "messageid only"-        , empty & set (at "message-id") (Just "asdf")-        , Just "asdf")-      , ( "references only"-        , empty & set (at "references") (Just "references")-        , Just "references")-      , ( "references & message id"-        , empty &-          set (at "references") (Just "references") .-          set (at "message-id") (Just "messageid")-        , Just "references messageid")-      , ( "in-reply-to and no references"-        , empty & set (at "in-reply-to") (Just "replyto")-        , Just "replyto")-      , ( "in-reply-to and message-id"-        , empty &-          set (at "in-reply-to") (Just "replyto") .-          set (at "message-id") (Just "message-id")-        , Just "replyto message-id")-      , ( "in-reply-to and references"-        , empty &-          set (at "in-reply-to") (Just "replyto") .-          set (at "references") (Just "references") .-          set (at "message-id") (Just "messageid")-        , Just "references messageid")-      ]+    mkSettings = ReplySettings+      ReplyToSender+      ReplyFromMatchingMailbox+      ReplyFromRewriteOn+      SelfInRecipientsRemove+    bobSettings   = mkSettings (pure bob) & set replyMode ReplyToGroup+    carolSettings =+      mkSettings ("carol@unknown.example.org" :| [carolWithDisplayName]) +    extraMsgId = (\(Right a) -> a) $ parseOnly parseMessageID "<extra@host>"++    msg1ID = (\(Right a) -> a) $ parseOnly parseMessageID "<msg1@host>"+    msg1 = createTextPlainMessage "hello, world!"+      & set headerMessageID (Just msg1ID)+      . set (headerSubject defaultCharsets) (Just "Hello, world!")+      . set (headerFrom defaultCharsets) [Single alice]+      . set (headerTo defaultCharsets) [Single bob, Single carol]+      . set (headerCC defaultCharsets) [Single frank]++    rep1ID = (\(Right a) -> a) $ parseOnly parseMessageID "<rep1@host>"+    rep1 = reply defaultCharsets bobSettings msg1+      & set headerMessageID (Just rep1ID)+    rep1' =  -- same as rep1, but with SelfInRecipientsIgnore+      let bobSettings' =+            bobSettings & set selfInRecipientsMode SelfInRecipientsIgnore+      in reply defaultCharsets bobSettings' msg1+          & set headerMessageID (Just rep1ID)++    rep2ID = (\(Right a) -> a) $ parseOnly parseMessageID "<rep2@host>"+    rep2 = reply defaultCharsets carolSettings rep1+      & set headerMessageID (Just rep2ID)+    rep2' =  -- same as rep2, but with ReplyFromRewriteOff+      let carolSettings' =+            carolSettings & set replyFromRewriteMode ReplyFromRewriteOff+      in reply defaultCharsets carolSettings' rep1+          & set headerMessageID (Just rep2ID)+    rep2'' = -- same as rep2, but with ReplyFromPreferredMailbox+      let carolSettings' =+            carolSettings & set replyFromMode ReplyFromPreferredMailbox+      in reply defaultCharsets carolSettings' rep1+          & set headerMessageID (Just rep2ID)++    -- reply to a message with no References + single-valued In-Reply_To+    rep_noRef_IRT =+      reply defaultCharsets carolSettings (set headerReferences [] rep1)++    -- reply to a message with no References + multi-valued In-Reply_To+    rep_noRef_2IRT =+      reply defaultCharsets carolSettings $+        rep1+          & set headerReferences []+          & over headerInReplyTo (extraMsgId:)++    -- reply to a message with Reply-To header set+    rep_ReplyTo =+      reply defaultCharsets carolSettings+        (set (headerReplyTo defaultCharsets) [Single frank] msg1)++ -- | Generate headers genFieldItem :: Gen B.ByteString genFieldItem = resize 55 (B.pack <$> listOf1 (suchThat arbitrary isFtext))@@ -362,3 +456,10 @@         ""      -> [acc <> h]         "\r\n"  -> acc <> h : go "" (L.drop 2 t)         _       -> go (acc <> h <> L.take 1 t) (L.drop 1 t)++alice, bob, carol, carolWithDisplayName, frank :: Mailbox+alice = "alice@example.com"+bob = "bob@example.com"+carol = "carol@example.com"+carolWithDisplayName = "Carol Charlie <carol@example.com>"+frank = "frank@example.com"
tests/MIME.hs view
@@ -26,11 +26,11 @@ import Data.Either (isLeft) import Data.List.NonEmpty (fromList) import Data.String (fromString)-import Data.Time.Clock (UTCTime)  import Control.Lens import qualified Data.ByteString as B import qualified Data.Text as T+import Data.Time (ZonedTime(ZonedTime), timeZoneMinutes)  import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit ((@?=), Assertion, assertBool, assertFailure, testCase)@@ -136,14 +136,14 @@         set           (attachments . headers . contentDisposition . traversed . filenameParameter)           Nothing-        (Message (Headers []) (Multipart . fromList $+        (Message (Headers []) (Multipart Mixed boundary . fromList $           [ Message (Headers [("Content-Disposition", "inline; filename=msg.txt")]) (Part "")           , Message (Headers [("Content-Disposition", "attachment; filename=foo.pdf")]) (Part "")           , Message (Headers [("Content-Disposition", "attachment; filename=bar.pdf")]) (Part "")           ]         ))         @?=-        Message (Headers []) (Multipart . fromList $+        Message (Headers []) (Multipart Mixed boundary . fromList $           [ Message (Headers [("Content-Disposition", "inline; filename=msg.txt")]) (Part "")           , Message (Headers [("Content-Disposition", "attachment")]) (Part "")           , Message (Headers [("Content-Disposition", "attachment")]) (Part "")@@ -153,6 +153,7 @@   where     lFilename = headers . contentDisposition . traversed . filename defaultCharsets     stripPath = snd . T.breakOnEnd "/"+    Right boundary = makeBoundary "boundary"  testParse :: TestTree testParse = testGroup "parsing tests"@@ -200,6 +201,10 @@       testHeaderDateGet       "Thu, 4 May 2017 03:08:43 +0000"       (Just $ read "2017-05-04 03:08:43 UTC")+  , testCase "headerDate get valid date with comment" $+      testHeaderDateGet+      "Fri, 15 Jan 2021 18:17:11 -0500 (EST)"+      (Just $ read "2021-01-15 18:17:11 EST")   , testCase "headerDate get invalid date" $       testHeaderDateGet       "Thu, 4 NOTMAY 2017 03:08:43 +0000"@@ -209,15 +214,17 @@   , testCase "headerDate unset" $ testHeaderDateSet Nothing   ] -testHeaderDateGet :: B.ByteString -> Maybe UTCTime -> Assertion+testHeaderDateGet :: B.ByteString -> Maybe ZonedTime -> Assertion testHeaderDateGet headerStr time =-  view headerDate msg @?= time+  fmap explode (view headerDate msg) @?= fmap explode time   where     Right msg = parse (message mime) msgStr     msgStr = "Date: " <> headerStr <> "\n\nbody\n" :: B.ByteString+    explode (ZonedTime lt z) = (lt, timeZoneMinutes z) -testHeaderDateSet :: Maybe UTCTime -> Assertion+testHeaderDateSet :: Maybe ZonedTime -> Assertion testHeaderDateSet time =-  view headerDate msg @?= time+  fmap explode (view headerDate msg) @?= fmap explode time   where+    explode (ZonedTime lt z) = (lt, timeZoneMinutes z)     msg = set headerDate time $ createTextPlainMessage "body"
tests/Message.hs view
@@ -31,16 +31,19 @@  import Control.Lens (set, view) import qualified Data.ByteString as B+import Data.CaseInsensitive import qualified Data.Text as T+import System.Random (uniform)  import Test.Tasty import Test.Tasty.Hedgehog import Hedgehog import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Internal.Gen as Gen import qualified Hedgehog.Range as Range  import Data.MIME-import Data.RFC5322.Internal (isAtext)+import Data.IMF.Syntax (isAtext)  tests :: TestTree tests = testGroup "message tests"@@ -72,7 +75,7 @@   -- Generate a 50 character multipart boundary.  These MUST be unique   -- for all (nested) multipart messages.  Assume negligible probability   -- of collision.-  genBoundary = Gen.utf8 (Range.singleton 50) printableAsciiChar+  genBoundary = Gen.generate (\_size seed -> fst (uniform seed))    go 0 = genTextPlain   go n = createMultipartMixedMessage@@ -103,14 +106,14 @@  prop_messageFromRoundTrip :: Property prop_messageFromRoundTrip = property $ do-  from <- forAll genMailbox+  from <- Single <$> forAll genMailbox   let     l = headerFrom defaultCharsets     msg = set l [from] (createTextPlainMessage "Hello")   (view l <$> parse (message mime) (renderMessage msg)) === Right [from]  genDomain :: Gen Domain-genDomain = DomainDotAtom <$> genDotAtom -- TODO domain literal+genDomain = DomainDotAtom . fmap mk <$> genDotAtom -- TODO domain literal  genDotAtom :: Gen (NonEmpty B.ByteString) genDotAtom = Gen.nonEmpty (Range.linear 1 5) (Gen.utf8 (Range.linear 1 20) (Gen.filter isAtext Gen.ascii))
tests/Parser.hs view
@@ -1,5 +1,5 @@ -- This file is part of purebred-email--- Copyright (C) 2019  Fraser Tweedale+-- Copyright (C) 2019, 2021  Fraser Tweedale -- -- purebred-email is free software: you can redistribute it and/or modify -- it under the terms of the GNU Affero General Public License as published by@@ -21,15 +21,17 @@ import Data.Either (isLeft)  import Data.Attoparsec.ByteString.Lazy as AL-import Data.ByteString as B-import Data.ByteString.Lazy as L+import qualified Data.ByteString as B+import qualified Data.ByteString.Char8 as Char8+import qualified Data.ByteString.Lazy as L+import Data.Time (ZonedTime(ZonedTime), timeZoneMinutes) import Test.QuickCheck.Instances () import Test.Tasty import Test.Tasty.HUnit import Test.Tasty.QuickCheck -import Data.RFC5322-import Data.RFC5322.Internal (takeTillString)+import Data.IMF+import Data.IMF.Syntax (takeTillString)  tests :: TestTree tests = testGroup "Parser tests"@@ -43,6 +45,7 @@             AL.Done r' l' -> r' == L.fromStrict r && l' == l             _ -> False   , testBodyParsing+  , testDateTimeParsing   ]  @@ -79,3 +82,100 @@     msg = "From: alice@example.net\r\n"     hdrs = Headers [("From", "alice@example.net")]     opt _ = OptionalBody (Present <$> AL.string "yeah", NotPresent)++testDateTimeParsing :: TestTree+testDateTimeParsing = testGroup "dateTime parsing" $+  let+    now = explode $ read "2021-01-03 20:16:00 +1000"+    go = fmap explode . parseOnly (dateTime <* endOfInput)+    explode (ZonedTime lt z) = (lt, timeZoneMinutes z)+  in+    [ testCase "good (full)" $+        go "Sun, 03 Jan 2021 20:16:00 +1000"+        @?= Right now+    , testCase "good (comment)" $+        go "Sun, 03 Jan 2021 20:16:00 +1000 (wat)"+        @?= Right now+    , testCase "good (extra whitespace)" $+        go "  Sun,  03  Jan  2021  20:16:00  +1000  "+        @?= Right now+    , testCase "good (no seconds)" $+        go "Sun, 03 Jan 2021 20:16 +1000"+        @?= Right now+    , testCase "good (no day-of-week)" $+        go "03 Jan 2021 20:16:00 +1000"+        @?= Right now+    , testCase "good (single-digit date)" $+        go "Sun, 3 Jan 2021 20:16:00 +1000"+        @?= Right now+    , testCase "good (leap second)" $+        go "Sun, 03 Jan 2021 20:16:60 +1000"+        @?= Right (explode $ read "2021-01-03 20:16:60 +1000")+    , testCase "good (5-digit year)" $+        go "Sun, 03 Jan 12021 20:16:00 +1000"+        @?= Right (explode $ read "12021-01-03 20:16:00 +1000")+    , testCase "bad (invalid day-of-week)" $+        isLeft (go "Moo, 03 Jan 2021 20:16:00 +1000")+        @?= True+    , testCase "bad (inconsistent day-of-week)" $+        isLeft (go "Mon, 03 Jan 2021 20:16:00 +1000")+        @?= True+    , testCase "bad (day < 1)" $+        isLeft (go "Thu, 01 Jan 2021 20:16:00 +1000")+        @?= True+    , testCase "bad (out of range day)" $+        isLeft (go "Tue, 33 Jan 2021 20:16:00 +1000")+        @?= True+    , testCase "bad (inconsistent day of month)" $+        isLeft (go "Mon, 29 Feb 2021 20:16:00 +1000")+        @?= True+    , testCase "bad (invalid month)" $+        isLeft (go "Sun, 03 Joo 2021 20:16:00 +1000")+        @?= True+    , testCase "bad (year insufficient digits)" $+        isLeft (go "03 Jan 9 20:16:00 +1000")+        @?= True+    , testCase "bad (year < 1900)" $+        isLeft (go "Sun, 03 Jan 1899 20:16:00 +1000")+        @?= True+    , testCase "bad (hour > 23)" $+        isLeft (go "Sun, 03 Jan 2021 24:16:00 +1000")+        @?= True+    , testCase "bad (minute > 59)" $+        isLeft (go "Sun, 03 Jan 2021 20:60:00 +1000")+        @?= True+    , testCase "bad (second > 60)" $+        isLeft (go "Sun, 03 Jan 2021 20:16:61 +1000")+        @?= True+    , testCase "bad (no timezone)" $+        isLeft (go "Sun, 03 Jan 2021 20:16:00")+        @?= True+    , testCase "bad (invalid tz seconds)" $+        isLeft (go "Sun, 03 Jan 2021 20:16:00 +0960")+        @?= True+    , testCase "good (obs-zone grammar)" $+        (fmap (fmap snd) . traverse go . fmap ("Sun, 03 Jan 2021 20:16:00 " <>))+          (+            ["UT", "GMT", "EST", "EDT", "CST", "CDT", "MST", "MDT", "PST", "PDT"]+            <> fmap Char8.singleton (['A'..'I'] <> ['K'..'Z'] <> ['a'..'i'] <> ['k'..'z'])+          )+        @?= Right (+          let mil = [1..12] <> fmap negate [1..12] <> [0]+          in fmap (* 60) ([0, 0, -5, -4, -6, -5, -7, -6, -8, -7] <> mil <> mil)+        )+    , testCase "bad (obs-zone grammar)" $+        all (isLeft . go) (("Sun, 03 Jan 2021 20:16:00 " <>) <$> ["UTC", "CET", "AEST", "J"])+        @?= True+    , testCase "good (obs-year 2 digit)" $+        go "Sun, 03 Jan 21 20:16:00 +1000"+        @?= Right now+    , testCase "good (obs-year 3 digit)" $+        go "Sun, 03 Jan 121 20:16:00 +1000"+        @?= Right now+    , testCase "good (obs-year 2 digit 2049)" $+        go "Sun, 03 Jan 49 20:16:60 +1000"+        @?= Right (explode $ read "2049-01-03 20:16:60 +1000")+    , testCase "good (obs-year 2 digit 1950)" $+        go "Tue, 03 Jan 50 20:16:60 +1000"+        @?= Right (explode $ read "1950-01-03 20:16:60 +1000")+    ]
tests/Test.hs view
@@ -1,3 +1,19 @@+-- This file is part of purebred-email+-- Copyright (C) 2017-2020  Róman Joost and Fraser Tweedale+--+-- purebred-email is free software: you can redistribute it and/or modify+-- it under the terms of the GNU Affero General Public License as published by+-- the Free Software Foundation, either version 3 of the License, or+-- (at your option) any later version.+--+-- This program is distributed in the hope that it will be useful,+-- but WITHOUT ANY WARRANTY; without even the implied warranty of+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+-- GNU Affero General Public License for more details.+--+-- 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/>.+ import Test.Tasty  import ContentTransferEncodings as CTE