@remit/web-client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/harness/build.mjs +84 -0
  2. package/harness/index.html +37 -0
  3. package/harness/vite-preset.ts +29 -0
  4. package/index.html +47 -0
  5. package/package.json +147 -0
  6. package/public/config.js +5 -0
  7. package/public/locales/de/common.json +1 -0
  8. package/public/locales/de/errors.json +1 -0
  9. package/public/locales/de/mail.json +13 -0
  10. package/public/locales/de/settings.json +1 -0
  11. package/public/locales/en/common.json +26 -0
  12. package/public/locales/en/errors.json +1 -0
  13. package/public/locales/en/mail.json +69 -0
  14. package/public/locales/en/settings.json +1 -0
  15. package/public/locales/fr/common.json +1 -0
  16. package/public/locales/fr/errors.json +1 -0
  17. package/public/locales/fr/mail.json +13 -0
  18. package/public/locales/fr/settings.json +1 -0
  19. package/public/locales/nl/common.json +1 -0
  20. package/public/locales/nl/errors.json +1 -0
  21. package/public/locales/nl/mail.json +13 -0
  22. package/public/locales/nl/settings.json +1 -0
  23. package/src/auth/AccountMenu.tsx +58 -0
  24. package/src/auth/AccountSession.tsx +19 -0
  25. package/src/auth/BetterAuthShell.tsx +198 -0
  26. package/src/auth/account-menu-mode.test.ts +64 -0
  27. package/src/auth/account-menu-mode.ts +28 -0
  28. package/src/auth/amplify-config.test.ts +158 -0
  29. package/src/auth/amplify-config.ts +60 -0
  30. package/src/auth/auth-error.test.ts +109 -0
  31. package/src/auth/auth-error.ts +107 -0
  32. package/src/auth/auth-interceptor.test.ts +86 -0
  33. package/src/auth/auth-interceptor.ts +23 -0
  34. package/src/auth/auth.css +334 -0
  35. package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
  36. package/src/auth/better-auth-config.ts +85 -0
  37. package/src/auth/better-auth-provider.tsx +20 -0
  38. package/src/auth/cognito/CognitoAccount.tsx +48 -0
  39. package/src/auth/cognito/CognitoShell.tsx +266 -0
  40. package/src/auth/cognito/cognito-token.test.ts +110 -0
  41. package/src/auth/cognito/cognito-token.ts +47 -0
  42. package/src/auth/cognito/cognito.css +21 -0
  43. package/src/auth/cognito-provider.tsx +22 -0
  44. package/src/auth/combined-provider.tsx +42 -0
  45. package/src/auth/composition.test.ts +136 -0
  46. package/src/auth/provider.tsx +63 -0
  47. package/src/auth/sign-out-visibility.test.ts +40 -0
  48. package/src/auth/sign-out-visibility.ts +10 -0
  49. package/src/auth/sign-up-disabled.test.ts +23 -0
  50. package/src/components/compose/AddressField.tsx +237 -0
  51. package/src/components/compose/ComposeBody.tsx +36 -0
  52. package/src/components/compose/ComposeForm.tsx +676 -0
  53. package/src/components/compose/ComposeProvider.tsx +133 -0
  54. package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
  55. package/src/components/compose/FromSelector.tsx +67 -0
  56. package/src/components/compose/FullCompose.tsx +82 -0
  57. package/src/components/compose/InlineCompose.tsx +29 -0
  58. package/src/components/compose/MobileComposeSheet.tsx +152 -0
  59. package/src/components/compose/PlateEditor.tsx +86 -0
  60. package/src/components/compose/PlateToolbar.tsx +110 -0
  61. package/src/components/compose/SubjectField.tsx +19 -0
  62. package/src/components/compose/draft-discard-banner.test.ts +76 -0
  63. package/src/components/compose/index.ts +6 -0
  64. package/src/components/compose/sanitize-quote-html.ts +23 -0
  65. package/src/components/layout/AppShellSkeleton.tsx +53 -0
  66. package/src/components/layout/ComposeFab.tsx +86 -0
  67. package/src/components/layout/Drawer.tsx +105 -0
  68. package/src/components/mail/AutoMovedIndicator.tsx +48 -0
  69. package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
  70. package/src/components/mail/BlockedImagesNotice.tsx +41 -0
  71. package/src/components/mail/BriefPane.tsx +289 -0
  72. package/src/components/mail/ConversationView.tsx +462 -0
  73. package/src/components/mail/DailyBrief.tsx +451 -0
  74. package/src/components/mail/DraftsView.tsx +279 -0
  75. package/src/components/mail/FlaggedList.tsx +170 -0
  76. package/src/components/mail/FlaggedPane.tsx +217 -0
  77. package/src/components/mail/IntelligencePane.test.ts +123 -0
  78. package/src/components/mail/IntelligencePane.tsx +441 -0
  79. package/src/components/mail/MailListHeader.tsx +187 -0
  80. package/src/components/mail/MailNav.tsx +70 -0
  81. package/src/components/mail/MailSidebarAdapter.tsx +351 -0
  82. package/src/components/mail/MailViewChrome.tsx +95 -0
  83. package/src/components/mail/MailboxPane.tsx +1219 -0
  84. package/src/components/mail/MessageActionMenu.tsx +368 -0
  85. package/src/components/mail/MessageBody.tsx +207 -0
  86. package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
  87. package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
  88. package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
  89. package/src/components/mail/MessageCard.tsx +401 -0
  90. package/src/components/mail/MessageDetail.tsx +153 -0
  91. package/src/components/mail/MessageList.tsx +804 -0
  92. package/src/components/mail/MessageListItem.tsx +267 -0
  93. package/src/components/mail/MessageToolbar.render.test.ts +57 -0
  94. package/src/components/mail/MessageToolbar.tsx +166 -0
  95. package/src/components/mail/MobileMessageBar.tsx +142 -0
  96. package/src/components/mail/MoveToTrigger.tsx +250 -0
  97. package/src/components/mail/NavMenuButton.tsx +25 -0
  98. package/src/components/mail/OutboxPane.tsx +531 -0
  99. package/src/components/mail/PullToRefresh.tsx +24 -0
  100. package/src/components/mail/RawMessageView.tsx +84 -0
  101. package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
  102. package/src/components/mail/SelectionToolbar.tsx +139 -0
  103. package/src/components/mail/SpamRescue.render.test.ts +35 -0
  104. package/src/components/mail/SpamRescue.tsx +138 -0
  105. package/src/components/mail/SwipeableMessageRow.tsx +148 -0
  106. package/src/components/mail/message-body-error-banner-content.ts +87 -0
  107. package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
  108. package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
  109. package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
  110. package/src/components/mail/organize/OrganizePanel.tsx +344 -0
  111. package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
  112. package/src/components/settings/AccountFormPanel.test.ts +72 -0
  113. package/src/components/settings/AccountFormPanel.tsx +942 -0
  114. package/src/components/settings/DangerZone.tsx +30 -0
  115. package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
  116. package/src/components/settings/DeleteAccountDialog.tsx +224 -0
  117. package/src/components/settings/FiltersList.render.test.ts +71 -0
  118. package/src/components/settings/FiltersList.tsx +93 -0
  119. package/src/components/settings/account-form-helpers.test.ts +172 -0
  120. package/src/components/settings/account-form-helpers.ts +105 -0
  121. package/src/components/ui/AppVersion.tsx +50 -0
  122. package/src/components/ui/BugReportButton.tsx +45 -0
  123. package/src/components/ui/ConfirmDialog.tsx +125 -0
  124. package/src/components/ui/DropdownMenu.tsx +105 -0
  125. package/src/components/ui/EmptyState.tsx +11 -0
  126. package/src/components/ui/ErrorBanner.tsx +88 -0
  127. package/src/components/ui/ErrorBannerProvider.tsx +77 -0
  128. package/src/components/ui/ErrorBannerStack.tsx +34 -0
  129. package/src/components/ui/ErrorState.tsx +80 -0
  130. package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
  131. package/src/components/ui/FatalErrorOverlay.tsx +151 -0
  132. package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
  133. package/src/components/ui/SlidePanel.tsx +72 -0
  134. package/src/components/ui/error-banners.test.ts +207 -0
  135. package/src/components/ui/error-banners.ts +111 -0
  136. package/src/hooks/queries/keys.ts +38 -0
  137. package/src/hooks/useArchiveMailbox.ts +104 -0
  138. package/src/hooks/useAutoMovedBadge.ts +77 -0
  139. package/src/hooks/useCurrentMailboxName.ts +99 -0
  140. package/src/hooks/useDebouncedValue.ts +12 -0
  141. package/src/hooks/useDeleteMessages.test.ts +103 -0
  142. package/src/hooks/useDeleteMessages.ts +226 -0
  143. package/src/hooks/useFilters.ts +112 -0
  144. package/src/hooks/useIntelligenceData.test.ts +269 -0
  145. package/src/hooks/useIntelligenceData.ts +284 -0
  146. package/src/hooks/useIsDark.ts +27 -0
  147. package/src/hooks/useKeyboardNavigation.ts +192 -0
  148. package/src/hooks/useLayoutTier.test.ts +47 -0
  149. package/src/hooks/useLayoutTier.ts +44 -0
  150. package/src/hooks/useLongPress.test.ts +291 -0
  151. package/src/hooks/useLongPress.ts +87 -0
  152. package/src/hooks/useMailboxAccount.ts +62 -0
  153. package/src/hooks/useMailboxNameIndex.ts +32 -0
  154. package/src/hooks/useMarkAsRead.test.ts +165 -0
  155. package/src/hooks/useMarkAsRead.ts +319 -0
  156. package/src/hooks/useMediaQuery.ts +39 -0
  157. package/src/hooks/useMessageBodyContent.test.ts +370 -0
  158. package/src/hooks/useMessageBodyContent.ts +380 -0
  159. package/src/hooks/useMoveMessages.test.ts +63 -0
  160. package/src/hooks/useMoveMessages.ts +221 -0
  161. package/src/hooks/useOrganizeJob.ts +90 -0
  162. package/src/hooks/useOrganizePreview.ts +40 -0
  163. package/src/hooks/useRescueCandidates.test.ts +217 -0
  164. package/src/hooks/useRescueCandidates.ts +48 -0
  165. package/src/hooks/useSaveDraft.ts +118 -0
  166. package/src/hooks/useSelection.test.ts +75 -0
  167. package/src/hooks/useSelection.ts +211 -0
  168. package/src/hooks/useSemanticSearch.ts +94 -0
  169. package/src/hooks/useSignature.ts +69 -0
  170. package/src/hooks/useStaleAccountSync.test.ts +211 -0
  171. package/src/hooks/useStaleAccountSync.ts +246 -0
  172. package/src/hooks/useSwipeNavigation.test.ts +35 -0
  173. package/src/hooks/useSwipeNavigation.ts +99 -0
  174. package/src/hooks/useToggleStar.ts +187 -0
  175. package/src/hooks/useToggleTrusted.test.ts +102 -0
  176. package/src/hooks/useToggleTrusted.ts +161 -0
  177. package/src/hooks/useTriageKeyboard.ts +103 -0
  178. package/src/hooks/useTriggerSync.test.ts +24 -0
  179. package/src/hooks/useTriggerSync.ts +58 -0
  180. package/src/hooks/useUpdateAddressFlags.ts +125 -0
  181. package/src/hooks/useVisualViewport.test.ts +127 -0
  182. package/src/hooks/useVisualViewport.ts +51 -0
  183. package/src/index.css +19 -0
  184. package/src/lib/account-order.test.ts +84 -0
  185. package/src/lib/account-order.ts +13 -0
  186. package/src/lib/adjacent-message.test.ts +43 -0
  187. package/src/lib/adjacent-message.ts +20 -0
  188. package/src/lib/api.ts +82 -0
  189. package/src/lib/app-info.ts +15 -0
  190. package/src/lib/auto-moved.test.ts +140 -0
  191. package/src/lib/auto-moved.ts +68 -0
  192. package/src/lib/autodiscovery.test.ts +94 -0
  193. package/src/lib/autodiscovery.ts +354 -0
  194. package/src/lib/brief.test.ts +366 -0
  195. package/src/lib/brief.ts +180 -0
  196. package/src/lib/bug-report.test.ts +213 -0
  197. package/src/lib/bug-report.ts +176 -0
  198. package/src/lib/client.ts +36 -0
  199. package/src/lib/console-errors.test.ts +64 -0
  200. package/src/lib/console-errors.ts +92 -0
  201. package/src/lib/conversation-target.test.ts +68 -0
  202. package/src/lib/conversation-target.ts +59 -0
  203. package/src/lib/display-category.ts +20 -0
  204. package/src/lib/drafts.test.ts +241 -0
  205. package/src/lib/drafts.ts +121 -0
  206. package/src/lib/error-classifier.test.ts +133 -0
  207. package/src/lib/error-classifier.ts +96 -0
  208. package/src/lib/fatal-error.test.ts +97 -0
  209. package/src/lib/fatal-error.ts +165 -0
  210. package/src/lib/folder-roles.test.ts +113 -0
  211. package/src/lib/folder-roles.ts +112 -0
  212. package/src/lib/format.test.ts +115 -0
  213. package/src/lib/format.ts +198 -0
  214. package/src/lib/i18n.ts +36 -0
  215. package/src/lib/intelligence-pref.test.ts +57 -0
  216. package/src/lib/intelligence-pref.ts +25 -0
  217. package/src/lib/keymap-dispatch.test.ts +178 -0
  218. package/src/lib/keymap-dispatch.ts +187 -0
  219. package/src/lib/keymap.test.ts +49 -0
  220. package/src/lib/keymap.ts +178 -0
  221. package/src/lib/mail-context.ts +64 -0
  222. package/src/lib/mail-route.test.ts +80 -0
  223. package/src/lib/mail-route.ts +44 -0
  224. package/src/lib/message-body-source.test.ts +98 -0
  225. package/src/lib/message-body-source.ts +48 -0
  226. package/src/lib/move-targets.test.ts +195 -0
  227. package/src/lib/move-targets.ts +74 -0
  228. package/src/lib/onboarding-completion.test.ts +64 -0
  229. package/src/lib/onboarding-completion.ts +23 -0
  230. package/src/lib/organize/filter-status.test.ts +99 -0
  231. package/src/lib/organize/filter-status.ts +86 -0
  232. package/src/lib/organize/organize-copy.test.ts +93 -0
  233. package/src/lib/organize/organize-copy.ts +61 -0
  234. package/src/lib/organize/organize-model.test.ts +104 -0
  235. package/src/lib/organize/organize-model.ts +105 -0
  236. package/src/lib/organize/organize-poll.test.ts +37 -0
  237. package/src/lib/organize/organize-poll.ts +28 -0
  238. package/src/lib/outbox-status.test.ts +64 -0
  239. package/src/lib/outbox-status.ts +75 -0
  240. package/src/lib/plate-serializer.ts +164 -0
  241. package/src/lib/provider-presets.test.ts +96 -0
  242. package/src/lib/provider-presets.ts +81 -0
  243. package/src/lib/query-error-handler.test.ts +144 -0
  244. package/src/lib/query-error-handler.ts +34 -0
  245. package/src/lib/query-escalation.integration.test.ts +138 -0
  246. package/src/lib/recent-searches.test.ts +53 -0
  247. package/src/lib/recent-searches.ts +34 -0
  248. package/src/lib/rescue-candidates.test.ts +94 -0
  249. package/src/lib/rescue-candidates.ts +35 -0
  250. package/src/lib/rescue-telemetry.test.ts +75 -0
  251. package/src/lib/rescue-telemetry.ts +55 -0
  252. package/src/lib/rum-adapter.test.ts +299 -0
  253. package/src/lib/rum-adapter.ts +107 -0
  254. package/src/lib/saved-searches.test.ts +80 -0
  255. package/src/lib/saved-searches.ts +52 -0
  256. package/src/lib/search-pending.test.ts +61 -0
  257. package/src/lib/search-pending.ts +24 -0
  258. package/src/lib/search-query.test.ts +35 -0
  259. package/src/lib/search-query.ts +11 -0
  260. package/src/lib/search-result.test.ts +160 -0
  261. package/src/lib/search-result.ts +102 -0
  262. package/src/lib/search-schemas.ts +34 -0
  263. package/src/lib/search-token-index.test.ts +112 -0
  264. package/src/lib/search-token-index.ts +56 -0
  265. package/src/lib/search-tokens.test.ts +218 -0
  266. package/src/lib/search-tokens.ts +133 -0
  267. package/src/lib/telemetry-context.tsx +9 -0
  268. package/src/lib/telemetry.ts +17 -0
  269. package/src/lib/theme-preference.ts +46 -0
  270. package/src/lib/theme.ts +33 -0
  271. package/src/lib/utils.ts +4 -0
  272. package/src/main.tsx +10 -0
  273. package/src/routeTree.gen.ts +392 -0
  274. package/src/router.test.ts +81 -0
  275. package/src/router.tsx +31 -0
  276. package/src/routes/__root.tsx +69 -0
  277. package/src/routes/index.tsx +7 -0
  278. package/src/routes/mail/$mailboxId.tsx +27 -0
  279. package/src/routes/mail/flagged.tsx +37 -0
  280. package/src/routes/mail/index.tsx +41 -0
  281. package/src/routes/mail/outbox.tsx +13 -0
  282. package/src/routes/mail.tsx +413 -0
  283. package/src/routes/onboarding.tsx +35 -0
  284. package/src/routes/settings/-accounts.test.ts +65 -0
  285. package/src/routes/settings/accounts.tsx +542 -0
  286. package/src/routes/settings/advanced.tsx +55 -0
  287. package/src/routes/settings/appearance.tsx +140 -0
  288. package/src/routes/settings/filters.tsx +141 -0
  289. package/src/routes/settings/folders.tsx +216 -0
  290. package/src/routes/settings/index.tsx +5 -0
  291. package/src/routes/settings/senders.tsx +466 -0
  292. package/src/routes/settings/suggested-vips.tsx +10 -0
  293. package/src/routes/settings.tsx +66 -0
  294. package/src/runtime-config.ts +75 -0
  295. package/src/shell/index.tsx +98 -0
  296. package/src/types/index.ts +73 -0
  297. package/src/vite-env.d.ts +4 -0
  298. package/vite.base.ts +43 -0
