-- | A pure SMTP client state machine.
--
-- Data structures for representing SMTP status codes and email messages are
-- re-exported here from /Text.ParserCombinators.Parsec.Rfc2821/ and
-- /Text.ParserCombinators.Parsec.Rfc2822/ in the /hsemail/ package.

module Network.SMTP.ClientSession (
        smtpClientSession,
        SMTPState(..),
        SmtpReply(..),
        SmtpCode(..),
        SuccessCode(..),
        Category(..),
        Message,
        GenericMessage(..),
        Field(..),
        NameAddr(..)
        {-, suite-}
    ) where

import Text.ParserCombinators.Parsec.Rfc2821 (
        SmtpReply(..),
        SmtpCode(..),
        SuccessCode(..),
        Category(..),
        reply
    )
import Text.ParserCombinators.Parsec.Rfc2822 (
        Message(..),
        GenericMessage(..),
        Field(..),
        NameAddr(..)
    )
import Data.Maybe
import qualified Data.Set as Set
import Prelude hiding (fail)
import Data.List
import System.Time
import System.Locale


{-import Test.Framework
import Test.Framework.Providers.HUnit
import Test.HUnit-}

{-suite = testGroup "MetaBarter.SMTP.SMTP" [
        testCase "dotAtomAllowed"   test_dotAtomAllowed,
        testCase "quoted_string"    test_quoted_string
    ]-}

data SMTPState = SMTPState {

        -- | Step 1. Caller must send any lines queued up in this list to the SMTP
        -- server.  They do not have end-of-line characters, so you must add
        -- \"\\r\\n\" on the end (both characters are required by RFC2821 - do not
        -- just send \"\\n\").
        smtpOutQueue :: [String],

        -- | Step 2. Check if this is True, which indicates that the SMTP session
        -- has completed successfully and there is no more work to do.
        smtpSuccess  :: Bool,

        -- | Step 3. Check if this is Just err, which indicates that a protocol error
        -- has occurred, and that the caller must terminate the session.
        smtpFailure  :: Maybe String,

        -- | Step 4. The caller should wait for a line from the SMTP server,
        -- strip the \"\\r\\n\" end-of-line characters, and pass the stripped
        -- line to this function for processing.  Go to step 1.
        smtpReceive  :: String -> SMTPState -> SMTPState,

        -- | A list containing a failure status for each message that has been sent so far,
        -- where each element corresponds to one in the list of messages.
        -- If the SMTP session does not complete successfully, then this list is
        -- likely to be shorter than the input messages list.  When smtpSuccess is
        -- true, this list is guaranteed to be the same length as the list of input
        -- messages.
        -- /Nothing/ means success, and /Just x/ is a failure status returned by
        -- the SMTP server.
        smtpStatuses :: [Maybe SmtpReply]
    }

send :: String -> (SMTPState -> SMTPState) -> SMTPState -> SMTPState
send txt cont state =
    cont $
        state {
                smtpOutQueue = txt:smtpOutQueue state
            }

-- | A 'null' smtpReceive callback that discards anything given to it.
nullReceive :: String -> SMTPState -> SMTPState
nullReceive _ state = state

receive :: (String -> SMTPState -> SMTPState) -> SMTPState -> SMTPState
receive cont state =
    state {
            -- Reverse the out queue before passing it to the caller because we
            -- have been assembling it backwards.
            smtpOutQueue = reverse $ smtpOutQueue state,
            smtpReceive = \line state -> cont line $ state {
                    smtpOutQueue = [],
                    smtpReceive = nullReceive
                }
        }

fail :: String -> SMTPState -> SMTPState
fail errorText state =
    state {
            smtpOutQueue = [],
            smtpFailure = Just errorText
        }

success :: SMTPState -> SMTPState
success state =
    state {
            smtpOutQueue = [],
            smtpSuccess = True
        }

equals :: SuccessCode -> Category -> SmtpReply -> Bool
equals code cat reply =
    case reply of
        Reply (Code gotCode gotCat _) _ | gotCode == code && gotCat == cat -> True
        otherwise -> False

check :: (SmtpReply -> Bool) -> String -> SmtpReply -> (SMTPState -> SMTPState) -> SMTPState -> SMTPState
check isOK descr reply cont =
    if isOK reply
        then cont
        else
            fail $ "SMTP error: got "++
                    (cleanUp $ show reply)++
                    " when I expected "++descr

