@remit/mailbox-service 0.0.97 → 0.0.98
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-delete-intent.test.ts +246 -0
- package/src/mailbox-management.ts +91 -3
- package/src/mailbox-queue.ts +24 -8
package/package.json
CHANGED
|
@@ -0,0 +1,246 @@
|
|
|
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 { MailboxQueueService } from "./mailbox-queue.js";
|
|
7
|
+
import type { IImapConnection } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const row = (over: Partial<MailboxItem> = {}): MailboxItem =>
|
|
10
|
+
({
|
|
11
|
+
mailboxId: "mbx-1",
|
|
12
|
+
accountId: "acc-1",
|
|
13
|
+
fullPath: "Receipts",
|
|
14
|
+
hierarchyDelimiter: "/",
|
|
15
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
16
|
+
...over,
|
|
17
|
+
}) as MailboxItem;
|
|
18
|
+
|
|
19
|
+
const store = (rows: MailboxItem[]) => {
|
|
20
|
+
const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
|
|
21
|
+
const removed: string[] = [];
|
|
22
|
+
|
|
23
|
+
const repo: Pick<
|
|
24
|
+
IMailboxRepository,
|
|
25
|
+
"get" | "transition" | "findByPathPrefix" | "deleteMailboxWithMail"
|
|
26
|
+
> = {
|
|
27
|
+
get: (async (_accountId: string, mailboxId: string) => {
|
|
28
|
+
const found = byId.get(mailboxId as string);
|
|
29
|
+
if (!found) {
|
|
30
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
31
|
+
name: "NotFoundError",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return found;
|
|
35
|
+
}) as IMailboxRepository["get"],
|
|
36
|
+
findByPathPrefix: async () => [],
|
|
37
|
+
transition: async (_accountId, mailboxId, intent) => {
|
|
38
|
+
const current = byId.get(mailboxId);
|
|
39
|
+
if (!current) return null;
|
|
40
|
+
if (!intent.from.includes(current.syncStatus)) return null;
|
|
41
|
+
const next = { ...current, syncStatus: intent.to } as MailboxItem;
|
|
42
|
+
byId.set(mailboxId, next);
|
|
43
|
+
return next;
|
|
44
|
+
},
|
|
45
|
+
deleteMailboxWithMail: async (_accountId, mailboxId) => {
|
|
46
|
+
removed.push(mailboxId);
|
|
47
|
+
byId.delete(mailboxId);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return { repo: repo as IMailboxRepository, byId, removed };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const queueOver = (repo: IMailboxRepository) => {
|
|
55
|
+
const sent: string[] = [];
|
|
56
|
+
const service = new MailboxQueueService({
|
|
57
|
+
mailboxService: repo,
|
|
58
|
+
sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
|
|
59
|
+
});
|
|
60
|
+
(
|
|
61
|
+
service as unknown as {
|
|
62
|
+
sqs: {
|
|
63
|
+
send: (command: { input: { MessageBody: string } }) => Promise<void>;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
).sqs = {
|
|
67
|
+
send: async (command) => {
|
|
68
|
+
sent.push(command.input.MessageBody);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
return { service, sent };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe("MailboxQueueService.deleteMailbox — the recorded intent", () => {
|
|
75
|
+
it("records `deleting` and enqueues one DELETE", async () => {
|
|
76
|
+
const { repo, byId } = store([row()]);
|
|
77
|
+
const { service, sent } = queueOver(repo);
|
|
78
|
+
|
|
79
|
+
await service.deleteMailbox("mbx-1", "acc-1");
|
|
80
|
+
|
|
81
|
+
assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.deleting);
|
|
82
|
+
assert.equal(sent.length, 1);
|
|
83
|
+
assert.equal(
|
|
84
|
+
(JSON.parse(sent[0]) as { path: string }).path,
|
|
85
|
+
"Receipts",
|
|
86
|
+
"the event names the path the server holds",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("accepts a retry from a failed delete", async () => {
|
|
91
|
+
const { repo, byId } = store([
|
|
92
|
+
row({ syncStatus: MailboxSyncStatus.failed }),
|
|
93
|
+
]);
|
|
94
|
+
const { service, sent } = queueOver(repo);
|
|
95
|
+
|
|
96
|
+
await service.deleteMailbox("mbx-1", "acc-1");
|
|
97
|
+
|
|
98
|
+
assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.deleting);
|
|
99
|
+
assert.equal(sent.length, 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("gives the loser a 409 naming the delete already in flight", async () => {
|
|
103
|
+
const { repo } = store([row({ syncStatus: MailboxSyncStatus.deleting })]);
|
|
104
|
+
const { service, sent } = queueOver(repo);
|
|
105
|
+
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
service.deleteMailbox("mbx-1", "acc-1"),
|
|
108
|
+
(error: unknown) =>
|
|
109
|
+
(error as { statusCode?: number }).statusCode === 409 &&
|
|
110
|
+
(error as Error).message ===
|
|
111
|
+
'A delete is already in progress for "Receipts".',
|
|
112
|
+
);
|
|
113
|
+
assert.equal(sent.length, 0, "the loser enqueues nothing");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("gives the loser a 409 naming a rename in flight", async () => {
|
|
117
|
+
const { repo } = store([
|
|
118
|
+
row({
|
|
119
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
120
|
+
pendingPath: "Bonnen",
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
123
|
+
const { service } = queueOver(repo);
|
|
124
|
+
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
service.deleteMailbox("mbx-1", "acc-1"),
|
|
127
|
+
(error: unknown) =>
|
|
128
|
+
(error as Error).message === 'A rename is in progress for "Receipts".',
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("MailboxManagementService.syncDelete — settling the intent", () => {
|
|
134
|
+
const deleting = () =>
|
|
135
|
+
store([row({ syncStatus: MailboxSyncStatus.deleting })]);
|
|
136
|
+
|
|
137
|
+
const connection = (calls: string[]): IImapConnection =>
|
|
138
|
+
({
|
|
139
|
+
deleteMailbox: async (path: string) => {
|
|
140
|
+
calls.push(path);
|
|
141
|
+
return { path };
|
|
142
|
+
},
|
|
143
|
+
}) as unknown as IImapConnection;
|
|
144
|
+
|
|
145
|
+
it("takes the folder's mail with it, not the row alone", async () => {
|
|
146
|
+
// Removing the row alone leaves every message and thread_message row keyed
|
|
147
|
+
// to a dead mailboxId — out of every reader, and still in the search index
|
|
148
|
+
// because nothing writes the `message.removed` outbox event (D8).
|
|
149
|
+
const { repo, removed, byId } = deleting();
|
|
150
|
+
const service = new MailboxManagementService(repo);
|
|
151
|
+
const calls: string[] = [];
|
|
152
|
+
|
|
153
|
+
const result = await service.syncDelete(
|
|
154
|
+
"acc-1",
|
|
155
|
+
"mbx-1",
|
|
156
|
+
"Receipts",
|
|
157
|
+
async () => connection(calls),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
assert.equal(result.success, true);
|
|
161
|
+
assert.deepEqual(calls, ["Receipts"]);
|
|
162
|
+
assert.deepEqual(removed, ["mbx-1"]);
|
|
163
|
+
assert.equal(byId.has("mbx-1"), false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("issues no DELETE when the row is not deleting", async () => {
|
|
167
|
+
const { repo, removed } = store([row()]);
|
|
168
|
+
const service = new MailboxManagementService(repo);
|
|
169
|
+
const calls: string[] = [];
|
|
170
|
+
|
|
171
|
+
const result = await service.syncDelete(
|
|
172
|
+
"acc-1",
|
|
173
|
+
"mbx-1",
|
|
174
|
+
"Receipts",
|
|
175
|
+
async () => connection(calls),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
assert.equal(result.success, true);
|
|
179
|
+
assert.deepEqual(calls, [], "the connection is never asked");
|
|
180
|
+
assert.deepEqual(removed, []);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("resolves when the row is already gone", async () => {
|
|
184
|
+
const { repo } = store([]);
|
|
185
|
+
const service = new MailboxManagementService(repo);
|
|
186
|
+
const calls: string[] = [];
|
|
187
|
+
|
|
188
|
+
const result = await service.syncDelete(
|
|
189
|
+
"acc-1",
|
|
190
|
+
"mbx-1",
|
|
191
|
+
"Receipts",
|
|
192
|
+
async () => connection(calls),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
assert.equal(result.success, true);
|
|
196
|
+
assert.deepEqual(calls, []);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("resumes an interrupted removal on a redelivery", async () => {
|
|
200
|
+
// The row stays `deleting` until the final commit, so a redelivery
|
|
201
|
+
// re-enters, passes the guard and continues where it stopped.
|
|
202
|
+
const { repo, removed } = deleting();
|
|
203
|
+
const service = new MailboxManagementService(repo);
|
|
204
|
+
const calls: string[] = [];
|
|
205
|
+
|
|
206
|
+
await service.settleDelete("acc-1", "mbx-1");
|
|
207
|
+
const result = await service.syncDelete(
|
|
208
|
+
"acc-1",
|
|
209
|
+
"mbx-1",
|
|
210
|
+
"Receipts",
|
|
211
|
+
async () => connection(calls),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
assert.equal(result.success, true);
|
|
215
|
+
assert.deepEqual(calls, [], "no second IMAP DELETE is issued");
|
|
216
|
+
assert.deepEqual(removed, ["mbx-1"]);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("MailboxManagementService.failDelete", () => {
|
|
221
|
+
it("marks the row failed, unwinding nothing", async () => {
|
|
222
|
+
// DELETE is all-or-nothing, so fullPath is still what the server holds,
|
|
223
|
+
// and no rename target is written — which is how the client tells a failed
|
|
224
|
+
// delete from a failed rename (T9).
|
|
225
|
+
const { repo, byId } = store([
|
|
226
|
+
row({ syncStatus: MailboxSyncStatus.deleting }),
|
|
227
|
+
]);
|
|
228
|
+
const service = new MailboxManagementService(repo);
|
|
229
|
+
|
|
230
|
+
await service.failDelete("acc-1", "mbx-1");
|
|
231
|
+
|
|
232
|
+
assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.failed);
|
|
233
|
+
assert.equal(byId.get("mbx-1")?.fullPath, "Receipts");
|
|
234
|
+
assert.equal(byId.get("mbx-1")?.pendingPath, undefined);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("raises a NotFoundError when the row is gone", async () => {
|
|
238
|
+
const { repo } = store([]);
|
|
239
|
+
const service = new MailboxManagementService(repo);
|
|
240
|
+
|
|
241
|
+
await assert.rejects(
|
|
242
|
+
service.failDelete("acc-1", "mbx-1"),
|
|
243
|
+
(error: unknown) => (error as Error).name === "NotFoundError",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -705,15 +705,103 @@ export class MailboxManagementService {
|
|
|
705
705
|
path: string,
|
|
706
706
|
getConnection: () => Promise<IImapConnection>,
|
|
707
707
|
): Promise<MailboxManagementSyncResult> => {
|
|
708
|
+
if (!(await this.deleteIntentStanding(accountId, mailboxId))) {
|
|
709
|
+
return { success: true };
|
|
710
|
+
}
|
|
711
|
+
|
|
708
712
|
const connection = await getConnection();
|
|
709
713
|
|
|
710
714
|
await connection.deleteMailbox(path);
|
|
711
715
|
|
|
712
|
-
this.log.info(
|
|
716
|
+
this.log.info(
|
|
717
|
+
{ accountId, mailboxId, intent: "delete", path },
|
|
718
|
+
"Deleted mailbox on IMAP server",
|
|
719
|
+
);
|
|
713
720
|
|
|
714
|
-
|
|
715
|
-
await this.mailboxService.delete(accountId, mailboxId);
|
|
721
|
+
await this.settleDelete(accountId, mailboxId);
|
|
716
722
|
|
|
717
723
|
return { success: true };
|
|
718
724
|
};
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Whether the delete this event was enqueued for is still the folder's live
|
|
728
|
+
* intent (D10). Anything else resolves the job with no IMAP call — never a
|
|
729
|
+
* throw, which would hold back every later job in the account's FIFO group
|
|
730
|
+
* (#339).
|
|
731
|
+
*/
|
|
732
|
+
private deleteIntentStanding = async (
|
|
733
|
+
accountId: string,
|
|
734
|
+
mailboxId: string,
|
|
735
|
+
): Promise<boolean> => {
|
|
736
|
+
const row = await this.mailboxService
|
|
737
|
+
.get(accountId, mailboxId)
|
|
738
|
+
.catch((error: unknown) => {
|
|
739
|
+
if (isNotFoundError(error)) return undefined;
|
|
740
|
+
throw error;
|
|
741
|
+
});
|
|
742
|
+
if (row?.syncStatus === MailboxSyncStatus.deleting) return true;
|
|
743
|
+
this.log.info(
|
|
744
|
+
{
|
|
745
|
+
accountId,
|
|
746
|
+
mailboxId,
|
|
747
|
+
intent: "delete",
|
|
748
|
+
from: row?.syncStatus,
|
|
749
|
+
outcome: row ? "superseded" : "already-settled",
|
|
750
|
+
},
|
|
751
|
+
"Skipping MAILBOX_DELETE: the recorded intent has moved on",
|
|
752
|
+
);
|
|
753
|
+
return false;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* T8: the confirmed delete takes the folder's local mail with it (D8).
|
|
758
|
+
* Removing the row alone leaves every `message` and `thread_message` row
|
|
759
|
+
* keyed to a dead mailboxId — out of every reader, and still in the search
|
|
760
|
+
* index, because nothing writes the `message.removed` outbox event.
|
|
761
|
+
*
|
|
762
|
+
* The removal is batched and the mailbox row goes last, so the row stays
|
|
763
|
+
* `deleting` throughout and an SQS redelivery re-enters the handler, passes
|
|
764
|
+
* the guard above, and resumes where it stopped.
|
|
765
|
+
*/
|
|
766
|
+
settleDelete = async (
|
|
767
|
+
accountId: string,
|
|
768
|
+
mailboxId: string,
|
|
769
|
+
): Promise<void> => {
|
|
770
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
|
|
771
|
+
this.log.info(
|
|
772
|
+
{
|
|
773
|
+
accountId,
|
|
774
|
+
mailboxId,
|
|
775
|
+
intent: "delete",
|
|
776
|
+
from: MailboxSyncStatus.deleting,
|
|
777
|
+
outcome: "settled",
|
|
778
|
+
},
|
|
779
|
+
"Removed folder and its mail",
|
|
780
|
+
);
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* T9: the server refused the delete. Nothing is unwound — DELETE is
|
|
785
|
+
* all-or-nothing, so `fullPath` is still what the server holds, and no rename
|
|
786
|
+
* target is written, which is how the client tells a failed delete from a
|
|
787
|
+
* failed rename.
|
|
788
|
+
*/
|
|
789
|
+
failDelete = async (accountId: string, mailboxId: string): Promise<void> => {
|
|
790
|
+
const failed = await this.mailboxService.transition(accountId, mailboxId, {
|
|
791
|
+
from: [MailboxSyncStatus.deleting],
|
|
792
|
+
to: MailboxSyncStatus.failed,
|
|
793
|
+
});
|
|
794
|
+
if (!failed) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
795
|
+
this.log.info(
|
|
796
|
+
{
|
|
797
|
+
accountId,
|
|
798
|
+
mailboxId,
|
|
799
|
+
intent: "delete",
|
|
800
|
+
from: MailboxSyncStatus.deleting,
|
|
801
|
+
to: MailboxSyncStatus.failed,
|
|
802
|
+
outcome: "refused",
|
|
803
|
+
},
|
|
804
|
+
"Recorded a refused delete",
|
|
805
|
+
);
|
|
806
|
+
};
|
|
719
807
|
}
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
recordedByRename,
|
|
15
15
|
refuseContestedIntent,
|
|
16
16
|
} from "./mailbox-intent.js";
|
|
17
|
-
import { EVERY_MAILBOX_STATE } from "./mailbox-presence.js";
|
|
18
17
|
|
|
19
18
|
/**
|
|
20
19
|
* MAILBOX_CREATE event structure (matches remit-imap-worker/events.ts)
|
|
@@ -330,8 +329,12 @@ export class MailboxQueueService {
|
|
|
330
329
|
};
|
|
331
330
|
|
|
332
331
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
332
|
+
* Record a delete intent (T7) and enqueue IMAP DELETE.
|
|
333
|
+
*
|
|
334
|
+
* The row stays at `deleting` and stays visible until the worker confirms the
|
|
335
|
+
* server delete and removes it with the folder's mail (D8, D11). The enqueue
|
|
336
|
+
* happens only after the transition wins, and an enqueue failure stays thrown
|
|
337
|
+
* — a 500 to the caller, loud — rather than being swallowed or rolled back.
|
|
335
338
|
*
|
|
336
339
|
* @param mailboxId - The mailbox to delete
|
|
337
340
|
* @param accountId - The account ID for the IMAP sync event
|
|
@@ -343,17 +346,30 @@ export class MailboxQueueService {
|
|
|
343
346
|
// Get current mailbox to capture path
|
|
344
347
|
const mailbox = await this.mailboxService.get(accountId, mailboxId);
|
|
345
348
|
|
|
346
|
-
// Mark as deleting (soft delete - worker will do actual delete after IMAP sync)
|
|
347
349
|
const recorded = await this.mailboxService.transition(
|
|
348
350
|
accountId,
|
|
349
351
|
mailboxId,
|
|
350
|
-
{ from:
|
|
352
|
+
{ from: INTENT_RECORDABLE_FROM, to: MailboxSyncStatus.deleting },
|
|
351
353
|
);
|
|
352
|
-
if (!recorded)
|
|
354
|
+
if (!recorded) {
|
|
355
|
+
await refuseContestedIntent(
|
|
356
|
+
this.mailboxService,
|
|
357
|
+
accountId,
|
|
358
|
+
mailboxId,
|
|
359
|
+
"folder",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
353
362
|
|
|
354
363
|
this.log.info(
|
|
355
|
-
{
|
|
356
|
-
|
|
364
|
+
{
|
|
365
|
+
accountId,
|
|
366
|
+
mailboxId,
|
|
367
|
+
intent: "delete",
|
|
368
|
+
from: mailbox.syncStatus,
|
|
369
|
+
to: MailboxSyncStatus.deleting,
|
|
370
|
+
path: mailbox.fullPath,
|
|
371
|
+
},
|
|
372
|
+
"Recorded delete intent",
|
|
357
373
|
);
|
|
358
374
|
|
|
359
375
|
// Enqueue IMAP sync
|