635 lines
19 KiB
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
|
|
}
|
|
}
|
|
}
|