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

338 lines
11 KiB
Swift

//
// 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 <https://www.gnu.org/licenses/>.
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<Void, Never>?
// 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<UInt32>()
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.
*/