mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
557 lines
16 KiB
Swift
557 lines
16 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - Email account (Go models.Email / web Inbox)
|
|
|
|
/// A connected sender mailbox. List responses omit several columns
|
|
/// (warmup_reply_rate, warmup_tag, warmup_pool_type, timezone, user_id,
|
|
/// worker_id, organization_id come back as zero values), so everything
|
|
/// that isn't structurally guaranteed is optional.
|
|
struct EmailAccount: Codable, Identifiable, Hashable, Sendable {
|
|
var id: String
|
|
var userID: String?
|
|
var organizationID: String?
|
|
var workerID: String?
|
|
var email: String
|
|
var name: String?
|
|
var signaturePlain: String?
|
|
var signatureHTML: String?
|
|
var signatureSync: Bool?
|
|
var signatureCode: Bool?
|
|
var provider: String?
|
|
var status: String?
|
|
var lastSyncedAt: Date?
|
|
var lastMessageID: Int64?
|
|
var campaignLimit: Int?
|
|
var minWaitTime: Int?
|
|
var replyTo: String?
|
|
var trackingDomain: String?
|
|
var trackingDomainVerified: Bool?
|
|
var trackingDomainVerifiedAt: Date?
|
|
/// Warmup ramp anchor; non-nil means warmup is enabled.
|
|
var warmup: Date?
|
|
/// Non-nil means warmup is paused (ramp progress kept).
|
|
var warmupPausedAt: Date?
|
|
var warmupBase: Int?
|
|
var warmupMax: Int?
|
|
var warmupIncrease: Int?
|
|
var warmupReplyRate: Int?
|
|
var warmupTag: String?
|
|
var warmupPoolType: String?
|
|
var warmupStartTime: String?
|
|
var warmupEndTime: String?
|
|
var warmupDays: Int?
|
|
var timezone: String?
|
|
var tags: [String]?
|
|
var createdAt: Date?
|
|
var updatedAt: Date?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, email, name, provider, status, warmup, timezone, tags
|
|
case userID = "user_id"
|
|
case organizationID = "organization_id"
|
|
case workerID = "worker_id"
|
|
case signaturePlain = "signature_plain"
|
|
case signatureHTML = "signature_html"
|
|
case signatureSync = "signature_sync"
|
|
case signatureCode = "signature_code"
|
|
case lastSyncedAt = "last_synced_at"
|
|
case lastMessageID = "last_id"
|
|
case campaignLimit = "campaign_limit"
|
|
case minWaitTime = "min_wait_time"
|
|
case replyTo = "reply_to"
|
|
case trackingDomain = "tracking_domain"
|
|
case trackingDomainVerified = "tracking_domain_verified"
|
|
case trackingDomainVerifiedAt = "tracking_domain_verified_at"
|
|
case warmupPausedAt = "warmup_paused_at"
|
|
case warmupBase = "warmup_base"
|
|
case warmupMax = "warmup_max"
|
|
case warmupIncrease = "warmup_increase"
|
|
case warmupReplyRate = "warmup_reply_rate"
|
|
case warmupTag = "warmup_tag"
|
|
case warmupPoolType = "warmup_pool_type"
|
|
case warmupStartTime = "warmup_start_time"
|
|
case warmupEndTime = "warmup_end_time"
|
|
case warmupDays = "warmup_days"
|
|
case createdAt = "created_at"
|
|
case updatedAt = "updated_at"
|
|
}
|
|
|
|
/// Derived from the warmup anchor + pause timestamp; never trust a
|
|
/// status string for warmup state.
|
|
var isWarmingActive: Bool { warmup != nil && warmupPausedAt == nil }
|
|
var isWarmupPaused: Bool { warmup != nil && warmupPausedAt != nil }
|
|
|
|
var warmupState: MailboxWarmupState {
|
|
if isWarmingActive { return .active }
|
|
if isWarmupPaused { return .paused }
|
|
return .off
|
|
}
|
|
|
|
var providerLabel: String {
|
|
switch provider {
|
|
case "gmail": return "Gmail"
|
|
case "outlook": return "Outlook"
|
|
case "smtp_imap": return "SMTP/IMAP"
|
|
default: return provider?.isEmpty == false ? provider! : "Mailbox"
|
|
}
|
|
}
|
|
|
|
var statusLabel: String {
|
|
switch status {
|
|
case "active": return "Active"
|
|
case "inactive": return "Inactive"
|
|
case "revoked": return "Revoked"
|
|
default: return status ?? "Unknown"
|
|
}
|
|
}
|
|
|
|
var statusTone: Tone {
|
|
switch status {
|
|
case "active": return .emerald
|
|
case "revoked": return .rose
|
|
default: return .slate
|
|
}
|
|
}
|
|
|
|
var senderDomain: String {
|
|
email.split(separator: "@").last.map(String.init) ?? email
|
|
}
|
|
}
|
|
|
|
enum MailboxWarmupState: Equatable {
|
|
case active, paused, off
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .active: return "Warming"
|
|
case .paused: return "Warmup paused"
|
|
case .off: return "Warmup off"
|
|
}
|
|
}
|
|
|
|
var pillText: String {
|
|
switch self {
|
|
case .active: return "warming"
|
|
case .paused: return "paused"
|
|
case .off: return "off"
|
|
}
|
|
}
|
|
|
|
var tone: Tone {
|
|
switch self {
|
|
case .active: return .orange
|
|
case .paused: return .amber
|
|
case .off: return .slate
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Account analytics (GET /analytics/accounts[/:id], Go models.EmailAccountStatus)
|
|
|
|
struct AccountAnalytics: Codable, Identifiable, Sendable {
|
|
var id: String
|
|
var email: String?
|
|
var provider: String?
|
|
var status: String?
|
|
var lastSyncedAt: Date?
|
|
var health: MailboxHealth?
|
|
var errors: [MailboxAccountError]?
|
|
var dailyUsage: MailboxDailyUsage?
|
|
var warmupStatus: WarmupStatus?
|
|
var warmupHealth: MailboxWarmupHealth?
|
|
var inCampaign: Bool?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, email, provider, status, health, errors
|
|
case lastSyncedAt = "last_synced_at"
|
|
case dailyUsage = "daily_usage"
|
|
case warmupStatus = "warmup_status"
|
|
case warmupHealth = "warmup_health"
|
|
case inCampaign = "in_campaign"
|
|
}
|
|
}
|
|
|
|
struct MailboxHealth: Codable, Sendable {
|
|
/// "healthy" | "warning" | "error"
|
|
var status: String?
|
|
/// 0-100
|
|
var score: Int?
|
|
var issues: [String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case status, score, issues
|
|
}
|
|
|
|
var tone: Tone {
|
|
switch status {
|
|
case "healthy": return .emerald
|
|
case "warning": return .amber
|
|
case "error": return .rose
|
|
default: return .slate
|
|
}
|
|
}
|
|
|
|
var label: String {
|
|
switch status {
|
|
case "healthy": return "Healthy"
|
|
case "warning": return "At risk"
|
|
case "error": return "Issue"
|
|
default: return "Unknown"
|
|
}
|
|
}
|
|
|
|
var hasIssue: Bool { status == "warning" || status == "error" }
|
|
}
|
|
|
|
struct MailboxAccountError: Codable, Identifiable, Sendable {
|
|
var id: String
|
|
var errorCode: String?
|
|
var severity: String?
|
|
var title: String?
|
|
var message: String?
|
|
var actionRequired: String?
|
|
var createdAt: Date?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, severity, title, message
|
|
case errorCode = "error_code"
|
|
case actionRequired = "action_required"
|
|
case createdAt = "created_at"
|
|
}
|
|
|
|
var tone: Tone { severity == "CRITICAL" ? .rose : .amber }
|
|
}
|
|
|
|
struct MailboxDailyUsage: Codable, Sendable {
|
|
/// "YYYY-MM-DD"
|
|
var date: String?
|
|
var campaignSent: Int?
|
|
var campaignLimit: Int?
|
|
var warmupSent: Int?
|
|
var warmupLimit: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case date
|
|
case campaignSent = "campaign_sent"
|
|
case campaignLimit = "campaign_limit"
|
|
case warmupSent = "warmup_sent"
|
|
case warmupLimit = "warmup_limit"
|
|
}
|
|
}
|
|
|
|
struct WarmupStatus: Codable, Sendable {
|
|
var enabled: Bool?
|
|
var paused: Bool?
|
|
var pausedAt: Date?
|
|
var startedAt: Date?
|
|
var currentVolume: Int?
|
|
var targetVolume: Int?
|
|
var maxVolume: Int?
|
|
var replyRate: Int?
|
|
var daysActive: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case enabled, paused
|
|
case pausedAt = "paused_at"
|
|
case startedAt = "started_at"
|
|
case currentVolume = "current_volume"
|
|
case targetVolume = "target_volume"
|
|
case maxVolume = "max_volume"
|
|
case replyRate = "reply_rate"
|
|
case daysActive = "days_active"
|
|
}
|
|
}
|
|
|
|
struct MailboxWarmupHealth: Codable, Sendable {
|
|
/// "healthy" | "watch" | "throttled" | "quarantined" | "blocked"
|
|
var state: String?
|
|
var score: Double?
|
|
var reason: String?
|
|
var spamScore: Int?
|
|
var blockedUntil: Date?
|
|
var evaluatedAt: Date?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case state, score, reason
|
|
case spamScore = "spam_score"
|
|
case blockedUntil = "blocked_until"
|
|
case evaluatedAt = "evaluated_at"
|
|
}
|
|
|
|
var stateLabel: String {
|
|
switch state {
|
|
case "healthy": return "Healthy"
|
|
case "watch": return "Watch"
|
|
case "throttled": return "Throttled"
|
|
case "quarantined": return "Quarantined"
|
|
case "blocked": return "Blocked"
|
|
default: return state ?? "Unknown"
|
|
}
|
|
}
|
|
|
|
var tone: Tone {
|
|
switch state {
|
|
case "healthy": return .emerald
|
|
case "watch", "throttled": return .amber
|
|
case "quarantined", "blocked": return .rose
|
|
default: return .slate
|
|
}
|
|
}
|
|
|
|
var isDegraded: Bool { state != nil && state != "healthy" }
|
|
}
|
|
|
|
// MARK: - Domain auth check (GET /emails/:id/auth-check)
|
|
|
|
struct AuthCheckResult: Codable, Sendable {
|
|
var domain: String?
|
|
var spfFound: Bool?
|
|
var spfRecord: String?
|
|
var dkimFound: Bool?
|
|
var dkimSelectors: [String]?
|
|
var dmarcFound: Bool?
|
|
var dmarcPolicy: String?
|
|
var allAligned: Bool?
|
|
var summary: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case domain, summary
|
|
case spfFound = "spf_found"
|
|
case spfRecord = "spf_record"
|
|
case dkimFound = "dkim_found"
|
|
case dkimSelectors = "dkim_selectors"
|
|
case dmarcFound = "dmarc_found"
|
|
case dmarcPolicy = "dmarc_policy"
|
|
case allAligned = "all_aligned"
|
|
}
|
|
}
|
|
|
|
// MARK: - Warmup ban status (GET /emails/:id/warmup/ban-status)
|
|
|
|
struct MailboxBanStatus: Codable, Sendable {
|
|
var emailAccountID: String?
|
|
var blocked: Bool?
|
|
var healthState: String?
|
|
var reason: String?
|
|
var blockedAt: Date?
|
|
var blockedUntil: Date?
|
|
var canAppeal: Bool?
|
|
var pendingAppeal: Bool?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case blocked, reason
|
|
case emailAccountID = "email_account_id"
|
|
case healthState = "health_state"
|
|
case blockedAt = "blocked_at"
|
|
case blockedUntil = "blocked_until"
|
|
case canAppeal = "can_appeal"
|
|
case pendingAppeal = "pending_appeal"
|
|
}
|
|
}
|
|
|
|
struct MailboxAppealResponse: Codable, Sendable {
|
|
var appealID: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case appealID = "appeal_id"
|
|
}
|
|
}
|
|
|
|
// MARK: - Warmup analytics (GET /analytics/warmup)
|
|
|
|
struct MailboxWarmupAnalytics: Codable, Sendable {
|
|
var emailAccountID: String?
|
|
var email: String?
|
|
var dateRange: MailboxDateRange?
|
|
var summary: MailboxWarmupSummary?
|
|
var dailyStats: [MailboxWarmupDailyStat]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case email, summary
|
|
case emailAccountID = "email_account_id"
|
|
case dateRange = "date_range"
|
|
case dailyStats = "daily_stats"
|
|
}
|
|
}
|
|
|
|
struct MailboxDateRange: Codable, Sendable {
|
|
var from: Date?
|
|
var to: Date?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case from, to
|
|
}
|
|
}
|
|
|
|
struct MailboxWarmupSummary: Codable, Sendable {
|
|
var totalSent: Int?
|
|
var totalReplied: Int?
|
|
var averageDaily: Double?
|
|
var replyRate: Double?
|
|
/// Never populated by the backend; always 0. Do not render.
|
|
var targetProgress: Double?
|
|
var daysActive: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case totalSent = "total_sent"
|
|
case totalReplied = "total_replied"
|
|
case averageDaily = "average_daily"
|
|
case replyRate = "reply_rate"
|
|
case targetProgress = "target_progress"
|
|
case daysActive = "days_active"
|
|
}
|
|
}
|
|
|
|
struct MailboxWarmupDailyStat: Codable, Sendable {
|
|
/// "YYYY-MM-DD"
|
|
var date: String?
|
|
var emailsSent: Int?
|
|
var emailsReplied: Int?
|
|
var targetVolume: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case date
|
|
case emailsSent = "emails_sent"
|
|
case emailsReplied = "emails_replied"
|
|
case targetVolume = "target_volume"
|
|
}
|
|
|
|
var day: Date? { MailboxFormat.parseDay(date ?? "") }
|
|
}
|
|
|
|
// MARK: - Connect / onboarding (POST /emails/onboarding/*)
|
|
|
|
/// `POST /emails/onboarding/oauth/start` body.
|
|
struct MailboxOAuthStartBody: Encodable {
|
|
var provider: String
|
|
}
|
|
|
|
/// `POST /emails/onboarding/oauth/start` response.
|
|
struct MailboxOAuthStartResponse: Codable, Sendable {
|
|
var url: String
|
|
var state: String
|
|
}
|
|
|
|
/// One SMTP or IMAP endpoint of the SMTP/IMAP connect body.
|
|
struct MailboxServerCredentials: Encodable {
|
|
var username: String
|
|
var password: String
|
|
var host: String
|
|
var port: Int
|
|
}
|
|
|
|
/// `POST /emails/onboarding/smtp-imap` body.
|
|
struct MailboxSMTPConnectBody: Encodable {
|
|
var email: String
|
|
var name: String
|
|
var smtp: MailboxServerCredentials
|
|
var imap: MailboxServerCredentials
|
|
}
|
|
|
|
// MARK: - Tracking domain (PATCH /emails/:id/track)
|
|
|
|
struct MailboxTrackingStatus: Codable, Sendable {
|
|
var trackingDomain: String?
|
|
var trackingDomainVerified: Bool?
|
|
var trackingDomainVerifiedAt: Date?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case trackingDomain = "tracking_domain"
|
|
case trackingDomainVerified = "tracking_domain_verified"
|
|
case trackingDomainVerifiedAt = "tracking_domain_verified_at"
|
|
}
|
|
}
|
|
|
|
// MARK: - PATCH /emails/:id body (all fields optional; nil = omit)
|
|
|
|
struct MailboxUpdateBody: Encodable {
|
|
var name: String?
|
|
var signaturePlain: String?
|
|
var signatureHTML: String?
|
|
var signatureSync: Bool?
|
|
var signatureCode: Bool?
|
|
var status: String?
|
|
var campaignLimit: Int?
|
|
var minWaitTime: Int?
|
|
var replyTo: String?
|
|
var warmupBase: Int?
|
|
var warmupMax: Int?
|
|
var warmupIncrease: Int?
|
|
var warmupReplyRate: Int?
|
|
var warmupTag: String?
|
|
var warmupStartTime: String?
|
|
var warmupEndTime: String?
|
|
var warmupDays: Int?
|
|
var tags: [String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, status, tags
|
|
case signaturePlain = "signature_plain"
|
|
case signatureHTML = "signature_html"
|
|
case signatureSync = "signature_sync"
|
|
case signatureCode = "signature_code"
|
|
case campaignLimit = "campaign_limit"
|
|
case minWaitTime = "min_wait_time"
|
|
case replyTo = "reply_to"
|
|
case warmupBase = "warmup_base"
|
|
case warmupMax = "warmup_max"
|
|
case warmupIncrease = "warmup_increase"
|
|
case warmupReplyRate = "warmup_reply_rate"
|
|
case warmupTag = "warmup_tag"
|
|
case warmupStartTime = "warmup_start_time"
|
|
case warmupEndTime = "warmup_end_time"
|
|
case warmupDays = "warmup_days"
|
|
}
|
|
}
|
|
|
|
// MARK: - PATCH /emails/tags (bulk tag add/remove, set semantics)
|
|
|
|
struct MailboxBulkTagsBody: Encodable {
|
|
var emailIDs: [String]
|
|
var addTags: [String]?
|
|
var removeTags: [String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case emailIDs = "email_ids"
|
|
case addTags = "add_tags"
|
|
case removeTags = "remove_tags"
|
|
}
|
|
}
|
|
|
|
struct MailboxBulkTagsResponse: Codable, Sendable {
|
|
var updated: Int?
|
|
}
|
|
|
|
// MARK: - Formatting helpers
|
|
|
|
enum MailboxFormat {
|
|
static let day: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.calendar = Calendar(identifier: .gregorian)
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter
|
|
}()
|
|
|
|
static func parseDay(_ raw: String) -> Date? {
|
|
day.date(from: raw)
|
|
}
|
|
|
|
/// Min-gap seconds as terse copy: "600 s" or "10 min".
|
|
static func gap(_ seconds: Int) -> String {
|
|
if seconds >= 60, seconds % 60 == 0 { return "\(seconds / 60) min" }
|
|
return "\(seconds) s"
|
|
}
|
|
|
|
/// Weekday bitmask (bit 0 = Sunday, matching Go's time.Weekday in the
|
|
/// scheduler); 0 or 127 = every day. Rendered Mon-first.
|
|
static func weekdays(_ mask: Int) -> String {
|
|
guard mask > 0, mask < 127 else { return "every day" }
|
|
let names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
let monFirst = [1, 2, 3, 4, 5, 6, 0]
|
|
let picked = monFirst.filter { mask & (1 << $0) != 0 }.map { names[$0] }
|
|
return picked.joined(separator: " ")
|
|
}
|
|
}
|