Alpha 2 Update
This commit is contained in:
committed by
Dr. Michael Kübler
parent
223bf746ef
commit
8e95cc3acd
+137
-6
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user