diff --git a/Apps/MagnumOpus/ContentView.swift b/Apps/MagnumOpus/ContentView.swift index 2c74711..7c839fa 100644 --- a/Apps/MagnumOpus/ContentView.swift +++ b/Apps/MagnumOpus/ContentView.swift @@ -46,19 +46,21 @@ struct ContentView: View { ThreadDetailView( thread: viewModel.selectedThread, messages: viewModel.messages, - composeMode: $composeMode + onComposeRequest: { mode in + requestCompose(mode) + } ) } .safeAreaInset(edge: .bottom) { statusBanner } - .sheet(item: composeModeBinding) { mode in + .sheet(item: composeModeBinding) { wrapper in if let config = viewModel.accountConfig, let store = viewModel.store, let actionQueue = viewModel.actionQueue { ComposeView( viewModel: ComposeViewModel( - mode: mode, + mode: wrapper.mode, accountConfig: config, store: store, actionQueue: actionQueue @@ -114,6 +116,25 @@ struct ContentView: View { } } + /// Fetch message body if needed, then present compose sheet. + private func requestCompose(_ mode: ComposeMode) { + Task { + switch mode { + case .reply(let msg): + let loaded = await viewModel.ensureBodyLoaded(for: msg) + composeMode = .reply(to: loaded) + case .replyAll(let msg): + let loaded = await viewModel.ensureBodyLoaded(for: msg) + composeMode = .replyAll(to: loaded) + case .forward(let msg): + let loaded = await viewModel.ensureBodyLoaded(for: msg) + composeMode = .forward(of: loaded) + case .new, .draft: + composeMode = mode + } + } + } + private func connectAccount() { guard let (config, credentials) = accountSetup.buildConfig() else { return } do { diff --git a/Apps/MagnumOpus/ViewModels/MailViewModel.swift b/Apps/MagnumOpus/ViewModels/MailViewModel.swift index 67b0a32..3260c57 100644 --- a/Apps/MagnumOpus/ViewModels/MailViewModel.swift +++ b/Apps/MagnumOpus/ViewModels/MailViewModel.swift @@ -4,6 +4,7 @@ import Models import MailStore import SyncEngine import IMAPClient +import SMTPClient @Observable @MainActor @@ -21,6 +22,7 @@ final class MailViewModel { var syncState: SyncState = .idle var errorMessage: String? + private var imapClientProvider: (() -> IMAPClient)? private var threadObservation: Task? private var messageObservation: Task? @@ -39,25 +41,52 @@ final class MailViewModel { ) store = mailStore accountConfig = config - coordinator = SyncCoordinator( - accountConfig: config, - imapClient: imapClient, - store: mailStore - ) - let capturedHost = config.imapHost - let capturedPort = config.imapPort + let capturedImapHost = config.imapHost + let capturedImapPort = config.imapPort let capturedCredentials = credentials - actionQueue = ActionQueue( + + // Build smtpClientProvider when SMTP fields are present + var smtpProvider: (@Sendable () -> SMTPClient)? + if let smtpHost = config.smtpHost, + let smtpPort = config.smtpPort { + let security = config.smtpSecurity ?? .starttls + smtpProvider = { + SMTPClient( + host: smtpHost, + port: smtpPort, + security: security, + credentials: capturedCredentials + ) + } + } + + let queue = ActionQueue( store: mailStore, accountId: config.id, imapClientProvider: { IMAPClient( - host: capturedHost, - port: capturedPort, + host: capturedImapHost, + port: capturedImapPort, credentials: capturedCredentials ) - } + }, + smtpClientProvider: smtpProvider + ) + actionQueue = queue + imapClientProvider = { + IMAPClient( + host: capturedImapHost, + port: capturedImapPort, + credentials: capturedCredentials + ) + } + + coordinator = SyncCoordinator( + accountConfig: config, + imapClient: imapClient, + store: mailStore, + actionQueue: queue ) } @@ -280,6 +309,37 @@ final class MailViewModel { } } + // MARK: - Body Fetch for Reply/Forward + + /// Ensures the message body is available for quoting in compose. + /// If bodyText is nil, attempts to fetch via IMAP. Returns the message + /// with body populated, or the original if fetch fails (offline). + func ensureBodyLoaded(for message: MessageSummary) async -> MessageSummary { + if message.bodyText != nil { return message } + guard let store, let provider = imapClientProvider else { return message } + + // Look up the MessageRecord to get the IMAP uid + guard let record = try? store.message(id: message.id) else { return message } + + let client = provider() + do { + try await client.connect() + let (text, html) = try await client.fetchBody(uid: record.uid) + try? await client.disconnect() + if text != nil || html != nil { + try store.storeBody(messageId: message.id, text: text, html: html) + var updated = message + updated.bodyText = text + updated.bodyHtml = html + return updated + } + } catch { + try? await client.disconnect() + // Offline or fetch failed — compose without quoted text + } + return message + } + // MARK: - Helpers private func mailboxName(for mailboxId: String) -> String? { diff --git a/Apps/MagnumOpus/Views/ThreadDetailView.swift b/Apps/MagnumOpus/Views/ThreadDetailView.swift index 7401970..67c605c 100644 --- a/Apps/MagnumOpus/Views/ThreadDetailView.swift +++ b/Apps/MagnumOpus/Views/ThreadDetailView.swift @@ -4,7 +4,7 @@ import Models struct ThreadDetailView: View { let thread: ThreadSummary? let messages: [MessageSummary] - @Binding var composeMode: ComposeMode? + var onComposeRequest: (ComposeMode) -> Void var body: some View { Group { @@ -28,7 +28,7 @@ struct ThreadDetailView: View { ToolbarItemGroup(placement: .automatic) { Button { if let last = messages.last { - composeMode = .reply(to: last) + onComposeRequest(.reply(to: last)) } } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") @@ -38,7 +38,7 @@ struct ThreadDetailView: View { Button { if let last = messages.last { - composeMode = .replyAll(to: last) + onComposeRequest(.replyAll(to: last)) } } label: { Label("Reply All", systemImage: "arrowshape.turn.up.left.2") @@ -48,7 +48,7 @@ struct ThreadDetailView: View { Button { if let last = messages.last { - composeMode = .forward(of: last) + onComposeRequest(.forward(of: last)) } } label: { Label("Forward", systemImage: "arrowshape.turn.up.right") diff --git a/Apps/project.yml b/Apps/project.yml index 174d2fa..b384588 100644 --- a/Apps/project.yml +++ b/Apps/project.yml @@ -36,6 +36,8 @@ targets: product: IMAPClient - package: MagnumOpusCore product: SyncEngine + - package: MagnumOpusCore + product: SMTPClient MagnumOpus-iOS: type: application platform: iOS @@ -61,6 +63,8 @@ targets: product: IMAPClient - package: MagnumOpusCore product: SyncEngine + - package: MagnumOpusCore + product: SMTPClient MagnumOpusTests: type: bundle.unit-test platform: macOS