mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-23 08:00:28 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user