mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
1124 lines
41 KiB
Swift
1124 lines
41 KiB
Swift
import AuthenticationServices
|
|
import SwiftUI
|
|
|
|
/// Full-screen "connect a mailbox" onboarding wearing the same air as
|
|
/// sign-up: the sky with its flight ambience and a step badge, a full-bleed
|
|
/// white sheet below. Gmail/Outlook connect through an in-app
|
|
/// ASWebAuthenticationSession ceremony; everything else goes through the
|
|
/// native SMTP/IMAP form. Dismissal always goes through `onClose()`; the
|
|
/// environment DismissAction can no-op in this app's presentation contexts.
|
|
struct MailboxConnectFlow: View {
|
|
var onClose: () -> Void = {}
|
|
var onConnected: ((EmailAccount) -> Void)? = nil
|
|
|
|
private enum Page: Int {
|
|
case provider, connect, account, imap, smtp, done
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .provider: "envelope.badge.person.crop"
|
|
case .connect: "lock.shield.fill"
|
|
case .account: "person.crop.circle"
|
|
case .imap: "tray.and.arrow.down.fill"
|
|
case .smtp: "paperplane.fill"
|
|
case .done: "checkmark.seal.fill"
|
|
}
|
|
}
|
|
|
|
var skyLabel: String {
|
|
switch self {
|
|
case .provider: "Add a mailbox"
|
|
case .connect: "Authorize access"
|
|
case .account: "Your address"
|
|
case .imap: "Incoming mail"
|
|
case .smtp: "Outgoing mail"
|
|
case .done: "Connected"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Which branch the step badge counts: OAuth is two steps, SMTP is five.
|
|
private enum Route {
|
|
case undecided, oauth, smtp
|
|
}
|
|
|
|
private enum WarmupOffer: Equatable {
|
|
case offer, starting, active, failed(String)
|
|
}
|
|
|
|
private enum Field: Hashable {
|
|
case name, email
|
|
case imapHost, imapPort, imapUser, imapPass
|
|
case smtpHost, smtpPort, smtpUser, smtpPass
|
|
}
|
|
|
|
@Environment(AppEnvironment.self) private var env
|
|
|
|
@State private var page = Page.provider
|
|
@State private var route = Route.undecided
|
|
@State private var direction = 1.0
|
|
@State private var badgeAppeared = false
|
|
|
|
// OAuth path
|
|
@State private var pendingProvider = "gmail"
|
|
@State private var oauthProvider: String?
|
|
@State private var oauthFlow = MailboxOAuthFlow()
|
|
|
|
// SMTP path form
|
|
@State private var name = ""
|
|
@State private var email = ""
|
|
@State private var imapHost = ""
|
|
@State private var imapPort = "993"
|
|
@State private var imapUsername = ""
|
|
@State private var imapPassword = ""
|
|
@State private var smtpHost = ""
|
|
@State private var smtpPort = "587"
|
|
@State private var sameLogin = true
|
|
@State private var smtpUsername = ""
|
|
@State private var smtpPassword = ""
|
|
|
|
@State private var busy = false
|
|
@State private var errorMessage: String?
|
|
@State private var errorPulse = 0
|
|
|
|
// Done page
|
|
@State private var connectedAccount: EmailAccount?
|
|
@State private var warmupOffer = WarmupOffer.offer
|
|
|
|
@FocusState private var focused: Field?
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottom) {
|
|
SkyBackdrop()
|
|
VStack(spacing: 0) {
|
|
topBar
|
|
skyArea
|
|
sheet
|
|
}
|
|
}
|
|
.sensoryFeedback(.impact(weight: .light), trigger: page)
|
|
.sensoryFeedback(.error, trigger: errorPulse)
|
|
}
|
|
|
|
private var pagePath: [Page] {
|
|
route == .smtp
|
|
? [.provider, .account, .imap, .smtp, .done]
|
|
: [.provider, .connect, .done]
|
|
}
|
|
|
|
private var stepIndex: Int { pagePath.firstIndex(of: page) ?? 0 }
|
|
|
|
private var isBusy: Bool { busy || warmupOffer == .starting || oauthProvider != nil }
|
|
|
|
private var pageTransition: AnyTransition {
|
|
.asymmetric(
|
|
insertion: .move(edge: direction > 0 ? .trailing : .leading).combined(with: .opacity),
|
|
removal: .move(edge: direction > 0 ? .leading : .trailing).combined(with: .opacity)
|
|
)
|
|
}
|
|
|
|
// MARK: - Sky chrome
|
|
|
|
private var topBar: some View {
|
|
HStack(spacing: 12) {
|
|
if stepIndex > 0, page != .done {
|
|
Button {
|
|
goBack()
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 40, height: 40)
|
|
.background(.white.opacity(0.16), in: Circle())
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
.accessibilityLabel("Back")
|
|
.disabled(isBusy)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.7)))
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
WarmblyLogo()
|
|
.fill(.white)
|
|
.frame(width: 27, height: 28)
|
|
Text("Warmbly")
|
|
.font(.system(size: 20, weight: .heavy))
|
|
.tracking(-0.4)
|
|
.foregroundStyle(.white)
|
|
.fixedSize()
|
|
}
|
|
.shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
onClose()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 40, height: 40)
|
|
.background(.white.opacity(0.16), in: Circle())
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
.accessibilityLabel("Cancel")
|
|
.disabled(isBusy)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 6)
|
|
.animation(.spring(response: 0.45, dampingFraction: 0.86), value: page)
|
|
}
|
|
|
|
private var skyArea: some View {
|
|
ZStack {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { focused = nil }
|
|
|
|
HeroFlightScene()
|
|
|
|
pageBadge
|
|
.scaleEffect(badgeAppeared ? 1 : 0.9)
|
|
.opacity(badgeAppeared ? 1 : 0)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity)
|
|
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: page)
|
|
.onAppear {
|
|
withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) {
|
|
badgeAppeared = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private var pageBadge: some View {
|
|
ViewThatFits(in: .vertical) {
|
|
VStack(spacing: 12) {
|
|
ZStack {
|
|
Circle().fill(.white.opacity(0.16))
|
|
Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1)
|
|
Image(systemName: page.icon)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.contentTransition(.symbolEffect(.replace))
|
|
}
|
|
.frame(width: 54, height: 54)
|
|
|
|
VStack(spacing: 4) {
|
|
Text(page.skyLabel)
|
|
.font(.system(size: 15.5, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.contentTransition(.opacity)
|
|
Text("Step \(stepIndex + 1) of \(pagePath.count)")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
.contentTransition(.numericText())
|
|
}
|
|
|
|
pageSegments
|
|
}
|
|
.padding(.vertical, 16)
|
|
|
|
HStack(spacing: 10) {
|
|
ZStack {
|
|
Circle().fill(.white.opacity(0.16))
|
|
Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1)
|
|
Image(systemName: page.icon)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.contentTransition(.symbolEffect(.replace))
|
|
}
|
|
.frame(width: 34, height: 34)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(page.skyLabel)
|
|
.font(.system(size: 13.5, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.contentTransition(.opacity)
|
|
HStack(spacing: 7) {
|
|
pageSegments
|
|
Text("Step \(stepIndex + 1) of \(pagePath.count)")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
.contentTransition(.numericText())
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
.shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2)
|
|
}
|
|
|
|
private var pageSegments: some View {
|
|
HStack(spacing: 6) {
|
|
ForEach(Array(pagePath.enumerated()), id: \.element.rawValue) { index, segment in
|
|
Capsule()
|
|
.fill(.white.opacity(index <= stepIndex ? 0.95 : 0.3))
|
|
.frame(width: segment == page ? 26 : 14, height: 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The sheet
|
|
|
|
private var sheet: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Always a ScrollView: ViewThatFits swaps identities when the
|
|
// keyboard shrinks the space, which destroys the focused field
|
|
// mid-tap on the taller form pages.
|
|
ScrollView {
|
|
pageContent
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
|
|
footer
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background {
|
|
UnevenRoundedRectangle(cornerRadii: .init(topLeading: 36, topTrailing: 36))
|
|
.fill(Color(.systemBackground)
|
|
.shadow(.drop(color: Color(hex: 0x0F172A).opacity(0.28), radius: 34, y: -6)))
|
|
.padding(.bottom, -600)
|
|
.ignoresSafeArea()
|
|
}
|
|
.geometryGroup()
|
|
.layoutPriority(1)
|
|
}
|
|
|
|
private var pageContent: some View {
|
|
Group {
|
|
switch page {
|
|
case .provider: providerPage
|
|
case .connect: connectPage
|
|
case .account: accountPage
|
|
case .imap: imapPage
|
|
case .smtp: smtpPage
|
|
case .done: donePage
|
|
}
|
|
}
|
|
.padding(.top, 30)
|
|
.padding(.bottom, 8)
|
|
.transition(pageTransition)
|
|
}
|
|
|
|
private var footer: some View {
|
|
VStack(spacing: 12) {
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.system(size: 13.5))
|
|
.foregroundStyle(WTheme.negative)
|
|
.frame(maxWidth: .infinity)
|
|
.multilineTextAlignment(.center)
|
|
.transition(.opacity)
|
|
}
|
|
|
|
if page == .smtp, busy {
|
|
Text("Verified against your server before saving.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.tertiary)
|
|
.frame(maxWidth: .infinity)
|
|
.multilineTextAlignment(.center)
|
|
.transition(.opacity)
|
|
}
|
|
|
|
if page == .connect, oauthProvider != nil {
|
|
Text("Waiting for authorization in the \(providerDisplayName) window.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.tertiary)
|
|
.frame(maxWidth: .infinity)
|
|
.multilineTextAlignment(.center)
|
|
.transition(.opacity)
|
|
}
|
|
|
|
if page != .provider {
|
|
Button {
|
|
Task { await primaryAction() }
|
|
} label: {
|
|
Group {
|
|
if primaryBusy {
|
|
ProgressView().tint(.white)
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
Text(primaryLabel)
|
|
if let icon = primaryIcon {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.background(
|
|
LinearGradient(
|
|
colors: !canAdvance && !primaryBusy
|
|
? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)]
|
|
: [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
),
|
|
in: RoundedRectangle(cornerRadius: 17)
|
|
)
|
|
.shadow(color: !canAdvance ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6)
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
.disabled(primaryBusy || !canAdvance)
|
|
.animation(.easeOut(duration: 0.18), value: canAdvance)
|
|
}
|
|
|
|
if page == .done, warmupOffer == .offer {
|
|
Button("Skip for now") {
|
|
onClose()
|
|
}
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.disabled(primaryBusy)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 14)
|
|
.animation(.easeOut(duration: 0.2), value: page)
|
|
.animation(.easeOut(duration: 0.2), value: errorMessage != nil)
|
|
.animation(.easeOut(duration: 0.2), value: warmupOffer)
|
|
}
|
|
|
|
private var primaryBusy: Bool { busy || warmupOffer == .starting || oauthProvider != nil }
|
|
|
|
private var primaryLabel: String {
|
|
switch page {
|
|
case .provider, .account, .imap: "Continue"
|
|
case .connect: "Continue with \(providerDisplayName)"
|
|
case .smtp: "Connect mailbox"
|
|
case .done:
|
|
switch warmupOffer {
|
|
case .offer, .starting: "Start warmup"
|
|
case .active, .failed: "Done"
|
|
}
|
|
}
|
|
}
|
|
|
|
private var primaryIcon: String? {
|
|
switch page {
|
|
case .connect: "arrow.up.right.square"
|
|
case .smtp: "paperplane.fill"
|
|
case .done: warmupOffer == .offer ? "flame.fill" : nil
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Pages
|
|
|
|
private func pageTitle(_ title: String, _ subtitle: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
Text(title)
|
|
.font(.system(size: 30, weight: .bold))
|
|
.tracking(-0.6)
|
|
.foregroundStyle(.primary)
|
|
Text(subtitle)
|
|
.font(.system(size: 15.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.bottom, 22)
|
|
}
|
|
|
|
private var providerPage: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
pageTitle(
|
|
"Connect a sending account",
|
|
"Warm it up and send campaigns from your own address."
|
|
)
|
|
|
|
VStack(spacing: 12) {
|
|
providerRow(
|
|
"Gmail / Google Workspace",
|
|
sub: "OAuth via Google. Best deliverability for Gmail.",
|
|
provider: "gmail"
|
|
) {
|
|
brandLogoTile("GoogleLogo", size: 42)
|
|
}
|
|
providerRow(
|
|
"Outlook / Microsoft 365",
|
|
sub: "OAuth via Microsoft. Native sync for Outlook accounts.",
|
|
provider: "outlook"
|
|
) {
|
|
brandLogoTile("OutlookLogo", size: 42)
|
|
}
|
|
providerRow(
|
|
"Other (SMTP / IMAP)",
|
|
sub: "Any provider with a host, port and app password.",
|
|
provider: nil
|
|
) {
|
|
IconTile(symbol: "server.rack", tone: .slate, size: 42)
|
|
}
|
|
}
|
|
|
|
Text("Credentials are stored encrypted. You can disconnect anytime.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.top, 12)
|
|
}
|
|
}
|
|
|
|
/// Brand mark on a white tile with a hairline border, like the web's
|
|
/// provider rows.
|
|
private func brandLogoTile(_ asset: String, size: CGFloat) -> some View {
|
|
Image(asset)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: size * 0.55, height: size * 0.55)
|
|
.frame(width: size, height: size)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: size * 0.32))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: size * 0.32)
|
|
.strokeBorder(Color(.separator).opacity(0.5), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func providerRow<Tile: View>(
|
|
_ title: String,
|
|
sub: String,
|
|
provider: String?,
|
|
@ViewBuilder tile: () -> Tile
|
|
) -> some View {
|
|
Button {
|
|
errorMessage = nil
|
|
if let provider {
|
|
pendingProvider = provider
|
|
route = .oauth
|
|
goTo(.connect)
|
|
} else {
|
|
route = .smtp
|
|
goTo(.account)
|
|
}
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
tile()
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.system(size: 15.5, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
Text(sub)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer(minLength: 8)
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 13)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17))
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
}
|
|
|
|
// MARK: - Connect page (OAuth ceremony)
|
|
|
|
private var providerDisplayName: String {
|
|
pendingProvider == "outlook" ? "Microsoft" : "Google"
|
|
}
|
|
|
|
private var providerLogoAsset: String {
|
|
pendingProvider == "outlook" ? "OutlookLogo" : "GoogleLogo"
|
|
}
|
|
|
|
private var connectPage: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 14) {
|
|
brandLogoTile(providerLogoAsset, size: 56)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Connect with \(providerDisplayName)")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.tracking(-0.4)
|
|
.foregroundStyle(.primary)
|
|
Text(pendingProvider == "outlook" ? "Outlook or Microsoft 365" : "Gmail or Google Workspace")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.bottom, 16)
|
|
|
|
Text("We'll open a \(providerDisplayName) sign-in window. Approve the access and you're done.")
|
|
.font(.system(size: 15.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.bottom, 20)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
connectScopeRow("paperplane.fill", "Send and read mail on your behalf")
|
|
connectScopeRow("arrowshape.turn.up.left.fill", "Track replies and deliveries")
|
|
connectScopeRow("lock.fill", "Tokens are stored encrypted. Revoke anytime.")
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17))
|
|
}
|
|
}
|
|
|
|
private func connectScopeRow(_ symbol: String, _ text: String) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 12.5, weight: .semibold))
|
|
.foregroundStyle(WTheme.accent)
|
|
.frame(width: 18)
|
|
Text(text)
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.primary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
private var accountPage: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
pageTitle(
|
|
"Your sending address",
|
|
"The name and email recipients will see."
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
fieldBlock("Name", helper: "The sender name recipients see.") {
|
|
styledField("Alex Rivera", text: $name, field: .name) {
|
|
focused = .email
|
|
}
|
|
.textContentType(.name)
|
|
}
|
|
|
|
fieldBlock("Email") {
|
|
styledField("alex@company.com", text: $email, field: .email, submit: .continue) {
|
|
Task { await advanceIfValid() }
|
|
}
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
if name.isEmpty { focused = .name }
|
|
}
|
|
}
|
|
|
|
private var imapPage: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
pageTitle(
|
|
"Incoming mail",
|
|
"IMAP keeps replies and inbox activity in sync."
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
fieldBlock("Host") {
|
|
styledField("imap.example.com", text: $imapHost, field: .imapHost) {
|
|
focused = .imapUser
|
|
}
|
|
.keyboardType(.URL)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
}
|
|
|
|
fieldBlock("Port") {
|
|
styledField("993", text: $imapPort, field: .imapPort)
|
|
.keyboardType(.numberPad)
|
|
.frame(width: 96)
|
|
}
|
|
}
|
|
|
|
fieldBlock("Username") {
|
|
styledField("alex@company.com", text: $imapUsername, field: .imapUser) {
|
|
focused = .imapPass
|
|
}
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
}
|
|
|
|
fieldBlock("Password", helper: "Usually an app-specific password, not your main login.") {
|
|
styledField("App password", text: $imapPassword, field: .imapPass, secure: true, submit: .continue) {
|
|
Task { await advanceIfValid() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
if imapUsername.isEmpty { imapUsername = trimmedEmail }
|
|
if imapHost.isEmpty { focused = .imapHost }
|
|
}
|
|
}
|
|
|
|
private var smtpPage: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
pageTitle(
|
|
"Outgoing mail",
|
|
"The SMTP server your campaigns send through."
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
fieldBlock("Host") {
|
|
styledField("smtp.example.com", text: $smtpHost, field: .smtpHost) {
|
|
focused = .smtpPort
|
|
}
|
|
.keyboardType(.URL)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
}
|
|
|
|
fieldBlock("Port", helper: "465 or 587.") {
|
|
styledField("587", text: $smtpPort, field: .smtpPort)
|
|
.keyboardType(.numberPad)
|
|
.frame(width: 96)
|
|
}
|
|
}
|
|
|
|
Toggle(isOn: $sameLogin.animation(.spring(response: 0.4, dampingFraction: 0.85))) {
|
|
Text("Use the same login as IMAP")
|
|
.font(.system(size: 15.5, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.tint(WTheme.accent)
|
|
.padding(.horizontal, 16)
|
|
.frame(height: 56)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17))
|
|
|
|
if !sameLogin {
|
|
fieldBlock("Username") {
|
|
styledField("alex@company.com", text: $smtpUsername, field: .smtpUser) {
|
|
focused = .smtpPass
|
|
}
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
|
|
fieldBlock("Password") {
|
|
styledField("App password", text: $smtpPassword, field: .smtpPass, secure: true, submit: .continue) {
|
|
Task { await advanceIfValid() }
|
|
}
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
if smtpHost.isEmpty { focused = .smtpHost }
|
|
}
|
|
}
|
|
|
|
private var donePage: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
pageTitle(
|
|
"Mailbox connected",
|
|
"Syncing has started. It will appear in your accounts list right away."
|
|
)
|
|
|
|
if let account = connectedAccount {
|
|
HStack(spacing: 12) {
|
|
IconTile(symbol: "envelope.fill", tone: .sky, size: 42)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(account.email)
|
|
.font(.system(size: 16.5, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Text(account.providerLabel)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 8)
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(.white, WTheme.positive)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 13)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17))
|
|
.padding(.bottom, 14)
|
|
}
|
|
|
|
warmupBlock
|
|
}
|
|
}
|
|
|
|
private var warmupBlock: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 12) {
|
|
IconTile(symbol: "flame.fill", tone: .orange, size: 42)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(warmupOffer == .active ? "Warming up" : "Start warming up")
|
|
.font(.system(size: 15.5, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.contentTransition(.opacity)
|
|
if warmupOffer == .active {
|
|
StatusPill(text: "warming", tone: .orange, pulsing: true)
|
|
.transition(.scale(scale: 0.6).combined(with: .opacity))
|
|
}
|
|
}
|
|
Spacer(minLength: 8)
|
|
if warmupOffer == .active {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(.white, Tone.orange.color)
|
|
.transition(.scale(scale: 0.4).combined(with: .opacity))
|
|
}
|
|
}
|
|
|
|
Text(warmupOffer == .active
|
|
? "This mailbox is building sender reputation. Track its progress from the accounts list."
|
|
: "Gradually builds sender reputation before campaigns. Starts at 10 a day and ramps automatically.")
|
|
.font(.system(size: 13.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if case let .failed(message) = warmupOffer {
|
|
Text(message)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(WTheme.negative)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Tone.orange.background.opacity(0.55), in: RoundedRectangle(cornerRadius: 17))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 17)
|
|
.strokeBorder(Tone.orange.color.opacity(0.25), lineWidth: 1)
|
|
)
|
|
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: warmupOffer)
|
|
}
|
|
|
|
// MARK: - Field primitives
|
|
|
|
private func fieldBlock(_ label: String, helper: String? = nil, @ViewBuilder content: () -> some View) -> some View {
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
Text(label)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
content()
|
|
if let helper {
|
|
Text(helper)
|
|
.font(.system(size: 12.5))
|
|
.foregroundStyle(.tertiary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func styledField(
|
|
_ placeholder: String,
|
|
text: Binding<String>,
|
|
field: Field,
|
|
secure: Bool = false,
|
|
submit: SubmitLabel = .next,
|
|
onSubmit: @escaping () -> Void = {}
|
|
) -> some View {
|
|
Group {
|
|
if secure {
|
|
SecureField(placeholder, text: text)
|
|
} else {
|
|
TextField(placeholder, text: text)
|
|
}
|
|
}
|
|
.focused($focused, equals: field)
|
|
.submitLabel(submit)
|
|
.onSubmit(onSubmit)
|
|
.font(.system(size: 16.5))
|
|
.padding(.horizontal, 16)
|
|
.frame(height: 56)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 17)
|
|
.strokeBorder(focused == field ? WTheme.accent : .clear, lineWidth: 1.8)
|
|
)
|
|
.animation(.easeOut(duration: 0.18), value: focused)
|
|
}
|
|
|
|
// MARK: - Flow
|
|
|
|
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
|
private var trimmedEmail: String { email.trimmingCharacters(in: .whitespaces) }
|
|
|
|
private var canAdvance: Bool {
|
|
switch page {
|
|
case .provider:
|
|
return false
|
|
case .connect:
|
|
return true
|
|
case .account:
|
|
return trimmedName.count >= 2 && trimmedEmail.contains("@") && trimmedEmail.contains(".")
|
|
case .imap:
|
|
return !imapHost.trimmingCharacters(in: .whitespaces).isEmpty
|
|
&& (Int(imapPort) ?? 0) > 0
|
|
case .smtp:
|
|
let port = Int(smtpPort) ?? 0
|
|
return !smtpHost.trimmingCharacters(in: .whitespaces).isEmpty
|
|
&& (port == 465 || port == 587)
|
|
case .done:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func goTo(_ target: Page) {
|
|
focused = nil
|
|
let path = pagePath
|
|
let from = path.firstIndex(of: page) ?? 0
|
|
let to = path.firstIndex(of: target) ?? 0
|
|
direction = to >= from ? 1 : -1
|
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) {
|
|
page = target
|
|
}
|
|
}
|
|
|
|
private func goBack() {
|
|
errorMessage = nil
|
|
let path = pagePath
|
|
guard let index = path.firstIndex(of: page), index > 0 else { return }
|
|
let previous = path[index - 1]
|
|
focused = nil
|
|
direction = -1
|
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) {
|
|
page = previous
|
|
}
|
|
if previous == .provider { route = .undecided }
|
|
}
|
|
|
|
private func primaryAction() async {
|
|
switch page {
|
|
case .provider:
|
|
break
|
|
case .connect:
|
|
await startOAuth(pendingProvider)
|
|
case .account, .imap, .smtp:
|
|
await advanceIfValid()
|
|
case .done:
|
|
switch warmupOffer {
|
|
case .offer:
|
|
await startWarmup()
|
|
case .active, .failed:
|
|
onClose()
|
|
case .starting:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func advanceIfValid() async {
|
|
guard canAdvance else { return }
|
|
errorMessage = nil
|
|
switch page {
|
|
case .account:
|
|
goTo(.imap)
|
|
case .imap:
|
|
goTo(.smtp)
|
|
case .smtp:
|
|
await submitSMTP()
|
|
case .provider, .connect, .done:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func showError(_ message: String) {
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
errorMessage = message
|
|
}
|
|
errorPulse += 1
|
|
}
|
|
|
|
private func finishConnect(_ account: EmailAccount) {
|
|
connectedAccount = account
|
|
onConnected?(account)
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
if route != .smtp { route = .oauth }
|
|
goTo(.done)
|
|
}
|
|
|
|
// MARK: - OAuth
|
|
|
|
private func startOAuth(_ provider: String) async {
|
|
guard oauthProvider == nil, !busy else { return }
|
|
withAnimation(.easeOut(duration: 0.2)) { errorMessage = nil }
|
|
oauthProvider = provider
|
|
defer { oauthProvider = nil }
|
|
do {
|
|
let start: MailboxOAuthStartResponse = try await env.api.post(
|
|
"emails/onboarding/oauth/start",
|
|
body: MailboxOAuthStartBody(provider: provider)
|
|
)
|
|
guard let url = URL(string: start.url) else {
|
|
showError("The provider returned an invalid authorization URL.")
|
|
return
|
|
}
|
|
|
|
let callback = try await oauthFlow.authorize(url: url)
|
|
let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
|
func query(_ name: String) -> String? {
|
|
items.first { $0.name == name }?.value
|
|
}
|
|
|
|
if let providerError = query("error"), !providerError.isEmpty {
|
|
// Declining consent is a cancel, not a failure worth a banner.
|
|
if providerError != "access_denied" {
|
|
showError("The provider couldn't authorize the connection (\(providerError)).")
|
|
}
|
|
return
|
|
}
|
|
guard query("state") == start.state else {
|
|
showError("Sign-in session mismatch. Please try again.")
|
|
return
|
|
}
|
|
guard let code = query("code"), !code.isEmpty else {
|
|
showError("The provider didn't return an authorization code. Please try again.")
|
|
return
|
|
}
|
|
|
|
let account: EmailAccount = try await env.api.post(
|
|
"emails/onboarding/oauth/finish",
|
|
body: MailboxOAuthFinishBody(code: code, state: start.state)
|
|
)
|
|
finishConnect(account)
|
|
} catch MailboxOAuthFlow.Failure.cancelled {
|
|
// User closed the sheet; stay quiet.
|
|
} catch {
|
|
showError((error as? APIError)?.errorDescription ?? error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// MARK: - SMTP submit
|
|
|
|
private func submitSMTP() async {
|
|
focused = nil
|
|
errorMessage = nil
|
|
busy = true
|
|
let login = (
|
|
username: imapUsername,
|
|
password: imapPassword
|
|
)
|
|
let body = MailboxSMTPConnectBody(
|
|
email: trimmedEmail,
|
|
name: trimmedName,
|
|
smtp: MailboxServerCredentials(
|
|
username: sameLogin ? login.username : smtpUsername,
|
|
password: sameLogin ? login.password : smtpPassword,
|
|
host: smtpHost.trimmingCharacters(in: .whitespaces),
|
|
port: Int(smtpPort) ?? 587
|
|
),
|
|
imap: MailboxServerCredentials(
|
|
username: login.username,
|
|
password: login.password,
|
|
host: imapHost.trimmingCharacters(in: .whitespaces),
|
|
port: Int(imapPort) ?? 993
|
|
)
|
|
)
|
|
do {
|
|
let account: EmailAccount = try await env.api.post("emails/onboarding/smtp-imap", body: body)
|
|
busy = false
|
|
finishConnect(account)
|
|
} catch {
|
|
busy = false
|
|
showError((error as? APIError)?.errorDescription ?? error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// MARK: - Warmup offer
|
|
|
|
private func startWarmup() async {
|
|
guard let account = connectedAccount, warmupOffer == .offer else { return }
|
|
withAnimation(.easeOut(duration: 0.2)) { warmupOffer = .starting }
|
|
do {
|
|
let updated: EmailAccount = try await env.api.post("emails/\(account.id)/warmup/start")
|
|
connectedAccount = updated
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
|
|
warmupOffer = .active
|
|
}
|
|
} catch {
|
|
let message = (error as? APIError)?.errorDescription ?? error.localizedDescription
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
warmupOffer = .failed(message)
|
|
}
|
|
errorPulse += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `POST /emails/onboarding/oauth/finish` body (not in MailboxModels).
|
|
private struct MailboxOAuthFinishBody: Encodable {
|
|
var code: String
|
|
var state: String
|
|
}
|
|
|
|
/// The mailbox OAuth ceremony: opens the backend-minted authorization URL in
|
|
/// an ASWebAuthenticationSession and hands back the `warmbly://email-oauth`
|
|
/// callback. Mirrors GoogleSignInFlow's presentation-context pattern.
|
|
@MainActor
|
|
final class MailboxOAuthFlow: NSObject, ASWebAuthenticationPresentationContextProviding {
|
|
enum Failure: Error {
|
|
case cancelled
|
|
case malformedResponse
|
|
}
|
|
|
|
private var activeSession: ASWebAuthenticationSession?
|
|
private var presentationWindow: UIWindow?
|
|
|
|
func authorize(url: URL) async throws -> URL {
|
|
// Captured up front (we're on the main actor) so the presentation
|
|
// callback never has to conjure a window of its own.
|
|
guard let window = Self.keyWindow else { throw Failure.malformedResponse }
|
|
presentationWindow = window
|
|
defer { presentationWindow = nil }
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "warmbly") { [weak self] callbackURL, error in
|
|
Task { @MainActor in self?.activeSession = nil }
|
|
if let error {
|
|
if let webError = error as? ASWebAuthenticationSessionError, webError.code == .canceledLogin {
|
|
continuation.resume(throwing: Failure.cancelled)
|
|
} else {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
return
|
|
}
|
|
guard let callbackURL else {
|
|
continuation.resume(throwing: Failure.malformedResponse)
|
|
return
|
|
}
|
|
continuation.resume(returning: callbackURL)
|
|
}
|
|
session.presentationContextProvider = self
|
|
// Keep the user's provider session so re-connects skip the login.
|
|
session.prefersEphemeralWebBrowserSession = false
|
|
activeSession = session
|
|
if !session.start() {
|
|
activeSession = nil
|
|
continuation.resume(throwing: Failure.malformedResponse)
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
MainActor.assumeIsolated {
|
|
// authorize guards that a window exists before the session starts.
|
|
presentationWindow ?? Self.keyWindow!
|
|
}
|
|
}
|
|
|
|
private static var keyWindow: UIWindow? {
|
|
let windows = UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.flatMap(\.windows)
|
|
return windows.first(where: \.isKeyWindow) ?? windows.first
|
|
}
|
|
}
|