Files
warmbly/ios/Warmbly/Features/Compose/ComposeModels.swift
T

211 lines
6.5 KiB
Swift

import Foundation
// MARK: - Mailbox candidates (From picker)
/// One scored sender from `GET /v1/unibox/compose/candidates`: budget, auth
/// health, and history with the typed recipient feed the score and reasons.
struct ComposeCandidate: Codable, Identifiable, Sendable, Hashable {
var id: String
var email: String
var name: String?
var provider: String?
var authState: String?
var warmupActive: Bool?
var dailyLimit: Int?
var sentToday: Int?
var remainingToday: Int?
var historyMessages: Int?
var lastContactAt: Date?
var score: Int?
var reasons: [String]?
var recommended: Bool?
enum CodingKeys: String, CodingKey {
case id, email, name, provider, score, reasons, recommended
case authState = "auth_state"
case warmupActive = "warmup_active"
case dailyLimit = "daily_limit"
case sentToday = "sent_today"
case remainingToday = "remaining_today"
case historyMessages = "history_messages"
case lastContactAt = "last_contact_at"
}
}
/// Org-wide suppression state for the typed recipient (bounced, complained,
/// or unsubscribed); compose sends to a suppressed address are rejected.
struct ComposeSuppression: Codable, Sendable {
var reason: String?
}
struct ComposeCandidatesResponse: Codable, Sendable {
var accounts: [ComposeCandidate]?
var recommendedAccountID: String?
var recommendedReason: String?
var contact: Contact?
var suppression: ComposeSuppression?
enum CodingKeys: String, CodingKey {
case accounts, contact, suppression
case recommendedAccountID = "recommended_account_id"
case recommendedReason = "recommended_reason"
}
}
// MARK: - Send
/// `POST /v1/unibox/compose`; empty `email_account_id` lets the backend pick
/// the best mailbox for the first recipient. With `from_tag_id` set, the
/// auto pick only considers mailboxes carrying that tag.
struct ComposeSendRequest: Encodable, Sendable {
var emailAccountID: String
var fromTagID: String?
var to: [String]
var cc: [String]?
var bcc: [String]?
var subject: String
var bodyHTML: String?
var bodyPlain: String?
var sendMode: String?
var scheduledAt: Date?
enum CodingKeys: String, CodingKey {
case to, cc, bcc, subject
case emailAccountID = "email_account_id"
case fromTagID = "from_tag_id"
case bodyHTML = "body_html"
case bodyPlain = "body_plain"
case sendMode = "send_mode"
case scheduledAt = "scheduled_at"
}
}
struct ComposeSendResponse: Codable, Sendable {
var taskID: String?
var scheduledAt: Date?
var sendMode: String?
var accountID: String?
var accountEmail: String?
var auto: Bool?
var pickedReason: String?
enum CodingKeys: String, CodingKey {
case auto
case taskID = "task_id"
case scheduledAt = "scheduled_at"
case sendMode = "send_mode"
case accountID = "account_id"
case accountEmail = "account_email"
case pickedReason = "picked_reason"
}
}
// MARK: - Autosaved drafts
/// `repository.ComposeDraft` — a per-user working copy. The id is
/// client-generated, so autosave is an idempotent PUT of the whole draft.
struct ComposeDraft: Codable, Identifiable, Sendable, Hashable {
var id: String
var emailAccountID: String?
var to: [String]?
var cc: [String]?
var bcc: [String]?
var subject: String?
var body: String?
var updatedAt: Date?
var createdAt: Date?
enum CodingKeys: String, CodingKey {
case id, to, cc, bcc, subject, body
case emailAccountID = "email_account_id"
case updatedAt = "updated_at"
case createdAt = "created_at"
}
}
struct ComposeDraftsPage: Codable, Sendable {
var data: [ComposeDraft]?
}
/// `PUT /v1/unibox/drafts/:id` body.
struct ComposeDraftPayload: Encodable, Sendable, Equatable {
var emailAccountID: String
var to: [String]
var cc: [String]
var bcc: [String]
var subject: String
var body: String
enum CodingKeys: String, CodingKey {
case to, cc, bcc, subject, body
case emailAccountID = "email_account_id"
}
var isEmpty: Bool {
to.isEmpty && cc.isEmpty && bcc.isEmpty
&& subject.trimmingCharacters(in: .whitespaces).isEmpty
&& body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
// MARK: - Grounded AI draft
/// `POST /v1/unibox/compose/draft` request; the server grounds the draft in
/// the recipient's contact record, correspondence history, and org voice.
struct ComposeAIDraftRequest: Encodable, Sendable {
var to: String?
var subject: String?
var instruction: String?
}
struct ComposeGrounding: Codable, Sendable {
var contact: Bool?
var history: Int?
var voiceProfile: Bool?
enum CodingKeys: String, CodingKey {
case contact, history
case voiceProfile = "voice_profile"
}
}
/// Exactly one of `text` / `question` is set: a ready draft, or a clarifying
/// question when the model genuinely cannot tell what the email is for.
struct ComposeAIDraftResponse: Codable, Sendable {
var text: String?
var question: String?
var grounding: ComposeGrounding?
var creditsRemaining: Int?
var creditsCharged: Int?
var tokensUsed: Int?
var model: String?
enum CodingKeys: String, CodingKey {
case text, question, grounding, model
case creditsRemaining = "credits_remaining"
case creditsCharged = "credits_charged"
case tokensUsed = "tokens_used"
}
/// "2 credits · 1.4k tok"; nil when nothing was charged (local model).
var usage: String? {
guard let creditsCharged, creditsCharged > 0 else { return nil }
let credits = "\(creditsCharged) credit\(creditsCharged == 1 ? "" : "s")"
guard let tokensUsed, tokensUsed > 0 else { return credits }
return "\(credits) · \(WFormat.compact(tokensUsed)) tok"
}
/// "Grounded in contact · 6 past emails · voice profile".
var groundingLine: String? {
guard let grounding else { return nil }
var parts: [String] = []
if grounding.contact == true { parts.append("contact") }
if let history = grounding.history, history > 0 {
parts.append("\(history) past email\(history == 1 ? "" : "s")")
}
if grounding.voiceProfile == true { parts.append("voice profile") }
guard !parts.isEmpty else { return nil }
return "Grounded in " + parts.joined(separator: " · ")
}
}