-- | Squish the SMTP reply description into one line.
cleanUp :: String -> String
cleanUp =
    map (\x -> if x == '\n' then '/' else x) .
    reverse .
    dropWhile (\x -> x == '\n') .
    reverse .
    filter (/= '\r')

maybeRead :: Read a => String -> Maybe a
maybeRead s = case reads s of
    [(x, "")] -> Just x
    _         -> Nothing

for :: [a]
       -- loop body, passed: value next
    -> (a -> (SMTPState -> SMTPState) -> SMTPState -> SMTPState)
    -> (SMTPState -> SMTPState)
    -> SMTPState -> SMTPState
for [] _ cont = cont
for (x:xs) body cont =
    body x $
    for xs body cont

putStatuses :: [Maybe SmtpReply] -> (SMTPState -> SMTPState) -> SMTPState -> SMTPState
putStatuses statuses cont state = cont $ state {smtpStatuses = statuses}

-- | Receive an SMTP reply, e.g.
-- 250-worked
-- 250-like
-- 250 a charm
receiveReply :: (SmtpReply -> SMTPState -> SMTPState) -> SMTPState -> SMTPState
receiveReply cont =
        rec [] $ \revMsgs@(final:_) ->
        if length final < 4 || final !! 3 /= ' ' then
            fail ("malformed SMTP reply: "++final)
        else
            case maybeRead (take 3 final) of
                Just code ->
                    cont $ mkReply code (reverse $ map (drop 4) revMsgs)
                Nothing ->
                    fail ("Malformed SMTP reply: "++final)
    where
        rec :: [String] -> ([String] -> SMTPState -> SMTPState) -> SMTPState -> SMTPState
        rec msgs cont =
            receive $ \line ->
            if length line < 4 then
                fail ("malformed SMTP reply: "++line)
            else if line !! 3 == '-' then
                rec (line:msgs) cont
            else
                cont (line:msgs)

        mkReply :: Int -> [String] -> SmtpReply
        mkReply code msgs = reply (code `div` 100) ((code `div` 10) `mod` 10) (code `mod` 10) msgs

equalsMailOK :: SmtpReply -> Bool
equalsMailOK = equals Success MailSystem

checkMailOK :: (SMTPState -> SMTPState) -> SMTPState -> SMTPState
checkMailOK cont =
    receiveReply $ \reply ->
    check equalsMailOK "\"mail system OK\" (code 25x)" reply $
    cont

checkConnectionOK :: (SMTPState -> SMTPState) -> SMTPState -> SMTPState
checkConnectionOK cont =
    receiveReply $ \reply ->
    check (equals Success Connection) "\"connection OK\" (code 22x)" reply $
    cont

equalsDataOK :: SmtpReply -> Bool
equalsDataOK = equals IntermediateSuccess MailSystem

-- | Construct a pure state machine for an SMTP client session.  Caller must
-- handle I/O.  The message body may use either \"\\n\" or \"\\r\\n\" as an
-- end-of-line marker.
smtpClientSession :: String     -- ^ Domain name used in EHLO command
                  -> [Message]  -- ^ List of messges to send
                  -> SMTPState
smtpClientSession domain messages = talk domain messages $ SMTPState {
            smtpOutQueue = [],
            smtpReceive  = nullReceive,
            smtpSuccess  = False,
            smtpFailure  = Nothing,
            smtpStatuses = []
        }

