// // 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 . 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 """
\(htmlContent)
📊 Processing Stats
Response Time: \(timeFormatted)s Tokens Used: \(totalTokens.formatted()) (\(promptTokens ?? 0) prompt + \(completionTokens ?? 0) completion) Cost: \(costFormatted)
""" } 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: "

") html = "

\(html)

" // Bold html = html.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "$1", options: .regularExpression) // Italic html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "$1", options: .regularExpression) // Inline code html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "$1", options: .regularExpression) // Line breaks html = html.replacingOccurrences(of: "\n", with: "
") return html } // MARK: - Error Handling private func sendErrorEmail(to: String, subject: String, error: Error) async throws { let errorHTML = """

⚠️ Email Processing Error

We encountered an error while processing your email:

\(error.localizedDescription)

Please try again later or contact support if the problem persists.

""" _ = 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") } } }