// // EmailService.swift // oAI // // IMAP IDLE email monitoring service for AI email handler // // 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 // MARK: - Email Models struct IncomingEmail: Identifiable { let id: UUID let uid: UInt32 let messageId: String let from: String let to: [String] let subject: String let body: String let receivedDate: Date let inReplyTo: String? // For threading } enum EmailServiceError: LocalizedError { case notConfigured case connectionFailed(String) case authenticationFailed case invalidCredentials case idleNotSupported case sendingFailed(String) var errorDescription: String? { switch self { case .notConfigured: return "Email not configured. Check Settings > Email." case .connectionFailed(let msg): return "Connection failed: \(msg)" case .authenticationFailed: return "Authentication failed. Check credentials." case .invalidCredentials: return "Invalid email credentials" case .idleNotSupported: return "IMAP IDLE not supported by server" case .sendingFailed(let msg): return "Failed to send email: \(msg)" } } } // MARK: - EmailService @Observable final class EmailService { static let shared = EmailService() private let settings = SettingsService.shared private let log = Logger(subsystem: "com.oai.oAI", category: "email") // IMAP IDLE state private var isConnected = false private var isIdling = false private var monitoringTask: Task? // Callback for new emails var onNewEmail: ((IncomingEmail) -> Void)? private init() {} // MARK: - Connection Management /// Start IMAP IDLE monitoring (called at app startup) func startMonitoring() { guard settings.emailHandlerEnabled else { log.info("Email handler disabled, skipping monitoring") return } guard settings.emailHandlerConfigured else { log.warning("Email handler not configured") return } // Email credentials should be configured in Settings > Email tab // (This check can be expanded when email settings are added) log.info("Starting IMAP IDLE monitoring...") monitoringTask = Task { [weak self] in await self?.monitorInbox() } } /// Stop IMAP IDLE monitoring func stopMonitoring() { log.info("Stopping IMAP IDLE monitoring...") monitoringTask?.cancel() monitoringTask = nil isIdling = false isConnected = false } // MARK: - IMAP Polling Implementation private func monitorInbox() async { guard let host = settings.emailImapHost, let username = settings.emailUsername, let password = settings.emailPassword else { log.error("Email credentials not configured") return } var checkedUIDs = Set() var retryDelay: UInt64 = 30 // Start with 30 seconds while !Task.isCancelled { do { 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") // Search for ALL unseen emails first let allUnseenUIDs = try await client.searchUnseen() // Remove UIDs that are no longer unseen (emails were deleted/marked read) checkedUIDs = checkedUIDs.intersection(Set(allUnseenUIDs)) // Check each email for the subject identifier for uid in allUnseenUIDs { // Skip if we've already checked this UID (for non-matching emails only) if checkedUIDs.contains(uid) { continue } do { let email = try await client.fetchEmail(uid: uid) // Check if email has the correct subject identifier if email.subject.contains(self.settings.emailSubjectIdentifier) { // Valid email - process it (don't add to checkedUIDs, handler will delete it) log.info("Found matching email: \(email.subject) from \(email.from)") // Call callback on main thread await MainActor.run { onNewEmail?(email) } } else { // Wrong subject - delete it and remember we checked it log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)") try await client.deleteEmail(uid: uid) checkedUIDs.insert(uid) // Only track non-matching emails } } catch { log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)") checkedUIDs.insert(uid) // Track failed emails to avoid retry loops } } // Expunge deleted messages try await client.expunge() client.disconnect() // Reset retry delay on success retryDelay = 30 // Wait before next check try await Task.sleep(for: .seconds(retryDelay)) } catch { log.error("IMAP monitoring error: \(error.localizedDescription)") // Exponential backoff (max 5 minutes) retryDelay = min(retryDelay * 2, 300) try? await Task.sleep(for: .seconds(retryDelay)) } } log.info("IMAP monitoring stopped") } /// Connect to IMAP and SMTP servers and verify credentials func testConnection() async throws -> String { guard let imapHost = settings.emailImapHost, let smtpHost = settings.emailSmtpHost, let username = settings.emailUsername, let password = settings.emailPassword else { throw EmailServiceError.notConfigured } // Test IMAP connection let imapClient = IMAPClient( host: imapHost, port: UInt16(settings.emailImapPort), username: username, password: password, useTLS: true ) try await imapClient.connect() try await imapClient.login() try await imapClient.selectMailbox("INBOX") imapClient.disconnect() // Test SMTP connection let smtpClient = SMTPClient( host: smtpHost, port: UInt16(settings.emailSmtpPort), username: username, password: password ) try await smtpClient.connect() smtpClient.disconnect() return "Successfully connected to IMAP (\(imapHost)) and SMTP (\(smtpHost))" } // MARK: - Email Sending (SMTP) /// Send email response via SMTP func sendEmail( to: String, subject: String, body: String, htmlBody: String? = nil, inReplyTo: String? = nil ) async throws -> String { guard let host = settings.emailSmtpHost, let username = settings.emailUsername, let password = settings.emailPassword else { throw EmailServiceError.notConfigured } let client = SMTPClient( host: host, port: UInt16(settings.emailSmtpPort), username: username, password: password ) let messageId = try await client.sendEmail( from: username, to: [to], subject: subject, body: body, htmlBody: htmlBody, inReplyTo: inReplyTo ) log.info("Email sent successfully: \(messageId)") return messageId } // MARK: - Email Fetching (for manual retrieval) /// Fetch recent emails from INBOX (for testing/debugging) func fetchRecentEmails(limit: Int = 10) async throws -> [IncomingEmail] { // TODO: Implement IMAP FETCH // 1. Connect to IMAP server // 2. SELECT INBOX // 3. SEARCH or FETCH recent UIDs // 4. FETCH headers and body for each UID // 5. Parse into IncomingEmail structs log.warning("IMAP FETCH implementation pending") return [] } // MARK: - Helper Methods /// Check if email subject contains the identifier private func matchesSubjectIdentifier(_ subject: String) -> Bool { let identifier = settings.emailSubjectIdentifier return subject.contains(identifier) } /// Extract plain text body from email private func extractPlainText(from body: String) -> String { // TODO: Handle multipart MIME, HTML stripping, etc. return body } } // MARK: - Implementation Notes /* IMAP IDLE Implementation Options: 1. MailCore2 (Recommended for production) - Add via SPM: https://github.com/MailCore/mailcore2 - Create Objective-C bridging header - Use MCOIMAPSession with MCOIMAPIdleOperation - Pros: Battle-tested, full IMAP/SMTP support - Cons: Objective-C bridging required 2. Swift NIO + Custom IMAP - Implement IMAP protocol manually - Pros: Pure Swift, no bridging - Cons: Complex, error-prone, time-consuming 3. Third-party Swift Libraries - Research Swift-native IMAP libraries on GitHub/SPM - Pros: Easier than custom implementation - Cons: May not be as mature as MailCore2 For now, this service provides the interface and structure. The actual IMAP IDLE implementation should be added based on chosen library/approach. */