Files
oai-swift/oAI/Services/EmailHandlerService.swift

514 lines
18 KiB
Swift

//
// EmailHandlerService.swift
// oAI
//
// AI-powered email auto-responder service
//
import Foundation
import os
@Observable
final class EmailHandlerService {
static let shared = EmailHandlerService()
private let settings = SettingsService.shared
private let emailService = EmailService.shared
private let emailLog = EmailLogService.shared
private let mcp = MCPService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "email-handler")
// Rate limiting
private var emailsProcessedThisHour: Int = 0
private var hourResetTimer: Timer?
// Processing state
private var isProcessing = false
private init() {}
// MARK: - Lifecycle
/// Start email handler (call at app startup)
func start() {
guard settings.emailHandlerEnabled else {
log.info("Email handler disabled")
return
}
guard settings.emailHandlerConfigured else {
log.warning("Email handler not configured properly")
return
}
log.info("Starting email handler...")
// Set up email monitoring callback
emailService.onNewEmail = { [weak self] email in
Task {
await self?.handleIncomingEmail(email)
}
}
// Start IMAP IDLE monitoring
emailService.startMonitoring()
// Start rate limit timer
startRateLimitTimer()
log.info("Email handler started successfully")
}
/// Stop email handler
func stop() {
log.info("Stopping email handler...")
emailService.stopMonitoring()
hourResetTimer?.invalidate()
hourResetTimer = nil
log.info("Email handler stopped")
}
// MARK: - Email Processing
private func handleIncomingEmail(_ email: IncomingEmail) async {
log.info("New email received from \(email.from): \(email.subject)")
// Check rate limiting
if self.settings.emailRateLimitEnabled && self.settings.emailRateLimitPerHour < 100 {
if self.emailsProcessedThisHour >= self.settings.emailRateLimitPerHour {
log.warning("Rate limit exceeded (\(self.emailsProcessedThisHour)/\(self.settings.emailRateLimitPerHour)), skipping email")
emailLog.logError(
sender: email.from,
subject: email.subject,
emailContent: String(email.body.prefix(200)),
errorMessage: "Rate limit exceeded (\(self.settings.emailRateLimitPerHour) emails/hour)",
modelId: nil
)
return
}
}
// Prevent concurrent processing
guard !isProcessing else {
log.warning("Already processing an email, queueing not implemented")
return
}
isProcessing = true
defer { isProcessing = false }
// Process with retry
var attemptCount = 0
let maxAttempts = 2
while attemptCount < maxAttempts {
attemptCount += 1
do {
try await processEmailWithAI(email, attempt: attemptCount)
emailsProcessedThisHour += 1
// Delete email after successful processing
try? await deleteEmail(email.uid)
log.info("Successfully processed and deleted email (attempt \(attemptCount))")
return
} catch {
log.error("Failed to process email (attempt \(attemptCount)): \(error.localizedDescription)")
if attemptCount >= maxAttempts {
// Send error email to sender
try? await sendErrorEmail(to: email.from, subject: email.subject, error: error)
// Log error
emailLog.logError(
sender: email.from,
subject: email.subject,
emailContent: String(email.body.prefix(200)),
errorMessage: error.localizedDescription,
modelId: settings.emailHandlerModel
)
}
}
}
}
private func processEmailWithAI(_ email: IncomingEmail, attempt: Int) async throws {
let startTime = Date()
log.info("Processing email with AI (attempt \(attempt))...")
// Get AI provider for email handling
guard let provider = getEmailProvider() else {
throw EmailServiceError.notConfigured
}
// Build AI prompt
let prompt = buildEmailPrompt(from: email)
// Prepare tools (read-only MCP access)
var tools: [Tool]? = nil
if settings.mcpEnabled && !mcp.allowedFolders.isEmpty {
tools = mcp.getToolSchemas().filter { tool in
isReadOnlyTool(tool.function.name)
}
}
// Build messages
let userMessage = Message(
role: .user,
content: prompt
)
// Create chat request
// IMPORTANT: Email handler uses ONLY its own system prompt (custom or default)
// All other prompts (main chat system prompt, user custom prompts) are excluded
// This ensures complete isolation of email handling behavior
let request = ChatRequest(
messages: [userMessage],
model: settings.emailHandlerModel,
stream: false,
maxTokens: settings.emailMaxTokens,
temperature: 0.7,
systemPrompt: getEmailSystemPrompt(), // Email-specific prompt ONLY
tools: tools,
onlineMode: settings.emailOnlineMode, // User-configurable online mode for emails
imageGeneration: false // Disable image generation for emails
)
// Call AI (non-streaming)
let response = try await provider.chat(request: request)
let fullResponse = response.content
let promptTokens = response.usage?.promptTokens
let completionTokens = response.usage?.completionTokens
let totalTokens = response.usage.map { $0.promptTokens + $0.completionTokens }
// Calculate cost if we have pricing info
var totalCost: Double? = nil
if let usage = response.usage,
let models = try? await provider.listModels(),
let modelInfo = models.first(where: { $0.id == settings.emailHandlerModel }) {
totalCost = (Double(usage.promptTokens) * modelInfo.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * modelInfo.pricing.completion / 1_000_000)
}
let responseTime = Date().timeIntervalSince(startTime)
log.info("AI response generated in \(String(format: "%.2f", responseTime))s")
// Generate HTML email with stats
let htmlBody = generateHTMLEmail(
aiResponse: fullResponse,
originalEmail: email,
responseTime: responseTime,
promptTokens: promptTokens,
completionTokens: completionTokens,
cost: totalCost
)
// Send response email
let replySubject = email.subject.hasPrefix("Re:") ? email.subject : "Re: \(email.subject)"
let messageId = try await emailService.sendEmail(
to: email.from,
subject: replySubject,
body: fullResponse, // Plain text fallback
htmlBody: htmlBody,
inReplyTo: email.messageId
)
log.info("Response email sent: \(messageId)")
// Log success
emailLog.logSuccess(
sender: email.from,
subject: email.subject,
emailContent: String(email.body.prefix(500)),
aiResponse: String(fullResponse.prefix(500)),
tokens: totalTokens,
cost: totalCost,
responseTime: responseTime,
modelId: settings.emailHandlerModel
)
}
// MARK: - Prompt Building
private func buildEmailPrompt(from email: IncomingEmail) -> String {
// Remove subject identifier from subject
var cleanSubject = email.subject
if let range = cleanSubject.range(of: settings.emailSubjectIdentifier) {
cleanSubject.removeSubrange(range)
cleanSubject = cleanSubject.trimmingCharacters(in: .whitespacesAndNewlines)
}
let prompt = """
You have received an email that requires a response. Please provide a helpful, professional reply.
From: \(email.from)
Subject: \(cleanSubject)
Date: \(email.receivedDate.formatted())
Email Content:
\(email.body)
---
Please provide a complete, well-formatted response to this email. Your response will be sent as an HTML email.
"""
return prompt
}
/// Get the email handler system prompt
/// - Returns: Email-specific system prompt (custom if set, default otherwise)
/// - Note: This is completely isolated from the main chat system prompt.
/// No other prompts are merged or concatenated.
private func getEmailSystemPrompt() -> String {
// ISOLATION: Use custom email prompt if provided, otherwise use default email prompt
// This completely overrides and replaces any other system prompts
// Main chat prompts and user custom prompts are NOT used for email handling
if let customPrompt = settings.emailHandlerSystemPrompt, !customPrompt.isEmpty {
return customPrompt
}
// Default email system prompt (used only when no custom email prompt is set)
return """
You are an AI email assistant. You respond to emails on behalf of the user.
Guidelines:
- Be professional and courteous
- Keep responses concise and relevant
- Use proper email etiquette
- Format your response using Markdown (it will be converted to HTML)
- If you need information from files, you have read-only access via MCP tools
- Never claim to write, modify, or delete files (read-only access)
- Sign emails appropriately
Your response will be automatically formatted and sent via email.
"""
}
// MARK: - HTML Email Generation
private func generateHTMLEmail(
aiResponse: String,
originalEmail: IncomingEmail,
responseTime: TimeInterval,
promptTokens: Int?,
completionTokens: Int?,
cost: Double?
) -> String {
// Convert markdown to HTML (basic implementation)
let htmlContent = markdownToHTML(aiResponse)
// Format stats
let timeFormatted = String(format: "%.2f", responseTime)
let totalTokens = (promptTokens ?? 0) + (completionTokens ?? 0)
let costFormatted = cost.map { String(format: "$%.4f", $0) } ?? "N/A"
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.content {
background: #ffffff;
padding: 20px;
border-radius: 8px;
}
.stats {
margin-top: 30px;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
font-size: 11px;
color: #666;
}
.stats-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 12px;
margin-top: 8px;
}
.stats-label {
font-weight: 600;
color: #555;
}
.stats-value {
color: #666;
}
.footer {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
font-size: 12px;
color: #666;
text-align: center;
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
pre {
background: #f5f5f5;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="content">
\(htmlContent)
</div>
<div class="stats">
<div style="font-weight: 600; margin-bottom: 6px; color: #555;">📊 Processing Stats</div>
<div class="stats-grid">
<span class="stats-label">Response Time:</span>
<span class="stats-value">\(timeFormatted)s</span>
<span class="stats-label">Tokens Used:</span>
<span class="stats-value">\(totalTokens.formatted()) (\(promptTokens ?? 0) prompt + \(completionTokens ?? 0) completion)</span>
<span class="stats-label">Cost:</span>
<span class="stats-value">\(costFormatted)</span>
</div>
</div>
<div class="footer">
<p>🤖 This response was generated by AI using oAI Email Handler</p>
</div>
</body>
</html>
"""
}
private func markdownToHTML(_ markdown: String) -> String {
// Basic markdown to HTML conversion
// TODO: Use proper markdown parser for production
var html = markdown
// Paragraphs
html = html.replacingOccurrences(of: "\n\n", with: "</p><p>")
html = "<p>\(html)</p>"
// Bold
html = html.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
// Italic
html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "<em>$1</em>", options: .regularExpression)
// Inline code
html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "<code>$1</code>", options: .regularExpression)
// Line breaks
html = html.replacingOccurrences(of: "\n", with: "<br>")
return html
}
// MARK: - Error Handling
private func sendErrorEmail(to: String, subject: String, error: Error) async throws {
let errorHTML = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.error { background: #fee; padding: 20px; border-left: 4px solid #f00; }
</style>
</head>
<body>
<div class="error">
<h2>⚠️ Email Processing Error</h2>
<p>We encountered an error while processing your email:</p>
<p><strong>\(error.localizedDescription)</strong></p>
<p>Please try again later or contact support if the problem persists.</p>
</div>
</body>
</html>
"""
_ = try await emailService.sendEmail(
to: to,
subject: "Re: \(subject) - Processing Error",
body: "An error occurred: \(error.localizedDescription)",
htmlBody: errorHTML
)
}
// MARK: - Helpers
private func deleteEmail(_ uid: UInt32) async throws {
guard let host = settings.emailImapHost,
let username = settings.emailUsername,
let password = settings.emailPassword else {
return
}
let client = IMAPClient(
host: host,
port: UInt16(settings.emailImapPort),
username: username,
password: password,
useTLS: true
)
try await client.connect()
try await client.login()
try await client.selectMailbox("INBOX")
try await client.deleteEmail(uid: uid)
try await client.expunge()
client.disconnect()
log.info("Deleted email UID \(uid)")
}
private func getEmailProvider() -> AIProvider? {
let providerNameString = settings.emailHandlerProvider
let registry = ProviderRegistry.shared
// Convert string to Settings.Provider enum
guard let providerType = Settings.Provider(rawValue: providerNameString) else {
log.error("Invalid provider name: '\(providerNameString)'")
return nil
}
// Check if provider is configured
let configuredProviders = registry.configuredProviders
guard configuredProviders.contains(providerType) else {
log.error("Email handler provider '\(providerNameString)' not configured (no API key)")
return nil
}
// Get provider instance
return registry.getProvider(for: providerType)
}
private func isReadOnlyTool(_ toolName: String) -> Bool {
// Only allow read-only MCP tools for email handling
let readOnlyTools = ["read_file", "list_directory", "search_files", "get_file_info"]
return readOnlyTools.contains(toolName)
}
private func startRateLimitTimer() {
// Reset counter every hour
hourResetTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
self?.emailsProcessedThisHour = 0
self?.log.info("Rate limit counter reset")
}
}
}