Files
oai-swift/oAI/Services/IMAPClient.swift
T
runeandClaude Sonnet 5 0af644456e 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:34:04 +02:00

311 lines
10 KiB
Swift

//
// IMAPClient.swift
// oAI
//
// Swift-native IMAP client for email monitoring
//
// 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 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: @unchecked Sendable {
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
)
}
}