@remit/mailbox-service 0.0.101 → 0.0.102
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-management.test.ts +48 -3
- package/src/mailbox-management.ts +25 -0
- package/src/mailbox-queue.ts +7 -5
- package/src/mailbox-sync.test.ts +52 -5
- package/src/mailbox-sync.ts +13 -4
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import assert from "node:assert";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type {
|
|
4
4
|
IMailboxRepository,
|
|
5
|
+
MailboxItem,
|
|
5
6
|
MailboxTransitionIntent,
|
|
6
7
|
} from "@remit/data-ports";
|
|
7
8
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
@@ -99,7 +100,10 @@ const boxStatus = (path: string): ImapBoxStatus => ({
|
|
|
99
100
|
newKeywords: false,
|
|
100
101
|
});
|
|
101
102
|
|
|
102
|
-
const recordingRepo = (
|
|
103
|
+
const recordingRepo = (
|
|
104
|
+
held: Pick<MailboxItem, "mailboxId" | "fullPath">[] = [],
|
|
105
|
+
) => {
|
|
106
|
+
const removed: string[] = [];
|
|
103
107
|
const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
|
|
104
108
|
[];
|
|
105
109
|
const settles: Array<{
|
|
@@ -108,7 +112,11 @@ const recordingRepo = () => {
|
|
|
108
112
|
}> = [];
|
|
109
113
|
const repo: Pick<
|
|
110
114
|
IMailboxRepository,
|
|
111
|
-
|
|
115
|
+
| "update"
|
|
116
|
+
| "transition"
|
|
117
|
+
| "findByPathPrefix"
|
|
118
|
+
| "findByPath"
|
|
119
|
+
| "deleteMailboxWithMail"
|
|
112
120
|
> = {
|
|
113
121
|
update: async (
|
|
114
122
|
_accountId: string,
|
|
@@ -127,8 +135,13 @@ const recordingRepo = () => {
|
|
|
127
135
|
return {} as never;
|
|
128
136
|
},
|
|
129
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
|
+
},
|
|
130
143
|
};
|
|
131
|
-
return { repo: repo as IMailboxRepository, updates, settles };
|
|
144
|
+
return { repo: repo as IMailboxRepository, updates, settles, removed };
|
|
132
145
|
};
|
|
133
146
|
|
|
134
147
|
const stubConnection = (
|
|
@@ -185,6 +198,38 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
|
|
|
185
198
|
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
186
199
|
});
|
|
187
200
|
|
|
201
|
+
it("drops the pending row when the prefixed path already has a folder (#318)", async () => {
|
|
202
|
+
const { repo, settles, updates, removed } = recordingRepo([
|
|
203
|
+
{ mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
|
|
204
|
+
]);
|
|
205
|
+
const connection = {
|
|
206
|
+
createMailbox: async (_path: string) => ({
|
|
207
|
+
path: "INBOX/Notifications",
|
|
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
|
+
);
|
|
223
|
+
|
|
224
|
+
assert.deepStrictEqual(result, {
|
|
225
|
+
success: true,
|
|
226
|
+
existing: { mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
|
|
227
|
+
});
|
|
228
|
+
assert.deepStrictEqual(removed, ["mbx-recreated"]);
|
|
229
|
+
assert.deepStrictEqual(settles, []);
|
|
230
|
+
assert.deepStrictEqual(updates, []);
|
|
231
|
+
});
|
|
232
|
+
|
|
188
233
|
it("leaves the path untouched when the server keeps it as requested", async () => {
|
|
189
234
|
const { repo, settles } = recordingRepo();
|
|
190
235
|
const { connection } = stubConnection("Work", ["Work"]);
|
|
@@ -48,6 +48,7 @@ export interface MailboxManagementSyncResult {
|
|
|
48
48
|
* which is how a caller tells the two apart without a second read.
|
|
49
49
|
*/
|
|
50
50
|
renamed?: { oldPath: string; newPath: string; delimiter: string };
|
|
51
|
+
existing?: { mailboxId: string; fullPath: string };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -304,6 +305,30 @@ export class MailboxManagementService {
|
|
|
304
305
|
this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
|
|
305
306
|
}
|
|
306
307
|
|
|
308
|
+
const holder =
|
|
309
|
+
confirmedPath === undefined
|
|
310
|
+
? null
|
|
311
|
+
: await this.mailboxService.findByPath(accountId, confirmedPath);
|
|
312
|
+
if (holder && holder.mailboxId !== mailboxId) {
|
|
313
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
|
|
314
|
+
this.log.info(
|
|
315
|
+
{
|
|
316
|
+
accountId,
|
|
317
|
+
mailboxId,
|
|
318
|
+
intent: "create",
|
|
319
|
+
path,
|
|
320
|
+
serverPath,
|
|
321
|
+
existingMailboxId: holder.mailboxId,
|
|
322
|
+
outcome: "already-exists",
|
|
323
|
+
},
|
|
324
|
+
"Folder already exists at the path the server resolved",
|
|
325
|
+
);
|
|
326
|
+
return {
|
|
327
|
+
success: true,
|
|
328
|
+
existing: { mailboxId: holder.mailboxId, fullPath: holder.fullPath },
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
307
332
|
// Refresh mailbox list to get UIDVALIDITY and other attributes
|
|
308
333
|
const mailboxes = await connection.listMailboxes();
|
|
309
334
|
const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
3
3
|
import type {
|
|
4
4
|
CreateMailboxInput,
|
|
5
|
+
CreateMailboxResult,
|
|
5
6
|
IMailboxRepository,
|
|
6
7
|
MailboxItem,
|
|
7
8
|
} from "@remit/data-ports";
|
|
@@ -120,18 +121,19 @@ export class MailboxQueueService {
|
|
|
120
121
|
* @param input - The mailbox creation input (without syncStatus)
|
|
121
122
|
* @param accountId - The account ID for the IMAP sync event
|
|
122
123
|
* @param subscribe - Whether to subscribe to the mailbox after creation
|
|
123
|
-
* @returns The created mailbox
|
|
124
|
+
* @returns The created mailbox, or `PathTaken` when the account already has a folder at that path
|
|
124
125
|
*/
|
|
125
126
|
createMailbox = async (
|
|
126
127
|
input: CreateMailboxQueueInput,
|
|
127
128
|
accountId: string,
|
|
128
129
|
subscribe?: boolean,
|
|
129
|
-
): Promise<
|
|
130
|
-
|
|
131
|
-
const mailbox = await this.mailboxService.create({
|
|
130
|
+
): Promise<CreateMailboxResult> => {
|
|
131
|
+
const created = await this.mailboxService.create({
|
|
132
132
|
...input,
|
|
133
133
|
syncStatus: MailboxSyncStatus.pending,
|
|
134
134
|
});
|
|
135
|
+
if (created.outcome === "PathTaken") return created;
|
|
136
|
+
const { mailbox } = created;
|
|
135
137
|
|
|
136
138
|
this.log.info(
|
|
137
139
|
{ mailboxId: mailbox.mailboxId, path: mailbox.fullPath },
|
|
@@ -149,7 +151,7 @@ export class MailboxQueueService {
|
|
|
149
151
|
subscribe,
|
|
150
152
|
});
|
|
151
153
|
|
|
152
|
-
return
|
|
154
|
+
return created;
|
|
153
155
|
};
|
|
154
156
|
|
|
155
157
|
/**
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
import { parseImapAttributes } from "./attribute-mapper.js";
|
|
14
14
|
import { type MailboxSyncLogger, MailboxSyncService } from "./mailbox-sync.js";
|
|
15
15
|
|
|
16
|
-
const silentLogger: MailboxSyncLogger = {
|
|
16
|
+
const silentLogger: MailboxSyncLogger = {
|
|
17
|
+
info: () => {},
|
|
18
|
+
debug: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
};
|
|
17
21
|
|
|
18
22
|
import type { IImapConnection, ImapNamespaces } from "./types.js";
|
|
19
23
|
|
|
@@ -110,7 +114,10 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
110
114
|
return {};
|
|
111
115
|
},
|
|
112
116
|
delete: async () => undefined,
|
|
113
|
-
create: async () => ({
|
|
117
|
+
create: async () => ({
|
|
118
|
+
outcome: "Created",
|
|
119
|
+
mailbox: { mailboxId: "new" },
|
|
120
|
+
}),
|
|
114
121
|
} as unknown as IMailboxRepository;
|
|
115
122
|
|
|
116
123
|
const specialUseService = {
|
|
@@ -257,6 +264,7 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
257
264
|
syncStatus?: string;
|
|
258
265
|
pendingPath?: string;
|
|
259
266
|
}>,
|
|
267
|
+
createOutcome: "Created" | "PathTaken" = "Created",
|
|
260
268
|
) => {
|
|
261
269
|
const deleted: string[] = [];
|
|
262
270
|
const created: Array<Record<string, unknown>> = [];
|
|
@@ -283,7 +291,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
283
291
|
},
|
|
284
292
|
create: async (input: Record<string, unknown>) => {
|
|
285
293
|
created.push(input);
|
|
286
|
-
return {
|
|
294
|
+
if (createOutcome === "PathTaken") return { outcome: "PathTaken" };
|
|
295
|
+
return {
|
|
296
|
+
outcome: "Created",
|
|
297
|
+
mailbox: { ...input, mailboxId: `new-${created.length}` },
|
|
298
|
+
};
|
|
287
299
|
},
|
|
288
300
|
} as unknown as IMailboxRepository;
|
|
289
301
|
|
|
@@ -401,6 +413,35 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
401
413
|
assert.deepEqual(deleted, []);
|
|
402
414
|
});
|
|
403
415
|
|
|
416
|
+
it("counts no insert and keeps the account sweep going when the path is already taken", async () => {
|
|
417
|
+
const { mailboxService, specialUseService, created } = buildServices(
|
|
418
|
+
[
|
|
419
|
+
{
|
|
420
|
+
mailboxId: "inbox",
|
|
421
|
+
fullPath: "INBOX",
|
|
422
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
"PathTaken",
|
|
426
|
+
);
|
|
427
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
428
|
+
const service = new MailboxSyncService(mailboxService, specialUseService, {
|
|
429
|
+
...silentLogger,
|
|
430
|
+
error: (obj) => {
|
|
431
|
+
errors.push(obj);
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const result = await service.syncMailboxes(
|
|
436
|
+
{ accountId: "acc-1" },
|
|
437
|
+
serverConnection([{ fullPath: "INBOX" }, { fullPath: "Projects" }]),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
assert.equal(created.length, 1);
|
|
441
|
+
assert.equal(result.created, 0);
|
|
442
|
+
assert.deepEqual(errors, [{ accountId: "acc-1", fullPath: "Projects" }]);
|
|
443
|
+
});
|
|
444
|
+
|
|
404
445
|
it("lets a failed rename's target be discovered as a folder of its own", async () => {
|
|
405
446
|
// A failed rename keeps its target so the UI can name what it was aiming
|
|
406
447
|
// at (T6), and nothing clears it until the user retries or dismisses. If
|
|
@@ -539,7 +580,10 @@ describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the u
|
|
|
539
580
|
delete: async (_accountId: string, mailboxId: string) => {
|
|
540
581
|
deleted.push(mailboxId);
|
|
541
582
|
},
|
|
542
|
-
create: async () => ({
|
|
583
|
+
create: async () => ({
|
|
584
|
+
outcome: "Created",
|
|
585
|
+
mailbox: { mailboxId: "new" },
|
|
586
|
+
}),
|
|
543
587
|
} as unknown as IMailboxRepository;
|
|
544
588
|
|
|
545
589
|
const specialUseService = {
|
|
@@ -717,7 +761,10 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
|
|
|
717
761
|
return {};
|
|
718
762
|
},
|
|
719
763
|
delete: async () => undefined,
|
|
720
|
-
create: async () => ({
|
|
764
|
+
create: async () => ({
|
|
765
|
+
outcome: "Created",
|
|
766
|
+
mailbox: { mailboxId: "new" },
|
|
767
|
+
}),
|
|
721
768
|
} as unknown as IMailboxRepository;
|
|
722
769
|
|
|
723
770
|
const specialUseService = {
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -82,6 +82,7 @@ export interface SyncAccountInfo {
|
|
|
82
82
|
export interface MailboxSyncLogger {
|
|
83
83
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
84
84
|
debug(obj: Record<string, unknown>, msg: string): void;
|
|
85
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/**
|
|
@@ -263,13 +264,13 @@ export class MailboxSyncService {
|
|
|
263
264
|
result.updated++;
|
|
264
265
|
}
|
|
265
266
|
} else if (!claimedByPendingRename.has(mailboxInfo.fullPath)) {
|
|
266
|
-
await this.createMailbox(
|
|
267
|
+
const created = await this.createMailbox(
|
|
267
268
|
account.accountId,
|
|
268
269
|
mailboxInfo,
|
|
269
270
|
namespaces,
|
|
270
271
|
connection,
|
|
271
272
|
);
|
|
272
|
-
result.created++;
|
|
273
|
+
if (created) result.created++;
|
|
273
274
|
}
|
|
274
275
|
},
|
|
275
276
|
{ concurrency: 3 },
|
|
@@ -432,7 +433,7 @@ export class MailboxSyncService {
|
|
|
432
433
|
},
|
|
433
434
|
_namespaces: ImapNamespaces,
|
|
434
435
|
connection: IImapConnection,
|
|
435
|
-
): Promise<MailboxItem> => {
|
|
436
|
+
): Promise<MailboxItem | null> => {
|
|
436
437
|
// Fetch mailbox status using STATUS command (doesn't require SELECT/EXAMINE)
|
|
437
438
|
// This gets us message counts including unseen without opening the mailbox
|
|
438
439
|
const status = await connection.getMailboxStatus(mailboxInfo.fullPath);
|
|
@@ -472,7 +473,15 @@ export class MailboxSyncService {
|
|
|
472
473
|
// parentMailboxId would need to be resolved from parentPath
|
|
473
474
|
};
|
|
474
475
|
|
|
475
|
-
const
|
|
476
|
+
const created = await this.mailboxService.create(input);
|
|
477
|
+
if (created.outcome === "PathTaken") {
|
|
478
|
+
this.log.error(
|
|
479
|
+
{ accountId, fullPath: mailboxInfo.fullPath },
|
|
480
|
+
"Sweep found no row for a folder path the account already holds",
|
|
481
|
+
);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const { mailbox } = created;
|
|
476
485
|
|
|
477
486
|
// Keep the MailboxSpecialUseEntry table in sync — every backend and worker
|
|
478
487
|
// special-folder lookup reads it through MailboxSpecialUseRepo. Denormalized
|