-- 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 .
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
{-# LANGUAGE OverloadedStrings #-}
module Headers where
import Control.Lens
import Data.List.NonEmpty (NonEmpty((:|)))
import Data.String (IsString)
import Data.Word (Word8)
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.Lazy.Char8 as L8
import Data.Attoparsec.ByteString.Char8 (parseOnly)
import qualified Data.Attoparsec.Text as AText (parseOnly)
import qualified Data.ByteString.Builder as Builder
import qualified Data.CaseInsensitive as CI
import Data.Either (isLeft)
import Test.Tasty
import Test.Tasty.HUnit (assertBool, (@=?), (@?=), testCase, Assertion)
import Test.Tasty.QuickCheck
import Test.QuickCheck.Instances ()
import Data.MIME
import qualified Data.IMF.Text as AddressText
(mailbox, address, renderAddress)
renderField :: (CI.CI B.ByteString, B.ByteString) -> L.ByteString
renderField = Builder.toLazyByteString . buildField
unittests :: TestTree
unittests = testGroup "Headers"
[ parsesMailboxesSuccessfully
, parsesTextMailboxesSuccessfully
, parsesAddressesSuccessfully
, parsesTextAddressesSuccessfully
, rendersAddressesToTextSuccessfully
, testRenderMailboxes
, rendersFieldsSuccessfully
, ixAndAt
, contentTypeTests
, parameterTests
, testReply
, testFromToCcBccOptics
, testProperty "field rendering round-trip" prop_renderHeadersRoundtrip
, testProperty "folded fields no longer than 78 chars" prop_foldedUnstructuredLimited
]
testFromToCcBccOptics :: TestTree
testFromToCcBccOptics = testGroup "headerFrom/To/Cc/Bcc tests" $
let
msg = createTextPlainMessage "hi"
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 @?= [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
]
rendersFieldsSuccessfully :: TestTree
rendersFieldsSuccessfully =
testGroup "correct folding for unstructured" $
(\(description,h,expected) ->
testCase description (expected @=? renderField h)) <$>
inputs
where
inputs =
[ ( "no folding"
, ("Subject", "Re: Simple Subject")
, "Subject: Re: Simple Subject\r\n")
, ( "continuous line"
, ("Subject", "ThisisalongcontiniousLineWithoutAnyWhiteSpaceandNowSomeGarbageASDFASDFASDFASDF")
, "Subject: ThisisalongcontiniousLineWithoutAnyWhiteSpaceandNowSomeGarbageASDFASDFASDFASDF\r\n")
, ( "folding"
, ( "Received"
, "from adsl-33-138-215-182-129-129.test.example ([XX.XX.XXX.XXX]) by this.is.another.hostname.example with esmtp (Exim 4.24) id 1Akwaj-00035l-NT for me@test.example; Sun, 25 2004 21:35:09 -0500")
, "Received: from adsl-33-138-215-182-129-129.test.example ([XX.XX.XXX.XXX])\r\n by this.is.another.hostname.example with esmtp (Exim 4.24) id\r\n 1Akwaj-00035l-NT for me@test.example; Sun, 25 2004 21:35:09 -0500\r\n")
, ( "folding with long words"
, ( "X-Test" , "these are short words and more and more and more and all asdfsdf of a suddenALongWordAppears")
, "X-Test: these are short words and more and more and more and all asdfsdf of\r\n a suddenALongWordAppears\r\n")
]
testRenderMailboxes :: TestTree
testRenderMailboxes = testCase "test renderMailboxes" $
renderMailboxes xs @?= "\"Roman Joost\" , bar@bar.com"
where
xs = [ Mailbox (Just "Roman Joost") (AddrSpec "foo" (DomainDotAtom ("bar" :| ["com"])))
, Mailbox Nothing (AddrSpec "bar" (DomainDotAtom ("bar" :| ["com"])))
]
rendersAddressesToTextSuccessfully :: TestTree
rendersAddressesToTextSuccessfully =
testGroup "renders addresses to text" $
(\(desc, addr, expected) ->
testCase desc $ expected @=? AddressText.renderAddress addr) <$>
xs
where
xs =
[ ( "single address"
, Single
(Mailbox Nothing (AddrSpec "foo" (DomainDotAtom $ pure "bar.com")))
, "foo@bar.com")
, ( "group of addresses"
, Group "Group"
[ Mailbox
(Just "Mr Foo")
(AddrSpec "foo" (DomainDotAtom $ pure "bar.com"))
, Mailbox
(Just "Mr Bar")
(AddrSpec "bar" (DomainDotAtom $ pure "bar.com"))
]
, "Group:\"Mr Foo\" , \"Mr Bar\" ;")
, ( "group of undisclosed recipients"
, Group "undisclosed-recipients" []
, "undisclosed-recipients:;")
]
-- | Note some examples are taken from https://tools.ietf.org/html/rfc3696#section-3
mailboxFixtures :: IsString s => [(String, Either String Mailbox -> Assertion, s)]
mailboxFixtures =
[ ( "address with FQDN"
, (Right (Mailbox Nothing (AddrSpec "foo" (DomainDotAtom $ "bar" :| ["com"]))) @=?)
, "foo@bar.com")
, ( "just with a host name"
, (Right (Mailbox Nothing (AddrSpec "foo" (DomainDotAtom $ pure "bar"))) @=?)
, "foo@bar")
, ( "domain as IPv4"
, (Right (Mailbox (Just "roman") (AddrSpec "roman" (DomainLiteral "192.168.1.1"))) @=?)
, "roman ")
, ( "domain as IPv6"
, (Right (Mailbox (Just "roman") (AddrSpec "roman" (DomainLiteral "::1"))) @=?)
, "roman ")
, ( "without TLD"
, (Right (Mailbox Nothing (AddrSpec "roman" (DomainDotAtom $ pure "host"))) @=?)
, "roman@host")
, ( "with quotes in local-part"
, (Right (Mailbox Nothing (AddrSpec "roman" (DomainDotAtom $ pure "host"))) @=?)
, "\"roman\"@host")
, ( "quoted localpart with @"
, (Right (Mailbox Nothing (AddrSpec "Abc@def" (DomainDotAtom $ pure "host"))) @=?)
, "\"Abc\\@def\"@host")
, ( "whitespace in quoted local-part"
, (Right (Mailbox Nothing (AddrSpec "Mr Whitespace" (DomainDotAtom $ pure "host"))) @=?)
, "\"Mr Whitespace\"@host")
, ( "special chars in local-part"
, (Right (Mailbox Nothing (AddrSpec "customer/department=shipping" (DomainDotAtom $ pure "host"))) @=?)
, "")
, ( "special chars in local-part"
, (Right (Mailbox Nothing (AddrSpec "!def!xyz%abc" (DomainDotAtom $ pure "host"))) @=?)
, "!def!xyz%abc@host")
, ( "garbled address"
, assertBool "Parse error expected" . isLeft
, "fasdf@")
, ( "wrong: comma in front of domain"
, assertBool "Parse error expected" . isLeft
, "foo@,bar,com")
, ( "displayName without quotes but with spaces"
, (Right (Mailbox (Just "John Doe") (AddrSpec "jdoe" (DomainDotAtom $ "machine" :| ["example"]))) @=?)
, "John Doe "
)
]
parsesMailboxesSuccessfully :: TestTree
parsesMailboxesSuccessfully =
testGroup "parsing mailboxes" $
(\(desc,f,input) ->
testCase desc $ f (AText.parseOnly AddressText.mailbox input)) <$>
mailboxFixtures
parsesTextMailboxesSuccessfully :: TestTree
parsesTextMailboxesSuccessfully =
testGroup "parsing mailboxes (text)" $
(\(desc,f,input) ->
testCase desc $ f (parseOnly (mailbox defaultCharsets) input)) <$>
mailboxFixtures
addresses :: IsString s => [(String, Either String Address -> Assertion, s)]
addresses =
[ ( "single address"
, (Right (Single (Mailbox Nothing (AddrSpec "foo" (DomainDotAtom $ "bar" :| ["com"])))) @=?)
, "")
, ( "group of addresses"
, (Right
(Group
"Group"
[ Mailbox (Just "Mr Foo") (AddrSpec "foo" (DomainDotAtom $ "bar" :| ["com"]))
, Mailbox (Just "Mr Bar") (AddrSpec "bar" (DomainDotAtom $ "bar" :| ["com"]))]) @=?)
, "Group: \"Mr Foo\" , \"Mr Bar\" ;")
, ( "group of undisclosed recipients"
, (Right (Group "undisclosed-recipients" []) @=?)
, "undisclosed-recipients:;")
]
parsesAddressesSuccessfully :: TestTree
parsesAddressesSuccessfully =
testGroup "parsing addresses" $
(\(desc,f,input) -> testCase desc $ f (parseOnly (address defaultCharsets) input))
<$> addresses
parsesTextAddressesSuccessfully :: TestTree
parsesTextAddressesSuccessfully =
testGroup "parsing addresses (text)" $
(\(desc,f,input) -> testCase desc $ f (AText.parseOnly AddressText.address input))
<$> addresses
-- | Sanity check Ixed and At instances
ixAndAt :: TestTree
ixAndAt = testGroup "Ix and At instances"
[ testCase "set header" $
set (at "content-type") (Just "text/plain") empty @?= textPlain
, testCase "set header (multiple)" $
set (at "content-type") (Just "text/html") multi
@?= Headers [("Content-Type", "text/html"), ("Content-Type", "text/plain")]
, testCase "update header (case differs)" $
set (at "content-type") (Just "text/html") textPlain @?= textHtml
, testCase "delete header (one)" $
sans "content-type" textPlain @?= empty
, testCase "delete header (one)" $
sans "content-type" textPlain @?= empty
, testCase "delete header (multiple)" $
sans "content-type" multi @?= textPlain
, testCase "delete header (no match)" $
sans "subject" textPlain @?= textPlain
, testCase "ix targets all" $
toListOf (ix "content-type") multi @?= ["foo/bar", "text/plain"]
]
contentTypeTests :: TestTree
contentTypeTests = testGroup "Content-Type header"
[ testCase "parsing header" $
view contentType textHtml @?= ctTextHtml
, testCase "no header yields default" $
view contentType empty @?= defaultContentType
, testCase "set when undefined" $
set contentType ctTextHtml empty @?= textHtml
, testCase "set when defined (update)" $
set contentType ctTextHtml textPlain @?= textHtml
, testCase "update undefined content type" $
over (contentType . parameterList) (("foo","bar"):) empty @?= defaultFoobar
, testCase "update defined content type" $
over (contentType . parameterList) (("foo","bar"):) textHtml @?= textHtmlFoobar
]
where
ctTextHtml = ContentType "text" "html" (Parameters [])
empty, textPlain, textHtml, multi, defaultFoobar, textHtmlFoobar :: Headers
empty = Headers []
textPlain = Headers [("Content-Type", "text/plain")]
textHtml = Headers [("Content-Type", "text/html")]
multi = Headers [("Content-Type", "foo/bar"), ("Content-Type", "text/plain")]
defaultFoobar = Headers [("Content-Type", "text/plain; foo=bar; charset=us-ascii")]
textHtmlFoobar = Headers [("Content-Type", "text/html; foo=bar")]
parameterTests :: TestTree
parameterTests = testGroup "parameter handling"
[ testCase "RFC 2231 §3 example" $
view (contentType . parameter "url")
(Headers [("Content-Type", "message/external-body; access-type=URL; URL*0=\"ftp://\"; URL*1=\"cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar\"")])
@?= Just (ParameterValue Nothing Nothing "ftp://cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar")
, testCase "RFC 2231 §4 example" $
view (contentType . parameter "title")
(Headers [("Content-Type", "application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A")])
@?= Just (ParameterValue (Just "us-ascii") (Just "en-us") "This is ***fun***")
, testCase "RFC 2231 §4.1 example" $
view (contentType . parameter "title")
(Headers [("Content-Type", "application/x-stuff; title*0*=us-ascii'en'This%20is%20even%20more%20; title*1*=%2A%2A%2Afun%2A%2A%2A%20; title*2=\"isn't it!\"")])
@?= Just (ParameterValue (Just "us-ascii") (Just "en") "This is even more ***fun*** isn't it!")
, testCase "set filename parameter in Content-Disposition" $
set (contentDisposition . traversed . parameter "filename")
(Just (ParameterValue Nothing Nothing "foo.pdf"))
(Headers [("Content-Disposition", "attachment")])
@?= Headers [("Content-Disposition", "attachment; filename=foo.pdf")]
, testCase "unset filename parameter in Content-Disposition" $
set (contentDisposition . traversed . parameter "filename") Nothing
(Headers [("Content-Disposition", "attachment; foo=bar; filename=foo.pdf")])
@?= Headers [("Content-Disposition", "attachment; foo=bar")]
]
-- 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
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 ""
msg1ID = (\(Right a) -> a) $ parseOnly parseMessageID ""
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 = 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 = 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))
isFtext :: Word8 -> Bool
isFtext c = (c >= 33 && c <= 57) || (c >= 59 && c <= 126)
-- * generate a growing list of words
-- * join all words by a whitespace
--
genFieldBody :: Gen B.ByteString
genFieldBody = do
nonWspStart <- suchThat arbitrary vchar
t <- listOf1 $ resize 30 $ listOf1 (suchThat arbitrary vchar)
pure (nonWspStart `B.cons` foldl (\acc x -> acc <> " " <> B.pack x) B.empty t) -- urks that's going to be costly
vchar :: Word8 -> Bool
vchar c = c >= 33 && c <= 126
genField :: Gen (CI.CI B.ByteString, B.ByteString)
genField = (,) <$> (CI.mk <$> genFieldItem) <*> genFieldBody
prop_renderHeadersRoundtrip :: Property
prop_renderHeadersRoundtrip = forAll genField $ \kv ->
parse field (renderField kv) == Right kv
prop_foldedUnstructuredLimited :: Property
prop_foldedUnstructuredLimited = forAll genField $ \kv ->
all ((<= 78) . L.length) (crlfLines $ renderField kv)
crlfLines :: L.ByteString -> [L.ByteString]
crlfLines = go ""
where
go acc s =
let (h,t) = L8.span (/= '\r') s
in
case L.take 2 t of
"" -> [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 "
frank = "frank@example.com"