Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986ec72af0 |
+132
-13
@@ -178,24 +178,58 @@ final class IMAPClient {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func inventory(folder: MailFolder, writable: Bool = false) async throws -> MailboxInventory {
|
static func highestModSequence(from lines: [String]) -> UInt64? {
|
||||||
let examined = try await command("\(writable ? "SELECT" : "EXAMINE") \(Self.quote(folder.path))")
|
for line in lines {
|
||||||
|
if line.uppercased().contains("[NOMODSEQ]") { return nil }
|
||||||
|
}
|
||||||
|
for line in lines {
|
||||||
|
guard let start = line.range(of: "[HIGHESTMODSEQ ", options: .caseInsensitive),
|
||||||
|
let end = line[start.upperBound...].firstIndex(of: "]"),
|
||||||
|
let value = UInt64(line[start.upperBound..<end]), value > 0 else { continue }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inventory(folder: MailFolder, writable: Bool = false,
|
||||||
|
previous: MailboxInventory? = nil, supportsCONDSTORE: Bool = false) async throws -> MailboxInventory {
|
||||||
|
try await Self.fetchInventory(folder: folder, writable: writable, previous: previous,
|
||||||
|
supportsCONDSTORE: supportsCONDSTORE, command: command)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fetchInventory(folder: MailFolder, writable: Bool = false,
|
||||||
|
previous: MailboxInventory? = nil, supportsCONDSTORE: Bool = false,
|
||||||
|
command: (String) async throws -> Response) async throws -> MailboxInventory {
|
||||||
|
let option = supportsCONDSTORE ? " (CONDSTORE)" : ""
|
||||||
|
let examined = try await command("\(writable ? "SELECT" : "EXAMINE") \(Self.quote(folder.path))\(option)")
|
||||||
let validity = try Self.uidValidity(from: examined.lines)
|
let validity = try Self.uidValidity(from: examined.lines)
|
||||||
|
// Keep the watermark from BEFORE fetching changes; later changes must be replayed next time.
|
||||||
|
let modSequence = supportsCONDSTORE ? Self.highestModSequence(from: examined.lines) : nil
|
||||||
let search = try await command("UID SEARCH ALL")
|
let search = try await command("UID SEARCH ALL")
|
||||||
guard let line = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else {
|
guard let line = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else {
|
||||||
throw MailError.message("Unvollständige IMAP-Bestandsliste.")
|
throw MailError.message("Unvollständige IMAP-Bestandsliste.")
|
||||||
}
|
}
|
||||||
let ids = line.split(separator: " ").dropFirst(2).compactMap { UInt64($0) }
|
let ids = Set(line.split(separator: " ").dropFirst(2).compactMap { UInt64($0) })
|
||||||
var flags: [UInt64: [String]] = [:]
|
let reusable = previous?.canReuseFlags(validity: validity, highestModSequence: modSequence) == true
|
||||||
for start in stride(from: 0, to: ids.count, by: 200) {
|
var flags = reusable ? (previous?.flags ?? [:]).filter { ids.contains($0.key) } : [:]
|
||||||
let batch = ids[start..<min(start + 200, ids.count)].map(String.init).joined(separator: ",")
|
if !ids.isEmpty {
|
||||||
let fetched = try await command("UID FETCH \(batch) (UID FLAGS)")
|
if reusable, let previousSequence = previous?.highestModSequence {
|
||||||
|
// Even an unchanged mod-sequence needs SEARCH above: CONDSTORE alone
|
||||||
|
// does not provide QRESYNC's VANISHED deletion history.
|
||||||
|
if modSequence != previousSequence || Set(flags.keys) != ids {
|
||||||
|
let fetched = try await command("UID FETCH 1:* (UID FLAGS) (CHANGEDSINCE \(previousSequence))")
|
||||||
flags.merge(try Self.parseFlags(fetched.lines)) { _, new in new }
|
flags.merge(try Self.parseFlags(fetched.lines)) { _, new in new }
|
||||||
}
|
}
|
||||||
guard Set(flags.keys) == Set(ids) else {
|
} else {
|
||||||
|
// A UID range needs one round trip rather than one per 200 messages.
|
||||||
|
let fetched = try await command("UID FETCH 1:* (UID FLAGS)")
|
||||||
|
flags = try Self.parseFlags(fetched.lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard Set(flags.keys) == ids else {
|
||||||
throw MailError.message("Der Ordner hat sich während des Abgleichs verändert. Bitte erneut abrufen.")
|
throw MailError.message("Der Ordner hat sich während des Abgleichs verändert. Bitte erneut abrufen.")
|
||||||
}
|
}
|
||||||
return MailboxInventory(validity: validity, flags: flags)
|
return MailboxInventory(validity: validity, flags: flags, highestModSequence: modSequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rawMessage(uid: UInt64) async throws -> Data {
|
func rawMessage(uid: UInt64) async throws -> Data {
|
||||||
@@ -275,17 +309,48 @@ final class IMAPClient {
|
|||||||
|
|
||||||
/// UID EXPUNGE must never be replaced by EXPUNGE: other clients may have marked unrelated mail.
|
/// UID EXPUNGE must never be replaced by EXPUNGE: other clients may have marked unrelated mail.
|
||||||
func remove(uids: [UInt64]) async throws {
|
func remove(uids: [UInt64]) async throws {
|
||||||
|
try await Self.remove(uids: uids, command: command, onRemoved: { _ in })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func remove(uids: [UInt64], command: (String) async throws -> Response,
|
||||||
|
onRemoved: ([UInt64]) throws -> Void) async throws {
|
||||||
guard !uids.isEmpty else { return }
|
guard !uids.isEmpty else { return }
|
||||||
guard try await capabilities().contains("UIDPLUS") else {
|
let response = try await command("CAPABILITY")
|
||||||
|
let capabilities = Set(response.lines.filter { $0.uppercased().hasPrefix("* CAPABILITY ") }
|
||||||
|
.flatMap { $0.uppercased().split(separator: " ").dropFirst(2).map(String.init) })
|
||||||
|
guard capabilities.contains("UIDPLUS") else {
|
||||||
throw MailError.message("Der Server unterstützt kein gezieltes Löschen (UIDPLUS). Es wurden keine Mails gelöscht.")
|
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) {
|
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: ",")
|
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 STORE \(batch) +FLAGS.SILENT (\\Deleted)")
|
||||||
_ = try await command("UID EXPUNGE \(batch)")
|
_ = try await command("UID EXPUNGE \(batch)")
|
||||||
|
try onRemoved(Array(uids[start..<min(start + 200, uids.count)]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func emptyTrash(folder: MailFolder,
|
||||||
|
onRemoved: (String, [UInt64]) throws -> Void) async throws -> String {
|
||||||
|
try await Self.emptyTrash(folder: folder, command: command, onRemoved: onRemoved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only enumerate the selected trash folder; bodies and flags are unnecessary.
|
||||||
|
static func emptyTrash(folder: MailFolder, command: (String) async throws -> Response,
|
||||||
|
onRemoved: (String, [UInt64]) throws -> Void) async throws -> String {
|
||||||
|
guard folder.isTrash else { throw MailError.message("Bitte einen Papierkorb auswählen.") }
|
||||||
|
let selected = try await command("SELECT \(Self.quote(folder.path))")
|
||||||
|
let validity = try Self.uidValidity(from: selected.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 uids = line.split(separator: " ").dropFirst(2).compactMap { UInt64($0) }.sorted()
|
||||||
|
try await remove(uids: uids, command: command) { removed in
|
||||||
|
try onRemoved(validity, removed)
|
||||||
|
}
|
||||||
|
return validity
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func append(_ message: Data, path: String, flags: [String]) async throws -> AssignedUID? {
|
func append(_ message: Data, path: String, flags: [String]) async throws -> AssignedUID? {
|
||||||
let allowed = ["\\Seen", "\\Answered", "\\Flagged", "\\Draft"]
|
let allowed = ["\\Seen", "\\Answered", "\\Flagged", "\\Draft"]
|
||||||
@@ -333,11 +398,64 @@ final class IMAPClient {
|
|||||||
return ["EXISTS", "EXPUNGE", "FETCH"].contains(String(parts[2]))
|
return ["EXISTS", "EXPUNGE", "FETCH"].contains(String(parts[2]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum IdleChange: Equatable {
|
||||||
|
case refresh
|
||||||
|
case removed(validity: String, uid: UInt64)
|
||||||
|
case flags(validity: String, uid: UInt64, flags: [String])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EXPUNGE uses sequence numbers, which shift after every removal.
|
||||||
|
struct IdleMailbox {
|
||||||
|
let validity: String
|
||||||
|
var uids: [UInt64?]
|
||||||
|
|
||||||
|
mutating func receive(_ line: String) -> IdleChange? {
|
||||||
|
let parts = line.uppercased().split(separator: " ")
|
||||||
|
guard parts.count >= 3, parts[0] == "*", let number = Int(parts[1]), number >= 0 else { return nil }
|
||||||
|
switch parts[2] {
|
||||||
|
case "EXISTS":
|
||||||
|
guard number != uids.count else { return nil }
|
||||||
|
if number > uids.count {
|
||||||
|
uids.append(contentsOf: repeatElement(nil, count: number - uids.count))
|
||||||
|
} else {
|
||||||
|
uids = Array(repeating: nil, count: number)
|
||||||
|
}
|
||||||
|
return .refresh
|
||||||
|
case "EXPUNGE":
|
||||||
|
guard number > 0, number <= uids.count else { return .refresh }
|
||||||
|
guard let uid = uids.remove(at: number - 1) else { return .refresh }
|
||||||
|
return .removed(validity: validity, uid: uid)
|
||||||
|
case "FETCH":
|
||||||
|
guard number > 0, number <= uids.count else { return .refresh }
|
||||||
|
// Servers may omit UID in unsolicited FETCH responses.
|
||||||
|
let flags = try? IMAPClient.parseFlags([line + " UID " + String(uids[number - 1] ?? 0)])
|
||||||
|
guard let (uid, values) = flags?.first, uid > 0 else { return .refresh }
|
||||||
|
uids[number - 1] = uid
|
||||||
|
return .flags(validity: validity, uid: uid, flags: values)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// This connection is reserved for IDLE; normal commands use separate sessions.
|
/// This connection is reserved for IDLE; normal commands use separate sessions.
|
||||||
/// Renew before the RFC 2177 inactivity limit, with a bounded DONE response wait.
|
/// Renew before the RFC 2177 inactivity limit, with a bounded DONE response wait.
|
||||||
func idleInbox(renewAfterSeconds: Int = 25 * 60,
|
func idleInbox(renewAfterSeconds: Int = 25 * 60,
|
||||||
onReady: () -> Void, onChange: () -> Void) async throws {
|
onReady: () -> Void, onChange: (IdleChange) -> Void) async throws {
|
||||||
_ = try await command("EXAMINE \"INBOX\"")
|
let examined = try await command("EXAMINE \"INBOX\"")
|
||||||
|
let validity = try Self.uidValidity(from: examined.lines)
|
||||||
|
let search = try await command("UID SEARCH ALL")
|
||||||
|
guard let listing = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else {
|
||||||
|
throw MailError.message("Unvollständige IMAP-Bestandsliste.")
|
||||||
|
}
|
||||||
|
var mailbox = IdleMailbox(validity: validity,
|
||||||
|
uids: listing.split(separator: " ").dropFirst(2).compactMap { UInt64($0) }.sorted().map { Optional($0) })
|
||||||
|
// SEARCH is the sequence baseline; subsequent unsolicited responses shift it.
|
||||||
|
if let index = search.lines.firstIndex(of: listing) {
|
||||||
|
for line in search.lines.dropFirst(index + 1) {
|
||||||
|
if let change = mailbox.receive(line) { onChange(change) }
|
||||||
|
}
|
||||||
|
}
|
||||||
sequence += 1
|
sequence += 1
|
||||||
let tag = "M\(sequence)"
|
let tag = "M\(sequence)"
|
||||||
try await connection.line("\(tag) IDLE")
|
try await connection.line("\(tag) IDLE")
|
||||||
@@ -347,6 +465,7 @@ final class IMAPClient {
|
|||||||
guard line.hasPrefix("*"), !line.uppercased().hasPrefix("* BYE") else {
|
guard line.hasPrefix("*"), !line.uppercased().hasPrefix("* BYE") else {
|
||||||
throw MailError.message("IMAP IDLE wurde nicht akzeptiert: \(line)")
|
throw MailError.message("IMAP IDLE wurde nicht akzeptiert: \(line)")
|
||||||
}
|
}
|
||||||
|
if let change = mailbox.receive(line) { onChange(change) }
|
||||||
}
|
}
|
||||||
onReady()
|
onReady()
|
||||||
let renewal = Task {
|
let renewal = Task {
|
||||||
@@ -373,7 +492,7 @@ final class IMAPClient {
|
|||||||
if line.uppercased().hasPrefix("* BYE") {
|
if line.uppercased().hasPrefix("* BYE") {
|
||||||
throw MailError.message("IMAP IDLE: Verbindung beendet.")
|
throw MailError.message("IMAP IDLE: Verbindung beendet.")
|
||||||
}
|
}
|
||||||
if Self.isMailboxChange(line) { onChange() }
|
if let change = mailbox.receive(line) { onChange(change) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,16 @@ struct ArchiveConfiguration: Codable {
|
|||||||
var recipients: [String]
|
var recipients: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MailboxInventory {
|
struct MailboxInventory: Codable, Equatable {
|
||||||
let validity: String
|
let validity: String
|
||||||
let flags: [UInt64: [String]]
|
let flags: [UInt64: [String]]
|
||||||
|
var highestModSequence: UInt64? = nil
|
||||||
|
|
||||||
|
func canReuseFlags(validity: String, highestModSequence: UInt64?) -> Bool {
|
||||||
|
guard self.validity == validity, let previous = self.highestModSequence,
|
||||||
|
let current = highestModSequence else { return false }
|
||||||
|
return previous > 0 && current >= previous
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ArchiveMailRecord: Codable {
|
struct ArchiveMailRecord: Codable {
|
||||||
@@ -160,15 +167,60 @@ final class MailArchive {
|
|||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server snapshot stays separate from local mutations and survives relaunches.
|
||||||
|
func syncInventory(folderID: String) throws -> MailboxInventory? {
|
||||||
|
let key = "inventory:" + folderID
|
||||||
|
let descriptor = FetchDescriptor<MailArchiveSchema.ArchiveState>(predicate: #Predicate { $0.key == key })
|
||||||
|
guard let state = try context.fetch(descriptor).first else { return nil }
|
||||||
|
// A missing/obsolete cache is recoverable through a full metadata fetch.
|
||||||
|
return try? JSONDecoder().decode(MailboxInventory.self, from: state.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheInventory(folderID: String, inventory: MailboxInventory) throws {
|
||||||
|
let key = "inventory:" + folderID
|
||||||
|
let payload = try JSONEncoder().encode(inventory)
|
||||||
|
if let state = try context.fetch(FetchDescriptor<MailArchiveSchema.ArchiveState>(
|
||||||
|
predicate: #Predicate { $0.key == key })).first {
|
||||||
|
if state.payload != payload { state.payload = payload }
|
||||||
|
} else {
|
||||||
|
context.insert(MailArchiveSchema.ArchiveState(key: key, payload: payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reconcile only after a complete server inventory. Keep removed objects for future backups.
|
/// Reconcile only after a complete server inventory. Keep removed objects for future backups.
|
||||||
func reconcile(folderID: String, inventory: MailboxInventory) throws {
|
@discardableResult
|
||||||
|
func reconcile(folderID: String, inventory: MailboxInventory) throws -> Bool {
|
||||||
do {
|
do {
|
||||||
|
var changed = false
|
||||||
for row in try rows(folderID: folderID) {
|
for row in try rows(folderID: folderID) {
|
||||||
let flags = UInt64(row.uid).flatMap { inventory.flags[$0] }
|
let flags = UInt64(row.uid).flatMap { inventory.flags[$0] }
|
||||||
row.present = row.validity == inventory.validity && flags != nil
|
let present = row.validity == inventory.validity && flags != nil
|
||||||
if let flags, row.present { row.flags = flags }
|
if row.present != present {
|
||||||
|
row.present = present
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
try context.save()
|
if let flags, present, Set(row.flags) != Set(flags) {
|
||||||
|
row.flags = flags
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if try syncInventory(folderID: folderID) != inventory {
|
||||||
|
try cacheInventory(folderID: folderID, inventory: inventory)
|
||||||
|
}
|
||||||
|
if context.hasChanges { try context.save() }
|
||||||
|
return changed
|
||||||
|
} catch { context.rollback(); throw error }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit each confirmed deletion batch, even if a later server request fails.
|
||||||
|
func applyRemovals(folderID: String, validity: String, uids: [UInt64]) throws {
|
||||||
|
let removed = Set(uids.map(String.init))
|
||||||
|
do {
|
||||||
|
for row in try rows(folderID: folderID)
|
||||||
|
where row.validity == validity && removed.contains(row.uid) && row.present {
|
||||||
|
row.present = false
|
||||||
|
}
|
||||||
|
if context.hasChanges { try context.save() }
|
||||||
} catch { context.rollback(); throw error }
|
} catch { context.rollback(); throw error }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ final class MailIdleMonitor {
|
|||||||
private var accounts: [UUID: MailAccount] = [:]
|
private var accounts: [UUID: MailAccount] = [:]
|
||||||
private var pathMonitor: NWPathMonitor?
|
private var pathMonitor: NWPathMonitor?
|
||||||
private var wakeTask: Task<Void, Never>?
|
private var wakeTask: Task<Void, Never>?
|
||||||
private var changed: ((UUID) -> Void)?
|
private var changed: ((UUID, IMAPClient.IdleChange) -> Void)?
|
||||||
private var authenticationFailed: ((UUID) -> Void)?
|
private var authenticationFailed: ((UUID) -> Void)?
|
||||||
private var report: ((UUID, String) -> Void)?
|
private var report: ((UUID, String) -> Void)?
|
||||||
|
|
||||||
func configure(accounts values: [MailAccount], enabled: Bool,
|
func configure(accounts values: [MailAccount], enabled: Bool,
|
||||||
changed: @escaping (UUID) -> Void, report: @escaping (UUID, String) -> Void,
|
changed: @escaping (UUID, IMAPClient.IdleChange) -> Void, report: @escaping (UUID, String) -> Void,
|
||||||
authenticationFailed: @escaping (UUID) -> Void) {
|
authenticationFailed: @escaping (UUID) -> Void) {
|
||||||
self.authenticationFailed = authenticationFailed
|
self.authenticationFailed = authenticationFailed
|
||||||
self.changed = changed
|
self.changed = changed
|
||||||
@@ -76,9 +76,9 @@ final class MailIdleMonitor {
|
|||||||
retrySeconds = 2
|
retrySeconds = 2
|
||||||
report?(account.id, "Sofortempfang aktiv")
|
report?(account.id, "Sofortempfang aktiv")
|
||||||
// Reconcile the gap before the listener became ready, also after reconnects.
|
// Reconcile the gap before the listener became ready, also after reconnects.
|
||||||
changed?(account.id)
|
changed?(account.id, .refresh)
|
||||||
} onChange: {
|
} onChange: { change in
|
||||||
changed?(account.id)
|
changed?(account.id, change)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} onCancel: {
|
} onCancel: {
|
||||||
|
|||||||
+60
-15
@@ -24,6 +24,7 @@ final class MailStore {
|
|||||||
private var didStart = false
|
private var didStart = false
|
||||||
private let idleMonitor = MailIdleMonitor()
|
private let idleMonitor = MailIdleMonitor()
|
||||||
private var pendingIdleAccounts: Set<UUID> = []
|
private var pendingIdleAccounts: Set<UUID> = []
|
||||||
|
private var pendingIdleChanges: [(UUID, IMAPClient.IdleChange)] = []
|
||||||
private var idleRefreshGeneration = UUID()
|
private var idleRefreshGeneration = UUID()
|
||||||
private var idleRefreshTask: Task<Void, Never>?
|
private var idleRefreshTask: Task<Void, Never>?
|
||||||
var idleStatuses: [UUID: String] = [:]
|
var idleStatuses: [UUID: String] = [:]
|
||||||
@@ -155,6 +156,7 @@ final class MailStore {
|
|||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
var failures: [String] = []
|
var failures: [String] = []
|
||||||
var newInboxMessages: [NewMailNotification] = []
|
var newInboxMessages: [NewMailNotification] = []
|
||||||
|
var changedFolderIDs: Set<String> = []
|
||||||
do {
|
do {
|
||||||
let archive = try storage()
|
let archive = try storage()
|
||||||
for account in accounts where accountID == nil || account.id == accountID {
|
for account in accounts where accountID == nil || account.id == accountID {
|
||||||
@@ -163,6 +165,7 @@ final class MailStore {
|
|||||||
defer { client.close() }
|
defer { client.close() }
|
||||||
status = "\(account.email): Verbindung wird aufgebaut …"
|
status = "\(account.email): Verbindung wird aufgebaut …"
|
||||||
try await client.login(account: account, password: PasswordStore.password(for: account.id))
|
try await client.login(account: account, password: PasswordStore.password(for: account.id))
|
||||||
|
let supportsCONDSTORE = try await client.capabilities().contains("CONDSTORE")
|
||||||
var discovered = inboxOnly ? (folders[account.id] ?? []) : try await client.folders(accountID: account.id)
|
var discovered = inboxOnly ? (folders[account.id] ?? []) : try await client.folders(accountID: account.id)
|
||||||
let pending = inboxOnly ? [] : try archive.pendingSent(accountID: account.id)
|
let pending = inboxOnly ? [] : try archive.pendingSent(accountID: account.id)
|
||||||
if !pending.isEmpty {
|
if !pending.isEmpty {
|
||||||
@@ -192,13 +195,17 @@ final class MailStore {
|
|||||||
try archive.saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: updated, recipients: recipientHistory))
|
try archive.saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: updated, recipients: recipientHistory))
|
||||||
let discoveredIDs = Set(allFolders(discovered).map(\.id))
|
let discoveredIDs = Set(allFolders(discovered).map(\.id))
|
||||||
for removed in allFolders(folders[account.id] ?? []) where !discoveredIDs.contains(removed.id) {
|
for removed in allFolders(folders[account.id] ?? []) where !discoveredIDs.contains(removed.id) {
|
||||||
try archive.reconcile(folderID: removed.id, inventory: MailboxInventory(validity: "", flags: [:]))
|
if try archive.reconcile(folderID: removed.id, inventory: MailboxInventory(validity: "", flags: [:])) {
|
||||||
|
changedFolderIDs.insert(removed.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
folders = updated
|
folders = updated
|
||||||
for folder in allFolders(discovered) where folder.selectable && (!inboxOnly || folder.isInbox) {
|
for folder in allFolders(discovered) where folder.selectable && (!inboxOnly || folder.isInbox) {
|
||||||
do {
|
do {
|
||||||
status = "\(folder.name): Änderungen prüfen …"
|
status = "\(folder.name): Änderungen prüfen …"
|
||||||
let inventory = try await client.inventory(folder: folder)
|
let previous = try archive.syncInventory(folderID: folder.id)
|
||||||
|
let inventory = try await client.inventory(folder: folder, previous: previous,
|
||||||
|
supportsCONDSTORE: supportsCONDSTORE)
|
||||||
let known = try archive.knownUIDs(folderID: folder.id, validity: inventory.validity)
|
let known = try archive.knownUIDs(folderID: folder.id, validity: inventory.validity)
|
||||||
let missing = Set(inventory.flags.keys).subtracting(known).sorted(by: >)
|
let missing = Set(inventory.flags.keys).subtracting(known).sorted(by: >)
|
||||||
for (index, uid) in missing.enumerated() {
|
for (index, uid) in missing.enumerated() {
|
||||||
@@ -215,8 +222,8 @@ final class MailStore {
|
|||||||
try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity,
|
try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity,
|
||||||
uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false)
|
uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false)
|
||||||
}
|
}
|
||||||
try archive.reconcile(folderID: folder.id, inventory: inventory)
|
let changed = try archive.reconcile(folderID: folder.id, inventory: inventory)
|
||||||
if selectedFolderID == folder.id { try display(folderID: folder.id) }
|
if changed || !missing.isEmpty { changedFolderIDs.insert(folder.id) }
|
||||||
} catch { failures.append("\(account.email) / \(folder.name): \(error.localizedDescription)") }
|
} catch { failures.append("\(account.email) / \(folder.name): \(error.localizedDescription)") }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -225,9 +232,14 @@ final class MailStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
unreadCounts = try archive.unreadCounts()
|
unreadCounts = try archive.unreadCounts()
|
||||||
archiveRevision += 1
|
if !changedFolderIDs.isEmpty { archiveRevision += 1 }
|
||||||
|
let previousSelection = selectedFolderID
|
||||||
if let resolvedFolderID = mailboxID(for: folderID) { selectedFolderID = resolvedFolderID }
|
if let resolvedFolderID = mailboxID(for: folderID) { selectedFolderID = resolvedFolderID }
|
||||||
if let selectedFolderID { try display(folderID: selectedFolderID) }
|
if let selectedFolderID,
|
||||||
|
previousSelection != selectedFolderID || changedFolderIDs.contains(selectedFolderID)
|
||||||
|
|| (selectedFolderID.hasPrefix("unified:") && !changedFolderIDs.isEmpty) {
|
||||||
|
try display(folderID: selectedFolderID)
|
||||||
|
}
|
||||||
await MailNotifications.postNewMail(
|
await MailNotifications.postNewMail(
|
||||||
newInboxMessages,
|
newInboxMessages,
|
||||||
badgeCount: totalUnreadCount
|
badgeCount: totalUnreadCount
|
||||||
@@ -612,12 +624,16 @@ final class MailStore {
|
|||||||
let targets = folderID == "unified:trash"
|
let targets = folderID == "unified:trash"
|
||||||
? accounts.flatMap { allFolders(folders[$0.id] ?? []) }.filter(\.isTrash)
|
? accounts.flatMap { allFolders(folders[$0.id] ?? []) }.filter(\.isTrash)
|
||||||
: [folder(id: folderID)].compactMap { $0 }.filter(\.isTrash)
|
: [folder(id: folderID)].compactMap { $0 }.filter(\.isTrash)
|
||||||
await mutate {
|
await mutate(refreshAfter: false) {
|
||||||
for folder in targets {
|
for folder in targets {
|
||||||
let client = try await self.client(for: folder.accountID)
|
let client = try await self.client(for: folder.accountID)
|
||||||
defer { client.close() }
|
defer { client.close() }
|
||||||
let inventory = try await client.inventory(folder: folder, writable: true)
|
let validity = try await client.emptyTrash(folder: folder) { validity, removed in
|
||||||
try await client.remove(uids: Array(inventory.flags.keys))
|
try self.storage().applyRemovals(folderID: folder.id, validity: validity, uids: removed)
|
||||||
|
}
|
||||||
|
// Also retire stale local entries that were already absent on the server.
|
||||||
|
try self.storage().reconcile(folderID: folder.id,
|
||||||
|
inventory: MailboxInventory(validity: validity, flags: [:]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,8 +727,8 @@ final class MailStore {
|
|||||||
|
|
||||||
func configureIdle() {
|
func configureIdle() {
|
||||||
idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } }
|
idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } }
|
||||||
idleMonitor.configure(accounts: accounts, enabled: true) { [weak self] id in
|
idleMonitor.configure(accounts: accounts, enabled: true) { [weak self] id, change in
|
||||||
self?.scheduleIdleRefresh(accountID: id)
|
self?.scheduleIdleRefresh(accountID: id, change: change)
|
||||||
} report: { [weak self] id, status in
|
} report: { [weak self] id, status in
|
||||||
self?.idleStatuses[id] = status
|
self?.idleStatuses[id] = status
|
||||||
} authenticationFailed: { [weak self] id in
|
} authenticationFailed: { [weak self] id in
|
||||||
@@ -720,9 +736,10 @@ final class MailStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleIdleRefresh(accountID: UUID) {
|
private func scheduleIdleRefresh(accountID: UUID, change: IMAPClient.IdleChange) {
|
||||||
guard accounts.contains(where: { $0.id == accountID }) else { return }
|
guard accounts.contains(where: { $0.id == accountID }) else { return }
|
||||||
pendingIdleAccounts.insert(accountID)
|
if change == .refresh { pendingIdleAccounts.insert(accountID) }
|
||||||
|
else { pendingIdleChanges.append((accountID, change)) }
|
||||||
guard idleRefreshTask == nil else { return }
|
guard idleRefreshTask == nil else { return }
|
||||||
let generation = UUID()
|
let generation = UUID()
|
||||||
idleRefreshGeneration = generation
|
idleRefreshGeneration = generation
|
||||||
@@ -731,12 +748,18 @@ final class MailStore {
|
|||||||
defer { if self.idleRefreshGeneration == generation { self.idleRefreshTask = nil } }
|
defer { if self.idleRefreshGeneration == generation { self.idleRefreshTask = nil } }
|
||||||
// Coalesce bursts and retain notifications arriving during another synchronization.
|
// Coalesce bursts and retain notifications arriving during another synchronization.
|
||||||
try? await Task.sleep(for: .milliseconds(400))
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
while !Task.isCancelled, !self.pendingIdleAccounts.isEmpty {
|
while !Task.isCancelled, !self.pendingIdleAccounts.isEmpty || !self.pendingIdleChanges.isEmpty {
|
||||||
if self.isBusy {
|
if self.isBusy {
|
||||||
do { try await Task.sleep(for: .milliseconds(200)) } catch { return }
|
do { try await Task.sleep(for: .milliseconds(200)) } catch { return }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guard let id = self.pendingIdleAccounts.first else { return }
|
let changes = self.pendingIdleChanges
|
||||||
|
self.pendingIdleChanges.removeAll()
|
||||||
|
for (id, change) in changes {
|
||||||
|
do { try self.applyIdleChange(accountID: id, change: change) }
|
||||||
|
catch { self.pendingIdleAccounts.insert(id) }
|
||||||
|
}
|
||||||
|
guard let id = self.pendingIdleAccounts.first else { continue }
|
||||||
self.pendingIdleAccounts.remove(id)
|
self.pendingIdleAccounts.remove(id)
|
||||||
guard self.accounts.contains(where: { $0.id == id }) else { continue }
|
guard self.accounts.contains(where: { $0.id == id }) else { continue }
|
||||||
await self.refresh(folderID: self.selectedFolderID, accountID: id, inboxOnly: true)
|
await self.refresh(folderID: self.selectedFolderID, accountID: id, inboxOnly: true)
|
||||||
@@ -744,6 +767,28 @@ final class MailStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply IDLE echoes after in-flight mutations have committed their local changes.
|
||||||
|
func applyIdleChange(accountID: UUID, change: IMAPClient.IdleChange) throws {
|
||||||
|
guard let inbox = allFolders(folders[accountID] ?? []).first(where: \.isInbox) else { return }
|
||||||
|
let validity: String
|
||||||
|
let uid: UInt64
|
||||||
|
let flags: [String]?
|
||||||
|
switch change {
|
||||||
|
case .refresh:
|
||||||
|
return
|
||||||
|
case .removed(let value, let identifier):
|
||||||
|
(validity, uid, flags) = (value, identifier, nil)
|
||||||
|
case .flags(let value, let identifier, let values):
|
||||||
|
(validity, uid, flags) = (value, identifier, values)
|
||||||
|
}
|
||||||
|
let id = inbox.id + ":" + validity + ":" + String(uid)
|
||||||
|
// Local moves/deletions already removed the source. Do not revive it.
|
||||||
|
guard let record = try? storage().record(id: id) else { return }
|
||||||
|
if let flags, Set(record.flags) == Set(flags) { return }
|
||||||
|
try storage().applyMessageChange(id: id, flags: flags, removed: flags == nil)
|
||||||
|
try updateLocalPresentation()
|
||||||
|
}
|
||||||
|
|
||||||
func requestPassword(accountID: UUID) {
|
func requestPassword(accountID: UUID) {
|
||||||
guard accounts.contains(where: { $0.id == accountID }) else { return }
|
guard accounts.contains(where: { $0.id == accountID }) else { return }
|
||||||
if authenticationAccountID == nil { authenticationAccountID = accountID }
|
if authenticationAccountID == nil { authenticationAccountID = accountID }
|
||||||
|
|||||||
@@ -4,6 +4,78 @@ import Testing
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct MailActionTests {
|
struct MailActionTests {
|
||||||
|
@Test func emptyTrashOnlyListsUIDsAndDeletesConfirmedBatches() async throws {
|
||||||
|
let trash = MailFolder(accountID: UUID(), path: "Trash", name: "Trash")
|
||||||
|
var commands: [String] = []
|
||||||
|
var removed: [UInt64] = []
|
||||||
|
let validity = try await IMAPClient.emptyTrash(folder: trash) { command in
|
||||||
|
commands.append(command)
|
||||||
|
switch command {
|
||||||
|
case "SELECT \"Trash\"": return .init(lines: ["* OK [UIDVALIDITY 7]"])
|
||||||
|
case "UID SEARCH ALL": return .init(lines: ["* SEARCH 10 20"])
|
||||||
|
case "CAPABILITY": return .init(lines: ["* CAPABILITY IMAP4rev1 UIDPLUS"])
|
||||||
|
case "UID STORE 10,20 +FLAGS.SILENT (\\Deleted)", "UID EXPUNGE 10,20":
|
||||||
|
return .init()
|
||||||
|
default:
|
||||||
|
Issue.record("Unexpected command: \(command)")
|
||||||
|
throw MailError.message("Unexpected command")
|
||||||
|
}
|
||||||
|
} onRemoved: { validity, uids in
|
||||||
|
#expect(validity == "7")
|
||||||
|
#expect(commands.last == "UID EXPUNGE 10,20")
|
||||||
|
removed += uids
|
||||||
|
}
|
||||||
|
#expect(validity == "7")
|
||||||
|
#expect(removed == [10, 20])
|
||||||
|
#expect(commands.count == 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func partialTrashFailureKeepsConfirmedDeletionsAndOtherFolders() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let archive = try MailArchive(root: root)
|
||||||
|
let account = UUID()
|
||||||
|
let trash = MailFolder(accountID: account, path: "Trash", name: "Trash")
|
||||||
|
let inbox = MailFolder(accountID: account, path: "INBOX", name: "Inbox")
|
||||||
|
let raw = Data("Subject: Fixture\r\n\r\nBody".utf8)
|
||||||
|
// Only a subset is cached locally; absent local records must not stop server deletion.
|
||||||
|
for uid in ["1", "201"] {
|
||||||
|
try archive.store(raw: raw, folderID: trash.id, validity: "7", uid: uid, flags: [])
|
||||||
|
}
|
||||||
|
try archive.store(raw: raw, folderID: inbox.id, validity: "7", uid: "1", flags: [])
|
||||||
|
try archive.store(raw: raw, folderID: trash.id, validity: "8", uid: "1", flags: [])
|
||||||
|
await #expect(throws: (any Error).self) {
|
||||||
|
_ = try await IMAPClient.emptyTrash(folder: trash) { command in
|
||||||
|
if command.hasPrefix("SELECT") { return .init(lines: ["* OK [UIDVALIDITY 7]"]) }
|
||||||
|
if command == "UID SEARCH ALL" {
|
||||||
|
return .init(lines: ["* SEARCH " + (1...201).map(String.init).joined(separator: " ")])
|
||||||
|
}
|
||||||
|
if command == "CAPABILITY" { return .init(lines: ["* CAPABILITY UIDPLUS"]) }
|
||||||
|
if command == "UID EXPUNGE 201" { throw MailError.message("Connection lost") }
|
||||||
|
return .init()
|
||||||
|
} onRemoved: { validity, uids in
|
||||||
|
try archive.applyRemovals(folderID: trash.id, validity: validity, uids: uids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(Set(try archive.messages(folderID: trash.id).map(\.id)) ==
|
||||||
|
[trash.id + ":7:201", trash.id + ":8:1"])
|
||||||
|
#expect(try archive.messages(folderID: inbox.id).count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyTrashRequiresUIDPLUSBeforeDeleting() async throws {
|
||||||
|
let trash = MailFolder(accountID: UUID(), path: "Trash", name: "Trash")
|
||||||
|
var commands: [String] = []
|
||||||
|
await #expect(throws: (any Error).self) {
|
||||||
|
_ = try await IMAPClient.emptyTrash(folder: trash) { command in
|
||||||
|
commands.append(command)
|
||||||
|
if command.hasPrefix("SELECT") { return .init(lines: ["* OK [UIDVALIDITY 7]"]) }
|
||||||
|
if command == "UID SEARCH ALL" { return .init(lines: ["* SEARCH 1"]) }
|
||||||
|
return .init(lines: ["* CAPABILITY IMAP4rev1"])
|
||||||
|
} onRemoved: { _, _ in Issue.record("No deletion was confirmed") }
|
||||||
|
}
|
||||||
|
#expect(commands == ["SELECT \"Trash\"", "UID SEARCH ALL", "CAPABILITY"])
|
||||||
|
}
|
||||||
|
|
||||||
@Test func ccAndBccStaySeparateFromEnvelope() throws {
|
@Test func ccAndBccStaySeparateFromEnvelope() throws {
|
||||||
let raw = try MIME.compose(from: "sender@example.com", to: ["to@example.com"],
|
let raw = try MIME.compose(from: "sender@example.com", to: ["to@example.com"],
|
||||||
cc: ["copy@example.com"], bcc: ["hidden@example.com"], subject: "CC BCC", body: "Hello", attachments: [])
|
cc: ["copy@example.com"], bcc: ["hidden@example.com"], subject: "CC BCC", body: "Hello", attachments: [])
|
||||||
|
|||||||
@@ -140,6 +140,122 @@ struct MailArchiveTests {
|
|||||||
#expect(try reopened.messages(folderID: inboxA.id).count == 1)
|
#expect(try reopened.messages(folderID: inboxA.id).count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func unchangedLargeMailboxNeedsOnlyTwoCommands() async throws {
|
||||||
|
let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox")
|
||||||
|
let flags = Dictionary(uniqueKeysWithValues: (1...10_000).map { (UInt64($0), ["\\Seen"]) })
|
||||||
|
let previous = MailboxInventory(validity: "7", flags: flags, highestModSequence: 100)
|
||||||
|
var commands: [String] = []
|
||||||
|
let result = try await IMAPClient.fetchInventory(folder: folder, previous: previous,
|
||||||
|
supportsCONDSTORE: true) { command in
|
||||||
|
commands.append(command)
|
||||||
|
if command == "EXAMINE \"INBOX\" (CONDSTORE)" {
|
||||||
|
return .init(lines: ["* OK [UIDVALIDITY 7]", "* OK [HIGHESTMODSEQ 100]"])
|
||||||
|
}
|
||||||
|
#expect(command == "UID SEARCH ALL")
|
||||||
|
return .init(lines: ["* SEARCH " + (1...10_000).map(String.init).joined(separator: " ")])
|
||||||
|
}
|
||||||
|
#expect(result == previous)
|
||||||
|
#expect(commands.count == 2) // Previously: EXAMINE + SEARCH + 50 flag batches.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func incrementalInventoryMergesFlagsArrivalsAndDeletions() async throws {
|
||||||
|
let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox")
|
||||||
|
let previous = MailboxInventory(validity: "7", flags: [1: [], 2: [], 3: ["\\Flagged"]],
|
||||||
|
highestModSequence: 100)
|
||||||
|
var commands: [String] = []
|
||||||
|
let result = try await IMAPClient.fetchInventory(folder: folder, previous: previous,
|
||||||
|
supportsCONDSTORE: true) { command in
|
||||||
|
commands.append(command)
|
||||||
|
if command.hasPrefix("EXAMINE") {
|
||||||
|
return .init(lines: ["* OK [UIDVALIDITY 7]", "* OK [HIGHESTMODSEQ 110]"])
|
||||||
|
}
|
||||||
|
if command == "UID SEARCH ALL" { return .init(lines: ["* SEARCH 1 3 4"]) }
|
||||||
|
#expect(command == "UID FETCH 1:* (UID FLAGS) (CHANGEDSINCE 100)")
|
||||||
|
return .init(lines: ["* 1 FETCH (UID 1 FLAGS (\\Seen) MODSEQ (105))",
|
||||||
|
"* 3 FETCH (UID 4 FLAGS () MODSEQ (110))"])
|
||||||
|
}
|
||||||
|
#expect(result.flags == [1: ["\\Seen"], 3: ["\\Flagged"], 4: []])
|
||||||
|
#expect(result.highestModSequence == 110)
|
||||||
|
#expect(commands.count == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["unsupported", "nomodseq", "validity", "rollback", "firstSync"])
|
||||||
|
func inventoryFallsBackToFullFlags(reason: String) async throws {
|
||||||
|
let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox")
|
||||||
|
let previous = MailboxInventory(validity: "7", flags: [1: ["\\Seen"]], highestModSequence: 100)
|
||||||
|
let validity = reason == "validity" ? "8" : "7"
|
||||||
|
var commands: [String] = []
|
||||||
|
let result = try await IMAPClient.fetchInventory(folder: folder,
|
||||||
|
previous: reason == "firstSync" ? nil : previous, supportsCONDSTORE: reason != "unsupported") { command in
|
||||||
|
commands.append(command)
|
||||||
|
if command.hasPrefix("EXAMINE") {
|
||||||
|
let sequence = reason == "nomodseq" ? "* OK [NOMODSEQ]" :
|
||||||
|
"* OK [HIGHESTMODSEQ \(reason == "rollback" ? 90 : 110)]"
|
||||||
|
return .init(lines: ["* OK [UIDVALIDITY \(validity)]", sequence])
|
||||||
|
}
|
||||||
|
if command == "UID SEARCH ALL" { return .init(lines: ["* SEARCH 1"]) }
|
||||||
|
#expect(command == "UID FETCH 1:* (UID FLAGS)")
|
||||||
|
return .init(lines: ["* 1 FETCH (UID 1 FLAGS ())"])
|
||||||
|
}
|
||||||
|
#expect(result.validity == validity)
|
||||||
|
#expect(result.flags == [1: []])
|
||||||
|
#expect(commands.count == 3)
|
||||||
|
if reason == "unsupported" || reason == "nomodseq" {
|
||||||
|
#expect(result.highestModSequence == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unchangedModSequenceStillDetectsDeletions() async throws {
|
||||||
|
let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox")
|
||||||
|
let previous = MailboxInventory(validity: "7", flags: [1: [], 2: []], highestModSequence: 100)
|
||||||
|
let result = try await IMAPClient.fetchInventory(folder: folder, previous: previous,
|
||||||
|
supportsCONDSTORE: true) { command in
|
||||||
|
if command.hasPrefix("EXAMINE") {
|
||||||
|
return .init(lines: ["* OK [UIDVALIDITY 7]", "* OK [HIGHESTMODSEQ 100]"])
|
||||||
|
}
|
||||||
|
#expect(command == "UID SEARCH ALL")
|
||||||
|
return .init(lines: ["* SEARCH 2"])
|
||||||
|
}
|
||||||
|
#expect(result.flags == [2: []])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func incompleteDeltaIsRejected() async throws {
|
||||||
|
let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox")
|
||||||
|
let previous = MailboxInventory(validity: "7", flags: [1: []], highestModSequence: 100)
|
||||||
|
await #expect(throws: (any Error).self) {
|
||||||
|
_ = try await IMAPClient.fetchInventory(folder: folder, previous: previous,
|
||||||
|
supportsCONDSTORE: true) { command in
|
||||||
|
if command.hasPrefix("EXAMINE") {
|
||||||
|
return .init(lines: ["* OK [UIDVALIDITY 7]", "* OK [HIGHESTMODSEQ 110]"])
|
||||||
|
}
|
||||||
|
if command == "UID SEARCH ALL" { return .init(lines: ["* SEARCH 1 2"]) }
|
||||||
|
return .init(lines: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func syncSnapshotSurvivesRelaunchAndLocalResetStillDownloadsBodies() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let account = UUID()
|
||||||
|
let folder = MailFolder(accountID: account, path: "INBOX", name: "Inbox")
|
||||||
|
let inventory = MailboxInventory(validity: "7", flags: [1: []], highestModSequence: 100)
|
||||||
|
do {
|
||||||
|
let archive = try MailArchive(root: root)
|
||||||
|
try archive.store(raw: Data("Subject: Cached\r\n\r\nBody".utf8),
|
||||||
|
folderID: folder.id, validity: "7", uid: "1", flags: [], visible: false)
|
||||||
|
#expect(try archive.reconcile(folderID: folder.id, inventory: inventory))
|
||||||
|
#expect(try !archive.reconcile(folderID: folder.id, inventory: inventory))
|
||||||
|
}
|
||||||
|
let reopened = try MailArchive(root: root)
|
||||||
|
#expect(try reopened.syncInventory(folderID: folder.id) == inventory)
|
||||||
|
#expect(try reopened.knownUIDs(folderID: folder.id, validity: "7") == [1])
|
||||||
|
_ = try reopened.clearLocalMessages(accountID: account)
|
||||||
|
let snapshot = try #require(try reopened.syncInventory(folderID: folder.id))
|
||||||
|
let known = try reopened.knownUIDs(folderID: folder.id, validity: snapshot.validity)
|
||||||
|
#expect(Set(snapshot.flags.keys).subtracting(known) == [1])
|
||||||
|
}
|
||||||
|
|
||||||
@Test func imapInventoryParsing() throws {
|
@Test func imapInventoryParsing() throws {
|
||||||
#expect(try IMAPClient.uidValidity(from: ["* OK [UIDVALIDITY 123] UIDs valid"]) == "123")
|
#expect(try IMAPClient.uidValidity(from: ["* OK [UIDVALIDITY 123] UIDs valid"]) == "123")
|
||||||
#expect(throws: (any Error).self) { try IMAPClient.uidValidity(from: ["* OK hello"]) }
|
#expect(throws: (any Error).self) { try IMAPClient.uidValidity(from: ["* OK hello"]) }
|
||||||
|
|||||||
@@ -16,6 +16,54 @@ struct MailIdleTests {
|
|||||||
#expect(!IMAPClient.isAuthenticationFailure("* BYE Connection closed"))
|
#expect(!IMAPClient.isAuthenticationFailure("* BYE Connection closed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func idleChangesTrackSequenceNumbersWithoutRefreshing() {
|
||||||
|
var mailbox = IMAPClient.IdleMailbox(validity: "7", uids: [10, 20, 30])
|
||||||
|
#expect(mailbox.receive("* 2 FETCH (FLAGS (\\Seen))") ==
|
||||||
|
.flags(validity: "7", uid: 20, flags: ["\\Seen"]))
|
||||||
|
#expect(mailbox.receive("* 2 EXPUNGE") == .removed(validity: "7", uid: 20))
|
||||||
|
#expect(mailbox.receive("* 2 EXPUNGE") == .removed(validity: "7", uid: 30))
|
||||||
|
#expect(mailbox.receive("* 1 EXISTS") == nil)
|
||||||
|
#expect(mailbox.receive("* 2 EXISTS") == .refresh)
|
||||||
|
#expect(mailbox.receive("* 2 FETCH (UID 40 FLAGS ())") ==
|
||||||
|
.flags(validity: "7", uid: 40, flags: []))
|
||||||
|
#expect(mailbox.receive("* 2 EXPUNGE") == .removed(validity: "7", uid: 40))
|
||||||
|
#expect(mailbox.receive("* 9 EXPUNGE") == .refresh)
|
||||||
|
#expect(mailbox.receive("* OK Still here") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func idleEchoAfterMoveOrDeletionDoesNotReloadOrReviveSource() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let archive = try MailArchive(root: root)
|
||||||
|
let account = MailAccount(name: "A")
|
||||||
|
let inbox = MailFolder(accountID: account.id, path: "INBOX", name: "Inbox")
|
||||||
|
let trash = MailFolder(accountID: account.id, path: "Trash", name: "Trash")
|
||||||
|
try archive.saveConfiguration(ArchiveConfiguration(accounts: [account],
|
||||||
|
folders: [account.id: [inbox, trash]], recipients: []))
|
||||||
|
let raw = Data("Subject: Test\r\n\r\nBody".utf8)
|
||||||
|
try archive.store(raw: raw, folderID: inbox.id, validity: "7", uid: "10", flags: [])
|
||||||
|
let store = MailStore(archive: archive)
|
||||||
|
let id = inbox.id + ":7:10"
|
||||||
|
try archive.applyMessageChange(id: id, destination: trash.id,
|
||||||
|
assigned: .init(validity: "8", uid: "50"))
|
||||||
|
try store.applyIdleChange(accountID: account.id,
|
||||||
|
change: .flags(validity: "7", uid: 10, flags: ["\\Deleted"]))
|
||||||
|
try store.applyIdleChange(accountID: account.id, change: .removed(validity: "7", uid: 10))
|
||||||
|
#expect(store.archiveRevision == 0)
|
||||||
|
#expect(try archive.messages(folderID: inbox.id).isEmpty)
|
||||||
|
#expect(try archive.messages(folderID: trash.id).count == 1)
|
||||||
|
#expect(!store.isBusy)
|
||||||
|
|
||||||
|
// Changes from another client still update the archive without a network refresh.
|
||||||
|
try archive.store(raw: raw, folderID: inbox.id, validity: "7", uid: "20", flags: [])
|
||||||
|
try store.applyIdleChange(accountID: account.id,
|
||||||
|
change: .flags(validity: "7", uid: 20, flags: ["\\Seen"]))
|
||||||
|
#expect(try archive.messages(folderID: inbox.id).first?.isRead == true)
|
||||||
|
try store.applyIdleChange(accountID: account.id, change: .removed(validity: "7", uid: 20))
|
||||||
|
#expect(try archive.messages(folderID: inbox.id).isEmpty)
|
||||||
|
#expect(store.archiveRevision == 2)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func queuesPasswordPromptsPerAccount() throws {
|
@Test func queuesPasswordPromptsPerAccount() throws {
|
||||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
|||||||
Reference in New Issue
Block a user