wire end-to-end: smtp provider for ActionQueue, actionQueue for SyncCoordinator, body fetch for reply/forward

- MailViewModel.setup() now creates SMTPClient provider when SMTP config is present,
  passes it to ActionQueue so send actions work
- ActionQueue is passed to SyncCoordinator so pending actions flush before sync
- Add ensureBodyLoaded() to fetch message body via IMAP before opening compose
- ThreadDetailView uses callback instead of binding for compose requests,
  allowing ContentView to fetch body before presenting sheet
- Add SMTPClient dependency to both app targets in project.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 06:00:42 +01:00
parent 0bfcf2d610
commit f3da0784b9
4 changed files with 103 additions and 18 deletions

View File

@@ -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 {

View File

@@ -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<Void, Never>?
private var messageObservation: Task<Void, Never>?
@@ -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? {

View File

@@ -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")

View File

@@ -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