380 lines
18 KiB
Swift
380 lines
18 KiB
Swift
import Foundation
|
|
|
|
@MainActor
|
|
final class IMAPClient {
|
|
private let connection: TLSConnection
|
|
private var sequence = 0
|
|
|
|
init(account: MailAccount) throws {
|
|
connection = try TLSConnection(host: account.host, port: account.imapPort)
|
|
}
|
|
|
|
func close() { connection.close() }
|
|
nonisolated func cancel() { connection.cancel() }
|
|
|
|
func login(account: MailAccount, password: String) async throws {
|
|
try await connection.open()
|
|
let greeting = try await connection.readLine()
|
|
if greeting.uppercased().hasPrefix("* PREAUTH") { return }
|
|
guard greeting.uppercased().hasPrefix("* OK") else { throw MailError.message("IMAP: \(greeting)") }
|
|
let capabilities = greeting.uppercased()
|
|
if capabilities.contains("AUTH=PLAIN"), capabilities.contains("SASL-IR") {
|
|
let credentials = Data(("\0" + account.username + "\0" + password).utf8).base64EncodedString()
|
|
_ = try await command("AUTHENTICATE PLAIN \(credentials)")
|
|
} else {
|
|
_ = try await command("LOGIN \(Self.quote(account.username)) \(Self.quote(password))")
|
|
}
|
|
}
|
|
|
|
static func quote(_ value: String) throws -> String {
|
|
guard !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else {
|
|
throw MailError.message("Ungültiges Steuerzeichen in IMAP-Eingabe.")
|
|
}
|
|
return "\"" + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
|
|
}
|
|
|
|
struct Response {
|
|
var lines: [String] = []
|
|
var literals: [Data] = []
|
|
}
|
|
|
|
private func command(_ value: String) async throws -> Response {
|
|
sequence += 1
|
|
let tag = "M\(sequence)"
|
|
try await connection.line("\(tag) \(value)")
|
|
return try await response(tag: tag)
|
|
}
|
|
|
|
private func response(tag: String) async throws -> Response {
|
|
var result = Response()
|
|
for _ in 0..<100_000 {
|
|
let line = try await connection.readLine()
|
|
if line.hasPrefix(tag + " ") {
|
|
guard line.uppercased().hasPrefix(tag + " OK") else {
|
|
if Self.isAuthenticationFailure(line) { throw MailError.authentication("Die Anmeldung wurde vom Mailserver abgelehnt.") }
|
|
throw MailError.message("IMAP: \(line)")
|
|
}
|
|
result.lines.append(line)
|
|
return result
|
|
}
|
|
if line.uppercased().hasPrefix("* BYE") { throw MailError.message("IMAP: Verbindung vom Server beendet.") }
|
|
result.lines.append(line)
|
|
if line.hasSuffix("}"), let start = line.lastIndex(of: "{"),
|
|
let count = Int(line[line.index(after: start)..<line.index(before: line.endIndex)]) {
|
|
result.literals.append(try await connection.readBytes(count))
|
|
}
|
|
}
|
|
throw MailError.message("IMAP-Antwort überschreitet das Limit.")
|
|
}
|
|
|
|
func folders(accountID: UUID) async throws -> [MailFolder] {
|
|
let result = try await command("LIST \"\" \"*\"")
|
|
var entries: [(path: String, delimiter: String, selectable: Bool, flags: [String])] = []
|
|
var literalIndex = 0
|
|
for line in result.lines where line.uppercased().hasPrefix("* LIST ") {
|
|
guard let close = line.firstIndex(of: ")") else { continue }
|
|
let flags = line[..<close].lowercased()
|
|
let rest = String(line[line.index(after: close)...])
|
|
let tokens = Self.tokens(rest)
|
|
guard tokens.count >= 2 else { continue }
|
|
var path = tokens[1]
|
|
if path.hasPrefix("{"), literalIndex < result.literals.count {
|
|
path = String(decoding: result.literals[literalIndex], as: UTF8.self)
|
|
literalIndex += 1
|
|
}
|
|
entries.append((path, tokens[0].uppercased() == "NIL" ? "" : tokens[0], !flags.contains("\\noselect"), flags.split(separator: " ").map { String($0).replacingOccurrences(of: "(", with: "") }.filter { $0.hasPrefix("\\") }))
|
|
}
|
|
func build(prefix: String?, entries: [(path: String, delimiter: String, selectable: Bool, flags: [String])]) -> [MailFolder] {
|
|
var roots: [String: MailFolder] = [:]
|
|
for entry in entries {
|
|
let remainder: String
|
|
if let prefix {
|
|
let start = prefix + entry.delimiter
|
|
guard !entry.delimiter.isEmpty, entry.path.hasPrefix(start) else { continue }
|
|
remainder = String(entry.path.dropFirst(start.count))
|
|
} else { remainder = entry.path }
|
|
let component = entry.delimiter.isEmpty ? remainder : remainder.components(separatedBy: entry.delimiter)[0]
|
|
let path = prefix.map { $0 + entry.delimiter + component } ?? component
|
|
let exact = entries.first { $0.path == path }
|
|
roots[path] = MailFolder(accountID: accountID, path: path,
|
|
name: path.uppercased() == "INBOX" ? "Posteingang" : Self.decodeMailbox(component),
|
|
selectable: exact?.selectable ?? false, delimiter: entry.delimiter, specialUse: exact?.flags)
|
|
}
|
|
return roots.values.map { folder in
|
|
var folder = folder
|
|
let descendants = build(prefix: folder.path, entries: entries)
|
|
folder.children = descendants.isEmpty ? nil : descendants
|
|
return folder
|
|
}.sorted {
|
|
if $0.path.uppercased() == "INBOX" { return true }
|
|
if $1.path.uppercased() == "INBOX" { return false }
|
|
return $0.name.localizedStandardCompare($1.name) == .orderedAscending
|
|
}
|
|
}
|
|
return build(prefix: nil, entries: entries)
|
|
}
|
|
|
|
/// IMAP quoted strings may contain escaped quotes or spaces.
|
|
static func tokens(_ value: String) -> [String] {
|
|
var tokens: [String] = []
|
|
var token = ""
|
|
var quoted = false
|
|
var escaped = false
|
|
var started = false
|
|
for character in value {
|
|
if escaped { token.append(character); escaped = false; continue }
|
|
if quoted && character == "\\" { escaped = true; continue }
|
|
if character == "\"" { quoted.toggle(); started = true; continue }
|
|
if character.isWhitespace && !quoted {
|
|
if started { tokens.append(token); token = ""; started = false }
|
|
} else { token.append(character); started = true }
|
|
}
|
|
if started { tokens.append(token) }
|
|
return tokens
|
|
}
|
|
|
|
static func decodeMailbox(_ value: String) -> String {
|
|
var result = ""
|
|
var remainder = value[...]
|
|
while let start = remainder.firstIndex(of: "&") {
|
|
result += remainder[..<start]
|
|
guard let end = remainder[start...].firstIndex(of: "-") else { result += remainder[start...]; return result }
|
|
var encoded = String(remainder[remainder.index(after: start)..<end])
|
|
if encoded.isEmpty { result += "&" }
|
|
else {
|
|
encoded = encoded.replacingOccurrences(of: ",", with: "/")
|
|
while encoded.count % 4 != 0 { encoded += "=" }
|
|
if let data = Data(base64Encoded: encoded), let decoded = String(data: data, encoding: .utf16BigEndian) { result += decoded }
|
|
else { result += remainder[start...end] }
|
|
}
|
|
remainder = remainder[remainder.index(after: end)...]
|
|
}
|
|
return result + remainder
|
|
}
|
|
|
|
static func uidValidity(from lines: [String]) throws -> String {
|
|
for line in lines {
|
|
guard let start = line.range(of: "[UIDVALIDITY ", options: .caseInsensitive),
|
|
let end = line[start.upperBound...].firstIndex(of: "]") else { continue }
|
|
let value = String(line[start.upperBound..<end])
|
|
if let number = UInt64(value), number > 0 { return value }
|
|
}
|
|
throw MailError.message("IMAP-Server liefert keine gültige UIDVALIDITY; Abgleich abgebrochen.")
|
|
}
|
|
|
|
static func parseFlags(_ lines: [String]) throws -> [UInt64: [String]] {
|
|
let uidPattern = try NSRegularExpression(pattern: "(?i)\\bUID ([0-9]+)")
|
|
let flagsPattern = try NSRegularExpression(pattern: "(?i)\\bFLAGS \\(([^)]*)\\)")
|
|
var result: [UInt64: [String]] = [:]
|
|
for line in lines {
|
|
let range = NSRange(line.startIndex..., in: line)
|
|
guard let uidMatch = uidPattern.firstMatch(in: line, range: range),
|
|
let uidRange = Range(uidMatch.range(at: 1), in: line),
|
|
let uid = UInt64(line[uidRange]),
|
|
let flagsMatch = flagsPattern.firstMatch(in: line, range: range),
|
|
let flagsRange = Range(flagsMatch.range(at: 1), in: line) else { continue }
|
|
result[uid] = line[flagsRange].split(separator: " ").map(String.init)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func inventory(folder: MailFolder, writable: Bool = false) async throws -> MailboxInventory {
|
|
let examined = try await command("\(writable ? "SELECT" : "EXAMINE") \(Self.quote(folder.path))")
|
|
let validity = try Self.uidValidity(from: examined.lines)
|
|
let search = try await command("UID SEARCH ALL")
|
|
guard let line = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else {
|
|
throw MailError.message("Unvollständige IMAP-Bestandsliste.")
|
|
}
|
|
let ids = line.split(separator: " ").dropFirst(2).compactMap { UInt64($0) }
|
|
var flags: [UInt64: [String]] = [:]
|
|
for start in stride(from: 0, to: ids.count, by: 200) {
|
|
let batch = ids[start..<min(start + 200, ids.count)].map(String.init).joined(separator: ",")
|
|
let fetched = try await command("UID FETCH \(batch) (UID FLAGS)")
|
|
flags.merge(try Self.parseFlags(fetched.lines)) { _, new in new }
|
|
}
|
|
guard Set(flags.keys) == Set(ids) else {
|
|
throw MailError.message("Der Ordner hat sich während des Abgleichs verändert. Bitte erneut abrufen.")
|
|
}
|
|
return MailboxInventory(validity: validity, flags: flags)
|
|
}
|
|
|
|
func rawMessage(uid: UInt64) async throws -> Data {
|
|
let fetched = try await command("UID FETCH \(uid) (UID BODY.PEEK[])")
|
|
guard let raw = fetched.literals.first else {
|
|
throw MailError.message("Nachricht \(uid) ist nicht mehr verfügbar; bitte erneut abrufen.")
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func appendSent(_ message: Data, path: String) async throws {
|
|
sequence += 1
|
|
let tag = "M\(sequence)"
|
|
try await connection.line("\(tag) APPEND \(Self.quote(path)) (\\Seen) {\(message.count)}")
|
|
let ready = try await connection.readLine()
|
|
guard ready.hasPrefix("+") else { throw MailError.message("Gesendet-Kopie konnte nicht gespeichert werden: \(ready)") }
|
|
try await connection.send(message + Data("\r\n".utf8))
|
|
_ = try await response(tag: tag)
|
|
}
|
|
|
|
func capabilities() async throws -> Set<String> {
|
|
let result = try await command("CAPABILITY")
|
|
return Set(result.lines.filter { $0.uppercased().hasPrefix("* CAPABILITY ") }
|
|
.flatMap { $0.uppercased().split(separator: " ").dropFirst(2).map(String.init) })
|
|
}
|
|
|
|
func createFolder(path: String) async throws { _ = try await command("CREATE \(Self.quote(path))") }
|
|
func deleteFolder(path: String) async throws { _ = try await command("DELETE \(Self.quote(path))") }
|
|
|
|
func containsMessage(id: String, folder: MailFolder) async throws -> Bool {
|
|
_ = try await command("EXAMINE \(Self.quote(folder.path))")
|
|
let result = try await command("UID SEARCH HEADER Message-ID \(Self.quote(id))")
|
|
guard let search = result.lines.first(where: { $0.hasPrefix("* SEARCH") }) else {
|
|
throw MailError.message("Gesendet-Kopie konnte nicht geprüft werden.")
|
|
}
|
|
return search.split(separator: " ").count > 2
|
|
}
|
|
|
|
func setSeen(uid: UInt64, read: Bool) async throws {
|
|
_ = try await command("UID STORE \(uid) \(read ? "+" : "-")FLAGS.SILENT (\\Seen)")
|
|
}
|
|
|
|
struct AssignedUID: Equatable {
|
|
let validity: String
|
|
let uid: String
|
|
}
|
|
|
|
/// Single-message COPYUID/APPENDUID responses identify the local destination record.
|
|
static func assignedUID(_ lines: [String], sourceUID: UInt64? = nil) -> AssignedUID? {
|
|
let code = sourceUID == nil ? "APPENDUID" : "COPYUID"
|
|
for line in lines {
|
|
guard let start = line.range(of: "[" + code + " ", options: .caseInsensitive),
|
|
let end = line[start.upperBound...].firstIndex(of: "]") else { continue }
|
|
let parts = line[start.upperBound..<end].split(separator: " ").map(String.init)
|
|
guard parts.count == (sourceUID == nil ? 2 : 3),
|
|
let validity = UInt64(parts[0]), validity > 0,
|
|
let uid = UInt64(parts[parts.count - 1]), uid > 0 else { continue }
|
|
if let sourceUID, UInt64(parts[1]) != sourceUID { continue }
|
|
return AssignedUID(validity: String(validity), uid: String(uid))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Select and inspect only the requested UID, without enumerating the mailbox.
|
|
func messageInventory(folder: MailFolder, uid: UInt64) async throws -> MailboxInventory {
|
|
let selected = try await command("SELECT \(Self.quote(folder.path))")
|
|
let validity = try Self.uidValidity(from: selected.lines)
|
|
let fetched = try await command("UID FETCH \(uid) (UID FLAGS)")
|
|
return MailboxInventory(validity: validity, flags: try Self.parseFlags(fetched.lines))
|
|
}
|
|
|
|
@discardableResult
|
|
func move(uid: UInt64, to path: String) async throws -> AssignedUID? {
|
|
let result = try await command("UID MOVE \(uid) \(Self.quote(path))")
|
|
return Self.assignedUID(result.lines, sourceUID: uid)
|
|
}
|
|
|
|
/// UID EXPUNGE must never be replaced by EXPUNGE: other clients may have marked unrelated mail.
|
|
func remove(uids: [UInt64]) async throws {
|
|
guard !uids.isEmpty else { return }
|
|
guard try await capabilities().contains("UIDPLUS") else {
|
|
throw MailError.message("Der Server unterstützt kein gezieltes Löschen (UIDPLUS). Es wurden keine Mails gelöscht.")
|
|
}
|
|
for start in stride(from: 0, to: uids.count, by: 200) {
|
|
let batch = uids[start..<min(start + 200, uids.count)].map(String.init).joined(separator: ",")
|
|
_ = try await command("UID STORE \(batch) +FLAGS.SILENT (\\Deleted)")
|
|
_ = try await command("UID EXPUNGE \(batch)")
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func append(_ message: Data, path: String, flags: [String]) async throws -> AssignedUID? {
|
|
let allowed = ["\\Seen", "\\Answered", "\\Flagged", "\\Draft"]
|
|
let safeFlags = flags.filter { allowed.contains($0) }.joined(separator: " ")
|
|
sequence += 1
|
|
let tag = "M\(sequence)"
|
|
try await connection.line("\(tag) APPEND \(Self.quote(path)) (\(safeFlags)) {\(message.count)}")
|
|
let ready = try await connection.readLine()
|
|
guard ready.hasPrefix("+") else { throw MailError.message("Nachricht konnte nicht im Ziel gespeichert werden: \(ready)") }
|
|
try await connection.send(message + Data("\r\n".utf8))
|
|
let result = try await response(tag: tag)
|
|
return Self.assignedUID(result.lines)
|
|
}
|
|
|
|
static func encodeMailbox(_ value: String) -> String {
|
|
var result = ""
|
|
var run = ""
|
|
func flush() {
|
|
if !run.isEmpty {
|
|
let data = run.data(using: .utf16BigEndian) ?? Data()
|
|
result += "&" + data.base64EncodedString().replacingOccurrences(of: "/", with: ",").replacingOccurrences(of: "=", with: "") + "-"
|
|
run = ""
|
|
}
|
|
}
|
|
for scalar in value.unicodeScalars {
|
|
if (0x20...0x7e).contains(scalar.value) {
|
|
flush()
|
|
result += scalar == "&" ? "&-" : String(scalar)
|
|
} else { run += String(scalar) }
|
|
}
|
|
flush()
|
|
return result
|
|
}
|
|
|
|
static func isAuthenticationFailure(_ line: String) -> Bool {
|
|
let value = line.uppercased()
|
|
return value.contains("[AUTHENTICATIONFAILED]") || value.contains("AUTHENTICATION FAILED")
|
|
|| value.contains("AUTHENTICATION FAILURE") || value.contains("LOGIN FAILED")
|
|
|| value.contains("INVALID CREDENTIALS")
|
|
}
|
|
|
|
static func isMailboxChange(_ line: String) -> Bool {
|
|
let parts = line.uppercased().split(separator: " ")
|
|
guard parts.count >= 3, parts[0] == "*", UInt64(parts[1]) != nil else { return false }
|
|
return ["EXISTS", "EXPUNGE", "FETCH"].contains(String(parts[2]))
|
|
}
|
|
|
|
/// This connection is reserved for IDLE; normal commands use separate sessions.
|
|
/// Renew before the RFC 2177 inactivity limit, with a bounded DONE response wait.
|
|
func idleInbox(renewAfterSeconds: Int = 25 * 60,
|
|
onReady: () -> Void, onChange: () -> Void) async throws {
|
|
_ = try await command("EXAMINE \"INBOX\"")
|
|
sequence += 1
|
|
let tag = "M\(sequence)"
|
|
try await connection.line("\(tag) IDLE")
|
|
while true {
|
|
let line = try await connection.readLine()
|
|
if line.hasPrefix("+") { break }
|
|
guard line.hasPrefix("*"), !line.uppercased().hasPrefix("* BYE") else {
|
|
throw MailError.message("IMAP IDLE wurde nicht akzeptiert: \(line)")
|
|
}
|
|
}
|
|
onReady()
|
|
let renewal = Task {
|
|
do {
|
|
try await Task.sleep(for: .seconds(renewAfterSeconds))
|
|
try Task.checkCancellation()
|
|
try await connection.line("DONE")
|
|
try await Task.sleep(for: .seconds(30))
|
|
connection.cancel()
|
|
} catch {
|
|
if !Task.isCancelled { connection.cancel() }
|
|
}
|
|
}
|
|
defer { renewal.cancel() }
|
|
while true {
|
|
try Task.checkCancellation()
|
|
let line = try await connection.readLine(timeoutSeconds: nil)
|
|
if line.hasPrefix(tag + " ") {
|
|
guard line.uppercased().hasPrefix(tag + " OK") else {
|
|
throw MailError.message("IMAP IDLE: \(line)")
|
|
}
|
|
return
|
|
}
|
|
if line.uppercased().hasPrefix("* BYE") {
|
|
throw MailError.message("IMAP IDLE: Verbindung beendet.")
|
|
}
|
|
if Self.isMailboxChange(line) { onChange() }
|
|
}
|
|
}
|
|
}
|