Skip to content

Post-login flow mis-detects an already-added account and routes an active subscriber to the paywall #56

Description

@tabmail-kmyi

Summary

Owner report (2026-08-19): signing in to TabMail with Google on a device where that same Gmail account was already fully configured (mail + calendar) produced two mis-detections in the post-login flow:

  1. The flow still offered to add the Gmail email account — it did not detect the account was already configured.
  2. The owner has an active subscription, yet the flow routed to the subscription/paywall page.

Both were suspected to be races. Investigation says: symptom 1 is not a race (the check simply doesn't exist on that path); symptom 2 is a stale latched flag consumed without an entitlement check, plus one branch that genuinely never fetches entitlement after login. Both mechanisms pre-date the signup-trial work (git log -S 'PendingAccountAdd' / -S 'pending_plan_navigation' both bottom out at the initial public release) — the trial launch exposed them, it didn't introduce them. Related but distinct: #49, #50, #51, #52 (purchase/restore-path gate races; this issue is the login/onboarding path).

Both decisions live in RootView.routedContent's if/else chain, which re-evaluates synchronously and reads two imperative globals (PendingAccountAdd.shared.pending, AISubscriptionGate.shared.isActive) that no branch requires to be settled — "not yet known" collapses into "needed".

Symptom 1 — offered to re-add an already-configured account

Root cause: there is no existing-account check on the login path. TabMailLoginView.signInGoogle() / signInMicrosoft() (TabMail/Views/Account/TabMailLoginView.swift) set PendingAccountAdd.shared.pending = .init(provider:email:) unconditionally after fetching the OAuth user info — no comparison against configured accounts. RootView.routedContent then shows pendingAccountGate(pending) purely on pending != nil. Reproduces 100% for any user whose sign-in identity matches an already-added account; it only looks timing-dependent.

The check the flow is missing already exists, copy-ready, in CalendarSetupView.connectGoogleCalendar() / connectOutlookCalendar() (TabMail/Views/Settings/CalendarSetupView.swift): Account.filter(emailAddress == userInfo.email && provider == …).fetchOne(db) → skip if found. (The Apple arm of the same login view also has a once-only guard, icloud_setup_prompted; Gmail/Outlook have no equivalent.)

Traps a naive fix must avoid:

  • Don't check navigationStore.accountsAccount.sidebarRequest (TabMail/Models/Account+Sidebar.swift) filters out calendarOnly == true rows, so a calendar-first Gmail row would be invisible to it. Query GRDB directly.
  • Compare case-insensitively. The downstream dedupe in AccountManager.setupOAuthAccount (TabMail/Services/Account/AccountManagerSetup.swift) uses Column("emailAddress") == email — SQLite BINARY collation, unlike the case-insensitive comparisons used everywhere else (EmailAddressUtils, push matching). A differently-cased stored address produces a duplicate account row instead of a token refresh.
  • Adjacent real defect: if the matched row is calendarOnly == true, setupOAuthAccount refreshes tokens and returns the existing row without clearing calendarOnly — the account can never appear in the sidebar, navigationStore.accounts stays empty, and RootView falls through to AddAccountGeneralView(), asking the user to add the account they just connected. Deciding to clear calendarOnly there is a distinct change; make it consciously.

Symptom 2 — active subscriber routed to the paywall

Root cause: pending_plan_navigation (UserDefaults) is a latched intent recorded while entitlement was unknowable, consumed after login with no entitlement check.

  • Writer: MailNavigationView's subscribe-banner action — shown to every signed-out (email-only-mode) user — sets pending_plan_navigation = true and opens the sign-in sheet.
  • Consumer: MailNavigationView's bare .task (re-run because the hasTabMailSession flip remounts the view — the email-only and signed-in branches of RootView.routedContent are different structural identities) checks only the flag and the AI opt-out, sleeps 100 ms, then selection = .planPicker. It never reads AISubscriptionGate.isActive or hasCheckedOnce, so the authoritative /whoami that may have just opened the gate is irrelevant to the routing decision.
  • The one screen that has entitlement in hand doesn't clear the flag: RootView's AIConsentView completion writes false when AI is disabled, true when the gate is closed — and falls through both arms for an active subscriber with AI enabled, so a stale true survives.

Contributing defects on the same path:

  • The onboarding login branch never revalidates entitlement at all. RootView.routedContent's own TabMailLoginView { withAnimation { hasTabMailSession = true } } does not post .tabMailDidSignIn; that notification's onReceive is the only trigger for RootView.revalidateAISubscriptionGate() (plus consent restore and DeviceSync connect). Only the sheet/prompt presenters post it. On a fresh device this deterministically routes a subscriber to the paywall: the gate hydrates isActive = false from UserDefaults and nothing on that path ever asks the backend.
  • isActive is read without the hasCheckedOnce guard. AISubscriptionGate.hasCheckedOnce exists precisely to distinguish "known inactive" from "not yet asked" (see Companion/Memory/Current/076-banner-flash-prevention-mailnavigationview.md — the banner honors it; this routing site doesn't). A slow/failed /whoami means the paywall is chosen on the hydrated default.
  • Ruled out: token-not-yet-stored → 401 → "no subscription" (the session is Keychain-persisted inside signInWithIdToken before it returns, and a thrown /whoami error leaves the gate untouched), and check-before-DB-hydration (AppStartup completes loadInitialData() before RootView first renders).

Suggested fix direction

Symptom 1:

  1. Gate the write: only set PendingAccountAdd.shared.pending when no account covers that address — via a single named predicate (e.g. AccountManager.existingAccount(forEmail:provider:)) shared by the login gate and setupOAuthAccount, querying GRDB directly and comparing case-insensitively.
  2. Belt-and-braces at the render site: RootView.pendingAccountGate drops a pending that matches a configured account.

Symptom 2:

  1. Make the consumer entitlement-aware: don't navigate to .planPicker while isActive, and don't navigate on an unknown gate — require hasCheckedOnce (await the authoritative response rather than routing on the hydrated default). Same discipline 076-banner-flash-prevention already mandates for the banner.
  2. Fix the writers: the AI-consent closure should clear the flag in the active-subscriber arm instead of falling through.
  3. Close the no-revalidation branch: RootView's login completion should run the same post-sign-in work as the sheet paths (post .tabMailDidSignIn, or factor the onReceive body into a handleDidSignIn() both call).
  4. The 100 ms Task.sleep mount barrier is a timed yield standing in for a readiness signal — replace it if touching this path.

Constraints: entitlement state is written server-side only — no client-side entitlement writes. Note Companion/Memory/Current/118-trial-ended-is-derived-never-a-new-whoami-flag.md records the RootView consent-read's discard of has_subscription as deliberate; if a fix feeds that response into AISubscriptionGate.apply, amend that doc explicitly rather than diverging silently. See also Companion/Decisions/Active/adr-ios-044.md (no new /whoami polling seams).

Acceptance

  • Red-first tests pinning the invariants, not the fix mechanisms: (a) a sign-in whose identity matches a configured account (any casing; including a calendarOnly row) never presents the add-account gate; (b) a user with an active entitlement is never routed to .planPicker by the post-login flow, regardless of a pre-existing pending_plan_navigation latch or /whoami timing; (c) two-sided — a genuinely unentitled user still reaches the paywall, and a genuinely new account still gets the add-account gate.
  • Verify on the onboarding branch specifically (RootView-presented login, not the sheet), since that branch currently skips revalidation entirely.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions