packages feed

gmail-simple (empty) → 0.1.0.0

raw patch · 4 files changed

+384/−0 lines, 4 filesdep +aesondep +basedep +base64

Dependencies added: aeson, base, base64, blaze-html, bytestring, containers, cryptonite, http-api-data, http-conduit, http-media, jwt, text, time, unordered-containers

Files

+ LICENSE view
@@ -0,0 +1,29 @@+BSD 3-Clause License++Copyright (c) 2021, Daniel Casanueva+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its+   contributors may be used to endorse or promote products derived from+   this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,14 @@+# gmail-simple++A library for [Haskell](https://www.haskell.org) that provides functions to easily+interact with the [Google Mail API](https://developers.google.com/gmail/api).+It doesn't intend to be fully featured, but rather simple.++## Current features++* OAuth management.+* Mail sending.+* Plain text and HTML supported.++Feel free to open an issue if you are interested in the addition of+some specific feature.
+ gmail-simple.cabal view
@@ -0,0 +1,38 @@+name: gmail-simple+version: 0.1.0.0+synopsis: Simple library for Google Mail (GMail).+description:+  Easy-to-use library to interact with the Google Mail API.+  .+  Current only supporting sending mails.+category: Email, Google+license: BSD3+license-file: LICENSE+bug-reports: https://github.com/Daniel-Diaz/gmail-simple/issues+homepage: https://github.com/Daniel-Diaz/gmail-simple/blob/main/README.md+build-type: Simple+cabal-version: >= 1.10+extra-source-files:+  README.md++library+  default-language: Haskell2010+  hs-source-dirs: src+  ghc-options: -Wall+  build-depends:+      base < 5+    , jwt+    , http-conduit+    , text+    , time+    , unordered-containers+    , aeson+    , base64+    , http-media+    , bytestring+    , cryptonite+    , containers+    , http-api-data+    , blaze-html+  exposed-modules:+    Network.GMail.Simple
+ src/Network/GMail/Simple.hs view
@@ -0,0 +1,303 @@++{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE FlexibleInstances #-}++-- | Interactions with GMail made simple.+--+--   == Sending mails+--+--   For now, only mail sending is implemented.+--   Here's an example:+--+--   First we read the Google key file.+--+-- > do gkey <- readKeyFile "google-key.json"+--+--   Then we start a session. We provide the+--   mail address of the user that will send+--   the mail.+--+-- >    session <- newSession gkey "sender@example.com"+--+--   Here's the mail description.+--+-- >    let mail :: Mail Text+-- >        mail = Mail+-- >          { mail_recipient = "recipient@example.com"+-- >          , mail_subject = "Example mail"+-- >          , mail_body = "This is an example mail."+-- >            }+--+--   Finally, we simply send the mail.+--+-- >    sendMail session mail+--+--   That's it.+--+--   == Importing this library+--+--   I would recommend importing this module qualified. For example:+--+-- > import qualified Network.GMail.Simple as GMail+--+--+module Network.GMail.Simple+  ( -- * Key+    Key (..)+  , readKeyFile+    -- * Session+  , Session+  , newSession+    -- * Mail+  , MailAddress (..)+  , Mail (..)+  , sendMail+    -- * Mail body+  , ToMailBody (..)+    -- * Exceptions+  , GMailException (..)+    ) where++import Control.Monad (unless)+import Control.Exception (Exception, throwIO)+import Control.Concurrent (MVar, newMVar, modifyMVar)+import Data.Proxy (Proxy (..))+import Data.String (IsString (..))+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Lazy as LazyText+import Data.Text.Encoding (encodeUtf8, decodeUtf8)+import qualified Web.JWT as JWT+import qualified Data.HashMap.Strict as HashMap+import qualified Data.Aeson as JSON+import qualified Network.HTTP.Simple as HTTP+import Data.ByteString.Base64 (encodeBase64)+import Network.HTTP.Media (MediaType)+import qualified Network.HTTP.Media as Media+import Data.Time.Clock (NominalDiffTime)+import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime)+import qualified Web.FormUrlEncoded as URLEncoded+import Crypto.PubKey.RSA.Types (PrivateKey)+import qualified Data.Map as Map+import Data.ByteString.Lazy (ByteString)+import qualified Data.ByteString.Lazy as LazyB+import Text.Blaze.Html (Html)+import qualified Text.Blaze.Html.Renderer.Text as HTML++-- | A mail address as text.+newtype MailAddress = MailAddress Text++mailAddressText :: MailAddress -> Text+mailAddressText (MailAddress t) = t++instance IsString MailAddress where+  fromString = MailAddress . fromString++-- | Mail datatype.+data Mail a = Mail+  { -- | Sender's alias.+    mail_sender :: Text+    -- | The recipient of this mail.+  , mail_recipient :: MailAddress+    -- | The subject of this mail.+  , mail_subject :: Text+    -- | Polymorphic mail body.+  , mail_body :: a+    }++-- | You can use `fmap` to map a function over the body of a mail.+instance Functor Mail where+  fmap f mail = mail { mail_body = f $ mail_body mail }++-- | Google API Key from a service account. You can create one+--   inside your project in <https://console.cloud.google.com Google Cloud>.+--   Once you have it, you can download it to a file and read it using 'readKeyFile'.+data Key = Key+  { private_key    :: PrivateKey+  , private_key_id :: Text+  , client_email   :: MailAddress+    }++-- | Read the key file provided by Google Cloud.+--   It throws a 'FailedToParseKey' exception when+--   the file fails to parse.+--+--   If you don't want to read it from a local file,+--   you can use the `JSON.FromJSON` instance of `Key`+--   to read it. The function 'readKeyFile' is just a+--   wrapper around that.+readKeyFile :: FilePath -> IO Key+readKeyFile fp = LazyB.readFile fp >>=+  either (throwIO . FailedToParseKey) pure . JSON.eitherDecode++instance JSON.FromJSON Key where+  parseJSON = JSON.withObject "Key" $ \o -> do+    pkt <- o JSON..: "private_key"+    case JWT.readRsaSecret $ encodeUtf8 pkt of+      Just pk -> Key+        <$> pure pk+        <*> o JSON..: "private_key_id"+        <*> (MailAddress <$> o JSON..: "client_email")+      _ -> fail "Error parsing private key."++data OAuth = OAuth+  { oauth_access_token :: Text+  , oauth_expires_in :: NominalDiffTime+    }++instance JSON.FromJSON OAuth where+  parseJSON = JSON.withObject "OAuth" $ \o ->+    OAuth <$> o JSON..: "access_token" <*> o JSON..: "expires_in"++oauthQuery+  :: Key -- ^ Google key+  -> MailAddress -- ^ Sender mail address+  -> IO OAuth+oauthQuery k sender = do+  now <- getPOSIXTime+  let -- JWT Header+      h = JWT.JOSEHeader+            { JWT.typ = Just "JWT"+            , JWT.cty = Nothing+            , JWT.alg = Just JWT.RS256+            , JWT.kid = Nothing+              }+      -- Scoped required to send mails+      scope :: Text+      scope = "https://www.googleapis.com/auth/gmail.send"+      -- JWT Claims+      c = JWT.JWTClaimsSet+            { JWT.iss = JWT.stringOrURI $ mailAddressText $ client_email k+            , JWT.sub = JWT.stringOrURI $ mailAddressText sender+            , JWT.aud = Left <$> JWT.stringOrURI "https://oauth2.googleapis.com/token"+            , JWT.unregisteredClaims = JWT.ClaimsMap $ Map.singleton "scope" $ JSON.toJSON scope+            , JWT.iat = JWT.numericDate now+            , JWT.exp = JWT.numericDate $ now + 3600+            , JWT.nbf = Nothing+            , JWT.jti = Nothing+              }+      -- Signed JWT+      jwt = JWT.encodeSigned (JWT.RSAPrivateKey $ private_key k) h c+      -- HTTP request body+      body :: ByteString+      body = URLEncoded.urlEncodeForm $ URLEncoded.Form $ HashMap.fromList+               [ ("grant_type", ["urn:ietf:params:oauth:grant-type:jwt-bearer"])+               , ("assertion", [jwt])+                 ]+      -- HTTP request+      req :: HTTP.Request+      req = HTTP.setRequestMethod "POST"+          $ HTTP.setRequestSecure True+          $ HTTP.setRequestPort 443+          $ HTTP.setRequestHost "oauth2.googleapis.com"+          $ HTTP.setRequestPath "/token"+          $ HTTP.addRequestHeader "Content-Type" "application/x-www-form-urlencoded"+          $ HTTP.setRequestBodyLBS body+          $ HTTP.defaultRequest+  HTTP.getResponseBody <$> HTTP.httpJSON req ++data OAuthWithTimestamp = OAuthWithTimestamp+  { oauth_value :: OAuth+  , oauth_time :: POSIXTime+    }++-- | A session that can be used to send mails.+--+--   * It may be reused.+--   * Multiple threads can use it simultaneously.+data Session = Session+  { session_key :: Key+  , session_sender :: MailAddress+  , session_oauth :: MVar OAuthWithTimestamp+    }++-- | Create a new session for the given sender.+newSession+  :: Key -- ^ Google API key+  -> MailAddress -- ^ Mail address of the sender+  -> IO Session+newSession k sender = do+  oauth <- oauthQuery k sender+  now <- getPOSIXTime+  let oauthw = OAuthWithTimestamp+        { oauth_value = oauth+        , oauth_time = now+          }+  Session k sender <$> newMVar oauthw++withOAuth :: Session -> (OAuth -> IO a) -> IO a+withOAuth session f = modifyMVar (session_oauth session) $ \oauthw -> do+  let oauth = oauth_value oauthw+  now <- getPOSIXTime+  -- We renew the token 5 seconds earlier+  if now + 5 < oauth_time oauthw + oauth_expires_in oauth+     then (,) oauthw <$> f oauth+     else do oauth' <- oauthQuery (session_key session) (session_sender session)+             now' <- getPOSIXTime+             let oauthw' = OAuthWithTimestamp+                   { oauth_value = oauth'+                   , oauth_time = now'+                     }+             (,) oauthw' <$> f oauth'++renderMail :: forall a . ToMailBody a => MailAddress -> Mail a -> JSON.Value+renderMail sender mail = JSON.Object $ HashMap.singleton "raw" $ JSON.String+    $ Text.replace "+" "-"+    $ Text.replace "/" "_"+    $ encodeBase64+    $ encodeUtf8 $ Text.concat+        [ "From: " <> mail_sender mail <> " <" <> mailAddressText sender <> ">\r\n"+        , "To: " <> mailAddressText (mail_recipient mail) <> "\r\n"+        , "Subject: " <> mail_subject mail <> "\r\n"+        , "Content-Type: " <> decodeUtf8 (Media.renderHeader $ mailContentType (Proxy @ a)) <> "\r\n"+        , "\r\n"+        , toMailBody $ mail_body mail+          ]++-- | Exceptions thrown by functions in this library.+data GMailException =+    -- | A mail failed to be sent. The JSON value contains+    --   the error message as sent by Google.+    FailedToSend JSON.Value+    -- | A key file failed to parse. The string contains+    --   the parsing error.+  | FailedToParseKey String+    deriving Show++instance Exception GMailException++-- | Send mail using a session. It might throw a 'FailedToSend' exception.+sendMail :: ToMailBody a => Session -> Mail a -> IO ()+sendMail session mail = withOAuth session $ \oauth -> do+  let mailReq :: HTTP.Request+      mailReq = HTTP.setRequestMethod "POST"+              $ HTTP.setRequestSecure True+              $ HTTP.setRequestPort 443+              $ HTTP.setRequestHost "gmail.googleapis.com"+              $ HTTP.setRequestPath ("/gmail/v1/users/me/messages/send")+              $ HTTP.setRequestQueryString [("key",Just $ encodeUtf8 $ private_key_id $ session_key session)]+              $ HTTP.addRequestHeader "Authorization" ("Bearer " <> encodeUtf8 (oauth_access_token oauth))+              $ HTTP.setRequestBodyJSON (renderMail (session_sender session) mail)+              $ HTTP.defaultRequest+  resp <- HTTP.httpJSON mailReq+  let respCode = HTTP.getResponseStatusCode resp+  unless (div respCode 100 == 2) $ throwIO $ FailedToSend $ HTTP.getResponseBody resp++-- ToMailBody class and instances++-- | Class of types that can be used as mail body.+class ToMailBody a where+  -- | Textual representation of the mail body.+  toMailBody :: a -> Text+  -- | Value for the @Content-Type@ header.+  mailContentType :: proxy a -> MediaType++instance ToMailBody Text where+  toMailBody = id+  mailContentType _ = "text" Media.// "plain"++instance ToMailBody Html where+  toMailBody = LazyText.toStrict . HTML.renderHtml+  mailContentType _ = "text" Media.// "html"