feat: iOS mailboxes - account list with health/warmup state, connect sheet, and mailbox detail with settings, limits and deliverability tabs

This commit is contained in:
Matthew Meszaros
2026-07-07 05:57:43 +02:00
parent 553a2844a5
commit eefa88adff
6 changed files with 1693 additions and 0 deletions
@@ -0,0 +1,257 @@
import SwiftUI
/// Modal connect flow. As a sheet it is its own root, so it wraps content in
/// a NavigationStack (unlike pushed detail views). Offers the two provider
/// OAuth launchers and the fully-native SMTP/IMAP form.
struct MailboxConnectSheet: View {
@Environment(AppEnvironment.self) private var env
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var mode: Mode = .choose
@State private var errorText: String?
@State private var isWorking = false
// SMTP/IMAP form
@State private var email = ""
@State private var displayName = ""
@State private var smtpHost = ""
@State private var smtpPort = "465"
@State private var smtpUsername = ""
@State private var smtpPassword = ""
@State private var imapHost = ""
@State private var imapPort = "993"
@State private var imapUsername = ""
@State private var imapPassword = ""
enum Mode: Equatable {
case choose
case smtp
}
var body: some View {
NavigationStack {
Group {
switch mode {
case .choose: chooser
case .smtp: smtpForm
}
}
.navigationTitle("Connect a mailbox")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
if mode == .smtp {
ToolbarItem(placement: .confirmationAction) {
if isWorking {
ProgressView().controlSize(.small)
} else {
Button("Connect") {
Task { await connectSMTP() }
}
.disabled(!smtpFormValid)
}
}
}
}
.alert(
"Couldn't connect",
isPresented: Binding(
get: { errorText != nil },
set: { if !$0 { errorText = nil } }
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(errorText ?? "")
}
}
.presentationDragIndicator(.visible)
.sensoryFeedback(.selection, trigger: mode)
}
// MARK: Chooser
private var chooser: some View {
List {
Section {
providerRow("Gmail", subtitle: "Connect with Google", provider: "gmail", symbol: "envelope.fill", tone: .rose)
providerRow("Outlook", subtitle: "Connect with Microsoft", provider: "outlook", symbol: "envelope.fill", tone: .sky)
} header: {
Text("OAuth")
}
Section {
Button {
mode = .smtp
} label: {
HStack(spacing: 12) {
IconTile(symbol: "server.rack", tone: .slate, size: 38)
VStack(alignment: .leading, spacing: 2) {
Text("SMTP / IMAP")
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Text("Host, port, and app password")
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
.buttonStyle(TapScaleStyle())
} header: {
Text("Manual")
} footer: {
Text("Use a host, port, and app password for providers without OAuth.")
}
}
}
private func providerRow(_ title: String, subtitle: String, provider: String, symbol: String, tone: Tone) -> some View {
Button {
Task { await startOAuth(provider: provider) }
} label: {
HStack(spacing: 12) {
IconTile(symbol: symbol, tone: tone, size: 38)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Text(subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
if isWorking {
ProgressView().controlSize(.small)
} else {
Image(systemName: "arrow.up.forward.app")
.font(.subheadline.weight(.semibold))
.foregroundStyle(WTheme.accent)
}
}
.padding(.vertical, 4)
}
.buttonStyle(TapScaleStyle())
.disabled(isWorking)
}
// MARK: SMTP form
private var smtpForm: some View {
List {
Section("Mailbox") {
TextField("Email address", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Sender name", text: $displayName)
}
Section("Outgoing (SMTP)") {
TextField("Host", text: $smtpHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port (465 or 587)", text: $smtpPort)
.keyboardType(.numberPad)
TextField("Username", text: $smtpUsername)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Password", text: $smtpPassword)
}
Section("Incoming (IMAP)") {
TextField("Host", text: $imapHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: $imapPort)
.keyboardType(.numberPad)
TextField("Username", text: $imapUsername)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Password", text: $imapPassword)
}
Section {
Button {
Task { await connectSMTP() }
} label: {
Group {
if isWorking {
ProgressView().tint(.white)
} else {
Text("Connect mailbox")
.font(.body.weight(.semibold))
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(WTheme.accent)
.controlSize(.large)
.disabled(!smtpFormValid || isWorking)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
}
}
private var smtpFormValid: Bool {
guard email.contains("@"), displayName.count >= 2 else { return false }
guard !smtpHost.isEmpty, !imapHost.isEmpty else { return false }
guard let sPort = Int(smtpPort), sPort == 465 || sPort == 587 else { return false }
guard let iPort = Int(imapPort), iPort > 0 else { return false }
return true
}
// MARK: Actions
private func startOAuth(provider: String) async {
isWorking = true
defer { isWorking = false }
do {
let response: MailboxOAuthStartResponse = try await env.api.post(
"emails/onboarding/oauth/start",
body: MailboxOAuthStartBody(provider: provider)
)
if let url = URL(string: response.url) {
openURL(url)
dismiss()
} else {
errorText = "The provider returned an invalid authorization URL."
}
} catch {
errorText = error.localizedDescription
}
}
private func connectSMTP() async {
isWorking = true
defer { isWorking = false }
let body = MailboxSMTPConnectBody(
email: email.trimmingCharacters(in: .whitespaces),
name: displayName.trimmingCharacters(in: .whitespaces),
smtp: MailboxServerCredentials(
username: smtpUsername,
password: smtpPassword,
host: smtpHost.trimmingCharacters(in: .whitespaces),
port: Int(smtpPort) ?? 465
),
imap: MailboxServerCredentials(
username: imapUsername,
password: imapPassword,
host: imapHost.trimmingCharacters(in: .whitespaces),
port: Int(imapPort) ?? 993
)
)
do {
let _: EmailAccount = try await env.api.post("emails/onboarding/smtp-imap", body: body)
dismiss()
} catch {
errorText = error.localizedDescription
}
}
}
@@ -0,0 +1,85 @@
import Foundation
import SwiftUI
/// Detail-screen store: keeps the mailbox row fresh, pulls the per-account
/// health snapshot (`/analytics/accounts/:id`), the on-demand domain auth
/// check, the warmup ban status, and drives the lifecycle + settings PATCH.
///
/// `GET /emails/:id` is broken server-side (500), so detail is rendered from
/// the list row plus `/analytics/accounts/:id`, exactly as the web does.
@MainActor
@Observable
final class MailboxDetailStore {
private(set) var account: EmailAccount
private(set) var analytics: AccountAnalytics?
private(set) var authCheck: AuthCheckResult?
private(set) var banStatus: MailboxBanStatus?
private(set) var isCheckingAuth = false
private(set) var isSaving = false
var actionError: String?
init(account: EmailAccount) {
self.account = account
}
// Matches the web's presence resource key so iOS and web viewers merge.
var presenceKey: String { "mailbox:\(account.id)" }
func apply(_ updated: EmailAccount) {
withAnimation { account = updated }
}
// MARK: Loading
func loadAnalytics(_ api: APIClient) async {
do {
let status: AccountAnalytics = try await api.get("analytics/accounts/\(account.id)")
withAnimation { analytics = status }
} catch {
// Missing analytics permission or transient; the header still renders.
}
}
func loadBanStatus(_ api: APIClient) async {
banStatus = try? await api.get("emails/\(account.id)/warmup/ban-status")
}
func runAuthCheck(_ api: APIClient) async {
isCheckingAuth = true
defer { isCheckingAuth = false }
do {
let result: AuthCheckResult = try await api.get("emails/\(account.id)/auth-check")
withAnimation { authCheck = result }
} catch {
actionError = error.localizedDescription
}
}
// MARK: Warmup lifecycle
/// action: "start" | "pause" | "resume" | "stop"; returns the updated row.
func warmupAction(_ api: APIClient, _ action: String) async {
do {
let updated: EmailAccount = try await api.post("emails/\(account.id)/warmup/\(action)")
apply(updated)
await loadBanStatus(api)
} catch {
actionError = error.localizedDescription
}
}
// MARK: Settings PATCH
func save(_ api: APIClient, _ body: MailboxUpdateBody) async -> Bool {
isSaving = true
defer { isSaving = false }
do {
let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body)
apply(updated)
return true
} catch {
actionError = error.localizedDescription
return false
}
}
}
@@ -0,0 +1,409 @@
import SwiftUI
enum MailboxDetailTab: String, CaseIterable, Identifiable {
case overview, warmup, setup
var id: String { rawValue }
var title: String {
switch self {
case .overview: return "Overview"
case .warmup: return "Warmup"
case .setup: return "Setup"
}
}
var icon: String {
switch self {
case .overview: return "waveform.path.ecg"
case .warmup: return "flame.fill"
case .setup: return "checkmark.shield.fill"
}
}
}
/// Mailbox detail: sky hero with health/usage/warmup chips, swipeable tabs for
/// Overview / Warmup / Setup. Pushed onto the Accounts tab stack, so it does
/// NOT create its own NavigationStack. Claims presence on `mailbox:<id>` and
/// reloads on the emailAccounts/analytics pulse.
struct MailboxDetailView: View {
@Environment(AppEnvironment.self) private var env
@State private var store: MailboxDetailStore
@State private var tab: MailboxDetailTab = .overview
init(account: EmailAccount) {
_store = State(initialValue: MailboxDetailStore(account: account))
}
private var account: EmailAccount { store.account }
private var health: MailboxHealth? { store.analytics?.health }
private var canManage: Bool { env.session.can(.manageEmails) }
var body: some View {
AirDetailScaffold(
tabs: MailboxDetailTab.allCases.map { AirTabItem(id: $0.rawValue, title: $0.title, icon: $0.icon) },
selection: Binding(
get: { tab.rawValue },
set: { tab = MailboxDetailTab(rawValue: $0) ?? .overview }
)
) {
hero
} content: {
content
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.presenceResource(store.presenceKey)
.task { await store.loadAnalytics(env.api) }
.task { await store.loadBanStatus(env.api) }
.onChange(of: env.realtime.pulse(for: .emailAccounts)) {
Task { await store.loadAnalytics(env.api) }
}
.onChange(of: env.realtime.pulse(for: .analytics)) {
Task { await store.loadAnalytics(env.api) }
}
.alert(
"Something went wrong",
isPresented: Binding(
get: { store.actionError != nil },
set: { if !$0 { store.actionError = nil } }
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(store.actionError ?? "")
}
}
// MARK: Hero
private var hero: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .center, spacing: 12) {
WAvatar(name: account.name ?? account.email, seed: account.id, size: 46)
.overlay(Circle().strokeBorder(.white.opacity(0.55), lineWidth: 1.5))
VStack(alignment: .leading, spacing: 5) {
Text(account.email)
.font(.title2.bold())
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.7)
.textSelection(.enabled)
HStack(spacing: 8) {
heroStatusPill
Text(heroMetaLine)
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
.lineLimit(1)
}
ResourceViewers(resource: store.presenceKey)
}
}
heroStats
}
.padding(.horizontal, 20)
.padding(.top, 2)
.padding(.bottom, 18)
}
/// Connection + warmup state in one glass capsule.
private var heroStatusPill: some View {
HStack(spacing: 5) {
Circle()
.fill(account.statusTone.color)
.frame(width: 7, height: 7)
.modifier(PingEffect(active: account.isWarmingActive, color: account.statusTone.color))
Text(account.statusLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
Text("· \(account.warmupState.title.lowercased())")
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.78))
}
.padding(.horizontal, 9)
.padding(.vertical, 4)
.background(.white.opacity(0.16), in: Capsule())
}
private var heroMetaLine: String {
if let name = account.name, !name.isEmpty {
return "\(account.providerLabel) · \(name)"
}
return account.providerLabel
}
private var heroStats: some View {
let usage = store.analytics?.dailyUsage
let ws = store.analytics?.warmupStatus
return HStack(spacing: 10) {
AirStatChip(
value: health?.score.map { "\($0)" } ?? "",
label: "Health",
symbol: "heart.fill"
)
AirStatChip(
value: usage?.campaignSent.map { "\($0)/\(usage?.campaignLimit ?? account.campaignLimit ?? 50)" } ?? "",
label: "Sent today",
symbol: "paperplane.fill"
)
AirStatChip(
value: ws.flatMap { s in s.currentVolume.map { "\($0)/\(s.targetVolume ?? 0)" } } ?? "",
label: "Warmup",
symbol: "flame.fill"
)
}
}
// MARK: Content
/// Horizontal page swipe between tabs, synced with the pill bar.
private var content: some View {
TabView(selection: $tab) {
overviewTab
.tag(MailboxDetailTab.overview)
warmupTab
.tag(MailboxDetailTab.warmup)
setupTab
.tag(MailboxDetailTab.setup)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.snappy, value: tab)
}
// MARK: Overview tab
private var overviewTab: some View {
List {
if let health {
healthSection(health)
} else {
Section("Health") {
Text("Health data isn't available yet.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
private func healthSection(_ health: MailboxHealth) -> some View {
Section("Health") {
HStack(spacing: 14) {
HealthRing(score: health.score, tone: health.tone, size: 44)
VStack(alignment: .leading, spacing: 2) {
Text(health.label)
.font(.body.weight(.medium))
.foregroundStyle(health.tone.color)
Text("Health score")
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
if let issues = health.issues, !issues.isEmpty {
ForEach(Array(issues.enumerated()), id: \.offset) { _, issue in
Label(issue, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if let errors = store.analytics?.errors, !errors.isEmpty {
ForEach(errors) { error in
VStack(alignment: .leading, spacing: 3) {
Text(error.title ?? "Issue")
.font(.body.weight(.medium))
.foregroundStyle(error.tone.color)
if let message = error.message {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let action = error.actionRequired, !action.isEmpty {
Text(action)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
}
}
// MARK: Warmup tab
private var warmupTab: some View {
List {
warmupSection
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
@ViewBuilder
private var warmupSection: some View {
Section("Warmup") {
if let ws = store.analytics?.warmupStatus {
HStack(spacing: 12) {
warmupStat("\(ws.currentVolume ?? 0)/\(ws.targetVolume ?? 0)", label: "Today")
warmupStat("\(ws.maxVolume ?? account.warmupMax ?? 40)", label: "Ceiling /day")
warmupStat("\(ws.daysActive ?? 0)", label: "Days warming")
}
.padding(.vertical, 6)
} else {
Text(account.warmupState.title)
.font(.subheadline)
.foregroundStyle(.secondary)
}
if let wh = store.analytics?.warmupHealth, wh.isDegraded {
StatusPill(text: wh.stateLabel, tone: wh.tone)
}
if let ban = store.banStatus, ban.blocked == true {
Label(ban.reason ?? "Blocked from the warmup pool", systemImage: "hand.raised")
.font(.footnote)
.foregroundStyle(WTheme.negative)
}
if canManage {
warmupControls
}
}
}
private func warmupStat(_ value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(value)
.font(.system(size: 22, weight: .bold, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText())
Text(label)
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var warmupControls: some View {
switch account.warmupState {
case .active:
Button {
Task { await store.warmupAction(env.api, "pause") }
} label: {
Label("Pause warmup", systemImage: "pause.fill")
}
Button(role: .destructive) {
Task { await store.warmupAction(env.api, "stop") }
} label: {
Label("Stop and reset", systemImage: "stop.fill")
}
case .paused:
Button {
Task { await store.warmupAction(env.api, "resume") }
} label: {
Label("Resume warmup", systemImage: "flame.fill")
}
.buttonStyle(.borderedProminent)
.tint(WTheme.accent)
Button(role: .destructive) {
Task { await store.warmupAction(env.api, "stop") }
} label: {
Label("Stop and reset", systemImage: "stop.fill")
}
case .off:
Button {
Task { await store.warmupAction(env.api, "start") }
} label: {
Label("Start warmup", systemImage: "flame.fill")
}
.buttonStyle(.borderedProminent)
.tint(WTheme.accent)
}
}
// MARK: Setup tab (domain auth + identity details)
private var setupTab: some View {
List {
authSection
identitySection
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
@ViewBuilder
private var authSection: some View {
Section("Domain authentication") {
if let auth = store.authCheck {
authRow("SPF", ok: auth.spfFound == true)
authRow("DKIM", ok: auth.dkimFound == true)
authRow("DMARC", ok: auth.dmarcFound == true)
if let summary = auth.summary, !summary.isEmpty {
Text(summary)
.font(.footnote)
.foregroundStyle(.secondary)
}
} else {
Text("Check \(account.senderDomain) for SPF, DKIM, and DMARC alignment.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Button {
Task { await store.runAuthCheck(env.api) }
} label: {
if store.isCheckingAuth {
ProgressView().controlSize(.small)
} else {
Text(store.authCheck == nil ? "Check now" : "Re-check")
}
}
.disabled(store.isCheckingAuth)
}
}
private func authRow(_ label: String, ok: Bool) -> some View {
HStack(spacing: 12) {
IconTile(symbol: ok ? "checkmark.seal.fill" : "xmark.circle", tone: ok ? .emerald : .slate, size: 34)
Text(label)
.font(.body.weight(.medium))
Spacer()
Text(ok ? "Found" : "Missing")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
private var identitySection: some View {
Section("Details") {
if let name = account.name, !name.isEmpty {
LabeledContent("Sender name", value: name)
}
LabeledContent("Provider", value: account.providerLabel)
LabeledContent("Daily cap") {
Text("\(account.campaignLimit ?? 50)/day")
.monospacedDigit()
}
LabeledContent("Min gap") {
Text(MailboxFormat.gap(account.minWaitTime ?? 600))
.monospacedDigit()
}
if let replyTo = account.replyTo, !replyTo.isEmpty {
LabeledContent("Reply-to", value: replyTo)
}
if let synced = account.lastSyncedAt {
LabeledContent("Last synced", value: WFormat.relative(synced))
}
if let created = account.createdAt {
LabeledContent("Connected", value: WFormat.relative(created))
}
}
}
}
@@ -0,0 +1,536 @@
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: - 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 = Monday); 0 or 127 = every day.
static func weekdays(_ mask: Int) -> String {
guard mask > 0, mask < 127 else { return "every day" }
let names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
let picked = (0 ..< 7).filter { mask & (1 << $0) != 0 }.map { names[$0] }
return picked.joined(separator: " ")
}
}
@@ -0,0 +1,289 @@
import SwiftUI
/// Mailboxes screen, pushed from Home or More: fleet summary, server-side
/// search, cursor pagination, and realtime pulse reloads. No NavigationStack
/// of its own; it lives on the pushing tab's stack.
struct MailboxesRootView: View {
@Environment(AppEnvironment.self) private var env
@State private var store = MailboxesStore()
@State private var searchText = ""
@State private var showConnect = false
@State private var pendingRemove: EmailAccount?
private var canManage: Bool { env.session.can(.manageEmails) }
private var canViewAnalytics: Bool { env.session.can(.viewAnalytics) }
var body: some View {
content
.navigationTitle("Mailboxes")
.background(Color(.systemGroupedBackground))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showConnect = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Connect a mailbox")
}
}
.navigationDestination(for: EmailAccount.self) { account in
MailboxDetailView(account: account)
}
.searchable(text: $searchText, prompt: "Search mailboxes")
.task(id: searchText) { await runSearch() }
.onChange(of: env.realtime.pulse(for: .emailAccounts)) {
Task { await store.load(env.api, includeStatuses: canViewAnalytics) }
}
.onChange(of: env.realtime.pulse(for: .analytics)) {
Task { await store.loadStatuses(env.api) }
}
.sheet(isPresented: $showConnect) { MailboxConnectSheet() }
.confirmationDialog(
"Remove \(pendingRemove?.email ?? "this mailbox")?",
isPresented: Binding(
get: { pendingRemove != nil },
set: { if !$0 { pendingRemove = nil } }
),
titleVisibility: .visible,
presenting: pendingRemove
) { account in
Button("Remove account", role: .destructive) {
Task { await store.deleteAccount(env.api, id: account.id) }
}
} message: { account in
Text("Sending stops immediately and \(account.email) leaves all warmup pools.")
}
.alert(
"Something went wrong",
isPresented: Binding(
get: { store.actionError != nil },
set: { if !$0 { store.actionError = nil } }
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(store.actionError ?? "")
}
}
// MARK: Content
@ViewBuilder
private var content: some View {
if !store.hasLoaded {
ScrollView { SkeletonRows(rows: 10) }
} else if let error = store.loadError, store.accounts.isEmpty {
ErrorStateView(title: "Couldn't load mailboxes", message: error) {
await store.load(env.api, includeStatuses: canViewAnalytics)
}
} else if store.accounts.isEmpty {
if searchText.isEmpty {
EmptyStateView(
title: "No mailboxes yet",
message: "Connect a sender account to start warming and sending.",
ctaTitle: "Connect a mailbox"
) { showConnect = true }
} else {
EmptyStateView(
title: "No matches",
message: "No mailbox matches \"\(searchText)\"."
)
}
} else {
accountList
}
}
private var accountList: some View {
List {
Section {
fleetSummary
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 2, leading: 20, bottom: 4, trailing: 20))
}
Section {
ForEach(store.accounts) { account in
NavigationLink(value: account) {
MailboxRowView(account: account, status: store.statuses[account.id])
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
swipeButtons(for: account)
}
}
if store.nextCursor != nil {
HStack {
Spacer()
ProgressView().controlSize(.small)
Spacer()
}
.listRowSeparator(.hidden)
.task { await store.loadMore(env.api) }
}
}
}
.listStyle(.insetGrouped)
.refreshable { await store.load(env.api, includeStatuses: canViewAnalytics) }
}
private var fleetSummary: some View {
HStack(spacing: 10) {
fleetStat(
value: "\(store.totalCount ?? store.accounts.count)",
label: "Connected",
symbol: "envelope.fill",
tone: .sky
)
fleetStat(
value: "\(store.warmingCount)",
label: "Warming",
symbol: "flame.fill",
tone: store.warmingCount > 0 ? .orange : .slate
)
fleetStat(
value: "\(store.issueCount)",
label: "Issues",
symbol: store.issueCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.circle.fill",
tone: store.issueCount > 0 ? .rose : .emerald
)
}
}
private func fleetStat(value: String, label: String, symbol: String, tone: Tone) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(tone.color)
Text(value)
.font(.system(size: 22, weight: .bold, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText())
}
Text(label)
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
@ViewBuilder
private func swipeButtons(for account: EmailAccount) -> some View {
if canManage {
Button {
pendingRemove = account
} label: {
Label("Remove", systemImage: "trash")
}
.tint(WTheme.negative)
switch account.warmupState {
case .active:
Button {
Task { await store.warmupAction(env.api, id: account.id, action: "pause") }
} label: {
Label("Pause", systemImage: "pause.fill")
}
.tint(WTheme.warning)
case .paused:
Button {
Task { await store.warmupAction(env.api, id: account.id, action: "resume") }
} label: {
Label("Resume", systemImage: "flame.fill")
}
.tint(Tone.orange.color)
case .off:
Button {
Task { await store.warmupAction(env.api, id: account.id, action: "start") }
} label: {
Label("Warm up", systemImage: "flame.fill")
}
.tint(Tone.orange.color)
}
}
}
// MARK: Search + initial load
private func runSearch() async {
if store.hasLoaded {
try? await Task.sleep(for: .milliseconds(350))
if Task.isCancelled { return }
}
store.query = searchText
await store.load(env.api, includeStatuses: canViewAnalytics)
}
}
// MARK: - Row
struct MailboxRowView: View {
let account: EmailAccount
let status: AccountAnalytics?
private var health: MailboxHealth? { status?.health }
var body: some View {
HStack(spacing: 12) {
WAvatar(name: account.email, seed: account.id, size: 42)
.overlay(alignment: .bottomTrailing) {
if account.isWarmingActive {
Image(systemName: "flame.fill")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.frame(width: 16, height: 16)
.background(Tone.orange.color, in: Circle())
.overlay(Circle().strokeBorder(Color(.secondarySystemGroupedBackground), lineWidth: 1.5))
.offset(x: 3, y: 3)
}
}
VStack(alignment: .leading, spacing: 3) {
Text(account.email)
.font(.body.weight(.medium))
.lineLimit(1)
HStack(spacing: 5) {
Text(account.providerLabel)
.foregroundStyle(.secondary)
warmupCaption
}
.font(.footnote)
}
Spacer(minLength: 8)
if let health {
HealthRing(score: health.score, tone: health.tone)
.modifier(PingEffect(active: health.hasIssue, color: health.tone.color))
}
}
.padding(.vertical, 6)
}
@ViewBuilder
private var warmupCaption: some View {
switch account.warmupState {
case .active:
Text("· \(volumeText)")
.monospacedDigit()
.foregroundStyle(Tone.orange.color)
case .paused:
Text("· warmup paused")
.foregroundStyle(WTheme.warning)
case .off:
if let sent = status?.dailyUsage?.campaignSent {
Text("· \(sent) sent today")
.monospacedDigit()
.foregroundStyle(.tertiary)
}
}
}
private var volumeText: String {
if let ws = status?.warmupStatus {
return "\(ws.currentVolume ?? 0)/\(ws.targetVolume ?? 0) warm"
}
return "warming"
}
}
@@ -0,0 +1,117 @@
import Foundation
import SwiftUI
/// List-screen store: the mailbox rows plus the per-account health snapshot
/// from `/analytics/accounts`, merged by id.
@MainActor
@Observable
final class MailboxesStore {
private(set) var accounts: [EmailAccount] = []
private(set) var statuses: [String: AccountAnalytics] = [:]
private(set) var hasLoaded = false
private(set) var isLoading = false
private(set) var isLoadingMore = false
private(set) var loadError: String?
private(set) var nextCursor: String?
private(set) var totalCount: Int?
var actionError: String?
var query = ""
// MARK: Derived stats
/// Warming = warmup anchor set and not paused; never a status string.
var warmingCount: Int { accounts.filter(\.isWarmingActive).count }
var issueCount: Int {
accounts.filter { statuses[$0.id]?.health?.hasIssue == true }.count
}
// MARK: Loading
func load(_ api: APIClient, includeStatuses: Bool) async {
isLoading = true
do {
let page: ListResponse<EmailAccount> = try await api.get("emails", query: listParams(cursor: nil))
withAnimation {
accounts = page.data
if let total = page.pagination?.total {
totalCount = Int(total)
} else {
totalCount = page.data.count
}
}
nextCursor = page.pagination?.nextCursor
loadError = nil
} catch {
if Task.isCancelled {
isLoading = false
return
}
loadError = error.localizedDescription
}
isLoading = false
hasLoaded = true
if includeStatuses { await loadStatuses(api) }
}
func loadMore(_ api: APIClient) async {
guard let cursor = nextCursor, !isLoadingMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let page: ListResponse<EmailAccount> = try await api.get("emails", query: listParams(cursor: cursor))
let known = Set(accounts.map(\.id))
withAnimation { accounts += page.data.filter { !known.contains($0.id) } }
nextCursor = page.pagination?.nextCursor
} catch {
nextCursor = nil
}
}
func loadStatuses(_ api: APIClient) async {
do {
let page: ListResponse<AccountAnalytics> = try await api.get("analytics/accounts")
withAnimation {
statuses = Dictionary(page.data.map { ($0.id, $0) }, uniquingKeysWith: { _, latest in latest })
}
} catch {
// Missing view analytics permission or a transient failure; the
// list stays usable without health data.
}
}
private func listParams(cursor: String?) -> [String: String?] {
var params: [String: String?] = ["limit": "200"]
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { params["q"] = trimmed }
if let cursor { params["cursor"] = cursor }
return params
}
// MARK: Row actions
/// action: "start" | "pause" | "resume" | "stop"; returns the updated row.
func warmupAction(_ api: APIClient, id: String, action: String) async {
do {
let updated: EmailAccount = try await api.post("emails/\(id)/warmup/\(action)")
if let index = accounts.firstIndex(where: { $0.id == id }) {
withAnimation { accounts[index] = updated }
}
} catch {
actionError = error.localizedDescription
}
}
func deleteAccount(_ api: APIClient, id: String) async {
do {
let _: EmptyBody = try await api.delete("emails/\(id)")
withAnimation {
accounts.removeAll { $0.id == id }
statuses.removeValue(forKey: id)
if let total = totalCount { totalCount = max(0, total - 1) }
}
} catch {
actionError = error.localizedDescription
}
}
}