@remit/mailbox-service 0.0.110 → 0.0.112
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 +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +226 -200
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +19 -15
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/types.ts +1 -1
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
package/src/mailbox-intent.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const INTENT_RECORDABLE_FROM = [
|
|
|
15
15
|
MailboxSyncStatus.failed,
|
|
16
16
|
] as const;
|
|
17
17
|
|
|
18
|
-
const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
|
|
18
|
+
export const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
|
|
19
19
|
(INTENT_RECORDABLE_FROM as readonly string[]).includes(row.syncStatus);
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -32,7 +32,7 @@ const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
|
|
|
32
32
|
* The identity is the pair: the row sits at or under the path the rename is
|
|
33
33
|
* leaving, **and** the target it recorded is exactly what this rename's own
|
|
34
34
|
* rebase would have written for it. Nothing but this intent writes that pair,
|
|
35
|
-
* because the intent is recorded over one subtree in one
|
|
35
|
+
* because the intent is recorded over one subtree in one batch (D6) and
|
|
36
36
|
* the transition is the only writer of either field (D3).
|
|
37
37
|
*/
|
|
38
38
|
export const recordedByRename = (
|
|
@@ -60,6 +60,8 @@ const inFlight = (row: MailboxItem): string => {
|
|
|
60
60
|
return `"${row.fullPath}" is still being created`;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
export const folderInFlight = (row: MailboxItem): string => `${inFlight(row)}.`;
|
|
64
|
+
|
|
63
65
|
/**
|
|
64
66
|
* Say why a folder intent lost its compare-and-set (D3).
|
|
65
67
|
*
|
|
@@ -86,7 +88,7 @@ export const refuseContestedIntent = async (
|
|
|
86
88
|
});
|
|
87
89
|
if (!root) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
88
90
|
|
|
89
|
-
if (!carriesIntent(root)) throw new ConflictError(
|
|
91
|
+
if (!carriesIntent(root)) throw new ConflictError(folderInFlight(root));
|
|
90
92
|
|
|
91
93
|
const descendants =
|
|
92
94
|
scope === "subtree"
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
IMailboxRepository,
|
|
5
|
-
MailboxItem,
|
|
6
|
-
MailboxTransitionIntent,
|
|
7
|
-
} from "@remit/data-ports";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, MailboxItem } from "@remit/data-ports";
|
|
8
4
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
9
5
|
import {
|
|
10
6
|
MailboxManagementService,
|
|
@@ -12,11 +8,16 @@ import {
|
|
|
12
8
|
validateMailboxOperation,
|
|
13
9
|
validateMailboxPath,
|
|
14
10
|
} from "./mailbox-management.js";
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
import {
|
|
12
|
+
type FakeImapConnection,
|
|
13
|
+
FakeImapServer,
|
|
14
|
+
} from "./test-helpers/fake-imap.js";
|
|
15
|
+
import {
|
|
16
|
+
openStore,
|
|
17
|
+
type Store,
|
|
18
|
+
seedAccount,
|
|
19
|
+
seedMailbox,
|
|
20
|
+
} from "./test-helpers/test-store.js";
|
|
20
21
|
|
|
21
22
|
describe("parseMailboxPath", () => {
|
|
22
23
|
it("parses simple path", () => {
|
|
@@ -88,239 +89,125 @@ describe("validateMailboxPath", () => {
|
|
|
88
89
|
});
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
messages: { total: 0, new: 0 },
|
|
100
|
-
newKeywords: false,
|
|
92
|
+
let store: Store;
|
|
93
|
+
let imap: FakeImapServer;
|
|
94
|
+
let account: AccountItem;
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
store = await openStore();
|
|
98
|
+
imap = new FakeImapServer();
|
|
99
|
+
account = await seedAccount(store);
|
|
101
100
|
});
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
) =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
intent: MailboxTransitionIntent,
|
|
133
|
-
) => {
|
|
134
|
-
settles.push({ mailboxId, intent });
|
|
135
|
-
return {} as never;
|
|
136
|
-
},
|
|
137
|
-
findByPathPrefix: async () => [],
|
|
138
|
-
findByPath: async (_accountId: string, fullPath: string) =>
|
|
139
|
-
(held.find((row) => row.fullPath === fullPath) as MailboxItem) ?? null,
|
|
140
|
-
deleteMailboxWithMail: async (_accountId: string, mailboxId: string) => {
|
|
141
|
-
removed.push(mailboxId);
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
return { repo: repo as IMailboxRepository, updates, settles, removed };
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const stubConnection = (
|
|
148
|
-
createdPath: string,
|
|
149
|
-
listed: string[],
|
|
150
|
-
): { connection: IImapConnection; opened: string[] } => {
|
|
151
|
-
const opened: string[] = [];
|
|
152
|
-
const connection = {
|
|
153
|
-
createMailbox: async (_path: string) => ({
|
|
154
|
-
path: createdPath,
|
|
155
|
-
created: true,
|
|
156
|
-
}),
|
|
157
|
-
subscribeMailbox: async () => undefined,
|
|
158
|
-
listMailboxes: async (): Promise<FlatMailboxInfo[]> =>
|
|
159
|
-
listed.map((fullPath) => ({
|
|
160
|
-
fullPath,
|
|
161
|
-
name: fullPath.split("/").pop() ?? fullPath,
|
|
162
|
-
delimiter: "/",
|
|
163
|
-
attributes: [],
|
|
164
|
-
parentPath: null,
|
|
165
|
-
})),
|
|
166
|
-
openBox: async (path: string) => {
|
|
167
|
-
opened.push(path);
|
|
168
|
-
return boxStatus(path);
|
|
169
|
-
},
|
|
170
|
-
closeBox: async () => undefined,
|
|
171
|
-
} as unknown as IImapConnection;
|
|
172
|
-
return { connection, opened };
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
describe("MailboxManagementService.syncCreate — server path normalization", () => {
|
|
176
|
-
it("adopts the server-materialized path as the row's identity when it is prefixed", async () => {
|
|
177
|
-
const { repo, settles } = recordingRepo();
|
|
178
|
-
const { connection, opened } = stubConnection("INBOX/Notifications", [
|
|
179
|
-
"INBOX/Notifications",
|
|
180
|
-
]);
|
|
181
|
-
const service = new MailboxManagementService(repo);
|
|
182
|
-
|
|
183
|
-
const result = await service.syncCreate(
|
|
184
|
-
"acc-1",
|
|
185
|
-
"mbx-1",
|
|
186
|
-
"Notifications",
|
|
187
|
-
async () => connection,
|
|
188
|
-
);
|
|
102
|
+
afterEach(() => store.close());
|
|
103
|
+
|
|
104
|
+
const pending = (fullPath: string) =>
|
|
105
|
+
seedMailbox(store, account, fullPath, {
|
|
106
|
+
overrides: { syncStatus: MailboxSyncStatus.pending },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const syncCreate = (
|
|
110
|
+
mailbox: MailboxItem,
|
|
111
|
+
connection: FakeImapConnection = imap.connection(),
|
|
112
|
+
subscribe?: boolean,
|
|
113
|
+
) =>
|
|
114
|
+
new MailboxManagementService(store.mailbox).syncCreate(
|
|
115
|
+
account.accountId,
|
|
116
|
+
mailbox.mailboxId,
|
|
117
|
+
mailbox.fullPath,
|
|
118
|
+
async () => connection,
|
|
119
|
+
subscribe,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const current = (mailbox: MailboxItem) =>
|
|
123
|
+
store.mailbox.get(account.accountId, mailbox.mailboxId);
|
|
124
|
+
|
|
125
|
+
describe("MailboxManagementService.syncCreate", () => {
|
|
126
|
+
it("adopts the path the server created under its prefix, keeping the row", async () => {
|
|
127
|
+
imap.namespacePrefix = "INBOX/";
|
|
128
|
+
const requested = await pending("Notifications");
|
|
129
|
+
|
|
130
|
+
const result = await syncCreate(requested);
|
|
189
131
|
|
|
190
132
|
assert.strictEqual(result.success, true);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
assert.strictEqual(
|
|
194
|
-
|
|
195
|
-
// updates it in place instead of insert+delete — every reference survives.
|
|
196
|
-
assert.strictEqual(settles[0].mailboxId, "mbx-1");
|
|
197
|
-
assert.strictEqual(settles[0].intent.set?.fullPath, "INBOX/Notifications");
|
|
198
|
-
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
133
|
+
assert.ok(imap.hasFolder("INBOX/Notifications"));
|
|
134
|
+
const row = await current(requested);
|
|
135
|
+
assert.strictEqual(row.fullPath, "INBOX/Notifications");
|
|
136
|
+
assert.strictEqual(row.syncStatus, MailboxSyncStatus.synced);
|
|
199
137
|
});
|
|
200
138
|
|
|
201
|
-
it("drops the pending row when the
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
created: false,
|
|
209
|
-
}),
|
|
210
|
-
subscribeMailbox: async () => undefined,
|
|
211
|
-
listMailboxes: async (): Promise<FlatMailboxInfo[]> => [],
|
|
212
|
-
openBox: async (path: string) => boxStatus(path),
|
|
213
|
-
closeBox: async () => undefined,
|
|
214
|
-
} as unknown as IImapConnection;
|
|
215
|
-
const service = new MailboxManagementService(repo);
|
|
216
|
-
|
|
217
|
-
const result = await service.syncCreate(
|
|
218
|
-
"acc-1",
|
|
219
|
-
"mbx-recreated",
|
|
220
|
-
"Notifications",
|
|
221
|
-
async () => connection,
|
|
222
|
-
);
|
|
139
|
+
it("drops the pending row when the server path already has a folder (#318)", async () => {
|
|
140
|
+
imap.namespacePrefix = "INBOX/";
|
|
141
|
+
imap.addFolder("INBOX/Notifications");
|
|
142
|
+
const existing = await seedMailbox(store, account, "INBOX/Notifications");
|
|
143
|
+
const requested = await pending("Notifications");
|
|
144
|
+
|
|
145
|
+
const result = await syncCreate(requested);
|
|
223
146
|
|
|
224
147
|
assert.deepStrictEqual(result, {
|
|
225
148
|
success: true,
|
|
226
|
-
existing: {
|
|
149
|
+
existing: {
|
|
150
|
+
mailboxId: existing.mailboxId,
|
|
151
|
+
fullPath: "INBOX/Notifications",
|
|
152
|
+
},
|
|
227
153
|
});
|
|
228
|
-
assert.
|
|
229
|
-
assert.deepStrictEqual(settles, []);
|
|
230
|
-
assert.deepStrictEqual(updates, []);
|
|
154
|
+
await assert.rejects(current(requested));
|
|
231
155
|
});
|
|
232
156
|
|
|
233
|
-
it("
|
|
234
|
-
const
|
|
235
|
-
const { connection } = stubConnection("Work", ["Work"]);
|
|
236
|
-
const service = new MailboxManagementService(repo);
|
|
157
|
+
it("keeps the requested path when the server does", async () => {
|
|
158
|
+
const requested = await pending("Work");
|
|
237
159
|
|
|
238
|
-
await
|
|
160
|
+
await syncCreate(requested);
|
|
239
161
|
|
|
240
|
-
|
|
241
|
-
assert.
|
|
242
|
-
assert.strictEqual(
|
|
162
|
+
const row = await current(requested);
|
|
163
|
+
assert.strictEqual(row.fullPath, "Work");
|
|
164
|
+
assert.strictEqual(row.syncStatus, MailboxSyncStatus.synced);
|
|
243
165
|
});
|
|
244
166
|
|
|
245
167
|
it("still adopts the server path when the fresh list cannot resolve it", async () => {
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
await service.syncCreate(
|
|
251
|
-
"acc-1",
|
|
252
|
-
"mbx-3",
|
|
253
|
-
"Notifications",
|
|
254
|
-
async () => connection,
|
|
255
|
-
);
|
|
168
|
+
imap.namespacePrefix = "INBOX/";
|
|
169
|
+
const requested = await pending("Notifications");
|
|
170
|
+
const connection = imap.connection();
|
|
171
|
+
connection.listMailboxes = async () => [];
|
|
256
172
|
|
|
257
|
-
|
|
258
|
-
assert.strictEqual(settles.length, 1);
|
|
259
|
-
assert.strictEqual(settles[0].intent.set?.fullPath, "INBOX/Notifications");
|
|
260
|
-
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
261
|
-
});
|
|
173
|
+
await syncCreate(requested, connection);
|
|
262
174
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const subscribed: string[] = [];
|
|
267
|
-
const connection = {
|
|
268
|
-
createMailbox: async (_path: string) => ({ created: true }),
|
|
269
|
-
subscribeMailbox: async (p: string) => {
|
|
270
|
-
subscribed.push(p);
|
|
271
|
-
},
|
|
272
|
-
listMailboxes: async (): Promise<FlatMailboxInfo[]> => [
|
|
273
|
-
{
|
|
274
|
-
fullPath: "Archive",
|
|
275
|
-
name: "Archive",
|
|
276
|
-
delimiter: "/",
|
|
277
|
-
attributes: [],
|
|
278
|
-
parentPath: null,
|
|
279
|
-
},
|
|
280
|
-
],
|
|
281
|
-
openBox: async (p: string) => {
|
|
282
|
-
opened.push(p);
|
|
283
|
-
return boxStatus(p);
|
|
284
|
-
},
|
|
285
|
-
closeBox: async () => undefined,
|
|
286
|
-
} as unknown as IImapConnection;
|
|
287
|
-
const service = new MailboxManagementService(repo);
|
|
288
|
-
|
|
289
|
-
await service.syncCreate(
|
|
290
|
-
"acc-1",
|
|
291
|
-
"mbx-4",
|
|
292
|
-
"Archive",
|
|
293
|
-
async () => connection,
|
|
294
|
-
true,
|
|
175
|
+
assert.strictEqual(
|
|
176
|
+
(await current(requested)).fullPath,
|
|
177
|
+
"INBOX/Notifications",
|
|
295
178
|
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("falls back to the requested path when the create result names none", async () => {
|
|
182
|
+
const requested = await pending("Archive");
|
|
183
|
+
const connection = imap.connection();
|
|
184
|
+
const create = connection.createMailbox;
|
|
185
|
+
connection.createMailbox = async (path) => {
|
|
186
|
+
await create(path);
|
|
187
|
+
return { created: true } as { path: string; created: boolean };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
await syncCreate(requested, connection, true);
|
|
296
191
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
assert.
|
|
300
|
-
assert.strictEqual(
|
|
301
|
-
assert.deepStrictEqual(settles[0].intent.set, {});
|
|
302
|
-
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
192
|
+
assert.strictEqual(imap.isSubscribed("Archive"), true);
|
|
193
|
+
const row = await current(requested);
|
|
194
|
+
assert.strictEqual(row.fullPath, "Archive");
|
|
195
|
+
assert.strictEqual(row.syncStatus, MailboxSyncStatus.synced);
|
|
303
196
|
});
|
|
304
197
|
|
|
305
|
-
it("
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
await service.syncCreate(
|
|
316
|
-
"acc-1",
|
|
317
|
-
"mbx-5",
|
|
318
|
-
"Archive",
|
|
319
|
-
async () => connection,
|
|
320
|
-
);
|
|
198
|
+
it("does not settle a row a rename has claimed since", async () => {
|
|
199
|
+
const requested = await pending("Archive");
|
|
200
|
+
await store.mailbox.transition(account.accountId, requested.mailboxId, {
|
|
201
|
+
from: [MailboxSyncStatus.pending],
|
|
202
|
+
to: MailboxSyncStatus.pending,
|
|
203
|
+
set: { pendingPath: "Archief" },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await syncCreate(requested);
|
|
321
207
|
|
|
322
|
-
|
|
323
|
-
assert.
|
|
208
|
+
const row = await current(requested);
|
|
209
|
+
assert.strictEqual(row.syncStatus, MailboxSyncStatus.pending);
|
|
210
|
+
assert.strictEqual(row.pendingPath, "Archief");
|
|
324
211
|
});
|
|
325
212
|
});
|
|
326
213
|
|
|
@@ -783,8 +783,7 @@ export class MailboxManagementService {
|
|
|
783
783
|
/**
|
|
784
784
|
* T8: the confirmed delete takes the folder's local mail with it (D8).
|
|
785
785
|
* Removing the row alone leaves every `message` and `thread_message` row
|
|
786
|
-
* keyed to a dead mailboxId — out of every reader
|
|
787
|
-
* index, because nothing writes the `message.removed` outbox event.
|
|
786
|
+
* keyed to a dead mailboxId — out of every reader.
|
|
788
787
|
*
|
|
789
788
|
* The removal is batched and the mailbox row goes last, so the row stays
|
|
790
789
|
* `deleting` throughout and an SQS redelivery re-enters the handler, passes
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import type { IMailboxRepository } from "@remit/data-ports";
|
|
4
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
5
3
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
6
4
|
import {
|
|
7
5
|
isFolderMutationInFlight,
|
|
8
6
|
isMailboxMutationInFlight,
|
|
9
7
|
} from "./mailbox-presence.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
import {
|
|
9
|
+
openStore,
|
|
10
|
+
seedAccount,
|
|
11
|
+
seedMailbox,
|
|
12
|
+
} from "./test-helpers/test-store.js";
|
|
14
13
|
|
|
15
14
|
describe("isFolderMutationInFlight", () => {
|
|
16
15
|
for (const [syncStatus, inFlight] of [
|
|
@@ -38,35 +37,38 @@ describe("isFolderMutationInFlight", () => {
|
|
|
38
37
|
});
|
|
39
38
|
|
|
40
39
|
describe("isMailboxMutationInFlight", () => {
|
|
41
|
-
it("reads the row
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
it("treats an absent row as in flight", async () => {
|
|
50
|
-
// The row's absence does not decide it at either end: on the way out the
|
|
51
|
-
// row is removed only after the IMAP folder is, and on the way in the row
|
|
52
|
-
// exists before the folder does.
|
|
53
|
-
const service = reader((async () => {
|
|
54
|
-
throw new NotFoundError("Mailbox not found: mbx");
|
|
55
|
-
}) as unknown as IMailboxRepository["get"]);
|
|
56
|
-
|
|
57
|
-
assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("rethrows a read that cannot answer", async () => {
|
|
61
|
-
// The caller uses this to classify a failure in hand; a read that fails
|
|
62
|
-
// for its own reasons must not be reported as "the folder is busy".
|
|
63
|
-
const service = reader((async () => {
|
|
64
|
-
throw new Error("connection reset by peer");
|
|
65
|
-
}) as unknown as IMailboxRepository["get"]);
|
|
40
|
+
it("reads the stored row, and treats an absent one as in flight", async () => {
|
|
41
|
+
const store = await openStore();
|
|
42
|
+
const account = await seedAccount(store);
|
|
43
|
+
const deleting = await seedMailbox(store, account, "Receipts", {
|
|
44
|
+
overrides: { syncStatus: MailboxSyncStatus.deleting },
|
|
45
|
+
});
|
|
46
|
+
const settled = await seedMailbox(store, account, "Work");
|
|
66
47
|
|
|
67
|
-
|
|
68
|
-
isMailboxMutationInFlight(
|
|
69
|
-
|
|
48
|
+
assert.equal(
|
|
49
|
+
await isMailboxMutationInFlight(
|
|
50
|
+
store.mailbox,
|
|
51
|
+
account.accountId,
|
|
52
|
+
deleting.mailboxId,
|
|
53
|
+
),
|
|
54
|
+
true,
|
|
55
|
+
);
|
|
56
|
+
assert.equal(
|
|
57
|
+
await isMailboxMutationInFlight(
|
|
58
|
+
store.mailbox,
|
|
59
|
+
account.accountId,
|
|
60
|
+
settled.mailboxId,
|
|
61
|
+
),
|
|
62
|
+
false,
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
await isMailboxMutationInFlight(
|
|
66
|
+
store.mailbox,
|
|
67
|
+
account.accountId,
|
|
68
|
+
"mbx-gone",
|
|
69
|
+
),
|
|
70
|
+
true,
|
|
70
71
|
);
|
|
72
|
+
await store.close();
|
|
71
73
|
});
|
|
72
74
|
});
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -7,7 +7,6 @@ import type {
|
|
|
7
7
|
MailboxItem,
|
|
8
8
|
} from "@remit/data-ports";
|
|
9
9
|
import { NotFoundError } from "@remit/data-ports/errors";
|
|
10
|
-
import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
|
|
11
10
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
12
11
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
13
12
|
import {
|
|
@@ -178,7 +177,7 @@ export class MailboxQueueService {
|
|
|
178
177
|
const mailbox = await this.mailboxService.get(accountId, mailboxId);
|
|
179
178
|
const oldPath = mailbox.fullPath;
|
|
180
179
|
|
|
181
|
-
// One intent over the folder and every descendant, in one
|
|
180
|
+
// One intent over the folder and every descendant, in one batch:
|
|
182
181
|
// IMAP RENAME moves the subtree in one command, so a partially recorded
|
|
183
182
|
// rename is the state that produced today's phantom rows (D6). A
|
|
184
183
|
// descendant already mid-mutation refuses the whole rename.
|
|
@@ -188,24 +187,7 @@ export class MailboxQueueService {
|
|
|
188
187
|
{
|
|
189
188
|
from: INTENT_RECORDABLE_FROM,
|
|
190
189
|
to: MailboxSyncStatus.pending,
|
|
191
|
-
|
|
192
|
-
const target = rebaseMailboxPath(
|
|
193
|
-
row.fullPath,
|
|
194
|
-
oldPath,
|
|
195
|
-
newPath,
|
|
196
|
-
mailbox.hierarchyDelimiter,
|
|
197
|
-
);
|
|
198
|
-
// The subtree is the folder and the rows under its own prefix, so
|
|
199
|
-
// every one of them rebases. A row that does not is a resolution
|
|
200
|
-
// bug, and recording an intent with no target for it would strand
|
|
201
|
-
// it `pending` with nothing able to settle it.
|
|
202
|
-
if (target === undefined) {
|
|
203
|
-
throw new Error(
|
|
204
|
-
`Mailbox ${row.mailboxId} at "${row.fullPath}" is not under "${oldPath}"`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
return { pendingPath: target };
|
|
208
|
-
},
|
|
190
|
+
newPath,
|
|
209
191
|
},
|
|
210
192
|
);
|
|
211
193
|
if (!written) {
|