532 lines
18 KiB
Swift
532 lines
18 KiB
Swift
//
|
|
// EmailHandlerService.swift
|
|
// oAI
|
|
//
|
|
// AI-powered email auto-responder service
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
|
// Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public
|
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|