338 lines
11 KiB
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.
|
|
*/
|