Erste Alpha-Version
This commit is contained in:
committed by
Dr. Michael Kübler
parent
93feb97739
commit
223bf746ef
@@ -0,0 +1,209 @@
|
||||
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() }
|
||||
|
||||
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)") }
|
||||
_ = 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 {
|
||||
throw MailError.message("IMAP: \(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)] = []
|
||||
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")))
|
||||
}
|
||||
func build(prefix: String?, entries: [(path: String, delimiter: String, selectable: Bool)]) -> [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)
|
||||
}
|
||||
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) async throws -> MailboxInventory {
|
||||
let examined = try await command("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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user