Added a lot of functionality. Bugfixes and changes
This commit is contained in:
451
oAI/Services/EmailHandlerService.swift
Normal file
451
oAI/Services/EmailHandlerService.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
//
|
||||
// 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 totalTokens = response.usage.map { $0.promptTokens + $0.completionTokens }
|
||||
let totalCost: Double? = nil // Calculate if provider supports it
|
||||
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
log.info("AI response generated in \(String(format: "%.2f", responseTime))s")
|
||||
|
||||
// Generate HTML email
|
||||
let htmlBody = generateHTMLEmail(aiResponse: fullResponse, originalEmail: email)
|
||||
|
||||
// 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) -> String {
|
||||
// Convert markdown to HTML (basic implementation)
|
||||
let htmlContent = markdownToHTML(aiResponse)
|
||||
|
||||
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;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
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="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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user