@remit/mailbox-service 0.0.26 → 0.0.27
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/mailbox-sync.test.ts +130 -1
- package/src/mailbox-sync.ts +16 -7
package/package.json
CHANGED
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -4,7 +4,11 @@ import type {
|
|
|
4
4
|
IMailboxRepository,
|
|
5
5
|
IMailboxSpecialUseRepository,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
MailboxCursorState,
|
|
9
|
+
MailboxSpecialUse,
|
|
10
|
+
MailboxSyncStatus,
|
|
11
|
+
} from "@remit/domain-enums";
|
|
8
12
|
import { parseImapAttributes } from "./attribute-mapper.js";
|
|
9
13
|
import { MailboxSyncService } from "./mailbox-sync.js";
|
|
10
14
|
import type { IImapConnection, ImapNamespaces } from "./types.js";
|
|
@@ -180,3 +184,128 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
180
184
|
assert.equal("cursorState" in (uidValidityUpdate ?? {}), false);
|
|
181
185
|
});
|
|
182
186
|
});
|
|
187
|
+
|
|
188
|
+
describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending folders (#290)", () => {
|
|
189
|
+
const namespaces: ImapNamespaces = {
|
|
190
|
+
personal: [{ prefix: "", delimiter: "/" }],
|
|
191
|
+
other: [],
|
|
192
|
+
shared: [],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// The server lists only INBOX: neither user folder is on it yet.
|
|
196
|
+
const serverConnection = (): IImapConnection =>
|
|
197
|
+
({
|
|
198
|
+
getNamespaces: async () => namespaces,
|
|
199
|
+
listMailboxes: async () => [
|
|
200
|
+
{
|
|
201
|
+
fullPath: "INBOX",
|
|
202
|
+
name: "INBOX",
|
|
203
|
+
delimiter: "/",
|
|
204
|
+
attributes: [],
|
|
205
|
+
parentPath: null,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
getMailboxStatus: async () => ({
|
|
209
|
+
messages: 0,
|
|
210
|
+
recent: 0,
|
|
211
|
+
unseen: 0,
|
|
212
|
+
uidNext: 1,
|
|
213
|
+
uidValidity: 1,
|
|
214
|
+
highestModseq: "0",
|
|
215
|
+
deletedCount: 0,
|
|
216
|
+
}),
|
|
217
|
+
}) as unknown as IImapConnection;
|
|
218
|
+
|
|
219
|
+
const buildServices = (
|
|
220
|
+
existing: Array<{
|
|
221
|
+
mailboxId: string;
|
|
222
|
+
fullPath: string;
|
|
223
|
+
syncStatus?: string;
|
|
224
|
+
}>,
|
|
225
|
+
) => {
|
|
226
|
+
const deleted: string[] = [];
|
|
227
|
+
const mailboxService = {
|
|
228
|
+
listByAccount: async () => ({
|
|
229
|
+
items: existing.map((m) => ({
|
|
230
|
+
mailboxId: m.mailboxId,
|
|
231
|
+
fullPath: m.fullPath,
|
|
232
|
+
uidNext: 1,
|
|
233
|
+
uidValidity: 1,
|
|
234
|
+
messageCount: 0,
|
|
235
|
+
unseenCount: 0,
|
|
236
|
+
deletedCount: 0,
|
|
237
|
+
highestModseq: "0",
|
|
238
|
+
specialUse: undefined,
|
|
239
|
+
syncStatus: m.syncStatus,
|
|
240
|
+
})),
|
|
241
|
+
continuationToken: undefined,
|
|
242
|
+
}),
|
|
243
|
+
update: async () => ({}),
|
|
244
|
+
delete: async (_accountId: string, mailboxId: string) => {
|
|
245
|
+
deleted.push(mailboxId);
|
|
246
|
+
},
|
|
247
|
+
create: async () => ({}),
|
|
248
|
+
} as unknown as IMailboxRepository;
|
|
249
|
+
|
|
250
|
+
const specialUseService = {
|
|
251
|
+
listByMailboxId: async () => [],
|
|
252
|
+
deleteByMailboxId: async () => undefined,
|
|
253
|
+
createMany: async () => undefined,
|
|
254
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
255
|
+
|
|
256
|
+
return { mailboxService, specialUseService, deleted };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
it("keeps a pending folder the server has not listed yet", async () => {
|
|
260
|
+
// The folder was just created locally; MAILBOX_CREATE has not reached the
|
|
261
|
+
// server, so the LIST omits it. Deleting the row here races the create and
|
|
262
|
+
// wedges the account's mailbox-sync FIFO group for a full visibility window.
|
|
263
|
+
const { mailboxService, specialUseService, deleted } = buildServices([
|
|
264
|
+
{
|
|
265
|
+
mailboxId: "inbox",
|
|
266
|
+
fullPath: "INBOX",
|
|
267
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
mailboxId: "pending-folder",
|
|
271
|
+
fullPath: "New Folder",
|
|
272
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
const service = new MailboxSyncService(mailboxService, specialUseService);
|
|
276
|
+
|
|
277
|
+
const result = await service.syncMailboxes(
|
|
278
|
+
{ accountId: "acc-1" },
|
|
279
|
+
serverConnection(),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
assert.deepEqual(deleted, []);
|
|
283
|
+
assert.equal(result.deleted, 0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("still deletes a synced folder that has left the server", async () => {
|
|
287
|
+
// A folder that was confirmed on the server and is now gone is a genuine
|
|
288
|
+
// server-side deletion; the reconcile must still reap it.
|
|
289
|
+
const { mailboxService, specialUseService, deleted } = buildServices([
|
|
290
|
+
{
|
|
291
|
+
mailboxId: "inbox",
|
|
292
|
+
fullPath: "INBOX",
|
|
293
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
mailboxId: "synced-gone",
|
|
297
|
+
fullPath: "Old Folder",
|
|
298
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
299
|
+
},
|
|
300
|
+
]);
|
|
301
|
+
const service = new MailboxSyncService(mailboxService, specialUseService);
|
|
302
|
+
|
|
303
|
+
const result = await service.syncMailboxes(
|
|
304
|
+
{ accountId: "acc-1" },
|
|
305
|
+
serverConnection(),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
assert.deepEqual(deleted, ["synced-gone"]);
|
|
309
|
+
assert.equal(result.deleted, 1);
|
|
310
|
+
});
|
|
311
|
+
});
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
import {
|
|
14
14
|
MailboxCursorState,
|
|
15
15
|
MailboxSpecialUse,
|
|
16
|
+
MailboxSyncStatus,
|
|
16
17
|
NamespaceType,
|
|
17
18
|
} from "@remit/domain-enums";
|
|
18
19
|
import pMap from "p-map";
|
|
@@ -198,13 +199,21 @@ export class MailboxSyncService {
|
|
|
198
199
|
|
|
199
200
|
// Handle deleted mailboxes (exist in DB but not on server)
|
|
200
201
|
for (const existing of existingMailboxes) {
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
if (seenPaths.has(existing.fullPath)) continue;
|
|
203
|
+
// A `pending` row is a folder the user just created (or renamed) whose
|
|
204
|
+
// MAILBOX_CREATE/RENAME has not yet reached the server, so its absence
|
|
205
|
+
// from the LIST is expected, not a server-side deletion. Deleting it
|
|
206
|
+
// races the create: the row vanishes, then MAILBOX_CREATE fails with
|
|
207
|
+
// NotFoundError trying to mark it synced, and — sharing this account's
|
|
208
|
+
// mailboxes FIFO group — that un-acked failure stalls every later
|
|
209
|
+
// mailbox sync for the queue's whole visibility window (#290). Leave
|
|
210
|
+
// pending rows to the create/rename flow that owns them.
|
|
211
|
+
if (existing.syncStatus === MailboxSyncStatus.pending) continue;
|
|
212
|
+
await this.mailboxService.delete(account.accountId, existing.mailboxId);
|
|
213
|
+
console.info(
|
|
214
|
+
`Deleted mailbox: ${existing.mailboxId} (${existing.fullPath})`,
|
|
215
|
+
);
|
|
216
|
+
result.deleted++;
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
return result;
|