Files
MagnumOpus/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift

635 lines
19 KiB
Swift

import Foundation
import Models
import NIO
import NIOIMAPCore
public actor IMAPClient: IMAPClientProtocol {
private let host: String
private let port: Int
private let credentials: Credentials
private var connection: IMAPConnection?
private var runner: IMAPCommandRunner?
private var cachedCapabilities: Set<String>?
public init(host: String, port: Int, credentials: Credentials) {
self.host = host
self.port = port
self.credentials = credentials
}
// MARK: - Connection lifecycle
public func connect() async throws {
let conn = IMAPConnection(host: host, port: port)
try await conn.connect()
connection = conn
var newRunner = IMAPCommandRunner(connection: conn)
let responses = try await newRunner.run(.login(username: credentials.username, password: credentials.password))
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.authenticationFailed
}
runner = newRunner
}
public func disconnect() async throws {
if var r = runner {
_ = try? await r.run(.logout)
runner = r
}
try await connection?.disconnect()
try await connection?.shutdown()
connection = nil
runner = nil
}
// MARK: - Mailbox operations
public func listMailboxes() async throws -> [IMAPMailboxInfo] {
guard var runner else { throw IMAPError.notConnected }
let responses = try await runner.run(
.list(nil, reference: MailboxName(ByteBuffer(string: "")), .mailbox(ByteBuffer(string: "*")))
)
self.runner = runner
return parseListResponses(responses)
}
public func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
guard var runner else { throw IMAPError.notConnected }
let responses = try await runner.run(.select(MailboxName(ByteBuffer(string: name))))
self.runner = runner
return parseSelectResponses(responses, name: name)
}
// MARK: - Fetch operations
public func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] {
guard var runner else { throw IMAPError.notConnected }
let lower = UID(rawValue: UInt32(uid + 1))
let range = MessageIdentifierRange<UID>(lower...UID.max)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
let responses = try await runner.run(.uidFetch(
.set(set),
[
.envelope,
.flags,
.uid,
.rfc822Size,
.bodySection(peek: true, SectionSpecifier(kind: .text), nil),
],
[]
))
self.runner = runner
return parseFetchResponses(responses)
}
public func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] {
guard var runner else { throw IMAPError.notConnected }
let lower = UID(rawValue: UInt32(uids.lowerBound))
let upper = UID(rawValue: UInt32(uids.upperBound))
let range = MessageIdentifierRange<UID>(lower...upper)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
let responses = try await runner.run(.uidFetch(.set(set), [.uid, .flags], []))
self.runner = runner
return parseFlagResponses(responses)
}
public func fetchFullMessage(uid: Int) async throws -> String {
guard var runner else { throw IMAPError.notConnected }
let uidValue = UID(rawValue: UInt32(uid))
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
let responses = try await runner.run(.uidFetch(
.set(set),
[.bodySection(peek: true, SectionSpecifier(kind: .complete), nil)],
[]
))
self.runner = runner
return parseFullMessageResponse(responses)
}
public func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data {
guard var runner else { throw IMAPError.notConnected }
// Select the mailbox first
let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: mailbox))))
self.runner = runner
guard selectResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("SELECT \(mailbox) failed")
}
let uidValue = UID(rawValue: UInt32(uid))
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
// Parse section path into SectionSpecifier.Part
let sectionParts = section.split(separator: ".").compactMap { Int($0) }
let part = SectionSpecifier.Part(sectionParts)
let spec = SectionSpecifier(part: part)
let responses = try await runner.run(.uidFetch(
.set(set),
[.bodySection(peek: true, spec, nil)],
[]
))
self.runner = runner
var bodyBuffer = ByteBuffer()
for response in responses {
if case .fetch(let fetchResponse) = response {
if case .streamingBytes(let bytes) = fetchResponse {
var mutableBytes = bytes
bodyBuffer.writeBuffer(&mutableBytes)
}
}
}
// The section content may be base64 encoded decode it
let raw = String(buffer: bodyBuffer)
let cleaned = raw.filter { !$0.isWhitespace }
if let decoded = Data(base64Encoded: cleaned) {
return decoded
}
return Data(bodyBuffer.readableBytesView)
}
public func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
guard var runner else { throw IMAPError.notConnected }
let uidValue = UID(rawValue: UInt32(uid))
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
let responses = try await runner.run(.uidFetch(
.set(set),
[.bodySection(peek: true, SectionSpecifier(kind: .text), nil)],
[]
))
self.runner = runner
return parseBodyResponse(responses)
}
// MARK: - Write operations
public func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
guard var runner else { throw IMAPError.notConnected }
let uidValue = UID(rawValue: UInt32(uid))
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
if !add.isEmpty {
let flags = add.map { Flag($0) }
let storeData = StoreData.flags(.add(silent: true, list: flags))
let responses = try await runner.run(.uidStore(.set(set), [], storeData))
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID STORE +FLAGS failed")
}
}
if !remove.isEmpty {
let flags = remove.map { Flag($0) }
let storeData = StoreData.flags(.remove(silent: true, list: flags))
let responses = try await runner.run(.uidStore(.set(set), [], storeData))
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID STORE -FLAGS failed")
}
}
self.runner = runner
}
public func moveMessage(uid: Int, from: String, to: String) async throws {
// Check capabilities first (updates self.runner internally)
let caps = try await capabilities()
guard var runner else { throw IMAPError.notConnected }
let uidValue = UID(rawValue: UInt32(uid))
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
let toMailbox = MailboxName(ByteBuffer(string: to))
// Select the source mailbox
let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: from))))
self.runner = runner
guard selectResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("SELECT \(from) failed")
}
if caps.contains("MOVE") {
let responses = try await runner.run(.uidMove(.set(set), toMailbox))
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID MOVE failed")
}
} else {
// Fallback: COPY + STORE \Deleted + EXPUNGE
let copyResponses = try await runner.run(.uidCopy(.set(set), toMailbox))
self.runner = runner
guard copyResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID COPY failed")
}
let storeData = StoreData.flags(.add(silent: true, list: [.deleted]))
let storeResponses = try await runner.run(.uidStore(.set(set), [], storeData))
self.runner = runner
guard storeResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID STORE +FLAGS \\Deleted failed")
}
let expungeResponses = try await runner.run(.expunge)
self.runner = runner
guard expungeResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("EXPUNGE failed")
}
}
self.runner = runner
}
public func copyMessage(uid: Int, from: String, to: String) async throws {
guard var runner else { throw IMAPError.notConnected }
let uidValue = UID(rawValue: UInt32(uid))
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
let toMailbox = MailboxName(ByteBuffer(string: to))
// Select the source mailbox first
let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: from))))
self.runner = runner
guard selectResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("SELECT \(from) failed")
}
let responses = try await runner.run(.uidCopy(.set(set), toMailbox))
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID COPY failed")
}
}
public func expunge(mailbox: String) async throws {
guard var runner else { throw IMAPError.notConnected }
let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: mailbox))))
self.runner = runner
guard selectResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("SELECT \(mailbox) failed")
}
let responses = try await runner.run(.expunge)
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("EXPUNGE failed")
}
}
public func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
guard var runner else { throw IMAPError.notConnected }
let mailboxName = MailboxName(ByteBuffer(string: mailbox))
let flagList = flags.map { Flag($0) }
let options = AppendOptions(flagList: flagList)
let data = AppendData(byteCount: message.count)
let appendMessage = AppendMessage(options: options, data: data)
let parts: [AppendCommand] = [
.start(tag: "", appendingTo: mailboxName),
.beginMessage(message: appendMessage),
.messageBytes(ByteBuffer(bytes: message)),
.endMessage,
.finish,
]
let responses = try await runner.runAppend(parts)
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("APPEND failed")
}
}
public func capabilities() async throws -> Set<String> {
if let cached = cachedCapabilities {
return cached
}
guard var runner else { throw IMAPError.notConnected }
let responses = try await runner.run(.capability)
self.runner = runner
let caps = parseCapabilityResponses(responses)
cachedCapabilities = caps
return caps
}
// MARK: - Response parsing helpers
private func isOKTagged(_ response: Response) -> Bool {
if case .tagged(let tagged) = response {
if case .ok = tagged.state {
return true
}
}
return false
}
private func parseCapabilityResponses(_ responses: [Response]) -> Set<String> {
var caps: Set<String> = []
for response in responses {
guard case .untagged(let payload) = response,
case .capabilityData(let capabilities) = payload
else { continue }
for cap in capabilities {
if let value = cap.value {
caps.insert("\(cap.name)=\(value)")
} else {
caps.insert(cap.name)
}
}
}
return caps
}
private func parseListResponses(_ responses: [Response]) -> [IMAPMailboxInfo] {
var results: [IMAPMailboxInfo] = []
for response in responses {
guard case .untagged(let payload) = response,
case .mailboxData(let mailboxData) = payload,
case .list(let info) = mailboxData
else { continue }
let name = String(decoding: info.path.name.bytes, as: UTF8.self)
let attributes = Set(info.attributes.map { String($0) })
results.append(IMAPMailboxInfo(name: name, attributes: attributes))
}
return results
}
private func parseSelectResponses(_ responses: [Response], name: String) -> IMAPMailboxStatus {
var uidValidity = 0
var uidNext = 0
var messageCount = 0
var recentCount = 0
for response in responses {
switch response {
case .untagged(let payload):
switch payload {
case .mailboxData(let data):
switch data {
case .exists(let count):
messageCount = count
case .recent(let count):
recentCount = count
default:
break
}
case .conditionalState(let status):
switch status {
case .ok(let text):
if let code = text.code {
switch code {
case .uidValidity(let val):
uidValidity = Int(val)
case .uidNext(let val):
uidNext = Int(val.rawValue)
default:
break
}
}
default:
break
}
default:
break
}
default:
break
}
}
return IMAPMailboxStatus(
name: name,
uidValidity: uidValidity,
uidNext: uidNext,
messageCount: messageCount,
recentCount: recentCount
)
}
private func parseFetchResponses(_ responses: [Response]) -> [FetchedEnvelope] {
var envelopes: [FetchedEnvelope] = []
var currentUID: Int?
var currentEnvelope: Envelope?
var currentFlags: [Flag] = []
var currentSize: Int = 0
var bodyBuffer = ByteBuffer()
for response in responses {
switch response {
case .fetch(let fetchResponse):
switch fetchResponse {
case .start:
currentUID = nil
currentEnvelope = nil
currentFlags = []
currentSize = 0
bodyBuffer = ByteBuffer()
case .startUID:
currentUID = nil
currentEnvelope = nil
currentFlags = []
currentSize = 0
bodyBuffer = ByteBuffer()
case .simpleAttribute(let attr):
switch attr {
case .uid(let uid):
currentUID = Int(uid.rawValue)
case .envelope(let env):
currentEnvelope = env
case .flags(let flags):
currentFlags = flags
case .rfc822Size(let size):
currentSize = size
default:
break
}
case .streamingBegin:
break
case .streamingBytes(let bytes):
var mutableBytes = bytes
bodyBuffer.writeBuffer(&mutableBytes)
case .streamingEnd:
break
case .finish:
if let uid = currentUID {
let envelope = buildFetchedEnvelope(
uid: uid,
envelope: currentEnvelope,
flags: currentFlags,
size: currentSize,
bodyBuffer: bodyBuffer
)
envelopes.append(envelope)
}
}
default:
break
}
}
return envelopes
}
private func parseFlagResponses(_ responses: [Response]) -> [UIDFlagsPair] {
var results: [UIDFlagsPair] = []
var currentUID: Int?
var currentFlags: [Flag] = []
for response in responses {
switch response {
case .fetch(let fetchResponse):
switch fetchResponse {
case .start, .startUID:
currentUID = nil
currentFlags = []
case .simpleAttribute(let attr):
switch attr {
case .uid(let uid):
currentUID = Int(uid.rawValue)
case .flags(let flags):
currentFlags = flags
default:
break
}
case .finish:
if let uid = currentUID {
results.append(UIDFlagsPair(
uid: uid,
isRead: currentFlags.contains(.seen),
isFlagged: currentFlags.contains(.flagged)
))
}
default:
break
}
default:
break
}
}
return results
}
private func parseFullMessageResponse(_ responses: [Response]) -> String {
var bodyBuffer = ByteBuffer()
for response in responses {
if case .fetch(let fetchResponse) = response {
switch fetchResponse {
case .streamingBytes(let bytes):
var mutableBytes = bytes
bodyBuffer.writeBuffer(&mutableBytes)
default:
break
}
}
}
return String(buffer: bodyBuffer)
}
private func parseBodyResponse(_ responses: [Response]) -> (text: String?, html: String?) {
var bodyBuffer = ByteBuffer()
for response in responses {
if case .fetch(let fetchResponse) = response {
switch fetchResponse {
case .streamingBytes(let bytes):
var mutableBytes = bytes
bodyBuffer.writeBuffer(&mutableBytes)
default:
break
}
}
}
guard bodyBuffer.readableBytes > 0 else {
return (text: nil, html: nil)
}
let bodyString = String(buffer: bodyBuffer)
// Simple heuristic: if the body contains HTML tags, treat as HTML
let lowered = bodyString.lowercased()
if lowered.contains("<html") || lowered.contains("<body") || lowered.contains("<div") {
return (text: nil, html: bodyString)
}
return (text: bodyString, html: nil)
}
// MARK: - Envelope conversion
private func buildFetchedEnvelope(
uid: Int,
envelope: Envelope?,
flags: [Flag],
size: Int,
bodyBuffer: ByteBuffer
) -> FetchedEnvelope {
let subject = envelope?.subject.flatMap { String(buffer: $0) }
let date = envelope?.date.map { String($0) } ?? ""
let messageId = envelope?.messageID.map { String($0) }
let inReplyTo = envelope?.inReplyTo.map { String($0) }
let from = envelope?.from.compactMap { extractEmailAddress($0) }.first
let to = envelope?.to.compactMap { extractEmailAddress($0) } ?? []
let cc = envelope?.cc.compactMap { extractEmailAddress($0) } ?? []
let bodyText: String?
let bodyHtml: String?
if bodyBuffer.readableBytes > 0 {
let bodyString = String(buffer: bodyBuffer)
let lowered = bodyString.lowercased()
if lowered.contains("<html") || lowered.contains("<body") || lowered.contains("<div") {
bodyText = nil
bodyHtml = bodyString
} else {
bodyText = bodyString
bodyHtml = nil
}
} else {
bodyText = nil
bodyHtml = nil
}
let snippet: String? = bodyText.map { text in
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return String(trimmed.prefix(200))
}
return FetchedEnvelope(
uid: uid,
messageId: messageId,
inReplyTo: inReplyTo,
references: nil,
subject: subject,
from: from,
to: to,
cc: cc,
date: date,
snippet: snippet,
bodyText: bodyText,
bodyHtml: bodyHtml,
isRead: flags.contains(.seen),
isFlagged: flags.contains(.flagged),
size: size
)
}
private func extractEmailAddress(_ element: EmailAddressListElement) -> Models.EmailAddress? {
switch element {
case .singleAddress(let addr):
let mailbox = addr.mailbox.flatMap { String(buffer: $0) }
let host = addr.host.flatMap { String(buffer: $0) }
let personName = addr.personName.flatMap { String(buffer: $0) }
guard let mailbox, let host else { return nil }
return Models.EmailAddress(name: personName, address: "\(mailbox)@\(host)")
case .group(let group):
// Return the first child address from the group
return group.children.compactMap { extractEmailAddress($0) }.first
}
}
}