summaryrefslogtreecommitdiff
path: root/Omni/Agent
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-14 23:29:19 -0500
committerBen Sima <ben@bensima.com>2025-12-14 23:29:19 -0500
commitadf693eb82cddd2c383cdebd3392716446ddf054 (patch)
treefdbb12da0ae76c5cd29cbbfdb0f9adc6f1a0b661 /Omni/Agent
parent867ff4dca8c0e6ac000290bbbc0a7147c728011d (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
Diffstat (limited to 'Omni/Agent')
-rw-r--r--Omni/Agent/Tools/Email.hs121
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
+ ]
+ )