Added a lot of functionality. Bugfixes and changes
This commit is contained in:
294
oAI/Services/IMAPClient.swift
Normal file
294
oAI/Services/IMAPClient.swift
Normal file
@@ -0,0 +1,294 @@
|
||||
//
|
||||
// IMAPClient.swift
|
||||
// oAI
|
||||
//
|
||||
// Swift-native IMAP client for email monitoring
|
||||
//
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user