Files
oai-swift/oAI/Services/EmailService.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

336 lines
11 KiB
Swift

//
// EmailService.swift
// oAI
//
// IMAP IDLE email monitoring service for AI email handler
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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.
*/