Added a lot of functionality. Bugfixes and changes
This commit is contained in:
313
oAI/Services/EmailService.swift
Normal file
313
oAI/Services/EmailService.swift
Normal file
@@ -0,0 +1,313 @@
|
||||
//
|
||||
// EmailService.swift
|
||||
// oAI
|
||||
//
|
||||
// IMAP IDLE email monitoring service for AI email handler
|
||||
//
|
||||
|
||||
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()
|
||||
|
||||
// Check each email for the subject identifier
|
||||
for uid in allUnseenUIDs {
|
||||
if !checkedUIDs.contains(uid) {
|
||||
checkedUIDs.insert(uid)
|
||||
|
||||
do {
|
||||
let email = try await client.fetchEmail(uid: uid)
|
||||
|
||||
// Check if email has the correct subject identifier
|
||||
if email.subject.contains(settings.emailSubjectIdentifier) {
|
||||
// Valid email - process it
|
||||
log.info("New email found: \(email.subject) from \(email.from)")
|
||||
|
||||
// Call callback on main thread
|
||||
await MainActor.run {
|
||||
onNewEmail?(email)
|
||||
}
|
||||
} else {
|
||||
// Wrong subject - delete it
|
||||
log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)")
|
||||
try await client.deleteEmail(uid: uid)
|
||||
}
|
||||
} catch {
|
||||
log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
*/
|
||||
Reference in New Issue
Block a user