@@ -0,0 +1,1547 @@
1
+ /**
2
+ * OnboardingWizard — full 7-step onboarding flow.
3
+ *
4
+ * Steps: Welcome → Connector → Address → Servers → Credentials → Test → Sync
5
+ * Microsoft path: Connector → MicrosoftEmail → redirect
6
+ *
7
+ * Entry points:
8
+ * - First-run (zero accounts): full-screen wizard via /onboarding route
9
+ * - Settings "Add account": steps 2-7 embedded in the settings content area
10
+ *
11
+ * Backend deps status:
12
+ * - POST /accounts — CREATE. Exists.
13
+ * - POST /accounts/test-connection — Exists.
14
+ * - GET /accounts/{accountId}/sync/status — Exists (#431 syncPhase).
15
+ * - POST /accounts/oauth/microsoft/start — Exists (#473).
16
+ * - GET /autodiscovery?email=… — NOT YET IMPLEMENTED (TypeSpec-first, future).
17
+ * Client-side autodiscovery (provider table + Mozilla autoconfig) used instead.
18
+ */
19
+
20
+ import {
21
+ accountOperationsCreateAccountMutation,
22
+ accountOperationsTestConnectionMutation,
23
+ configOperationsGetConfigOptions,
24
+ configOperationsGetConfigQueryKey,
25
+ microsoftOAuthOperationsMicrosoftOAuthStartMutation,
26
+ syncOperationsGetSyncStatusOptions,
27
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
28
+ import {
29
+ AppPasswordHint,
30
+ Banner,
31
+ Button,
32
+ CheckRow,
33
+ ConnectorTile,
34
+ FieldLabel,
35
+ Input,
36
+ Kbd,
37
+ Select,
38
+ ServerFields,
39
+ securityToApi,
40
+ WizardShell,
41
+ } from "@remit/ui";
42
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
43
+ import { AtSign, Inbox, Loader2, Mail, Server } from "lucide-react";
44
+ import { useCallback, useEffect, useRef, useState } from "react";
45
+ // useRef is kept for the hasCreatedRef guard — not for DOM refs
46
+ import {
47
+ type DiscoveryResult,
48
+ discoverSettings,
49
+ getAppPasswordUrl,
50
+ getDiscoveryStatusMessage,
51
+ type SecurityMode,
52
+ } from "../../lib/autodiscovery.js";
53
+ import {
54
+ getPresetById,
55
+ PROVIDER_PRESETS,
56
+ presetIdForEmail,
57
+ } from "../../lib/provider-presets.js";
58
+ import { computeSmtpAutoFill } from "../settings/account-form-helpers.js";
59
+
60
+ /* ------------------------------------------------------------------ */
61
+ /* Wizard step definitions */
62
+ /* ------------------------------------------------------------------ */
63
+
64
+ type WizardStep =
65
+ | "welcome"
66
+ | "connector"
67
+ | "microsoft-email"
68
+ | "address"
69
+ | "servers"
70
+ | "credentials"
71
+ | "test"
72
+ | "sync";
73
+
74
+ const STEP_LABELS = [
75
+ "Connector",
76
+ "Address",
77
+ "Servers",
78
+ "Credentials",
79
+ "Test",
80
+ "Sync",
81
+ ];
82
+
83
+ // Maps WizardStep to zero-based index in the rail (welcome hides rail)
84
+ const STEP_INDEX: Record<WizardStep, number> = {
85
+ welcome: 0,
86
+ connector: 0,
87
+ "microsoft-email": 0,
88
+ address: 1,
89
+ servers: 2,
90
+ credentials: 3,
91
+ test: 4,
92
+ sync: 5,
93
+ };
94
+
95
+ /* ------------------------------------------------------------------ */
96
+ /* State shapes */
97
+ /* ------------------------------------------------------------------ */
98
+
99
+ interface ServerConfig {
100
+ host: string;
101
+ port: number;
102
+ security: SecurityMode;
103
+ }
104
+
105
+ interface WizardState {
106
+ email: string;
107
+ displayName: string;
108
+ imapConfig: ServerConfig;
109
+ smtpConfig: ServerConfig;
110
+ username: string;
111
+ password: string;
112
+ discoveryResult: DiscoveryResult | null;
113
+ /** True if autodiscovery succeeded (affects subtitle copy) */
114
+ discovered: boolean;
115
+ /** Account ID created at step 7 */
116
+ createdAccountId: string | null;
117
+ }
118
+
119
+ const DEFAULT_IMAP: ServerConfig = { host: "", port: 993, security: "tls" };
120
+ const DEFAULT_SMTP: ServerConfig = {
121
+ host: "",
122
+ port: 587,
123
+ security: "starttls",
124
+ };
125
+
126
+ type TestPhase =
127
+ | "idle"
128
+ | "running"
129
+ | "success"
130
+ | "auth-failure"
131
+ | "network-failure";
132
+
133
+ interface TestResult {
134
+ imapState: "pending" | "running" | "ok" | "failed";
135
+ smtpState: "pending" | "running" | "ok" | "failed";
136
+ imapDetail?: string;
137
+ smtpDetail?: string;
138
+ rawError?: string;
139
+ phase: TestPhase;
140
+ }
141
+
142
+ /* ------------------------------------------------------------------ */
143
+ /* Helpers */
144
+ /* ------------------------------------------------------------------ */
145
+
146
+ /* ------------------------------------------------------------------ */
147
+ /* Individual step components */
148
+ /* ------------------------------------------------------------------ */
149
+
150
+ function StepWelcome({ onStart }: { onStart: () => void }) {
151
+ // Keyboard: Enter advances
152
+ useEffect(() => {
153
+ const handler = (e: KeyboardEvent) => {
154
+ if (e.key === "Enter") onStart();
155
+ };
156
+ window.addEventListener("keydown", handler);
157
+ return () => window.removeEventListener("keydown", handler);
158
+ }, [onStart]);
159
+
160
+ return (
161
+ <WizardShell
162
+ steps={STEP_LABELS}
163
+ activeStep={0}
164
+ hideSteps
165
+ title="Welcome to Remit"
166
+ subtitle="One inbox for all your mail, with context about every message."
167
+ footer={
168
+ <>
169
+ <span className="text-2xs text-fg-subtle">
170
+ <Kbd>Enter</Kbd> to continue
171
+ </span>
172
+ <Button variant="primary" onClick={onStart}>
173
+ Add your first account
174
+ </Button>
175
+ </>
176
+ }
177
+ >
178
+ <div className="flex flex-col items-start py-5">
179
+ <span className="flex size-14 items-center justify-center rounded-md bg-accent-soft">
180
+ <Mail className="size-7 text-accent" />
181
+ </span>
182
+ <p className="mt-5 max-w-sm text-sm leading-relaxed text-fg-muted">
183
+ Remit syncs your existing mailboxes and tells you who is really
184
+ writing to you — sender history, authenticity checks, and similar
185
+ messages, right next to every email.
186
+ </p>
187
+ </div>
188
+ </WizardShell>
189
+ );
190
+ }
191
+
192
+ type ConnectorChoice = "imap" | "microsoft";
193
+
194
+ function StepConnector({
195
+ onContinue,
196
+ onMicrosoft,
197
+ onBack,
198
+ showBack,
199
+ }: {
200
+ onContinue: () => void;
201
+ onMicrosoft: () => void;
202
+ onBack: () => void;
203
+ showBack: boolean;
204
+ }) {
205
+ const [selected, setSelected] = useState<ConnectorChoice>("imap");
206
+
207
+ // Keyboard: Enter advances, Esc goes back
208
+ useEffect(() => {
209
+ const handler = (e: KeyboardEvent) => {
210
+ if (e.key === "Enter") {
211
+ if (selected === "microsoft") onMicrosoft();
212
+ else onContinue();
213
+ }
214
+ if (e.key === "Escape") onBack();
215
+ };
216
+ window.addEventListener("keydown", handler);
217
+ return () => window.removeEventListener("keydown", handler);
218
+ }, [onContinue, onMicrosoft, onBack, selected]);
219
+
220
+ const handleContinue = () => {
221
+ if (selected === "microsoft") onMicrosoft();
222
+ else onContinue();
223
+ };
224
+
225
+ return (
226
+ <WizardShell
227
+ steps={STEP_LABELS}
228
+ activeStep={STEP_INDEX.connector}
229
+ title="How does this account connect?"
230
+ subtitle="Choose a sign-in method. IMAP works with any provider."
231
+ footer={
232
+ <>
233
+ {showBack ? (
234
+ <Button variant="ghost" onClick={onBack}>
235
+ Back
236
+ </Button>
237
+ ) : (
238
+ <span />
239
+ )}
240
+ <Button variant="primary" onClick={handleContinue}>
241
+ {selected === "microsoft"
242
+ ? "Continue with Microsoft"
243
+ : "Continue with IMAP"}
244
+ </Button>
245
+ </>
246
+ }
247
+ >
248
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
249
+ <ConnectorTile
250
+ name="IMAP / SMTP"
251
+ description="Any mail provider — Fastmail, iCloud, your own server."
252
+ icon={<Server className="size-5" />}
253
+ selected={selected === "imap"}
254
+ onSelect={() => setSelected("imap")}
255
+ />
256
+ <ConnectorTile
257
+ name="Gmail"
258
+ description="Sign in with Google. No app passwords."
259
+ icon={<AtSign className="size-5" />}
260
+ comingSoon
261
+ />
262
+ <ConnectorTile
263
+ name="Outlook / Microsoft 365"
264
+ description="Sign in with Microsoft. Works with Outlook.com and work accounts."
265
+ icon={<Inbox className="size-5" />}
266
+ selected={selected === "microsoft"}
267
+ onSelect={() => setSelected("microsoft")}
268
+ />
269
+ </div>
270
+ </WizardShell>
271
+ );
272
+ }
273
+
274
+ function StepMicrosoftEmail({
275
+ onBack,
276
+ onRedirecting,
277
+ }: {
278
+ onBack: () => void;
279
+ onRedirecting: () => void;
280
+ }) {
281
+ const [email, setEmail] = useState("");
282
+ const [error, setError] = useState<string | null>(null);
283
+
284
+ const startMutation = useMutation({
285
+ ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
286
+ onSuccess: (data) => {
287
+ onRedirecting();
288
+ window.location.assign(data.authorizationUrl);
289
+ },
290
+ onError: (err) => {
291
+ setError(err instanceof Error ? err.message : "Failed to start sign-in");
292
+ },
293
+ });
294
+
295
+ const handleSubmit = () => {
296
+ setError(null);
297
+ startMutation.mutate({
298
+ body: { email: email.trim() || undefined },
299
+ });
300
+ };
301
+
302
+ // Keyboard: Enter submits, Esc goes back
303
+ // biome-ignore lint/correctness/useExhaustiveDependencies: handleSubmit's identity changes every render; including it would re-run this keydown listener on every render. Omitted to preserve existing behavior (matches the pre-existing eslint-disable). Enter-submit uses a stale handleSubmit — latent, tracked separately.
304
+ useEffect(() => {
305
+ const handler = (e: KeyboardEvent) => {
306
+ if (e.key === "Enter" && !startMutation.isPending) handleSubmit();
307
+ if (e.key === "Escape") onBack();
308
+ };
309
+ window.addEventListener("keydown", handler);
310
+ return () => window.removeEventListener("keydown", handler);
311
+ // handleSubmit identity changes — intentional dep exclusion here
312
+ // eslint-disable-next-line react-hooks/exhaustive-deps
313
+ }, [onBack, startMutation.isPending]);
314
+
315
+ return (
316
+ <WizardShell
317
+ steps={STEP_LABELS}
318
+ activeStep={STEP_INDEX.connector}
319
+ title="Sign in with Microsoft"
320
+ subtitle="You'll be redirected to Microsoft to sign in securely."
321
+ footer={
322
+ <>
323
+ <Button variant="ghost" onClick={onBack}>
324
+ Back
325
+ </Button>
326
+ <Button
327
+ variant="primary"
328
+ onClick={handleSubmit}
329
+ disabled={startMutation.isPending}
330
+ icon={
331
+ startMutation.isPending ? (
332
+ <Loader2 className="size-4 animate-spin" />
333
+ ) : undefined
334
+ }
335
+ >
336
+ {startMutation.isPending
337
+ ? "Redirecting…"
338
+ : "Sign in with Microsoft"}
339
+ </Button>
340
+ </>
341
+ }
342
+ >
343
+ <div className="space-y-3">
344
+ <div>
345
+ <FieldLabel htmlFor="microsoft-email">
346
+ Email address (optional)
347
+ </FieldLabel>
348
+ <Input
349
+ id="microsoft-email"
350
+ icon={<AtSign className="size-4" />}
351
+ placeholder="you@outlook.com"
352
+ value={email}
353
+ onChange={(e) => setEmail(e.target.value)}
354
+ type="email"
355
+ autoComplete="email"
356
+ />
357
+ <p className="mt-1.5 text-2xs text-fg-subtle">
358
+ Pre-fills the Microsoft sign-in form. Leave blank to choose on the
359
+ Microsoft page.
360
+ </p>
361
+ </div>
362
+ {error && <Banner tone="danger">{error}</Banner>}
363
+ </div>
364
+ </WizardShell>
365
+ );
366
+ }
367
+
368
+ function StepAddress({
369
+ initialEmail,
370
+ initialDisplayName,
371
+ onContinue,
372
+ onBack,
373
+ }: {
374
+ initialEmail: string;
375
+ initialDisplayName: string;
376
+ onContinue: (
377
+ email: string,
378
+ displayName: string,
379
+ result: DiscoveryResult | null,
380
+ ) => void;
381
+ onBack: () => void;
382
+ }) {
383
+ const [email, setEmail] = useState(initialEmail);
384
+ const [displayName, setDisplayName] = useState(initialDisplayName);
385
+ const [discovering, setDiscovering] = useState(false);
386
+ const [statusMsg, setStatusMsg] = useState("");
387
+ const [error, setError] = useState<string | null>(null);
388
+
389
+ const handleSubmit = useCallback(async () => {
390
+ const trimmed = email.trim();
391
+ if (!trimmed.includes("@") || !trimmed.includes(".")) {
392
+ setError("Enter a valid email address.");
393
+ return;
394
+ }
395
+ setError(null);
396
+
397
+ setDiscovering(true);
398
+ setStatusMsg(getDiscoveryStatusMessage(trimmed));
399
+ try {
400
+ // Short budget — browser autoconfig fetches are CORS-blocked and
401
+ // fall through to the heuristic; see discoverSettings docs.
402
+ const result = await discoverSettings(trimmed);
403
+ onContinue(trimmed, displayName.trim(), result);
404
+ } finally {
405
+ setDiscovering(false);
406
+ }
407
+ }, [email, displayName, onContinue]);
408
+
409
+ // Keyboard: Enter submits, Esc goes back
410
+ useEffect(() => {
411
+ const handler = (e: KeyboardEvent) => {
412
+ if (e.key === "Enter" && !discovering) void handleSubmit();
413
+ if (e.key === "Escape") onBack();
414
+ };
415
+ window.addEventListener("keydown", handler);
416
+ return () => window.removeEventListener("keydown", handler);
417
+ }, [handleSubmit, discovering, onBack]);
418
+
419
+ return (
420
+ <WizardShell
421
+ steps={STEP_LABELS}
422
+ activeStep={STEP_INDEX.address}
423
+ title="What's the email address?"
424
+ subtitle="We'll detect the server settings for you."
425
+ footer={
426
+ <>
427
+ <Button variant="ghost" onClick={onBack}>
428
+ Back
429
+ </Button>
430
+ <Button
431
+ variant="primary"
432
+ disabled={discovering}
433
+ onClick={() => void handleSubmit()}
434
+ >
435
+ Continue
436
+ </Button>
437
+ </>
438
+ }
439
+ >
440
+ <div className="space-y-4">
441
+ <div>
442
+ <FieldLabel htmlFor="onboarding-email">Email address</FieldLabel>
443
+ <Input
444
+ id="onboarding-email"
445
+ icon={<AtSign className="size-4" />}
446
+ placeholder="you@example.com"
447
+ value={email}
448
+ onChange={(e) => {
449
+ setEmail(e.target.value);
450
+ setError(null);
451
+ }}
452
+ type="email"
453
+ autoComplete="email"
454
+ />
455
+ </div>
456
+ <div>
457
+ <FieldLabel htmlFor="onboarding-display-name">
458
+ Display name (optional)
459
+ </FieldLabel>
460
+ <Input
461
+ id="onboarding-display-name"
462
+ placeholder="Alice"
463
+ value={displayName}
464
+ onChange={(e) => setDisplayName(e.target.value)}
465
+ />
466
+ <p className="mt-1.5 text-2xs text-fg-subtle">
467
+ What to call this account in Remit. Leave blank to use a name
468
+ derived from the address.
469
+ </p>
470
+ </div>
471
+ {discovering && (
472
+ <div className="mt-3 flex items-center gap-2 text-xs text-fg-muted">
473
+ <Loader2 className="size-3.5 animate-spin text-accent" />
474
+ {statusMsg}
475
+ </div>
476
+ )}
477
+ {error && !discovering && (
478
+ <div className="mt-3">
479
+ <Banner tone="danger">{error}</Banner>
480
+ </div>
481
+ )}
482
+ </div>
483
+ </WizardShell>
484
+ );
485
+ }
486
+
487
+ function StepServers({
488
+ email,
489
+ imapConfig,
490
+ smtpConfig,
491
+ discovered,
492
+ onContinue,
493
+ onBack,
494
+ onChange,
495
+ }: {
496
+ email: string;
497
+ imapConfig: ServerConfig;
498
+ smtpConfig: ServerConfig;
499
+ discovered: boolean;
500
+ onContinue: () => void;
501
+ onBack: () => void;
502
+ onChange: (imap: ServerConfig, smtp: ServerConfig) => void;
503
+ }) {
504
+ const [imap, setImap] = useState<ServerConfig>(imapConfig);
505
+ const [smtp, setSmtp] = useState<ServerConfig>(smtpConfig);
506
+ const [providerId, setProviderId] = useState<string>(() =>
507
+ presetIdForEmail(email),
508
+ );
509
+ const [advanced, setAdvanced] = useState(false);
510
+ const [error, setError] = useState<string | null>(null);
511
+
512
+ const preset = getPresetById(providerId);
513
+ // A recognised provider locks the fields until the user chooses Advanced.
514
+ const locked = preset !== undefined && !advanced;
515
+
516
+ const applyPreset = useCallback((id: string) => {
517
+ setProviderId(id);
518
+ setAdvanced(false);
519
+ setError(null);
520
+ const next = getPresetById(id);
521
+ if (!next) return;
522
+ setImap({
523
+ host: next.imap.host,
524
+ port: next.imap.port,
525
+ security: next.imap.security,
526
+ });
527
+ setSmtp({
528
+ host: next.smtp.host,
529
+ port: next.smtp.port,
530
+ security: next.smtp.security,
531
+ });
532
+ }, []);
533
+
534
+ // Propagate changes upward immediately
535
+ useEffect(() => {
536
+ onChange(imap, smtp);
537
+ }, [imap, smtp, onChange]);
538
+
539
+ const isValid = imap.host.trim() !== "";
540
+
541
+ const handleContinue = useCallback(() => {
542
+ if (!isValid) {
543
+ setError("Enter the IMAP host.");
544
+ return;
545
+ }
546
+ setError(null);
547
+ onContinue();
548
+ }, [isValid, onContinue]);
549
+
550
+ // Keyboard: Enter advances, Esc goes back
551
+ useEffect(() => {
552
+ const handler = (e: KeyboardEvent) => {
553
+ if (e.key === "Enter") handleContinue();
554
+ if (e.key === "Escape") onBack();
555
+ };
556
+ window.addEventListener("keydown", handler);
557
+ return () => window.removeEventListener("keydown", handler);
558
+ }, [handleContinue, onBack]);
559
+
560
+ const domain = email.split("@")[1] ?? "your domain";
561
+ const subtitle = preset
562
+ ? "Pick your provider to fill in the right settings — or choose Custom to enter them by hand."
563
+ : discovered
564
+ ? "Found via autodiscovery — adjust anything that looks off."
565
+ : `We couldn't detect settings for ${domain} — enter them manually.`;
566
+
567
+ const badge = locked
568
+ ? ({ label: "preset", tone: "neutral" } as const)
569
+ : !preset && discovered
570
+ ? ({ label: "detected", tone: "positive" } as const)
571
+ : undefined;
572
+
573
+ const appPasswordUrl = preset?.passwordHelp.url ?? getAppPasswordUrl(email);
574
+
575
+ return (
576
+ <WizardShell
577
+ steps={STEP_LABELS}
578
+ activeStep={STEP_INDEX.servers}
579
+ title="Confirm server settings"
580
+ subtitle={subtitle}
581
+ footer={
582
+ <>
583
+ <Button variant="ghost" onClick={onBack}>
584
+ Back
585
+ </Button>
586
+ <Button variant="primary" onClick={handleContinue}>
587
+ Continue
588
+ </Button>
589
+ </>
590
+ }
591
+ >
592
+ <div className="space-y-5">
593
+ {error && <Banner tone="danger">{error}</Banner>}
594
+ <div>
595
+ <FieldLabel htmlFor="provider-select">Provider</FieldLabel>
596
+ <Select
597
+ id="provider-select"
598
+ value={providerId}
599
+ onChange={(e) => applyPreset(e.target.value)}
600
+ >
601
+ <option value="">Custom / other</option>
602
+ {PROVIDER_PRESETS.map((p) => (
603
+ <option key={p.id} value={p.id}>
604
+ {p.label}
605
+ </option>
606
+ ))}
607
+ </Select>
608
+ {preset && (
609
+ <p className="mt-1 text-2xs text-fg-subtle">
610
+ {locked ? (
611
+ <>
612
+ Server settings are pre-filled for {preset.label} and locked.{" "}
613
+ <button
614
+ type="button"
615
+ className="text-accent underline"
616
+ onClick={() => setAdvanced(true)}
617
+ >
618
+ Advanced
619
+ </button>{" "}
620
+ lets you edit them by hand.
621
+ </>
622
+ ) : (
623
+ <>Editing {preset.label}'s settings by hand.</>
624
+ )}
625
+ </p>
626
+ )}
627
+ </div>
628
+ <ServerFields
629
+ legend="IMAP — incoming"
630
+ badge={badge}
631
+ readOnly={locked}
632
+ host={imap.host}
633
+ port={String(imap.port)}
634
+ security={imap.security}
635
+ hostPlaceholder="imap.example.com"
636
+ portPlaceholder="993"
637
+ onHostChange={(v) => {
638
+ setImap((s) => ({ ...s, host: v }));
639
+ setError(null);
640
+ }}
641
+ onPortChange={(v) => setImap((s) => ({ ...s, port: Number(v) }))}
642
+ onSecurityChange={(v) => setImap((s) => ({ ...s, security: v }))}
643
+ />
644
+ <p className="text-2xs text-fg-subtle">
645
+ Sending (SMTP) is optional — leave it blank to set up a receive-only
646
+ account and add sending later in Settings.
647
+ </p>
648
+ <ServerFields
649
+ legend="SMTP — outgoing (optional)"
650
+ badge={badge}
651
+ readOnly={locked}
652
+ host={smtp.host}
653
+ port={String(smtp.port)}
654
+ security={smtp.security}
655
+ hostPlaceholder="smtp.example.com"
656
+ portPlaceholder="587"
657
+ onHostChange={(v) => {
658
+ setSmtp((s) => ({ ...s, host: v }));
659
+ setError(null);
660
+ }}
661
+ onPortChange={(v) => setSmtp((s) => ({ ...s, port: Number(v) }))}
662
+ onSecurityChange={(v) => setSmtp((s) => ({ ...s, security: v }))}
663
+ />
664
+ {preset && (
665
+ <p className="text-2xs text-fg-subtle">
666
+ {preset.passwordHelp.text}{" "}
667
+ {appPasswordUrl && (
668
+ <a
669
+ href={appPasswordUrl}
670
+ target="_blank"
671
+ rel="noopener noreferrer"
672
+ className="text-accent underline"
673
+ >
674
+ Get an app password
675
+ </a>
676
+ )}
677
+ </p>
678
+ )}
679
+ </div>
680
+ </WizardShell>
681
+ );
682
+ }
683
+
684
+ function StepCredentials({
685
+ email,
686
+ username,
687
+ password,
688
+ onContinue,
689
+ onBack,
690
+ onChange,
691
+ }: {
692
+ email: string;
693
+ username: string;
694
+ password: string;
695
+ onContinue: () => void;
696
+ onBack: () => void;
697
+ onChange: (username: string, password: string) => void;
698
+ }) {
699
+ const [localUsername, setLocalUsername] = useState(username || email);
700
+ const [localPassword, setLocalPassword] = useState(password);
701
+ const [error, setError] = useState<string | null>(null);
702
+
703
+ const domain = email.split("@")[1] ?? "";
704
+ const appPasswordUrl = getAppPasswordUrl(email);
705
+ const isValid = localUsername.trim() !== "" && localPassword.trim() !== "";
706
+
707
+ useEffect(() => {
708
+ onChange(localUsername, localPassword);
709
+ }, [localUsername, localPassword, onChange]);
710
+
711
+ const handleContinue = useCallback(() => {
712
+ if (!isValid) {
713
+ setError("Enter your username and password.");
714
+ return;
715
+ }
716
+ setError(null);
717
+ onContinue();
718
+ }, [isValid, onContinue]);
719
+
720
+ // Keyboard: Enter advances, Esc goes back
721
+ useEffect(() => {
722
+ const handler = (e: KeyboardEvent) => {
723
+ if (e.key === "Enter") handleContinue();
724
+ if (e.key === "Escape") onBack();
725
+ };
726
+ window.addEventListener("keydown", handler);
727
+ return () => window.removeEventListener("keydown", handler);
728
+ }, [handleContinue, onBack]);
729
+
730
+ return (
731
+ <WizardShell
732
+ steps={STEP_LABELS}
733
+ activeStep={STEP_INDEX.credentials}
734
+ title={`Sign in to ${domain || "your mail server"}`}
735
+ subtitle="Many providers require an app password instead of your normal one."
736
+ footer={
737
+ <>
738
+ <Button variant="ghost" onClick={onBack}>
739
+ Back
740
+ </Button>
741
+ <Button variant="primary" onClick={handleContinue}>
742
+ Test connection
743
+ </Button>
744
+ </>
745
+ }
746
+ >
747
+ <div className="space-y-3">
748
+ {error && (
749
+ <div className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger">
750
+ {error}
751
+ </div>
752
+ )}
753
+ <div>
754
+ <FieldLabel htmlFor="credentials-username">Username</FieldLabel>
755
+ <Input
756
+ id="credentials-username"
757
+ value={localUsername}
758
+ onChange={(e) => {
759
+ setLocalUsername(e.target.value);
760
+ setError(null);
761
+ }}
762
+ autoComplete="username"
763
+ />
764
+ </div>
765
+ <div>
766
+ <FieldLabel htmlFor="credentials-password">
767
+ Password or app password
768
+ </FieldLabel>
769
+ <Input
770
+ id="credentials-password"
771
+ type="password"
772
+ value={localPassword}
773
+ onChange={(e) => {
774
+ setLocalPassword(e.target.value);
775
+ setError(null);
776
+ }}
777
+ autoComplete="current-password"
778
+ />
779
+ </div>
780
+ <AppPasswordHint url={appPasswordUrl} />
781
+ </div>
782
+ </WizardShell>
783
+ );
784
+ }
785
+
786
+ function StepTest({
787
+ email,
788
+ imapConfig,
789
+ smtpConfig,
790
+ username,
791
+ password,
792
+ onSuccess,
793
+ onBackToCredentials,
794
+ onBackToServers,
795
+ }: {
796
+ email: string;
797
+ imapConfig: ServerConfig;
798
+ smtpConfig: ServerConfig;
799
+ username: string;
800
+ password: string;
801
+ onSuccess: () => void;
802
+ onBackToCredentials: () => void;
803
+ onBackToServers: () => void;
804
+ }) {
805
+ const [testResult, setTestResult] = useState<TestResult>({
806
+ imapState: "running",
807
+ smtpState: "pending",
808
+ phase: "running",
809
+ });
810
+ const [attempt, setAttempt] = useState(0);
811
+ const smtpConfigured = smtpConfig.host.trim() !== "";
812
+ const imapTls = securityToApi(imapConfig.security);
813
+ const smtpTls = securityToApi(smtpConfig.security);
814
+
815
+ const testMutation = useMutation({
816
+ ...accountOperationsTestConnectionMutation(),
817
+ });
818
+
819
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs on mount and each Retry via `attempt`; the connection inputs are read intentionally and must not re-trigger the test on every render
820
+ useEffect(() => {
821
+ let cancelled = false;
822
+ const timers: number[] = [];
823
+
824
+ setTestResult({
825
+ imapState: "running",
826
+ smtpState: "pending",
827
+ phase: "running",
828
+ });
829
+
830
+ testMutation.mutate(
831
+ {
832
+ body: {
833
+ username,
834
+ password,
835
+ imapHost: imapConfig.host,
836
+ imapPort: imapConfig.port,
837
+ imapTls: imapTls.tls,
838
+ imapStartTls: imapTls.startTls,
839
+ smtpHost: smtpConfig.host,
840
+ smtpPort: smtpConfig.port,
841
+ smtpTls: smtpTls.tls,
842
+ smtpStartTls: smtpTls.startTls,
843
+ },
844
+ },
845
+ {
846
+ onSuccess: (data) => {
847
+ if (cancelled) return;
848
+ // Show IMAP result first, then SMTP. SMTP is optional: when no
849
+ // host was given the account is receive-only, so IMAP alone
850
+ // decides success.
851
+ const imapOk = data.imapSuccess;
852
+ const smtpOk = smtpConfigured ? data.smtpSuccess : true;
853
+
854
+ setTestResult({
855
+ imapState: imapOk ? "ok" : "failed",
856
+ smtpState: smtpConfigured ? "running" : "ok",
857
+ imapDetail: imapOk
858
+ ? `Connected — ${imapConfig.host}:${imapConfig.port}`
859
+ : data.imapError,
860
+ phase: "running",
861
+ });
862
+
863
+ timers.push(
864
+ window.setTimeout(() => {
865
+ if (cancelled) return;
866
+ // An auth error can come from either protocol; route on the
867
+ // one that actually failed. IMAP failures take precedence in
868
+ // the raw-error display since IMAP is tested first.
869
+ const isAuthError = (msg: string | undefined): boolean => {
870
+ const m = msg?.toLowerCase() ?? "";
871
+ return (
872
+ m.includes("auth") ||
873
+ m.includes("login") ||
874
+ m.includes("535") ||
875
+ m.includes("credential")
876
+ );
877
+ };
878
+ const isAuthFailure =
879
+ (!imapOk && isAuthError(data.imapError)) ||
880
+ (imapOk &&
881
+ smtpConfigured &&
882
+ !data.smtpSuccess &&
883
+ isAuthError(data.smtpError));
884
+
885
+ setTestResult({
886
+ imapState: imapOk ? "ok" : "failed",
887
+ smtpState: !smtpConfigured
888
+ ? "ok"
889
+ : data.smtpSuccess
890
+ ? "ok"
891
+ : "failed",
892
+ imapDetail: imapOk
893
+ ? `Connected — ${imapConfig.host}:${imapConfig.port}`
894
+ : data.imapError,
895
+ smtpDetail: !smtpConfigured
896
+ ? "Not set up — receive-only"
897
+ : data.smtpSuccess
898
+ ? `Connected — ${smtpConfig.host}:${smtpConfig.port}`
899
+ : data.smtpError,
900
+ rawError: !imapOk
901
+ ? data.imapError
902
+ : smtpConfigured && !data.smtpSuccess
903
+ ? data.smtpError
904
+ : undefined,
905
+ phase:
906
+ imapOk && smtpOk
907
+ ? "success"
908
+ : isAuthFailure
909
+ ? "auth-failure"
910
+ : "network-failure",
911
+ });
912
+
913
+ if (imapOk && smtpOk) {
914
+ timers.push(
915
+ window.setTimeout(() => {
916
+ if (!cancelled) onSuccess();
917
+ }, 800),
918
+ );
919
+ }
920
+ }, 400),
921
+ );
922
+ },
923
+ onError: (err) => {
924
+ if (cancelled) return;
925
+ const msg = err instanceof Error ? err.message : "Connection failed";
926
+ setTestResult({
927
+ imapState: "failed",
928
+ smtpState: "failed",
929
+ imapDetail: msg,
930
+ smtpDetail: msg,
931
+ rawError: msg,
932
+ phase: "network-failure",
933
+ });
934
+ },
935
+ },
936
+ );
937
+
938
+ return () => {
939
+ cancelled = true;
940
+ for (const t of timers) window.clearTimeout(t);
941
+ };
942
+ }, [attempt]);
943
+
944
+ const { phase } = testResult;
945
+
946
+ // Keyboard: Esc goes back based on failure type
947
+ useEffect(() => {
948
+ const handler = (e: KeyboardEvent) => {
949
+ if (e.key === "Escape") {
950
+ if (phase === "auth-failure") onBackToCredentials();
951
+ else if (phase === "network-failure") onBackToServers();
952
+ }
953
+ };
954
+ window.addEventListener("keydown", handler);
955
+ return () => window.removeEventListener("keydown", handler);
956
+ }, [phase, onBackToCredentials, onBackToServers]);
957
+
958
+ const footer =
959
+ phase === "auth-failure" ? (
960
+ <>
961
+ <Button variant="ghost" onClick={onBackToCredentials}>
962
+ Back to credentials
963
+ </Button>
964
+ <Button variant="primary" onClick={() => setAttempt((a) => a + 1)}>
965
+ Retry
966
+ </Button>
967
+ </>
968
+ ) : phase === "network-failure" ? (
969
+ <>
970
+ <Button variant="ghost" onClick={onBackToServers}>
971
+ Back to servers
972
+ </Button>
973
+ <Button variant="primary" onClick={() => setAttempt((a) => a + 1)}>
974
+ Retry
975
+ </Button>
976
+ </>
977
+ ) : phase === "success" ? (
978
+ <>
979
+ <span className="text-2xs text-fg-subtle">Connection verified</span>
980
+ <Button variant="primary" onClick={onSuccess}>
981
+ Start syncing
982
+ </Button>
983
+ </>
984
+ ) : (
985
+ <>
986
+ <span className="text-2xs text-fg-subtle">Testing…</span>
987
+ <span />
988
+ </>
989
+ );
990
+
991
+ return (
992
+ <WizardShell
993
+ steps={STEP_LABELS}
994
+ activeStep={STEP_INDEX.test}
995
+ title="Testing the connection"
996
+ subtitle={`Checking ${email}`}
997
+ footer={footer}
998
+ >
999
+ <div className="divide-y divide-line">
1000
+ <CheckRow
1001
+ label={`IMAP — ${imapConfig.host}:${imapConfig.port}`}
1002
+ detail={testResult.imapDetail}
1003
+ state={testResult.imapState}
1004
+ />
1005
+ <CheckRow
1006
+ label={
1007
+ smtpConfigured
1008
+ ? `SMTP — ${smtpConfig.host}:${smtpConfig.port}`
1009
+ : "SMTP — sending"
1010
+ }
1011
+ detail={
1012
+ smtpConfigured
1013
+ ? testResult.smtpDetail
1014
+ : "Not set up — you can add sending later in Settings"
1015
+ }
1016
+ state={testResult.smtpState}
1017
+ />
1018
+ {(phase === "auth-failure" || phase === "network-failure") &&
1019
+ testResult.rawError && (
1020
+ <div className="py-3">
1021
+ {phase === "auth-failure" && (
1022
+ <p className="mb-2 text-xs text-fg-muted">
1023
+ Check your password — many providers require an app password.
1024
+ </p>
1025
+ )}
1026
+ <code className="block rounded bg-surface-sunken px-2.5 py-2 text-2xs text-fg-muted">
1027
+ {testResult.rawError}
1028
+ </code>
1029
+ </div>
1030
+ )}
1031
+ </div>
1032
+ </WizardShell>
1033
+ );
1034
+ }
1035
+
1036
+ function StepSync({
1037
+ email,
1038
+ displayName,
1039
+ imapConfig,
1040
+ smtpConfig,
1041
+ username,
1042
+ password,
1043
+ onGoToInbox,
1044
+ }: {
1045
+ email: string;
1046
+ displayName: string;
1047
+ imapConfig: ServerConfig;
1048
+ smtpConfig: ServerConfig;
1049
+ username: string;
1050
+ password: string;
1051
+ onGoToInbox: (accountId: string) => void;
1052
+ }) {
1053
+ const queryClient = useQueryClient();
1054
+ const [accountId, setAccountId] = useState<string | null>(null);
1055
+ const [createError, setCreateError] = useState<string | null>(null);
1056
+ const hasCreatedRef = useRef(false);
1057
+
1058
+ const imapTls = securityToApi(imapConfig.security);
1059
+ const smtpTls = securityToApi(smtpConfig.security);
1060
+
1061
+ const createMutation = useMutation({
1062
+ ...accountOperationsCreateAccountMutation(),
1063
+ onSuccess: (data) => {
1064
+ setAccountId(data.accountId);
1065
+ queryClient.invalidateQueries({
1066
+ queryKey: configOperationsGetConfigQueryKey(),
1067
+ });
1068
+ },
1069
+ onError: (err) => {
1070
+ setCreateError(
1071
+ err instanceof Error ? err.message : "Failed to create account",
1072
+ );
1073
+ },
1074
+ });
1075
+
1076
+ // Create the account once on mount
1077
+ // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect guarded by hasCreatedRef; re-running on field change would trigger duplicate account creation
1078
+ useEffect(() => {
1079
+ if (hasCreatedRef.current) return;
1080
+ hasCreatedRef.current = true;
1081
+
1082
+ // SMTP is optional. When the user left the host blank the account is
1083
+ // receive-only: send no SMTP host so the backend derives smtpEnabled=false.
1084
+ // Only when a host was supplied do we autofill the remaining SMTP fields.
1085
+ const smtpConfigured = smtpConfig.host.trim() !== "";
1086
+ const autoFill = computeSmtpAutoFill({
1087
+ imapHost: imapConfig.host,
1088
+ smtpHost: smtpConfig.host,
1089
+ smtpPort: smtpConfig.port,
1090
+ smtpTls: smtpTls.tls,
1091
+ smtpStartTls: smtpTls.startTls,
1092
+ });
1093
+ const effectiveSmtp = smtpConfigured
1094
+ ? (autoFill ?? {
1095
+ smtpHost: smtpConfig.host,
1096
+ smtpPort: smtpConfig.port,
1097
+ smtpTls: smtpTls.tls,
1098
+ smtpStartTls: smtpTls.startTls,
1099
+ })
1100
+ : null;
1101
+
1102
+ createMutation.mutate({
1103
+ body: {
1104
+ email,
1105
+ displayName: displayName || undefined,
1106
+ username: username !== email ? username : undefined,
1107
+ password,
1108
+ imapHost: imapConfig.host,
1109
+ imapPort: imapConfig.port,
1110
+ imapTls: imapTls.tls,
1111
+ imapStartTls: imapTls.startTls,
1112
+ smtpHost: effectiveSmtp?.smtpHost || undefined,
1113
+ smtpPort: effectiveSmtp?.smtpPort || undefined,
1114
+ smtpTls: effectiveSmtp?.smtpTls ?? false,
1115
+ smtpStartTls: effectiveSmtp?.smtpStartTls ?? false,
1116
+ },
1117
+ });
1118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1119
+ }, []);
1120
+
1121
+ // Poll sync status every 2s once account is created. Stop polling once the
1122
+ // account reaches a terminal phase (complete/error) so we don't hammer the
1123
+ // endpoint while the user lingers on this step (pattern from ComposeProvider).
1124
+ const { data: syncStatus } = useQuery({
1125
+ ...syncOperationsGetSyncStatusOptions({
1126
+ path: { accountId: accountId ?? "" },
1127
+ }),
1128
+ enabled: !!accountId,
1129
+ refetchInterval: (query) => {
1130
+ const phase = query.state.data?.syncPhase;
1131
+ return phase === "complete" || phase === "error" ? false : 2000;
1132
+ },
1133
+ });
1134
+
1135
+ // When the account reaches a terminal error phase, surface its lastError
1136
+ // from the config (the sync-status response carries syncPhase but not
1137
+ // lastError). Only fetched once the account is created and in error.
1138
+ const syncErrored = syncStatus?.syncPhase === "error";
1139
+ const { data: config } = useQuery({
1140
+ ...configOperationsGetConfigOptions(),
1141
+ enabled: !!accountId && syncErrored,
1142
+ });
1143
+ const syncLastError = config?.accounts.find(
1144
+ (a) => a.accountId === accountId,
1145
+ )?.lastError;
1146
+
1147
+ // Determine inbox progress from mailboxes array
1148
+ const inboxMailbox = syncStatus?.mailboxes.find(
1149
+ (m) => m.fullPath === "INBOX" || m.fullPath === "Inbox",
1150
+ );
1151
+ const totalMailboxes = syncStatus?.mailboxCountTotal ?? 0;
1152
+ const syncedMailboxes = syncStatus?.mailboxCountSynced ?? 0;
1153
+ const inboxTotal = inboxMailbox?.messagesTotal ?? 0;
1154
+ const inboxSynced = inboxMailbox?.messagesSynced ?? 0;
1155
+ const inboxProgress = inboxTotal > 0 ? inboxSynced / inboxTotal : 0;
1156
+
1157
+ // Determine check states based on syncPhase
1158
+ const syncPhase = syncStatus?.syncPhase;
1159
+ const mailboxState = syncErrored
1160
+ ? "failed"
1161
+ : syncPhase === "idle" || syncPhase === undefined
1162
+ ? "running"
1163
+ : syncPhase === "discovering_mailboxes"
1164
+ ? "running"
1165
+ : totalMailboxes > 0
1166
+ ? "ok"
1167
+ : "running";
1168
+
1169
+ const inboxSyncState = syncErrored
1170
+ ? "failed"
1171
+ : !syncPhase ||
1172
+ syncPhase === "idle" ||
1173
+ syncPhase === "discovering_mailboxes"
1174
+ ? "pending"
1175
+ : syncPhase === "syncing_inbox" || syncPhase === "syncing_others"
1176
+ ? "running"
1177
+ : "ok";
1178
+
1179
+ const otherSyncState = syncErrored
1180
+ ? "failed"
1181
+ : !syncPhase ||
1182
+ syncPhase === "idle" ||
1183
+ syncPhase === "discovering_mailboxes" ||
1184
+ syncPhase === "syncing_inbox"
1185
+ ? "pending"
1186
+ : syncPhase === "syncing_others"
1187
+ ? "running"
1188
+ : "ok";
1189
+
1190
+ if (createError) {
1191
+ return (
1192
+ <WizardShell
1193
+ steps={STEP_LABELS}
1194
+ activeStep={STEP_INDEX.sync}
1195
+ title="Couldn't create account"
1196
+ subtitle="Something went wrong saving your account."
1197
+ footer={
1198
+ <>
1199
+ <span />
1200
+ <Button
1201
+ variant="primary"
1202
+ onClick={() => {
1203
+ hasCreatedRef.current = false;
1204
+ setCreateError(null);
1205
+ createMutation.mutate({
1206
+ body: {
1207
+ email,
1208
+ displayName: displayName || undefined,
1209
+ username: username !== email ? username : undefined,
1210
+ password,
1211
+ imapHost: imapConfig.host,
1212
+ imapPort: imapConfig.port,
1213
+ imapTls: imapTls.tls,
1214
+ imapStartTls: imapTls.startTls,
1215
+ smtpHost: smtpConfig.host || undefined,
1216
+ smtpPort: smtpConfig.port || undefined,
1217
+ smtpTls: smtpTls.tls,
1218
+ smtpStartTls: smtpTls.startTls,
1219
+ },
1220
+ });
1221
+ }}
1222
+ >
1223
+ Retry
1224
+ </Button>
1225
+ </>
1226
+ }
1227
+ >
1228
+ <code className="block rounded bg-surface-sunken px-2.5 py-2 text-2xs text-fg-muted">
1229
+ {createError}
1230
+ </code>
1231
+ </WizardShell>
1232
+ );
1233
+ }
1234
+
1235
+ return (
1236
+ <WizardShell
1237
+ steps={STEP_LABELS}
1238
+ activeStep={STEP_INDEX.sync}
1239
+ title={`Syncing ${email}`}
1240
+ subtitle="Newest mail first — you can start reading right away."
1241
+ footer={
1242
+ <>
1243
+ <span className="text-2xs text-fg-subtle">
1244
+ Sync continues in the background
1245
+ </span>
1246
+ <Button
1247
+ variant="primary"
1248
+ onClick={() => accountId && onGoToInbox(accountId)}
1249
+ >
1250
+ Go to inbox
1251
+ </Button>
1252
+ </>
1253
+ }
1254
+ >
1255
+ {createMutation.isPending ? (
1256
+ <div className="flex items-center gap-2 py-4 text-sm text-fg-muted">
1257
+ <Loader2 className="size-4 animate-spin text-accent" />
1258
+ Creating your account…
1259
+ </div>
1260
+ ) : (
1261
+ <div className="space-y-3">
1262
+ {inboxTotal > 0 && (
1263
+ <div>
1264
+ <div className="flex items-baseline justify-between text-xs">
1265
+ <span className="font-medium text-fg">INBOX</span>
1266
+ <span className="text-fg-subtle tabular-nums">
1267
+ {inboxSynced.toLocaleString()} / {inboxTotal.toLocaleString()}{" "}
1268
+ messages
1269
+ </span>
1270
+ </div>
1271
+ <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface-sunken">
1272
+ <div
1273
+ className="h-full rounded-full bg-accent transition-all duration-500"
1274
+ style={{ width: `${Math.max(2, inboxProgress * 100)}%` }}
1275
+ />
1276
+ </div>
1277
+ </div>
1278
+ )}
1279
+ <div className="divide-y divide-line">
1280
+ <CheckRow
1281
+ label="Mailboxes discovered"
1282
+ detail={
1283
+ totalMailboxes > 0
1284
+ ? `${totalMailboxes} mailbox${totalMailboxes === 1 ? "" : "es"} found`
1285
+ : "Discovering…"
1286
+ }
1287
+ state={mailboxState}
1288
+ />
1289
+ <CheckRow
1290
+ label="INBOX"
1291
+ detail={
1292
+ inboxSyncState === "ok"
1293
+ ? `${inboxSynced.toLocaleString()} messages synced`
1294
+ : inboxSyncState === "running"
1295
+ ? "Syncing newest first…"
1296
+ : "Queued"
1297
+ }
1298
+ state={inboxSyncState}
1299
+ />
1300
+ {syncedMailboxes > 1 ||
1301
+ syncPhase === "syncing_others" ||
1302
+ syncPhase === "complete" ? (
1303
+ <CheckRow
1304
+ label="Other mailboxes"
1305
+ detail={
1306
+ otherSyncState === "ok"
1307
+ ? `${syncedMailboxes - 1} mailboxes synced`
1308
+ : otherSyncState === "running"
1309
+ ? "Syncing…"
1310
+ : "Queued"
1311
+ }
1312
+ state={otherSyncState}
1313
+ />
1314
+ ) : null}
1315
+ </div>
1316
+ {syncErrored && (
1317
+ <div className="mt-2 space-y-2">
1318
+ <p className="text-xs text-danger">
1319
+ Sync stalled. The account is still active —{" "}
1320
+ <button
1321
+ type="button"
1322
+ className="underline"
1323
+ onClick={() => {
1324
+ queryClient.invalidateQueries({
1325
+ queryKey: syncOperationsGetSyncStatusOptions({
1326
+ path: { accountId: accountId ?? "" },
1327
+ }).queryKey,
1328
+ });
1329
+ }}
1330
+ >
1331
+ retry
1332
+ </button>
1333
+ </p>
1334
+ {syncLastError && (
1335
+ <code className="block rounded bg-surface-sunken px-2.5 py-2 text-2xs text-fg-muted">
1336
+ {syncLastError}
1337
+ </code>
1338
+ )}
1339
+ </div>
1340
+ )}
1341
+ </div>
1342
+ )}
1343
+ </WizardShell>
1344
+ );
1345
+ }
1346
+
1347
+ /* ------------------------------------------------------------------ */
1348
+ /* Main wizard orchestrator */
1349
+ /* ------------------------------------------------------------------ */
1350
+
1351
+ export interface OnboardingWizardProps {
1352
+ /** If false, show the Welcome step first */
1353
+ skipWelcome?: boolean;
1354
+ /** Called when the user clicks "Go to inbox" with the new accountId */
1355
+ onComplete: (accountId: string) => void;
1356
+ /** Called when the user cancels (Esc from connector picker) */
1357
+ onCancel?: () => void;
1358
+ }
1359
+
1360
+ export function OnboardingWizard({
1361
+ skipWelcome = false,
1362
+ onComplete,
1363
+ onCancel,
1364
+ }: OnboardingWizardProps) {
1365
+ const [step, setStep] = useState<WizardStep>(
1366
+ skipWelcome ? "connector" : "welcome",
1367
+ );
1368
+ const [state, setState] = useState<WizardState>({
1369
+ email: "",
1370
+ displayName: "",
1371
+ imapConfig: DEFAULT_IMAP,
1372
+ smtpConfig: DEFAULT_SMTP,
1373
+ username: "",
1374
+ password: "",
1375
+ discoveryResult: null,
1376
+ discovered: false,
1377
+ createdAccountId: null,
1378
+ });
1379
+
1380
+ // Welcome → Connector
1381
+ const handleWelcomeStart = useCallback(() => setStep("connector"), []);
1382
+
1383
+ // Connector → Address (IMAP path) or microsoft-email (Microsoft path), or cancel
1384
+ const handleConnectorContinue = useCallback(() => setStep("address"), []);
1385
+ const handleConnectorMicrosoft = useCallback(
1386
+ () => setStep("microsoft-email"),
1387
+ [],
1388
+ );
1389
+ const handleConnectorBack = useCallback(() => {
1390
+ if (skipWelcome) {
1391
+ onCancel?.();
1392
+ } else {
1393
+ setStep("welcome");
1394
+ }
1395
+ }, [skipWelcome, onCancel]);
1396
+
1397
+ // Address → Servers
1398
+ const handleAddressContinue = useCallback(
1399
+ (email: string, displayName: string, result: DiscoveryResult | null) => {
1400
+ let imap = DEFAULT_IMAP;
1401
+ let smtp = DEFAULT_SMTP;
1402
+ let discovered = false;
1403
+
1404
+ if (result) {
1405
+ // Heuristic is a fallback, not "discovered"
1406
+ discovered = result.source !== "heuristic";
1407
+ imap = {
1408
+ host: result.imap.host,
1409
+ port: result.imap.port,
1410
+ security: result.imap.security,
1411
+ };
1412
+ smtp = {
1413
+ host: result.smtp.host,
1414
+ port: result.smtp.port,
1415
+ security: result.smtp.security,
1416
+ };
1417
+ }
1418
+
1419
+ setState((s) => ({
1420
+ ...s,
1421
+ email,
1422
+ displayName,
1423
+ imapConfig: imap,
1424
+ smtpConfig: smtp,
1425
+ discoveryResult: result,
1426
+ discovered,
1427
+ username: email,
1428
+ }));
1429
+ setStep("servers");
1430
+ },
1431
+ [],
1432
+ );
1433
+
1434
+ // Servers → Credentials
1435
+ const handleServersChange = useCallback(
1436
+ (imap: ServerConfig, smtp: ServerConfig) => {
1437
+ setState((s) => ({ ...s, imapConfig: imap, smtpConfig: smtp }));
1438
+ },
1439
+ [],
1440
+ );
1441
+
1442
+ // Credentials → Test
1443
+ const handleCredentialsChange = useCallback(
1444
+ (username: string, password: string) => {
1445
+ setState((s) => ({ ...s, username, password }));
1446
+ },
1447
+ [],
1448
+ );
1449
+
1450
+ // Test → Sync
1451
+ const handleTestSuccess = useCallback(() => setStep("sync"), []);
1452
+
1453
+ // Sync → Inbox
1454
+ const handleGoToInbox = useCallback(
1455
+ (accountId: string) => {
1456
+ onComplete(accountId);
1457
+ },
1458
+ [onComplete],
1459
+ );
1460
+
1461
+ switch (step) {
1462
+ case "welcome":
1463
+ return <StepWelcome onStart={handleWelcomeStart} />;
1464
+
1465
+ case "connector":
1466
+ return (
1467
+ <StepConnector
1468
+ onContinue={handleConnectorContinue}
1469
+ onMicrosoft={handleConnectorMicrosoft}
1470
+ onBack={handleConnectorBack}
1471
+ showBack={!skipWelcome}
1472
+ />
1473
+ );
1474
+
1475
+ case "microsoft-email":
1476
+ return (
1477
+ <StepMicrosoftEmail
1478
+ onBack={() => setStep("connector")}
1479
+ onRedirecting={() => {
1480
+ // Full-page redirect is happening — nothing else to do
1481
+ }}
1482
+ />
1483
+ );
1484
+
1485
+ case "address":
1486
+ return (
1487
+ <StepAddress
1488
+ initialEmail={state.email}
1489
+ initialDisplayName={state.displayName}
1490
+ onContinue={handleAddressContinue}
1491
+ onBack={() => setStep("connector")}
1492
+ />
1493
+ );
1494
+
1495
+ case "servers":
1496
+ return (
1497
+ <StepServers
1498
+ email={state.email}
1499
+ imapConfig={state.imapConfig}
1500
+ smtpConfig={state.smtpConfig}
1501
+ discovered={state.discovered}
1502
+ onContinue={() => setStep("credentials")}
1503
+ onBack={() => setStep("address")}
1504
+ onChange={handleServersChange}
1505
+ />
1506
+ );
1507
+
1508
+ case "credentials":
1509
+ return (
1510
+ <StepCredentials
1511
+ email={state.email}
1512
+ username={state.username}
1513
+ password={state.password}
1514
+ onContinue={() => setStep("test")}
1515
+ onBack={() => setStep("servers")}
1516
+ onChange={handleCredentialsChange}
1517
+ />
1518
+ );
1519
+
1520
+ case "test":
1521
+ return (
1522
+ <StepTest
1523
+ email={state.email}
1524
+ imapConfig={state.imapConfig}
1525
+ smtpConfig={state.smtpConfig}
1526
+ username={state.username}
1527
+ password={state.password}
1528
+ onSuccess={handleTestSuccess}
1529
+ onBackToCredentials={() => setStep("credentials")}
1530
+ onBackToServers={() => setStep("servers")}
1531
+ />
1532
+ );
1533
+
1534
+ case "sync":
1535
+ return (
1536
+ <StepSync
1537
+ email={state.email}
1538
+ displayName={state.displayName}
1539
+ imapConfig={state.imapConfig}
1540
+ smtpConfig={state.smtpConfig}
1541
+ username={state.username}
1542
+ password={state.password}
1543
+ onGoToInbox={handleGoToInbox}
1544
+ />
1545
+ );
1546
+ }
1547
+ }