@remit/config-transfer 0.0.1 → 0.0.3
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/package.json +1 -1
- package/src/binder.ts +184 -0
- package/src/export.test.ts +51 -1
- package/src/export.ts +96 -10
- package/src/fixtures.ts +17 -0
- package/src/import-repositories.ts +97 -0
- package/src/import.test.ts +1200 -0
- package/src/import.ts +1165 -0
- package/src/index.ts +34 -1
- package/src/report.ts +69 -0
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
|
+
};
|