@remit/config-transfer 0.0.2 → 0.0.4
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/export.test.ts +90 -3
- package/src/export.ts +97 -11
- package/src/fixtures.ts +92 -13
- package/src/import-repositories.ts +6 -1
- package/src/import.test.ts +62 -7
- package/src/import.ts +4 -1
- package/src/index.ts +5 -1
- package/src/repositories.ts +7 -1
package/package.json
CHANGED
package/src/export.test.ts
CHANGED
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
composeFolderRoleAppointmentLabelName,
|
|
8
8
|
composeFolderRoleAppointmentName,
|
|
9
9
|
} from "@remit/data-ports/folder-role";
|
|
10
|
-
import { readConfigForExport } from "./export.js";
|
|
10
|
+
import { ConfigExportRefusedError, readConfigForExport } from "./export.js";
|
|
11
11
|
import {
|
|
12
12
|
ACCOUNT_CONFIG_ID,
|
|
13
|
+
asAddressRepository,
|
|
13
14
|
asRepositories,
|
|
14
15
|
type ConfigFixture,
|
|
15
16
|
makeAccount,
|
|
@@ -17,7 +18,9 @@ import {
|
|
|
17
18
|
makeAddress,
|
|
18
19
|
makeAnchor,
|
|
19
20
|
makeFilter,
|
|
21
|
+
makeJunkOnlyFlaggedAddress,
|
|
20
22
|
makeLabel,
|
|
23
|
+
makeLegacyFlaggedAddress,
|
|
21
24
|
makeMailbox,
|
|
22
25
|
makeSetting,
|
|
23
26
|
OAUTH_ACCOUNT_ID,
|
|
@@ -462,13 +465,48 @@ test("an address exports only when the user decided something about it", async (
|
|
|
462
465
|
},
|
|
463
466
|
}),
|
|
464
467
|
];
|
|
465
|
-
fixture.addresses = [
|
|
468
|
+
fixture.addresses = [
|
|
469
|
+
...derivedOnly,
|
|
470
|
+
...fixture.addresses,
|
|
471
|
+
makeJunkOnlyFlaggedAddress(),
|
|
472
|
+
];
|
|
466
473
|
|
|
467
474
|
const document = await exportFixture(fixture);
|
|
468
475
|
|
|
469
476
|
assert.deepEqual(
|
|
470
477
|
document.addressFlags.map((entry) => entry.normalizedEmail),
|
|
471
|
-
["post@bank.example", "noreply@newsletter.example"],
|
|
478
|
+
["post@bank.example", "sale@junk.example", "noreply@newsletter.example"],
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("an address the machine marked junk-only still exports the decision on it", async () => {
|
|
483
|
+
const fixture = fullFixture();
|
|
484
|
+
fixture.addresses = [...fixture.addresses, makeJunkOnlyFlaggedAddress()];
|
|
485
|
+
|
|
486
|
+
const suggested = await asAddressRepository(fixture).listByAccountConfig({
|
|
487
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
488
|
+
});
|
|
489
|
+
assert.equal(
|
|
490
|
+
suggested.items.some((item) => item.addressId === "adr-junk-news"),
|
|
491
|
+
false,
|
|
492
|
+
"the suggest listing is right to hide it, and is not the export's read",
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const document = await exportFixture(fixture);
|
|
496
|
+
|
|
497
|
+
const exported = document.addressFlags.find(
|
|
498
|
+
(entry) => entry.normalizedEmail === "sale@junk.example",
|
|
499
|
+
);
|
|
500
|
+
assert.deepEqual(exported?.flags.unsubscribed, {
|
|
501
|
+
value: true,
|
|
502
|
+
setAt: 1755000000000,
|
|
503
|
+
});
|
|
504
|
+
assert.equal(exported?.flags.autoArchive?.value, true);
|
|
505
|
+
assert.equal(exported?.flags.category?.value, "newsletter");
|
|
506
|
+
assert.equal(
|
|
507
|
+
"junkOnly" in (exported?.flags ?? {}),
|
|
508
|
+
false,
|
|
509
|
+
"the machine's own flag is still not carried",
|
|
472
510
|
);
|
|
473
511
|
});
|
|
474
512
|
|
|
@@ -491,6 +529,55 @@ test("an exported address carries its whole flag payload, and no derived flag",
|
|
|
491
529
|
});
|
|
492
530
|
});
|
|
493
531
|
|
|
532
|
+
test("a flag set before the timestamp was recorded exports at the sentinel", async () => {
|
|
533
|
+
const fixture = fullFixture();
|
|
534
|
+
fixture.addresses = [makeLegacyFlaggedAddress()];
|
|
535
|
+
|
|
536
|
+
const document = await exportFixture(fixture);
|
|
537
|
+
|
|
538
|
+
assert.equal(ReaderConfigDocumentSchema.safeParse(document).success, true);
|
|
539
|
+
assert.deepEqual(document.addressFlags, [
|
|
540
|
+
{
|
|
541
|
+
normalizedEmail: "old@legacy.example",
|
|
542
|
+
displayName: "Legacy",
|
|
543
|
+
flags: {
|
|
544
|
+
vip: { value: true, setAt: 0, setBy: "web-client" },
|
|
545
|
+
category: { value: "newsletter", setAt: 0 },
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("a flag the format cannot carry is refused by address and by flag", async () => {
|
|
552
|
+
const fixture = fullFixture();
|
|
553
|
+
fixture.addresses = [
|
|
554
|
+
...fixture.addresses,
|
|
555
|
+
makeAddress({
|
|
556
|
+
addressId: "adr-broken",
|
|
557
|
+
normalizedEmail: "broken@example.com",
|
|
558
|
+
flags: {
|
|
559
|
+
category: { value: "Nonsense", setAt: 1750000000000 },
|
|
560
|
+
} as unknown as AddressItem["flags"],
|
|
561
|
+
}),
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
await assert.rejects(exportFixture(fixture), (error: unknown) => {
|
|
565
|
+
assert.ok(error instanceof ConfigExportRefusedError);
|
|
566
|
+
assert.match(
|
|
567
|
+
error.message,
|
|
568
|
+
/addressFlags\[broken@example\.com\]\.flags\.category\.value/,
|
|
569
|
+
);
|
|
570
|
+
return true;
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("a refusal outside the address list keeps the path the schema gave it", async () => {
|
|
575
|
+
const fixture = fullFixture();
|
|
576
|
+
fixture.labels = [makeLabel({ labelId: "lbl-blank", name: "" })];
|
|
577
|
+
|
|
578
|
+
await assert.rejects(exportFixture(fixture), /labels\[0\]\.name/);
|
|
579
|
+
});
|
|
580
|
+
|
|
494
581
|
test("a configuration that holds no row at all still exports a valid document", async () => {
|
|
495
582
|
const repositories = asRepositories(fullFixture());
|
|
496
583
|
const document = await readConfigForExport(
|
package/src/export.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ReaderConfigDocument,
|
|
10
10
|
ReaderConfigDocumentSchema,
|
|
11
11
|
} from "@remit/config-format";
|
|
12
|
+
import { formatPath } from "@remit/config-format/errors";
|
|
12
13
|
import type {
|
|
13
14
|
AccountItem,
|
|
14
15
|
AddressFlags,
|
|
@@ -39,12 +40,72 @@ import {
|
|
|
39
40
|
*/
|
|
40
41
|
const NO_ACTION = "None";
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The instance's own data does not fit the document format. Nothing is written:
|
|
45
|
+
* a file the import would refuse is worse than no file. Every problem names the
|
|
46
|
+
* row it came from, because the operator reading this has to go and fix one.
|
|
47
|
+
*/
|
|
48
|
+
export class ConfigExportRefusedError extends Error {
|
|
49
|
+
readonly problems: readonly string[];
|
|
50
|
+
|
|
51
|
+
constructor(problems: readonly string[]) {
|
|
52
|
+
super(
|
|
53
|
+
`This configuration cannot be exported as a v${CURRENT_SCHEMA_VERSION} document: ${problems.join("; ")}`,
|
|
54
|
+
);
|
|
55
|
+
this.name = "ConfigExportRefusedError";
|
|
56
|
+
this.problems = problems;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
/** Which credential the account needs before it can sync again. */
|
|
43
61
|
const credentialsFor = (account: AccountItem): ConfigAccount["credentials"] =>
|
|
44
62
|
account.authType === AccountAuthType.OauthMicrosoft
|
|
45
63
|
? { required: "oauth", provider: "microsoft" }
|
|
46
64
|
: { required: "password" };
|
|
47
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A flag as the rows actually hold it. `setAt` arrived after the first flags
|
|
68
|
+
* were written, so rows from before it carry none; the stored type says
|
|
69
|
+
* otherwise because it describes the shape being written today.
|
|
70
|
+
*/
|
|
71
|
+
interface StoredFlag<TValue> {
|
|
72
|
+
value: TValue;
|
|
73
|
+
setAt?: number;
|
|
74
|
+
setBy?: string;
|
|
75
|
+
expiresAt?: number;
|
|
76
|
+
reason?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The same flag as the document spells it: `setAt` always present. */
|
|
80
|
+
interface CarriedFlag<TValue> {
|
|
81
|
+
value: TValue;
|
|
82
|
+
setAt: number;
|
|
83
|
+
setBy?: string;
|
|
84
|
+
expiresAt?: number;
|
|
85
|
+
reason?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The format's sentinel for a flag set before the timestamp was recorded. */
|
|
89
|
+
const SET_AT_UNKNOWN = 0;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One flag, field by field rather than as a copy of the stored object, so no
|
|
93
|
+
* attribute a later build adds to the row travels without a line here.
|
|
94
|
+
*
|
|
95
|
+
* A row with no `setAt` exports at the sentinel. Dropping the flag would lose a
|
|
96
|
+
* decision its owner made, and stamping the export's own clock would claim it
|
|
97
|
+
* was made today.
|
|
98
|
+
*/
|
|
99
|
+
const carriedFlag = <TValue>(
|
|
100
|
+
flag: StoredFlag<TValue>,
|
|
101
|
+
): CarriedFlag<TValue> => ({
|
|
102
|
+
value: flag.value,
|
|
103
|
+
setAt: flag.setAt ?? SET_AT_UNKNOWN,
|
|
104
|
+
...(flag.setBy === undefined ? {} : { setBy: flag.setBy }),
|
|
105
|
+
...(flag.expiresAt === undefined ? {} : { expiresAt: flag.expiresAt }),
|
|
106
|
+
...(flag.reason === undefined ? {} : { reason: flag.reason }),
|
|
107
|
+
});
|
|
108
|
+
|
|
48
109
|
/**
|
|
49
110
|
* Every mailbox in the configuration, by id, plus which account holds it.
|
|
50
111
|
* Folder-referencing config is stored against mailbox ids, and every one of
|
|
@@ -111,7 +172,7 @@ const folderOverridesOf = (
|
|
|
111
172
|
overrides.push({
|
|
112
173
|
folderPath,
|
|
113
174
|
displayName: override.displayName,
|
|
114
|
-
muted: override.muted,
|
|
175
|
+
muted: override.muted === null ? null : carriedFlag(override.muted),
|
|
115
176
|
});
|
|
116
177
|
}
|
|
117
178
|
return overrides;
|
|
@@ -163,7 +224,7 @@ const toConfigAccount = (
|
|
|
163
224
|
username: account.smtpUsername,
|
|
164
225
|
},
|
|
165
226
|
displayName: settings.displayName,
|
|
166
|
-
muted: settings.muted,
|
|
227
|
+
muted: settings.muted === null ? null : carriedFlag(settings.muted),
|
|
167
228
|
composeLanguages: settings.composeLanguages,
|
|
168
229
|
signature: {
|
|
169
230
|
plainText: settings.signaturePlainText,
|
|
@@ -234,13 +295,13 @@ const toConfigFilter = (
|
|
|
234
295
|
const userFlagsOf = (flags: AddressFlags | undefined): ConfigAddressFlags => {
|
|
235
296
|
const stored = flags ?? {};
|
|
236
297
|
const user: ConfigAddressFlags = {};
|
|
237
|
-
if (stored.trusted) user.trusted = stored.trusted;
|
|
238
|
-
if (stored.blocked) user.blocked = stored.blocked;
|
|
239
|
-
if (stored.muted) user.muted = stored.muted;
|
|
240
|
-
if (stored.vip) user.vip = stored.vip;
|
|
241
|
-
if (stored.category) user.category = stored.category;
|
|
242
|
-
if (stored.autoArchive) user.autoArchive = stored.autoArchive;
|
|
243
|
-
if (stored.unsubscribed) user.unsubscribed = stored.unsubscribed;
|
|
298
|
+
if (stored.trusted) user.trusted = carriedFlag(stored.trusted);
|
|
299
|
+
if (stored.blocked) user.blocked = carriedFlag(stored.blocked);
|
|
300
|
+
if (stored.muted) user.muted = carriedFlag(stored.muted);
|
|
301
|
+
if (stored.vip) user.vip = carriedFlag(stored.vip);
|
|
302
|
+
if (stored.category) user.category = carriedFlag(stored.category);
|
|
303
|
+
if (stored.autoArchive) user.autoArchive = carriedFlag(stored.autoArchive);
|
|
304
|
+
if (stored.unsubscribed) user.unsubscribed = carriedFlag(stored.unsubscribed);
|
|
244
305
|
return user;
|
|
245
306
|
};
|
|
246
307
|
|
|
@@ -268,7 +329,7 @@ const readAddresses = async (
|
|
|
268
329
|
const addresses: AddressItem[] = [];
|
|
269
330
|
let cursor: string | undefined;
|
|
270
331
|
do {
|
|
271
|
-
const page = await repositories.address.
|
|
332
|
+
const page = await repositories.address.listAllByAccountConfigPage({
|
|
272
333
|
accountConfigId,
|
|
273
334
|
cursor,
|
|
274
335
|
});
|
|
@@ -278,6 +339,25 @@ const readAddresses = async (
|
|
|
278
339
|
return addresses;
|
|
279
340
|
};
|
|
280
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Where a refusal landed, said so an operator can act on it. A path into
|
|
344
|
+
* `addressFlags` names an array index of a list nobody can look up, so the
|
|
345
|
+
* index is replaced by the address itself; the flag is already the next segment.
|
|
346
|
+
*/
|
|
347
|
+
const refusalLocation = (
|
|
348
|
+
path: readonly PropertyKey[],
|
|
349
|
+
document: ReaderConfigDocument,
|
|
350
|
+
): string => {
|
|
351
|
+
const [head, index, ...rest] = path;
|
|
352
|
+
if (head !== "addressFlags" || typeof index !== "number")
|
|
353
|
+
return formatPath(path);
|
|
354
|
+
const email = document.addressFlags[index]?.normalizedEmail;
|
|
355
|
+
if (email === undefined) return formatPath(path);
|
|
356
|
+
return rest.length === 0
|
|
357
|
+
? `addressFlags[${email}]`
|
|
358
|
+
: `addressFlags[${email}].${formatPath(rest)}`;
|
|
359
|
+
};
|
|
360
|
+
|
|
281
361
|
const accountSettingsOf = (
|
|
282
362
|
settings: SettingsView,
|
|
283
363
|
accountId: string,
|
|
@@ -380,5 +460,11 @@ export const readConfigForExport = async (
|
|
|
380
460
|
.map(toConfigAddressFlags),
|
|
381
461
|
};
|
|
382
462
|
|
|
383
|
-
|
|
463
|
+
const parsed = ReaderConfigDocumentSchema.safeParse(document);
|
|
464
|
+
if (parsed.success) return parsed.data;
|
|
465
|
+
throw new ConfigExportRefusedError(
|
|
466
|
+
parsed.error.issues.map(
|
|
467
|
+
(issue) => `${refusalLocation(issue.path, document)}: ${issue.message}`,
|
|
468
|
+
),
|
|
469
|
+
);
|
|
384
470
|
};
|
package/src/fixtures.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
AddressItem,
|
|
6
6
|
FilterAnchorItem,
|
|
7
7
|
FilterItem,
|
|
8
|
+
IAddressRepository,
|
|
8
9
|
LabelItem,
|
|
9
10
|
MailboxItem,
|
|
10
11
|
} from "@remit/data-ports";
|
|
@@ -168,6 +169,96 @@ export const makeAddress = (
|
|
|
168
169
|
...overrides,
|
|
169
170
|
});
|
|
170
171
|
|
|
172
|
+
/**
|
|
173
|
+
* An address flagged before `setAt` was recorded, as v0.6 left it in the
|
|
174
|
+
* database. The stored type cannot spell the row — it describes what is written
|
|
175
|
+
* today — so the fixture asserts the shape rather than deriving it, which is
|
|
176
|
+
* the whole point: this is what the live instance actually holds.
|
|
177
|
+
*/
|
|
178
|
+
export const makeLegacyFlaggedAddress = (): AddressItem =>
|
|
179
|
+
makeAddress({
|
|
180
|
+
addressId: "adr-legacy",
|
|
181
|
+
normalizedEmail: "old@legacy.example",
|
|
182
|
+
displayName: "Legacy",
|
|
183
|
+
flags: {
|
|
184
|
+
vip: { value: true, setBy: "web-client" },
|
|
185
|
+
category: { value: "newsletter" },
|
|
186
|
+
} as unknown as AddressItem["flags"],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The shape #1029 was about: mail the reader unsubscribed from and never
|
|
191
|
+
* replied to, landing only in Junk, so sync marks the row `junkOnly`. The
|
|
192
|
+
* autocomplete listing is right to hide it; neither half of the transfer is.
|
|
193
|
+
*/
|
|
194
|
+
export const makeJunkOnlyFlaggedAddress = (): AddressItem =>
|
|
195
|
+
makeAddress({
|
|
196
|
+
addressId: "adr-junk-news",
|
|
197
|
+
normalizedEmail: "sale@junk.example",
|
|
198
|
+
flags: {
|
|
199
|
+
junkOnly: { value: true, setAt: 1750000000000 },
|
|
200
|
+
unsubscribed: { value: true, setAt: 1755000000000 },
|
|
201
|
+
autoArchive: { value: true, setAt: 1755000000000 },
|
|
202
|
+
category: { value: "newsletter", setAt: 1755000000000 },
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The suggest predicate the stored listing applies, spelled in JavaScript: an
|
|
208
|
+
* address is hidden exactly when the machine marked it `junkOnly` and the
|
|
209
|
+
* account has neither corresponded with the sender nor flagged it. Modelled
|
|
210
|
+
* rather than ignored, because a stub that filters nothing cannot tell a read
|
|
211
|
+
* that hides decisions apart from one that does (#1029).
|
|
212
|
+
*/
|
|
213
|
+
export const suggestable = (address: AddressItem): boolean => {
|
|
214
|
+
const flags = address.flags ?? {};
|
|
215
|
+
if (flags.junkOnly?.value !== true) return true;
|
|
216
|
+
const corresponded =
|
|
217
|
+
address.outboundCount + address.replyCount > 0 ||
|
|
218
|
+
flags.vip?.value === true ||
|
|
219
|
+
flags.trusted?.value === true;
|
|
220
|
+
return (
|
|
221
|
+
corresponded || flags.blocked?.value === true || flags.muted?.value === true
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const byAddressId = (rows: AddressItem[]): AddressItem[] =>
|
|
226
|
+
[...rows].sort((left, right) =>
|
|
227
|
+
left.addressId < right.addressId
|
|
228
|
+
? -1
|
|
229
|
+
: left.addressId > right.addressId
|
|
230
|
+
? 1
|
|
231
|
+
: 0,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Both address reads, so a test can hold them against each other: the suggest
|
|
236
|
+
* listing hides a junk-only row, the export's listing carries every row in
|
|
237
|
+
* `addressId` order.
|
|
238
|
+
*/
|
|
239
|
+
export const asAddressRepository = (
|
|
240
|
+
fixture: ConfigFixture,
|
|
241
|
+
): Pick<
|
|
242
|
+
IAddressRepository,
|
|
243
|
+
"listByAccountConfig" | "listAllByAccountConfigPage"
|
|
244
|
+
> => {
|
|
245
|
+
const pageOf = (rows: AddressItem[], cursor: string | undefined) => {
|
|
246
|
+
const size = fixture.addressPageSize ?? rows.length + 1;
|
|
247
|
+
const offset = cursor ? Number(cursor) : 0;
|
|
248
|
+
const next = offset + size;
|
|
249
|
+
return {
|
|
250
|
+
items: rows.slice(offset, next),
|
|
251
|
+
continuationToken: next < rows.length ? String(next) : undefined,
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
listByAccountConfig: async ({ cursor }) =>
|
|
256
|
+
pageOf(fixture.addresses.filter(suggestable), cursor),
|
|
257
|
+
listAllByAccountConfigPage: async ({ cursor }) =>
|
|
258
|
+
pageOf(byAddressId(fixture.addresses), cursor),
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
|
|
171
262
|
/** The fixture, behind the repository interfaces the export takes. */
|
|
172
263
|
export const asRepositories = (
|
|
173
264
|
fixture: ConfigFixture,
|
|
@@ -194,17 +285,5 @@ export const asRepositories = (
|
|
|
194
285
|
filterAnchor: {
|
|
195
286
|
listByAccountConfig: async () => fixture.anchors,
|
|
196
287
|
},
|
|
197
|
-
address:
|
|
198
|
-
listByAccountConfig: async ({ cursor }) => {
|
|
199
|
-
const size = fixture.addressPageSize ?? fixture.addresses.length + 1;
|
|
200
|
-
const offset = cursor ? Number(cursor) : 0;
|
|
201
|
-
const items = fixture.addresses.slice(offset, offset + size);
|
|
202
|
-
const next = offset + size;
|
|
203
|
-
return {
|
|
204
|
-
items,
|
|
205
|
-
continuationToken:
|
|
206
|
-
next < fixture.addresses.length ? String(next) : undefined,
|
|
207
|
-
};
|
|
208
|
-
},
|
|
209
|
-
},
|
|
288
|
+
address: asAddressRepository(fixture),
|
|
210
289
|
});
|
|
@@ -39,9 +39,14 @@ export interface ConfigImportRepositories {
|
|
|
39
39
|
label: Pick<ILabelRepository, "listByAccountConfig" | "create" | "update">;
|
|
40
40
|
filter: Pick<IFilterRepository, "listByAccountConfig" | "create" | "update">;
|
|
41
41
|
filterAnchor: Pick<IFilterAnchorRepository, "put">;
|
|
42
|
+
/**
|
|
43
|
+
* Not the suggest listing, for the reason the export refuses it too: what a
|
|
44
|
+
* configuration already holds is decided over every stored row, junk-only
|
|
45
|
+
* ones included (#1029).
|
|
46
|
+
*/
|
|
42
47
|
address: Pick<
|
|
43
48
|
IAddressRepository,
|
|
44
|
-
"
|
|
49
|
+
"listAllByAccountConfigPage" | "upsertAddress" | "mergeFlags"
|
|
45
50
|
>;
|
|
46
51
|
configImport: Pick<
|
|
47
52
|
IConfigImportRepository,
|
package/src/import.test.ts
CHANGED
|
@@ -28,11 +28,14 @@ import {
|
|
|
28
28
|
makeAddress,
|
|
29
29
|
makeAnchor,
|
|
30
30
|
makeFilter,
|
|
31
|
+
makeJunkOnlyFlaggedAddress,
|
|
31
32
|
makeLabel,
|
|
33
|
+
makeLegacyFlaggedAddress,
|
|
32
34
|
makeMailbox,
|
|
33
35
|
makeSetting,
|
|
34
36
|
OAUTH_ACCOUNT_ID,
|
|
35
37
|
PASSWORD_ACCOUNT_ID,
|
|
38
|
+
suggestable,
|
|
36
39
|
} from "./fixtures.js";
|
|
37
40
|
import { ANCHOR_EMBEDDING_PENDING, importConfig } from "./import.js";
|
|
38
41
|
import type { ConfigImportDeps } from "./import-repositories.js";
|
|
@@ -245,7 +248,13 @@ const repositoriesOf = (store: Store, accountConfigId: string): any => {
|
|
|
245
248
|
listByAccountConfig: async () => [...store.anchors],
|
|
246
249
|
},
|
|
247
250
|
address: {
|
|
251
|
+
// Both reads, so the stub can tell them apart: the suggest listing
|
|
252
|
+
// hides a junk-only row, and the import must not be reading it (#1029).
|
|
248
253
|
listByAccountConfig: async () => ({
|
|
254
|
+
items: store.addresses.filter(suggestable),
|
|
255
|
+
continuationToken: undefined,
|
|
256
|
+
}),
|
|
257
|
+
listAllByAccountConfigPage: async () => ({
|
|
249
258
|
items: [...store.addresses],
|
|
250
259
|
continuationToken: undefined,
|
|
251
260
|
}),
|
|
@@ -524,12 +533,20 @@ const sourceFixture = (): ConfigFixture => ({
|
|
|
524
533
|
],
|
|
525
534
|
});
|
|
526
535
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
536
|
+
/**
|
|
537
|
+
* The same configuration with one sender flagged before `setAt` existed. Held
|
|
538
|
+
* apart from the source fixture rather than folded into it: half the tests here
|
|
539
|
+
* count what the file carries, and a legacy row is a fact about the database
|
|
540
|
+
* this instance happens to hold, not about every configuration.
|
|
541
|
+
*/
|
|
542
|
+
const sourceWithLegacyFlagsFixture = (): ConfigFixture => {
|
|
543
|
+
const fixture = sourceFixture();
|
|
544
|
+
fixture.addresses = [...fixture.addresses, makeLegacyFlaggedAddress()];
|
|
545
|
+
return fixture;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const exportSource = (fixture: ConfigFixture = sourceFixture()) =>
|
|
549
|
+
readConfigForExport(asRepositories(fixture), ACCOUNT_CONFIG_ID, IDENTITY);
|
|
533
550
|
|
|
534
551
|
const apply = (
|
|
535
552
|
store: Store,
|
|
@@ -564,7 +581,7 @@ const discover = (
|
|
|
564
581
|
};
|
|
565
582
|
|
|
566
583
|
test("a configuration survives an export, an import and a discovery unchanged", async () => {
|
|
567
|
-
const document = await exportSource();
|
|
584
|
+
const document = await exportSource(sourceWithLegacyFlagsFixture());
|
|
568
585
|
const store = emptyStore();
|
|
569
586
|
|
|
570
587
|
const report = reportOf(await apply(store, document));
|
|
@@ -685,6 +702,31 @@ test("a configuration that already holds something refuses the default import",
|
|
|
685
702
|
assert.equal(outcome.conflict.details.addressFlags, "2");
|
|
686
703
|
});
|
|
687
704
|
|
|
705
|
+
test("a junk-only sender the user decided about still holds the configuration", async () => {
|
|
706
|
+
const document = await exportSource();
|
|
707
|
+
const store = emptyStore();
|
|
708
|
+
store.addresses.push(makeJunkOnlyFlaggedAddress());
|
|
709
|
+
|
|
710
|
+
assert.equal(
|
|
711
|
+
store.addresses.filter(suggestable).length,
|
|
712
|
+
0,
|
|
713
|
+
"the suggest listing hides it, and is not the read the import may use",
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
const outcome = await importConfig(depsOf(store, ACCOUNT_CONFIG_ID), {
|
|
717
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
718
|
+
userId: TARGET_USER_ID,
|
|
719
|
+
document,
|
|
720
|
+
mode: "apply",
|
|
721
|
+
onExisting: "abort",
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
assert.equal(outcome.outcome, "conflict");
|
|
725
|
+
if (outcome.outcome !== "conflict") throw new Error("unreachable");
|
|
726
|
+
assert.equal(outcome.conflict.code, "config_not_empty");
|
|
727
|
+
assert.equal(outcome.conflict.details.addressFlags, "1");
|
|
728
|
+
});
|
|
729
|
+
|
|
688
730
|
test("merge folds the file in and deletes nothing the file left out", async () => {
|
|
689
731
|
const document = await exportSource();
|
|
690
732
|
const store = emptyStore();
|
|
@@ -770,6 +812,19 @@ test("a flagged sender arrives ahead of its mail, keyed on the email string", as
|
|
|
770
812
|
assert.equal(news?.flags?.unsubscribed?.value, true);
|
|
771
813
|
});
|
|
772
814
|
|
|
815
|
+
test("a flag that arrives at the sentinel is stored at the sentinel", async () => {
|
|
816
|
+
const document = await exportSource(sourceWithLegacyFlagsFixture());
|
|
817
|
+
const store = emptyStore();
|
|
818
|
+
|
|
819
|
+
await apply(store, document);
|
|
820
|
+
|
|
821
|
+
const legacy = store.addresses.find(
|
|
822
|
+
(address) => address.normalizedEmail === "old@legacy.example",
|
|
823
|
+
);
|
|
824
|
+
assert.equal(legacy?.flags?.vip?.setAt, 0);
|
|
825
|
+
assert.equal(legacy?.flags?.category?.setAt, 0);
|
|
826
|
+
});
|
|
827
|
+
|
|
773
828
|
test("a folder no mailbox answers to yet is kept, then bound when discovery finds it", async () => {
|
|
774
829
|
const document = await exportSource();
|
|
775
830
|
const store = emptyStore();
|
package/src/import.ts
CHANGED
|
@@ -118,11 +118,14 @@ const readExisting = async (
|
|
|
118
118
|
]);
|
|
119
119
|
const live = accounts.filter((account) => account.deletedAt === undefined);
|
|
120
120
|
|
|
121
|
+
// Not the suggest listing: it hides an address the machine marked `junkOnly`,
|
|
122
|
+
// so a decision the user made about a junk-only sender would read as absent
|
|
123
|
+
// and be re-created or left out of the conflict count (#1029).
|
|
121
124
|
let flaggedAddresses = 0;
|
|
122
125
|
const addressEmails = new Set<string>();
|
|
123
126
|
let cursor: string | undefined;
|
|
124
127
|
do {
|
|
125
|
-
const page = await repositories.address.
|
|
128
|
+
const page = await repositories.address.listAllByAccountConfigPage({
|
|
126
129
|
accountConfigId,
|
|
127
130
|
cursor,
|
|
128
131
|
});
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,11 @@ export {
|
|
|
5
5
|
type ConfigBinderRepositories,
|
|
6
6
|
pendingImportOf,
|
|
7
7
|
} from "./binder.js";
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
ConfigExportRefusedError,
|
|
10
|
+
carriesUserFlag,
|
|
11
|
+
readConfigForExport,
|
|
12
|
+
} from "./export.js";
|
|
9
13
|
export {
|
|
10
14
|
ANCHOR_EMBEDDING_PENDING,
|
|
11
15
|
type ImportConfigInput,
|
package/src/repositories.ts
CHANGED
|
@@ -25,7 +25,13 @@ export interface ConfigExportRepositories {
|
|
|
25
25
|
label: Pick<ILabelRepository, "listByAccountConfig">;
|
|
26
26
|
filter: Pick<IFilterRepository, "listByAccountConfig">;
|
|
27
27
|
filterAnchor: Pick<IFilterAnchorRepository, "listByAccountConfig">;
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Not the suggest listing: that one hides an address the machine marked
|
|
30
|
+
* `junkOnly` unless the account has corresponded with it, which is right for
|
|
31
|
+
* autocomplete and wrong for a file whose whole job is to keep the reader's
|
|
32
|
+
* decisions (#1029).
|
|
33
|
+
*/
|
|
34
|
+
address: Pick<IAddressRepository, "listAllByAccountConfigPage">;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** Who wrote the file, and where from. Recorded; never authoritative on import. */
|