mailtrap-0.1.2.0: src/Network/Mail/Mailtrap.hs
----------------------------------------------------------------------------------------------------
-- | Mailtrap API.
module Network.Mail.Mailtrap
( -- * Tokens
Token (..)
, Exception (..)
-- * Accounts
, AccountID (..)
, Account (..)
, getAllAccounts
-- * Attachments
, Disposition (..)
, setDisposition
, Attachment (..)
, attachmentFromFile
-- * Templates
, Template (..)
, template
, setTemplateVariable
-- * Testing inboxes
, InboxID (..)
, Inbox (..)
, getInboxes
, InboxMessageID (..)
, InboxMessage (..)
, getInboxMessages
, downloadMessageRaw
, downloadMessageEML
, downloadMessageText
, downloadMessageHTML
-- * Sending e-mails
, EmailAddress
, parseEmailAddress
, NamedEmailAddress (..)
, MessageID (..)
, Message (..)
, EmailBody (..)
, Email (..)
, sendEmail
, sendTestEmail
) where
-- base
import Control.Exception qualified as Base
import GHC.TypeLits (Symbol, KnownSymbol, symbolVal)
import Data.Proxy
import Data.String (fromString)
-- text
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
-- email-validate
import Text.Email.Validate (EmailAddress)
import Text.Email.Validate qualified as Email
-- bytestring
import Data.ByteString (ByteString)
import Data.ByteString qualified as ByteString
-- mime-types
import Network.Mime (MimeType, defaultMimeLookup)
-- aeson
import Data.Aeson (ToJSON, (.=), FromJSON, (.:))
import Data.Aeson qualified as JSON
#if MIN_VERSION_aeson(2,0,0)
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Key qualified as Key
#else
import Data.HashMap.Strict qualified as HashMap
#endif
-- base64
import Data.ByteString.Base64 (encodeBase64)
-- blaze-html
import Text.Blaze.Html (Html)
import Text.Blaze.Html.Renderer.Text (renderHtml)
-- uuid
import Data.UUID.Types (UUID)
-- http-conduit
import Network.HTTP.Simple qualified as HTTP
-- filepath
import System.FilePath (takeFileName)
-- time
import Data.Time.Clock (UTCTime)
-- | Authorization token.
newtype Token = Token Text deriving (Eq, Show)
instance FromJSON Token where
parseJSON = JSON.withText "Mailtrap Token" (pure . Token)
instance ToJSON Token where
toJSON (Token token) = JSON.toJSON token
-- | Exceptions thrown by functions from this module.
data Exception =
-- | API request returned list of errors.
-- HTTP status code and error messages.
MultipleErrors Int [Text]
-- | API request returned a single error message.
-- HTTP status code and error message.
| SingleError Int Text
-- | Parsing failed.
-- Input that failed to parse plus error message.
| ParsingError ByteString String
deriving Show
instance Base.Exception Exception
-- | Constructor of simple error.
singleError :: Int -> JSONResp "error" Text -> Exception
singleError code (JSONResp err) = SingleError code err
-- | Constructor of multiple errors.
multipleErrors :: Int -> JSONResp "errors" [Text] -> Exception
multipleErrors code (JSONResp errs) = MultipleErrors code errs
-- | JSON object wrapper to help with parsing HTTP response.
data JSONResp (k :: Symbol) a = JSONResp { fromJSONResp :: a }
instance (KnownSymbol k, FromJSON a) => FromJSON (JSONResp k a) where
parseJSON =
let k = fromString $ symbolVal $ Proxy @k
in JSON.withObject "JSONResp" $ fmap JSONResp . (.: k)
-- | Wrapper that provides a text-based 'FromJSON' instance.
newtype AsText a = AsText { asText :: a }
instance FromJSON a => FromJSON (AsText a) where
parseJSON = JSON.withText "AsText" $
either fail (pure . AsText) . JSON.eitherDecodeStrict . encodeUtf8
-- | Generic API query that returns JSON.
genericQuery
:: (FromJSON err, ToJSON a, FromJSON b)
=> ByteString -- ^ HTTP method.
-> ByteString -- ^ API URL.
-> ByteString -- ^ HTTP path.
-> Token -- ^ API token.
-> (Int -> err -> Exception) -- ^ Error parsing.
-> Maybe a -- ^ Body.
-> IO b -- ^ Response.
genericQuery method url path (Token token) ferr mbody = do
let req = HTTP.setRequestMethod method
$ HTTP.setRequestHost url
$ HTTP.setRequestPort 443
$ HTTP.setRequestPath path
$ HTTP.setRequestSecure True
$ maybe id HTTP.setRequestBodyJSON mbody
$ HTTP.addRequestHeader "Api-Token" (encodeUtf8 token)
$ HTTP.defaultRequest
resp <- HTTP.httpBS req
let code = HTTP.getResponseStatusCode resp
body = HTTP.getResponseBody resp
case code of
200 ->
either (Base.throwIO . ParsingError body) pure $
JSON.eitherDecodeStrict body
_ ->
either (Base.throwIO . ParsingError body) (Base.throwIO . ferr code) $
JSON.eitherDecodeStrict body
-- | Helper to set an empty body when using 'genericQuery'.
noBody :: Maybe ()
noBody = Nothing
-- | Generic API query to download files.
genericDownload
:: ByteString -- ^ API URL.
-> ByteString -- ^ HTTP path.
-> Token -- ^ API token.
-> IO ByteString -- ^ Response.
genericDownload url path (Token token) = do
let req = HTTP.setRequestMethod "GET"
$ HTTP.setRequestHost url
$ HTTP.setRequestPort 443
$ HTTP.setRequestPath path
$ HTTP.setRequestSecure True
$ HTTP.addRequestHeader "Api-Token" (encodeUtf8 token)
$ HTTP.defaultRequest
resp <- HTTP.httpBS req
let code = HTTP.getResponseStatusCode resp
body = HTTP.getResponseBody resp
case code of
200 -> pure body
404 -> Base.throwIO $ SingleError 404 "File not found."
_ ->
either (Base.throwIO . ParsingError body) (Base.throwIO . singleError code) $
JSON.eitherDecodeStrict body
-- | Mailtrap account ID.
newtype AccountID = AccountID Int deriving (Eq, Show, FromJSON)
-- | Mailtrap account.
data Account = Account
{ account_id :: AccountID
, account_name :: Text
} deriving Show
instance FromJSON Account where
parseJSON = JSON.withObject "Account" $ \o ->
Account <$> o .: "id" <*> o .: "name"
-- | Get all the accounts the given token has access to.
getAllAccounts :: Token -> IO [Account]
getAllAccounts token = genericQuery "GET" "mailtrap.io" "/api/accounts" token singleError noBody
-- | 'EmailAddress' wrapper to provide 'ToJSON' and 'FromJSON' instances.
newtype EmailAddressJSON = EmailAddressJSON { fromEmailAddressJSON :: EmailAddress }
instance ToJSON EmailAddressJSON where
toJSON = JSON.String . decodeUtf8 . Email.toByteString . fromEmailAddressJSON
instance FromJSON EmailAddressJSON where
parseJSON = JSON.withText "EmailAddressJSON" $ \t ->
either fail (pure . EmailAddressJSON) $ Email.validate $ encodeUtf8 t
-- | An e-mail address with a name.
data NamedEmailAddress = NamedEmailAddress
{ emailAddress :: EmailAddress
, emailAddressName :: Text
} deriving Show
instance ToJSON NamedEmailAddress where
toJSON addr = JSON.object
[ "email" .= EmailAddressJSON (emailAddress addr)
, "name" .= emailAddressName addr
]
-- | Attempt to parse an e-mail address
parseEmailAddress :: ByteString -> Either String EmailAddress
parseEmailAddress = Email.validate
-- | Attachment disposition.
data Disposition =
-- | Inline with identifier.
Inline Text
| Attached
deriving Show
-- | File that can be attached to an e-mail.
data Attachment = Attachment
{ -- | File name.
attachment_name :: Text
-- | MIME type of the content.
, attachment_type :: MimeType
-- | Attachment content.
, attachment_content :: ByteString
-- | Attachment disposition.
, attachment_disposition :: Disposition
} deriving Show
-- | Create an attachment from a file. It guesses the mime type from
-- the file extension. Disposition is set to 'Attached'.
-- The file is read strictly.
attachmentFromFile :: FilePath -> IO Attachment
attachmentFromFile fp = do
let fptext :: Text
fptext = Text.pack $ takeFileName fp
bytes <- ByteString.readFile fp
pure $ Attachment
{ attachment_name = fptext
, attachment_type = defaultMimeLookup fptext
, attachment_content = bytes
, attachment_disposition = Attached
}
-- | Set an attachment's disposition.
setDisposition :: Disposition -> Attachment -> Attachment
setDisposition d a = a { attachment_disposition = d }
instance ToJSON Attachment where
toJSON att = JSON.object $
[ "filename" .= attachment_name att
, "type" .= decodeUtf8 (attachment_type att)
, "content" .= encodeBase64 (attachment_content att)
, "disposition" .=
(case attachment_disposition att of
Inline _ -> "inline" :: Text
Attached -> "attachment"
)
] ++ (case attachment_disposition att of
Inline i -> [ "content_id" .= i ]
Attached -> []
)
-- | An e-mail body.
data EmailBody =
-- | Plain-text body.
PlainTextBody Text
-- | HTML-only body.
| HTMLOnlyBody Html
-- | HTML body with text fallback.
| HTMLBody Html Text
-- | E-mail message, including subject and body.
data Message = Message
{ message_subject :: Text
, message_body :: EmailBody
-- | Message category.
, message_category :: Text
}
-- | Template that can be used when sending e-mails.
data Template = Template
{ -- | ID of the template.
template_id :: UUID
-- | Template variable assignments.
, template_variables :: JSON.Object
} deriving Show
-- | Template with no variable set.
template :: UUID -> Template
#if MIN_VERSION_aeson(2,0,0)
template i = Template i KeyMap.empty
#else
template i = Template i HashMap.empty
#endif
-- | Set template variable.
setTemplateVariable :: ToJSON a => Text -> a -> Template -> Template
setTemplateVariable k x t =
#if MIN_VERSION_aeson(2,0,0)
t { template_variables = KeyMap.insert (Key.fromText k) (JSON.toJSON x) $ template_variables t }
#else
t { template_variables = HashMap.insert k (JSON.toJSON x) $ template_variables t }
#endif
-- | E-mail that can be sent using 'sendEmail'.
data Email = Email
{ -- | Sender address.
email_from :: NamedEmailAddress
-- | Recipient list. Max 1000.
, email_to :: [NamedEmailAddress]
-- | Carbon Copy recipients.
, email_cc :: [NamedEmailAddress]
-- | Blind Carbon Copy recipients.
, email_bcc :: [NamedEmailAddress]
-- | Files attached to the e-mail.
, email_attachments :: [Attachment]
-- | Custom JSON object.
, email_custom :: JSON.Object
-- | Message to send.
, email_message :: Either Template Message
}
instance ToJSON Email where
toJSON email = JSON.object $
[ "from" .= email_from email
, "to" .= email_to email
, "cc" .= email_cc email
, "bcc" .= email_bcc email
, "attachments" .= email_attachments email
, "custom_variables" .= email_custom email
] ++ (case email_message email of
Left temp ->
[ "template_uuid" .= template_id temp
, "template_variables" .= template_variables temp
]
Right msg ->
[ "subject" .= message_subject msg
, "category" .= message_category msg
] ++ (case message_body msg of
PlainTextBody t -> [ "text" .= t ]
HTMLOnlyBody h -> [ "html" .= renderHtml h ]
HTMLBody h t -> [ "html" .= renderHtml h, "text" .= t ]
)
)
-- | Testing inbox identifier.
newtype InboxID = InboxID Int deriving (Eq, Show, FromJSON)
-- | Testing inbox.
data Inbox = Inbox
{ inbox_id :: InboxID
, inbox_name :: Text
-- | Number of emails in the inbox.
, inbox_emailCount :: Int
-- | Number of unread emails in the inbox.
, inbox_unreadCount :: Int
, inbox_maxSize :: Int
} deriving Show
instance FromJSON Inbox where
parseJSON = JSON.withObject "Inbox" $ \o -> Inbox
<$> o .: "id"
<*> o .: "name"
<*> o .: "emails_count"
<*> o .: "emails_unread_count"
<*> o .: "max_size"
-- | Get all inboxes from an account.
getInboxes :: Token -> AccountID -> IO [Inbox]
getInboxes token (AccountID i) =
let path = fromString $ "/api/accounts/" ++ show i ++ "/inboxes"
in genericQuery "GET" "mailtrap.io" path token singleError noBody
-- | Inbox message identifier.
newtype InboxMessageID = InboxMessageID Int deriving (Eq, Show, FromJSON)
-- | A message in a testing inbox.
data InboxMessage = InboxMessage
{ inboxMessage_id :: InboxMessageID
, inboxMessage_inbox :: InboxID
, inboxMessage_sentAt :: UTCTime
, inboxMessage_from :: EmailAddress
, inboxMessage_to :: EmailAddress
, inboxMessage_subject :: Text
, inboxMessage_isRead :: Bool
} deriving Show
instance FromJSON InboxMessage where
parseJSON = JSON.withObject "InboxMessage" $ \o -> InboxMessage
<$> o .: "id"
<*> o .: "inbox_id"
<*> o .: "sent_at"
<*> (fromEmailAddressJSON <$> o .: "from_email")
<*> (fromEmailAddressJSON <$> o .: "to_email")
<*> o .: "subject"
<*> o .: "is_read"
-- | Get all inbox messages from an testing inbox.
getInboxMessages :: Token -> AccountID -> InboxID -> IO [InboxMessage]
getInboxMessages token (AccountID accid) (InboxID inboxid) =
let path = fromString $ "/api/accounts/" ++ show accid
++ "/inboxes/" ++ show inboxid ++ "/messages"
in genericQuery "GET" "mailtrap.io" path token singleError noBody
-- | Generic function to implement all the message download functions in one place.
downloadMessageGeneric
:: String -- ^ Extension
-> Token -> AccountID -> InboxID -> InboxMessageID -> IO Text
downloadMessageGeneric ext token (AccountID accid) (InboxID inboxid) (InboxMessageID msgid) =
let path = fromString $ "/api/accounts/" ++ show accid
++ "/inboxes/" ++ show inboxid
++ "/messages/" ++ show msgid
++ "/body." ++ ext
in decodeUtf8 <$> genericDownload "mailtrap.io" path token
-- | Download inbox message raw email body.
downloadMessageRaw :: Token -> AccountID -> InboxID -> InboxMessageID -> IO Text
downloadMessageRaw = downloadMessageGeneric "raw"
-- | Download inbox message in EML format.
downloadMessageEML :: Token -> AccountID -> InboxID -> InboxMessageID -> IO Text
downloadMessageEML = downloadMessageGeneric "eml"
-- | Download inbox message text part.
downloadMessageText :: Token -> AccountID -> InboxID -> InboxMessageID -> IO Text
downloadMessageText = downloadMessageGeneric "txt"
-- | Download inbox message HTML part.
downloadMessageHTML :: Token -> AccountID -> InboxID -> InboxMessageID -> IO Text
downloadMessageHTML = downloadMessageGeneric "html"
-- | Production message identifier.
newtype MessageID = MessageID UUID deriving (Eq, Show, FromJSON)
-- | Send an e-mail and return the list of IDs of the messages sent (one per recipient).
sendEmail :: Token -> Email -> IO [MessageID]
sendEmail = genericSendEmail Nothing
-- | Send a testing e-mail to the given inbox and return the list of IDs of the messages
-- sent (one per recipient).
sendTestEmail :: Token -> InboxID -> Email -> IO [InboxMessageID]
sendTestEmail token i = fmap (fmap asText) . genericSendEmail (Just i) token
-- | Unified implementation for sending testing and production e-mails.
genericSendEmail :: FromJSON a => Maybe InboxID -> Token -> Email -> IO [a]
genericSendEmail minbox token email =
let url = case minbox of
Nothing -> "send.api.mailtrap.io"
_ -> "sandbox.api.mailtrap.io"
path = case minbox of
Just (InboxID i) -> fromString $ "/api/send/" ++ show i
Nothing -> "/api/send"
in fromJSONResp @"message_ids" <$> genericQuery "POST" url path token multipleErrors (Just email)