@remit/config-transfer 0.0.1 → 0.0.2

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/src/import.ts ADDED
@@ -0,0 +1,1165 @@
1
+ import {
2
+ type ConfigAccount,
3
+ type ConfigAddressFlagsEntry,
4
+ type ConfigFilter,
5
+ ConfigReadError,
6
+ type ReaderConfigDocument,
7
+ readConfigDocument,
8
+ } from "@remit/config-format";
9
+ import {
10
+ type AccountItem,
11
+ type AccountSettingValue,
12
+ type ConfigImportUnresolvedRefItem,
13
+ deriveFilterTtl,
14
+ type FilterItem,
15
+ type LabelItem,
16
+ } from "@remit/data-ports";
17
+ import {
18
+ type AccountSettingNameValue,
19
+ composeSettingName,
20
+ } from "@remit/data-ports/account-settings";
21
+ import { NotFoundError } from "@remit/data-ports/errors";
22
+ import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
23
+ import { deriveAddressId } from "@remit/data-ports/id";
24
+ import {
25
+ AccountSettingName,
26
+ ConfigImportRefKind,
27
+ ConfigImportState,
28
+ ConnectionState,
29
+ } from "@remit/domain-enums";
30
+ import { carriesUserFlag } from "./export.js";
31
+ import type { ConfigImportDeps } from "./import-repositories.js";
32
+ import {
33
+ type ConfigImportItemReport,
34
+ type ConfigImportOutcome,
35
+ type ConfigImportProblem,
36
+ ConfigImportSection,
37
+ ConfigImportVerdict,
38
+ } from "./report.js";
39
+
40
+ /**
41
+ * The `<modelId>@<dimensions>` stamp an anchor carries when this deployment
42
+ * could not embed its source text at import time. It matches no real embedder,
43
+ * which is what makes the existing lazy repair (`refreshAnchorForEmbedder`)
44
+ * treat the anchor as needing a fresh vector the first time it is matched —
45
+ * the same route a model migration takes. Deliberately the same literal as
46
+ * `UNKNOWN_CHUNK_EMBEDDING_ID` in `@remit/search-service`, which config
47
+ * transfer cannot import without dragging the vector stack into the CLI.
48
+ */
49
+ export const ANCHOR_EMBEDDING_PENDING = "unknown";
50
+
51
+ /** The named sentinel a Filter carries for "no action of this kind". */
52
+ const NO_ACTION = "None";
53
+
54
+ export interface ImportConfigInput {
55
+ accountConfigId: string;
56
+ /** Owner of a configuration this import may have to create the row for. */
57
+ userId: string;
58
+ document: unknown;
59
+ mode: "validate" | "apply";
60
+ onExisting: "abort" | "merge";
61
+ }
62
+
63
+ const normalize = (value: string): string => value.trim().toLowerCase();
64
+
65
+ /**
66
+ * The version a document claims, read straight off the raw input. The parse
67
+ * failed, so this is the only honest answer the report can give — and a report
68
+ * that omitted it would leave "written by a newer reader" unexplained.
69
+ */
70
+ const claimedVersion = (input: unknown): number => {
71
+ if (typeof input !== "object" || input === null) return 0;
72
+ const version = (input as { schemaVersion?: unknown }).schemaVersion;
73
+ return typeof version === "number" ? version : 0;
74
+ };
75
+
76
+ const problem = (
77
+ code: string,
78
+ message: string,
79
+ details?: Record<string, string>,
80
+ ): ConfigImportProblem =>
81
+ details ? { code, message, details } : { code, message };
82
+
83
+ const rejected = (
84
+ schemaVersion: number,
85
+ errors: ConfigImportProblem[],
86
+ ): ConfigImportOutcome => ({
87
+ outcome: "report",
88
+ report: {
89
+ valid: false,
90
+ schemaVersion,
91
+ applied: false,
92
+ items: [],
93
+ errors,
94
+ warnings: [],
95
+ accountsNeedingCredentials: [],
96
+ },
97
+ });
98
+
99
+ interface ExistingConfig {
100
+ accounts: AccountItem[];
101
+ labels: LabelItem[];
102
+ filters: FilterItem[];
103
+ flaggedAddresses: number;
104
+ addressEmails: Set<string>;
105
+ /** accountId → its folders by IMAP path. Empty for an account that has never synced. */
106
+ mailboxPaths: Map<string, Map<string, string>>;
107
+ }
108
+
109
+ const readExisting = async (
110
+ deps: ConfigImportDeps,
111
+ accountConfigId: string,
112
+ ): Promise<ExistingConfig> => {
113
+ const { repositories } = deps;
114
+ const [accounts, labels, filters] = await Promise.all([
115
+ repositories.account.listAllByAccountConfig(accountConfigId),
116
+ repositories.label.listByAccountConfig(accountConfigId),
117
+ repositories.filter.listByAccountConfig(accountConfigId),
118
+ ]);
119
+ const live = accounts.filter((account) => account.deletedAt === undefined);
120
+
121
+ let flaggedAddresses = 0;
122
+ const addressEmails = new Set<string>();
123
+ let cursor: string | undefined;
124
+ do {
125
+ const page = await repositories.address.listByAccountConfig({
126
+ accountConfigId,
127
+ cursor,
128
+ });
129
+ for (const address of page.items) {
130
+ addressEmails.add(normalize(address.normalizedEmail));
131
+ if (carriesUserFlag(address.flags)) flaggedAddresses++;
132
+ }
133
+ cursor = page.continuationToken;
134
+ } while (cursor);
135
+
136
+ const mailboxPaths = new Map(
137
+ await Promise.all(
138
+ live.map(
139
+ async (account): Promise<[string, Map<string, string>]> => [
140
+ account.accountId,
141
+ new Map(
142
+ (
143
+ await repositories.mailbox.listAllByAccount(account.accountId)
144
+ ).map((mailbox) => [mailbox.fullPath, mailbox.mailboxId] as const),
145
+ ),
146
+ ],
147
+ ),
148
+ ),
149
+ );
150
+
151
+ return {
152
+ accounts: live,
153
+ labels,
154
+ filters,
155
+ flaggedAddresses,
156
+ addressEmails,
157
+ mailboxPaths,
158
+ };
159
+ };
160
+
161
+ const firstDuplicate = (keys: readonly string[]): string | undefined => {
162
+ const seen = new Set<string>();
163
+ for (const key of keys) {
164
+ if (seen.has(key)) return key;
165
+ seen.add(key);
166
+ }
167
+ return undefined;
168
+ };
169
+
170
+ /**
171
+ * Everything that makes a document unimportable, gathered before the first
172
+ * write. One rejected item stops the whole import: half a configuration is
173
+ * worse than none, because nothing says which half.
174
+ */
175
+ const validateDocument = (
176
+ document: ReaderConfigDocument,
177
+ existing: ExistingConfig,
178
+ ): ConfigImportProblem[] => {
179
+ const errors: ConfigImportProblem[] = [];
180
+
181
+ const duplicate = (section: string, keys: readonly string[]): void => {
182
+ const key = firstDuplicate(keys);
183
+ if (key === undefined) return;
184
+ errors.push(
185
+ problem(
186
+ "duplicate_key",
187
+ `Two entries in ${section} share the key "${key}". A configuration names each of these once.`,
188
+ { section, key },
189
+ ),
190
+ );
191
+ };
192
+
193
+ duplicate(
194
+ "accounts",
195
+ document.accounts.map((account) => normalize(account.email)),
196
+ );
197
+ duplicate(
198
+ "labels",
199
+ document.labels.map((label) => normalize(label.name)),
200
+ );
201
+ duplicate(
202
+ "filters",
203
+ document.filters.map((filter) => normalize(filter.name)),
204
+ );
205
+ duplicate(
206
+ "addressFlags",
207
+ document.addressFlags.map((entry) => normalize(entry.normalizedEmail)),
208
+ );
209
+
210
+ const labelNames = new Set([
211
+ ...document.labels.map((label) => normalize(label.name)),
212
+ ...existing.labels.map((label) => normalize(label.name)),
213
+ ]);
214
+ const accountIds = new Set([
215
+ ...document.accounts.map((account) => account.accountId),
216
+ ...existing.accounts.map((account) => account.accountId),
217
+ ]);
218
+
219
+ for (const filter of document.filters) {
220
+ if (
221
+ filter.actionLabelName !== null &&
222
+ !labelNames.has(normalize(filter.actionLabelName))
223
+ ) {
224
+ errors.push(
225
+ problem(
226
+ "unknown_label",
227
+ `Filter "${filter.name}" applies the label "${filter.actionLabelName}", which this file does not carry and this configuration does not hold.`,
228
+ { filter: filter.name, label: filter.actionLabelName },
229
+ ),
230
+ );
231
+ }
232
+ if (
233
+ filter.actionFolder !== null &&
234
+ !accountIds.has(filter.actionFolder.accountId)
235
+ ) {
236
+ errors.push(
237
+ problem(
238
+ "unknown_account",
239
+ `Filter "${filter.name}" files mail into a folder on an account this file does not carry.`,
240
+ { filter: filter.name, accountId: filter.actionFolder.accountId },
241
+ ),
242
+ );
243
+ }
244
+ }
245
+
246
+ return errors;
247
+ };
248
+
249
+ /** Which credential an imported account needs before it can sync again. */
250
+ const landingState = (
251
+ account: ConfigAccount,
252
+ ): AccountItem["connectionState"] =>
253
+ account.credentials.required === "oauth"
254
+ ? ConnectionState.ReauthRequired
255
+ : ConnectionState.CredentialsMissing;
256
+
257
+ /**
258
+ * What the apply has decided so far. Owned by `importConfig` rather than by
259
+ * `applyDocument` so a write that fails part-way still has it: on a backend
260
+ * without a transaction those items are what actually landed, and a report that
261
+ * threw them away would name a stopping point without naming the state.
262
+ */
263
+ interface ApplyProgress {
264
+ items: ConfigImportItemReport[];
265
+ warnings: ConfigImportProblem[];
266
+ accountsNeedingCredentials: string[];
267
+ unresolvedRefs: ConfigImportUnresolvedRefItem[];
268
+ }
269
+
270
+ const emptyProgress = (): ApplyProgress => ({
271
+ items: [],
272
+ warnings: [],
273
+ accountsNeedingCredentials: [],
274
+ unresolvedRefs: [],
275
+ });
276
+
277
+ interface AnchorVector {
278
+ embedding: number[];
279
+ embeddingId: string;
280
+ }
281
+
282
+ /**
283
+ * Every anchor's vector, computed before the transaction opens.
284
+ *
285
+ * Two reasons it happens here and not at the point of write. An embedder is a
286
+ * network round trip and the sqlite write queue is process-wide, so embedding
287
+ * inside the transaction holds that queue against every other writer for the
288
+ * length of the call. And a failed embed is not a failed import: it degrades
289
+ * exactly the way a deployment that ships no embedder does, landing the anchor
290
+ * stamped `ANCHOR_EMBEDDING_PENDING` for the lazy repair to rebuild the first
291
+ * time the filter runs. A 503 from the embedder must not cost a person their
292
+ * configuration.
293
+ */
294
+ const embedAnchors = async (
295
+ deps: ConfigImportDeps,
296
+ document: ReaderConfigDocument,
297
+ warnings: ConfigImportProblem[],
298
+ ): Promise<Map<string, AnchorVector>> => {
299
+ const vectors = new Map<string, AnchorVector>();
300
+ const { embedAnchor } = deps;
301
+
302
+ for (const filter of document.filters) {
303
+ const { anchor } = filter;
304
+ if (anchor === null) continue;
305
+
306
+ const embedded = embedAnchor
307
+ ? await embedAnchor(anchor.sourceText).catch(() => undefined)
308
+ : undefined;
309
+
310
+ if (embedded) {
311
+ vectors.set(normalize(filter.name), embedded);
312
+ continue;
313
+ }
314
+
315
+ warnings.push(
316
+ problem(
317
+ "anchor_not_embedded",
318
+ `The example behind filter "${filter.name}" is carried as text; its vector is rebuilt the first time the filter runs.`,
319
+ { filter: filter.name },
320
+ ),
321
+ );
322
+ }
323
+
324
+ return vectors;
325
+ };
326
+
327
+ /**
328
+ * The id this instance will hold each account in the file under: the one it
329
+ * already has for that id or email, or the file's own, which `create` preserves
330
+ * verbatim. Derived before any write so the dry run resolves folders against
331
+ * exactly the accounts an apply would.
332
+ */
333
+ const resolveLiveAccountIds = (
334
+ document: ReaderConfigDocument,
335
+ existing: ExistingConfig,
336
+ ): Map<string, string> => {
337
+ const byId = new Map(
338
+ existing.accounts.map((account) => [account.accountId, account] as const),
339
+ );
340
+ const byEmail = new Map(
341
+ existing.accounts.map(
342
+ (account) => [normalize(account.email), account] as const,
343
+ ),
344
+ );
345
+
346
+ return new Map(
347
+ document.accounts.map((account) => {
348
+ const held =
349
+ byId.get(account.accountId) ?? byEmail.get(normalize(account.email));
350
+ return [account.accountId, held?.accountId ?? account.accountId] as const;
351
+ }),
352
+ );
353
+ };
354
+
355
+ /** The folders this instance holds for an account the file names, by IMAP path. */
356
+ const foldersFor = (
357
+ existing: ExistingConfig,
358
+ liveAccountId: ReadonlyMap<string, string>,
359
+ fileAccountId: string,
360
+ ): Map<string, string> =>
361
+ existing.mailboxPaths.get(
362
+ liveAccountId.get(fileAccountId) ?? fileAccountId,
363
+ ) ?? new Map();
364
+
365
+ /**
366
+ * A write that failed after validation. It carries the item it stopped on so
367
+ * the report can name it: the import is atomic, so nothing landed, and where it
368
+ * stopped is the only fact worth recovering from.
369
+ */
370
+ class ImportWriteError extends Error {
371
+ constructor(
372
+ readonly section: ConfigImportItemReport["section"],
373
+ readonly key: string,
374
+ readonly cause: unknown,
375
+ ) {
376
+ super(
377
+ `${section} "${key}" could not be written: ${cause instanceof Error ? cause.message : String(cause)}`,
378
+ );
379
+ this.name = "ImportWriteError";
380
+ }
381
+ }
382
+
383
+ const applyDocument = async (
384
+ deps: ConfigImportDeps,
385
+ input: ImportConfigInput,
386
+ document: ReaderConfigDocument,
387
+ existing: ExistingConfig,
388
+ anchorVectors: ReadonlyMap<string, AnchorVector>,
389
+ progress: ApplyProgress,
390
+ ): Promise<string> => {
391
+ const { repositories } = deps;
392
+ const now = deps.now ?? Date.now;
393
+ const { accountConfigId } = input;
394
+
395
+ const { items, warnings, accountsNeedingCredentials, unresolvedRefs } =
396
+ progress;
397
+
398
+ const record = (
399
+ section: ConfigImportItemReport["section"],
400
+ key: string,
401
+ verdict: ConfigImportItemReport["verdict"],
402
+ ): void => {
403
+ items.push({ section, key, verdict });
404
+ };
405
+
406
+ const write = async <T>(
407
+ section: ConfigImportItemReport["section"],
408
+ key: string,
409
+ run: () => Promise<T>,
410
+ ): Promise<T> =>
411
+ run().catch((error: unknown) => {
412
+ throw new ImportWriteError(section, key, error);
413
+ });
414
+
415
+ // ---- the configuration row ------------------------------------------
416
+ // First: everything below hangs off it, and on a fresh instance there is no
417
+ // row for an account to reference until this has run.
418
+ await write(ConfigImportSection.Settings, "accountConfig", () =>
419
+ ensureAccountConfig(deps, input, document),
420
+ );
421
+
422
+ // ---- accounts -------------------------------------------------------
423
+ const existingByEmail = new Map(
424
+ existing.accounts.map(
425
+ (account) => [normalize(account.email), account] as const,
426
+ ),
427
+ );
428
+ const existingById = new Map(
429
+ existing.accounts.map((account) => [account.accountId, account] as const),
430
+ );
431
+ /** The id in the file → the id this instance actually holds it under. */
432
+ const liveAccountId = resolveLiveAccountIds(document, existing);
433
+
434
+ for (const account of document.accounts) {
435
+ const held =
436
+ existingById.get(account.accountId) ??
437
+ existingByEmail.get(normalize(account.email));
438
+ const endpoints = {
439
+ username: account.username,
440
+ email: account.email,
441
+ authType: account.authType,
442
+ imapHost: account.imap.host,
443
+ imapPort: account.imap.port,
444
+ imapTls: account.imap.tls,
445
+ imapStartTls: account.imap.startTls,
446
+ smtpEnabled: account.smtp.enabled,
447
+ smtpHost: account.smtp.host,
448
+ smtpPort: account.smtp.port,
449
+ smtpTls: account.smtp.tls,
450
+ smtpStartTls: account.smtp.startTls,
451
+ smtpUsername: account.smtp.username,
452
+ };
453
+
454
+ if (held) {
455
+ // The file carries no credential, so it cannot invalidate the one this
456
+ // account already has: `isActive` and `connectionState` are left alone.
457
+ await write(ConfigImportSection.Accounts, account.email, () =>
458
+ repositories.account.update(held.accountId, endpoints),
459
+ );
460
+ liveAccountId.set(account.accountId, held.accountId);
461
+ record(
462
+ ConfigImportSection.Accounts,
463
+ account.email,
464
+ ConfigImportVerdict.Updated,
465
+ );
466
+ continue;
467
+ }
468
+
469
+ const created = await write(
470
+ ConfigImportSection.Accounts,
471
+ account.email,
472
+ () =>
473
+ repositories.account.create({
474
+ // Verbatim: a resynced message id hashes from it, so preserving it is
475
+ // what keeps every filter anchor's provenance valid across the drop.
476
+ accountId: account.accountId,
477
+ accountConfigId,
478
+ ...endpoints,
479
+ isActive: false,
480
+ connectionState: landingState(account),
481
+ }),
482
+ );
483
+ liveAccountId.set(account.accountId, created.accountId);
484
+ accountsNeedingCredentials.push(created.accountId);
485
+ record(
486
+ ConfigImportSection.Accounts,
487
+ account.email,
488
+ ConfigImportVerdict.Created,
489
+ );
490
+ }
491
+
492
+ const foldersOf = (fileAccountId: string): Map<string, string> =>
493
+ foldersFor(existing, liveAccountId, fileAccountId);
494
+
495
+ // ---- labels ---------------------------------------------------------
496
+ const labelIdByName = new Map(
497
+ existing.labels.map(
498
+ (label) => [normalize(label.name), label.labelId] as const,
499
+ ),
500
+ );
501
+
502
+ for (const label of document.labels) {
503
+ const key = normalize(label.name);
504
+ const heldId = labelIdByName.get(key);
505
+ if (heldId) {
506
+ await write(ConfigImportSection.Labels, label.name, () =>
507
+ repositories.label.update(accountConfigId, heldId, {
508
+ name: label.name,
509
+ color: label.color,
510
+ }),
511
+ );
512
+ record(
513
+ ConfigImportSection.Labels,
514
+ label.name,
515
+ ConfigImportVerdict.Updated,
516
+ );
517
+ continue;
518
+ }
519
+ const created = await write(ConfigImportSection.Labels, label.name, () =>
520
+ repositories.label.create({
521
+ accountConfigId,
522
+ name: label.name,
523
+ color: label.color,
524
+ }),
525
+ );
526
+ labelIdByName.set(key, created.labelId);
527
+ record(ConfigImportSection.Labels, label.name, ConfigImportVerdict.Created);
528
+ }
529
+
530
+ // ---- filters --------------------------------------------------------
531
+ const existingFilterByName = new Map(
532
+ existing.filters.map((filter) => [normalize(filter.name), filter] as const),
533
+ );
534
+ const filterIdByName = new Map<string, string>();
535
+
536
+ const resolveFilterFolder = (
537
+ filter: ConfigFilter,
538
+ ): { actionMailboxId: string; pending: boolean } => {
539
+ if (filter.actionFolder === null)
540
+ return { actionMailboxId: NO_ACTION, pending: false };
541
+ const mailboxId = foldersOf(filter.actionFolder.accountId).get(
542
+ filter.actionFolder.folderPath,
543
+ );
544
+ return mailboxId
545
+ ? { actionMailboxId: mailboxId, pending: false }
546
+ : { actionMailboxId: NO_ACTION, pending: true };
547
+ };
548
+
549
+ for (const filter of document.filters) {
550
+ const key = normalize(filter.name);
551
+ const folder = resolveFilterFolder(filter);
552
+ const shape = {
553
+ scope: filter.scope,
554
+ expiresAt: filter.expiresAt ?? undefined,
555
+ ttl: deriveFilterTtl(filter.scope, filter.expiresAt ?? undefined),
556
+ matchOperator: filter.matchOperator,
557
+ literalClauses: filter.literalClauses,
558
+ actionLabelId:
559
+ filter.actionLabelName === null
560
+ ? NO_ACTION
561
+ : (labelIdByName.get(normalize(filter.actionLabelName)) ?? NO_ACTION),
562
+ actionMailboxId: folder.actionMailboxId,
563
+ hasAnchor: filter.anchor !== null,
564
+ };
565
+
566
+ const held = existingFilterByName.get(key);
567
+ const filterId = held
568
+ ? (
569
+ await write(ConfigImportSection.Filters, filter.name, () =>
570
+ repositories.filter.update(accountConfigId, held.filterId, {
571
+ name: filter.name,
572
+ ...shape,
573
+ }),
574
+ )
575
+ ).filterId
576
+ : (
577
+ await write(ConfigImportSection.Filters, filter.name, () =>
578
+ repositories.filter.create({
579
+ accountConfigId,
580
+ name: filter.name,
581
+ ...shape,
582
+ }),
583
+ )
584
+ ).filterId;
585
+
586
+ filterIdByName.set(key, filterId);
587
+ record(
588
+ ConfigImportSection.Filters,
589
+ filter.name,
590
+ held ? ConfigImportVerdict.Updated : ConfigImportVerdict.Created,
591
+ );
592
+
593
+ if (folder.pending && filter.actionFolder) {
594
+ unresolvedRefs.push({
595
+ kind: ConfigImportRefKind.FilterAction,
596
+ accountId:
597
+ liveAccountId.get(filter.actionFolder.accountId) ??
598
+ filter.actionFolder.accountId,
599
+ folderPath: filter.actionFolder.folderPath,
600
+ target: filterId,
601
+ });
602
+ warnings.push(
603
+ problem(
604
+ "folder_not_found_yet",
605
+ `Filter "${filter.name}" files mail into "${filter.actionFolder.folderPath}", which this account does not hold yet. It is bound once the folder list has been read.`,
606
+ { filter: filter.name, folderPath: filter.actionFolder.folderPath },
607
+ ),
608
+ );
609
+ }
610
+ }
611
+
612
+ // ---- anchors --------------------------------------------------------
613
+ // Vectors were computed before the transaction opened; a missing one is an
614
+ // anchor carried as text, already warned about there.
615
+ for (const filter of document.filters) {
616
+ const { anchor } = filter;
617
+ if (anchor === null) continue;
618
+ const key = normalize(filter.name);
619
+ const filterId = filterIdByName.get(key);
620
+ if (filterId === undefined) {
621
+ throw new Error(
622
+ `Filter "${filter.name}" carries an anchor but the filter loop minted no id for it. The two loops read the same documents.filters, so this cannot happen without one of them changing.`,
623
+ );
624
+ }
625
+
626
+ const embedded = anchorVectors.get(key);
627
+
628
+ await write(ConfigImportSection.Filters, filter.name, () =>
629
+ repositories.filterAnchor.put({
630
+ accountConfigId,
631
+ filterId,
632
+ anchorEmbedding: embedded?.embedding ?? [],
633
+ anchorEmbeddingId: embedded?.embeddingId ?? ANCHOR_EMBEDDING_PENDING,
634
+ anchorSourceText: anchor.sourceText,
635
+ anchorMessageId: anchor.sourceMessageId,
636
+ }),
637
+ );
638
+ }
639
+
640
+ // ---- address flags --------------------------------------------------
641
+ for (const entry of document.addressFlags) {
642
+ await write(ConfigImportSection.AddressFlags, entry.normalizedEmail, () =>
643
+ writeAddressFlags(deps, accountConfigId, entry),
644
+ );
645
+ record(
646
+ ConfigImportSection.AddressFlags,
647
+ entry.normalizedEmail,
648
+ existing.addressEmails.has(normalize(entry.normalizedEmail))
649
+ ? ConfigImportVerdict.Updated
650
+ : ConfigImportVerdict.Created,
651
+ );
652
+ }
653
+
654
+ // ---- settings -------------------------------------------------------
655
+ const settings = new SettingWriter(deps, accountConfigId);
656
+
657
+ const composerFormat = document.accountConfig.defaultComposerFormat;
658
+ if (composerFormat !== undefined) {
659
+ await write(ConfigImportSection.Settings, "composer format", () =>
660
+ settings.put(AccountSettingName.DefaultComposerFormat, {
661
+ kind: "String",
662
+ value: composerFormat,
663
+ }),
664
+ );
665
+ }
666
+
667
+ const pinned = [
668
+ ...new Set(document.accounts.flatMap((account) => account.pinnedFolders)),
669
+ ];
670
+ if (pinned.length > 0) {
671
+ await write(ConfigImportSection.Settings, "pinned folders", () =>
672
+ settings.put(AccountSettingName.PinnedFolders, {
673
+ kind: "StringList",
674
+ value: pinned,
675
+ }),
676
+ );
677
+ }
678
+
679
+ for (const account of document.accounts) {
680
+ const accountId = liveAccountId.get(account.accountId) ?? account.accountId;
681
+ await write(ConfigImportSection.Settings, account.email, () =>
682
+ settings.writeAccount(account, accountId),
683
+ );
684
+ unresolvedRefs.push(
685
+ ...(await write(ConfigImportSection.Settings, account.email, () =>
686
+ settings.writeFolders(account, accountId, foldersOf(account.accountId)),
687
+ )),
688
+ );
689
+ record(
690
+ ConfigImportSection.Settings,
691
+ account.email,
692
+ ConfigImportVerdict.Updated,
693
+ );
694
+ }
695
+
696
+ for (const ref of unresolvedRefs) {
697
+ if (ref.kind === ConfigImportRefKind.FilterAction) continue;
698
+ warnings.push(
699
+ problem(
700
+ "folder_not_found_yet",
701
+ `"${ref.folderPath}" is not a folder this account holds yet. It is bound once the folder list has been read.`,
702
+ { folderPath: ref.folderPath, accountId: ref.accountId },
703
+ ),
704
+ );
705
+ }
706
+
707
+ const pendingFolders = unresolvedRefs.length > 0;
708
+ const row = await write(ConfigImportSection.Settings, "import record", () =>
709
+ repositories.configImport.create({
710
+ accountConfigId,
711
+ schemaVersion: document.schemaVersion,
712
+ state: pendingFolders
713
+ ? ConfigImportState.Pending
714
+ : ConfigImportState.Complete,
715
+ document: document as unknown as Record<string, unknown>,
716
+ unresolvedRefs,
717
+ completedAt: pendingFolders ? 0 : now(),
718
+ }),
719
+ );
720
+
721
+ return row.importId;
722
+ };
723
+
724
+ /**
725
+ * An address arrives ahead of any mail, keyed on the email string, so its id
726
+ * re-derives from the importing configuration. The flags then go on through the
727
+ * existing merge fold rather than replacing the stored object: a resync running
728
+ * alongside this keeps the counters and the machine-derived flags it has
729
+ * already worked out.
730
+ */
731
+ const writeAddressFlags = async (
732
+ deps: ConfigImportDeps,
733
+ accountConfigId: string,
734
+ entry: ConfigAddressFlagsEntry,
735
+ ): Promise<void> => {
736
+ const normalizedEmail = normalize(entry.normalizedEmail);
737
+ const at = normalizedEmail.lastIndexOf("@");
738
+ const addressId = deriveAddressId(accountConfigId, normalizedEmail);
739
+ await deps.repositories.address.upsertAddress({
740
+ addressId,
741
+ accountConfigId,
742
+ displayName: entry.displayName,
743
+ localPart: at === -1 ? normalizedEmail : normalizedEmail.slice(0, at),
744
+ domain: at === -1 ? "" : normalizedEmail.slice(at + 1),
745
+ normalizedEmail,
746
+ normalizedCompound:
747
+ `${entry.displayName.toLowerCase()} ${normalizedEmail}`.trim(),
748
+ });
749
+ await deps.repositories.address.mergeFlags(
750
+ accountConfigId,
751
+ addressId,
752
+ entry.flags,
753
+ );
754
+ };
755
+
756
+ /**
757
+ * The configuration row itself. A fresh instance holds none — `GET /config`
758
+ * synthesizes an empty answer rather than writing one — so an import into a
759
+ * dropped database has to materialize it before anything can hang off it.
760
+ */
761
+ const ensureAccountConfig = async (
762
+ deps: ConfigImportDeps,
763
+ input: ImportConfigInput,
764
+ document: ReaderConfigDocument,
765
+ ): Promise<void> => {
766
+ const { accountConfig } = deps.repositories;
767
+ // Absence is the expected answer on a fresh instance and the only one that
768
+ // means "create it"; anything else is a store fault and belongs to the caller.
769
+ const held = await accountConfig
770
+ .get(input.accountConfigId)
771
+ .catch((error: unknown) => {
772
+ if (error instanceof NotFoundError) return undefined;
773
+ throw error;
774
+ });
775
+ const name = document.accountConfig.name;
776
+
777
+ if (!held) {
778
+ await accountConfig.create({
779
+ accountConfigId: input.accountConfigId,
780
+ userId: input.userId,
781
+ name,
782
+ });
783
+ return;
784
+ }
785
+ if (name !== "" && name !== held.name) {
786
+ await accountConfig.update(input.accountConfigId, { name });
787
+ }
788
+ };
789
+
790
+ /** Every per-account setting an import writes, keyed the way the registry spells it. */
791
+ class SettingWriter {
792
+ constructor(
793
+ private readonly deps: ConfigImportDeps,
794
+ private readonly accountConfigId: string,
795
+ ) {}
796
+
797
+ put(name: string, value: AccountSettingValue): Promise<unknown> {
798
+ return this.deps.repositories.accountSetting.upsert({
799
+ accountConfigId: this.accountConfigId,
800
+ name,
801
+ value,
802
+ });
803
+ }
804
+
805
+ async writeAccount(account: ConfigAccount, accountId: string): Promise<void> {
806
+ const per = (base: AccountSettingNameValue): string =>
807
+ composeSettingName(base, accountId);
808
+
809
+ if (account.displayName !== "") {
810
+ await this.put(per(AccountSettingName.AccountDisplayName), {
811
+ kind: "String",
812
+ value: account.displayName,
813
+ });
814
+ }
815
+ if (account.muted !== null) {
816
+ await this.put(per(AccountSettingName.AccountMuted), {
817
+ kind: "MutedFlag",
818
+ value: account.muted,
819
+ });
820
+ }
821
+ if (account.composeLanguages.length > 0) {
822
+ await this.put(per(AccountSettingName.AccountComposeLanguages), {
823
+ kind: "StringList",
824
+ value: account.composeLanguages,
825
+ });
826
+ }
827
+ if (account.signature.plainText !== "") {
828
+ await this.put(per(AccountSettingName.AccountSignaturePlainText), {
829
+ kind: "String",
830
+ value: account.signature.plainText,
831
+ });
832
+ }
833
+ if (account.signature.html !== "") {
834
+ await this.put(per(AccountSettingName.AccountSignatureHtml), {
835
+ kind: "String",
836
+ value: account.signature.html,
837
+ });
838
+ }
839
+ }
840
+
841
+ /**
842
+ * The folder-keyed half. A role whose folder is not here yet waits whole:
843
+ * the appointment and the path it was made on are one decision, and half of
844
+ * one is read by nothing.
845
+ */
846
+ async writeFolders(
847
+ account: ConfigAccount,
848
+ accountId: string,
849
+ folders: ReadonlyMap<string, string>,
850
+ ): Promise<ConfigImportUnresolvedRefItem[]> {
851
+ const pending: ConfigImportUnresolvedRefItem[] = [];
852
+
853
+ for (const role of account.folderRoles) {
854
+ const mailboxId = folders.get(role.folderPath);
855
+ if (mailboxId === undefined) {
856
+ pending.push({
857
+ kind: ConfigImportRefKind.FolderRole,
858
+ accountId,
859
+ folderPath: role.folderPath,
860
+ target: role.role,
861
+ });
862
+ continue;
863
+ }
864
+ await this.deps.appointFolderRole(
865
+ this.accountConfigId,
866
+ accountId,
867
+ role.role as CanonicalMailboxRoleValue,
868
+ mailboxId,
869
+ role.folderPath,
870
+ );
871
+ }
872
+
873
+ for (const override of account.folderOverrides) {
874
+ const mailboxId = folders.get(override.folderPath);
875
+ if (mailboxId === undefined) {
876
+ pending.push({
877
+ kind: ConfigImportRefKind.MailboxOverride,
878
+ accountId,
879
+ folderPath: override.folderPath,
880
+ target: override.folderPath,
881
+ });
882
+ continue;
883
+ }
884
+ await this.writeMailboxOverride(mailboxId, override);
885
+ }
886
+
887
+ return pending;
888
+ }
889
+
890
+ async writeMailboxOverride(
891
+ mailboxId: string,
892
+ override: ConfigAccount["folderOverrides"][number],
893
+ ): Promise<void> {
894
+ if (override.displayName !== "") {
895
+ await this.put(
896
+ composeSettingName(AccountSettingName.MailboxDisplayName, mailboxId),
897
+ { kind: "String", value: override.displayName },
898
+ );
899
+ }
900
+ if (override.muted !== null) {
901
+ await this.put(
902
+ composeSettingName(AccountSettingName.MailboxMuted, mailboxId),
903
+ { kind: "MutedFlag", value: override.muted },
904
+ );
905
+ }
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Validate, or validate and apply, one configuration document.
911
+ *
912
+ * The order is fixed: parse and migrate, check every reference and every
913
+ * natural key against what this configuration already holds, and only then
914
+ * write — inside one transaction, accounts before labels before filters before
915
+ * their anchors before addresses before settings, because each of those reads
916
+ * an id the one before it minted.
917
+ *
918
+ * A document this reader will not import is the operation's expected outcome,
919
+ * not a fault: it comes back as a report saying why. A fault on the way to the
920
+ * store is a fault, and propagates.
921
+ */
922
+ export const importConfig = async (
923
+ deps: ConfigImportDeps,
924
+ input: ImportConfigInput,
925
+ ): Promise<ConfigImportOutcome> => {
926
+ let document: ReaderConfigDocument;
927
+ try {
928
+ document = readConfigDocument(input.document);
929
+ } catch (error) {
930
+ if (!(error instanceof ConfigReadError)) throw error;
931
+ return rejected(claimedVersion(input.document), [
932
+ problem(error.code, error.message),
933
+ ]);
934
+ }
935
+
936
+ const existing = await readExisting(deps, input.accountConfigId);
937
+ const held =
938
+ existing.accounts.length +
939
+ existing.labels.length +
940
+ existing.filters.length +
941
+ existing.flaggedAddresses;
942
+
943
+ if (held > 0 && input.onExisting === "abort") {
944
+ return {
945
+ outcome: "conflict",
946
+ conflict: {
947
+ code: "config_not_empty",
948
+ message:
949
+ "This configuration already holds accounts, labels, filters or flagged senders. Import it again with merge to fold the file into what is here.",
950
+ details: {
951
+ accounts: String(existing.accounts.length),
952
+ labels: String(existing.labels.length),
953
+ filters: String(existing.filters.length),
954
+ addressFlags: String(existing.flaggedAddresses),
955
+ },
956
+ },
957
+ };
958
+ }
959
+
960
+ const errors = validateDocument(document, existing);
961
+ if (errors.length > 0) return rejected(document.schemaVersion, errors);
962
+
963
+ if (input.mode === "validate") {
964
+ const dryRun = plan(document, existing);
965
+ return {
966
+ outcome: "report",
967
+ report: {
968
+ valid: true,
969
+ schemaVersion: document.schemaVersion,
970
+ applied: false,
971
+ items: dryRun.items,
972
+ errors: [],
973
+ warnings: dryRun.warnings,
974
+ accountsNeedingCredentials: dryRun.accountsNeedingCredentials,
975
+ },
976
+ };
977
+ }
978
+
979
+ // Before the transaction: an embed is a network round trip, and the write
980
+ // queue it would otherwise hold is process-wide.
981
+ const embedWarnings: ConfigImportProblem[] = [];
982
+ const anchorVectors = await embedAnchors(deps, document, embedWarnings);
983
+
984
+ const { transaction } = deps;
985
+ const atomic = transaction !== undefined;
986
+ const progress = emptyProgress();
987
+ const apply = (): Promise<string> =>
988
+ applyDocument(deps, input, document, existing, anchorVectors, progress);
989
+
990
+ try {
991
+ const importId = await (transaction ? transaction(apply) : apply());
992
+ return {
993
+ outcome: "report",
994
+ report: {
995
+ importId,
996
+ valid: true,
997
+ schemaVersion: document.schemaVersion,
998
+ applied: true,
999
+ items: progress.items,
1000
+ errors: [],
1001
+ warnings: [...embedWarnings, ...progress.warnings],
1002
+ accountsNeedingCredentials: progress.accountsNeedingCredentials,
1003
+ },
1004
+ };
1005
+ } catch (error) {
1006
+ if (!(error instanceof ImportWriteError)) throw error;
1007
+ // What the report owes the reader is where it stopped and what is now in
1008
+ // the store. Under a transaction the answer to the second is "nothing";
1009
+ // without one it is everything recorded up to the failure.
1010
+ const landed = atomic ? emptyProgress() : progress;
1011
+ return {
1012
+ outcome: "report",
1013
+ report: {
1014
+ valid: true,
1015
+ schemaVersion: document.schemaVersion,
1016
+ applied: landed.items.length > 0,
1017
+ items: [
1018
+ ...landed.items,
1019
+ {
1020
+ section: error.section,
1021
+ key: error.key,
1022
+ verdict: ConfigImportVerdict.Rejected,
1023
+ reason: error.message,
1024
+ },
1025
+ ],
1026
+ errors: [
1027
+ problem(
1028
+ "import_write_failed",
1029
+ `${error.message} ${
1030
+ atomic
1031
+ ? "Nothing was written; the import stopped here."
1032
+ : "The items above it were written and remain; the import stopped here."
1033
+ }`,
1034
+ { section: error.section, key: error.key },
1035
+ ),
1036
+ ],
1037
+ warnings: [...embedWarnings, ...landed.warnings],
1038
+ accountsNeedingCredentials: landed.accountsNeedingCredentials,
1039
+ },
1040
+ };
1041
+ }
1042
+ };
1043
+
1044
+ interface PlannedImport {
1045
+ items: ConfigImportItemReport[];
1046
+ warnings: ConfigImportProblem[];
1047
+ accountsNeedingCredentials: string[];
1048
+ }
1049
+
1050
+ /**
1051
+ * What an apply would do — the dry run's whole answer, and the same answer.
1052
+ * The wizard shows this before commit, so it resolves folder references against
1053
+ * the mailboxes already read and names the accounts that would land needing a
1054
+ * credential; a plan that reported neither would show a clean run and then
1055
+ * surprise the person with both.
1056
+ */
1057
+ const plan = (
1058
+ document: ReaderConfigDocument,
1059
+ existing: ExistingConfig,
1060
+ ): PlannedImport => {
1061
+ const items: ConfigImportItemReport[] = [];
1062
+ const warnings: ConfigImportProblem[] = [];
1063
+ const accountsNeedingCredentials: string[] = [];
1064
+ const liveAccountId = resolveLiveAccountIds(document, existing);
1065
+ const heldIds = new Set(
1066
+ existing.accounts.map((account) => account.accountId),
1067
+ );
1068
+ const heldEmails = new Set(
1069
+ existing.accounts.map((account) => normalize(account.email)),
1070
+ );
1071
+ const heldLabels = new Set(
1072
+ existing.labels.map((label) => normalize(label.name)),
1073
+ );
1074
+ const heldFilters = new Set(
1075
+ existing.filters.map((filter) => normalize(filter.name)),
1076
+ );
1077
+
1078
+ const verdict = (held: boolean): ConfigImportItemReport["verdict"] =>
1079
+ held ? ConfigImportVerdict.Updated : ConfigImportVerdict.Created;
1080
+
1081
+ for (const account of document.accounts) {
1082
+ const held =
1083
+ heldIds.has(account.accountId) ||
1084
+ heldEmails.has(normalize(account.email));
1085
+ items.push({
1086
+ section: ConfigImportSection.Accounts,
1087
+ key: account.email,
1088
+ verdict: verdict(held),
1089
+ });
1090
+ // A held account keeps the credential it already has; a new one lands
1091
+ // inactive, exactly as `applyDocument` records it.
1092
+ if (!held) accountsNeedingCredentials.push(account.accountId);
1093
+ }
1094
+ for (const label of document.labels) {
1095
+ items.push({
1096
+ section: ConfigImportSection.Labels,
1097
+ key: label.name,
1098
+ verdict: verdict(heldLabels.has(normalize(label.name))),
1099
+ });
1100
+ }
1101
+ for (const filter of document.filters) {
1102
+ items.push({
1103
+ section: ConfigImportSection.Filters,
1104
+ key: filter.name,
1105
+ verdict: verdict(heldFilters.has(normalize(filter.name))),
1106
+ });
1107
+ }
1108
+ for (const entry of document.addressFlags) {
1109
+ items.push({
1110
+ section: ConfigImportSection.AddressFlags,
1111
+ key: entry.normalizedEmail,
1112
+ verdict: verdict(
1113
+ existing.addressEmails.has(normalize(entry.normalizedEmail)),
1114
+ ),
1115
+ });
1116
+ }
1117
+ for (const account of document.accounts) {
1118
+ items.push({
1119
+ section: ConfigImportSection.Settings,
1120
+ key: account.email,
1121
+ verdict: ConfigImportVerdict.Updated,
1122
+ });
1123
+ }
1124
+
1125
+ // Folder references, resolved the way the apply resolves them: a path this
1126
+ // account has not synced yet waits for the binder rather than blocking.
1127
+ for (const filter of document.filters) {
1128
+ const { actionFolder } = filter;
1129
+ if (actionFolder === null) continue;
1130
+ if (
1131
+ foldersFor(existing, liveAccountId, actionFolder.accountId).has(
1132
+ actionFolder.folderPath,
1133
+ )
1134
+ )
1135
+ continue;
1136
+ warnings.push(
1137
+ problem(
1138
+ "folder_not_found_yet",
1139
+ `Filter "${filter.name}" files mail into "${actionFolder.folderPath}", which this account does not hold yet. It is bound once the folder list has been read.`,
1140
+ { filter: filter.name, folderPath: actionFolder.folderPath },
1141
+ ),
1142
+ );
1143
+ }
1144
+
1145
+ for (const account of document.accounts) {
1146
+ const folders = foldersFor(existing, liveAccountId, account.accountId);
1147
+ const accountId = liveAccountId.get(account.accountId) ?? account.accountId;
1148
+ const paths = [
1149
+ ...account.folderRoles.map((role) => role.folderPath),
1150
+ ...account.folderOverrides.map((override) => override.folderPath),
1151
+ ];
1152
+ for (const folderPath of paths) {
1153
+ if (folders.has(folderPath)) continue;
1154
+ warnings.push(
1155
+ problem(
1156
+ "folder_not_found_yet",
1157
+ `"${folderPath}" is not a folder this account holds yet. It is bound once the folder list has been read.`,
1158
+ { folderPath, accountId },
1159
+ ),
1160
+ );
1161
+ }
1162
+ }
1163
+
1164
+ return { items, warnings, accountsNeedingCredentials };
1165
+ };