@remit/mailbox-service 0.0.38 → 0.0.40
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/README.md +8 -7
- package/package.json +1 -1
- package/src/body-parse.ts +1 -1
- package/src/mailbox-cursor.ts +2 -2
- package/src/mailbox-management.test.ts +4 -3
- package/src/mailbox-management.ts +60 -2
- package/src/mailbox-presence.ts +1 -1
- package/src/mailbox-queue.ts +1 -1
- package/src/mailbox-rename-subtree.test.ts +225 -0
- package/src/message-move.ts +1 -1
- package/src/mime-walker.ts +3 -3
- package/src/pass-through-unit-of-work.ts +3 -3
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# @remit/mailbox-service
|
|
2
2
|
|
|
3
|
-
IMAP mailbox synchronization service for Remit. Provides connection management, mailbox discovery, and message sync
|
|
3
|
+
IMAP mailbox synchronization service for Remit. Provides connection management, mailbox discovery, and message sync. Persistence is injected: the service takes repository ports, never a database client.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **ImapFlow-based**: Modern async/await IMAP client with native envelope parsing
|
|
8
|
-
- **Mailbox Sync**: Discovers and syncs mailbox metadata from IMAP
|
|
8
|
+
- **Mailbox Sync**: Discovers and syncs mailbox metadata from IMAP into the mailbox repository
|
|
9
9
|
- **Message Sync**: Newest-first sync strategy with dual-watermark tracking
|
|
10
10
|
- **Address Extraction**: Parses and stores envelope addresses with role tracking
|
|
11
11
|
|
|
@@ -55,10 +55,11 @@ await connection.disconnect();
|
|
|
55
55
|
```typescript
|
|
56
56
|
import { MailboxSyncService } from "@remit/mailbox-service";
|
|
57
57
|
|
|
58
|
-
const syncService = new MailboxSyncService(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const syncService = new MailboxSyncService(
|
|
59
|
+
mailboxRepository,
|
|
60
|
+
mailboxSpecialUseRepository,
|
|
61
|
+
logger,
|
|
62
|
+
);
|
|
62
63
|
|
|
63
64
|
const result = await syncService.syncMailboxes(
|
|
64
65
|
{ accountId: "acc-123" },
|
|
@@ -103,7 +104,7 @@ const synced = await messageSyncService.syncMessages(
|
|
|
103
104
|
|
|
104
105
|
| Export | Description |
|
|
105
106
|
| -------------------- | ----------------------------------------- |
|
|
106
|
-
| `MailboxSyncService` | Syncs mailbox list from IMAP
|
|
107
|
+
| `MailboxSyncService` | Syncs mailbox list from IMAP into the store |
|
|
107
108
|
| `MessageSyncService` | Syncs messages with newest-first strategy |
|
|
108
109
|
|
|
109
110
|
### Types
|
package/package.json
CHANGED
package/src/body-parse.ts
CHANGED
|
@@ -9,7 +9,7 @@ type FailureCode = QuarantineItem["failureCode"];
|
|
|
9
9
|
*
|
|
10
10
|
* This type is the whole reason the sync path can quarantine anything. Before
|
|
11
11
|
* it, every catch site on the body path saw one undifferentiated `unknown`
|
|
12
|
-
* covering mailparser, S3,
|
|
12
|
+
* covering mailparser, S3, the database and SQS alike, so "the message is built in
|
|
13
13
|
* a way Remit could not read" was indistinguishable from "S3 returned a 503".
|
|
14
14
|
* Recording the second as the first advances the cursor past mail that is
|
|
15
15
|
* perfectly fine and never fetches it again.
|
package/src/mailbox-cursor.ts
CHANGED
|
@@ -33,8 +33,8 @@ export interface MailboxCursorGuardDeps {
|
|
|
33
33
|
/**
|
|
34
34
|
* `cursorState` is total per RFC 032 (defaults to `normal`) — but that default
|
|
35
35
|
* only applies to rows written after this field existed. A row persisted
|
|
36
|
-
* before this migration (
|
|
37
|
-
*
|
|
36
|
+
* before this migration (the column genuinely NULL) reads back absent
|
|
37
|
+
* despite the type
|
|
38
38
|
* saying otherwise, so every consumer here treats `undefined` the same as
|
|
39
39
|
* `normal` defensively rather than trusting the type.
|
|
40
40
|
*/
|
|
@@ -99,7 +99,7 @@ const boxStatus = (path: string): ImapBoxStatus => ({
|
|
|
99
99
|
const recordingRepo = () => {
|
|
100
100
|
const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
|
|
101
101
|
[];
|
|
102
|
-
const repo = {
|
|
102
|
+
const repo: Pick<IMailboxRepository, "update" | "findByPathPrefix"> = {
|
|
103
103
|
update: async (
|
|
104
104
|
_accountId: string,
|
|
105
105
|
mailboxId: string,
|
|
@@ -108,8 +108,9 @@ const recordingRepo = () => {
|
|
|
108
108
|
updates.push({ mailboxId, patch });
|
|
109
109
|
return {} as never;
|
|
110
110
|
},
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
findByPathPrefix: async () => [],
|
|
112
|
+
};
|
|
113
|
+
return { repo: repo as IMailboxRepository, updates };
|
|
113
114
|
};
|
|
114
115
|
|
|
115
116
|
const stubConnection = (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IMailboxRepository } from "@remit/data-ports";
|
|
2
2
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
|
+
import { isNotFoundError } from "./mailbox-presence.js";
|
|
3
4
|
import type { IImapConnection } from "./types.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -100,7 +101,7 @@ export const validateMailboxOperation = (
|
|
|
100
101
|
* Service for managing mailbox operations (create, rename, delete).
|
|
101
102
|
*
|
|
102
103
|
* Implements an optimistic local-first pattern:
|
|
103
|
-
* 1. Updates are applied locally first
|
|
104
|
+
* 1. Updates are applied locally first
|
|
104
105
|
* 2. Changes are queued for IMAP sync via SQS
|
|
105
106
|
* 3. Worker processes queue and syncs to IMAP server
|
|
106
107
|
*/
|
|
@@ -220,9 +221,66 @@ export class MailboxManagementService {
|
|
|
220
221
|
syncStatus: MailboxSyncStatus.synced,
|
|
221
222
|
});
|
|
222
223
|
|
|
224
|
+
// The server rename is done and the row records it. The settle is repair
|
|
225
|
+
// work on top of that, so nothing it hits — the listing included — may reach
|
|
226
|
+
// the caller's failure path, which rolls the row back to a path the server
|
|
227
|
+
// no longer has and hands it to the reconcile sweep to reap.
|
|
228
|
+
await this.settleRenamedSubtree(accountId, newPath, connection).catch(
|
|
229
|
+
(error: unknown) => {
|
|
230
|
+
this.log.error(
|
|
231
|
+
{ mailboxId, newPath, error },
|
|
232
|
+
"Renamed subtree left pending",
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
223
237
|
return { success: true };
|
|
224
238
|
};
|
|
225
239
|
|
|
240
|
+
/**
|
|
241
|
+
* IMAP RENAME moves the whole subtree in one command, so the descendants the
|
|
242
|
+
* local rename marked pending are on the server the moment the parent's is.
|
|
243
|
+
* Nothing else settles them: the reconcile sweep treats pending as in-flight
|
|
244
|
+
* and leaves it alone, and a descendant left pending is read as off-server, so
|
|
245
|
+
* its sync events are terminally acked and its mail never arrives.
|
|
246
|
+
*
|
|
247
|
+
* A descendant is settled only where the server's own listing holds its path.
|
|
248
|
+
* A local path the server has not materialized is still in flight behind
|
|
249
|
+
* another queued operation, and stripping its pending marker would expose the
|
|
250
|
+
* row to the reconcile sweep.
|
|
251
|
+
*/
|
|
252
|
+
private settleRenamedSubtree = async (
|
|
253
|
+
accountId: string,
|
|
254
|
+
newPath: string,
|
|
255
|
+
connection: IImapConnection,
|
|
256
|
+
): Promise<void> => {
|
|
257
|
+
const listed = await connection.listMailboxes();
|
|
258
|
+
const onServer = new Set(listed.map((mailbox) => mailbox.fullPath));
|
|
259
|
+
const delimiter =
|
|
260
|
+
listed.find((mailbox) => mailbox.fullPath === newPath)?.delimiter ??
|
|
261
|
+
listed[0]?.delimiter ??
|
|
262
|
+
"/";
|
|
263
|
+
|
|
264
|
+
const descendants = await this.mailboxService.findByPathPrefix(
|
|
265
|
+
accountId,
|
|
266
|
+
newPath,
|
|
267
|
+
delimiter,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
for (const descendant of descendants) {
|
|
271
|
+
if (descendant.syncStatus !== MailboxSyncStatus.pending) continue;
|
|
272
|
+
if (!onServer.has(descendant.fullPath)) continue;
|
|
273
|
+
await this.mailboxService
|
|
274
|
+
.update(accountId, descendant.mailboxId, {
|
|
275
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
276
|
+
})
|
|
277
|
+
.catch((error: unknown) => {
|
|
278
|
+
if (isNotFoundError(error)) return;
|
|
279
|
+
throw error;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
226
284
|
/**
|
|
227
285
|
* Sync a DELETE operation to IMAP.
|
|
228
286
|
* Called by worker after dequeuing MAILBOX_DELETE event.
|
|
@@ -244,7 +302,7 @@ export class MailboxManagementService {
|
|
|
244
302
|
|
|
245
303
|
this.log.info({ mailboxId, path }, "Deleted mailbox on IMAP server");
|
|
246
304
|
|
|
247
|
-
// Delete the mailbox entity from
|
|
305
|
+
// Delete the mailbox entity from the local store
|
|
248
306
|
await this.mailboxService.delete(accountId, mailboxId);
|
|
249
307
|
|
|
250
308
|
return { success: true };
|
package/src/mailbox-presence.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
2
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
3
|
|
|
4
|
-
const isNotFoundError = (error: unknown): boolean =>
|
|
4
|
+
export const isNotFoundError = (error: unknown): boolean =>
|
|
5
5
|
error instanceof Error && error.name === "NotFoundError";
|
|
6
6
|
|
|
7
7
|
/**
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface MailboxQueueConfig {
|
|
|
83
83
|
* Service for mailbox management with automatic IMAP sync queueing.
|
|
84
84
|
*
|
|
85
85
|
* Implements optimistic local-first pattern:
|
|
86
|
-
* 1. Updates local
|
|
86
|
+
* 1. Updates local state immediately
|
|
87
87
|
* 2. Enqueues mailbox management event to SQS for worker to sync to IMAP
|
|
88
88
|
*
|
|
89
89
|
* This follows the same pattern as FlagQueueService (RFC 014).
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
|
+
import { MailboxManagementService } from "./mailbox-management.js";
|
|
6
|
+
import type { FlatMailboxInfo, IImapConnection } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const row = (
|
|
9
|
+
mailboxId: string,
|
|
10
|
+
fullPath: string,
|
|
11
|
+
syncStatus: MailboxItem["syncStatus"],
|
|
12
|
+
): MailboxItem =>
|
|
13
|
+
({
|
|
14
|
+
mailboxId,
|
|
15
|
+
accountId: "acc-1",
|
|
16
|
+
fullPath,
|
|
17
|
+
hierarchyDelimiter: "/",
|
|
18
|
+
syncStatus,
|
|
19
|
+
}) as MailboxItem;
|
|
20
|
+
|
|
21
|
+
const store = (rows: MailboxItem[], vanishAfterSweep: string[] = []) => {
|
|
22
|
+
const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
|
|
23
|
+
|
|
24
|
+
const repo: Pick<IMailboxRepository, "update" | "findByPathPrefix"> = {
|
|
25
|
+
update: async (_accountId, mailboxId, patch) => {
|
|
26
|
+
const existing = byId.get(mailboxId);
|
|
27
|
+
if (!existing) {
|
|
28
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
29
|
+
name: "NotFoundError",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const next = { ...existing, ...patch } as MailboxItem;
|
|
33
|
+
byId.set(mailboxId, next);
|
|
34
|
+
return next;
|
|
35
|
+
},
|
|
36
|
+
findByPathPrefix: async (_accountId, pathPrefix, delimiter = "/") => {
|
|
37
|
+
const prefix = `${pathPrefix}${delimiter}`;
|
|
38
|
+
const found = [...byId.values()].filter((r) =>
|
|
39
|
+
r.fullPath.startsWith(prefix),
|
|
40
|
+
);
|
|
41
|
+
for (const mailboxId of vanishAfterSweep) byId.delete(mailboxId);
|
|
42
|
+
return found;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
repo: repo as IMailboxRepository,
|
|
48
|
+
statusOf: (mailboxId: string) => byId.get(mailboxId)?.syncStatus,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const connectionListing = (paths: string[]): IImapConnection =>
|
|
53
|
+
({
|
|
54
|
+
renameMailbox: async () => undefined,
|
|
55
|
+
listMailboxes: async (): Promise<FlatMailboxInfo[]> =>
|
|
56
|
+
paths.map((fullPath) => ({
|
|
57
|
+
fullPath,
|
|
58
|
+
name: fullPath.split("/").pop() ?? fullPath,
|
|
59
|
+
delimiter: "/",
|
|
60
|
+
attributes: [],
|
|
61
|
+
parentPath: null,
|
|
62
|
+
})),
|
|
63
|
+
}) as unknown as IImapConnection;
|
|
64
|
+
|
|
65
|
+
const connectionWithoutListing = (): IImapConnection =>
|
|
66
|
+
({
|
|
67
|
+
renameMailbox: async () => undefined,
|
|
68
|
+
listMailboxes: async () => {
|
|
69
|
+
throw new Error("IMAP connection lost");
|
|
70
|
+
},
|
|
71
|
+
}) as unknown as IImapConnection;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The local rename writes the new path across the whole subtree and marks every
|
|
75
|
+
* row pending, so a reconcile in that window cannot reap them (#290). These
|
|
76
|
+
* fixtures are that state at the moment the worker picks the event up: the
|
|
77
|
+
* parent and its descendants already carry their new paths.
|
|
78
|
+
*/
|
|
79
|
+
describe("MailboxManagementService.syncRename — subtree settle", () => {
|
|
80
|
+
it("settles every descendant the rename carried, not just the renamed row", async () => {
|
|
81
|
+
const { repo, statusOf } = store([
|
|
82
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
83
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
84
|
+
row("mbx-grandchild", "Projects/2026/Q1", MailboxSyncStatus.pending),
|
|
85
|
+
]);
|
|
86
|
+
const service = new MailboxManagementService(repo);
|
|
87
|
+
|
|
88
|
+
const result = await service.syncRename(
|
|
89
|
+
"acc-1",
|
|
90
|
+
"mbx-parent",
|
|
91
|
+
"Work",
|
|
92
|
+
"Projects",
|
|
93
|
+
async () =>
|
|
94
|
+
connectionListing(["Projects", "Projects/2026", "Projects/2026/Q1"]),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
assert.equal(result.success, true);
|
|
98
|
+
assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
|
|
99
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
|
|
100
|
+
assert.equal(statusOf("mbx-grandchild"), MailboxSyncStatus.synced);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves a descendant behind a rename of its own pending", async () => {
|
|
104
|
+
// Two renames in one per-account FIFO group: `Projects` → `Work` moves the
|
|
105
|
+
// child row to `Work/2026`, then `Work/2026` → `Work/Archive` moves it
|
|
106
|
+
// again before the first RENAME is worked. When the first one lands the
|
|
107
|
+
// server holds `Work/2026`; `Work/Archive` exists only locally, and
|
|
108
|
+
// stripping its pending marker would let the reconcile sweep reap the row
|
|
109
|
+
// and rebuild it under a fresh mailboxId, dangling every filter bound to
|
|
110
|
+
// the old one.
|
|
111
|
+
const { repo, statusOf } = store([
|
|
112
|
+
row("mbx-parent", "Work", MailboxSyncStatus.pending),
|
|
113
|
+
row("mbx-child", "Work/Archive", MailboxSyncStatus.pending),
|
|
114
|
+
]);
|
|
115
|
+
const service = new MailboxManagementService(repo);
|
|
116
|
+
|
|
117
|
+
await service.syncRename(
|
|
118
|
+
"acc-1",
|
|
119
|
+
"mbx-parent",
|
|
120
|
+
"Projects",
|
|
121
|
+
"Work",
|
|
122
|
+
async () => connectionListing(["Work", "Work/2026"]),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
|
|
126
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.pending);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("leaves a descendant the user has asked to delete on its way out", async () => {
|
|
130
|
+
// A delete requested after the local rename wrote the subtree pending: the
|
|
131
|
+
// folder is still on the server, and settling it would undo the request.
|
|
132
|
+
const { repo, statusOf } = store([
|
|
133
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
134
|
+
row("mbx-deleting", "Projects/Old", MailboxSyncStatus.deleting),
|
|
135
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
136
|
+
]);
|
|
137
|
+
const service = new MailboxManagementService(repo);
|
|
138
|
+
|
|
139
|
+
await service.syncRename(
|
|
140
|
+
"acc-1",
|
|
141
|
+
"mbx-parent",
|
|
142
|
+
"Work",
|
|
143
|
+
"Projects",
|
|
144
|
+
async () =>
|
|
145
|
+
connectionListing(["Projects", "Projects/Old", "Projects/2026"]),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
assert.equal(statusOf("mbx-deleting"), MailboxSyncStatus.deleting);
|
|
149
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("settles inside the renamed subtree without reaching a lookalike sibling", async () => {
|
|
153
|
+
const { repo, statusOf } = store([
|
|
154
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
155
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
156
|
+
row("mbx-lookalike", "Projectsy/2026", MailboxSyncStatus.pending),
|
|
157
|
+
]);
|
|
158
|
+
const service = new MailboxManagementService(repo);
|
|
159
|
+
|
|
160
|
+
await service.syncRename(
|
|
161
|
+
"acc-1",
|
|
162
|
+
"mbx-parent",
|
|
163
|
+
"Work",
|
|
164
|
+
"Projects",
|
|
165
|
+
async () =>
|
|
166
|
+
connectionListing(["Projects", "Projects/2026", "Projectsy/2026"]),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
|
|
170
|
+
assert.equal(statusOf("mbx-lookalike"), MailboxSyncStatus.pending);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("reports the rename as done when a descendant row vanishes mid-settle", async () => {
|
|
174
|
+
// The settle is repair work, not the mutation. A descendant deleted while
|
|
175
|
+
// it runs has nothing left to settle, and must not roll a completed server
|
|
176
|
+
// rename back to failed — the handler's catch treats any throw from here
|
|
177
|
+
// as an IMAP failure and restores the old path.
|
|
178
|
+
const { repo, statusOf } = store(
|
|
179
|
+
[
|
|
180
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
181
|
+
row("mbx-gone", "Projects/Gone", MailboxSyncStatus.pending),
|
|
182
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
183
|
+
],
|
|
184
|
+
["mbx-gone"],
|
|
185
|
+
);
|
|
186
|
+
const service = new MailboxManagementService(repo);
|
|
187
|
+
|
|
188
|
+
const result = await service.syncRename(
|
|
189
|
+
"acc-1",
|
|
190
|
+
"mbx-parent",
|
|
191
|
+
"Work",
|
|
192
|
+
"Projects",
|
|
193
|
+
async () =>
|
|
194
|
+
connectionListing(["Projects", "Projects/Gone", "Projects/2026"]),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
assert.equal(result.success, true);
|
|
198
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("reports the rename as done when the listing it settles against fails", async () => {
|
|
202
|
+
// The listing runs after the server rename has landed. Letting it fail the
|
|
203
|
+
// rename would send the caller down its IMAP-failure path, restoring a path
|
|
204
|
+
// the server no longer has and marking the row failed — which drops the
|
|
205
|
+
// pending marker that keeps the reconcile sweep from reaping it. A
|
|
206
|
+
// descendant left pending is the safe outcome.
|
|
207
|
+
const { repo, statusOf } = store([
|
|
208
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
209
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
210
|
+
]);
|
|
211
|
+
const service = new MailboxManagementService(repo);
|
|
212
|
+
|
|
213
|
+
const result = await service.syncRename(
|
|
214
|
+
"acc-1",
|
|
215
|
+
"mbx-parent",
|
|
216
|
+
"Work",
|
|
217
|
+
"Projects",
|
|
218
|
+
async () => connectionWithoutListing(),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(result.success, true);
|
|
222
|
+
assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
|
|
223
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.pending);
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/message-move.ts
CHANGED
|
@@ -115,7 +115,7 @@ export interface DeleteOptions {
|
|
|
115
115
|
* Service for moving, copying, and deleting messages.
|
|
116
116
|
*
|
|
117
117
|
* Implements optimistic local-first pattern:
|
|
118
|
-
* 1. Updates local
|
|
118
|
+
* 1. Updates local state immediately (Message + ThreadMessage)
|
|
119
119
|
* 2. Enqueues event to SQS for worker to sync to IMAP
|
|
120
120
|
*
|
|
121
121
|
* Following RFC 016 for message deletion and moving.
|
package/src/mime-walker.ts
CHANGED
|
@@ -266,8 +266,8 @@ const toRecord = (
|
|
|
266
266
|
* **Part-path uniqueness**: some IMAP servers (and the `message/rfc822`
|
|
267
267
|
* inner-body convention) return child nodes with an empty `part` field.
|
|
268
268
|
* Assigning ROOT_PART_PATH to every such node would produce duplicate keys
|
|
269
|
-
* and
|
|
270
|
-
*
|
|
269
|
+
* and make `upsertBodyParts` write the same body part twice. Non-root nodes
|
|
270
|
+
* without a `part` therefore receive a
|
|
271
271
|
* synthetic path `<parentPath>.<siblingIndex>` that is stable across
|
|
272
272
|
* repeated syncs of the same message.
|
|
273
273
|
*/
|
|
@@ -287,7 +287,7 @@ export const walkMimeStructure = (root: MimeNode): BodyPartRecord[] => {
|
|
|
287
287
|
partPath = ROOT_PART_PATH;
|
|
288
288
|
} else {
|
|
289
289
|
// Non-root node without an IMAP part path — synthesise one so
|
|
290
|
-
// the
|
|
290
|
+
// the body-part keys remain unique. This happens most commonly
|
|
291
291
|
// for the body of a message/rfc822 attachment, whose inner
|
|
292
292
|
// structure imapflow attaches as a childNode with part="".
|
|
293
293
|
partPath = `${parentPath}.${siblingIndex}`;
|
|
@@ -2,9 +2,9 @@ import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Runs the write set against fixed repositories with no surrounding
|
|
5
|
-
* transaction. For
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* transaction: the writes are not atomic. For a backend that has no
|
|
6
|
+
* cross-entity transaction this matches its own guarantees; the SQLite path
|
|
7
|
+
* injects a real transactional unit of work instead.
|
|
8
8
|
*/
|
|
9
9
|
export class PassThroughUnitOfWork implements IUnitOfWork {
|
|
10
10
|
constructor(private repos: UnitOfWorkRepositories) {}
|