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? 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(lower...UID.max) let set = MessageIdentifierSetNonEmpty(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) 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(lower...upper) let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) let set = MessageIdentifierSetNonEmpty(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 { 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 { var caps: Set = [] 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(" 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(" 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 } } }