diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-14 23:29:19 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-14 23:29:19 -0500 |
| commit | adf693eb82cddd2c383cdebd3392716446ddf054 (patch) | |
| tree | fdbb12da0ae76c5cd29cbbfdb0f9adc6f1a0b661 | |
| parent | 867ff4dca8c0e6ac000290bbbc0a7147c728011d (diff) | |
t-265.5: Add SMTP email sending for Ava outreach
- Add emailSendTool to Email.hs for sending approved drafts
- Add sendApprovedEmail function that checks draft status
- Use Network.Mail.Mime.simpleMail' with SMTP.sendMail
- Integrate with Outreach module to verify approval and mark sent
- Add tests for new tool
| -rw-r--r-- | Omni/Agent/Tools/Email.hs | 121 |
1 files changed, 116 insertions, 5 deletions
diff --git a/Omni/Agent/Tools/Email.hs b/Omni/Agent/Tools/Email.hs index 9c63340..7a9bc64 100644 --- a/Omni/Agent/Tools/Email.hs +++ b/Omni/Agent/Tools/Email.hs @@ -3,14 +3,15 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE NoImplicitPrelude #-} --- | Email tools for IMAP access via Telegram bot. +-- | Email tools for IMAP and SMTP access via Telegram bot. -- -- Provides email management for agents: -- - Check for urgent/time-sensitive emails -- - Identify emails needing response vs FYI -- - Auto-unsubscribe from marketing +-- - Send approved outreach emails via SMTP -- --- Uses HaskellNet for proper IMAP client support. +-- Uses HaskellNet for IMAP/SMTP client support. -- Password retrieved via `pass ben@bensima.com`. -- -- : out omni-agent-tools-email @@ -26,6 +27,7 @@ module Omni.Agent.Tools.Email emailReadTool, emailUnsubscribeTool, emailArchiveTool, + emailSendTool, -- * All tools allEmailTools, @@ -36,6 +38,7 @@ module Omni.Agent.Tools.Email unsubscribeFromEmail, archiveEmail, getPassword, + sendApprovedEmail, -- * Scheduled Check emailCheckLoop, @@ -54,6 +57,7 @@ import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.ByteString.Char8 as BS8 import qualified Data.List as List import qualified Data.Text as Text +import qualified Data.Text.Lazy as LText import Data.Time (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime) import Data.Time.Format (defaultTimeLocale, formatTime, parseTimeM) import Data.Time.LocalTime (TimeZone (..), utcToZonedTime) @@ -61,7 +65,11 @@ import qualified Network.HTTP.Simple as HTTP import qualified Network.HaskellNet.IMAP as IMAP import Network.HaskellNet.IMAP.Connection (IMAPConnection) import qualified Network.HaskellNet.IMAP.SSL as IMAPSSL +import qualified Network.HaskellNet.SMTP as SMTP +import qualified Network.HaskellNet.SMTP.SSL as SMTPSSL +import Network.Mail.Mime (Address (..), simpleMail') import qualified Omni.Agent.Engine as Engine +import qualified Omni.Agent.Tools.Outreach as Outreach import qualified Omni.Test as Test import System.Process (readProcessWithExitCode) import Text.Regex.Applicative (RE, anySym, few, (=~)) @@ -82,8 +90,10 @@ test = Engine.toolName emailUnsubscribeTool Test.@=? "email_unsubscribe", Test.unit "emailArchiveTool has correct name" <| do Engine.toolName emailArchiveTool Test.@=? "email_archive", - Test.unit "allEmailTools has 4 tools" <| do - length allEmailTools Test.@=? 4, + Test.unit "emailSendTool has correct name" <| do + Engine.toolName emailSendTool Test.@=? "email_send", + Test.unit "allEmailTools has 5 tools" <| do + length allEmailTools Test.@=? 5, Test.unit "parseEmailHeaders extracts fields" <| do let headers = "From: test@example.com\r\n\ @@ -314,7 +324,8 @@ allEmailTools = [ emailCheckTool, emailReadTool, emailUnsubscribeTool, - emailArchiveTool + emailArchiveTool, + emailSendTool ] emailCheckTool :: Engine.Tool @@ -562,3 +573,103 @@ performScheduledCheck sendFn chatId = do formatOne :: EmailSummary -> Text formatOne e = "• " <> emailSubject e <> " (from: " <> emailFrom e <> ", uid: " <> tshow (emailUid e) <> ")" + +smtpServer :: String +smtpServer = "bensima.com" + +smtpUser :: String +smtpUser = "ben@bensima.com" + +withSmtpConnection :: (SMTP.SMTPConnection -> IO a) -> IO (Either Text a) +withSmtpConnection action = do + pwResult <- getPassword + case pwResult of + Left err -> pure (Left err) + Right pw -> do + result <- + try <| do + conn <- SMTPSSL.connectSMTPSSL smtpServer + authSuccess <- SMTP.authenticate SMTP.LOGIN smtpUser (Text.unpack pw) conn + if authSuccess + then do + r <- action conn + SMTP.closeSMTP conn + pure r + else do + SMTP.closeSMTP conn + panic "SMTP authentication failed" + case result of + Left (e :: SomeException) -> pure (Left ("SMTP error: " <> tshow e)) + Right r -> pure (Right r) + +sendApprovedEmail :: Text -> IO (Either Text Text) +sendApprovedEmail draftId = do + mDraft <- Outreach.getDraft draftId + case mDraft of + Nothing -> pure (Left "Draft not found") + Just draft -> do + case Outreach.draftStatus draft of + Outreach.Approved -> do + let recipientAddr = Address Nothing (Outreach.draftRecipient draft) + senderAddr = Address (Just "Ben Sima") "ben@bensima.com" + subject = fromMaybe "" (Outreach.draftSubject draft) + body = LText.fromStrict (Outreach.draftBody draft) + footer = "\n\n---\nSent by Ava on behalf of Ben" + fullBody = body <> footer + mail = simpleMail' recipientAddr senderAddr subject fullBody + sendResult <- + withSmtpConnection <| \conn -> do + SMTP.sendMail mail conn + case sendResult of + Left err -> pure (Left err) + Right () -> do + _ <- Outreach.markSent draftId + pure (Right ("Email sent to " <> Outreach.draftRecipient draft)) + Outreach.Pending -> pure (Left "Draft is still pending approval") + Outreach.Rejected -> pure (Left "Draft was rejected") + Outreach.Sent -> pure (Left "Draft was already sent") + +emailSendTool :: Engine.Tool +emailSendTool = + Engine.Tool + { Engine.toolName = "email_send", + Engine.toolDescription = + "Send an approved outreach email. Only sends emails that have been approved " + <> "by Ben in the outreach queue. Use outreach_draft to create drafts first, " + <> "wait for approval, then use this to send.", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "draft_id" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("ID of the approved draft to send" :: Text) + ] + ], + "required" .= (["draft_id"] :: [Text]) + ], + Engine.toolExecute = executeEmailSend + } + +executeEmailSend :: Aeson.Value -> IO Aeson.Value +executeEmailSend v = do + let draftIdM = case v of + Aeson.Object obj -> case KeyMap.lookup "draft_id" obj of + Just (Aeson.String s) -> Just s + _ -> Nothing + _ -> Nothing + case draftIdM of + Nothing -> pure (Aeson.object ["error" .= ("Missing draft_id parameter" :: Text)]) + Just draftId -> do + result <- sendApprovedEmail draftId + case result of + Left err -> pure (Aeson.object ["error" .= err]) + Right msg -> + pure + ( Aeson.object + [ "success" .= True, + "message" .= msg + ] + ) |
