diff --git a/Apps/MagnumOpus/ContentView.swift b/Apps/MagnumOpus/ContentView.swift index 3f5e485..e554661 100644 --- a/Apps/MagnumOpus/ContentView.swift +++ b/Apps/MagnumOpus/ContentView.swift @@ -111,7 +111,7 @@ struct ContentView: View { @ViewBuilder private var statusBanner: some View { switch viewModel.syncState { - case .error(let message): + case .error(_): HStack { Image(systemName: "wifi.slash") Text("Offline — showing cached mail") diff --git a/Apps/MagnumOpus/ViewModels/MailViewModel.swift b/Apps/MagnumOpus/ViewModels/MailViewModel.swift index 05abfe8..77778c3 100644 --- a/Apps/MagnumOpus/ViewModels/MailViewModel.swift +++ b/Apps/MagnumOpus/ViewModels/MailViewModel.swift @@ -59,6 +59,7 @@ final class MailViewModel { var selectedPerspective: Perspective = .inbox var items: [ItemSummary] = [] + var selectedItem: ItemSummary? var perspectiveCounts: [Perspective: Int] = [:] private var imapClientProvider: (() -> IMAPClient)? @@ -134,7 +135,15 @@ final class MailViewModel { imapClient: imapClient, store: mailStore, actionQueue: queue, - taskStore: ts + taskStore: ts, + credentials: credentials, + imapClientProvider: { + IMAPClient( + host: capturedImapHost, + port: capturedImapPort, + credentials: capturedCredentials + ) + } ) } @@ -471,7 +480,7 @@ final class MailViewModel { // MARK: - GTD Triage Actions (unified items) func deferItem(_ item: ItemSummary, until date: Date?) { - guard let store, let accountConfig else { return } + guard let store, let _ = accountConfig else { return } do { switch item { case .email(let msg): @@ -498,7 +507,7 @@ final class MailViewModel { } func fileItem(_ item: ItemSummary, label: LabelInfo) { - guard let store, let accountConfig else { return } + guard let store, let _ = accountConfig else { return } do { switch item { case .email(let msg): @@ -674,7 +683,7 @@ final class MailViewModel { deferUntil: record.deferUntil.flatMap { VTODOParser.parseDate($0) }, createdAt: VTODOParser.parseDate(record.createdAt) ?? Date.distantPast, linkedMessageId: record.linkedMessageId, - categories: [], + categories: (try? store?.labelsForItem(itemType: "task", itemId: record.id).map(\.name)) ?? [], isSomeday: record.isSomeday ) } diff --git a/Apps/MagnumOpus/Views/ThreadListView.swift b/Apps/MagnumOpus/Views/ThreadListView.swift index 4c51fa1..cdcf611 100644 --- a/Apps/MagnumOpus/Views/ThreadListView.swift +++ b/Apps/MagnumOpus/Views/ThreadListView.swift @@ -8,15 +8,15 @@ struct ThreadListView: View { @State var showDeferPicker = false @State var showLabelPicker = false - /// Whether the current view shows a GTD perspective (vs. a raw mailbox folder) + /// Whether the current view shows a GTD perspective (vs. a raw mailbox folder). + /// Items being empty does not mean we're in folder mode — an empty GTD perspective is still GTD. private var isGTDPerspective: Bool { - // GTD perspectives are active when items are loaded - !viewModel.items.isEmpty || viewModel.selectedMailbox == nil + viewModel.selectedMailbox == nil } var body: some View { Group { - if !viewModel.items.isEmpty { + if isGTDPerspective { itemListView } else { threadListView @@ -52,7 +52,12 @@ struct ThreadListView: View { // MARK: - Unified Item List (GTD perspectives) private var itemListView: some View { - List(viewModel.items) { item in + List(viewModel.items, selection: Binding( + get: { viewModel.selectedItem?.id }, + set: { newId in + viewModel.selectedItem = viewModel.items.first { $0.id == newId } + } + )) { item in ItemRow(item: item) .tag(item.id) } @@ -239,9 +244,11 @@ struct ThreadListView: View { // MARK: - Helpers private var selectedItemSummary: ItemSummary? { - // In GTD mode, use the first item (or a future selection mechanism) - // For now, if there's a selected thread, build the email item from it - if let thread = viewModel.selectedThread, + if let selected = viewModel.selectedItem { + return selected + } + // Fallback: in folder view, use selected thread + if let _ = viewModel.selectedThread, let msg = viewModel.messages.first { return .email(msg) } diff --git a/Packages/MagnumOpusCore/Package.swift b/Packages/MagnumOpusCore/Package.swift index bb9c97e..8dc3b13 100644 --- a/Packages/MagnumOpusCore/Package.swift +++ b/Packages/MagnumOpusCore/Package.swift @@ -38,7 +38,12 @@ let package = Package( "Models", .product(name: "NIOIMAP", package: "swift-nio-imap"), .product(name: "NIOSSL", package: "swift-nio-ssl"), - ] + ], + // NIO ecosystem hasn't fully adopted Swift 6 strict concurrency; + // NIOSSLClientHandler's Sendable conformance is marked unavailable upstream. + // NIOSSLClientHandler has @available(*, unavailable) Sendable conformance upstream; + // suppress until NIO ecosystem completes Swift 6 adoption. + swiftSettings: [.unsafeFlags(["-suppress-warnings"])] ), .target( name: "SMTPClient", @@ -46,7 +51,8 @@ let package = Package( "Models", .product(name: "NIO", package: "swift-nio"), .product(name: "NIOSSL", package: "swift-nio-ssl"), - ] + ], + swiftSettings: [.unsafeFlags(["-suppress-warnings"])] ), .target(name: "MIMEParser"), .target( diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift index 15f00a3..260df32 100644 --- a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift @@ -152,23 +152,24 @@ public actor IMAPClient: IMAPClientProtocol { } public func fetchBody(uid: Int) async throws -> (text: String?, html: String?) { - guard var runner else { throw IMAPError.notConnected } - let uidValue = UID(rawValue: UInt32(uid)) - let range = MessageIdentifierRange(uidValue...uidValue) - let set = MessageIdentifierSetNonEmpty(range: range) - let responses = try await runner.run(.uidFetch( - .set(set), - [.bodySection(peek: true, SectionSpecifier(kind: .text), nil)], - [] - )) - self.runner = runner - return parseBodyResponse(responses) + // Fetch the complete RFC822 message and parse with MIME-aware logic + let raw = try await fetchFullMessage(uid: uid) + guard !raw.isEmpty else { return (text: nil, html: nil) } + return parseBodyResponse(raw) } // MARK: - Write operations public func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws { guard var runner else { throw IMAPError.notConnected } + + // SELECT the mailbox before UID STORE — IMAP requires it + let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: mailbox)))) + self.runner = runner + guard selectResponses.contains(where: { isOKTagged($0) }) else { + throw IMAPError.unexpectedResponse("SELECT \(mailbox) failed") + } + let uidValue = UID(rawValue: UInt32(uid)) let range = MessageIdentifierRange(uidValue...uidValue) let set = MessageIdentifierSetNonEmpty(range: range) @@ -516,28 +517,21 @@ public actor IMAPClient: IMAPClientProtocol { return String(buffer: bodyBuffer) } - private func parseBodyResponse(_ responses: [Response]) -> (text: String?, html: String?) { - var bodyBuffer = ByteBuffer() - - for response in responses { - if case .fetch(let fetchResponse) = response { - switch fetchResponse { - case .streamingBytes(let bytes): - var mutableBytes = bytes - bodyBuffer.writeBuffer(&mutableBytes) - default: - break - } - } + private func parseBodyResponse(_ raw: String) -> (text: String?, html: String?) { + // Extract body after blank line (headers end at \r\n\r\n) + let bodyString: String + if let range = raw.range(of: "\r\n\r\n") { + bodyString = String(raw[range.upperBound...]) + } else if let range = raw.range(of: "\n\n") { + bodyString = String(raw[range.upperBound...]) + } else { + bodyString = raw } - guard bodyBuffer.readableBytes > 0 else { + guard !bodyString.isEmpty else { return (text: nil, html: nil) } - let bodyString = String(buffer: bodyBuffer) - - // Simple heuristic: if the body contains HTML tags, treat as HTML let lowered = bodyString.lowercased() if lowered.contains(" SMTPResponse { let handler = responseHandler let hostname = host @@ -26,11 +30,11 @@ actor SMTPConnection { let bootstrap: ClientBootstrap if security == .ssl { let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration()) + nonisolated(unsafe) let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: hostname) bootstrap = ClientBootstrap(group: group) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .channelInitializer { channel in - let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: hostname) - return channel.pipeline.addHandlers([sslHandler, handler]) + channel.pipeline.addHandlers([sslHandler, handler]) } } else { // STARTTLS: start plain, upgrade later diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift index 370394e..6e4dc9b 100644 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift @@ -42,18 +42,34 @@ public actor ActionQueue { // MARK: - Flush /// Dispatch all pending actions in creation order. Called before IMAP fetch. + /// Reuses a single IMAP connection for all IMAP actions in the batch. public func flush() async { - guard let records = try? store.pendingActions(accountId: accountId) else { return } + guard let records = try? store.pendingActions(accountId: accountId), + !records.isEmpty else { return } + + // Create one shared IMAP client for all IMAP actions in this flush + let sharedImapClient = imapClientProvider() + var imapConnected = false + + defer { + if imapConnected { + Task { try? await sharedImapClient.disconnect() } + } + } for record in records { guard let action = decodeAction(record) else { - // Corrupt record — remove it try? store.deletePendingAction(id: record.id) continue } do { - try await dispatch(action) + // Connect shared IMAP client on first IMAP action + if !imapConnected, action.payload.requiresIMAP { + try await sharedImapClient.connect() + imapConnected = true + } + try await dispatch(action, sharedImapClient: sharedImapClient) try store.deletePendingAction(id: record.id) } catch { var updated = record @@ -88,26 +104,25 @@ public actor ActionQueue { } } - private func dispatch(_ action: PendingAction) async throws { + private func dispatch(_ action: PendingAction, sharedImapClient: (any IMAPClientProtocol)? = nil) async throws { switch action.payload { case .setFlags(let uid, let mailbox, let add, let remove): - let client = imapClientProvider() - try await client.connect() - defer { Task { try? await client.disconnect() } } + let client = sharedImapClient ?? imapClientProvider() + if sharedImapClient == nil { try await client.connect() } + defer { if sharedImapClient == nil { Task { try? await client.disconnect() } } } try await client.storeFlags(uid: uid, mailbox: mailbox, add: add, remove: remove) case .move(let uid, let from, let to): - let client = imapClientProvider() - try await client.connect() - defer { Task { try? await client.disconnect() } } + let client = sharedImapClient ?? imapClientProvider() + if sharedImapClient == nil { try await client.connect() } + defer { if sharedImapClient == nil { Task { try? await client.disconnect() } } } try await client.moveMessage(uid: uid, from: from, to: to) case .delete(let uid, let mailbox, let trashMailbox): - let client = imapClientProvider() - try await client.connect() - defer { Task { try? await client.disconnect() } } + let client = sharedImapClient ?? imapClientProvider() + if sharedImapClient == nil { try await client.connect() } + defer { if sharedImapClient == nil { Task { try? await client.disconnect() } } } if mailbox == trashMailbox { - // Already in trash — permanent delete try await client.storeFlags(uid: uid, mailbox: mailbox, add: ["\\Deleted"], remove: []) try await client.expunge(mailbox: mailbox) } else { @@ -122,9 +137,9 @@ public actor ActionQueue { try await client.send(message: message) case .append(let mailbox, let messageData, let flags): - let client = imapClientProvider() - try await client.connect() - defer { Task { try? await client.disconnect() } } + let client = sharedImapClient ?? imapClientProvider() + if sharedImapClient == nil { try await client.connect() } + defer { if sharedImapClient == nil { Task { try? await client.disconnect() } } } guard let data = messageData.data(using: .utf8) else { throw ActionQueueError.invalidMessageData } diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift index 3ace171..e8a2e91 100644 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift @@ -47,4 +47,11 @@ public enum ActionPayload: Sendable, Codable { case .append: return .append } } + + public var requiresIMAP: Bool { + switch self { + case .setFlags, .move, .delete, .append: true + case .send: false + } + } } diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift index db6f38b..c9cb9e7 100644 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift @@ -10,6 +10,7 @@ import TaskStore public final class SyncCoordinator { private let accountConfig: AccountConfig private let imapClient: any IMAPClientProtocol + private let imapClientProvider: (@Sendable () -> any IMAPClientProtocol)? private let store: MailStore private let actionQueue: ActionQueue? private let taskStore: TaskStore? @@ -27,7 +28,8 @@ public final class SyncCoordinator { store: MailStore, actionQueue: ActionQueue? = nil, taskStore: TaskStore? = nil, - credentials: Credentials? = nil + credentials: Credentials? = nil, + imapClientProvider: (@Sendable () -> any IMAPClientProtocol)? = nil ) { self.accountConfig = accountConfig self.imapClient = imapClient @@ -35,6 +37,7 @@ public final class SyncCoordinator { self.actionQueue = actionQueue self.taskStore = taskStore self.credentials = credentials + self.imapClientProvider = imapClientProvider } public func onEvent(_ handler: @escaping (SyncEvent) -> Void) { @@ -65,7 +68,7 @@ public final class SyncCoordinator { } private func resurfaceDeferrals() { - let now = ISO8601DateFormatter().string(from: Date()) + let now = VTODOParser.formatDateOnly(Date()) do { // Resurface deferred emails: delete deferral records so messages reappear in Inbox let expiredDeferrals = try store.expiredDeferrals(beforeDate: now) @@ -359,10 +362,20 @@ public final class SyncCoordinator { throw AttachmentError.noSectionPath } - // Fetch the section data from IMAP - try await imapClient.connect() - let data = try await imapClient.fetchSection(uid: messageUid, mailbox: mailboxName, section: sectionPath) - try? await imapClient.disconnect() + // Use a separate IMAP connection to avoid interfering with the sync client + guard let provider = imapClientProvider else { + throw AttachmentError.noSectionPath + } + let client = provider() + try await client.connect() + let data: Data + do { + data = try await client.fetchSection(uid: messageUid, mailbox: mailboxName, section: sectionPath) + try? await client.disconnect() + } catch { + try? await client.disconnect() + throw error + } // Build cache directory let cacheDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! @@ -396,12 +409,12 @@ public final class SyncCoordinator { stopSync() syncTask = Task { [weak self] in while !Task.isCancelled { - try? await self?.syncNow() do { try await Task.sleep(for: interval) } catch { break } + try? await self?.syncNow() } } } diff --git a/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift b/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift index 13c8774..4d83d83 100644 --- a/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift +++ b/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift @@ -64,7 +64,7 @@ public struct TaskStore: Sendable { try icsContent.write(to: filePath, atomically: true, encoding: .utf8) // Upsert cache record - var record = TaskRecord( + let record = TaskRecord( id: id, accountId: accountId, summary: summary, @@ -172,7 +172,7 @@ public struct TaskStore: Sendable { linkedMessageId = nil } - var record = TaskRecord( + let record = TaskRecord( id: uid, accountId: accountId, summary: summary,