From 2f81d3420011b769d44b0f00ac608412abde3676 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:30:23 -0700 Subject: [PATCH] fix(waitlist): queue + retry failed signups instead of silently opening mailto (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signup that hit a network error, the 8s timeout or a 5xx was handed straight to a `mailto:` composer. That path is lossy by design: it only works if the user actually presses send, and if we keep reconciling the support inbox into Brevo list 4 forever (AGE-61's hourly job). 20 of 21 signups were lost that way before that reconciler existed, and Play's active base is ~100% on v0.4.10+ — so this was current builds leaking, not just the ~436 stale sideloads. Now: - Failed-but-retryable signups are persisted on-device (`opencode.waitlist.pending.v1`, AsyncStorage) and retried on every app foreground (`app/_layout.tsx`) and on the Add Connection screen mount. - 4xx stays non-retryable: the server will never accept that address, so we ask the user to fix it instead of queueing garbage forever. - `mailto:` is now only ever opened by an explicit user tap ("Still not working? Email us instead"), shown after 3 failed attempts, or offered in an alert when device storage itself refuses the write — never as the silent default. - The UI tells the truth: "Saved on this device — we'll finish signing you up as soon as you're back online" instead of implying it was sent. - `WaitlistResult.fallback` -> `retryable`, `shouldFallbackToMailto` -> `isRetryableFailure`: the decision is about retry, not about mail. Queue policy: dedupe by email, cap 5 entries, 30-day TTL, corrupt/foreign JSON is discarded rather than replayed. Storage and the clock are injected so the whole thing runs under `node --test` (16 new tests, incl. the acceptance case: offline signup -> queued -> reconnect -> reaches the server, no mail client). Also commits the AGE-61 measurement artifacts that were only ever local (`distribution/waitlist-signup-path-coverage.md`, `scripts/play-version-share.mjs`) and updates the doc's "current builds still leak" section, which this fixes. Refs AGE-87, AGE-61. Co-authored-by: engineer --- app/_layout.tsx | 25 ++ app/connection/add.tsx | 133 ++++++++-- distribution/waitlist-signup-path-coverage.md | 12 +- src/lib/i18n/en.json | 6 +- src/lib/i18n/zh-Hans.json | 6 +- src/lib/waitlist-queue-storage.ts | 35 +++ src/lib/waitlist-queue.test.ts | 244 +++++++++++++++++ src/lib/waitlist.test.ts | 46 ++-- src/lib/waitlist.ts | 248 +++++++++++++++++- 9 files changed, 698 insertions(+), 57 deletions(-) create mode 100644 src/lib/waitlist-queue-storage.ts create mode 100644 src/lib/waitlist-queue.test.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 8e47b70..c669aac 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -19,6 +19,7 @@ import * as notifications from "../src/lib/notifications" import { addBreadcrumb, wrap } from "../src/lib/sentry" import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry" import { initAnalytics, trackAppOpened } from "../src/lib/analytics" +import { flushPendingSignups } from "../src/lib/waitlist-queue-storage" const queryClient = new QueryClient() @@ -95,6 +96,30 @@ function RootLayout() { return () => sub.remove() }, []) + // Retry any waitlist signup that couldn't reach the server when the user + // tapped Join (AGE-87). Runs at cold start and on every foreground, which is + // the cheapest reliable proxy for "connectivity may have come back" — it is a + // no-op (single storage read, no network) when the queue is empty, and it + // replaces the old silent mailto: fallback that lost 20 of 21 signups. + useEffect(() => { + const flush = () => { + void flushPendingSignups() + .then((outcome) => { + if (outcome.synced.length > 0) { + addBreadcrumb({ category: "waitlist", message: `retried ${outcome.synced.length} queued signup(s)` }) + } + }) + .catch(() => { + // Best effort: the entry stays queued for the next foreground. + }) + } + flush() + const sub = AppState.addEventListener("change", (next) => { + if (next === "active") flush() + }) + return () => sub.remove() + }, []) + // Connect/disconnect SSE and load catalog when client changes useEffect(() => { if (client && !sseStarted.current) { diff --git a/app/connection/add.tsx b/app/connection/add.tsx index f35bbd9..dc7fe23 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import { View, Text, @@ -21,7 +21,8 @@ import { captureDiagnostic } from "../../src/lib/sentry" import { parseUrl } from "../../src/lib/diagnostics-classify" import { buildAuth } from "../../src/lib/auth" import { AnalyticsEvent, track } from "../../src/lib/analytics" -import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/waitlist" +import { submitWaitlistSignup, buildWaitlistMailtoUrl, needsManualEscapeHatch, type QueuedSignup } from "../../src/lib/waitlist" +import { flushPendingSignups, queuePendingSignup, readPendingSignups, dropPendingSignup } from "../../src/lib/waitlist-queue-storage" export default function AddConnectionScreen() { const colorScheme = useColorScheme() @@ -41,7 +42,34 @@ export default function AddConnectionScreen() { const [password, setPassword] = useState("") const [isConnecting, setIsConnecting] = useState(false) const [waitlistEmail, setWaitlistEmail] = useState("") - const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined">("idle") + // "queued" = the POST failed but the signup is persisted on-device and will + // be retried on the next foreground/connectivity (AGE-87). It is NOT "sent". + const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined" | "queued">("idle") + const [pendingSignup, setPendingSignup] = useState(null) + + // Retry anything left over from a previous session as soon as this screen + // opens (the root layout also flushes on every foreground), then reflect the + // real state back to the user instead of pretending nothing is pending. + useEffect(() => { + let cancelled = false + void (async () => { + const outcome = await flushPendingSignups().catch(() => null) + if (cancelled) return + const pending = outcome ? outcome.pending : await readPendingSignups().catch(() => []) + if (cancelled) return + if (outcome && outcome.synced.length > 0 && pending.length === 0) { + setWaitlistState("joined") + return + } + if (pending.length > 0) { + setPendingSignup(pending[pending.length - 1]) + setWaitlistState((current) => (current === "idle" ? "queued" : current)) + } + })() + return () => { + cancelled = true + } + }, []) const buildUrl = () => { if (mode === "advanced") return url.trim() @@ -213,25 +241,70 @@ export default function AddConnectionScreen() { const handleJoinWaitlist = async () => { if (waitlistState === "submitting") return + const attemptedEmail = waitlistEmail setWaitlistState("submitting") - const result = await submitWaitlistSignup(waitlistEmail) + const result = await submitWaitlistSignup(attemptedEmail) if (result.ok) { + // Clear any earlier queued attempt for the same address so the flush + // doesn't re-post it. + void dropPendingSignup(result.email) + setPendingSignup(null) setWaitlistState("joined") return } - setWaitlistState("idle") - if (result.fallback) { - // API unreachable/broken: fall back to the pre-#87 mailto path so the - // signup still reaches the support inbox instead of being lost. - try { - await Linking.openURL(buildWaitlistMailtoUrl(result.email)) - } catch { - // No mail app either — tell the user instead of failing silently. - Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.fallbackMessage")) - } - } else { + + if (!result.retryable) { + // The server rejected this address; queueing it would retry forever. + setWaitlistState(pendingSignup ? "queued" : "idle") Alert.alert(t("connection.add.waitlist.alertTitle"), result.error) + return } + + // Offline / timeout / 5xx: persist and retry later instead of dumping the + // user into a mail composer they may never send (AGE-87). + const entry = await queuePendingSignup(result.email, result.error) + if (entry) { + setPendingSignup(entry) + setWaitlistState("queued") + return + } + + // Storage refused the write — we cannot promise to finish this later, so + // offer the manual email path explicitly rather than claiming success. + setWaitlistState("idle") + Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.queueFailedMessage"), [ + { text: t("common.cancel"), style: "cancel" }, + { text: t("connection.add.waitlist.emailUsButton"), onPress: () => void openWaitlistMailto(result.email) }, + ]) + } + + // Last-resort, user-initiated only. Never opened automatically. + const openWaitlistMailto = async (email: string) => { + try { + await Linking.openURL(buildWaitlistMailtoUrl(email)) + } catch { + Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.noMailAppMessage")) + } + } + + // Explicit "Retry" from the queued state — same code path the foreground + // flush uses, so there is only one retry implementation. + const handleRetryQueued = async () => { + setWaitlistState("submitting") + const outcome = await flushPendingSignups().catch(() => null) + if (outcome && outcome.pending.length === 0 && outcome.synced.length > 0) { + setPendingSignup(null) + setWaitlistState("joined") + return + } + if (outcome && outcome.pending.length === 0) { + // Nothing left pending and nothing synced: the address was rejected. + setPendingSignup(null) + setWaitlistState("idle") + return + } + if (outcome) setPendingSignup(outcome.pending[outcome.pending.length - 1]) + setWaitlistState("queued") } // Quick connect mode - simplified @@ -376,6 +449,27 @@ export default function AddConnectionScreen() { {t("connection.add.waitlist.successText")} + ) : waitlistState === "queued" ? ( + + + + + {t("connection.add.waitlist.queuedText")} + + + {needsManualEscapeHatch(pendingSignup) && ( + void openWaitlistMailto(pendingSignup?.email ?? waitlistEmail)} + testID="waitlist-email-us" + > + {t("connection.add.waitlist.emailUsLink")} + + )} + void handleRetryQueued()} testID="waitlist-retry"> + {t("common.retry")} + + ) : ( <> AsyncStorage.getItem(key), + setItem: (key, value) => AsyncStorage.setItem(key, value), + removeItem: (key) => AsyncStorage.removeItem(key), +} + +export function queuePendingSignup(email: string, error?: string): Promise { + return enqueueSignup(storage, email, { error }) +} + +export function readPendingSignups(): Promise { + return loadQueue(storage) +} + +export function dropPendingSignup(email: string): Promise { + return removeFromQueue(storage, email) +} + +/** Best-effort retry of every pending signup. Safe to call on every foreground. */ +export function flushPendingSignups(): Promise { + return flushQueue(storage) +} diff --git a/src/lib/waitlist-queue.test.ts b/src/lib/waitlist-queue.test.ts new file mode 100644 index 0000000..f1d5940 --- /dev/null +++ b/src/lib/waitlist-queue.test.ts @@ -0,0 +1,244 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + WAITLIST_QUEUE_KEY, + WAITLIST_QUEUE_MAX, + WAITLIST_QUEUE_TTL_MS, + enqueueSignup, + flushQueue, + loadQueue, + needsManualEscapeHatch, + pruneQueue, + removeFromQueue, + type QueueStorage, + type QueuedSignup, + type WaitlistResult, +} from "./waitlist.ts" + +// In-memory stand-in for AsyncStorage. `fail` makes every write throw, which is +// how a device with full/locked storage behaves. +function memoryStorage(initial?: string, opts: { failWrites?: boolean } = {}) { + const map = new Map() + if (initial !== undefined) map.set(WAITLIST_QUEUE_KEY, initial) + const storage: QueueStorage & { map: Map } = { + map, + async getItem(key) { + return map.get(key) ?? null + }, + async setItem(key, value) { + if (opts.failWrites) throw new Error("storage full") + map.set(key, value) + }, + async removeItem(key) { + if (opts.failWrites) throw new Error("storage full") + map.delete(key) + }, + } + return storage +} + +function stored(storage: { map: Map }): QueuedSignup[] { + const raw = storage.map.get(WAITLIST_QUEUE_KEY) + return raw ? (JSON.parse(raw) as QueuedSignup[]) : [] +} + +const ok = (email: string): WaitlistResult => ({ ok: true, email }) +const offline = (email: string): WaitlistResult => ({ ok: false, email, retryable: true, error: "Network request failed" }) +const rejected = (email: string): WaitlistResult => ({ ok: false, email, retryable: false, error: "Enter a valid email address." }) + +// --- enqueue --- + +test("enqueue persists a normalized signup with attempt 1", async () => { + const storage = memoryStorage() + const entry = await enqueueSignup(storage, " Dev@Example.COM ", { now: 1_000, error: "Network request failed" }) + assert.deepEqual(entry, { + email: "dev@example.com", + queuedAt: 1_000, + attempts: 1, + lastAttemptAt: 1_000, + lastError: "Network request failed", + }) + assert.deepEqual(stored(storage), [entry]) +}) + +test("enqueue dedupes by email and bumps attempts instead of growing the queue", async () => { + const storage = memoryStorage() + await enqueueSignup(storage, "dev@example.com", { now: 1_000 }) + const second = await enqueueSignup(storage, "DEV@example.com", { now: 2_000 }) + assert.equal(stored(storage).length, 1) + assert.equal(second?.attempts, 2) + assert.equal(second?.queuedAt, 1_000, "original queue time is preserved") + assert.equal(second?.lastAttemptAt, 2_000) +}) + +test("enqueue caps the queue at WAITLIST_QUEUE_MAX, keeping the newest", async () => { + const storage = memoryStorage() + for (let i = 0; i < WAITLIST_QUEUE_MAX + 3; i++) { + await enqueueSignup(storage, `user${i}@example.com`, { now: 1_000 + i }) + } + const queue = stored(storage) + assert.equal(queue.length, WAITLIST_QUEUE_MAX) + assert.equal(queue[queue.length - 1].email, `user${WAITLIST_QUEUE_MAX + 2}@example.com`) +}) + +test("enqueue refuses an invalid email — nothing is stored, caller must not claim success", async () => { + const storage = memoryStorage() + assert.equal(await enqueueSignup(storage, "not-an-email"), null) + assert.equal(storage.map.size, 0) +}) + +test("enqueue returns null when storage refuses the write (never lie about saving)", async () => { + const storage = memoryStorage(undefined, { failWrites: true }) + assert.equal(await enqueueSignup(storage, "dev@example.com", { now: 1 }), null) +}) + +// --- load resilience: a corrupt blob must not brick the screen --- + +test("load tolerates missing, corrupt, non-array and foreign-shaped payloads", async () => { + assert.deepEqual(await loadQueue(memoryStorage()), []) + assert.deepEqual(await loadQueue(memoryStorage("{not json")), []) + assert.deepEqual(await loadQueue(memoryStorage('{"email":"a@b.co"}')), []) + assert.deepEqual(await loadQueue(memoryStorage('[{"nope":1},null,3]')), []) + assert.deepEqual( + await loadQueue(memoryStorage('[{"email":"NOT normalized","queuedAt":1,"attempts":1}]')), + [], + "entries that were never normalized are dropped rather than replayed as garbage", + ) +}) + +test("load backfills lastAttemptAt for entries written before that field existed", async () => { + const storage = memoryStorage('[{"email":"dev@example.com","queuedAt":42,"attempts":1}]') + assert.deepEqual(await loadQueue(storage), [ + { email: "dev@example.com", queuedAt: 42, attempts: 1, lastAttemptAt: 42 }, + ]) +}) + +test("load survives a storage read that throws", async () => { + const storage: QueueStorage = { + async getItem() { + throw new Error("AsyncStorage unavailable") + }, + async setItem() {}, + async removeItem() {}, + } + assert.deepEqual(await loadQueue(storage), []) +}) + +// --- TTL --- + +test("entries older than the TTL are pruned", () => { + const fresh: QueuedSignup = { email: "a@b.co", queuedAt: 1_000, attempts: 1, lastAttemptAt: 1_000 } + const stale: QueuedSignup = { email: "c@d.co", queuedAt: 0, attempts: 9, lastAttemptAt: 0 } + assert.deepEqual(pruneQueue([fresh, stale], WAITLIST_QUEUE_TTL_MS + 500), [fresh]) +}) + +// --- flush: the AGE-87 acceptance test --- + +test("offline signup reaches the server on the next flush, with no mail client involved", async () => { + const storage = memoryStorage() + + // 1. User taps "Join Waitlist" on a plane: the POST fails, we queue it. + const failure = offline("dev@example.com") + assert.equal(failure.ok, false) + const queued = await enqueueSignup(storage, "dev@example.com", { now: 1_000, error: failure.error }) + assert.equal(queued?.attempts, 1) + + // 2. Still offline on the next foreground: kept, attempts bumped. + const attempted: string[] = [] + const stillOffline = await flushQueue(storage, { + now: 2_000, + submit: async (email) => { + attempted.push(email) + return offline(email) + }, + }) + assert.deepEqual(stillOffline.synced, []) + assert.deepEqual(stillOffline.pending.map((e) => e.attempts), [2]) + assert.deepEqual(stored(storage).map((e) => e.email), ["dev@example.com"]) + + // 3. Device reconnects: the queued signup lands in Brevo list 4 and the + // queue empties. No mailto: URL was ever built. + const reconnected = await flushQueue(storage, { + now: 3_000, + submit: async (email) => { + attempted.push(email) + return ok(email) + }, + }) + assert.deepEqual(reconnected.synced, ["dev@example.com"]) + assert.deepEqual(reconnected.pending, []) + assert.equal(storage.map.has(WAITLIST_QUEUE_KEY), false, "queue key is removed once empty") + assert.deepEqual(attempted, ["dev@example.com", "dev@example.com"]) +}) + +test("flush drops entries the server permanently rejects (4xx) instead of retrying forever", async () => { + const storage = memoryStorage() + await enqueueSignup(storage, "dev@example.com", { now: 1 }) + const outcome = await flushQueue(storage, { now: 2, submit: async (email) => rejected(email) }) + assert.deepEqual(outcome.rejected, ["dev@example.com"]) + assert.deepEqual(outcome.pending, []) + assert.deepEqual(stored(storage), []) +}) + +test("flush keeps the other entries when one submit throws", async () => { + const storage = memoryStorage() + await enqueueSignup(storage, "a@example.com", { now: 1 }) + await enqueueSignup(storage, "b@example.com", { now: 2 }) + const outcome = await flushQueue(storage, { + now: 3, + submit: async (email) => { + if (email === "a@example.com") throw new TypeError("boom") + return ok(email) + }, + }) + assert.deepEqual(outcome.synced, ["b@example.com"]) + assert.deepEqual(outcome.pending.map((e) => e.email), ["a@example.com"]) + assert.match(outcome.pending[0].lastError ?? "", /boom/) +}) + +test("flush prunes expired entries without submitting them", async () => { + const storage = memoryStorage() + await enqueueSignup(storage, "old@example.com", { now: 0 }) + let called = 0 + const outcome = await flushQueue(storage, { + now: WAITLIST_QUEUE_TTL_MS + 1, + submit: async (email) => { + called++ + return ok(email) + }, + }) + assert.equal(called, 0) + assert.deepEqual(outcome, { synced: [], rejected: [], pending: [] }) + assert.deepEqual(stored(storage), []) +}) + +test("flush on an empty queue is a no-op", async () => { + const storage = memoryStorage() + const outcome = await flushQueue(storage, { submit: async () => assert.fail("must not submit") }) + assert.deepEqual(outcome, { synced: [], rejected: [], pending: [] }) +}) + +// --- manual escape hatch policy --- + +test("the manual email escape hatch appears only after repeated failures", async () => { + const storage = memoryStorage() + let entry = await enqueueSignup(storage, "dev@example.com", { now: 1 }) + assert.equal(needsManualEscapeHatch(entry), false) + entry = await enqueueSignup(storage, "dev@example.com", { now: 2 }) + assert.equal(needsManualEscapeHatch(entry), false) + entry = await enqueueSignup(storage, "dev@example.com", { now: 3 }) + assert.equal(needsManualEscapeHatch(entry), true, "3rd failed attempt -> offer 'still not working? email us'") + assert.equal(needsManualEscapeHatch(null), false) +}) + +// --- explicit removal --- + +test("removeFromQueue drops just that email", async () => { + const storage = memoryStorage() + await enqueueSignup(storage, "a@example.com", { now: 1 }) + await enqueueSignup(storage, "b@example.com", { now: 2 }) + await removeFromQueue(storage, "a@example.com") + assert.deepEqual(stored(storage).map((e) => e.email), ["b@example.com"]) + await removeFromQueue(storage, "missing@example.com") // no-op, no throw + assert.deepEqual(stored(storage).map((e) => e.email), ["b@example.com"]) +}) diff --git a/src/lib/waitlist.test.ts b/src/lib/waitlist.test.ts index 5cd4ec6..6221980 100644 --- a/src/lib/waitlist.test.ts +++ b/src/lib/waitlist.test.ts @@ -6,7 +6,7 @@ import { normalizeWaitlistEmail, buildWaitlistPayload, buildWaitlistMailtoUrl, - shouldFallbackToMailto, + isRetryableFailure, submitWaitlistSignup, } from "./waitlist.ts" @@ -34,32 +34,32 @@ test("payload tags the signup with the opencode-connect source", () => { }) }) -// --- mailto fallback URL: byte-compatible with the pre-#87 behavior --- +// --- mailto URL: last-resort escape hatch only (AGE-87), byte-compatible --- -test("mailto fallback preserves subject and embeds the email", () => { +test("mailto escape hatch preserves subject and embeds the email", () => { const url = buildWaitlistMailtoUrl("dev@example.com") assert.ok(url.startsWith("mailto:support@agentlabs.cc?")) assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist")) assert.ok(url.includes(encodeURIComponent("Email: dev@example.com"))) }) -test("mailto fallback works without an email", () => { +test("mailto escape hatch works without an email", () => { const url = buildWaitlistMailtoUrl("") assert.ok(url.includes("body=Sign%20me%20up!")) assert.ok(!url.includes("Email")) }) -// --- fallback decision --- +// --- retry decision --- // 502 named explicitly: the server returns it when Brevo itself fails, and the -// signup must survive that via mailto. -test("fallback: transport failures and 5xx (incl. 502 Brevo failure) -> mailto, 4xx -> fix input", () => { - assert.equal(shouldFallbackToMailto({ kind: "network-error" }), true) - assert.equal(shouldFallbackToMailto({ kind: "http", status: 500 }), true) - assert.equal(shouldFallbackToMailto({ kind: "http", status: 502 }), true) - assert.equal(shouldFallbackToMailto({ kind: "http", status: 503 }), true) - assert.equal(shouldFallbackToMailto({ kind: "http", status: 400 }), false) - assert.equal(shouldFallbackToMailto({ kind: "http", status: 429 }), false) +// signup must survive that by being queued and retried (AGE-87), not mailed. +test("retryable: transport failures and 5xx (incl. 502 Brevo failure) -> retry, 4xx -> fix input", () => { + assert.equal(isRetryableFailure({ kind: "network-error" }), true) + assert.equal(isRetryableFailure({ kind: "http", status: 500 }), true) + assert.equal(isRetryableFailure({ kind: "http", status: 502 }), true) + assert.equal(isRetryableFailure({ kind: "http", status: 503 }), true) + assert.equal(isRetryableFailure({ kind: "http", status: 400 }), false) + assert.equal(isRetryableFailure({ kind: "http", status: 429 }), false) }) // --- submitWaitlistSignup with injected fetch --- @@ -89,37 +89,37 @@ test("submit rejects an invalid email locally without hitting the network", asyn const calls: FetchCall[] = [] const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) }) assert.equal(calls.length, 0) - assert.deepEqual(result, { ok: false, email: "nope", fallback: false, error: "Enter a valid email address." }) + assert.deepEqual(result, { ok: false, email: "nope", retryable: false, error: "Enter a valid email address." }) }) -test("submit surfaces the server's 400 message without falling back to mailto", async () => { +test("submit surfaces the server's 400 message and marks it non-retryable", async () => { const result = await submitWaitlistSignup("dev@example.com", { fetchFn: fakeFetch({ ok: false, status: 400, body: { error: "Enter a valid email address." } }), }) - assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: false, error: "Enter a valid email address." }) + assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: false, error: "Enter a valid email address." }) }) -test("submit falls back to mailto on 5xx (server broken, keep the signup alive)", async () => { +test("submit marks 5xx retryable (server broken, keep the signup alive)", async () => { const result = await submitWaitlistSignup("dev@example.com", { fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }), }) assert.equal(result.ok, false) if (!result.ok) { - assert.equal(result.fallback, true) + assert.equal(result.retryable, true) assert.equal(result.error, "The waitlist is temporarily unavailable. Please try again later.") } }) -test("submit falls back to mailto when fetch rejects (offline)", async () => { +test("submit marks a rejected fetch retryable (offline)", async () => { const result = await submitWaitlistSignup("dev@example.com", { fetchFn: async () => { throw new TypeError("Network request failed") }, }) - assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Network request failed" }) + assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "Network request failed" }) }) -test("submit aborts after timeoutMs and falls back to mailto", async () => { +test("submit aborts after timeoutMs and marks it retryable", async () => { const result = await submitWaitlistSignup("dev@example.com", { timeoutMs: 20, fetchFn: (_url, init) => @@ -131,7 +131,7 @@ test("submit aborts after timeoutMs and falls back to mailto", async () => { }) }), }) - assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "timeout after 20ms" }) + assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "timeout after 20ms" }) }) test("submit tolerates a non-JSON error body", async () => { @@ -144,5 +144,5 @@ test("submit tolerates a non-JSON error body", async () => { }, }), }) - assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Signup failed (HTTP 502)." }) + assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "Signup failed (HTTP 502)." }) }) diff --git a/src/lib/waitlist.ts b/src/lib/waitlist.ts index 61a75f5..ac66f0f 100644 --- a/src/lib/waitlist.ts +++ b/src/lib/waitlist.ts @@ -31,7 +31,12 @@ export function buildWaitlistPayload(email: string): { email: string; source: st return { email, source: WAITLIST_SOURCE } } -/** The pre-#87 mailto path, kept as the fallback when the API is unreachable. */ +/** + * Last-resort, USER-INITIATED escape hatch (AGE-87). Never open this + * automatically: a failed signup is queued locally and retried + * (waitlist-queue.ts). This URL is only offered after retries keep failing, + * behind an explicit "still not working? email us" tap. + */ export function buildWaitlistMailtoUrl(email: string): string { const subject = encodeURIComponent("OpenCode Connect Waitlist") const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!") @@ -41,19 +46,19 @@ export function buildWaitlistMailtoUrl(email: string): string { export type WaitlistResult = /** Signup persisted server-side. */ | { ok: true; email: string } - /** Signup not persisted. `fallback` decides the UX: true -> open the - * mailto fallback so the signup still reaches the support inbox; + /** Signup not persisted. `retryable` decides the UX: true -> the same + * request can succeed later, so queue it and retry on reconnect; * false -> the input (or server validation) is wrong, ask the user to fix - * their email instead of mailing garbage. */ - | { ok: false; email: string; fallback: boolean; error: string } + * their email instead of queueing garbage forever. */ + | { ok: false; email: string; retryable: boolean; error: string } /** - * Fallback decision, isolated for testability: - * - transport failure (offline, DNS, timeout) -> mailto keeps the signup alive - * - 5xx -> server broken through no fault of the user's -> mailto - * - 4xx -> the server rejected this email; mailing it wouldn't help + * Retry decision, isolated for testability: + * - transport failure (offline, DNS, timeout) -> retry when connectivity returns + * - 5xx -> server broken through no fault of the user's -> retry + * - 4xx -> the server rejected this email; repeating it wouldn't help */ -export function shouldFallbackToMailto(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean { +export function isRetryableFailure(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean { if (outcome.kind === "network-error") return true return outcome.status >= 500 } @@ -78,7 +83,7 @@ function serverError(body: unknown): string | undefined { export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise { const email = normalizeWaitlistEmail(rawEmail) if (email === null) { - return { ok: false, email: rawEmail.trim(), fallback: false, error: "Enter a valid email address." } + return { ok: false, email: rawEmail.trim(), retryable: false, error: "Enter a valid email address." } } const fetchFn = deps.fetchFn ?? (fetch as unknown as FetchLike) @@ -99,7 +104,7 @@ export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps return { ok: false, email, - fallback: shouldFallbackToMailto({ kind: "http", status: res.status }), + retryable: isRetryableFailure({ kind: "http", status: res.status }), error: message || `Signup failed (HTTP ${res.status}).`, } } catch (error: unknown) { @@ -108,10 +113,227 @@ export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps return { ok: false, email, - fallback: shouldFallbackToMailto({ kind: "network-error" }), + retryable: isRetryableFailure({ kind: "network-error" }), error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error), } } finally { clearTimeout(timer) } } + +// --------------------------------------------------------------------------- +// Durable retry queue (AGE-87) +// --------------------------------------------------------------------------- +// +// Before this, a signup that hit a network error / 8s timeout / 5xx was handed +// straight to a `mailto:` composer. That is lossy by design: it depends on the +// user actually pressing send in their mail client, and on us reconciling the +// support inbox into Brevo list 4 forever (AGE-61's hourly reconciler). 20 of +// 21 signups were lost that way before that reconciler existed. +// +// Instead we persist the pending signup on the device and retry it on the next +// foreground / connectivity. `mailto:` survives only as a last-resort, +// USER-INITIATED escape hatch once retries are clearly not working. +// +// Lives in this file (rather than its own module) so it stays a dependency-free +// leaf that `node --test` can run directly, the same constraint that keeps +// react-native imports out of here. Storage and the clock are injected; the +// production AsyncStorage adapter is waitlist-queue-storage.ts. + +export const WAITLIST_QUEUE_KEY = "opencode.waitlist.pending.v1" + +/** Cap the queue so a broken device can't grow storage without bound. */ +export const WAITLIST_QUEUE_MAX = 5 + +/** After this many failed attempts the UI offers the manual email escape hatch. */ +export const WAITLIST_QUEUE_ATTEMPTS_BEFORE_MANUAL = 3 + +/** Entries older than this are dropped: the address is stale, the user moved on. */ +export const WAITLIST_QUEUE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days + +export interface QueuedSignup { + /** Already normalized (trimmed/lowercased) by normalizeWaitlistEmail. */ + email: string + /** Epoch ms of the first attempt. */ + queuedAt: number + /** Number of failed submit attempts so far (>= 1 — enqueue follows a failure). */ + attempts: number + /** Epoch ms of the last attempt. */ + lastAttemptAt: number + /** Last transport/server error, for diagnostics only. */ + lastError?: string +} + +/** + * Minimal storage port. AsyncStorage satisfies this structurally, so does a + * plain Map in tests. Deliberately not typed against AsyncStorage so this file + * stays importable by `node --test`. + */ +export interface QueueStorage { + getItem(key: string): Promise + setItem(key: string, value: string): Promise + removeItem(key: string): Promise +} + +function isQueuedSignup(value: unknown): value is QueuedSignup { + if (typeof value !== "object" || value === null) return false + const entry = value as Record + return ( + typeof entry.email === "string" && + normalizeWaitlistEmail(entry.email) === entry.email && + typeof entry.queuedAt === "number" && + Number.isFinite(entry.queuedAt) && + typeof entry.attempts === "number" && + Number.isFinite(entry.attempts) + ) +} + +/** Reads the queue, tolerating absent/corrupt/foreign JSON (never throws). */ +export async function loadQueue(storage: QueueStorage): Promise { + let raw: string | null = null + try { + raw = await storage.getItem(WAITLIST_QUEUE_KEY) + } catch { + return [] + } + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter(isQueuedSignup).map((entry) => ({ + ...entry, + lastAttemptAt: typeof entry.lastAttemptAt === "number" ? entry.lastAttemptAt : entry.queuedAt, + })) + } catch { + return [] + } +} + +async function saveQueue(storage: QueueStorage, queue: QueuedSignup[]): Promise { + try { + if (queue.length === 0) await storage.removeItem(WAITLIST_QUEUE_KEY) + else await storage.setItem(WAITLIST_QUEUE_KEY, JSON.stringify(queue)) + } catch { + // Storage unavailable: the retry is lost, but the caller already told the + // user the truth ("saved, we'll finish signing you up") only when this + // resolves. enqueueSignup() surfaces the failure via its return value. + throw new Error("waitlist queue storage unavailable") + } +} + +/** Drops entries past the TTL. Exported for the test that pins the policy. */ +export function pruneQueue(queue: QueuedSignup[], now: number): QueuedSignup[] { + return queue.filter((entry) => now - entry.queuedAt < WAITLIST_QUEUE_TTL_MS) +} + +/** + * Persists a failed signup for later retry. Dedupes by email (an impatient + * user tapping twice must not produce two entries) and keeps the newest + * WAITLIST_QUEUE_MAX entries. + * + * Returns the stored entry, or null when the email is invalid or storage + * refused the write — in that case the caller must NOT tell the user we saved it. + */ +export async function enqueueSignup( + storage: QueueStorage, + rawEmail: string, + options: { now?: number; error?: string } = {}, +): Promise { + const email = normalizeWaitlistEmail(rawEmail) + if (email === null) return null + const now = options.now ?? Date.now() + + const existing = pruneQueue(await loadQueue(storage), now) + const previous = existing.find((entry) => entry.email === email) + const entry: QueuedSignup = { + email, + queuedAt: previous?.queuedAt ?? now, + attempts: (previous?.attempts ?? 0) + 1, + lastAttemptAt: now, + ...(options.error ? { lastError: options.error } : {}), + } + const next = [...existing.filter((e) => e.email !== email), entry].slice(-WAITLIST_QUEUE_MAX) + try { + await saveQueue(storage, next) + } catch { + return null + } + return entry +} + +export async function removeFromQueue(storage: QueueStorage, email: string): Promise { + const queue = await loadQueue(storage) + const next = queue.filter((entry) => entry.email !== email) + if (next.length === queue.length) return + try { + await saveQueue(storage, next) + } catch { + // best effort + } +} + +export interface FlushOutcome { + /** Entries that reached the server (Brevo list 4). */ + synced: string[] + /** Entries the server permanently rejected (4xx) — dropped, retrying can't help. */ + rejected: string[] + /** Entries still pending after this flush. */ + pending: QueuedSignup[] +} + +export type SubmitFn = (email: string) => Promise + +/** + * Retries every pending signup once. Called on app foreground and when the + * waitlist screen mounts. + * + * - ok -> drop (it's in Brevo now) + * - 4xx -> drop (the server will never accept this address) + * - network/5xx -> keep, bump attempts (retry next foreground) + * + * Never throws: a flush is a background best-effort action. + */ +export async function flushQueue( + storage: QueueStorage, + options: { submit?: SubmitFn; now?: number } = {}, +): Promise { + const now = options.now ?? Date.now() + const submit = options.submit ?? ((email: string) => submitWaitlistSignup(email)) + + const queue = pruneQueue(await loadQueue(storage), now) + const synced: string[] = [] + const rejected: string[] = [] + const pending: QueuedSignup[] = [] + + for (const entry of queue) { + let result: WaitlistResult + try { + result = await submit(entry.email) + } catch (error: unknown) { + pending.push({ ...entry, attempts: entry.attempts + 1, lastAttemptAt: now, lastError: String(error) }) + continue + } + if (result.ok) { + synced.push(entry.email) + } else if (!result.retryable) { + rejected.push(entry.email) + } else { + pending.push({ ...entry, attempts: entry.attempts + 1, lastAttemptAt: now, lastError: result.error }) + } + } + + try { + await saveQueue(storage, pending) + } catch { + // Storage went away mid-flush; the in-memory outcome is still accurate. + } + return { synced, rejected, pending } +} + +/** + * True once an entry has failed enough times that we should stop implying + * "we'll handle it" and offer the manual email escape hatch instead. + */ +export function needsManualEscapeHatch(entry: QueuedSignup | null | undefined): boolean { + return !!entry && entry.attempts >= WAITLIST_QUEUE_ATTEMPTS_BEFORE_MANUAL +}