From 605760c75afe27e154177b939b4a1cd289e3a2b2 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Mon, 19 Aug 2024 00:12:59 +0530 Subject: [PATCH 1/9] APIs for email communication --- azure-email/Azure/Email.hs | 2 ++ azure-email/CHANGELOG.md | 5 +++ azure-email/LICENSE | 21 ++++++++++++ azure-email/azure-email.cabal | 60 +++++++++++++++++++++++++++++++++++ cabal.project | 1 + 5 files changed, 89 insertions(+) create mode 100644 azure-email/Azure/Email.hs create mode 100644 azure-email/CHANGELOG.md create mode 100644 azure-email/LICENSE create mode 100644 azure-email/azure-email.cabal diff --git a/azure-email/Azure/Email.hs b/azure-email/Azure/Email.hs new file mode 100644 index 0000000..4bc78fa --- /dev/null +++ b/azure-email/Azure/Email.hs @@ -0,0 +1,2 @@ +module Azure.Email + ( ) where diff --git a/azure-email/CHANGELOG.md b/azure-email/CHANGELOG.md new file mode 100644 index 0000000..18f52d5 --- /dev/null +++ b/azure-email/CHANGELOG.md @@ -0,0 +1,5 @@ +# Revision history for azure-auth + +## 0.1.0.0 -- YYYY-mm-dd + +* First version. Released on an unsuspecting world. diff --git a/azure-email/LICENSE b/azure-email/LICENSE new file mode 100644 index 0000000..1f229d7 --- /dev/null +++ b/azure-email/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Holmusk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/azure-email/azure-email.cabal b/azure-email/azure-email.cabal new file mode 100644 index 0000000..57b36d1 --- /dev/null +++ b/azure-email/azure-email.cabal @@ -0,0 +1,60 @@ +cabal-version: 3.0 +name: azure-email +version: 0.1.0.0 +synopsis: Boilerplace/startkit for azure in Haskell (using servant) +description: This provides from useful functionalities for starting out with Azure in Haskell. + This includes authentication, Key vault, Blob storage and email communication related APIs. +license: MIT +author: Holmusk +maintainer: tech@holmusk.com +category: Azure +build-type: Simple +extra-doc-files: CHANGELOG.md +tested-with: GHC == 9.2.8 + GHC == 9.4.8 + GHC == 9.6.3 + GHC == 9.8.2 + +common common-options + ghc-options: -Wall + -Wincomplete-uni-patterns + -Wincomplete-record-updates + -Wcompat + -Widentities + -Wredundant-constraints + -fhide-source-paths + -Wpartial-fields + -Wunrecognised-pragmas + -Wmissing-deriving-strategies + -Wunticked-promoted-constructors + -Winvalid-haddock + -Woperator-whitespace + -Wredundant-bang-patterns + -Wunused-packages + build-depends: base >= 4.7 && <5 + default-language: GHC2021 + default-extensions: DataKinds + DerivingStrategies + DerivingVia + LambdaCase + NoImportQualifiedPost + NoGeneralisedNewtypeDeriving + OverloadedStrings + OverloadedLabels + RecordWildCards + TypeFamilies + ViewPatterns + if os(linux) + ghc-options: -optl-fuse-ld=gold + ld-options: -fuse-ld=gold + +library + import: common-options + exposed-modules: Azure.Email + build-depends: aeson + , http-client + , servant + , servant-client + , text + , unliftio + default-language: Haskell2010 diff --git a/cabal.project b/cabal.project index 3ac2529..910676f 100644 --- a/cabal.project +++ b/cabal.project @@ -1,4 +1,5 @@ packages: ./azure-auth + ./azure-email ./azure-key-vault ./azure-blob-storage From 27fa80e92aade64640771c1f3ce2e58340fb6fd9 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Thu, 22 Aug 2024 14:01:49 +0530 Subject: [PATCH 2/9] core types --- Makefile | 2 +- azure-email/Azure/Email.hs | 76 +++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index ff41316..8d188a2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -SRC=$(shell find azure-auth/ azure-key-vault/ azure-blob-storage/ -type f -name '*.hs') +SRC=$(shell find azure-auth/ azure-key-vault/ azure-blob-storage/ azure-email/ -type f -name '*.hs') .PHONY: format format: $(SRC) diff --git a/azure-email/Azure/Email.hs b/azure-email/Azure/Email.hs index 4bc78fa..abf5092 100644 --- a/azure-email/Azure/Email.hs +++ b/azure-email/Azure/Email.hs @@ -1,2 +1,74 @@ -module Azure.Email - ( ) where +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DerivingVia #-} + +module Azure.Email () where + +import Data.Aeson (ToJSON (..), object, (.=)) +import Data.Text (Text) +import GHC.Generics (Generic) + +data EmailAddress = EmailAddress + { eaEmail :: !Text + , eaDisplayName :: !Text + } + deriving stock (Eq, Show, Generic) + +instance ToJSON EmailAddress where + toJSON EmailAddress{..} = + object + [ "address" .= eaEmail + , "displayName" .= eaDisplayName + ] + +-- | Fields to represent @cc@, @bcc@ and @to@ in an email +data EmailRecipients = EmailRecipients + { ccRecipients :: ![EmailAddress] + , bccRecipients :: ![EmailAddress] + , toRecipients :: ![EmailAddress] + } + deriving stock (Generic) + +instance ToJSON EmailRecipients where + toJSON EmailRecipients{..} = + object + [ "to" .= toRecipients + , "cc" .= ccRecipients + , "bcc" .= bccRecipients + ] + +-- | Azure email requires both HTML and plain text format email content +data EmailContent = EmailContent + { ecHtml :: !Text + -- ^ Html version of the email message. + , ecPlainText :: !Text + -- ^ Plain text version of the email message. + , ecSubject :: !Text + -- ^ Subject of the email message. + } + deriving stock (Eq, Show, Generic) + +instance ToJSON EmailContent where + toJSON EmailContent{..} = + object + [ "plainText" .= ecPlainText + , "html" .= ecHtml + , "subject" .= ecSubject + ] + +{- | Source: +https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP +-} +data AzureEmailRequest = AzureEmailRequest + { aerContent :: !EmailContent + , aerRecipients :: !EmailRecipients + , aerSenderAddress :: !Text -- TODO: This should probably be it's own newtype + } + deriving stock (Generic) + +instance ToJSON AzureEmailRequest where + toJSON AzureEmailRequest{..} = + object + [ "content" .= aerContent + , "recipients" .= aerRecipients + , "senderAddress" .= aerSenderAddress + ] From 953c93c47d574678dd82be772422126fbfe748e4 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Thu, 22 Aug 2024 14:04:40 +0530 Subject: [PATCH 3/9] Regenerate CI --- .github/workflows/haskell-ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/haskell-ci.yml b/.github/workflows/haskell-ci.yml index 31b6797..dba0cdb 100644 --- a/.github/workflows/haskell-ci.yml +++ b/.github/workflows/haskell-ci.yml @@ -140,6 +140,7 @@ jobs: run: | touch cabal.project echo "packages: $GITHUB_WORKSPACE/source/./azure-auth" >> cabal.project + echo "packages: $GITHUB_WORKSPACE/source/./azure-email" >> cabal.project echo "packages: $GITHUB_WORKSPACE/source/./azure-key-vault" >> cabal.project echo "packages: $GITHUB_WORKSPACE/source/./azure-blob-storage" >> cabal.project cat cabal.project @@ -155,6 +156,8 @@ jobs: run: | PKGDIR_azure_auth="$(find "$GITHUB_WORKSPACE/unpacked" -maxdepth 1 -type d -regex '.*/azure-auth-[0-9.]*')" echo "PKGDIR_azure_auth=${PKGDIR_azure_auth}" >> "$GITHUB_ENV" + PKGDIR_azure_email="$(find "$GITHUB_WORKSPACE/unpacked" -maxdepth 1 -type d -regex '.*/azure-email-[0-9.]*')" + echo "PKGDIR_azure_email=${PKGDIR_azure_email}" >> "$GITHUB_ENV" PKGDIR_azure_key_vault="$(find "$GITHUB_WORKSPACE/unpacked" -maxdepth 1 -type d -regex '.*/azure-key-vault-[0-9.]*')" echo "PKGDIR_azure_key_vault=${PKGDIR_azure_key_vault}" >> "$GITHUB_ENV" PKGDIR_azure_blob_storage="$(find "$GITHUB_WORKSPACE/unpacked" -maxdepth 1 -type d -regex '.*/azure-blob-storage-[0-9.]*')" @@ -163,17 +166,20 @@ jobs: touch cabal.project touch cabal.project.local echo "packages: ${PKGDIR_azure_auth}" >> cabal.project + echo "packages: ${PKGDIR_azure_email}" >> cabal.project echo "packages: ${PKGDIR_azure_key_vault}" >> cabal.project echo "packages: ${PKGDIR_azure_blob_storage}" >> cabal.project echo "package azure-auth" >> cabal.project echo " ghc-options: -Werror=missing-methods" >> cabal.project + echo "package azure-email" >> cabal.project + echo " ghc-options: -Werror=missing-methods" >> cabal.project echo "package azure-key-vault" >> cabal.project echo " ghc-options: -Werror=missing-methods" >> cabal.project echo "package azure-blob-storage" >> cabal.project echo " ghc-options: -Werror=missing-methods" >> cabal.project cat >> cabal.project <> cabal.project.local + $HCPKG list --simple-output --names-only | perl -ne 'for (split /\s+/) { print "constraints: $_ installed\n" unless /^(azure-auth|azure-blob-storage|azure-email|azure-key-vault)$/; }' >> cabal.project.local cat cabal.project cat cabal.project.local - name: dump install plan @@ -200,6 +206,8 @@ jobs: run: | cd ${PKGDIR_azure_auth} || false ${CABAL} -vnormal check + cd ${PKGDIR_azure_email} || false + ${CABAL} -vnormal check cd ${PKGDIR_azure_key_vault} || false ${CABAL} -vnormal check cd ${PKGDIR_azure_blob_storage} || false From 6c6586b41a7f0baad0da0b21d107ed2b5a707f68 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Thu, 22 Aug 2024 16:17:42 +0530 Subject: [PATCH 4/9] missing fields in email request type --- azure-email/Azure/Email.hs | 35 +++++++++++++++++++++++++++++++++++ azure-email/azure-email.cabal | 1 + 2 files changed, 36 insertions(+) diff --git a/azure-email/Azure/Email.hs b/azure-email/Azure/Email.hs index abf5092..7d8e23e 100644 --- a/azure-email/Azure/Email.hs +++ b/azure-email/Azure/Email.hs @@ -55,6 +55,24 @@ instance ToJSON EmailContent where , "subject" .= ecSubject ] +data EmailAttachment = EmailAttachment + { eaContentInBase64 :: !Text + -- ^ Base64 encoded contents of the attachment + , eaContentType :: !Text + -- ^ MIME type of the attachment + , eaName :: !Text + -- ^ Name of the attachment + } + deriving stock (Generic) + +instance ToJSON EmailAttachment where + toJSON EmailAttachment{..} = + object + [ "name" .= eaName + , "contentType" .= eaContentType + , "contentInBase64" .= eaContentInBase64 + ] + {- | Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP -} @@ -62,6 +80,9 @@ data AzureEmailRequest = AzureEmailRequest { aerContent :: !EmailContent , aerRecipients :: !EmailRecipients , aerSenderAddress :: !Text -- TODO: This should probably be it's own newtype + , aerReplyTo :: ![EmailAddress] -- TODO: Should this be NonEmpty instead? + , aerAttachments :: ![EmailAttachment] + , aerUserEngagementTrackingDisabled :: !Bool } deriving stock (Generic) @@ -71,4 +92,18 @@ instance ToJSON AzureEmailRequest where [ "content" .= aerContent , "recipients" .= aerRecipients , "senderAddress" .= aerSenderAddress + , "replyTo" .= aerReplyTo + , "attachments" .= aerAttachments + , "userEngagementTrackingDisabled" .= aerUserEngagementTrackingDisabled ] + +{- | Possible states once a send email action is triggered. +Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP#emailsendstatus +-} +data EmailSendStatus + = Canceled + | Failed + | NotStarted + | Running + | Succeeded + deriving stock (Eq, Show, Generic, Enum, Bounded) diff --git a/azure-email/azure-email.cabal b/azure-email/azure-email.cabal index 57b36d1..e470590 100644 --- a/azure-email/azure-email.cabal +++ b/azure-email/azure-email.cabal @@ -52,6 +52,7 @@ library import: common-options exposed-modules: Azure.Email build-depends: aeson + , bytestring , http-client , servant , servant-client From 732b0781acc75fbf682916517e760f0bd5387fe6 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Sun, 25 Aug 2024 10:54:06 +0530 Subject: [PATCH 5/9] client for sending emails --- azure-email/Azure/Email.hs | 178 +++++++++++++++------------------- azure-email/Azure/Types.hs | 131 +++++++++++++++++++++++++ azure-email/azure-email.cabal | 5 + 3 files changed, 215 insertions(+), 99 deletions(-) create mode 100644 azure-email/Azure/Types.hs diff --git a/azure-email/Azure/Email.hs b/azure-email/Azure/Email.hs index 7d8e23e..bf5a206 100644 --- a/azure-email/Azure/Email.hs +++ b/azure-email/Azure/Email.hs @@ -1,109 +1,89 @@ -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE DerivingVia #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeOperators #-} -module Azure.Email () where +module Azure.Email where -import Data.Aeson (ToJSON (..), object, (.=)) +import Azure.Types (AzureEmailRequest (..)) +import Crypto.Hash.SHA256 (hash, hmac) +import Data.Aeson (encode) +import Data.Proxy (Proxy (..)) import Data.Text (Text) -import GHC.Generics (Generic) +import Data.Text.Encoding (decodeUtf8) +import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime) +import Network.HTTP.Client.TLS (newTlsManager) +import Servant.API +import Servant.Client (BaseUrl (..), ClientM, Scheme (..), client, mkClientEnv, runClientM) +import UnliftIO (MonadIO (..)) -data EmailAddress = EmailAddress - { eaEmail :: !Text - , eaDisplayName :: !Text - } - deriving stock (Eq, Show, Generic) +import qualified Data.ByteString as BS +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Char8 as C8 +import qualified Data.Text as Text -instance ToJSON EmailAddress where - toJSON EmailAddress{..} = - object - [ "address" .= eaEmail - , "displayName" .= eaDisplayName - ] +type SendEmailApi = + "emails:send" + :> QueryParam' '[Required, Strict] "api-version" Text + :> Header' '[Required, Strict] "x-ms-date" Text + :> Header' '[Required, Strict] "Host" Text + :> Header' '[Required, Strict] "x-ms-content-sha256" Text + :> Header' '[Required, Strict] "Authorization" Text + :> ReqBody '[JSON] AzureEmailRequest + :> PostNoContent --- | Fields to represent @cc@, @bcc@ and @to@ in an email -data EmailRecipients = EmailRecipients - { ccRecipients :: ![EmailAddress] - , bccRecipients :: ![EmailAddress] - , toRecipients :: ![EmailAddress] - } - deriving stock (Generic) +sendEmailApi :: Text -> Text -> Text -> Text -> Text -> AzureEmailRequest -> ClientM NoContent +sendEmailApi = client (Proxy @SendEmailApi) -instance ToJSON EmailRecipients where - toJSON EmailRecipients{..} = - object - [ "to" .= toRecipients - , "cc" .= ccRecipients - , "bcc" .= bccRecipients - ] +callSendEmailClient :: + (Text -> Text -> Text -> Text -> Text -> AzureEmailRequest -> ClientM NoContent) -> + AzureEmailRequest -> + Text -> + Text -> + IO (Either Text ()) +callSendEmailClient action req azureEmailHost secret = do + manager <- liftIO newTlsManager + (formatToAzureTime -> now) <- getCurrentTime + encodedPayload <- encodePayload + let stringToSign = + "POST\n" + <> "/emails:send?api-version=" + <> apiVersion + <> "\n" + <> now + <> ";" + <> azureEmailHost + <> ";" + <> encodedPayload + let sign = buildSignature stringToSign secret + res <- + liftIO $ + runClientM + (action apiVersion now azureEmailHost encodedPayload ("HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=" <> sign) req) + (mkClientEnv manager $ BaseUrl Https (Text.unpack azureEmailHost) 443 "") + pure $ case res of + Left err -> do + Left . Text.pack $ show err + Right _ -> do + Right () + where + apiVersion :: Text + apiVersion = "2023-03-31" --- | Azure email requires both HTML and plain text format email content -data EmailContent = EmailContent - { ecHtml :: !Text - -- ^ Html version of the email message. - , ecPlainText :: !Text - -- ^ Plain text version of the email message. - , ecSubject :: !Text - -- ^ Subject of the email message. - } - deriving stock (Eq, Show, Generic) + encodePayload :: IO Text + encodePayload = do + let contentBytes = encode req + hashedBytes = hash (BS.toStrict contentBytes) + encodedHash = B64.encode hashedBytes + pure $ decodeUtf8 encodedHash -instance ToJSON EmailContent where - toJSON EmailContent{..} = - object - [ "plainText" .= ecPlainText - , "html" .= ecHtml - , "subject" .= ecSubject - ] + -- TODO: formatToAzureTime and buildSignature are borrowed from azure-blob-storage. + -- We should not be duplicating these utility functions + formatToAzureTime :: UTCTime -> Text + formatToAzureTime time = Text.pack $ formatTime defaultTimeLocale "%FT%TZ" time -data EmailAttachment = EmailAttachment - { eaContentInBase64 :: !Text - -- ^ Base64 encoded contents of the attachment - , eaContentType :: !Text - -- ^ MIME type of the attachment - , eaName :: !Text - -- ^ Name of the attachment - } - deriving stock (Generic) - -instance ToJSON EmailAttachment where - toJSON EmailAttachment{..} = - object - [ "name" .= eaName - , "contentType" .= eaContentType - , "contentInBase64" .= eaContentInBase64 - ] - -{- | Source: -https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP --} -data AzureEmailRequest = AzureEmailRequest - { aerContent :: !EmailContent - , aerRecipients :: !EmailRecipients - , aerSenderAddress :: !Text -- TODO: This should probably be it's own newtype - , aerReplyTo :: ![EmailAddress] -- TODO: Should this be NonEmpty instead? - , aerAttachments :: ![EmailAttachment] - , aerUserEngagementTrackingDisabled :: !Bool - } - deriving stock (Generic) - -instance ToJSON AzureEmailRequest where - toJSON AzureEmailRequest{..} = - object - [ "content" .= aerContent - , "recipients" .= aerRecipients - , "senderAddress" .= aerSenderAddress - , "replyTo" .= aerReplyTo - , "attachments" .= aerAttachments - , "userEngagementTrackingDisabled" .= aerUserEngagementTrackingDisabled - ] - -{- | Possible states once a send email action is triggered. -Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP#emailsendstatus --} -data EmailSendStatus - = Canceled - | Failed - | NotStarted - | Running - | Succeeded - deriving stock (Eq, Show, Generic, Enum, Bounded) + buildSignature :: Text -> Text -> Text + buildSignature stringToSign sec = + let decodedSecret = B64.decodeLenient (C8.pack (Text.unpack sec)) + encodedStringToSign = C8.pack (Text.unpack stringToSign) + hashedBytes = hmac decodedSecret encodedStringToSign + encodedSignature = B64.encode hashedBytes + in decodeUtf8 encodedSignature diff --git a/azure-email/Azure/Types.hs b/azure-email/Azure/Types.hs new file mode 100644 index 0000000..8f01256 --- /dev/null +++ b/azure-email/Azure/Types.hs @@ -0,0 +1,131 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DerivingVia #-} + +module Azure.Types (AzureEmailRequest (..), AzureEmailResponse (..)) where + +import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, withText, (.:), (.=)) +import Data.Aeson.Types (parseFail) +import Data.Text (Text) +import GHC.Generics (Generic) + +data EmailAddress = EmailAddress + { eaEmail :: !Text + , eaDisplayName :: !Text + } + deriving stock (Eq, Show, Generic) + +instance ToJSON EmailAddress where + toJSON EmailAddress{..} = + object + [ "address" .= eaEmail + , "displayName" .= eaDisplayName + ] + +-- | Fields to represent @cc@, @bcc@ and @to@ in an email +data EmailRecipients = EmailRecipients + { ccRecipients :: ![EmailAddress] + , bccRecipients :: ![EmailAddress] + , toRecipients :: ![EmailAddress] + } + deriving stock (Generic) + +instance ToJSON EmailRecipients where + toJSON EmailRecipients{..} = + object + [ "to" .= toRecipients + , "cc" .= ccRecipients + , "bcc" .= bccRecipients + ] + +-- | Azure email requires both HTML and plain text format email content +data EmailContent = EmailContent + { ecHtml :: !Text + -- ^ Html version of the email message. + , ecPlainText :: !Text + -- ^ Plain text version of the email message. + , ecSubject :: !Text + -- ^ Subject of the email message. + } + deriving stock (Eq, Show, Generic) + +instance ToJSON EmailContent where + toJSON EmailContent{..} = + object + [ "plainText" .= ecPlainText + , "html" .= ecHtml + , "subject" .= ecSubject + ] + +data EmailAttachment = EmailAttachment + { eaContentInBase64 :: !Text + -- ^ Base64 encoded contents of the attachment + , eaContentType :: !Text + -- ^ MIME type of the attachment + , eaName :: !Text + -- ^ Name of the attachment + } + deriving stock (Generic) + +instance ToJSON EmailAttachment where + toJSON EmailAttachment{..} = + object + [ "name" .= eaName + , "contentType" .= eaContentType + , "contentInBase64" .= eaContentInBase64 + ] + +{- | Source: +https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP +-} +data AzureEmailRequest = AzureEmailRequest + { aerContent :: !EmailContent + , aerRecipients :: !EmailRecipients + , aerSenderAddress :: !Text -- TODO: This should probably be it's own newtype + , aerReplyTo :: ![EmailAddress] -- TODO: Should this be NonEmpty instead? + , aerAttachments :: ![EmailAttachment] + , aerUserEngagementTrackingDisabled :: !Bool + } + deriving stock (Generic) + +instance ToJSON AzureEmailRequest where + toJSON AzureEmailRequest{..} = + object + [ "content" .= aerContent + , "recipients" .= aerRecipients + , "senderAddress" .= aerSenderAddress + , "replyTo" .= aerReplyTo + , "attachments" .= aerAttachments + , "userEngagementTrackingDisabled" .= aerUserEngagementTrackingDisabled + ] + +{- | Possible states once a send email action is triggered. +Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP#emailsendstatus +-} +data EmailSendStatus + = Canceled + | Failed + | NotStarted + | Running + | Succeeded + deriving stock (Eq, Show, Generic, Enum, Bounded) + +instance FromJSON EmailSendStatus where + parseJSON = withText "EmailSendStatus" $ \case + "Canceled" -> pure Canceled + "Failed" -> pure Failed + "NotStarted" -> pure NotStarted + "Running" -> pure Running + "Succeeded" -> pure Succeeded + invalidStatus -> parseFail $ "Unexpected EmailSendStatus: " <> show invalidStatus + +data AzureEmailResponse = AzureEmailResponse + { aerId :: !Text + , aerStatus :: !EmailSendStatus + } + deriving stock (Eq, Show, Generic) + +instance FromJSON AzureEmailResponse where + parseJSON = withObject "AzureEmailResponse" $ \o -> do + aerId <- o .: "id" + aerStatus <- o .: "status" + pure AzureEmailResponse{..} diff --git a/azure-email/azure-email.cabal b/azure-email/azure-email.cabal index e470590..1e974f9 100644 --- a/azure-email/azure-email.cabal +++ b/azure-email/azure-email.cabal @@ -51,11 +51,16 @@ common common-options library import: common-options exposed-modules: Azure.Email + Azure.Types build-depends: aeson + , base64-bytestring , bytestring + , cryptohash-sha256 , http-client + , http-client-tls , servant , servant-client + , time , text , unliftio default-language: Haskell2010 From fc38c4da4435ecda8d6ab1f5928c5b967e201b65 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Sun, 25 Aug 2024 11:02:02 +0530 Subject: [PATCH 6/9] exports --- azure-email/Azure/Email.hs | 14 +++++++++++++- azure-email/azure-email.cabal | 1 - 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/azure-email/Azure/Email.hs b/azure-email/Azure/Email.hs index bf5a206..795be82 100644 --- a/azure-email/Azure/Email.hs +++ b/azure-email/Azure/Email.hs @@ -1,7 +1,7 @@ {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} -module Azure.Email where +module Azure.Email (sendEmail, sendEmailEither) where import Azure.Types (AzureEmailRequest (..)) import Crypto.Hash.SHA256 (hash, hmac) @@ -20,6 +20,18 @@ import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as C8 import qualified Data.Text as Text +sendEmail :: + MonadIO m => + Text -> + m () +sendEmail apiSecret = undefined + +sendEmailEither :: + MonadIO m => + Text -> + m (Either Text ()) +sendEmailEither apiSecret = undefined + type SendEmailApi = "emails:send" :> QueryParam' '[Required, Strict] "api-version" Text diff --git a/azure-email/azure-email.cabal b/azure-email/azure-email.cabal index 1e974f9..dccf94a 100644 --- a/azure-email/azure-email.cabal +++ b/azure-email/azure-email.cabal @@ -56,7 +56,6 @@ library , base64-bytestring , bytestring , cryptohash-sha256 - , http-client , http-client-tls , servant , servant-client From 84a9b7796d03e2eddaf2d11709c61daaa3d832c6 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Mon, 7 Oct 2024 16:31:42 +0530 Subject: [PATCH 7/9] remove redundant derivingVia --- azure-email/Azure/Email.hs | 17 +++++++++++------ azure-email/Azure/Types.hs | 39 +++++++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/azure-email/Azure/Email.hs b/azure-email/Azure/Email.hs index 795be82..1b57852 100644 --- a/azure-email/Azure/Email.hs +++ b/azure-email/Azure/Email.hs @@ -1,9 +1,12 @@ {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} -module Azure.Email (sendEmail, sendEmailEither) where +module Azure.Email + ( sendEmail + , sendEmailEither + ) where -import Azure.Types (AzureEmailRequest (..)) +import Azure.Types (AzureEmailRequest (..), AzureEmailResponse (..)) import Crypto.Hash.SHA256 (hash, hmac) import Data.Aeson (encode) import Data.Proxy (Proxy (..)) @@ -23,14 +26,16 @@ import qualified Data.Text as Text sendEmail :: MonadIO m => Text -> - m () -sendEmail apiSecret = undefined + AzureEmailRequest -> + m AzureEmailResponse +sendEmail apiSecret payload = undefined sendEmailEither :: MonadIO m => Text -> - m (Either Text ()) -sendEmailEither apiSecret = undefined + AzureEmailRequest -> + m (Either Text AzureEmailResponse) +sendEmailEither apiSecret payload = undefined type SendEmailApi = "emails:send" diff --git a/azure-email/Azure/Types.hs b/azure-email/Azure/Types.hs index 8f01256..dab3b3c 100644 --- a/azure-email/Azure/Types.hs +++ b/azure-email/Azure/Types.hs @@ -1,18 +1,34 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DerivingVia #-} -module Azure.Types (AzureEmailRequest (..), AzureEmailResponse (..)) where +module Azure.Types + ( AzureEmailRequest (..) + , AzureEmailResponse (..) + + -- * Types to work with email addresses. + + -- These types merely represent the address and + -- are not responsible for validating them whatsoever. + , EmailAddress (..) + , EmailRecipients (..) + , EmailContent (..) + , EmailAttachment (..) + ) where import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, withText, (.:), (.=)) import Data.Aeson.Types (parseFail) import Data.Text (Text) -import GHC.Generics (Generic) +{- | Each email is represented as an object with @displayName@ +and an associated @address@. + +Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP#emailaddress +-} data EmailAddress = EmailAddress { eaEmail :: !Text , eaDisplayName :: !Text } - deriving stock (Eq, Show, Generic) + deriving stock (Eq, Show) instance ToJSON EmailAddress where toJSON EmailAddress{..} = @@ -27,7 +43,7 @@ data EmailRecipients = EmailRecipients , bccRecipients :: ![EmailAddress] , toRecipients :: ![EmailAddress] } - deriving stock (Generic) + deriving stock (Show) instance ToJSON EmailRecipients where toJSON EmailRecipients{..} = @@ -46,7 +62,7 @@ data EmailContent = EmailContent , ecSubject :: !Text -- ^ Subject of the email message. } - deriving stock (Eq, Show, Generic) + deriving stock (Eq, Show) instance ToJSON EmailContent where toJSON EmailContent{..} = @@ -64,7 +80,7 @@ data EmailAttachment = EmailAttachment , eaName :: !Text -- ^ Name of the attachment } - deriving stock (Generic) + deriving stock (Show) instance ToJSON EmailAttachment where toJSON EmailAttachment{..} = @@ -74,8 +90,9 @@ instance ToJSON EmailAttachment where , "contentInBase64" .= eaContentInBase64 ] -{- | Source: -https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP +{- | Represents the payload for sending an email message. + +Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP#emailmessage -} data AzureEmailRequest = AzureEmailRequest { aerContent :: !EmailContent @@ -85,7 +102,7 @@ data AzureEmailRequest = AzureEmailRequest , aerAttachments :: ![EmailAttachment] , aerUserEngagementTrackingDisabled :: !Bool } - deriving stock (Generic) + deriving stock (Show) instance ToJSON AzureEmailRequest where toJSON AzureEmailRequest{..} = @@ -107,7 +124,7 @@ data EmailSendStatus | NotStarted | Running | Succeeded - deriving stock (Eq, Show, Generic, Enum, Bounded) + deriving stock (Eq, Show, Enum, Bounded) instance FromJSON EmailSendStatus where parseJSON = withText "EmailSendStatus" $ \case @@ -122,7 +139,7 @@ data AzureEmailResponse = AzureEmailResponse { aerId :: !Text , aerStatus :: !EmailSendStatus } - deriving stock (Eq, Show, Generic) + deriving stock (Eq, Show) instance FromJSON AzureEmailResponse where parseJSON = withObject "AzureEmailResponse" $ \o -> do From eb79866e8b4e4da955d37c7006d6db8cc376dcb3 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Sun, 27 Oct 2024 20:52:46 +0530 Subject: [PATCH 8/9] smart constructor + top level send function --- azure-email/Azure/Email.hs | 31 ++++++++++++++++++++++--------- azure-email/Azure/Types.hs | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/azure-email/Azure/Email.hs b/azure-email/Azure/Email.hs index 1b57852..1a9db38 100644 --- a/azure-email/Azure/Email.hs +++ b/azure-email/Azure/Email.hs @@ -16,26 +16,39 @@ import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime) import Network.HTTP.Client.TLS (newTlsManager) import Servant.API import Servant.Client (BaseUrl (..), ClientM, Scheme (..), client, mkClientEnv, runClientM) -import UnliftIO (MonadIO (..)) +import UnliftIO (MonadIO (..), throwString) import qualified Data.ByteString as BS import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as C8 import qualified Data.Text as Text +{- | Send an email provided a request payload + +Errors are thrown in IO. For a variant where error is captured +in an @Left@ branch, see @sendEmailEither@ +-} sendEmail :: MonadIO m => Text -> + Text -> AzureEmailRequest -> m AzureEmailResponse -sendEmail apiSecret payload = undefined +sendEmail apiSecret emailHost payload = do + resp <- sendEmailEither apiSecret emailHost payload + case resp of + Left err -> throwString $ show err + Right r -> pure r +-- | Send an email provided a request payload sendEmailEither :: MonadIO m => Text -> + Text -> AzureEmailRequest -> m (Either Text AzureEmailResponse) -sendEmailEither apiSecret payload = undefined +sendEmailEither apiSecret emailHost payload = + liftIO $ callSendEmailClient sendEmailApi payload emailHost apiSecret type SendEmailApi = "emails:send" @@ -45,17 +58,17 @@ type SendEmailApi = :> Header' '[Required, Strict] "x-ms-content-sha256" Text :> Header' '[Required, Strict] "Authorization" Text :> ReqBody '[JSON] AzureEmailRequest - :> PostNoContent + :> Post '[JSON] AzureEmailResponse -sendEmailApi :: Text -> Text -> Text -> Text -> Text -> AzureEmailRequest -> ClientM NoContent +sendEmailApi :: Text -> Text -> Text -> Text -> Text -> AzureEmailRequest -> ClientM AzureEmailResponse sendEmailApi = client (Proxy @SendEmailApi) callSendEmailClient :: - (Text -> Text -> Text -> Text -> Text -> AzureEmailRequest -> ClientM NoContent) -> + (Text -> Text -> Text -> Text -> Text -> AzureEmailRequest -> ClientM AzureEmailResponse) -> AzureEmailRequest -> Text -> Text -> - IO (Either Text ()) + IO (Either Text AzureEmailResponse) callSendEmailClient action req azureEmailHost secret = do manager <- liftIO newTlsManager (formatToAzureTime -> now) <- getCurrentTime @@ -79,8 +92,8 @@ callSendEmailClient action req azureEmailHost secret = do pure $ case res of Left err -> do Left . Text.pack $ show err - Right _ -> do - Right () + Right r -> do + Right r where apiVersion :: Text apiVersion = "2023-03-31" diff --git a/azure-email/Azure/Types.hs b/azure-email/Azure/Types.hs index dab3b3c..32e20a5 100644 --- a/azure-email/Azure/Types.hs +++ b/azure-email/Azure/Types.hs @@ -13,12 +13,17 @@ module Azure.Types , EmailRecipients (..) , EmailContent (..) , EmailAttachment (..) + + -- * Smart constructors + , newAzureEmailRequest ) where import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, withText, (.:), (.=)) import Data.Aeson.Types (parseFail) import Data.Text (Text) +import qualified Data.Text as Text + {- | Each email is represented as an object with @displayName@ and an associated @address@. @@ -37,6 +42,14 @@ instance ToJSON EmailAddress where , "displayName" .= eaDisplayName ] +{- | Why text type instead of represting it as @EmailAddress@? + +Well, Azure API dictates that sender address should only be the email +instead of a combination of email and display name (EmailAddress in our case). +Therefore, we fallback to use text as a type alias for this one case. +-} +type SenderEmailAddress = Text + -- | Fields to represent @cc@, @bcc@ and @to@ in an email data EmailRecipients = EmailRecipients { ccRecipients :: ![EmailAddress] @@ -97,7 +110,7 @@ Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email data AzureEmailRequest = AzureEmailRequest { aerContent :: !EmailContent , aerRecipients :: !EmailRecipients - , aerSenderAddress :: !Text -- TODO: This should probably be it's own newtype + , aerSenderAddress :: !SenderEmailAddress , aerReplyTo :: ![EmailAddress] -- TODO: Should this be NonEmpty instead? , aerAttachments :: ![EmailAttachment] , aerUserEngagementTrackingDisabled :: !Bool @@ -115,6 +128,24 @@ instance ToJSON AzureEmailRequest where , "userEngagementTrackingDisabled" .= aerUserEngagementTrackingDisabled ] +{- | Smart constructor to build a send email request. + +There are few default settings that the caller needs to be aware of: +1. @replyTo@ for recipient is the sender's email address. In case there needs to be multiple + email addresses in @replyTo@ field, it is advised to build a custom request based on the + exposed data types instead. +2. Attachements are not included, yet. +3. Enagagement tracking is disabled. +-} +newAzureEmailRequest :: + SenderEmailAddress -> + EmailRecipients -> + EmailContent -> + AzureEmailRequest +newAzureEmailRequest senderAddress recipients content = + let senderEmailAddress = EmailAddress senderAddress Text.empty + in AzureEmailRequest content recipients senderAddress [senderEmailAddress] [] True + {- | Possible states once a send email action is triggered. Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP#emailsendstatus -} From 85bfd17ef106037a02d124208d09133959be4a82 Mon Sep 17 00:00:00 2001 From: Nitin Prakash Date: Tue, 29 Oct 2024 09:12:22 +0530 Subject: [PATCH 9/9] haddocks --- azure-email/Azure/Types.hs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/azure-email/Azure/Types.hs b/azure-email/Azure/Types.hs index 32e20a5..961a6b7 100644 --- a/azure-email/Azure/Types.hs +++ b/azure-email/Azure/Types.hs @@ -130,21 +130,23 @@ instance ToJSON AzureEmailRequest where {- | Smart constructor to build a send email request. +This is very priliminary in nature and facilitates quickly building an email request +with 1 recipient and sender with the content. There are few default settings that the caller needs to be aware of: 1. @replyTo@ for recipient is the sender's email address. In case there needs to be multiple email addresses in @replyTo@ field, it is advised to build a custom request based on the exposed data types instead. -2. Attachements are not included, yet. +2. Attachements are not included. 3. Enagagement tracking is disabled. -} newAzureEmailRequest :: SenderEmailAddress -> - EmailRecipients -> + EmailAddress -> EmailContent -> AzureEmailRequest -newAzureEmailRequest senderAddress recipients content = +newAzureEmailRequest senderAddress recipient content = let senderEmailAddress = EmailAddress senderAddress Text.empty - in AzureEmailRequest content recipients senderAddress [senderEmailAddress] [] True + in AzureEmailRequest content (EmailRecipients [] [] [recipient]) senderAddress [senderEmailAddress] [] True {- | Possible states once a send email action is triggered. Source: https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send?view=rest-communication-dataplane-2023-03-31&tabs=HTTP#emailsendstatus