@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 +1 -1
- package/src/binder.ts +219 -40
- package/src/fixtures.ts +2 -0
- package/src/import.test.ts +563 -10
- package/src/import.ts +50 -17
- package/src/index.ts +2 -0
package/package.json
CHANGED
package/src/binder.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { readConfigDocument } from "@remit/config-format";
|
|
2
|
-
import
|
|
3
|
-
ConfigImportItem,
|
|
4
|
-
ConfigImportUnresolvedRefItem,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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,
|
|
58
|
-
* than doing it
|
|
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
|
|
98
|
+
if (imports.length === 0) return result;
|
|
72
99
|
|
|
100
|
+
const mailboxes = await repositories.mailbox.listAllByAccount(accountId);
|
|
73
101
|
const byPath = new Map(
|
|
74
|
-
(
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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,
|
|
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
|
-
|
|
151
|
+
if (state.mailboxId !== ref.mailboxId) changed = true;
|
|
152
|
+
remaining.push({ ...ref, mailboxId: state.mailboxId });
|
|
101
153
|
continue;
|
|
102
154
|
}
|
|
103
|
-
|
|
104
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
127
|
-
|
|
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
|
-
!
|
|
273
|
+
!filtersById.has(ref.target)
|
|
132
274
|
) {
|
|
133
275
|
return { kind: "TargetGone" };
|
|
134
276
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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,
|
package/src/import.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
),
|
|
1192
|
+
problem("folder_not_found_yet", folderPendingMessage(folderPath), {
|
|
1193
|
+
folderPath,
|
|
1194
|
+
accountId,
|
|
1195
|
+
}),
|
|
1163
1196
|
);
|
|
1164
1197
|
}
|
|
1165
1198
|
}
|