Files
warmbly/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift
T

837 lines
30 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
private enum SetupField: Hashable { case name, replyTo }
@Environment(AppEnvironment.self) private var env
@Environment(\.dismiss) private var dismiss
@State private var store: MailboxDetailStore
@State private var tab: MailboxDetailTab = .overview
@State private var displayName = ""
@State private var replyTo = ""
@State private var fieldsSeeded = false
@State private var confirmDisconnect = false
@State private var showTagPicker = false
@FocusState private var focusedField: SetupField?
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 {
seedFieldsIfNeeded()
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) }
}
.onChange(of: focusedField) { previous, current in
// Text rows save on focus loss, like the profile settings rows.
if previous == .name, current != .name { saveDisplayName() }
if previous == .replyTo, current != .replyTo { saveReplyTo() }
}
.confirmationDialog(
"Disconnect \(account.email)?",
isPresented: $confirmDisconnect,
titleVisibility: .visible
) {
Button("Disconnect", role: .destructive) {
Task {
if await store.deleteAccount(env.api) { dismiss() }
}
}
} message: {
Text("Campaigns stop sending from it and history stays.")
}
.sheet(isPresented: $showTagPicker) {
MailboxTagPickerSheet(
title: "Mailbox tags",
confirmLabel: "Save",
initialSelection: account.tags ?? []
) { tagIDs in
await store.update(env.api, body: MailboxUpdateBody(tags: tagIDs)) { $0.tags = tagIDs }
}
}
.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(.plain)
}
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
rampSection
windowSection
}
.listStyle(.plain)
}
@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: Warmup ramp (editable)
// List responses omit warmup_reply_rate (decodes as 0), so prefer the
// analytics snapshot until a PATCH refreshes the row.
private var replyRateValue: Int {
if store.rowEdited { return account.warmupReplyRate ?? 0 }
return store.analytics?.warmupStatus?.replyRate ?? account.warmupReplyRate ?? 0
}
private var rampSection: some View {
Section {
settingStepper(
"Starting volume", helper: "Emails per day when warmup begins.",
value: account.warmupBase ?? 10, range: 1...100, suffix: "/day",
write: { $0.warmupBase = $1 }, mutate: { $0.warmupBase = $1 }
)
settingStepper(
"Daily increase", helper: "How many more each day as reputation builds.",
value: account.warmupIncrease ?? 1, range: 1...50, suffix: "+/day",
write: { $0.warmupIncrease = $1 }, mutate: { $0.warmupIncrease = $1 }
)
settingStepper(
"Maximum volume", helper: "Keep conservative for new mailboxes, about 40 a day.",
value: account.warmupMax ?? 40, range: max(1, account.warmupBase ?? 10)...500, suffix: "/day",
write: { $0.warmupMax = $1 }, mutate: { $0.warmupMax = $1 }
)
settingStepper(
"Reply rate", helper: "Share of warmup mail that gets a reply.",
value: replyRateValue, range: 0...100, suffix: "%",
write: { $0.warmupReplyRate = $1 }, mutate: { $0.warmupReplyRate = $1 }
)
} header: {
EyebrowLabel("Ramp configuration")
}
}
// MARK: Sending window (editable)
/// HH:MM in 30-minute steps, the format the scheduler parses ("15:04").
private static let timeSlots: [String] = (0..<48).map {
String(format: "%02d:%02d", $0 / 2, $0 % 2 * 30)
}
/// Backend bit layout is Go time.Weekday: bit 0 = Sunday ... bit 6 =
/// Saturday (scheduler findNextValidDay); mask 0 means every day.
private static let dayBits: [(label: String, bit: Int)] = [
("Mon", 1), ("Tue", 2), ("Wed", 3), ("Thu", 4), ("Fri", 5), ("Sat", 6), ("Sun", 0),
]
private var windowSection: some View {
Section {
timeRow("Start time", current: timeValue(account.warmupStartTime, fallback: "08:00")) { slot in
Task {
await store.update(env.api, body: MailboxUpdateBody(warmupStartTime: slot)) {
$0.warmupStartTime = slot
}
}
}
timeRow("End time", current: timeValue(account.warmupEndTime, fallback: "20:00")) { slot in
Task {
await store.update(env.api, body: MailboxUpdateBody(warmupEndTime: slot)) {
$0.warmupEndTime = slot
}
}
}
sendingDaysRow
} header: {
EyebrowLabel("Sending window")
}
}
private func timeValue(_ raw: String?, fallback: String) -> String {
guard let raw, !raw.isEmpty else { return fallback }
return raw
}
private func timeRow(_ title: String, current: String, apply: @escaping (String) -> Void) -> some View {
HStack(spacing: 12) {
Text(title)
.font(.body.weight(.medium))
Spacer(minLength: 8)
if canManage {
Menu {
ForEach(Self.timeSlots, id: \.self) { slot in
Button {
apply(slot)
} label: {
if slot == current {
Label(slot, systemImage: "checkmark")
} else {
Text(slot)
}
}
}
} label: {
HStack(spacing: 3) {
Text(current)
.font(.subheadline)
.monospacedDigit()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
}
.foregroundStyle(WTheme.accent)
}
} else {
Text(current)
.font(.subheadline)
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private var sendingDaysRow: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Sending days")
.font(.body.weight(.medium))
HStack(spacing: 6) {
ForEach(Self.dayBits, id: \.bit) { day in
dayChip(day.label, bit: day.bit)
}
}
Text("Leave all off to send every day.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.vertical, 6)
}
private func dayChip(_ label: String, bit: Int) -> some View {
let mask = account.warmupDays ?? 0
let on = mask & (1 << bit) != 0
return Button {
let next = mask ^ (1 << bit)
Task {
await store.update(env.api, body: MailboxUpdateBody(warmupDays: next)) {
$0.warmupDays = next
}
}
} label: {
Text(label)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(on ? Color.white : Color.secondary)
.frame(maxWidth: .infinity)
.frame(height: 30)
.background(on ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Tone.slate.background), in: Capsule())
}
.buttonStyle(TapScaleStyle())
.disabled(!canManage)
}
// MARK: Shared editing controls
private func settingStepper(
_ title: String,
helper: String,
value: Int,
range: ClosedRange<Int>,
suffix: String,
write: @escaping (inout MailboxUpdateBody, Int) -> Void,
mutate: @escaping (inout EmailAccount, Int) -> Void
) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.body.weight(.medium))
Text(helper)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if canManage {
HStack(spacing: 10) {
stepButton("minus", enabled: value > range.lowerBound) {
step(to: max(range.lowerBound, value - 1), write: write, mutate: mutate)
}
Text("\(value)\(suffix)")
.font(.system(size: 15, weight: .semibold))
.monospacedDigit()
.frame(minWidth: 46)
.contentTransition(.numericText())
stepButton("plus", enabled: value < range.upperBound) {
step(to: min(range.upperBound, value + 1), write: write, mutate: mutate)
}
}
} else {
Text("\(value)\(suffix)")
.font(.subheadline)
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private func step(
to newValue: Int,
write: @escaping (inout MailboxUpdateBody, Int) -> Void,
mutate: @escaping (inout EmailAccount, Int) -> Void
) {
Task {
await store.updateDebounced(
env.api,
mutateBody: { write(&$0, newValue) },
mutate: { mutate(&$0, newValue) }
)
}
}
private func stepButton(_ symbol: String, enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(enabled ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Color(.tertiaryLabel)))
.frame(width: 30, height: 30)
.background(Tone.slate.background, in: Circle())
}
.buttonStyle(TapScaleStyle())
.disabled(!enabled)
}
// MARK: Setup tab (domain auth + identity details)
private var setupTab: some View {
List {
senderProfileSection
tagsSection
limitsSection
authSection
identitySection
if canManage {
dangerSection
}
}
.listStyle(.plain)
}
// MARK: Sender profile (editable)
private var senderProfileSection: some View {
Section {
fieldRow("Display name") {
TextField("Sender name", text: $displayName)
.focused($focusedField, equals: .name)
.submitLabel(.done)
.onSubmit { saveDisplayName() }
.disabled(!canManage)
}
VStack(alignment: .leading, spacing: 4) {
fieldRow("Reply-to") {
TextField(account.email, text: $replyTo)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .replyTo)
.submitLabel(.done)
.onSubmit { saveReplyTo() }
.disabled(!canManage)
}
Text("Where replies land. Empty uses the mailbox address.")
.font(.footnote)
.foregroundStyle(.secondary)
}
} header: {
EyebrowLabel("Sender profile")
}
}
private func fieldRow<Field: View>(_ label: String, @ViewBuilder field: () -> Field) -> some View {
HStack(spacing: 12) {
Text(label)
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(width: 96, alignment: .leading)
field()
}
.padding(.vertical, 4)
}
private func seedFieldsIfNeeded() {
guard !fieldsSeeded else { return }
displayName = account.name ?? ""
replyTo = account.replyTo ?? ""
fieldsSeeded = true
}
private func saveDisplayName() {
let trimmed = displayName.trimmingCharacters(in: .whitespaces)
guard trimmed != (account.name ?? "") else { return }
Task {
await store.update(env.api, body: MailboxUpdateBody(name: trimmed)) { $0.name = trimmed }
displayName = store.account.name ?? trimmed
}
}
private func saveReplyTo() {
let trimmed = replyTo.trimmingCharacters(in: .whitespaces)
guard trimmed != (account.replyTo ?? "") else { return }
Task {
// Empty string clears reply-to; replies go to the mailbox itself.
await store.update(env.api, body: MailboxUpdateBody(replyTo: trimmed)) { $0.replyTo = trimmed }
replyTo = store.account.replyTo ?? ""
}
}
// MARK: Tags (editable)
/// The account's tag ids resolved against the session registry; unknown
/// (deleted) ids are hidden.
private var accountTagGroups: [UserGroup] {
let known = env.session.user?.tags ?? []
return (account.tags ?? [])
.compactMap { id in known.first { $0.id == id } }
.sorted { ($0.position ?? 0) < ($1.position ?? 0) }
}
private var tagsSection: some View {
Section {
Button {
showTagPicker = true
} label: {
HStack(spacing: 12) {
if accountTagGroups.isEmpty {
Text("No tags")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(accountTagGroups) { tag in
tagChip(tag)
}
}
}
}
Spacer(minLength: 8)
if canManage {
Text("Edit")
.font(.subheadline)
.foregroundStyle(WTheme.accent)
}
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!canManage)
Text("Campaigns can pick senders by tag.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: {
EyebrowLabel("Tags")
}
}
private func tagChip(_ tag: UserGroup) -> some View {
let tint = Color(uniboxHex: tag.color) ?? WTheme.accent
return HStack(spacing: 5) {
Circle().fill(tint).frame(width: 7, height: 7)
Text(tag.name ?? "Tag")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(tint)
.lineLimit(1)
}
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(tint.opacity(0.12), in: Capsule())
}
// MARK: Sending limits (editable)
// min_wait_time is stored in seconds; edit it in whole minutes.
private var minGapMinutes: Int {
max(1, min(120, (account.minWaitTime ?? 600) / 60))
}
private var limitsSection: some View {
Section {
settingStepper(
"Daily campaign cap", helper: "Max cold emails per day. Default 50, raise only with good reputation.",
value: account.campaignLimit ?? 50, range: 1...100, suffix: "/day",
write: { $0.campaignLimit = $1 }, mutate: { $0.campaignLimit = $1 }
)
settingStepper(
"Minimum gap", helper: "Time between two sends from this mailbox.",
value: minGapMinutes, range: 1...120, suffix: " min",
write: { $0.minWaitTime = $1 * 60 }, mutate: { $0.minWaitTime = $1 * 60 }
)
} header: {
EyebrowLabel("Sending limits")
}
}
// MARK: Danger zone
private var dangerSection: some View {
Section {
Button(role: .destructive) {
confirmDisconnect = true
} label: {
HStack {
Spacer()
Text("Disconnect mailbox")
Spacer()
}
}
} header: {
EyebrowLabel("Danger zone")
}
}
@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)
}
// Name, reply-to, cap and gap moved into the editable sections above.
private var identitySection: some View {
Section("Details") {
LabeledContent("Provider", value: account.providerLabel)
if let synced = account.lastSyncedAt {
LabeledContent("Last synced", value: WFormat.relative(synced))
}
if let created = account.createdAt {
LabeledContent("Connected", value: WFormat.relative(created))
}
}
}
}