@remit/config-transfer 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@remit/config-transfer",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "default": "./src/index.ts"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "test:typecheck": "tsgo --noEmit",
15
+ "test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-lines=95 --test 'src/**/*.test.ts'",
16
+ "test": "npm run test:typecheck && npm run test:run"
17
+ },
18
+ "license": "MIT",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/remit-mail/reader.git",
25
+ "directory": "packages/config-transfer"
26
+ },
27
+ "dependencies": {
28
+ "@remit/config-format": "*",
29
+ "@remit/data-ports": "*",
30
+ "@remit/domain-enums": "*"
31
+ }
32
+ }
@@ -0,0 +1,550 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { ReaderConfigDocumentSchema } from "@remit/config-format";
4
+ import type { AccountItem, AddressItem } from "@remit/data-ports";
5
+ import { NotFoundError } from "@remit/data-ports/errors";
6
+ import {
7
+ composeFolderRoleAppointmentLabelName,
8
+ composeFolderRoleAppointmentName,
9
+ } from "@remit/data-ports/folder-role";
10
+ import { readConfigForExport } from "./export.js";
11
+ import {
12
+ ACCOUNT_CONFIG_ID,
13
+ asRepositories,
14
+ type ConfigFixture,
15
+ makeAccount,
16
+ makeAccountConfig,
17
+ makeAddress,
18
+ makeAnchor,
19
+ makeFilter,
20
+ makeLabel,
21
+ makeMailbox,
22
+ makeSetting,
23
+ OAUTH_ACCOUNT_ID,
24
+ PASSWORD_ACCOUNT_ID,
25
+ } from "./fixtures.js";
26
+
27
+ const IDENTITY = {
28
+ app: "reader",
29
+ version: "v0.1.0",
30
+ exportedAt: "2026-08-27T09:15:00+02:00",
31
+ instance: "reader.ischen.nl",
32
+ };
33
+
34
+ const INBOX_ID = "mbx-inbox";
35
+ const SENT_ID = "mbx-sent";
36
+ const INVOICES_ID = "mbx-invoices";
37
+ const LISTS_ID = "mbx-lists";
38
+ const OAUTH_INBOX_ID = "mbx-oauth-inbox";
39
+
40
+ const passwordAccount = (): AccountItem =>
41
+ makeAccount({ accountId: PASSWORD_ACCOUNT_ID });
42
+
43
+ const oauthAccount = (): AccountItem =>
44
+ makeAccount({
45
+ accountId: OAUTH_ACCOUNT_ID,
46
+ email: "work@example.com",
47
+ username: "work@example.com",
48
+ authType: "oauthMicrosoft",
49
+ imapHost: "outlook.office365.com",
50
+ smtpEnabled: false,
51
+ smtpHost: "",
52
+ });
53
+
54
+ const fullFixture = (): ConfigFixture => ({
55
+ accountConfig: makeAccountConfig(),
56
+ accounts: [passwordAccount(), oauthAccount()],
57
+ settings: [
58
+ makeSetting("DefaultComposerFormat", {
59
+ kind: "String",
60
+ value: "markdown",
61
+ }),
62
+ makeSetting(`AccountDisplayName#${PASSWORD_ACCOUNT_ID}`, {
63
+ kind: "String",
64
+ value: "Matthijs van Henten",
65
+ }),
66
+ makeSetting(`AccountComposeLanguages#${PASSWORD_ACCOUNT_ID}`, {
67
+ kind: "StringList",
68
+ value: ["nl-NL", "en-GB"],
69
+ }),
70
+ makeSetting(`AccountSignaturePlainText#${PASSWORD_ACCOUNT_ID}`, {
71
+ kind: "String",
72
+ value: "Matthijs\n",
73
+ }),
74
+ makeSetting(`AccountSignatureHtml#${PASSWORD_ACCOUNT_ID}`, {
75
+ kind: "String",
76
+ value: "<p>Matthijs</p>",
77
+ }),
78
+ makeSetting(`AccountMuted#${OAUTH_ACCOUNT_ID}`, {
79
+ kind: "MutedFlag",
80
+ value: { value: true, setAt: 1750000000000, setBy: "web-client" },
81
+ }),
82
+ // One list for the whole configuration, which is how the registry spells
83
+ // it — INBOX.Werk is on neither account and INBOX is on both.
84
+ makeSetting("PinnedFolders", {
85
+ kind: "StringList",
86
+ value: ["INBOX", "INBOX.Facturen", "INBOX.Werk"],
87
+ }),
88
+ makeSetting(`MailboxDisplayName#${LISTS_ID}`, {
89
+ kind: "String",
90
+ value: "dev/null",
91
+ }),
92
+ makeSetting(`MailboxMuted#${LISTS_ID}`, {
93
+ kind: "MutedFlag",
94
+ value: { value: true, setAt: 1756281600000 },
95
+ }),
96
+ makeSetting(composeFolderRoleAppointmentName(PASSWORD_ACCOUNT_ID, "Sent"), {
97
+ kind: "String",
98
+ value: SENT_ID,
99
+ }),
100
+ makeSetting(
101
+ composeFolderRoleAppointmentLabelName(PASSWORD_ACCOUNT_ID, "Sent"),
102
+ { kind: "String", value: "INBOX.Sent" },
103
+ ),
104
+ // A tombstone row, a configuration-wide setting this export does not
105
+ // carry, and a recorded path for a role nobody appointed: all three are
106
+ // passed over rather than refused.
107
+ makeSetting("MailboxRole#mbx-gone", { kind: "String", value: "Archive" }),
108
+ makeSetting("Theme", { kind: "String", value: "dark" }),
109
+ makeSetting(
110
+ composeFolderRoleAppointmentLabelName(PASSWORD_ACCOUNT_ID, "Archive"),
111
+ { kind: "String", value: "INBOX.Archief" },
112
+ ),
113
+ ],
114
+ mailboxes: [
115
+ makeMailbox({
116
+ mailboxId: INBOX_ID,
117
+ accountId: PASSWORD_ACCOUNT_ID,
118
+ fullPath: "INBOX",
119
+ }),
120
+ makeMailbox({
121
+ mailboxId: SENT_ID,
122
+ accountId: PASSWORD_ACCOUNT_ID,
123
+ fullPath: "INBOX.Sent",
124
+ }),
125
+ makeMailbox({
126
+ mailboxId: INVOICES_ID,
127
+ accountId: PASSWORD_ACCOUNT_ID,
128
+ fullPath: "INBOX.Facturen",
129
+ }),
130
+ makeMailbox({
131
+ mailboxId: LISTS_ID,
132
+ accountId: PASSWORD_ACCOUNT_ID,
133
+ fullPath: "INBOX.Lists.dev-null",
134
+ }),
135
+ makeMailbox({
136
+ mailboxId: OAUTH_INBOX_ID,
137
+ accountId: OAUTH_ACCOUNT_ID,
138
+ fullPath: "INBOX",
139
+ }),
140
+ ],
141
+ labels: [
142
+ makeLabel({ labelId: "lbl-facturen", name: "Facturen", color: "Blue" }),
143
+ makeLabel({ labelId: "lbl-lezen", name: "Te lezen" }),
144
+ ],
145
+ filters: [
146
+ makeFilter({
147
+ filterId: "flt-invoices",
148
+ name: "Invoices to Facturen",
149
+ matchOperator: "Or",
150
+ literalClauses: [
151
+ { field: "Subject", value: "factuur" },
152
+ { field: "FromDomain", value: "moneybird.com" },
153
+ ],
154
+ actionLabelId: "lbl-facturen",
155
+ actionMailboxId: INVOICES_ID,
156
+ }),
157
+ makeFilter({
158
+ filterId: "flt-semantic",
159
+ name: "Anything like this release note",
160
+ scope: "Temporary",
161
+ expiresAt: "2026-12-31T23:59:59+01:00",
162
+ hasAnchor: true,
163
+ }),
164
+ ],
165
+ anchors: [makeAnchor({ filterId: "flt-semantic" })],
166
+ addresses: [
167
+ makeAddress({
168
+ addressId: "adr-bank",
169
+ normalizedEmail: "post@bank.example",
170
+ displayName: "Bank",
171
+ flags: {
172
+ vip: { value: true, setAt: 1750000000000, setBy: "web-client" },
173
+ wellknown: { value: true, setAt: 1750000000000 },
174
+ },
175
+ }),
176
+ makeAddress({
177
+ addressId: "adr-news",
178
+ normalizedEmail: "noreply@newsletter.example",
179
+ flags: {
180
+ unsubscribed: {
181
+ value: true,
182
+ setAt: 1755000000000,
183
+ expiresAt: 1790000000000,
184
+ reason: "unsubscribe link never worked",
185
+ },
186
+ },
187
+ }),
188
+ ],
189
+ addressPageSize: 1,
190
+ });
191
+
192
+ const exportFixture = (fixture: ConfigFixture) =>
193
+ readConfigForExport(asRepositories(fixture), ACCOUNT_CONFIG_ID, IDENTITY);
194
+
195
+ test("a configuration exports as a document the v1 schema accepts", async () => {
196
+ const document = await exportFixture(fullFixture());
197
+
198
+ assert.equal(ReaderConfigDocumentSchema.safeParse(document).success, true);
199
+ assert.equal(document.kind, "reader.config");
200
+ assert.equal(document.schemaVersion, 1);
201
+ assert.deepEqual(document.provenance, {
202
+ accountConfigId: ACCOUNT_CONFIG_ID,
203
+ instance: "reader.ischen.nl",
204
+ });
205
+ assert.equal(document.accountConfig.name, "Matthijs");
206
+ assert.equal(document.accountConfig.defaultComposerFormat, "markdown");
207
+ });
208
+
209
+ test("no credential field is emitted, for either authentication type", async () => {
210
+ const document = await exportFixture(fullFixture());
211
+
212
+ const forbidden = [
213
+ "password",
214
+ "passwordHash",
215
+ "smtpPassword",
216
+ "smtpPasswordHash",
217
+ "oauthRefreshToken",
218
+ "oauthRefreshTokenHash",
219
+ "oauthTokenUpdatedAt",
220
+ ];
221
+ assert.equal(document.accounts.length, 2);
222
+ for (const account of document.accounts) {
223
+ for (const key of forbidden) {
224
+ // Structural absence, not a blank: `in` fails on an emitted empty string.
225
+ assert.equal(key in account, false, `${key} reached the document`);
226
+ assert.equal(key in account.imap, false);
227
+ assert.equal(key in account.smtp, false);
228
+ assert.equal(key in account.credentials, false);
229
+ }
230
+ }
231
+ });
232
+
233
+ test("each account declares the credential it will need back", async () => {
234
+ const document = await exportFixture(fullFixture());
235
+ const [password, oauth] = document.accounts;
236
+
237
+ assert.deepEqual(password?.credentials, { required: "password" });
238
+ assert.deepEqual(oauth?.credentials, {
239
+ required: "oauth",
240
+ provider: "microsoft",
241
+ });
242
+ });
243
+
244
+ test("a stored credential never survives the read", async () => {
245
+ const fixture = fullFixture();
246
+ fixture.accounts = [
247
+ makeAccount({
248
+ accountId: PASSWORD_ACCOUNT_ID,
249
+ passwordHash: "enc:v1:not-a-real-secret",
250
+ smtpPasswordHash: "enc:v1:not-a-real-secret-either",
251
+ }),
252
+ makeAccount({
253
+ accountId: OAUTH_ACCOUNT_ID,
254
+ email: "work@example.com",
255
+ username: "work@example.com",
256
+ authType: "oauthMicrosoft",
257
+ oauthRefreshTokenHash: "enc:v1:not-a-real-token",
258
+ oauthTokenUpdatedAt: 1750000000,
259
+ }),
260
+ ];
261
+
262
+ const document = await exportFixture(fixture);
263
+
264
+ assert.equal(JSON.stringify(document).includes("not-a-real"), false);
265
+ });
266
+
267
+ test("a deleted account is left behind", async () => {
268
+ const fixture = fullFixture();
269
+ fixture.accounts = [
270
+ passwordAccount(),
271
+ makeAccount({ accountId: OAUTH_ACCOUNT_ID, deletedAt: 1756281600000 }),
272
+ ];
273
+
274
+ const document = await exportFixture(fixture);
275
+
276
+ assert.deepEqual(
277
+ document.accounts.map((account) => account.accountId),
278
+ [PASSWORD_ACCOUNT_ID],
279
+ );
280
+ });
281
+
282
+ test("the account id travels verbatim", async () => {
283
+ const document = await exportFixture(fullFixture());
284
+
285
+ assert.deepEqual(
286
+ document.accounts.map((account) => account.accountId),
287
+ [PASSWORD_ACCOUNT_ID, OAUTH_ACCOUNT_ID],
288
+ );
289
+ });
290
+
291
+ test("per-account settings land on the account that holds them", async () => {
292
+ const document = await exportFixture(fullFixture());
293
+ const [password, oauth] = document.accounts;
294
+
295
+ assert.equal(password?.displayName, "Matthijs van Henten");
296
+ assert.deepEqual(password?.composeLanguages, ["nl-NL", "en-GB"]);
297
+ assert.deepEqual(password?.signature, {
298
+ plainText: "Matthijs\n",
299
+ html: "<p>Matthijs</p>",
300
+ });
301
+ assert.equal(password?.muted, null);
302
+
303
+ assert.equal(oauth?.displayName, "");
304
+ assert.deepEqual(oauth?.composeLanguages, []);
305
+ assert.deepEqual(oauth?.signature, { plainText: "", html: "" });
306
+ assert.equal(oauth?.muted?.value, true);
307
+ });
308
+
309
+ test("a pinned folder lands on each account that holds it, and nowhere else", async () => {
310
+ const document = await exportFixture(fullFixture());
311
+ const [password, oauth] = document.accounts;
312
+
313
+ assert.deepEqual(password?.pinnedFolders, ["INBOX", "INBOX.Facturen"]);
314
+ assert.deepEqual(oauth?.pinnedFolders, ["INBOX"]);
315
+ });
316
+
317
+ test("every folder reference travels as an IMAP path", async () => {
318
+ const document = await exportFixture(fullFixture());
319
+ const [password] = document.accounts;
320
+
321
+ assert.deepEqual(password?.folderRoles, [
322
+ { role: "Sent", folderPath: "INBOX.Sent" },
323
+ ]);
324
+ assert.deepEqual(password?.folderOverrides, [
325
+ {
326
+ folderPath: "INBOX.Lists.dev-null",
327
+ displayName: "dev/null",
328
+ muted: { value: true, setAt: 1756281600000 },
329
+ },
330
+ ]);
331
+ assert.deepEqual(document.filters[0]?.actionFolder, {
332
+ accountId: PASSWORD_ACCOUNT_ID,
333
+ folderPath: "INBOX.Facturen",
334
+ });
335
+ });
336
+
337
+ test("a role whose folder is gone falls back to the path it was appointed on", async () => {
338
+ const fixture = fullFixture();
339
+ fixture.mailboxes = fixture.mailboxes.filter(
340
+ (mailbox) => mailbox.mailboxId !== SENT_ID,
341
+ );
342
+
343
+ const document = await exportFixture(fixture);
344
+
345
+ assert.deepEqual(document.accounts[0]?.folderRoles, [
346
+ { role: "Sent", folderPath: "INBOX.Sent" },
347
+ ]);
348
+ });
349
+
350
+ test("a role naming a folder nothing can name is dropped", async () => {
351
+ const fixture = fullFixture();
352
+ fixture.mailboxes = fixture.mailboxes.filter(
353
+ (mailbox) => mailbox.mailboxId !== SENT_ID,
354
+ );
355
+ fixture.settings = fixture.settings.filter(
356
+ (setting) =>
357
+ setting.name !==
358
+ composeFolderRoleAppointmentLabelName(PASSWORD_ACCOUNT_ID, "Sent"),
359
+ );
360
+
361
+ const document = await exportFixture(fixture);
362
+
363
+ assert.deepEqual(document.accounts[0]?.folderRoles, []);
364
+ });
365
+
366
+ test("an override on a folder that no longer exists is dropped", async () => {
367
+ const fixture = fullFixture();
368
+ fixture.mailboxes = fixture.mailboxes.filter(
369
+ (mailbox) => mailbox.mailboxId !== LISTS_ID,
370
+ );
371
+
372
+ const document = await exportFixture(fixture);
373
+
374
+ assert.deepEqual(document.accounts[0]?.folderOverrides, []);
375
+ });
376
+
377
+ test("a filter names its label and its folder, never their ids", async () => {
378
+ const document = await exportFixture(fullFixture());
379
+ const [invoices, semantic] = document.filters;
380
+
381
+ assert.equal(invoices?.actionLabelName, "Facturen");
382
+ assert.equal(invoices?.matchOperator, "Or");
383
+ assert.deepEqual(invoices?.literalClauses, [
384
+ { field: "Subject", value: "factuur" },
385
+ { field: "FromDomain", value: "moneybird.com" },
386
+ ]);
387
+ assert.equal(invoices?.expiresAt, null);
388
+ assert.equal(invoices?.anchor, null);
389
+
390
+ assert.equal(semantic?.actionLabelName, null);
391
+ assert.equal(semantic?.actionFolder, null);
392
+ assert.equal(semantic?.expiresAt, "2026-12-31T23:59:59+01:00");
393
+ });
394
+
395
+ test("a filter action naming something that is gone exports as no action", async () => {
396
+ const fixture = fullFixture();
397
+ fixture.labels = [];
398
+ fixture.mailboxes = fixture.mailboxes.filter(
399
+ (mailbox) => mailbox.mailboxId !== INVOICES_ID,
400
+ );
401
+
402
+ const document = await exportFixture(fixture);
403
+
404
+ assert.equal(document.filters[0]?.actionLabelName, null);
405
+ assert.equal(document.filters[0]?.actionFolder, null);
406
+ });
407
+
408
+ test("an anchor exports its text and its provenance, never its vector", async () => {
409
+ const document = await exportFixture(fullFixture());
410
+
411
+ assert.deepEqual(document.filters[1]?.anchor, {
412
+ sourceText: "the release note this filter was drawn from",
413
+ embeddingId: "amazon.titan-embed-text-v2:0@1024",
414
+ sourceMessageId: "2c9e4b117d5a4f609a315b8c0",
415
+ });
416
+ assert.equal(JSON.stringify(document).includes("anchorEmbedding"), false);
417
+ assert.equal(JSON.stringify(document).includes("0.22"), false);
418
+ });
419
+
420
+ test("a filter claiming an anchor it has no row for exports without one", async () => {
421
+ // hasAnchor is a cache of whether the sibling row exists, and a write that
422
+ // failed after the filter landed leaves it saying yes over nothing. The
423
+ // export answers with the rows it can read, never with the flag.
424
+ const fixture = fullFixture();
425
+ fixture.anchors = [];
426
+
427
+ const document = await exportFixture(fixture);
428
+
429
+ assert.equal(document.filters[1]?.anchor, null);
430
+ assert.equal(ReaderConfigDocumentSchema.safeParse(document).success, true);
431
+ });
432
+
433
+ test("labels export by name and colour", async () => {
434
+ const document = await exportFixture(fullFixture());
435
+
436
+ assert.deepEqual(document.labels, [
437
+ { name: "Facturen", color: "Blue" },
438
+ { name: "Te lezen", color: "Default" },
439
+ ]);
440
+ });
441
+
442
+ test("an address exports only when the user decided something about it", async () => {
443
+ const fixture = fullFixture();
444
+ const derivedOnly: AddressItem[] = [
445
+ makeAddress({ addressId: "adr-bare", normalizedEmail: "bare@example.com" }),
446
+ makeAddress({
447
+ addressId: "adr-wellknown",
448
+ normalizedEmail: "known@example.com",
449
+ flags: { wellknown: { value: true, setAt: 1750000000000 } },
450
+ }),
451
+ makeAddress({
452
+ addressId: "adr-junk",
453
+ normalizedEmail: "junk@example.com",
454
+ flags: { junkOnly: { value: true, setAt: 1750000000000 } },
455
+ }),
456
+ makeAddress({
457
+ addressId: "adr-both",
458
+ normalizedEmail: "both@example.com",
459
+ flags: {
460
+ wellknown: { value: true, setAt: 1750000000000 },
461
+ junkOnly: { value: true, setAt: 1750000000000 },
462
+ },
463
+ }),
464
+ ];
465
+ fixture.addresses = [...derivedOnly, ...fixture.addresses];
466
+
467
+ const document = await exportFixture(fixture);
468
+
469
+ assert.deepEqual(
470
+ document.addressFlags.map((entry) => entry.normalizedEmail),
471
+ ["post@bank.example", "noreply@newsletter.example"],
472
+ );
473
+ });
474
+
475
+ test("an exported address carries its whole flag payload, and no derived flag", async () => {
476
+ const document = await exportFixture(fullFixture());
477
+ const [bank, newsletter] = document.addressFlags;
478
+
479
+ assert.equal(bank?.displayName, "Bank");
480
+ assert.deepEqual(bank?.flags, {
481
+ vip: { value: true, setAt: 1750000000000, setBy: "web-client" },
482
+ });
483
+ assert.equal("wellknown" in (bank?.flags ?? {}), false);
484
+
485
+ assert.equal(newsletter?.displayName, "");
486
+ assert.deepEqual(newsletter?.flags.unsubscribed, {
487
+ value: true,
488
+ setAt: 1755000000000,
489
+ expiresAt: 1790000000000,
490
+ reason: "unsubscribe link never worked",
491
+ });
492
+ });
493
+
494
+ test("a configuration that holds no row at all still exports a valid document", async () => {
495
+ const repositories = asRepositories(fullFixture());
496
+ const document = await readConfigForExport(
497
+ {
498
+ ...repositories,
499
+ accountConfig: {
500
+ get: async () => {
501
+ throw new NotFoundError("accountConfig not found");
502
+ },
503
+ },
504
+ },
505
+ ACCOUNT_CONFIG_ID,
506
+ IDENTITY,
507
+ );
508
+
509
+ assert.equal(ReaderConfigDocumentSchema.safeParse(document).success, true);
510
+ assert.equal(document.accountConfig.name, "");
511
+ });
512
+
513
+ test("a read failure that is not a missing row propagates", async () => {
514
+ const repositories = asRepositories(fullFixture());
515
+
516
+ await assert.rejects(
517
+ readConfigForExport(
518
+ {
519
+ ...repositories,
520
+ accountConfig: {
521
+ get: async () => {
522
+ throw new Error("the database is gone");
523
+ },
524
+ },
525
+ },
526
+ ACCOUNT_CONFIG_ID,
527
+ IDENTITY,
528
+ ),
529
+ /the database is gone/,
530
+ );
531
+ });
532
+
533
+ test("a configuration with nothing in it still exports a valid document", async () => {
534
+ const document = await exportFixture({
535
+ accountConfig: makeAccountConfig({ name: undefined }),
536
+ accounts: [],
537
+ settings: [],
538
+ mailboxes: [],
539
+ labels: [],
540
+ filters: [],
541
+ anchors: [],
542
+ addresses: [],
543
+ });
544
+
545
+ assert.equal(ReaderConfigDocumentSchema.safeParse(document).success, true);
546
+ assert.equal(document.accountConfig.name, "");
547
+ assert.equal(document.accountConfig.defaultComposerFormat, undefined);
548
+ assert.deepEqual(document.accounts, []);
549
+ assert.deepEqual(document.addressFlags, []);
550
+ });