{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE CPP #-} {- | Parse server replies. The general format of replies is described in , at p. 49: @ The format for multiline replies requires that every line, except the last, begin with the reply code, followed immediately by a hyphen, "-" (also known as minus), followed by text. The last line will begin with the reply code, followed immediately by \, optionally some text, and \. For example: 123-First line 123-Second line 123-234 text beginning with numbers 123 The last line @ On p. 49, sec 4.2.2, "Reply Codes by Function Groups", the RFC lists the various server responses that can be made. (Also Wikipedia has a more pleasantly formatted version of the list at .) -} module Network.Mail.Assumpta.ParseResponse ( -- * Fetching replies -- | 'getReply' is intended to be the main function -- used by other modules - it allows fetching and parsing replies -- from a server. The other functions are lower-level utility functions, and -- might be useful if you want to customize parsing. getReply -- * Low-level functions and types , Parser , textstring , code , reply , getReply_ , parseFrom ) where import Control.Monad.Except import qualified Data.Attoparsec.ByteString.Char8 as Att import Data.Attoparsec.ByteString.Char8 ( decimal , takeWhile1, string , char, option , space, many' , parseWith, eitherResult , () ) import qualified Data.ByteString as BS import Data.ByteString ( ByteString) import Data.Functor import qualified Data.List as L import Data.Monoid -- needed for early versions of Base import Network.Mail.Assumpta.Types #if MIN_VERSION_mtl(2,2,2) #else liftEither :: MonadError e m => Either e a -> m a liftEither = either throwError return #endif -- | parser type. type Parser = Att.Parser -- | -- A string of length at least 1, which may contain printable US ASCII, -- characters, space characters, and horizontal tabs. -- -- See , p. 47, "textstring". -- -- The spec in the RFC is -- -- @ -- textstring = 1*(%d09 / %d32-126) ; HT, SP, Printable US-ASCII -- @ -- -- >>> :set -XOverloadedStrings -- >>> Att.parseOnly (textstring <* Att.endOfInput) "~" -- ASCII 126 -- Right "~" -- >>> Att.parseOnly (textstring <* Att.endOfInput) "\x7f" -- ASCII 127 -- Left "printable ASCII text: Failed reading: takeWhile1" textstring :: Parser ByteString textstring = (takeWhile1 is_printable) "printable ASCII text" where -- 9 is horizontal tab, and [32, 126] is all printable US ASCII. is_printable c' = let c = fromEnum c' in c == 9 || (c >= 32 && c <= 126) -- textstring function courtesy of -- Alexander Vieth, smtp-mail-ng -- | The @\@ sequence. -- -- >>> :set -XOverloadedStrings -- >>> Att.parseOnly (crlf <* Att.endOfInput) "\r\n" -- Right () -- >>> Att.parseOnly (crlf <* Att.endOfInput) "\r\n." -- Left "endOfInput" crlf :: Parser () crlf = string "\r\n" $> () -- | Parser for a reply code. -- -- The RFC states that an SMTP server SHOULD only send the codes -- listed in the spec, but we don't validate that here; -- we accept any sequence of decimal digits, higher-level functions can -- further validate it if desired. -- -- >>> :set -XOverloadedStrings -- >>> Att.parseOnly (code <* Att.endOfInput) "42" -- Right 42 -- >>> Att.parseOnly (code <* Att.endOfInput) "042" -- Right 42 code :: Parser ReplyCode code = decimal -- | One or more server reply lines, terminating with a -- last line. The parser does not check that they all have the -- same reply code. reply :: Parser Reply reply = (++) <$> many' continue <*> (pure <$> lastLine) -- | Last line of a (potentially multi-line) reply. -- -- Code and , possibly a text string. -- -- >>> :set -XOverloadedStrings -- >>> let bs = "42 Answering your questions\r\n" -- >>> Att.parseOnly (lastLine <* Att.endOfInput) bs -- Right (ReplyLine {replyCode = 42, replyText = "Answering your questions"}) lastLine :: Parser ReplyLine lastLine = ReplyLine <$> code <* space <*> option "" textstring <* crlf -- | Continuation line of a multiline reply. -- -- Code and '-' char, possibly a text string. -- -- >>> :set -XOverloadedStrings -- >>> let bs = "42-Answering your questions\r\n" -- >>> Att.parseOnly (continue <* Att.endOfInput) bs -- Right (ReplyLine {replyCode = 42, replyText = "Answering your questions"}) continue :: Parser ReplyLine continue = ReplyLine <$> code <* char '-' <*> option "" textstring <* crlf -- | @parseFrom x pull@ -- -- Given some action \'pull' which can be called to -- supply the parser with more input, parse thing 'x' -- and return a result. parseFrom :: Monad m => Parser r -> m ByteString -> m (Either String r) parseFrom x pull = eitherResult <$> parseWith pull x BS.empty -- | @getReply_ pull@ -- -- Given some action \'pull' which can be called to -- supply the parser with more input, attempt to -- fetch input and parse it as a superficially -- well-formed 'Reply' (multiple lines ending in a terminating -- line). -- We don't check that all reply lines have the -- same reply code. getReply_ :: Monad m => m ByteString -> m (Either String Reply) getReply_ pull = eitherResult <$> parseWith pull reply BS.empty allSame :: Eq a => [a] -> Bool allSame [] = True allSame (x:xs) = all (== x) xs toParseError :: Either String b -> Either SmtpError b toParseError = either (Left . ParseError) Right -- | @getReply pull@ -- -- Given some action \'pull' which can be called to -- supply the parser with more input, attempt to fetch -- content from the server and parse it as a 'Reply'. -- On failure, 'ParseError' will be thrown, with -- a message explaining the failure. -- -- A 'ParseError' will also be thrown if the response -- looks superficially like a reply, but has multiple -- reply codes for different lines. (In other words, -- a successful return value means the reply has at -- least one line, and all lines have the same reply -- code.) -- -- The result returned is in the 'MonadError' monad, -- so can be specialised by the caller to a 'Maybe', -- 'Either', or some other 'MonadError' instance as desired. getReply :: MonadError SmtpError m => m ByteString -> m Reply getReply f = wellFormed =<< liftEither =<< toParseError <$> getReply_ f -- | Check if reply is well-formed (has same reply code -- for each line) else throw a 'ParseError'. wellFormed :: MonadError SmtpError m => Reply -> m Reply wellFormed replyLines = let codes = map replyCode replyLines mesg = "Malformed reply contained multiple reply codes: " <> L.intercalate ", " (map show codes) in if allSame codes then return replyLines else throwError (ParseError mesg)