feat: iOS mailboxes rebuilt end to end - the list is a full-screen drawer browser cover (warmup status scopes with live counts, search pill, multi-select bulk warmup/remove, circular close) since pushing it fought the system back swipe; connecting is an air onboarding flow with real Google/Outlook brand logos, a web-style authorize step with scope bullets, working in-app OAuth via ASWebAuthenticationSession against the new warmbly:// callback, an SMTP/IMAP wizard, and a start-warmup offer on success; the detail screen becomes editable (warmup ramp steppers, sending window and weekday bitmask chips matching the Go scheduler's Sunday-first layout, sender profile, sending limits, disconnect) with debounced optimistic PATCHes

This commit is contained in:
Matthew Meszaros
2026-07-11 10:13:10 +02:00
parent 07a6777536
commit de85abc47c
12 changed files with 2501 additions and 416 deletions
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "google-logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "outlook-logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because it is too large Load Diff
@@ -1,257 +0,0 @@
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
}
}
}
@@ -16,8 +16,16 @@ final class MailboxDetailStore {
private(set) var banStatus: MailboxBanStatus?
private(set) var isCheckingAuth = false
private(set) var isSaving = false
/// True once a local edit or PATCH response refreshed the row, so views
/// can stop preferring analytics for fields the list response omits.
private(set) var rowEdited = false
var actionError: String?
// Debounce state for stepper bursts: one snapshot per burst, one body.
private var pendingBody = MailboxUpdateBody()
private var pendingSnapshot: EmailAccount?
private var editGeneration = 0
init(account: EmailAccount) {
self.account = account
}
@@ -75,6 +83,7 @@ final class MailboxDetailStore {
defer { isSaving = false }
do {
let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body)
rowEdited = true
apply(updated)
return true
} catch {
@@ -82,4 +91,63 @@ final class MailboxDetailStore {
return false
}
}
/// Optimistic partial update: apply the local mutation immediately, PATCH,
/// then adopt the server's row (or roll back and surface the error).
func update(_ api: APIClient, body: MailboxUpdateBody, mutate: (inout EmailAccount) -> Void) async {
let previous = account
rowEdited = true
withAnimation(.snappy) { mutate(&account) }
do {
let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body)
withAnimation(.snappy) { account = updated }
} catch {
withAnimation(.snappy) { account = previous }
actionError = error.localizedDescription
}
}
/// Stepper variant: every tap applies locally right away, but rapid taps
/// coalesce into one PATCH after 600ms of quiet. A failed PATCH rolls the
/// whole burst back to the pre-burst row.
func updateDebounced(
_ api: APIClient,
mutateBody: (inout MailboxUpdateBody) -> Void,
mutate: (inout EmailAccount) -> Void
) async {
if pendingSnapshot == nil { pendingSnapshot = account }
mutateBody(&pendingBody)
rowEdited = true
withAnimation(.snappy) { mutate(&account) }
editGeneration += 1
let generation = editGeneration
try? await Task.sleep(for: .milliseconds(600))
guard generation == editGeneration else { return }
let body = pendingBody
let snapshot = pendingSnapshot
pendingBody = MailboxUpdateBody()
pendingSnapshot = nil
do {
let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body)
withAnimation(.snappy) { account = updated }
} catch {
if let snapshot {
withAnimation(.snappy) { account = snapshot }
}
actionError = error.localizedDescription
}
}
// MARK: Disconnect
/// DELETE /emails/:id (204); true on success so the view can pop.
func deleteAccount(_ api: APIClient) async -> Bool {
do {
let _: EmptyBody = try await api.delete("emails/\(account.id)")
return true
} catch {
actionError = error.localizedDescription
return false
}
}
}
@@ -27,10 +27,18 @@ enum MailboxDetailTab: String, CaseIterable, Identifiable {
/// 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
@FocusState private var focusedField: SetupField?
init(account: EmailAccount) {
_store = State(initialValue: MailboxDetailStore(account: account))
@@ -55,7 +63,10 @@ struct MailboxDetailView: View {
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.presenceResource(store.presenceKey)
.task { await store.loadAnalytics(env.api) }
.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) }
@@ -63,6 +74,24 @@ struct MailboxDetailView: View {
.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.")
}
.alert(
"Something went wrong",
isPresented: Binding(
@@ -185,8 +214,7 @@ struct MailboxDetailView: View {
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.listStyle(.plain)
}
private func healthSection(_ health: MailboxHealth) -> some View {
@@ -239,9 +267,10 @@ struct MailboxDetailView: View {
private var warmupTab: some View {
List {
warmupSection
rampSection
windowSection
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.listStyle(.plain)
}
@ViewBuilder
@@ -327,15 +356,351 @@ struct MailboxDetailView: View {
}
}
// 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
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: 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")
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
@ViewBuilder
@@ -381,23 +746,10 @@ struct MailboxDetailView: View {
.padding(.vertical, 2)
}
// Name, reply-to, cap and gap moved into the editable sections above.
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))
}
@@ -526,11 +526,13 @@ enum MailboxFormat {
return "\(seconds) s"
}
/// Weekday bitmask (bit 0 = Monday); 0 or 127 = every day.
/// Weekday bitmask (bit 0 = Sunday, matching Go's time.Weekday in the
/// scheduler); 0 or 127 = every day. Rendered Mon-first.
static func weekdays(_ mask: Int) -> String {
guard mask > 0, mask < 127 else { return "every day" }
let names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
let picked = (0 ..< 7).filter { mask & (1 << $0) != 0 }.map { names[$0] }
let names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
let monFirst = [1, 2, 3, 4, 5, 6, 0]
let picked = monFirst.filter { mask & (1 << $0) != 0 }.map { names[$0] }
return picked.joined(separator: " ")
}
}
@@ -1,174 +1,494 @@
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.
/// Mailboxes browser, presented as a full-screen cover from Home or More
/// (like the CRM browsers): a slide-in drawer (sky hero + warmup status
/// scopes with live counts + sliding tone capsule + edge swipe), a search
/// pill, bare fleet stat columns that drive the same scope, multi-select with
/// bulk warmup and remove, cursor pagination, and realtime pulse reloads.
/// Owns its NavigationStack; dismissal goes through `onClose` (the
/// environment DismissAction is unreliable in this app's cover contexts).
struct MailboxesRootView: View {
var onClose: () -> Void = {}
@Environment(AppEnvironment.self) private var env
@State private var store = MailboxesStore()
@State private var searchText = ""
@State private var scope: MailboxScope = .all
@State private var sidebarOpen = false
@State private var sidebarDrag: CGFloat = 0
@State private var showConnect = false
@State private var pendingRemove: EmailAccount?
@State private var confirmBulkRemove = false
@FocusState private var searchFocused: Bool
private static let sidebarWidth: CGFloat = 300
private var canManage: Bool { env.session.can(.manageEmails) }
private var canViewAnalytics: Bool { env.session.can(.viewAnalytics) }
private var isSearching: Bool { !searchText.trimmingCharacters(in: .whitespaces).isEmpty }
private var connectedCount: Int { store.allCount }
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 ?? "")
}
/// The spine bumps emailAccounts and analytics together for account
/// events; one summed key avoids double reloads.
private var reloadPulse: Int {
env.realtime.pulse(for: .emailAccounts) &+ env.realtime.pulse(for: .analytics)
}
// MARK: Content
/// The loaded rows narrowed to the active scope.
private var visibleAccounts: [EmailAccount] {
switch scope {
case .all:
return store.accounts
case .warming:
return store.accounts.filter(\.isWarmingActive)
case .paused:
return store.accounts.filter(\.isWarmupPaused)
case .issues:
return store.accounts.filter { store.statuses[$0.id]?.health?.hasIssue == true }
case .off:
return store.accounts.filter { $0.warmup == nil }
}
}
var body: some View {
NavigationStack {
browser
}
}
private var browser: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
mainPane
.scaleEffect(sidebarOpen ? 0.97 : 1, anchor: .trailing)
if sidebarOpen {
Color.black.opacity(0.32)
.ignoresSafeArea()
.transition(.opacity)
.onTapGesture { closeSidebar() }
}
drawer(topInset: geo.safeAreaInsets.top)
}
}
// Own chrome, like the other drawer browsers: the drawer hero runs to
// the top and the search pill row carries the close button.
.toolbarVisibility(.hidden, for: .navigationBar)
.navigationDestination(for: EmailAccount.self) { account in
MailboxDetailView(account: account)
}
.task(id: searchText) { await runSearch() }
.onChange(of: reloadPulse) {
Task { await store.load(env.api, includeStatuses: canViewAnalytics) }
}
.onChange(of: scope) { store.exitSelection() }
.sensoryFeedback(.selection, trigger: scope)
.sensoryFeedback(.impact(weight: .light), trigger: sidebarOpen)
.sensoryFeedback(.impact(weight: .medium), trigger: store.isSelecting)
.fullScreenCover(isPresented: $showConnect) {
MailboxConnectFlow(onClose: { showConnect = false }, onConnected: { account in
store.insert(account)
})
}
.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.")
}
.confirmationDialog(
"Remove \(store.selectedCount) mailbox\(store.selectedCount == 1 ? "" : "es")?",
isPresented: $confirmBulkRemove,
titleVisibility: .visible
) {
Button("Remove \(store.selectedCount)", role: .destructive) {
Task { await store.bulkRemove(env.api) }
}
} message: {
Text("This disconnects them from Warmbly.")
}
.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: Main pane
private var mainPane: some View {
VStack(spacing: 0) {
if store.isSelecting { selectionHeader } else { searchBar }
if store.hasLoaded, !isSearching, !store.accounts.isEmpty {
statsHeader
}
scopeCaption
listArea
}
.background(Color(.systemBackground))
.overlay(alignment: .bottom) {
if store.isSelecting {
MailboxSelectionBar(
count: store.selectedCount,
onStart: { Task { await store.bulkWarmup(env.api, action: "start") } },
onPause: { Task { await store.bulkWarmup(env.api, action: "pause") } },
onRemove: { confirmBulkRemove = true },
onClear: { store.exitSelection() }
)
.padding(.bottom, 10)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.simultaneousGesture(
DragGesture(minimumDistance: 25)
.onEnded { value in
// Gmail's edge swipe: open the drawer from the left edge.
if !sidebarOpen, !store.isSelecting, value.startLocation.x < 44, value.translation.width > 70 {
openSidebar()
}
}
)
}
// MARK: Search pill
private var searchBar: some View {
HStack(spacing: 10) {
HStack(spacing: 6) {
Button {
openSidebar()
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.primary)
.frame(width: 38, height: 38)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Open mailboxes menu")
TextField("Search by email", text: $searchText)
.font(.subheadline)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.focused($searchFocused)
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear search")
}
PresenceAvatars()
.padding(.trailing, 8)
}
.frame(height: 44)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
if canManage {
Button {
showConnect = true
} label: {
Image(systemName: "plus")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.primary)
.frame(width: 44, height: 44)
.background(Color(.secondarySystemBackground), in: Circle())
}
.buttonStyle(TapScaleStyle())
.accessibilityLabel("Connect a mailbox")
}
Button {
onClose()
} label: {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 44, height: 44)
.background(Color(.secondarySystemBackground), in: Circle())
}
.buttonStyle(TapScaleStyle())
.accessibilityLabel("Close mailboxes")
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 6)
}
// MARK: Selection header
private var selectionHeader: some View {
let visibleIDs = visibleAccounts.map(\.id)
return HStack(spacing: 12) {
Button("Done") { store.exitSelection() }
.fontWeight(.semibold)
Spacer()
Text("\(store.selectedCount) selected")
.font(.subheadline.weight(.semibold))
.monospacedDigit()
.contentTransition(.numericText())
Spacer()
Button(store.allSelected(of: visibleIDs) ? "Clear" : "Select all") {
store.selectAll(visibleIDs)
}
}
.padding(.horizontal, 16)
.frame(height: 44)
.padding(.top, 4)
.padding(.bottom, 6)
}
// MARK: Fleet stats
/// Bare stat columns over a hairline; each drives the same scope state as
/// the drawer.
private var statsHeader: some View {
VStack(spacing: 10) {
HStack(spacing: 10) {
fleetStat("Connected", count: connectedCount, tone: nil, target: .all)
fleetStat("Warming", count: store.warmingCount, tone: store.warmingCount > 0 ? .orange : nil, target: .warming)
fleetStat(
"Issues",
count: store.issueCount,
tone: store.issueCount > 0 ? .rose : nil,
target: .issues,
enabled: canViewAnalytics
)
}
Divider()
}
.padding(.horizontal, 20)
.padding(.top, 6)
}
private func fleetStat(_ label: String, count: Int, tone: Tone?, target: MailboxScope, enabled: Bool = true) -> some View {
let selected = scope == target && target != .all
return Button {
withAnimation(.snappy) { scope = selected ? .all : target }
} label: {
VStack(alignment: .leading, spacing: 3) {
EyebrowLabel(label)
Text("\(count)")
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(tone?.color ?? Color.primary)
.contentTransition(.numericText())
Capsule()
.fill(selected ? (tone?.color ?? WTheme.accent) : Color.clear)
.frame(width: 26, height: 3)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(label), \(count)")
.accessibilityAddTraits(selected ? .isSelected : [])
}
// MARK: Scope caption
private var scopeCaption: some View {
HStack(spacing: 6) {
Text(captionTitle)
.font(.caption.weight(.semibold))
.tracking(0.9)
.foregroundStyle(.secondary)
if !isSearching, store.count(for: scope) > 0 {
Text(WFormat.compact(store.count(for: scope)))
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.tertiary)
.contentTransition(.numericText())
}
Spacer()
if isSearching, store.hasLoaded {
Text("\(store.totalCount ?? store.accounts.count) found")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.secondary)
.contentTransition(.numericText())
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 2)
}
private var captionTitle: String {
if isSearching { return "SEARCH RESULTS" }
return scope.title.uppercased()
}
// MARK: List
@ViewBuilder
private var content: some View {
private var listArea: 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 if visibleAccounts.isEmpty {
emptyState
} 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)
}
List {
ForEach(visibleAccounts) { account in
row(account)
}
if store.nextCursor != nil {
HStack {
HStack(spacing: 8) {
Spacer()
ProgressView().controlSize(.small)
Text("Loading more…")
.font(.footnote)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.vertical, 6)
.listRowSeparator(.hidden)
.task { await store.loadMore(env.api) }
} else {
endMarker
}
}
}
.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
)
.listStyle(.plain)
.scrollDismissesKeyboard(.immediately)
.refreshable { await store.load(env.api, includeStatuses: canViewAnalytics) }
}
}
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())
@ViewBuilder
private func row(_ account: EmailAccount) -> some View {
Group {
if store.isSelecting {
Button {
store.toggleSelected(account.id)
} label: {
MailboxRowView(
account: account,
status: store.statuses[account.id],
selecting: true,
selected: store.isSelected(account.id)
)
}
.buttonStyle(.plain)
} else {
MailboxRowView(account: account, status: store.statuses[account.id])
.background(NavigationLink(value: account) { EmptyView() }.opacity(0))
}
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))
.listRowBackground(store.isSelected(account.id) ? Tone.sky.background : Color(.systemBackground))
.listRowInsets(EdgeInsets(top: 4, leading: 14, bottom: 4, trailing: 16))
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.4).onEnded { _ in
if !store.isSelecting, canManage { store.enterSelection(with: account.id) }
}
)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if !store.isSelecting {
swipeButtons(for: account)
}
}
}
/// End-of-list marker: the exact total for the current scope or search.
private var endMarker: some View {
let count = scope == .all ? connectedCount : visibleAccounts.count
return HStack {
Spacer()
Text("\(count) mailbox\(count == 1 ? "" : "es")")
.font(.footnote)
.monospacedDigit()
.foregroundStyle(.tertiary)
Spacer()
}
.padding(.vertical, 10)
.listRowSeparator(.hidden)
}
@ViewBuilder
private var emptyState: some View {
if isSearching {
EmptyStateView(
title: "No matches",
message: "No mailbox matches \"\(searchText)\"."
)
} else {
switch scope {
case .warming:
EmptyStateView(
title: "Nothing warming",
message: "Start warmup on a mailbox and it shows up here."
)
case .paused:
EmptyStateView(
title: "Nothing paused",
message: "Mailboxes with warmup on hold show up here."
)
case .issues:
EmptyStateView(
title: "No issues",
message: "Every mailbox with health data looks fine right now."
)
case .off:
EmptyStateView(
title: "Everything is warming",
message: "Mailboxes that never started warmup show up here."
)
case .all:
connectEmptyState
}
}
}
private var connectEmptyState: some View {
VStack(spacing: 10) {
Image(systemName: "envelope.open")
.font(.system(size: 18))
.foregroundStyle(WTheme.accent)
.frame(width: 36, height: 36)
.background(Tone.sky.background, in: RoundedRectangle(cornerRadius: 8))
Text("No mailboxes yet")
.font(.system(size: 14, weight: .medium))
Text("Connect your first mailbox to start warming up and sending campaigns.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 260)
if canManage {
Button("Add mailbox") { showConnect = true }
.buttonStyle(.borderedProminent)
.controlSize(.small)
.padding(.top, 6)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 48)
}
@ViewBuilder
@@ -207,6 +527,60 @@ struct MailboxesRootView: View {
}
}
// MARK: Drawer
private func drawer(topInset: CGFloat) -> some View {
MailboxesSidebar(
store: store,
selection: scope,
topInset: topInset,
revealed: sidebarOpen
) { newScope in
withAnimation(.spring(response: 0.38, dampingFraction: 0.8)) { scope = newScope }
// Let the highlight capsule slide to the tapped row before closing.
Task {
try? await Task.sleep(for: .milliseconds(280))
closeSidebar()
}
}
.frame(width: Self.sidebarWidth)
.frame(maxHeight: .infinity)
.background(Color(.systemBackground))
.clipShape(UnevenRoundedRectangle(bottomTrailingRadius: 26, topTrailingRadius: 26, style: .continuous))
.shadow(color: .black.opacity(sidebarOpen ? 0.22 : 0), radius: 30, x: 6, y: 0)
.ignoresSafeArea()
.offset(x: drawerOffset)
.gesture(
DragGesture()
.onChanged { value in
sidebarDrag = min(0, value.translation.width)
}
.onEnded { value in
if value.translation.width < -80 || value.predictedEndTranslation.width < -160 {
closeSidebar()
} else {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { sidebarDrag = 0 }
}
}
)
}
private var drawerOffset: CGFloat {
(sidebarOpen ? 0 : -Self.sidebarWidth - 40) + sidebarDrag
}
private func openSidebar() {
searchFocused = false
withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { sidebarOpen = true }
}
private func closeSidebar() {
withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) {
sidebarOpen = false
sidebarDrag = 0
}
}
// MARK: Search + initial load
private func runSearch() async {
@@ -214,6 +588,10 @@ struct MailboxesRootView: View {
try? await Task.sleep(for: .milliseconds(350))
if Task.isCancelled { return }
}
// Searching runs server-side across the whole fleet; drop any filter.
if isSearching, scope != .all {
withAnimation(.snappy) { scope = .all }
}
store.query = searchText
await store.load(env.api, includeStatuses: canViewAnalytics)
}
@@ -224,11 +602,19 @@ struct MailboxesRootView: View {
struct MailboxRowView: View {
let account: EmailAccount
let status: AccountAnalytics?
var selecting: Bool = false
var selected: Bool = false
private var health: MailboxHealth? { status?.health }
var body: some View {
HStack(spacing: 12) {
if selecting {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
.foregroundStyle(selected ? WTheme.accent : Color(.tertiaryLabel))
.transition(.scale.combined(with: .opacity))
}
WAvatar(name: account.email, seed: account.id, size: 42)
.overlay(alignment: .bottomTrailing) {
if account.isWarmingActive {
@@ -237,7 +623,7 @@ struct MailboxRowView: View {
.foregroundStyle(.white)
.frame(width: 16, height: 16)
.background(Tone.orange.color, in: Circle())
.overlay(Circle().strokeBorder(Color(.secondarySystemGroupedBackground), lineWidth: 1.5))
.overlay(Circle().strokeBorder(Color(.systemBackground), lineWidth: 1.5))
.offset(x: 3, y: 3)
}
}
@@ -259,6 +645,7 @@ struct MailboxRowView: View {
}
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
@ViewBuilder
@@ -287,3 +674,77 @@ struct MailboxRowView: View {
return "warming"
}
}
// MARK: - Selection bar
/// Floating bottom-center bar shown while mailboxes are multi-selected:
/// count plus bulk warmup start/pause and remove.
struct MailboxSelectionBar: View {
let count: Int
let onStart: () -> Void
let onPause: () -> Void
let onRemove: () -> Void
let onClear: () -> Void
var body: some View {
HStack(spacing: 8) {
Text("\(count)")
.font(.subheadline.weight(.bold))
.monospacedDigit()
.foregroundStyle(.white)
.frame(minWidth: 26, minHeight: 26)
.background(WTheme.accent, in: Circle())
.contentTransition(.numericText())
Spacer(minLength: 6)
Button(action: onStart) {
Label("Start", systemImage: "flame.fill")
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.borderedProminent)
.tint(Tone.orange.color)
.controlSize(.small)
.disabled(count == 0)
.accessibilityLabel("Start warmup for \(count) mailboxes")
Button(action: onPause) {
Label("Pause", systemImage: "pause.fill")
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.bordered)
.tint(WTheme.warning)
.controlSize(.small)
.disabled(count == 0)
.accessibilityLabel("Pause warmup for \(count) mailboxes")
Button(role: .destructive, action: onRemove) {
Image(systemName: "trash")
.font(.system(size: 15, weight: .semibold))
.frame(width: 30, height: 30)
}
.buttonStyle(.bordered)
.tint(WTheme.negative)
.controlSize(.small)
.disabled(count == 0)
.accessibilityLabel("Remove \(count) mailboxes")
Button(action: onClear) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 30, height: 30)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Clear selection")
}
.padding(.leading, 12)
.padding(.trailing, 8)
.padding(.vertical, 8)
.background(.regularMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(Color(.separator).opacity(0.35), lineWidth: 1))
.shadow(color: .black.opacity(0.14), radius: 14, y: 4)
.padding(.horizontal, 20)
}
}
@@ -0,0 +1,158 @@
import SwiftUI
/// Navigation drawer for the mailboxes browser, mirroring the contacts /
/// campaigns / unibox sidebars: a slim sky hero with live fleet totals, then
/// the warmup status scopes as pill rows on a rounded white sheet. Counts come
/// from the loaded fleet. The selected pill slides between rows in the scope's
/// tone and rows cascade in when the drawer opens.
struct MailboxesSidebar: View {
let store: MailboxesStore
let selection: MailboxScope
let topInset: CGFloat
let revealed: Bool
let onSelect: (MailboxScope) -> Void
@Namespace private var activeNS
var body: some View {
VStack(alignment: .leading, spacing: 0) {
hero
ScrollView {
VStack(alignment: .leading, spacing: 2) {
sectionLabel("Status")
scopeRows
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 40)
}
.background {
UnevenRoundedRectangle(topLeadingRadius: 24, topTrailingRadius: 24, style: .continuous)
.fill(Color(.systemBackground))
.shadow(color: .black.opacity(0.1), radius: 14, y: -4)
}
}
.background(alignment: .top) {
AirSkyWash().frame(height: 340)
}
.background(Color(.systemBackground))
}
// MARK: Hero
private var hero: some View {
VStack(alignment: .leading, spacing: 13) {
HStack(spacing: 8) {
WarmblyLogo()
.fill(.white)
.frame(width: 21, height: 21 * (764 / 746))
Text("Mailboxes")
.font(.system(size: 17.5, weight: .heavy))
.tracking(-0.4)
.foregroundStyle(.white)
}
Text("Sender accounts, warmup and health")
.font(.footnote.weight(.medium))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
HStack(spacing: 6) {
heroBadge(symbol: "envelope.fill", text: "\(WFormat.compact(store.allCount)) connected")
if store.warmingCount > 0 {
heroBadge(symbol: "flame.fill", text: "\(WFormat.compact(store.warmingCount)) warming", live: true)
}
}
}
.padding(.horizontal, 20)
.padding(.top, topInset + 12)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func heroBadge(symbol: String, text: String, live: Bool = false) -> some View {
HStack(spacing: 5) {
Image(systemName: symbol)
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(.white.opacity(0.9))
.modifier(PingEffect(active: live, color: .white))
Text(text)
.font(.footnote.weight(.medium))
.monospacedDigit()
.foregroundStyle(.white)
.contentTransition(.numericText())
}
.padding(.horizontal, 10)
.padding(.vertical, 5.5)
.background(.white.opacity(0.16), in: Capsule())
}
private func sectionLabel(_ text: String) -> some View {
EyebrowLabel(text)
.padding(.horizontal, 14)
.padding(.top, 14)
.padding(.bottom, 6)
}
// MARK: Rows
@ViewBuilder
private var scopeRows: some View {
ForEach(Array(MailboxScope.allCases.enumerated()), id: \.element) { index, scope in
row(index: index, scope: scope)
}
}
private func row(index: Int, scope: MailboxScope) -> some View {
let selected = selection == scope
let count = store.count(for: scope)
// Warming stays orange and issues stay rose even unselected, mirroring
// the stat columns; the capsule itself carries the scope's tone.
let countColor: Color? = switch scope {
case .warming: count > 0 ? Tone.orange.color : nil
case .issues: count > 0 ? Tone.rose.color : nil
default: nil
}
return Button {
onSelect(scope)
} label: {
HStack(spacing: 13) {
Image(systemName: scope.icon)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(selected ? scope.tone.color : Color.secondary)
.frame(width: 24)
Text(scope.title)
.font(.subheadline.weight(selected ? .semibold : .medium))
.foregroundStyle(selected ? scope.tone.color : Color.primary)
.lineLimit(1)
Spacer(minLength: 8)
if count > 0 {
Text(WFormat.compact(count))
.font(.footnote.weight(countColor != nil || selected ? .semibold : .medium))
.monospacedDigit()
.foregroundStyle(countColor ?? (selected ? scope.tone.color : Color.secondary))
.contentTransition(.numericText())
}
}
.padding(.horizontal, 16)
.frame(height: 44)
.background {
if selected {
Capsule()
.fill(scope.tone.background)
.matchedGeometryEffect(id: "mailboxdrawer-active", in: activeNS)
}
}
.contentShape(Capsule())
}
.buttonStyle(TapScaleStyle())
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(scope.title), \(count)")
.accessibilityAddTraits(selected ? .isSelected : [])
.opacity(revealed ? 1 : 0)
.offset(x: revealed ? 0 : -18)
.animation(
.spring(response: 0.42, dampingFraction: 0.82)
.delay(revealed ? 0.03 + min(Double(index), 14) * 0.024 : 0),
value: revealed
)
}
}
@@ -1,8 +1,49 @@
import Foundation
import SwiftUI
// MARK: - Scope
/// Which slice of the fleet the list shows. Every scope is a client-side
/// filter over the loaded rows; Issues additionally needs analytics statuses.
enum MailboxScope: Hashable, CaseIterable {
case all, warming, paused, issues, off
var title: String {
switch self {
case .all: "All mailboxes"
case .warming: "Warming"
case .paused: "Paused"
case .issues: "Issues"
case .off: "Warmup off"
}
}
var icon: String {
switch self {
case .all: "tray.full"
case .warming: "flame.fill"
case .paused: "pause.circle"
case .issues: "exclamationmark.triangle"
case .off: "moon.zzz"
}
}
var tone: Tone {
switch self {
case .all: .sky
case .warming: .orange
case .paused: .amber
case .issues: .rose
case .off: .slate
}
}
}
// MARK: - Store
/// List-screen store: the mailbox rows plus the per-account health snapshot
/// from `/analytics/accounts`, merged by id.
/// from `/analytics/accounts`, merged by id. Owns multi-select and the bulk
/// warmup/remove actions behind the floating selection bar.
@MainActor
@Observable
final class MailboxesStore {
@@ -19,13 +60,68 @@ final class MailboxesStore {
// MARK: Derived stats
var allCount: Int { totalCount ?? accounts.count }
/// Warming = warmup anchor set and not paused; never a status string.
var warmingCount: Int { accounts.filter(\.isWarmingActive).count }
var pausedCount: Int { accounts.filter(\.isWarmupPaused).count }
var issueCount: Int {
accounts.filter { statuses[$0.id]?.health?.hasIssue == true }.count
}
/// Warmup never enabled: no ramp anchor at all.
var offCount: Int { accounts.filter { $0.warmup == nil }.count }
func count(for scope: MailboxScope) -> Int {
switch scope {
case .all: allCount
case .warming: warmingCount
case .paused: pausedCount
case .issues: issueCount
case .off: offCount
}
}
// MARK: Selection
private(set) var selectedIDs: Set<String> = []
var isSelecting = false
var selectedCount: Int { selectedIDs.count }
func isSelected(_ id: String) -> Bool { selectedIDs.contains(id) }
func toggleSelected(_ id: String) {
if selectedIDs.contains(id) { selectedIDs.remove(id) } else { selectedIDs.insert(id) }
}
func enterSelection(with id: String? = nil) {
withAnimation(.snappy) {
isSelecting = true
if let id { selectedIDs.insert(id) }
}
}
func exitSelection() {
withAnimation(.snappy) {
isSelecting = false
selectedIDs.removeAll()
}
}
/// Toggle select-all over the rows currently visible in the list.
func selectAll(_ ids: [String]) {
withAnimation(.snappy) {
if allSelected(of: ids) { selectedIDs.removeAll() } else { selectedIDs = Set(ids) }
}
}
func allSelected(of ids: [String]) -> Bool {
!ids.isEmpty && ids.allSatisfy { selectedIDs.contains($0) }
}
// MARK: Loading
func load(_ api: APIClient, includeStatuses: Bool) async {
@@ -88,6 +184,18 @@ final class MailboxesStore {
return params
}
/// Optimistic insert from the connect flow; realtime refreshes the rest.
func insert(_ account: EmailAccount) {
withAnimation {
if let index = accounts.firstIndex(where: { $0.id == account.id }) {
accounts[index] = account
} else {
accounts.insert(account, at: 0)
if let total = totalCount { totalCount = total + 1 }
}
}
}
// MARK: Row actions
/// action: "start" | "pause" | "resume" | "stop"; returns the updated row.
@@ -114,4 +222,50 @@ final class MailboxesStore {
actionError = error.localizedDescription
}
}
// MARK: Bulk actions
/// action: "start" | "pause". Applies per-row results as they land and
/// reports partial failures in one line.
func bulkWarmup(_ api: APIClient, action: String) async {
let ids = Array(selectedIDs)
guard !ids.isEmpty else { return }
var failures = 0
for id in ids {
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 {
failures += 1
}
}
if failures > 0 {
actionError = "\(failures) of \(ids.count) mailboxes couldn't \(action)"
}
exitSelection()
}
func bulkRemove(_ api: APIClient) async {
let ids = Array(selectedIDs)
guard !ids.isEmpty else { return }
var failures = 0
for id in ids {
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 {
failures += 1
}
}
if failures > 0 {
actionError = "\(failures) of \(ids.count) mailboxes couldn't be removed"
}
exitSelection()
}
}