mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
211 lines
6.5 KiB
Swift
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: " · ")
|
|
}
|
|
}
|