// // IMAPClient.swift // oAI // // Swift-native IMAP client for email monitoring // // 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 Network import os class IMAPClient { private let log = Logger(subsystem: "com.oai.oAI", category: "imap") private var connection: NWConnection? private var host: String private var port: UInt16 private var username: String private var password: String private var useTLS: Bool private var commandTag = 1 private var receiveBuffer = Data() init(host: String, port: UInt16 = 993, username: String, password: String, useTLS: Bool = true) { self.host = host self.port = port self.username = username self.password = password self.useTLS = useTLS } // MARK: - Connection func connect() async throws { let tlsOptions = useTLS ? NWProtocolTLS.Options() : nil let tcpOptions = NWProtocolTCP.Options() let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params) let hostName = self.host return try await withCheckedThrowingContinuation { continuation in final class ResumeOnce { var resumed = false let lock = NSLock() } let resumeOnce = ResumeOnce() connection?.stateUpdateHandler = { [weak self] state in resumeOnce.lock.lock() defer { resumeOnce.lock.unlock() } guard !resumeOnce.resumed else { return } switch state { case .ready: resumeOnce.resumed = true self?.log.info("IMAP connected to \(hostName)") continuation.resume() case .failed(let error): resumeOnce.resumed = true self?.log.error("IMAP connection failed: \(error.localizedDescription)") continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription)) default: break } } connection?.start(queue: .global()) } } func disconnect() { connection?.cancel() connection = nil log.info("IMAP disconnected") } // MARK: - Authentication func login() async throws { // Wait for server greeting _ = try await readResponse() // Send LOGIN command let loginCmd = "LOGIN \"\(username)\" \"\(password)\"" let response = try await sendCommand(loginCmd) if !response.contains("OK") { throw EmailServiceError.authenticationFailed } log.info("IMAP login successful") } // MARK: - Mailbox Operations func selectMailbox(_ mailbox: String = "INBOX") async throws { let response = try await sendCommand("SELECT \(mailbox)") if !response.contains("OK") { throw EmailServiceError.connectionFailed("Failed to select mailbox") } } func searchUnseenWithSubject(_ subject: String) async throws -> [UInt32] { let response = try await sendCommand("SEARCH UNSEEN SUBJECT \"\(subject)\"") // Parse UIDs from response like "* SEARCH 123 124 125" var uids: [UInt32] = [] for line in response.split(separator: "\r\n") { if line.hasPrefix("* SEARCH") { let parts = line.split(separator: " ") for part in parts.dropFirst(2) { if let uid = UInt32(part) { uids.append(uid) } } } } return uids } func searchUnseen() async throws -> [UInt32] { let response = try await sendCommand("SEARCH UNSEEN") // Parse UIDs from response like "* SEARCH 123 124 125" var uids: [UInt32] = [] for line in response.split(separator: "\r\n") { if line.hasPrefix("* SEARCH") { let parts = line.split(separator: " ") for part in parts.dropFirst(2) { if let uid = UInt32(part) { uids.append(uid) } } } } return uids } func fetchEmail(uid: UInt32) async throws -> IncomingEmail { let response = try await sendCommand("FETCH \(uid) (BODY.PEEK[] FLAGS)") // Parse email from response return try parseEmailResponse(response, uid: uid) } func markAsRead(uid: UInt32) async throws { _ = try await sendCommand("STORE \(uid) +FLAGS (\\Seen)") } func deleteEmail(uid: UInt32) async throws { _ = try await sendCommand("STORE \(uid) +FLAGS (\\Deleted)") } func expunge() async throws { _ = try await sendCommand("EXPUNGE") } // MARK: - Low-level Protocol private func sendCommand(_ command: String) async throws -> String { guard let connection = connection else { throw EmailServiceError.connectionFailed("Not connected") } let tag = "A\(commandTag)" commandTag += 1 let fullCommand = "\(tag) \(command)\r\n" let data = fullCommand.data(using: .utf8)! // Send command try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in connection.send(content: data, completion: .contentProcessed { error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume() } }) } // Read response return try await readResponse(until: tag) } private func readResponse(until tag: String? = nil) async throws -> String { guard let connection = connection else { throw EmailServiceError.connectionFailed("Not connected") } var fullResponse = "" while true { let data: Data = try await withCheckedThrowingContinuation { continuation in connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in if let error = error { continuation.resume(throwing: error) } else if let data = data { continuation.resume(returning: data) } else { continuation.resume(throwing: EmailServiceError.connectionFailed("No data received")) } } } receiveBuffer.append(data) if let response = String(data: receiveBuffer, encoding: .utf8) { fullResponse = response // Check if we have a complete response if let tag = tag { if response.contains("\(tag) OK") || response.contains("\(tag) NO") || response.contains("\(tag) BAD") { receiveBuffer.removeAll() break } } else { // Just return what we got receiveBuffer.removeAll() break } } } return fullResponse } // MARK: - Email Parsing private func parseEmailResponse(_ response: String, uid: UInt32) throws -> IncomingEmail { // Basic email parsing - extract headers and body let lines = response.split(separator: "\r\n", omittingEmptySubsequences: false) var from = "" var subject = "" var messageId = "" var inReplyTo: String? var body = "" var inBody = false var receivedDate = Date() for line in lines { let lineStr = String(line) if lineStr.hasPrefix("From: ") { from = String(lineStr.dropFirst(6)) // Extract email from "Name " if let start = from.firstIndex(of: "<"), let end = from.firstIndex(of: ">") { from = String(from[start...end].dropFirst().dropLast()) } } else if lineStr.hasPrefix("Subject: ") { subject = String(lineStr.dropFirst(9)) } else if lineStr.hasPrefix("Message-ID: ") || lineStr.hasPrefix("Message-Id: ") { messageId = String(lineStr.split(separator: ": ", maxSplits: 1).last ?? "") } else if lineStr.hasPrefix("In-Reply-To: ") { inReplyTo = String(lineStr.split(separator: ": ", maxSplits: 1).last ?? "") } else if lineStr.hasPrefix("Date: ") { let dateStr = String(lineStr.dropFirst(6)) // Parse RFC 2822 date format let formatter = DateFormatter() formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" formatter.locale = Locale(identifier: "en_US_POSIX") if let date = formatter.date(from: dateStr) { receivedDate = date } } else if lineStr.isEmpty && !inBody { // Empty line marks end of headers inBody = true } else if inBody { body += lineStr + "\n" } } return IncomingEmail( id: UUID(), uid: uid, messageId: messageId.isEmpty ? "msg-\(uid)" : messageId, from: from, to: [username], subject: subject, body: body.trimmingCharacters(in: .whitespacesAndNewlines), receivedDate: receivedDate, inReplyTo: inReplyTo ) } }