-- Continuation passing style.
talk :: String -> [Message] -> SMTPState -> SMTPState
talk domain messages =
        checkConnectionOK $
        send ("EHLO "++domain) $
        checkMailOK $
        sendMessages messages [] $
        send "QUIT" $
        checkConnectionOK $
        success
    where
        sendMessages :: [Message] -> [Maybe SmtpReply] -> (SMTPState -> SMTPState) -> SMTPState -> SMTPState
        sendMessages [] _ cont = cont
        sendMessages (message:rest) statuses cont =
            sendMessage message $ \status ->
            let statuses' = status:statuses in   -- collate statuses backwards
            putStatuses (reverse statuses') $    -- reverse to store in correct order
            sendMessages rest statuses' cont

        sendMessage :: Message -> (Maybe SmtpReply -> SMTPState -> SMTPState) -> SMTPState -> SMTPState
        sendMessage message cont =
            let Message fields _ = message

                froms = map (\(NameAddr _ addr) -> addr) $
                    concatMap (\f ->
                        case f of
                            From from -> from
                            _         -> []) fields

                tos = map (\(NameAddr _ addr) -> addr) $
                    concatMap (\f ->
                        case f of
                            To to  -> to
                            Cc to  -> to
                            Bcc to -> to
                            _      -> []) fields in

            if null froms then
                fail "email contains no From: field"
            else if null tos then
                fail "email contains no To:, Cc: or Bcc: field"
            else (
                    send ((("MAIL FROM:"++) . angle_addr (head froms)) "") $
                    receiveReply $ \reply ->
                    if not $ equalsMailOK reply then
                        cont (Just reply)  -- failure status
                    else (
                            for tos (\to next ->
                                    send ((("RCPT TO:"++) . angle_addr to) "") $
                                    receiveReply $ \reply ->
                                    if not $ equalsMailOK reply
                                        then cont (Just reply)  -- failure status
                                        else next
                                ) $
                            send "DATA" $
                            receiveReply $ \reply ->
                            if not $ equalsDataOK reply then
                                cont (Just reply)  -- failure status
                            else (
                                    let msgLines = formatMessage message in
                                    for msgLines (\line cont ->
                                            send (if line == "." then ". " else line) $
                                            cont
                                        ) $
                                    send "." $
                                    receiveReply $ \reply ->
                                    if not $ equalsMailOK reply then
                                        cont (Just reply)  -- failure status
                                    else
                                        cont Nothing  -- success
                                )
                        )
                )

atext_alphabet = Set.fromList $
    "abcdefghijklmnopqrstuvwxyz"++
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ"++
    "0123456789"++
    "!#$%&'*+-/=?^_`{|}~"

atomAllowed :: String -> Bool
atomAllowed "" = False
atomAllowed txt = all (`Set.member` atext_alphabet) txt

dotAtomAllowed :: String -> Bool
dotAtomAllowed str =
    let (at, rest) = break (== '.') str in
    if not $ atomAllowed at then
        False
    else
        if null rest then
            True
        else
            dotAtomAllowed (tail rest)

{-
test_dotAtomAllowed = do
    assertEqual "" False (dotAtomAllowed "")
    assertEqual "a" True (dotAtomAllowed "a")
    assertEqual "a<" False (dotAtomAllowed "a<")
    assertEqual ".a" False (dotAtomAllowed ".a")
    assertEqual "abc.012$" True (dotAtomAllowed "abc.012$")
    assertEqual "01.%^.xyzzy" True (dotAtomAllowed "01.%^.xyzzy")
    assertEqual "01..xyzzy" False (dotAtomAllowed "01..xyzzy")
    assertEqual "01.xyzzy." False (dotAtomAllowed "01.xyzzy.")
-}

addr_spec :: String -> ShowS
addr_spec addr =
    let (local, at_domain) = break (=='@') addr
        domain = if "@" `isPrefixOf` at_domain then tail at_domain else at_domain in
    dotatom_or_quoted local . ("@"++) . dotatom_or_domain_literal domain

angle_addr :: String -> ShowS
angle_addr addr =
    ("<"++) . addr_spec addr . (">"++)

msg_id :: String -> ShowS
msg_id mid = angle_addr mid

atom_or_quoted :: String -> ShowS
atom_or_quoted text =
    if atomAllowed text then
        (text++)
    else
        quoted_string text

phrase = atom_or_quoted
display_name = phrase

dotatom_or_quoted :: String -> ShowS
dotatom_or_quoted text =
    if dotAtomAllowed text then
        (text++)
    else
        quoted_string text

quoted_string :: String -> ShowS
quoted_string text = ("\""++) . (quoted_ text++) . ("\""++)
    where
        quoted_ [] = []
        quoted_ (x:xs) =
            case x of
                '\\' -> '\\':'\\':quoted_ xs
                '"'  -> '\\':'"':quoted_ xs
                _    -> x:quoted_ xs

{-
test_quoted_string = do
    assertEqual "" (quoted_string "" "") "\"\""
    assertEqual "Hello" (quoted_string "Hello" "") "\"Hello\""
    assertEqual "Say, \"Hello\"" (quoted_string "Say, \"Hello\"" "") "\"Say, \\\"Hello\\\"\""
    assertEqual "Backslash\\" (quoted_string "Backslash\\" "") "\"Backslash\\\\\""
-}

dotatom_or_domain_literal :: String -> ShowS
dotatom_or_domain_literal text =
    if dotAtomAllowed text then
        (text++)
    else
        domain_literal text

domain_literal :: String -> ShowS
domain_literal text = ("["++) . (quoted_ text++) . ("]"++)
    where
        quoted_ [] = []
        quoted_ (x:xs) =
            case x of
                '\\' -> '\\':'\\':quoted_ xs
                '['  -> '\\':'[':quoted_ xs
                ']'  -> '\\':']':quoted_ xs
                _    -> x:quoted_ xs

name_addr :: NameAddr -> ShowS
name_addr (NameAddr mName addr) =
    (case mName of
         Just name -> display_name name . (" "++)
         Nothing   -> id) .
    angle_addr addr

address_list :: [NameAddr] -> ShowS
address_list addrs =
    foldr (.) id $ intersperse (",\n"++) $ map name_addr addrs

formatMessage :: Message -> [String]
formatMessage (Message fields body) =
    concatMap (indent . formatField) fields ++
    [""] ++
    map (reverse . dropWhile (=='\r') . reverse) (lines body)

indent :: String -> [String]
indent text =
    case lines text of
        [] -> []
        (l:ls) -> l:map ("        "++) ls

formatField :: Field -> String
formatField (OptionalField name value) = name ++ ": " ++ value
formatField (From addrs)        = (("From: "++) . address_list addrs) ""
formatField (Sender addr)       = (("From: "++) . name_addr addr) ""
formatField (ReturnPath txt)    = "Return-Path: "++txt
formatField (ReplyTo addrs)     = (("Reply-To: "++) . address_list addrs) ""
formatField (To addrs)          = (("To: "++) . address_list addrs) ""
formatField (Cc addrs)          = (("Cc: "++) . address_list addrs) ""
formatField (Bcc addrs)         = (("Bcc: "++) . address_list addrs) ""
formatField (MessageID mid)     = (("Message-ID: "++) . msg_id mid) ""
formatField (InReplyTo mids)    = (("In-Reply-To: "++) . foldr (.) id (map msg_id mids)) ""
formatField (References mids)   = (("References: "++) . foldr (.) id (map msg_id mids)) ""
formatField (Subject txt)       = "Subject: "++txt
formatField (Comments txt)      = "Comments: "++txt
formatField (Keywords kws)      = (("Keywords: "++) . foldr (.) id (intersperse (",\n"++) $ map phrase (concat kws))) ""
formatField (Date date)         = "Date: "++formatDate date
formatField (ResentDate date)   = "Resent-Date: "++formatDate date
formatField (ResentFrom addrs)  = (("Resent-From: "++) . address_list addrs) ""
formatField (ResentSender addr) = (("Resent-From: "++) . name_addr addr) ""
formatField (ResentTo addrs)    = (("Resent-To: "++) . address_list addrs) ""
formatField (ResentCc addrs)    = (("Resent-Cc: "++) . address_list addrs) ""
formatField (ResentBcc addrs)   = (("Resent-Bcc: "++) . address_list addrs) ""
formatField (ResentMessageID mid) = (("Resent-Message-ID: "++) . msg_id mid) ""
formatField (ResentReplyTo addrs) = (("Resent-Reply-To: "++) . address_list addrs) ""
formatField (Received (ps, date)) = (("Received: "++) . pairs ps . (";"++) . (formatDate date++)) ""
formatField (ObsReceived ps)    = (("Received: "++) . pairs ps) ""

formatDate :: CalendarTime -> String
formatDate ct = formatCalendarTime defaultTimeLocale "%a, %d %b %Y %H:%M:%S " ct ++ formatTimeZone (ctTZ ct)

formatTimeZone :: Int -> String
formatTimeZone offset =
        if offset < 0 then
            "-"++ftz (-offset)
        else
            "+"++ftz offset
    where
        ftz offset = dig2 (offset `div` 3600) ++ dig2 ((offset `div` 60) `mod` 60)
        dig2 n = (\n -> if n < 10 then "0"++show n else show n) (n `mod` 100)

pairs :: [(String, String)] -> ShowS
pairs ps = foldr (.) id $ intersperse ("\n"++) $ map pair ps
    where
        pair (name, value) =
            (name++) . (" "++) . (value++)
