@remit/mailbox-service 0.0.26 → 0.0.28
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 +163 -0
- package/src/mailbox-management.ts +22 -5
- package/src/mailbox-sync.test.ts +130 -1
- package/src/mailbox-sync.ts +16 -7
package/package.json
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import type { IMailboxRepository } from "@remit/data-ports";
|
|
4
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
5
|
import {
|
|
6
|
+
MailboxManagementService,
|
|
4
7
|
parseMailboxPath,
|
|
5
8
|
validateMailboxOperation,
|
|
6
9
|
validateMailboxPath,
|
|
7
10
|
} from "./mailbox-management.js";
|
|
11
|
+
import type {
|
|
12
|
+
FlatMailboxInfo,
|
|
13
|
+
IImapConnection,
|
|
14
|
+
ImapBoxStatus,
|
|
15
|
+
} from "./types.js";
|
|
8
16
|
|
|
9
17
|
describe("parseMailboxPath", () => {
|
|
10
18
|
it("parses simple path", () => {
|
|
@@ -76,6 +84,161 @@ describe("validateMailboxPath", () => {
|
|
|
76
84
|
});
|
|
77
85
|
});
|
|
78
86
|
|
|
87
|
+
const boxStatus = (path: string): ImapBoxStatus => ({
|
|
88
|
+
name: path,
|
|
89
|
+
readOnly: true,
|
|
90
|
+
uidvalidity: 42,
|
|
91
|
+
uidnext: 7,
|
|
92
|
+
flags: [],
|
|
93
|
+
permFlags: [],
|
|
94
|
+
persistentUIDs: true,
|
|
95
|
+
messages: { total: 0, new: 0 },
|
|
96
|
+
newKeywords: false,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const recordingRepo = () => {
|
|
100
|
+
const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
|
|
101
|
+
[];
|
|
102
|
+
const repo = {
|
|
103
|
+
update: async (
|
|
104
|
+
_accountId: string,
|
|
105
|
+
mailboxId: string,
|
|
106
|
+
patch: Record<string, unknown>,
|
|
107
|
+
) => {
|
|
108
|
+
updates.push({ mailboxId, patch });
|
|
109
|
+
return {} as never;
|
|
110
|
+
},
|
|
111
|
+
} as unknown as IMailboxRepository;
|
|
112
|
+
return { repo, updates };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const stubConnection = (
|
|
116
|
+
createdPath: string,
|
|
117
|
+
listed: string[],
|
|
118
|
+
): { connection: IImapConnection; opened: string[] } => {
|
|
119
|
+
const opened: string[] = [];
|
|
120
|
+
const connection = {
|
|
121
|
+
createMailbox: async (_path: string) => ({
|
|
122
|
+
path: createdPath,
|
|
123
|
+
created: true,
|
|
124
|
+
}),
|
|
125
|
+
subscribeMailbox: async () => undefined,
|
|
126
|
+
listMailboxes: async (): Promise<FlatMailboxInfo[]> =>
|
|
127
|
+
listed.map((fullPath) => ({
|
|
128
|
+
fullPath,
|
|
129
|
+
name: fullPath.split("/").pop() ?? fullPath,
|
|
130
|
+
delimiter: "/",
|
|
131
|
+
attributes: [],
|
|
132
|
+
parentPath: null,
|
|
133
|
+
})),
|
|
134
|
+
openBox: async (path: string) => {
|
|
135
|
+
opened.push(path);
|
|
136
|
+
return boxStatus(path);
|
|
137
|
+
},
|
|
138
|
+
closeBox: async () => undefined,
|
|
139
|
+
} as unknown as IImapConnection;
|
|
140
|
+
return { connection, opened };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
describe("MailboxManagementService.syncCreate — server path normalization", () => {
|
|
144
|
+
it("adopts the server-materialized path as the row's identity when it is prefixed", async () => {
|
|
145
|
+
const { repo, updates } = recordingRepo();
|
|
146
|
+
const { connection, opened } = stubConnection("INBOX/Notifications", [
|
|
147
|
+
"INBOX/Notifications",
|
|
148
|
+
]);
|
|
149
|
+
const service = new MailboxManagementService(repo);
|
|
150
|
+
|
|
151
|
+
const result = await service.syncCreate(
|
|
152
|
+
"acc-1",
|
|
153
|
+
"mbx-1",
|
|
154
|
+
"Notifications",
|
|
155
|
+
async () => connection,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
assert.strictEqual(result.success, true);
|
|
159
|
+
// The mailbox is opened at the server's path, not the requested leaf.
|
|
160
|
+
assert.deepStrictEqual(opened, ["INBOX/Notifications"]);
|
|
161
|
+
assert.strictEqual(updates.length, 1);
|
|
162
|
+
// The row keeps its id and takes on the prefixed path, so the next reconcile
|
|
163
|
+
// updates it in place instead of insert+delete — every reference survives.
|
|
164
|
+
assert.strictEqual(updates[0].mailboxId, "mbx-1");
|
|
165
|
+
assert.strictEqual(updates[0].patch.fullPath, "INBOX/Notifications");
|
|
166
|
+
assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("leaves the path untouched when the server keeps it as requested", async () => {
|
|
170
|
+
const { repo, updates } = recordingRepo();
|
|
171
|
+
const { connection } = stubConnection("Work", ["Work"]);
|
|
172
|
+
const service = new MailboxManagementService(repo);
|
|
173
|
+
|
|
174
|
+
await service.syncCreate("acc-1", "mbx-2", "Work", async () => connection);
|
|
175
|
+
|
|
176
|
+
assert.strictEqual(updates.length, 1);
|
|
177
|
+
assert.ok(!("fullPath" in updates[0].patch));
|
|
178
|
+
assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("still adopts the server path when the fresh list cannot resolve it", async () => {
|
|
182
|
+
const { repo, updates } = recordingRepo();
|
|
183
|
+
const { connection, opened } = stubConnection("INBOX/Notifications", []);
|
|
184
|
+
const service = new MailboxManagementService(repo);
|
|
185
|
+
|
|
186
|
+
await service.syncCreate(
|
|
187
|
+
"acc-1",
|
|
188
|
+
"mbx-3",
|
|
189
|
+
"Notifications",
|
|
190
|
+
async () => connection,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
assert.deepStrictEqual(opened, []);
|
|
194
|
+
assert.strictEqual(updates.length, 1);
|
|
195
|
+
assert.strictEqual(updates[0].patch.fullPath, "INBOX/Notifications");
|
|
196
|
+
assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("falls back to the requested path when the create result carries none", async () => {
|
|
200
|
+
const { repo, updates } = recordingRepo();
|
|
201
|
+
const opened: string[] = [];
|
|
202
|
+
const subscribed: string[] = [];
|
|
203
|
+
const connection = {
|
|
204
|
+
createMailbox: async (_path: string) => ({ created: true }),
|
|
205
|
+
subscribeMailbox: async (p: string) => {
|
|
206
|
+
subscribed.push(p);
|
|
207
|
+
},
|
|
208
|
+
listMailboxes: async (): Promise<FlatMailboxInfo[]> => [
|
|
209
|
+
{
|
|
210
|
+
fullPath: "Archive",
|
|
211
|
+
name: "Archive",
|
|
212
|
+
delimiter: "/",
|
|
213
|
+
attributes: [],
|
|
214
|
+
parentPath: null,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
openBox: async (p: string) => {
|
|
218
|
+
opened.push(p);
|
|
219
|
+
return boxStatus(p);
|
|
220
|
+
},
|
|
221
|
+
closeBox: async () => undefined,
|
|
222
|
+
} as unknown as IImapConnection;
|
|
223
|
+
const service = new MailboxManagementService(repo);
|
|
224
|
+
|
|
225
|
+
await service.syncCreate(
|
|
226
|
+
"acc-1",
|
|
227
|
+
"mbx-4",
|
|
228
|
+
"Archive",
|
|
229
|
+
async () => connection,
|
|
230
|
+
true,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// A thin result never blanks fullPath — the requested path is used throughout.
|
|
234
|
+
assert.deepStrictEqual(subscribed, ["Archive"]);
|
|
235
|
+
assert.deepStrictEqual(opened, ["Archive"]);
|
|
236
|
+
assert.strictEqual(updates.length, 1);
|
|
237
|
+
assert.ok(!("fullPath" in updates[0].patch));
|
|
238
|
+
assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
79
242
|
describe("validateMailboxOperation", () => {
|
|
80
243
|
it("throws when deleting INBOX", () => {
|
|
81
244
|
assert.throws(() => validateMailboxOperation("delete", "INBOX"), {
|
|
@@ -135,25 +135,41 @@ export class MailboxManagementService {
|
|
|
135
135
|
|
|
136
136
|
const result = await connection.createMailbox(path);
|
|
137
137
|
|
|
138
|
+
// The server is free to materialize the requested path under a namespace
|
|
139
|
+
// prefix — a Dovecot INBOX namespace turns "Notifications" into
|
|
140
|
+
// "INBOX/Notifications". `mailboxCreate` reports the canonical path the
|
|
141
|
+
// server assigned; adopt it as this row's identity so the next mailbox
|
|
142
|
+
// reconcile matches it by fullPath and updates it in place, rather than
|
|
143
|
+
// inserting a fresh row for the prefixed path and deleting this one — which
|
|
144
|
+
// would strand every filter and placement that references this mailboxId.
|
|
145
|
+
// Fall back to the requested path when the result carries no usable path,
|
|
146
|
+
// so a thin result never blanks the row's fullPath to undefined.
|
|
147
|
+
const serverPath =
|
|
148
|
+
typeof result.path === "string" && result.path.length > 0
|
|
149
|
+
? result.path
|
|
150
|
+
: path;
|
|
151
|
+
const pathUpdate = serverPath !== path ? { fullPath: serverPath } : {};
|
|
152
|
+
|
|
138
153
|
this.log.info(
|
|
139
|
-
{ mailboxId, path, created: result.created },
|
|
154
|
+
{ mailboxId, path, serverPath, created: result.created },
|
|
140
155
|
"Created mailbox on IMAP server",
|
|
141
156
|
);
|
|
142
157
|
|
|
143
158
|
if (subscribe) {
|
|
144
|
-
await connection.subscribeMailbox(
|
|
145
|
-
this.log.info({ mailboxId, path }, "Subscribed to mailbox");
|
|
159
|
+
await connection.subscribeMailbox(serverPath);
|
|
160
|
+
this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
|
|
146
161
|
}
|
|
147
162
|
|
|
148
163
|
// Refresh mailbox list to get UIDVALIDITY and other attributes
|
|
149
164
|
const mailboxes = await connection.listMailboxes();
|
|
150
|
-
const mailboxInfo = mailboxes.find((m) => m.fullPath ===
|
|
165
|
+
const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
|
|
151
166
|
|
|
152
167
|
if (mailboxInfo) {
|
|
153
168
|
// Open the mailbox to get UIDVALIDITY and other status info
|
|
154
|
-
const status = await connection.openBox(
|
|
169
|
+
const status = await connection.openBox(serverPath, true);
|
|
155
170
|
|
|
156
171
|
await this.mailboxService.update(accountId, mailboxId, {
|
|
172
|
+
...pathUpdate,
|
|
157
173
|
uidValidity: status.uidvalidity,
|
|
158
174
|
uidNext: status.uidnext,
|
|
159
175
|
messageCount: status.messages.total,
|
|
@@ -164,6 +180,7 @@ export class MailboxManagementService {
|
|
|
164
180
|
} else {
|
|
165
181
|
// Mark as synced even if we couldn't get full info
|
|
166
182
|
await this.mailboxService.update(accountId, mailboxId, {
|
|
183
|
+
...pathUpdate,
|
|
167
184
|
syncStatus: MailboxSyncStatus.synced,
|
|
168
185
|
});
|
|
169
186
|
}
|
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;
|