@remit/mailbox-service 0.0.27 → 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/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
|
}
|