313 lines
10 KiB
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
|
|
)
|
|
}
|
|
}
|