@remit/web-client 0.0.228 → 0.0.230

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.
@@ -0,0 +1,492 @@
1
+ /**
2
+ * What a config import report means on screen (#1021).
3
+ *
4
+ * `POST /config/import` answers with one flat report: per-item verdicts, a list
5
+ * of blocking errors and a list of non-blocking warnings, each keyed by a code.
6
+ * The screens group that into sections, and turn a code into copy that says
7
+ * what failed and what to do about it. Nothing here touches the network or the
8
+ * DOM, so every branch the wizard can land in is decidable in a unit test.
9
+ */
10
+
11
+ import type {
12
+ ApiError,
13
+ RemitImapConfigImportItemReport,
14
+ RemitImapConfigImportReport,
15
+ } from "@remit/api-http-client/types.gen.ts";
16
+ // `ApiError` above is the wire model for an error body; this is the thrown
17
+ // wrapper `lib/client.ts` puts every HTTP failure in.
18
+ import { ApiError as ThrownApiError } from "./api.js";
19
+ import { getErrorStatus } from "./error-classifier.js";
20
+ import { safeJsonParse } from "./json.js";
21
+
22
+ export const IMPORT_SECTION_ORDER = [
23
+ "accounts",
24
+ "labels",
25
+ "filters",
26
+ "addressFlags",
27
+ "settings",
28
+ ] as const;
29
+
30
+ export type ImportSection = (typeof IMPORT_SECTION_ORDER)[number];
31
+
32
+ const SECTION_TITLES: Record<ImportSection, string> = {
33
+ accounts: "Accounts",
34
+ labels: "Labels",
35
+ filters: "Rules & filters",
36
+ addressFlags: "Senders",
37
+ settings: "Folders & appearance",
38
+ };
39
+
40
+ export const IMPORT_VERDICTS = [
41
+ "created",
42
+ "updated",
43
+ "unchanged",
44
+ "skipped",
45
+ "rejected",
46
+ ] as const;
47
+
48
+ export type ImportVerdict = (typeof IMPORT_VERDICTS)[number];
49
+
50
+ export const verdictTone: Record<
51
+ ImportVerdict,
52
+ "neutral" | "positive" | "warning" | "danger" | "accent"
53
+ > = {
54
+ created: "positive",
55
+ updated: "accent",
56
+ unchanged: "neutral",
57
+ skipped: "warning",
58
+ rejected: "danger",
59
+ };
60
+
61
+ /**
62
+ * The wire model spells the verdict as a plain string, so a value this client
63
+ * does not know reads as rejected rather than as an unstyled row: an item the
64
+ * screen cannot vouch for is one the reader must look at.
65
+ */
66
+ export const asVerdict = (value: string): ImportVerdict =>
67
+ (IMPORT_VERDICTS as readonly string[]).includes(value)
68
+ ? (value as ImportVerdict)
69
+ : "rejected";
70
+
71
+ /**
72
+ * A section this client does not know is not folded into one it does. Quietly
73
+ * filing an unrecognised item under "settings" would tell the reader something
74
+ * false about what a newer Reader's file is doing to their configuration, which
75
+ * is the failure the whole format exists to prevent. Unknown reads as unknown,
76
+ * and the screen shows it under its own heading.
77
+ */
78
+ export const UNKNOWN_SECTION = "unknown" as const;
79
+
80
+ export type ReportSectionId = ImportSection | typeof UNKNOWN_SECTION;
81
+
82
+ export const asSection = (value: string): ImportSection | undefined =>
83
+ (IMPORT_SECTION_ORDER as readonly string[]).includes(value)
84
+ ? (value as ImportSection)
85
+ : undefined;
86
+
87
+ export interface ReportEntry {
88
+ id: string;
89
+ label: string;
90
+ verdict: ImportVerdict;
91
+ reason?: string;
92
+ }
93
+
94
+ export interface ReportSection {
95
+ id: ReportSectionId;
96
+ title: string;
97
+ entries: ReportEntry[];
98
+ }
99
+
100
+ export const groupReportSections = (
101
+ items: readonly RemitImapConfigImportItemReport[],
102
+ ): ReportSection[] => {
103
+ const grouped = new Map<ReportSectionId, ReportEntry[]>();
104
+ items.forEach((item, index) => {
105
+ const section = asSection(item.section) ?? UNKNOWN_SECTION;
106
+ const entries = grouped.get(section) ?? [];
107
+ entries.push({
108
+ id: `${section}-${item.key}-${index}`,
109
+ label: item.key,
110
+ verdict: asVerdict(item.verdict),
111
+ reason:
112
+ section === UNKNOWN_SECTION
113
+ ? (item.reason ??
114
+ `This instance does not know the "${item.section}" section, so it cannot say what this entry does.`)
115
+ : item.reason,
116
+ });
117
+ grouped.set(section, entries);
118
+ });
119
+ const ordered: ReportSectionId[] = [...IMPORT_SECTION_ORDER, UNKNOWN_SECTION];
120
+ return ordered
121
+ .filter((section) => grouped.has(section))
122
+ .map((section) => ({
123
+ id: section,
124
+ title:
125
+ section === UNKNOWN_SECTION
126
+ ? "Not recognised"
127
+ : SECTION_TITLES[section],
128
+ entries: grouped.get(section) ?? [],
129
+ }));
130
+ };
131
+
132
+ export type VerdictCounts = Record<ImportVerdict, number>;
133
+
134
+ export const countVerdicts = (
135
+ items: readonly RemitImapConfigImportItemReport[],
136
+ ): VerdictCounts => {
137
+ const counts: VerdictCounts = {
138
+ created: 0,
139
+ updated: 0,
140
+ unchanged: 0,
141
+ skipped: 0,
142
+ rejected: 0,
143
+ };
144
+ for (const item of items) counts[asVerdict(item.verdict)] += 1;
145
+ return counts;
146
+ };
147
+
148
+ /**
149
+ * A blocking failure, worded for the person holding the file: what the reader
150
+ * refused, why refusing beats importing around it, and the move that fixes it.
151
+ * `raw` is the server's own sentence, kept verbatim so a bug report carries it.
152
+ */
153
+ export interface FailureCopy {
154
+ title: string;
155
+ explanation: string;
156
+ fix: string;
157
+ raw: string;
158
+ }
159
+
160
+ const READ_FAILURE_COPY: Record<
161
+ string,
162
+ (error: ApiError) => Omit<FailureCopy, "raw">
163
+ > = {
164
+ NotAnObject: () => ({
165
+ title: "That is not a Reader config file",
166
+ explanation:
167
+ "The file was read, but it is not a configuration document — a Reader config is a JSON object carrying a kind and a schemaVersion.",
168
+ fix: "Export one from Settings › Advanced on the instance you are moving from, or run remit config save on its host.",
169
+ }),
170
+ WrongKind: () => ({
171
+ title: "That is not a Reader config file",
172
+ explanation:
173
+ "The file parses as JSON but does not declare itself a Reader configuration, so nothing here can be trusted to mean what it looks like.",
174
+ fix: "Export one from Settings › Advanced on the instance you are moving from, or run remit config save on its host.",
175
+ }),
176
+ UnsupportedVersion: (error) => ({
177
+ title: "This file was written by a newer Reader",
178
+ explanation:
179
+ error.message ||
180
+ "The file declares a schema version this instance does not read, so importing it would drop settings it cannot understand.",
181
+ fix: "Update this Reader, then import again. The file is unchanged and safe to keep.",
182
+ }),
183
+ UnknownKeys: (error) => ({
184
+ title: "The file contains settings this version does not know",
185
+ explanation:
186
+ "An unknown key is refused rather than quietly dropped: losing configuration without saying so is exactly what this feature exists to prevent.",
187
+ fix: `Update this Reader, or remove the named keys from the file and import again. ${error.message}`,
188
+ }),
189
+ CredentialPresent: () => ({
190
+ title: "The file carries a credential",
191
+ explanation:
192
+ "A configuration document describes which credential an account needs; it never carries one. Reader refuses the file rather than importing around it — the secret may now be in your backups, your chat history, or a pull request.",
193
+ fix: "Delete the field from the file and import again, then change that mailbox's password. Exports written by Reader never contain one.",
194
+ }),
195
+ Malformed: (error) => ({
196
+ title: "The file does not match the configuration format",
197
+ explanation:
198
+ "Every part of the document has to validate before anything is written, and this one does not.",
199
+ fix: `Re-export it from the instance you are moving from. ${error.message}`,
200
+ }),
201
+ duplicate_key: (error) => ({
202
+ title: "The file names something twice",
203
+ explanation:
204
+ "A configuration names each account, label and filter once, so a document with two of the same key has no single answer to import.",
205
+ fix: `Remove the duplicate and import again. ${error.message}`,
206
+ }),
207
+ unknown_label: (error) => ({
208
+ title: "A rule points at a label that is not here",
209
+ explanation:
210
+ "The file applies a label neither it nor this instance carries, so the rule would land doing nothing.",
211
+ fix: `Add the label to the file, or create it here first, then import again. ${error.message}`,
212
+ }),
213
+ unknown_account: (error) => ({
214
+ title: "A rule points at an account that is not here",
215
+ explanation:
216
+ "The file files mail into a folder on an account it does not carry, so the rule has no destination to bind to.",
217
+ fix: `Export the configuration again from an instance that holds both, then import that file. ${error.message}`,
218
+ }),
219
+ };
220
+
221
+ const UNRECOGNISED_FAILURE = (error: ApiError): Omit<FailureCopy, "raw"> => ({
222
+ title: "The file could not be imported",
223
+ explanation:
224
+ error.message || "The import was refused and nothing was written.",
225
+ fix: "Re-export the configuration from the instance you are moving from and try again. If it keeps failing, report it with the line below.",
226
+ });
227
+
228
+ export const WRITE_FAILURE_CODE = "import_write_failed";
229
+
230
+ export const CONFLICT_CODE = "config_not_empty";
231
+
232
+ export interface ImportConflict {
233
+ message: string;
234
+ details?: Record<string, string>;
235
+ }
236
+
237
+ /**
238
+ * The 409 a non-empty configuration answers with, or nothing.
239
+ *
240
+ * The endpoint's body is flat — `code`, `message`, `details` — but it does not
241
+ * always arrive that way: `lib/client.ts` re-wraps every HTTP failure as an
242
+ * `ApiError` so the fail-fast classifier can read a status off it, and that
243
+ * moves the body onto `error.body`. Reading the code off the error itself finds
244
+ * nothing there and turns the refusal into "check your connection", which is
245
+ * how the abort-or-merge screen became unreachable. Both shapes are checked,
246
+ * and the code is what decides: a status is confirmation where there is one,
247
+ * never the thing being matched on.
248
+ */
249
+ export const readConflict = (error: unknown): ImportConflict | undefined => {
250
+ const status = getErrorStatus(error);
251
+ if (status !== undefined && status !== 409) return undefined;
252
+ const body: unknown = error instanceof ThrownApiError ? error.body : error;
253
+ if (typeof body !== "object" || body === null) return undefined;
254
+ const flat = body as {
255
+ code?: unknown;
256
+ message?: unknown;
257
+ details?: unknown;
258
+ };
259
+ if (flat.code !== CONFLICT_CODE) return undefined;
260
+ return {
261
+ message: typeof flat.message === "string" ? flat.message : "",
262
+ details:
263
+ typeof flat.details === "object" && flat.details !== null
264
+ ? (flat.details as Record<string, string>)
265
+ : undefined,
266
+ };
267
+ };
268
+
269
+ /**
270
+ * The blocking failure to render, when there is one. `import_write_failed` is
271
+ * deliberately not one of these: the import got past validation and part of it
272
+ * may be live, which is a different screen with a different instruction.
273
+ */
274
+ export const readFailure = (
275
+ report: RemitImapConfigImportReport,
276
+ ): FailureCopy | undefined => {
277
+ const error = report.errors.find((it) => it.code !== WRITE_FAILURE_CODE);
278
+ if (!error) return undefined;
279
+ const build = READ_FAILURE_COPY[error.code] ?? UNRECOGNISED_FAILURE;
280
+ return { ...build(error), raw: `${error.code}: ${error.message}` };
281
+ };
282
+
283
+ export const writeFailure = (
284
+ report: RemitImapConfigImportReport,
285
+ ): ApiError | undefined =>
286
+ report.errors.find((it) => it.code === WRITE_FAILURE_CODE);
287
+
288
+ export type SectionOutcome = "landed" | "failed" | "not-attempted" | "unknown";
289
+
290
+ export interface SectionResult {
291
+ section: ImportSection;
292
+ title: string;
293
+ state: SectionOutcome;
294
+ detail: string;
295
+ }
296
+
297
+ /**
298
+ * What a stopped import left behind, section by section. The write order is
299
+ * fixed, so the section the failure names splits the document in three: what is
300
+ * before it ran, it did not, and what is after it was never reached. The
301
+ * server's own sentence says whether the earlier writes survived, because only
302
+ * the backend knows whether it had a transaction.
303
+ *
304
+ * A failure that names no section leaves that split undecidable, and every
305
+ * section reads `unknown` rather than `landed`. Telling someone their accounts
306
+ * are in when nothing said so is the one answer this screen must never give:
307
+ * they would stop looking.
308
+ */
309
+ export const sectionResults = (
310
+ report: RemitImapConfigImportReport,
311
+ ): SectionResult[] => {
312
+ const failure = writeFailure(report);
313
+ const named = failure?.details?.section;
314
+ const failedSection = named ? asSection(named) : undefined;
315
+ const counts = new Map<ImportSection, number>();
316
+ for (const item of report.items) {
317
+ if (asVerdict(item.verdict) === "rejected") continue;
318
+ const section = asSection(item.section);
319
+ if (!section) continue;
320
+ counts.set(section, (counts.get(section) ?? 0) + 1);
321
+ }
322
+
323
+ if (!failedSection) {
324
+ const detail = named
325
+ ? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`
326
+ : "The import stopped without naming where, so it cannot say whether this section was written. Open Settings to see what is here.";
327
+ return IMPORT_SECTION_ORDER.map((section) => ({
328
+ section,
329
+ title: SECTION_TITLES[section],
330
+ state: "unknown" as const,
331
+ detail,
332
+ }));
333
+ }
334
+
335
+ const failedAt = IMPORT_SECTION_ORDER.indexOf(failedSection);
336
+
337
+ return IMPORT_SECTION_ORDER.map((section, index) => {
338
+ const title = SECTION_TITLES[section];
339
+ if (section === failedSection) {
340
+ return {
341
+ section,
342
+ title,
343
+ state: "failed" as const,
344
+ detail: failure?.message ?? "This section could not be written.",
345
+ };
346
+ }
347
+ if (index > failedAt) {
348
+ return {
349
+ section,
350
+ title,
351
+ state: "not-attempted" as const,
352
+ detail: "Not attempted — the import stopped before this section.",
353
+ };
354
+ }
355
+ const written = counts.get(section) ?? 0;
356
+ return {
357
+ section,
358
+ title,
359
+ state: "landed" as const,
360
+ detail:
361
+ written === 0
362
+ ? "Nothing in the file for this section."
363
+ : `${written} ${written === 1 ? "entry" : "entries"} written.`,
364
+ };
365
+ });
366
+ };
367
+
368
+ export const FOLDER_PENDING_CODE = "folder_not_found_yet";
369
+
370
+ export interface PendingFolder {
371
+ path: string;
372
+ accountId?: string;
373
+ waitingFor: string;
374
+ }
375
+
376
+ /**
377
+ * The folders the file names that this account does not hold yet. They are
378
+ * recorded rather than dropped: the binder writes each setting the moment IMAP
379
+ * discovery produces its folder, so the copy has to say waiting, never lost.
380
+ */
381
+ export const pendingFolders = (
382
+ report: RemitImapConfigImportReport,
383
+ ): PendingFolder[] => {
384
+ const seen = new Set<string>();
385
+ const folders: PendingFolder[] = [];
386
+ for (const warning of report.warnings) {
387
+ if (warning.code !== FOLDER_PENDING_CODE) continue;
388
+ const path = warning.details?.folderPath;
389
+ if (!path) continue;
390
+ const filter = warning.details?.filter;
391
+ const accountId = warning.details?.accountId;
392
+ const key = `${accountId ?? ""}:${path}:${filter ?? ""}`;
393
+ if (seen.has(key)) continue;
394
+ seen.add(key);
395
+ folders.push({
396
+ path,
397
+ accountId,
398
+ waitingFor: filter
399
+ ? `Rule “${filter}”`
400
+ : "A folder role or a folder's own settings",
401
+ });
402
+ }
403
+ return folders;
404
+ };
405
+
406
+ export const anchorWarnings = (
407
+ report: RemitImapConfigImportReport,
408
+ ): ApiError[] =>
409
+ report.warnings.filter((warning) => warning.code !== FOLDER_PENDING_CODE);
410
+
411
+ /* ------------------------------------------------------------------ */
412
+ /* Reading a file off disk */
413
+ /* ------------------------------------------------------------------ */
414
+
415
+ /**
416
+ * A config document is small — the largest realistic export is tens of
417
+ * kilobytes — so anything past this is a file picked by mistake, and saying so
418
+ * beats posting megabytes to be told the same thing.
419
+ */
420
+ export const MAX_CONFIG_FILE_BYTES = 2 * 1024 * 1024;
421
+
422
+ export type ConfigDocument = Record<string, unknown>;
423
+
424
+ export type FileReadResult =
425
+ | { ok: true; document: ConfigDocument }
426
+ | { ok: false; failure: FailureCopy };
427
+
428
+ const NOT_JSON = Symbol("not json");
429
+
430
+ export const readConfigText = async (
431
+ name: string,
432
+ size: number,
433
+ text: string,
434
+ ): Promise<FileReadResult> => {
435
+ if (size > MAX_CONFIG_FILE_BYTES) {
436
+ return {
437
+ ok: false,
438
+ failure: {
439
+ title: "That file is too large to be a config file",
440
+ explanation: `${name} is ${Math.round(size / 1024)} kB. A Reader configuration is tens of kilobytes; a file this size is something else.`,
441
+ fix: "Pick the .json file written by Settings › Advanced, or by remit config save.",
442
+ raw: `${size} bytes > ${MAX_CONFIG_FILE_BYTES} byte limit`,
443
+ },
444
+ };
445
+ }
446
+
447
+ let syntaxError = "";
448
+ const parsed: unknown = await safeJsonParse<unknown>(text).catch(
449
+ (cause: unknown) => {
450
+ syntaxError = cause instanceof Error ? cause.message : String(cause);
451
+ return NOT_JSON;
452
+ },
453
+ );
454
+
455
+ if (parsed === NOT_JSON) {
456
+ return {
457
+ ok: false,
458
+ failure: {
459
+ title: "That file is not JSON",
460
+ explanation: `${name} could not be read as JSON at all, so there is nothing in it to import.`,
461
+ fix: "Pick the .json file written by Settings › Advanced, or by remit config save.",
462
+ raw: syntaxError,
463
+ },
464
+ };
465
+ }
466
+
467
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
468
+ return {
469
+ ok: false,
470
+ failure: {
471
+ title: "That is not a Reader config file",
472
+ explanation: `${name} is valid JSON but not a configuration document — a Reader config is a JSON object carrying a kind and a schemaVersion.`,
473
+ fix: "Export one from Settings › Advanced on the instance you are moving from, or run remit config save on its host.",
474
+ raw: `expected a JSON object, found ${Array.isArray(parsed) ? "an array" : typeof parsed}`,
475
+ },
476
+ };
477
+ }
478
+
479
+ return { ok: true, document: parsed as ConfigDocument };
480
+ };
481
+
482
+ /* ------------------------------------------------------------------ */
483
+ /* The export half */
484
+ /* ------------------------------------------------------------------ */
485
+
486
+ export const exportFileName = (now: Date): string => {
487
+ const iso = now.toISOString().slice(0, 10);
488
+ return `reader-config.${iso}.json`;
489
+ };
490
+
491
+ export const formatFileSize = (bytes: number): string =>
492
+ bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} kB`;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Parse JSON in a Promise so a malformed string surfaces as a rejection the
3
+ * caller can `.catch()`, instead of a synchronous throw that would need a block
4
+ * try/catch. Use only where an unparseable value is an expected, recoverable
5
+ * outcome — a file the reader picked by mistake — never to soften an internal
6
+ * contract, which must stay a fatal uncaught `JSON.parse`.
7
+ */
8
+ export const safeJsonParse = <T>(raw: string): Promise<T> =>
9
+ new Promise((resolve) => {
10
+ resolve(JSON.parse(raw));
11
+ });
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as IndexRouteImport } from './routes/index'
13
+ import { Route as ImportRouteImport } from './routes/import'
13
14
  import { Route as MailRouteImport } from './routes/mail'
14
15
  import { Route as OnboardingRouteImport } from './routes/onboarding'
15
16
  import { Route as SettingsRouteImport } from './routes/settings'
@@ -54,6 +55,11 @@ const IndexRoute = IndexRouteImport.update({
54
55
  path: '/',
55
56
  getParentRoute: () => rootRouteImport,
56
57
  } as any)
58
+ const ImportRoute = ImportRouteImport.update({
59
+ id: '/import',
60
+ path: '/import',
61
+ getParentRoute: () => rootRouteImport,
62
+ } as any)
57
63
  const MailRoute = MailRouteImport.update({
58
64
  id: '/mail',
59
65
  path: '/mail',
@@ -266,6 +272,7 @@ const MailFlaggedThreadIdMessageIdModeChar123OutboxMessageIdChar125Route =
266
272
 
267
273
  export interface FileRoutesByFullPath {
268
274
  '/': typeof IndexRoute
275
+ '/import': typeof ImportRoute
269
276
  '/mail': typeof MailRouteWithChildren
270
277
  '/onboarding': typeof OnboardingRoute
271
278
  '/settings': typeof SettingsRouteWithChildren
@@ -307,6 +314,7 @@ export interface FileRoutesByFullPath {
307
314
  }
308
315
  export interface FileRoutesByTo {
309
316
  '/': typeof IndexRoute
317
+ '/import': typeof ImportRoute
310
318
  '/onboarding': typeof OnboardingRoute
311
319
  '/settings/accounts': typeof SettingsAccountsRoute
312
320
  '/settings/advanced': typeof SettingsAdvancedRoute
@@ -340,6 +348,7 @@ export interface FileRoutesByTo {
340
348
  export interface FileRoutesById {
341
349
  __root__: typeof rootRouteImport
342
350
  '/': typeof IndexRoute
351
+ '/import': typeof ImportRoute
343
352
  '/mail': typeof MailRouteWithChildren
344
353
  '/onboarding': typeof OnboardingRoute
345
354
  '/settings': typeof SettingsRouteWithChildren
@@ -383,6 +392,7 @@ export interface FileRouteTypes {
383
392
  fileRoutesByFullPath: FileRoutesByFullPath
384
393
  fullPaths:
385
394
  | '/'
395
+ | '/import'
386
396
  | '/mail'
387
397
  | '/onboarding'
388
398
  | '/settings'
@@ -424,6 +434,7 @@ export interface FileRouteTypes {
424
434
  fileRoutesByTo: FileRoutesByTo
425
435
  to:
426
436
  | '/'
437
+ | '/import'
427
438
  | '/onboarding'
428
439
  | '/settings/accounts'
429
440
  | '/settings/advanced'
@@ -456,6 +467,7 @@ export interface FileRouteTypes {
456
467
  id:
457
468
  | '__root__'
458
469
  | '/'
470
+ | '/import'
459
471
  | '/mail'
460
472
  | '/onboarding'
461
473
  | '/settings'
@@ -498,6 +510,7 @@ export interface FileRouteTypes {
498
510
  }
499
511
  export interface RootRouteChildren {
500
512
  IndexRoute: typeof IndexRoute
513
+ ImportRoute: typeof ImportRoute
501
514
  MailRoute: typeof MailRouteWithChildren
502
515
  OnboardingRoute: typeof OnboardingRoute
503
516
  SettingsRoute: typeof SettingsRouteWithChildren
@@ -512,6 +525,13 @@ declare module '@tanstack/react-router' {
512
525
  preLoaderRoute: typeof IndexRouteImport
513
526
  parentRoute: typeof rootRouteImport
514
527
  }
528
+ '/import': {
529
+ id: '/import'
530
+ path: '/import'
531
+ fullPath: '/import'
532
+ preLoaderRoute: typeof ImportRouteImport
533
+ parentRoute: typeof rootRouteImport
534
+ }
515
535
  '/mail': {
516
536
  id: '/mail'
517
537
  path: '/mail'
@@ -985,6 +1005,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
985
1005
 
986
1006
  const rootRouteChildren: RootRouteChildren = {
987
1007
  IndexRoute: IndexRoute,
1008
+ ImportRoute: ImportRoute,
988
1009
  MailRoute: MailRouteWithChildren,
989
1010
  OnboardingRoute: OnboardingRoute,
990
1011
  SettingsRoute: SettingsRouteWithChildren,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * /import — the configuration import wizard, full screen.
3
+ *
4
+ * One address for two entrances: the first-run welcome screen offers it beside
5
+ * adding an account, and Settings › Advanced links to it. A path segment rather
6
+ * than a query param because it mounts a different surface entirely
7
+ * (url-state.md R1); the wizard's own step stays in component state, since the
8
+ * file behind it cannot survive a reload.
9
+ */
10
+ import { useQueryClient } from "@tanstack/react-query";
11
+ import { createFileRoute } from "@tanstack/react-router";
12
+ import { useCallback } from "react";
13
+ import { ConfigImportWizard } from "@/components/config-import/ConfigImportWizard";
14
+ import { completeOnboarding } from "@/lib/onboarding-completion";
15
+ import { useTelemetry } from "@/lib/telemetry-context";
16
+
17
+ export const Route = createFileRoute("/import")({
18
+ component: ConfigImportPage,
19
+ });
20
+
21
+ function ConfigImportPage() {
22
+ const telemetry = useTelemetry();
23
+ const queryClient = useQueryClient();
24
+
25
+ // A client-side navigation carries the query cache with it, and the /mail
26
+ // first-run guard reads the account list out of that cache — a stale
27
+ // zero-account entry would bounce a freshly imported instance straight back
28
+ // into onboarding. A document load starts from an empty cache.
29
+ //
30
+ // The event records an import, so only an apply that wrote may raise it: a
31
+ // 409 the reader aborted and a file that was refused both leave by the same
32
+ // door and neither imported anything.
33
+ const handleDone = useCallback(
34
+ (outcome: "imported" | "abandoned") => {
35
+ void completeOnboarding({
36
+ queryClient,
37
+ recordCompleted: () => {
38
+ if (outcome === "imported") {
39
+ telemetry.recordEvent("config.imported");
40
+ }
41
+ },
42
+ navigateToInbox: () => {
43
+ window.location.assign("/mail");
44
+ },
45
+ });
46
+ },
47
+ [telemetry, queryClient],
48
+ );
49
+
50
+ return <ConfigImportWizard onDone={handleDone} />;
51
+ }
@@ -1,5 +1,5 @@
1
1
  import { useQueryClient } from "@tanstack/react-query";
2
- import { createFileRoute } from "@tanstack/react-router";
2
+ import { createFileRoute, useNavigate } from "@tanstack/react-router";
3
3
  import { useCallback } from "react";
4
4
  import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
5
5
  import { completeOnboarding } from "@/lib/onboarding-completion";
@@ -18,6 +18,7 @@ export const Route = createFileRoute("/onboarding")({
18
18
  function OnboardingPage() {
19
19
  const telemetry = useTelemetry();
20
20
  const queryClient = useQueryClient();
21
+ const navigate = useNavigate();
21
22
 
22
23
  // A client-side navigation carries the query cache with it, and the /mail
23
24
  // first-run guard reads config from that cache — a stale zero-account entry
@@ -36,5 +37,11 @@ function OnboardingPage() {
36
37
  [telemetry, queryClient],
37
38
  );
38
39
 
39
- return <OnboardingWizard skipWelcome={false} onComplete={handleComplete} />;
40
+ return (
41
+ <OnboardingWizard
42
+ skipWelcome={false}
43
+ onComplete={handleComplete}
44
+ onImportConfig={() => void navigate({ to: "/import" })}
45
+ />
46
+ );
40
47
  }