diff --git a/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift b/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift new file mode 100644 index 00000000..f26a043f --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift @@ -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 + } + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift b/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift new file mode 100644 index 00000000..9541bc8e --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift @@ -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 + } + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift b/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift new file mode 100644 index 00000000..3ed94dec --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift @@ -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:` 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)) + } + } + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxModels.swift b/ios/Warmbly/Features/Mailboxes/MailboxModels.swift new file mode 100644 index 00000000..6dc714d3 --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxModels.swift @@ -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: " ") + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift b/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift new file mode 100644 index 00000000..9f68a413 --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift @@ -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" + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift b/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift new file mode 100644 index 00000000..cb96bc97 --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift @@ -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 = 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 = 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 = 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 + } + } +}