Alpha 2 Update

This commit is contained in:
Dr. Michael Kübler
2026-11-07 12:24:11 +01:00
committed by Dr. Michael Kübler
parent 223bf746ef
commit 8e95cc3acd
22 changed files with 1935 additions and 144 deletions
+137 -6
View File
@@ -10,6 +10,7 @@ final class IMAPClient {
}
func close() { connection.close() }
nonisolated func cancel() { connection.cancel() }
func login(account: MailAccount, password: String) async throws {
try await connection.open()
@@ -44,8 +45,10 @@ final class IMAPClient {
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.") }
@@ -60,7 +63,7 @@ final class IMAPClient {
func folders(accountID: UUID) async throws -> [MailFolder] {
let result = try await command("LIST \"\" \"*\"")
var entries: [(path: String, delimiter: String, selectable: Bool)] = []
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 }
@@ -73,9 +76,9 @@ final class IMAPClient {
path = String(decoding: result.literals[literalIndex], as: UTF8.self)
literalIndex += 1
}
entries.append((path, tokens[0].uppercased() == "NIL" ? "" : tokens[0], !flags.contains("\\noselect")))
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)]) -> [MailFolder] {
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
@@ -89,7 +92,7 @@ final class IMAPClient {
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)
selectable: exact?.selectable ?? false, delimiter: entry.delimiter, specialUse: exact?.flags)
}
return roots.values.map { folder in
var folder = folder
@@ -169,8 +172,8 @@ final class IMAPClient {
return result
}
func inventory(folder: MailFolder) async throws -> MailboxInventory {
let examined = try await command("EXAMINE \(Self.quote(folder.path))")
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 {
@@ -206,4 +209,132 @@ final class IMAPClient {
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)")
}
func move(uid: UInt64, to path: String) async throws {
_ = try await command("UID MOVE \(uid) \(Self.quote(path))")
}
/// 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)")
}
}
func append(_ message: Data, path: String, flags: [String]) async throws {
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))
_ = try await response(tag: tag)
}
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() }
}
}
}