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

313 lines
10 KiB
Swift

//
// 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 <https://www.gnu.org/licenses/>.
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<Void, Error>) 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 <email@domain.com>"
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
)
}
}