import SwiftUI /// Backs one open compose window: mailbox candidates for the From picker, /// the send call, and the autosaved working copy (client-generated draft id, /// idempotent PUT on a debounce — web parity). @MainActor @Observable final class ComposeStore { // MARK: Candidates private(set) var candidates: [ComposeCandidate] = [] private(set) var recommendedAccountID: String? private(set) var recommendedReason: String? private(set) var contact: Contact? private(set) var suppression: ComposeSuppression? private(set) var candidatesLoaded = false private var candidatesGeneration = 0 // MARK: Send / errors private(set) var isSending = false private(set) var errorMessage: String? // MARK: Draft autosave enum SaveState { case idle, saving, saved } /// Client-generated id; the same id on every PUT makes autosave idempotent. private(set) var draftID: String private(set) var saveState: SaveState = .idle private var lastSavedPayload: ComposeDraftPayload? private var draftPersisted: Bool private var saveTask: Task? static let bodyLimit = 4000 init(draft: ComposeDraft? = nil) { draftID = draft?.id ?? UUID().uuidString.lowercased() draftPersisted = draft != nil if let draft { lastSavedPayload = ComposeDraftPayload( emailAccountID: draft.emailAccountID ?? "", to: draft.to ?? [], cc: draft.cc ?? [], bcc: draft.bcc ?? [], subject: draft.subject ?? "", body: draft.body ?? "" ) saveState = .saved } } func setError(_ message: String) { errorMessage = message } /// Scores every active mailbox against the recipient. Guarded by a /// generation counter so a stale response never overwrites a newer one. func loadCandidates(_ api: APIClient, to address: String) async { candidatesGeneration += 1 let gen = candidatesGeneration do { let response: ComposeCandidatesResponse = try await api.get( "unibox/compose/candidates", query: ["to": address.isEmpty ? nil : address] ) guard gen == candidatesGeneration else { return } withAnimation(.snappy) { candidates = response.accounts ?? [] recommendedAccountID = response.recommendedAccountID recommendedReason = response.recommendedReason contact = response.contact suppression = response.suppression candidatesLoaded = true } } catch { guard gen == candidatesGeneration else { return } // Candidates are advisory; the picker just shows what it has. candidatesLoaded = true } } var recommendedCandidate: ComposeCandidate? { candidates.first { $0.id == recommendedAccountID } ?? candidates.first { $0.recommended == true } } // MARK: Account tags /// Mailbox id -> tag ids, from the account directory; feeds the picker's /// tag filter and the tag-scoped Auto preview. private(set) var accountTags: [String: Set] = [:] private var accountTagsLoaded = false /// Loads tag membership once per window; a failed load retries on the /// next call so the picker degrades to search-only, not permanently. func loadAccountTags(_ api: APIClient) async { guard !accountTagsLoaded else { return } guard let page: ListResponse = try? await api.get( "emails", query: ["limit": "200"] ) else { return } accountTags = Dictionary( page.data.map { ($0.id, Set($0.tags ?? [])) }, uniquingKeysWith: { first, _ in first } ) accountTagsLoaded = true } /// Best mailbox carrying the tag: first member in candidate order, which /// is already score-sorted by the backend. func bestCandidate(inTag tagID: String) -> ComposeCandidate? { candidates.first { accountTags[$0.id]?.contains(tagID) == true } } // MARK: Autosave /// Debounced upsert; the empty draft is deleted instead of saved so /// abandoned windows don't litter the Drafts list. func scheduleAutosave(_ api: APIClient, payload: ComposeDraftPayload) { guard payload != lastSavedPayload else { return } saveTask?.cancel() saveTask = Task { try? await Task.sleep(for: .milliseconds(1200)) guard !Task.isCancelled else { return } await save(api, payload: payload) } } /// Immediate flush for window close: persists the pending edit (or /// deletes an emptied draft) without waiting out the debounce. func flush(_ api: APIClient, payload: ComposeDraftPayload) { saveTask?.cancel() guard payload != lastSavedPayload else { return } Task { await save(api, payload: payload) } } private func save(_ api: APIClient, payload: ComposeDraftPayload) async { if payload.isEmpty { await deleteDraft(api) return } saveState = .saving do { struct UpsertResponse: Codable { var id: String? } let _: UpsertResponse = try await api.put("unibox/drafts/\(draftID)", body: payload) lastSavedPayload = payload draftPersisted = true saveState = .saved } catch { // Autosave failures are silent; the next edit retries. saveState = .idle } } /// Drops the working copy (after send, or when the draft was emptied). func deleteDraft(_ api: APIClient) async { saveTask?.cancel() lastSavedPayload = nil saveState = .idle guard draftPersisted else { return } draftPersisted = false struct DeleteResponse: Codable { var deleted: Bool? } let _: DeleteResponse? = try? await api.delete("unibox/drafts/\(draftID)") } // MARK: Send func send(_ api: APIClient, request: ComposeSendRequest) async throws -> ComposeSendResponse { isSending = true errorMessage = nil defer { isSending = false } let response: ComposeSendResponse = try await api.post( "unibox/compose", body: request, idempotent: true ) await deleteDraft(api) return response } // MARK: Grounded AI draft func aiDraft( _ api: APIClient, to: String?, subject: String?, instruction: String? ) async throws -> ComposeAIDraftResponse { try await api.post( "unibox/compose/draft", body: ComposeAIDraftRequest(to: to, subject: subject, instruction: instruction), idempotent: true ) } } // MARK: - Drafts list /// Backs the Drafts screen: the member's autosaved compose drafts, newest /// first (`GET /v1/unibox/drafts`, capped at 100 server-side). @MainActor @Observable final class ComposeDraftsStore { private(set) var drafts: [ComposeDraft] = [] private(set) var isLoading = false private(set) var hasLoaded = false private(set) var errorMessage: String? func load(_ api: APIClient) async { isLoading = true do { let page: ComposeDraftsPage = try await api.get("unibox/drafts") withAnimation(.snappy) { drafts = page.data ?? [] } errorMessage = nil hasLoaded = true } catch { if !hasLoaded { errorMessage = error.localizedDescription } } isLoading = false } func delete(_ api: APIClient, id: String) async { struct DeleteResponse: Codable { var deleted: Bool? } let _: DeleteResponse? = try? await api.delete("unibox/drafts/\(id)") withAnimation(.snappy) { drafts.removeAll { $0.id == id } } } }