@remit/config-transfer 0.0.9 → 0.0.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/config-transfer",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/binder.ts CHANGED
@@ -1,17 +1,24 @@
1
1
  import { readConfigDocument } from "@remit/config-format";
2
- import type {
3
- ConfigImportItem,
4
- ConfigImportUnresolvedRefItem,
5
- IAccountSettingRepository,
6
- IConfigImportRepository,
7
- IFilterRepository,
8
- IMailboxRepository,
2
+ import {
3
+ type ConfigImportItem,
4
+ type ConfigImportUnresolvedRefItem,
5
+ type CreateMailboxResult,
6
+ FILTER_NO_ACTION,
7
+ type FilterItem,
8
+ type IAccountSettingRepository,
9
+ type IConfigImportRepository,
10
+ type IFilterRepository,
11
+ type IMailboxRepository,
12
+ type MailboxItem,
9
13
  } from "@remit/data-ports";
10
14
  import { composeSettingName } from "@remit/data-ports/account-settings";
11
15
  import {
12
16
  AccountSettingName,
13
17
  ConfigImportRefKind,
14
18
  ConfigImportState,
19
+ FilterDisabledReason,
20
+ FilterState,
21
+ MailboxSyncStatus,
15
22
  } from "@remit/domain-enums";
16
23
  import type { AppointFolderRole } from "./import-repositories.js";
17
24
 
@@ -19,12 +26,18 @@ export interface ConfigBinderRepositories {
19
26
  configImport: Pick<IConfigImportRepository, "listByAccountConfig" | "update">;
20
27
  accountSetting: Pick<IAccountSettingRepository, "upsert">;
21
28
  filter: Pick<IFilterRepository, "listByAccountConfig" | "update">;
22
- mailbox: Pick<IMailboxRepository, "listAllByAccount">;
29
+ mailbox: Pick<IMailboxRepository, "listAllByAccount" | "resolveAccountId">;
23
30
  }
24
31
 
32
+ export type CreateImportedFolder = (
33
+ accountId: string,
34
+ folderPath: string,
35
+ ) => Promise<CreateMailboxResult>;
36
+
25
37
  export interface ConfigBinderDeps {
26
38
  repositories: ConfigBinderRepositories;
27
39
  appointFolderRole: AppointFolderRole;
40
+ createFolder: CreateImportedFolder;
28
41
  now?: () => number;
29
42
  }
30
43
 
@@ -35,27 +48,34 @@ export interface ConfigBinderDeps {
35
48
  export interface BindResult {
36
49
  bound: number;
37
50
  dropped: number;
51
+ created: number;
52
+ disabled: number;
38
53
  stillPending: number;
39
54
  }
40
55
 
41
56
  type RefState =
42
57
  | { kind: "TargetGone" }
43
- | { kind: "Waiting" }
44
- | { kind: "Ready"; mailboxId: string };
58
+ | { kind: "Waiting"; mailboxId: string }
59
+ | { kind: "Ready"; mailboxId: string }
60
+ | { kind: "Absent" }
61
+ | { kind: "Refused"; reason: FilterItem["disabledReason"] };
45
62
 
46
63
  /**
47
64
  * Bind the folder references an import could not resolve, now that discovery
48
65
  * has produced this account's mailboxes.
49
66
  *
50
67
  * Every reference names a folder by IMAP path, because the ids are new on every
51
- * discovery. This runs where discovery ends, resolves what it can, writes the
52
- * row each reference belongs to, and drops it. An import whose last reference
53
- * is gone is Complete; the rest stay, and surface on `GET /config` until the
54
- * folder they name shows up or the person removes the expectation.
68
+ * discovery. A path discovery did not produce is a folder the account lacks,
69
+ * so the binder creates it through the folder-create mutation and waits for
70
+ * the server to confirm it (imap-mutations R1, R2: wait), binding to that row
71
+ * by id because the server may have normalized its path. A create the server
72
+ * refused, or a created folder that vanished, drops the reference and turns
73
+ * its filter off with the reason.
55
74
  *
56
75
  * Idempotent and replayable: every write is an upsert or an update onto a row
57
- * the import already created, so a second discovery finds nothing to do rather
58
- * than doing it again differently.
76
+ * the import already created, and a create is recorded on the reference before
77
+ * the next run, so a second discovery finds nothing to do rather than doing it
78
+ * again differently.
59
79
  */
60
80
  export const bindImportedFolders = async (
61
81
  deps: ConfigBinderDeps,
@@ -64,48 +84,108 @@ export const bindImportedFolders = async (
64
84
  ): Promise<BindResult> => {
65
85
  const { repositories } = deps;
66
86
  const now = deps.now ?? Date.now;
87
+ const result: BindResult = {
88
+ bound: 0,
89
+ dropped: 0,
90
+ created: 0,
91
+ disabled: 0,
92
+ stillPending: 0,
93
+ };
67
94
 
68
95
  const imports = (
69
96
  await repositories.configImport.listByAccountConfig(accountConfigId)
70
97
  ).filter((row) => row.state === ConfigImportState.Pending);
71
- if (imports.length === 0) return { bound: 0, dropped: 0, stillPending: 0 };
98
+ if (imports.length === 0) return result;
72
99
 
100
+ const mailboxes = await repositories.mailbox.listAllByAccount(accountId);
73
101
  const byPath = new Map(
74
- (await repositories.mailbox.listAllByAccount(accountId)).map(
75
- (mailbox) => [mailbox.fullPath, mailbox.mailboxId] as const,
76
- ),
102
+ mailboxes.map((mailbox) => [mailbox.fullPath, mailbox] as const),
103
+ );
104
+ const byId = new Map(
105
+ mailboxes.map((mailbox) => [mailbox.mailboxId, mailbox] as const),
77
106
  );
78
107
 
79
- const filterIds = new Set(
108
+ const filtersById = new Map(
80
109
  (await repositories.filter.listByAccountConfig(accountConfigId)).map(
81
- (filter) => filter.filterId,
110
+ (filter) => [filter.filterId, filter] as const,
82
111
  ),
83
112
  );
84
113
 
85
- let bound = 0;
86
- let dropped = 0;
87
- let stillPending = 0;
114
+ const createdByPath = new Map<string, string>();
115
+ const createFolder = async (folderPath: string): Promise<string> => {
116
+ const held = createdByPath.get(folderPath);
117
+ if (held !== undefined) return held;
118
+ const created = await deps.createFolder(accountId, folderPath);
119
+ if (created.outcome === "PathTaken") return FILTER_NO_ACTION;
120
+ createdByPath.set(folderPath, created.mailbox.mailboxId);
121
+ result.created++;
122
+ return created.mailbox.mailboxId;
123
+ };
88
124
 
89
125
  for (const row of imports) {
90
126
  const document = readConfigDocument(row.document);
91
127
  const remaining: ConfigImportUnresolvedRefItem[] = [];
128
+ let changed = false;
92
129
 
93
130
  for (const ref of row.unresolvedRefs) {
94
- const state = refStateOf(ref, accountId, byPath, filterIds);
131
+ const state = refStateOf(ref, accountId, byPath, byId, filtersById);
132
+ if (
133
+ (state.kind === "Ready" || state.kind === "Refused") &&
134
+ !movesIntoNothing(ref, filtersById)
135
+ ) {
136
+ await settlePickedFolder(
137
+ repositories,
138
+ accountConfigId,
139
+ filtersById.get(ref.target),
140
+ );
141
+ result.dropped++;
142
+ changed = true;
143
+ continue;
144
+ }
95
145
  if (state.kind === "TargetGone") {
96
- dropped++;
146
+ result.dropped++;
147
+ changed = true;
97
148
  continue;
98
149
  }
99
150
  if (state.kind === "Waiting") {
100
- remaining.push(ref);
151
+ if (state.mailboxId !== ref.mailboxId) changed = true;
152
+ remaining.push({ ...ref, mailboxId: state.mailboxId });
101
153
  continue;
102
154
  }
103
- await bindRef(deps, accountConfigId, ref, state.mailboxId, document);
104
- bound++;
155
+ if (state.kind === "Absent") {
156
+ remaining.push({
157
+ ...ref,
158
+ mailboxId: await createFolder(ref.folderPath),
159
+ });
160
+ changed = true;
161
+ continue;
162
+ }
163
+ if (state.kind === "Refused") {
164
+ if (ref.kind === ConfigImportRefKind.FilterAction) {
165
+ await repositories.filter.update(accountConfigId, ref.target, {
166
+ state: FilterState.Disabled,
167
+ disabledReason: state.reason,
168
+ });
169
+ result.disabled++;
170
+ }
171
+ result.dropped++;
172
+ changed = true;
173
+ continue;
174
+ }
175
+ await bindRef(
176
+ deps,
177
+ accountConfigId,
178
+ ref,
179
+ state.mailboxId,
180
+ document,
181
+ filtersById,
182
+ );
183
+ result.bound++;
184
+ changed = true;
105
185
  }
106
186
 
107
- stillPending += remaining.length;
108
- if (remaining.length === row.unresolvedRefs.length) continue;
187
+ result.stillPending += remaining.length;
188
+ if (!changed) continue;
109
189
 
110
190
  await repositories.configImport.update(row.importId, {
111
191
  unresolvedRefs: remaining,
@@ -115,27 +195,116 @@ export const bindImportedFolders = async (
115
195
  });
116
196
  }
117
197
 
118
- return { bound, dropped, stillPending };
198
+ return result;
199
+ };
200
+
201
+ export const disableFiltersMissingFolders = async (
202
+ repositories: {
203
+ filter: Pick<IFilterRepository, "listByAccountConfig" | "update">;
204
+ mailbox: Pick<IMailboxRepository, "resolveAccountId">;
205
+ },
206
+ accountConfigId: string,
207
+ ): Promise<number> => {
208
+ const filters =
209
+ await repositories.filter.listByAccountConfig(accountConfigId);
210
+ let disabled = 0;
211
+ for (const filter of filters) {
212
+ if (filter.state !== FilterState.Active) continue;
213
+ if (filter.actionMailboxId === FILTER_NO_ACTION) continue;
214
+ const owner = await repositories.mailbox.resolveAccountId(
215
+ filter.actionMailboxId,
216
+ );
217
+ if (owner !== null) continue;
218
+ await repositories.filter.update(accountConfigId, filter.filterId, {
219
+ state: FilterState.Disabled,
220
+ disabledReason: FilterDisabledReason.FolderMissing,
221
+ });
222
+ disabled++;
223
+ }
224
+ return disabled;
119
225
  };
120
226
 
121
227
  type BoundDocument = ReturnType<typeof readConfigDocument>;
122
228
 
229
+ const FOLDERLESS_REASONS: ReadonlySet<FilterItem["disabledReason"]> = new Set([
230
+ FilterDisabledReason.AwaitingFolder,
231
+ FilterDisabledReason.FolderCreateFailed,
232
+ ]);
233
+
234
+ const settlePickedFolder = async (
235
+ repositories: ConfigBinderRepositories,
236
+ accountConfigId: string,
237
+ filter: FilterItem | undefined,
238
+ ): Promise<void> => {
239
+ if (!filter || filter.state !== FilterState.Disabled) return;
240
+ if (!FOLDERLESS_REASONS.has(filter.disabledReason)) return;
241
+ await repositories.filter.update(accountConfigId, filter.filterId, {
242
+ disabledReason: FilterDisabledReason.UserDisabled,
243
+ });
244
+ };
245
+
246
+ const movesIntoNothing = (
247
+ ref: ConfigImportUnresolvedRefItem,
248
+ filtersById: ReadonlyMap<string, FilterItem>,
249
+ ): boolean =>
250
+ ref.kind !== ConfigImportRefKind.FilterAction ||
251
+ filtersById.get(ref.target)?.actionMailboxId === FILTER_NO_ACTION;
252
+
253
+ const findByPath = (
254
+ folderPath: string,
255
+ byPath: ReadonlyMap<string, MailboxItem>,
256
+ ): MailboxItem | undefined => {
257
+ const exact = byPath.get(folderPath);
258
+ if (exact) return exact;
259
+ const prefix = byPath.get("INBOX")?.namespacePrefix ?? "";
260
+ if (prefix === "" || folderPath.startsWith(prefix)) return undefined;
261
+ return byPath.get(`${prefix}${folderPath}`);
262
+ };
263
+
123
264
  const refStateOf = (
124
265
  ref: ConfigImportUnresolvedRefItem,
125
266
  accountId: string,
126
- byPath: ReadonlyMap<string, string>,
127
- filterIds: ReadonlySet<string>,
267
+ byPath: ReadonlyMap<string, MailboxItem>,
268
+ byId: ReadonlyMap<string, MailboxItem>,
269
+ filtersById: ReadonlyMap<string, FilterItem>,
128
270
  ): RefState => {
129
271
  if (
130
272
  ref.kind === ConfigImportRefKind.FilterAction &&
131
- !filterIds.has(ref.target)
273
+ !filtersById.has(ref.target)
132
274
  ) {
133
275
  return { kind: "TargetGone" };
134
276
  }
135
- const mailboxId =
136
- ref.accountId === accountId ? byPath.get(ref.folderPath) : undefined;
137
- if (mailboxId === undefined) return { kind: "Waiting" };
138
- return { kind: "Ready", mailboxId };
277
+ if (ref.accountId !== accountId) {
278
+ return { kind: "Waiting", mailboxId: ref.mailboxId };
279
+ }
280
+ const created = byId.get(ref.mailboxId);
281
+ if (created) {
282
+ if (created.syncStatus === MailboxSyncStatus.synced) {
283
+ return { kind: "Ready", mailboxId: created.mailboxId };
284
+ }
285
+ if (
286
+ created.syncStatus === MailboxSyncStatus.failed &&
287
+ created.pendingPath === undefined
288
+ ) {
289
+ return {
290
+ kind: "Refused",
291
+ reason: FilterDisabledReason.FolderCreateFailed,
292
+ };
293
+ }
294
+ return { kind: "Waiting", mailboxId: created.mailboxId };
295
+ }
296
+ const found = findByPath(ref.folderPath, byPath);
297
+ if (found?.syncStatus === MailboxSyncStatus.synced) {
298
+ return { kind: "Ready", mailboxId: found.mailboxId };
299
+ }
300
+ if (found) return { kind: "Waiting", mailboxId: FILTER_NO_ACTION };
301
+ if (ref.mailboxId !== FILTER_NO_ACTION) {
302
+ return {
303
+ kind: "Refused",
304
+ reason: FilterDisabledReason.FolderCreateFailed,
305
+ };
306
+ }
307
+ return { kind: "Absent" };
139
308
  };
140
309
 
141
310
  const bindRef = async (
@@ -144,11 +313,21 @@ const bindRef = async (
144
313
  ref: ConfigImportUnresolvedRefItem,
145
314
  mailboxId: string,
146
315
  document: BoundDocument,
316
+ filtersById: ReadonlyMap<string, FilterItem>,
147
317
  ): Promise<void> => {
148
318
  const { repositories } = deps;
149
319
  if (ref.kind === ConfigImportRefKind.FilterAction) {
320
+ const awaiting =
321
+ filtersById.get(ref.target)?.disabledReason ===
322
+ FilterDisabledReason.AwaitingFolder;
150
323
  await repositories.filter.update(accountConfigId, ref.target, {
151
324
  actionMailboxId: mailboxId,
325
+ ...(awaiting
326
+ ? {
327
+ state: FilterState.Active,
328
+ disabledReason: FilterDisabledReason.None,
329
+ }
330
+ : {}),
152
331
  });
153
332
  return;
154
333
  }
package/src/fixtures.ts CHANGED
@@ -79,6 +79,7 @@ export const makeMailbox = (
79
79
  Pick<MailboxItem, "mailboxId" | "accountId" | "fullPath">,
80
80
  ): MailboxItem => ({
81
81
  namespaceType: "personal",
82
+ syncFailureReason: "",
82
83
  namespacePrefix: "",
83
84
  hierarchyDelimiter: ".",
84
85
  uidValidity: 1,
@@ -128,6 +129,7 @@ export const makeFilter = (
128
129
  accountConfigId: ACCOUNT_CONFIG_ID,
129
130
  scope: "Standing",
130
131
  state: "Active",
132
+ disabledReason: "None",
131
133
  hasAnchor: false,
132
134
  ruleChangedAt: NOW,
133
135
  actionChangedAt: NOW,
@@ -17,7 +17,11 @@ import {
17
17
  composeFolderRoleAppointmentName,
18
18
  } from "@remit/data-ports/folder-role";
19
19
  import { deriveAddressId } from "@remit/data-ports/id";
20
- import { bindImportedFolders, pendingImportOf } from "./binder.js";
20
+ import {
21
+ bindImportedFolders,
22
+ disableFiltersMissingFolders,
23
+ pendingImportOf,
24
+ } from "./binder.js";
21
25
  import { readConfigForExport } from "./export.js";
22
26
  import {
23
27
  ACCOUNT_CONFIG_ID,
@@ -185,6 +189,9 @@ const repositoriesOf = (store: Store, accountConfigId: string): any => {
185
189
  mailbox: {
186
190
  listAllByAccount: async (accountId: string) =>
187
191
  store.mailboxes.filter((mailbox) => mailbox.accountId === accountId),
192
+ resolveAccountId: async (mailboxId: string) =>
193
+ store.mailboxes.find((mailbox) => mailbox.mailboxId === mailboxId)
194
+ ?.accountId ?? null,
188
195
  },
189
196
  label: {
190
197
  listByAccountConfig: async () => [...store.labels],
@@ -353,6 +360,38 @@ const appointFolderRoleInto =
353
360
  );
354
361
  };
355
362
 
363
+ const createFolderInto =
364
+ (store: Store) =>
365
+ async (
366
+ accountId: string,
367
+ fullPath: string,
368
+ ): Promise<{ outcome: "Created"; mailbox: MailboxItem }> => {
369
+ const row = makeMailbox({
370
+ mailboxId: nextId("mbx"),
371
+ accountId,
372
+ fullPath,
373
+ syncStatus: "pending",
374
+ });
375
+ store.mailboxes.push(row);
376
+ return { outcome: "Created", mailbox: row };
377
+ };
378
+
379
+ const settle = (
380
+ store: Store,
381
+ fullPath: string,
382
+ syncStatus: MailboxItem["syncStatus"],
383
+ confirmedPath: string = fullPath,
384
+ ): MailboxItem => {
385
+ const at = store.mailboxes.findIndex(
386
+ (mailbox) =>
387
+ mailbox.fullPath === fullPath && mailbox.syncStatus === "pending",
388
+ );
389
+ assert.notEqual(at, -1, `no pending create for ${fullPath}`);
390
+ const next = { ...store.mailboxes[at], syncStatus, fullPath: confirmedPath };
391
+ store.mailboxes[at] = next;
392
+ return next;
393
+ };
394
+
356
395
  /**
357
396
  * A transaction that actually rolls back. The backend's `writeSet` discards
358
397
  * every write on a throw, so a fake that merely called through would let an
@@ -602,6 +641,7 @@ test("a configuration survives an export, an import and a discovery unchanged",
602
641
  const binder = {
603
642
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
604
643
  appointFolderRole: appointFolderRoleInto(store),
644
+ createFolder: createFolderInto(store),
605
645
  now: () => NOW,
606
646
  };
607
647
  await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
@@ -828,7 +868,7 @@ test("a flag that arrives at the sentinel is stored at the sentinel", async () =
828
868
  assert.equal(legacy?.flags?.category?.setAt, 0);
829
869
  });
830
870
 
831
- test("a folder no mailbox answers to yet is kept, then bound when discovery finds it", async () => {
871
+ test("a folder discovery did not produce is created, and bound once the server confirms it", async () => {
832
872
  const document = await exportSource();
833
873
  const store = emptyStore();
834
874
 
@@ -849,6 +889,7 @@ test("a folder no mailbox answers to yet is kept, then bound when discovery find
849
889
  const binder = {
850
890
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
851
891
  appointFolderRole: appointFolderRoleInto(store),
892
+ createFolder: createFolderInto(store),
852
893
  now: () => NOW,
853
894
  };
854
895
  const first = await bindImportedFolders(
@@ -858,6 +899,7 @@ test("a folder no mailbox answers to yet is kept, then bound when discovery find
858
899
  );
859
900
 
860
901
  assert.equal(first.bound > 0, true);
902
+ assert.equal(first.created, 2);
861
903
  assert.equal(first.stillPending > 0, true);
862
904
  const invoices = store.mailboxes.find(
863
905
  (mailbox) => mailbox.fullPath === "INBOX.Facturen",
@@ -868,16 +910,28 @@ test("a folder no mailbox answers to yet is kept, then bound when discovery find
868
910
  invoices?.mailboxId,
869
911
  );
870
912
 
871
- // Replayable: a second discovery over the same folders changes nothing.
913
+ // Replayable: a second discovery over the same folders creates nothing again.
872
914
  const again = await bindImportedFolders(
873
915
  binder,
874
916
  TARGET_CONFIG_ID,
875
917
  PASSWORD_ACCOUNT_ID,
876
918
  );
877
919
  assert.equal(again.bound, 0);
920
+ assert.equal(again.created, 0);
921
+ assert.equal(
922
+ store.mailboxes.filter((mailbox) => mailbox.fullPath === "INBOX.Sent")
923
+ .length,
924
+ 1,
925
+ );
878
926
  assert.equal(store.imports[0].state, "Pending");
879
927
 
880
- discover(store, PASSWORD_ACCOUNT_ID, ["INBOX.Sent", "INBOX.Lists.dev-null"]);
928
+ settle(store, "INBOX.Sent", "synced");
929
+ const lists = settle(
930
+ store,
931
+ "INBOX.Lists.dev-null",
932
+ "synced",
933
+ "INBOX.Lists.dev-null-normalized",
934
+ );
881
935
  discover(store, OAUTH_ACCOUNT_ID, ["INBOX"]);
882
936
  await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
883
937
 
@@ -890,15 +944,499 @@ test("a folder no mailbox answers to yet is kept, then bound when discovery find
890
944
  )?.value.kind,
891
945
  "String",
892
946
  );
893
- const lists = store.mailboxes.find(
894
- (mailbox) => mailbox.fullPath === "INBOX.Lists.dev-null",
895
- );
896
947
  assert.equal(
897
- store.settings.get(`MailboxDisplayName#${lists?.mailboxId}`)?.value.value,
948
+ store.settings.get(`MailboxDisplayName#${lists.mailboxId}`)?.value.value,
898
949
  "dev/null",
899
950
  );
900
951
  });
901
952
 
953
+ test("a filter whose folder is missing lands disabled, awaiting the folder, and the report says so", async () => {
954
+ const document = await exportSource();
955
+ const store = emptyStore();
956
+
957
+ const report = reportOf(await apply(store, document));
958
+
959
+ const invoices = store.filters.find(
960
+ (filter) => filter.name === "Invoices to Facturen",
961
+ );
962
+ assert.equal(invoices?.state, "Disabled");
963
+ assert.equal(invoices?.disabledReason, "AwaitingFolder");
964
+ assert.match(
965
+ report.items.find((item) => item.key === "Invoices to Facturen")?.reason ??
966
+ "",
967
+ /Off until "INBOX\.Facturen" exists/,
968
+ );
969
+ });
970
+
971
+ test("the dry run names the filters an apply would leave off", async () => {
972
+ const document = await exportSource();
973
+ const store = emptyStore();
974
+
975
+ const outcome = await importConfig(depsOf(store, TARGET_CONFIG_ID), {
976
+ accountConfigId: TARGET_CONFIG_ID,
977
+ userId: TARGET_USER_ID,
978
+ document,
979
+ mode: "validate",
980
+ onExisting: "abort",
981
+ });
982
+
983
+ assert.match(
984
+ reportOf(outcome).items.find((item) => item.key === "Invoices to Facturen")
985
+ ?.reason ?? "",
986
+ /Off until "INBOX\.Facturen" exists/,
987
+ );
988
+ });
989
+
990
+ test("an imported filter turns on once the folder the binder created settles, under the path the server chose", async () => {
991
+ const document = await exportSource();
992
+ const store = emptyStore();
993
+ await apply(store, document);
994
+
995
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
996
+ const binder = {
997
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
998
+ appointFolderRole: appointFolderRoleInto(store),
999
+ createFolder: createFolderInto(store),
1000
+ now: () => NOW,
1001
+ };
1002
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1003
+
1004
+ const waiting = store.filters.find(
1005
+ (filter) => filter.name === "Invoices to Facturen",
1006
+ );
1007
+ assert.equal(waiting?.state, "Disabled");
1008
+ assert.equal(waiting?.disabledReason, "AwaitingFolder");
1009
+ assert.equal(waiting?.actionMailboxId, "None");
1010
+
1011
+ const created = settle(store, "INBOX.Facturen", "synced", "INBOX/Facturen");
1012
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1013
+
1014
+ const bound = store.filters.find(
1015
+ (filter) => filter.name === "Invoices to Facturen",
1016
+ );
1017
+ assert.equal(bound?.state, "Active");
1018
+ assert.equal(bound?.disabledReason, "None");
1019
+ assert.equal(bound?.actionMailboxId, created.mailboxId);
1020
+ });
1021
+
1022
+ test("a folder the server refused to create turns its filter off with FolderCreateFailed", async () => {
1023
+ const document = await exportSource();
1024
+ const store = emptyStore();
1025
+ await apply(store, document);
1026
+
1027
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1028
+ const binder = {
1029
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1030
+ appointFolderRole: appointFolderRoleInto(store),
1031
+ createFolder: createFolderInto(store),
1032
+ now: () => NOW,
1033
+ };
1034
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1035
+ settle(store, "INBOX.Facturen", "failed");
1036
+
1037
+ const result = await bindImportedFolders(
1038
+ binder,
1039
+ TARGET_CONFIG_ID,
1040
+ PASSWORD_ACCOUNT_ID,
1041
+ );
1042
+
1043
+ assert.equal(result.disabled, 1);
1044
+ const refused = store.filters.find(
1045
+ (filter) => filter.name === "Invoices to Facturen",
1046
+ );
1047
+ assert.equal(refused?.state, "Disabled");
1048
+ assert.equal(refused?.disabledReason, "FolderCreateFailed");
1049
+ assert.equal(
1050
+ pendingImportOf(store.imports)?.folderPaths.includes("INBOX.Facturen") ??
1051
+ false,
1052
+ false,
1053
+ );
1054
+ });
1055
+
1056
+ test("a created folder that disappears before it settles means the create never landed", async () => {
1057
+ const document = await exportSource();
1058
+ const store = emptyStore();
1059
+ await apply(store, document);
1060
+
1061
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1062
+ const binder = {
1063
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1064
+ appointFolderRole: appointFolderRoleInto(store),
1065
+ createFolder: createFolderInto(store),
1066
+ now: () => NOW,
1067
+ };
1068
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1069
+ store.mailboxes = store.mailboxes.filter(
1070
+ (mailbox) => mailbox.fullPath !== "INBOX.Facturen",
1071
+ );
1072
+
1073
+ const result = await bindImportedFolders(
1074
+ binder,
1075
+ TARGET_CONFIG_ID,
1076
+ PASSWORD_ACCOUNT_ID,
1077
+ );
1078
+
1079
+ assert.equal(result.disabled, 1);
1080
+ const refused = store.filters.find(
1081
+ (filter) => filter.name === "Invoices to Facturen",
1082
+ );
1083
+ assert.equal(refused?.state, "Disabled");
1084
+ assert.equal(refused?.disabledReason, "FolderCreateFailed");
1085
+ });
1086
+
1087
+ test("a create that finds its path already taken binds the existing folder on the next sync", async () => {
1088
+ const document = await exportSource();
1089
+ const store = emptyStore();
1090
+ await apply(store, document);
1091
+
1092
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1093
+ const binder = {
1094
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1095
+ appointFolderRole: appointFolderRoleInto(store),
1096
+ createFolder: async () => ({ outcome: "PathTaken" as const }),
1097
+ now: () => NOW,
1098
+ };
1099
+ const first = await bindImportedFolders(
1100
+ binder,
1101
+ TARGET_CONFIG_ID,
1102
+ PASSWORD_ACCOUNT_ID,
1103
+ );
1104
+
1105
+ assert.equal(first.created, 0);
1106
+ assert.equal(
1107
+ store.imports[0].unresolvedRefs.find(
1108
+ (ref) => ref.folderPath === "INBOX.Facturen",
1109
+ )?.mailboxId,
1110
+ "None",
1111
+ );
1112
+
1113
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX.Facturen"]);
1114
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1115
+
1116
+ const existing = store.mailboxes.find(
1117
+ (mailbox) => mailbox.fullPath === "INBOX.Facturen",
1118
+ );
1119
+ const bound = store.filters.find(
1120
+ (filter) => filter.name === "Invoices to Facturen",
1121
+ );
1122
+ assert.equal(bound?.state, "Active");
1123
+ assert.equal(bound?.actionMailboxId, existing?.mailboxId);
1124
+ });
1125
+
1126
+ const withUnprefixedPaths = async () => {
1127
+ const document = (await exportSource()) as unknown as {
1128
+ accounts: {
1129
+ folderRoles: { role: string; folderPath: string }[];
1130
+ }[];
1131
+ filters: { actionFolder: { folderPath: string } | null }[];
1132
+ };
1133
+ for (const filter of document.filters) {
1134
+ if (filter.actionFolder?.folderPath === "INBOX.Facturen") {
1135
+ filter.actionFolder.folderPath = "Facturen";
1136
+ }
1137
+ }
1138
+ for (const account of document.accounts) {
1139
+ for (const role of account.folderRoles) {
1140
+ if (role.folderPath === "INBOX.Sent") role.folderPath = "Sent";
1141
+ }
1142
+ }
1143
+ return document;
1144
+ };
1145
+
1146
+ const discoverUnderInbox = (store: Store, paths: readonly string[]): void => {
1147
+ for (const fullPath of ["INBOX", ...paths]) {
1148
+ store.mailboxes.push(
1149
+ makeMailbox({
1150
+ mailboxId: nextId("mbx"),
1151
+ accountId: PASSWORD_ACCOUNT_ID,
1152
+ fullPath,
1153
+ namespacePrefix: "INBOX.",
1154
+ }),
1155
+ );
1156
+ }
1157
+ };
1158
+
1159
+ test("a folder the account already holds under its namespace is bound, not created", async () => {
1160
+ const store = emptyStore();
1161
+ await apply(store, await withUnprefixedPaths());
1162
+
1163
+ discoverUnderInbox(store, ["INBOX.Facturen", "INBOX.Sent"]);
1164
+ const result = await bindImportedFolders(
1165
+ {
1166
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1167
+ appointFolderRole: appointFolderRoleInto(store),
1168
+ createFolder: createFolderInto(store),
1169
+ now: () => NOW,
1170
+ },
1171
+ TARGET_CONFIG_ID,
1172
+ PASSWORD_ACCOUNT_ID,
1173
+ );
1174
+
1175
+ const facturen = store.mailboxes.find(
1176
+ (mailbox) => mailbox.fullPath === "INBOX.Facturen",
1177
+ );
1178
+ const sent = store.mailboxes.find(
1179
+ (mailbox) => mailbox.fullPath === "INBOX.Sent",
1180
+ );
1181
+ assert.equal(
1182
+ store.mailboxes.some((mailbox) => mailbox.fullPath === "Facturen"),
1183
+ false,
1184
+ );
1185
+ assert.equal(result.disabled, 0);
1186
+ const bound = store.filters.find(
1187
+ (filter) => filter.name === "Invoices to Facturen",
1188
+ );
1189
+ assert.equal(bound?.state, "Active");
1190
+ assert.equal(bound?.actionMailboxId, facturen?.mailboxId);
1191
+ assert.equal(
1192
+ store.settings.get(
1193
+ composeFolderRoleAppointmentName(PASSWORD_ACCOUNT_ID, "Sent"),
1194
+ )?.value.value,
1195
+ sent?.mailboxId,
1196
+ );
1197
+ });
1198
+
1199
+ test("a created folder the worker folds into one the server already held binds to that one", async () => {
1200
+ const store = emptyStore();
1201
+ await apply(store, await withUnprefixedPaths());
1202
+
1203
+ discoverUnderInbox(store, ["INBOX.Sent"]);
1204
+ const binder = {
1205
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1206
+ appointFolderRole: appointFolderRoleInto(store),
1207
+ createFolder: createFolderInto(store),
1208
+ now: () => NOW,
1209
+ };
1210
+ const first = await bindImportedFolders(
1211
+ binder,
1212
+ TARGET_CONFIG_ID,
1213
+ PASSWORD_ACCOUNT_ID,
1214
+ );
1215
+ assert.equal(first.created > 0, true);
1216
+
1217
+ store.mailboxes = store.mailboxes.filter(
1218
+ (mailbox) => mailbox.fullPath !== "Facturen",
1219
+ );
1220
+ const holder = makeMailbox({
1221
+ mailboxId: nextId("mbx"),
1222
+ accountId: PASSWORD_ACCOUNT_ID,
1223
+ fullPath: "INBOX.Facturen",
1224
+ namespacePrefix: "INBOX.",
1225
+ });
1226
+ store.mailboxes.push(holder);
1227
+
1228
+ const second = await bindImportedFolders(
1229
+ binder,
1230
+ TARGET_CONFIG_ID,
1231
+ PASSWORD_ACCOUNT_ID,
1232
+ );
1233
+
1234
+ assert.equal(second.disabled, 0);
1235
+ const bound = store.filters.find(
1236
+ (filter) => filter.name === "Invoices to Facturen",
1237
+ );
1238
+ assert.equal(bound?.state, "Active");
1239
+ assert.equal(bound?.disabledReason, "None");
1240
+ assert.equal(bound?.actionMailboxId, holder.mailboxId);
1241
+ });
1242
+
1243
+ test("a filter the user pointed at a folder keeps it when the imported folder appears", async () => {
1244
+ const document = await exportSource();
1245
+ const store = emptyStore();
1246
+ await apply(store, document);
1247
+
1248
+ const invoices = store.filters.find(
1249
+ (filter) => filter.name === "Invoices to Facturen",
1250
+ );
1251
+ assert.ok(invoices);
1252
+ invoices.actionMailboxId = "mbx-picked";
1253
+
1254
+ discover(store, PASSWORD_ACCOUNT_ID, [
1255
+ "INBOX",
1256
+ "INBOX.Sent",
1257
+ "INBOX.Facturen",
1258
+ ]);
1259
+ await bindImportedFolders(
1260
+ {
1261
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1262
+ appointFolderRole: appointFolderRoleInto(store),
1263
+ createFolder: createFolderInto(store),
1264
+ now: () => NOW,
1265
+ },
1266
+ TARGET_CONFIG_ID,
1267
+ PASSWORD_ACCOUNT_ID,
1268
+ );
1269
+
1270
+ const picked = store.filters.find(
1271
+ (filter) => filter.name === "Invoices to Facturen",
1272
+ );
1273
+ assert.equal(picked?.actionMailboxId, "mbx-picked");
1274
+ assert.equal(picked?.state, "Disabled");
1275
+ assert.equal(picked?.disabledReason, "UserDisabled");
1276
+ assert.equal(
1277
+ store.imports[0].unresolvedRefs.some(
1278
+ (ref) => ref.target === invoices.filterId,
1279
+ ),
1280
+ false,
1281
+ );
1282
+ });
1283
+
1284
+ test("a filter the user already turned on with their own folder survives the import's failed create", async () => {
1285
+ const document = await exportSource();
1286
+ const store = emptyStore();
1287
+ await apply(store, document);
1288
+
1289
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1290
+ const binder = {
1291
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1292
+ appointFolderRole: appointFolderRoleInto(store),
1293
+ createFolder: createFolderInto(store),
1294
+ now: () => NOW,
1295
+ };
1296
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1297
+
1298
+ const invoices = store.filters.find(
1299
+ (filter) => filter.name === "Invoices to Facturen",
1300
+ );
1301
+ assert.ok(invoices);
1302
+ Object.assign(invoices, {
1303
+ actionMailboxId: "mbx-picked",
1304
+ state: "Active",
1305
+ disabledReason: "None",
1306
+ });
1307
+ settle(store, "INBOX.Facturen", "failed");
1308
+
1309
+ const result = await bindImportedFolders(
1310
+ binder,
1311
+ TARGET_CONFIG_ID,
1312
+ PASSWORD_ACCOUNT_ID,
1313
+ );
1314
+
1315
+ assert.equal(result.disabled, 0);
1316
+ const kept = store.filters.find(
1317
+ (filter) => filter.name === "Invoices to Facturen",
1318
+ );
1319
+ assert.equal(kept?.state, "Active");
1320
+ assert.equal(kept?.actionMailboxId, "mbx-picked");
1321
+ });
1322
+
1323
+ test("a failed rename of the created folder is not read as a failed create", async () => {
1324
+ const document = await exportSource();
1325
+ const store = emptyStore();
1326
+ await apply(store, document);
1327
+
1328
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1329
+ const binder = {
1330
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1331
+ appointFolderRole: appointFolderRoleInto(store),
1332
+ createFolder: createFolderInto(store),
1333
+ now: () => NOW,
1334
+ };
1335
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1336
+ const created = settle(store, "INBOX.Facturen", "failed");
1337
+ Object.assign(created, { pendingPath: "INBOX.Invoices" });
1338
+
1339
+ const result = await bindImportedFolders(
1340
+ binder,
1341
+ TARGET_CONFIG_ID,
1342
+ PASSWORD_ACCOUNT_ID,
1343
+ );
1344
+
1345
+ assert.equal(result.disabled, 0);
1346
+ const waiting = store.filters.find(
1347
+ (filter) => filter.name === "Invoices to Facturen",
1348
+ );
1349
+ assert.equal(waiting?.disabledReason, "AwaitingFolder");
1350
+ assert.equal(
1351
+ store.imports[0].unresolvedRefs.some(
1352
+ (ref) => ref.mailboxId === created.mailboxId,
1353
+ ),
1354
+ true,
1355
+ );
1356
+ });
1357
+
1358
+ test("a folder still pending on the server is waited for, never bound", async () => {
1359
+ const document = await exportSource();
1360
+ const store = emptyStore();
1361
+ await apply(store, document);
1362
+
1363
+ store.mailboxes.push(
1364
+ makeMailbox({
1365
+ mailboxId: nextId("mbx"),
1366
+ accountId: PASSWORD_ACCOUNT_ID,
1367
+ fullPath: "INBOX.Facturen",
1368
+ syncStatus: "pending",
1369
+ }),
1370
+ );
1371
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1372
+ const result = await bindImportedFolders(
1373
+ {
1374
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1375
+ appointFolderRole: appointFolderRoleInto(store),
1376
+ createFolder: createFolderInto(store),
1377
+ now: () => NOW,
1378
+ },
1379
+ TARGET_CONFIG_ID,
1380
+ PASSWORD_ACCOUNT_ID,
1381
+ );
1382
+
1383
+ assert.equal(
1384
+ store.mailboxes.filter((mailbox) => mailbox.fullPath === "INBOX.Facturen")
1385
+ .length,
1386
+ 1,
1387
+ );
1388
+ assert.equal(result.stillPending > 0, true);
1389
+ assert.equal(
1390
+ store.filters.find((filter) => filter.name === "Invoices to Facturen")
1391
+ ?.actionMailboxId,
1392
+ "None",
1393
+ );
1394
+ });
1395
+
1396
+ test("a running filter whose folder another client deleted turns off with FolderMissing", async () => {
1397
+ const store = emptyStore();
1398
+ store.mailboxes.push(
1399
+ makeMailbox({
1400
+ mailboxId: INVOICES_ID,
1401
+ accountId: PASSWORD_ACCOUNT_ID,
1402
+ fullPath: "INBOX.Facturen",
1403
+ }),
1404
+ );
1405
+ store.filters.push(
1406
+ makeFilter({
1407
+ filterId: "flt-live",
1408
+ name: "Live",
1409
+ actionMailboxId: INVOICES_ID,
1410
+ }),
1411
+ makeFilter({
1412
+ filterId: "flt-gone",
1413
+ name: "Gone",
1414
+ actionMailboxId: LISTS_ID,
1415
+ }),
1416
+ makeFilter({
1417
+ filterId: "flt-off",
1418
+ name: "Off",
1419
+ state: "Disabled",
1420
+ disabledReason: "UserDisabled",
1421
+ actionMailboxId: SENT_ID,
1422
+ }),
1423
+ );
1424
+
1425
+ const disabled = await disableFiltersMissingFolders(
1426
+ repositoriesOf(store, TARGET_CONFIG_ID),
1427
+ TARGET_CONFIG_ID,
1428
+ );
1429
+
1430
+ assert.equal(disabled, 1);
1431
+ const byId = new Map(
1432
+ store.filters.map((filter) => [filter.filterId, filter]),
1433
+ );
1434
+ assert.equal(byId.get("flt-live")?.state, "Active");
1435
+ assert.equal(byId.get("flt-gone")?.state, "Disabled");
1436
+ assert.equal(byId.get("flt-gone")?.disabledReason, "FolderMissing");
1437
+ assert.equal(byId.get("flt-off")?.disabledReason, "UserDisabled");
1438
+ });
1439
+
902
1440
  test("an import with no folders to wait for is complete the moment it lands", async () => {
903
1441
  const document = await exportSource();
904
1442
  const store = emptyStore();
@@ -1226,12 +1764,19 @@ test("a binder run on a configuration with no import does nothing", async () =>
1226
1764
  {
1227
1765
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1228
1766
  appointFolderRole: appointFolderRoleInto(store),
1767
+ createFolder: createFolderInto(store),
1229
1768
  now: () => NOW,
1230
1769
  },
1231
1770
  TARGET_CONFIG_ID,
1232
1771
  PASSWORD_ACCOUNT_ID,
1233
1772
  );
1234
- assert.deepEqual(result, { bound: 0, dropped: 0, stillPending: 0 });
1773
+ assert.deepEqual(result, {
1774
+ bound: 0,
1775
+ dropped: 0,
1776
+ created: 0,
1777
+ disabled: 0,
1778
+ stillPending: 0,
1779
+ });
1235
1780
  assert.equal(pendingImportOf([]), undefined);
1236
1781
  });
1237
1782
 
@@ -1258,6 +1803,7 @@ test("a filter deleted before its folder is discovered drops its reference inste
1258
1803
  const binder = {
1259
1804
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1260
1805
  appointFolderRole: appointFolderRoleInto(store),
1806
+ createFolder: createFolderInto(store),
1261
1807
  now: () => NOW,
1262
1808
  };
1263
1809
 
@@ -1282,7 +1828,13 @@ test("a filter deleted before its folder is discovered drops its reference inste
1282
1828
  TARGET_CONFIG_ID,
1283
1829
  PASSWORD_ACCOUNT_ID,
1284
1830
  );
1285
- assert.deepEqual(again, { bound: 0, dropped: 0, stillPending: 0 });
1831
+ assert.deepEqual(again, {
1832
+ bound: 0,
1833
+ dropped: 0,
1834
+ created: 0,
1835
+ disabled: 0,
1836
+ stillPending: 0,
1837
+ });
1286
1838
  });
1287
1839
 
1288
1840
  test("a deleted filter's reference is dropped even when its folder never appears", async () => {
@@ -1303,6 +1855,7 @@ test("a deleted filter's reference is dropped even when its folder never appears
1303
1855
  {
1304
1856
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1305
1857
  appointFolderRole: appointFolderRoleInto(store),
1858
+ createFolder: createFolderInto(store),
1306
1859
  now: () => NOW,
1307
1860
  },
1308
1861
  TARGET_CONFIG_ID,
package/src/import.ts CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  ConfigImportRefKind,
27
27
  ConfigImportState,
28
28
  ConnectionState,
29
+ FilterDisabledReason,
30
+ FilterState,
29
31
  } from "@remit/domain-enums";
30
32
  import { carriesUserFlag } from "./export.js";
31
33
  import type { ConfigImportDeps } from "./import-repositories.js";
@@ -62,6 +64,15 @@ export interface ImportConfigInput {
62
64
 
63
65
  const normalize = (value: string): string => value.trim().toLowerCase();
64
66
 
67
+ const filterFolderPendingMessage = (name: string, folderPath: string): string =>
68
+ `Filter "${name}" files mail into "${folderPath}", which this account does not hold yet. The folder is created on the mail server when the account next syncs, and the filter stays off until the server confirms it.`;
69
+
70
+ const folderPendingMessage = (folderPath: string): string =>
71
+ `"${folderPath}" is not a folder this account holds yet. It is created on the mail server when the account next syncs, and the setting is bound once the server confirms it.`;
72
+
73
+ const awaitingFolderReason = (folderPath: string): string =>
74
+ `Off until "${folderPath}" exists on the mail server.`;
75
+
65
76
  /**
66
77
  * The version a document claims, read straight off the raw input. The parse
67
78
  * failed, so this is the only honest answer the report can give — and a report
@@ -564,6 +575,12 @@ const applyDocument = async (
564
575
  : (labelIdByName.get(normalize(filter.actionLabelName)) ?? NO_ACTION),
565
576
  actionMailboxId: folder.actionMailboxId,
566
577
  hasAnchor: filter.anchor !== null,
578
+ ...(folder.pending
579
+ ? {
580
+ state: FilterState.Disabled,
581
+ disabledReason: FilterDisabledReason.AwaitingFolder,
582
+ }
583
+ : {}),
567
584
  };
568
585
 
569
586
  const held = existingFilterByName.get(key);
@@ -587,11 +604,14 @@ const applyDocument = async (
587
604
  ).filterId;
588
605
 
589
606
  filterIdByName.set(key, filterId);
590
- record(
591
- ConfigImportSection.Filters,
592
- filter.name,
593
- held ? ConfigImportVerdict.Updated : ConfigImportVerdict.Created,
594
- );
607
+ items.push({
608
+ section: ConfigImportSection.Filters,
609
+ key: filter.name,
610
+ verdict: held ? ConfigImportVerdict.Updated : ConfigImportVerdict.Created,
611
+ ...(folder.pending && filter.actionFolder
612
+ ? { reason: awaitingFolderReason(filter.actionFolder.folderPath) }
613
+ : {}),
614
+ });
595
615
 
596
616
  if (folder.pending && filter.actionFolder) {
597
617
  unresolvedRefs.push({
@@ -601,11 +621,15 @@ const applyDocument = async (
601
621
  filter.actionFolder.accountId,
602
622
  folderPath: filter.actionFolder.folderPath,
603
623
  target: filterId,
624
+ mailboxId: NO_ACTION,
604
625
  });
605
626
  warnings.push(
606
627
  problem(
607
628
  "folder_not_found_yet",
608
- `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.`,
629
+ filterFolderPendingMessage(
630
+ filter.name,
631
+ filter.actionFolder.folderPath,
632
+ ),
609
633
  { filter: filter.name, folderPath: filter.actionFolder.folderPath },
610
634
  ),
611
635
  );
@@ -699,11 +723,10 @@ const applyDocument = async (
699
723
  for (const ref of unresolvedRefs) {
700
724
  if (ref.kind === ConfigImportRefKind.FilterAction) continue;
701
725
  warnings.push(
702
- problem(
703
- "folder_not_found_yet",
704
- `"${ref.folderPath}" is not a folder this account holds yet. It is bound once the folder list has been read.`,
705
- { folderPath: ref.folderPath, accountId: ref.accountId },
706
- ),
726
+ problem("folder_not_found_yet", folderPendingMessage(ref.folderPath), {
727
+ folderPath: ref.folderPath,
728
+ accountId: ref.accountId,
729
+ }),
707
730
  );
708
731
  }
709
732
 
@@ -861,6 +884,7 @@ class SettingWriter {
861
884
  accountId,
862
885
  folderPath: role.folderPath,
863
886
  target: role.role,
887
+ mailboxId: NO_ACTION,
864
888
  });
865
889
  continue;
866
890
  }
@@ -881,6 +905,7 @@ class SettingWriter {
881
905
  accountId,
882
906
  folderPath: override.folderPath,
883
907
  target: override.folderPath,
908
+ mailboxId: NO_ACTION,
884
909
  });
885
910
  continue;
886
911
  }
@@ -1102,10 +1127,19 @@ const plan = (
1102
1127
  });
1103
1128
  }
1104
1129
  for (const filter of document.filters) {
1130
+ const { actionFolder } = filter;
1131
+ const awaiting =
1132
+ actionFolder !== null &&
1133
+ !foldersFor(existing, liveAccountId, actionFolder.accountId).has(
1134
+ actionFolder.folderPath,
1135
+ );
1105
1136
  items.push({
1106
1137
  section: ConfigImportSection.Filters,
1107
1138
  key: filter.name,
1108
1139
  verdict: verdict(heldFilters.has(normalize(filter.name))),
1140
+ ...(awaiting && actionFolder
1141
+ ? { reason: awaitingFolderReason(actionFolder.folderPath) }
1142
+ : {}),
1109
1143
  });
1110
1144
  }
1111
1145
  for (const entry of document.addressFlags) {
@@ -1139,7 +1173,7 @@ const plan = (
1139
1173
  warnings.push(
1140
1174
  problem(
1141
1175
  "folder_not_found_yet",
1142
- `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.`,
1176
+ filterFolderPendingMessage(filter.name, actionFolder.folderPath),
1143
1177
  { filter: filter.name, folderPath: actionFolder.folderPath },
1144
1178
  ),
1145
1179
  );
@@ -1155,11 +1189,10 @@ const plan = (
1155
1189
  for (const folderPath of paths) {
1156
1190
  if (folders.has(folderPath)) continue;
1157
1191
  warnings.push(
1158
- problem(
1159
- "folder_not_found_yet",
1160
- `"${folderPath}" is not a folder this account holds yet. It is bound once the folder list has been read.`,
1161
- { folderPath, accountId },
1162
- ),
1192
+ problem("folder_not_found_yet", folderPendingMessage(folderPath), {
1193
+ folderPath,
1194
+ accountId,
1195
+ }),
1163
1196
  );
1164
1197
  }
1165
1198
  }
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ export {
3
3
  bindImportedFolders,
4
4
  type ConfigBinderDeps,
5
5
  type ConfigBinderRepositories,
6
+ type CreateImportedFolder,
7
+ disableFiltersMissingFolders,
6
8
  pendingImportOf,
7
9
  } from "./binder.js";
8
10
  export {