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:
@@ -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 {
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user