fix code review issues: deferral date format, storeFlags SELECT, event loop leaks, GTD selection tracking

- fix deferral resurfacing using VTODOParser.formatDateOnly instead of ISO8601 (date format mismatch)
- add SELECT mailbox before UID STORE in storeFlags (IMAP protocol requirement)
- pass credentials to SyncCoordinator so IDLE monitoring activates
- add selectedItem tracking to MailViewModel, wire List selection in GTD views
- fix startPeriodicSync to sleep-first, preventing duplicate sync on launch
- add deinit cleanup for EventLoopGroup in IMAPConnection, SMTPConnection
- use separate IMAP client for attachment downloads, avoid shared connection interference
- remove [weak self] from IMAPIdleClient actor Task to prevent orphaned connections
- fix isGTDPerspective to check selectedMailbox instead of items.isEmpty
- fix fetchBody to use complete RFC822 fetch instead of BODY[TEXT]
- reuse single IMAP connection per ActionQueue.flush() batch
- add requiresIMAP to ActionPayload for connection batching
- load task categories from label store instead of hardcoded empty array
- suppress NIOSSLHandler Sendable warnings via Package.swift unsafeFlags
- fix unused variable warnings across codebase

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 23:13:46 +01:00
parent 3b82e6cd95
commit 10b7cb2fd2
12 changed files with 140 additions and 82 deletions

View File

@@ -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<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(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<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(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("<html") || lowered.contains("<body") || lowered.contains("<div") {
return (text: nil, html: bodyString)

View File

@@ -1,7 +1,7 @@
import NIO
import NIOIMAPCore
@preconcurrency import NIOIMAP
@preconcurrency import NIOSSL
import NIOIMAP
import NIOSSL
actor IMAPConnection {
private let host: String
@@ -17,16 +17,20 @@ actor IMAPConnection {
self.responseHandler = IMAPResponseHandler()
}
deinit {
try? group.syncShutdownGracefully()
}
func connect() async throws {
let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
let handler = responseHandler
let hostname = host
nonisolated(unsafe) let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
let 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([
channel.pipeline.addHandlers([
sslHandler,
IMAPClientHandler(),
handler,

View File

@@ -1,8 +1,8 @@
import Foundation
import NIO
import NIOIMAPCore
@preconcurrency import NIOIMAP
@preconcurrency import NIOSSL
import NIOIMAP
import NIOSSL
import Models
public actor IMAPIdleClient {
@@ -29,10 +29,9 @@ public actor IMAPIdleClient {
try await connectAndLogin()
try await selectInbox()
monitorTask = Task { [weak self] in
monitorTask = Task {
var backoffSeconds: UInt64 = 5
while !Task.isCancelled {
guard let self else { break }
do {
try await self.idleLoop(onNewMail: onNewMail)
} catch {
@@ -79,11 +78,11 @@ public actor IMAPIdleClient {
let responseHandler = IMAPResponseHandler()
nonisolated(unsafe) let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
let bootstrap = ClientBootstrap(group: eventLoopGroup)
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
return channel.pipeline.addHandlers([
channel.pipeline.addHandlers([
sslHandler,
IMAPClientHandler(),
responseHandler,