@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.
- package/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- 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
|
+
}
|