@remit/mailbox-service 0.0.66 → 0.0.68

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.66",
3
+ "version": "0.0.68",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -191,6 +191,9 @@ export {
191
191
  type MessageMoveLogger,
192
192
  MessageMoveService,
193
193
  NoTrashMailboxError,
194
+ requireTrashMailbox,
195
+ StaleTrashAppointmentError,
196
+ UnconfirmedTrashMailboxError,
194
197
  } from "./message-move.js";
195
198
  export {
196
199
  type ParsedMessageContent,
@@ -8,6 +8,7 @@ import type {
8
8
  IThreadMessageRepository,
9
9
  } from "@remit/data-ports";
10
10
  import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
11
+ import { trashRole } from "./test-helpers/folder-roles.js";
11
12
 
12
13
  const ACCOUNT = "acc-1";
13
14
  const ACCOUNT_CONFIG = "cfg-1";
@@ -51,9 +52,11 @@ const buildWorld = (trashExists: boolean) => {
51
52
  ],
52
53
  } as unknown as IMailboxRepository;
53
54
 
55
+ const trash = trashExists ? { mailboxId: TRASH, fullPath: "Trash" } : null;
56
+
54
57
  const mailboxSpecialUseService = {
55
- findTrashMailbox: async () =>
56
- trashExists ? { mailboxId: TRASH, fullPath: "Trash" } : null,
58
+ findTrashMailbox: async () => trash,
59
+ resolveTrashRole: async () => trashRole(trash),
57
60
  } as unknown as IMailboxSpecialUseRepository;
58
61
 
59
62
  const addressService = {
@@ -7,10 +7,12 @@ import type {
7
7
  IMessageRepository,
8
8
  IThreadMessageRepository,
9
9
  } from "@remit/data-ports";
10
+ import type { RoleResolution } from "@remit/data-ports/folder-role";
10
11
  import {
11
12
  type MessageMoveConfig,
12
13
  MessageMoveService,
13
14
  NoTrashMailboxError,
15
+ StaleTrashAppointmentError,
14
16
  } from "./message-move.js";
15
17
 
16
18
  const ACCOUNT = "acc-1";
@@ -19,13 +21,15 @@ const INBOX = "mbx-inbox";
19
21
  const TRASH = "mbx-trash";
20
22
  const MESSAGE_ID = "msg-1";
21
23
 
24
+ type TrashMailbox = { mailboxId: string; fullPath: string };
25
+
22
26
  interface EnqueuedEvent {
23
27
  operation: string;
24
28
  destinationMailboxPath?: string;
25
29
  }
26
30
 
27
31
  const buildWorld = (
28
- trash: { mailboxId: string; fullPath: string } | null,
32
+ trashResolution: RoleResolution<TrashMailbox>,
29
33
  startingMailboxId = INBOX,
30
34
  ) => {
31
35
  const patches: Array<Record<string, unknown>> = [];
@@ -74,7 +78,7 @@ const buildWorld = (
74
78
  } as unknown as IMailboxRepository;
75
79
 
76
80
  const mailboxSpecialUseService = {
77
- findTrashMailbox: async () => trash,
81
+ resolveTrashRole: async () => trashResolution,
78
82
  } as unknown as IMailboxSpecialUseRepository;
79
83
 
80
84
  const addressService = {
@@ -102,12 +106,14 @@ const buildWorld = (
102
106
  return { service, patches, events, message };
103
107
  };
104
108
 
109
+ const flaggedTrash: RoleResolution<TrashMailbox> = {
110
+ kind: "flagged",
111
+ mailbox: { mailboxId: TRASH, fullPath: "INBOX/Trash" },
112
+ };
113
+
105
114
  describe("delete only expunges when it was asked to", () => {
106
115
  it("moves to a Trash the account resolves under a nested path", async () => {
107
- const { service, events, message } = buildWorld({
108
- mailboxId: TRASH,
109
- fullPath: "INBOX/Trash",
110
- });
116
+ const { service, events, message } = buildWorld(flaggedTrash);
111
117
 
112
118
  await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
113
119
 
@@ -120,13 +126,14 @@ describe("delete only expunges when it was asked to", () => {
120
126
  });
121
127
 
122
128
  it("refuses, and touches nothing, when no Trash resolves", async () => {
123
- const { service, patches, events, message } = buildWorld(null);
129
+ const { service, patches, events, message } = buildWorld({ kind: "none" });
124
130
 
125
131
  await assert.rejects(
126
132
  () => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
127
133
  (error: unknown) =>
128
134
  error instanceof NoTrashMailboxError &&
129
135
  error.statusCode === 409 &&
136
+ error.publicApiError?.details?.reason === "none" &&
130
137
  error.message.includes("no Trash folder"),
131
138
  );
132
139
 
@@ -136,12 +143,53 @@ describe("delete only expunges when it was asked to", () => {
136
143
  assert.equal(message.status, "active");
137
144
  });
138
145
 
139
- it("expunges only when the caller asked for a permanent delete", async () => {
140
- const { service, events } = buildWorld({
141
- mailboxId: TRASH,
142
- fullPath: "INBOX/Trash",
146
+ it("refuses a stale appointment rather than filing into the fallback", async () => {
147
+ // #887 Done item 2: the user appointed a folder another client has since
148
+ // deleted. Silently filing 200 messages into the flagged folder overrides
149
+ // a choice they made and cannot see was lost.
150
+ const { service, patches, events, message } = buildWorld({
151
+ kind: "appointment_stale",
152
+ appointedMailboxId: "mbx-appointed-and-gone",
153
+ fallback: flaggedTrash,
154
+ });
155
+
156
+ await assert.rejects(
157
+ () => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
158
+ (error: unknown) =>
159
+ error instanceof StaleTrashAppointmentError &&
160
+ error.statusCode === 409 &&
161
+ error.publicApiError?.details?.reason === "stale" &&
162
+ error.publicApiError?.details?.accountId === ACCOUNT,
163
+ );
164
+
165
+ assert.deepEqual(events, []);
166
+ assert.deepEqual(patches, []);
167
+ assert.equal(message.mailboxId, INBOX);
168
+ assert.equal(message.status, "active");
169
+ });
170
+
171
+ it("files into a Trash that resolves by name alone", async () => {
172
+ // A name guess is enough to move mail somewhere retrievable; only the
173
+ // expunge demands more (D4).
174
+ const { service, events, message } = buildWorld({
175
+ kind: "proposed",
176
+ mailbox: { mailboxId: TRASH, fullPath: "Trash" },
143
177
  });
144
178
 
179
+ await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
180
+
181
+ assert.deepEqual(
182
+ events.map((event) => event.operation),
183
+ ["move_to_trash"],
184
+ );
185
+ assert.equal(message.mailboxId, TRASH);
186
+ });
187
+
188
+ it("expunges only when the caller asked for a permanent delete", async () => {
189
+ // fc685509: an explicit permanent delete never resolves Trash at all, so
190
+ // an account with none can still empty a message it selected.
191
+ const { service, events } = buildWorld({ kind: "none" });
192
+
145
193
  await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT, {
146
194
  permanent: true,
147
195
  });
@@ -152,9 +200,11 @@ describe("delete only expunges when it was asked to", () => {
152
200
  );
153
201
  });
154
202
 
155
- it("expunges a message already in Trash, which the dialog asks as such", async () => {
203
+ it("expunges a message already inside a Trash that resolves by name", async () => {
204
+ // D4a: the rows were selected and the dialog named the consequence, so
205
+ // consent is per message. Empty Trash has no such consent and refuses.
156
206
  const { service, events } = buildWorld(
157
- { mailboxId: TRASH, fullPath: "INBOX/Trash" },
207
+ { kind: "proposed", mailbox: { mailboxId: TRASH, fullPath: "Trash" } },
158
208
  TRASH,
159
209
  );
160
210
 
@@ -15,7 +15,7 @@ import { MailboxCursorState } from "@remit/domain-enums";
15
15
  import type { ManagedConnectionFactory } from "./connection-factory.js";
16
16
  import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
17
17
  import { MessageSyncService } from "./message-sync.js";
18
- import { noFolderRoles } from "./test-helpers/folder-roles.js";
18
+ import { noFolderRoles, trashRole } from "./test-helpers/folder-roles.js";
19
19
  import type { IImapConnection, ImapMessage } from "./types.js";
20
20
 
21
21
  const stubAddressService = (): IAddressRepository =>
@@ -175,6 +175,7 @@ const buildWorld = () => {
175
175
  // where a copy's placement row must be reachable.
176
176
  const mailboxSpecialUseService = {
177
177
  findTrashMailbox: async () => null,
178
+ resolveTrashRole: async () => trashRole(null),
178
179
  } as unknown as IMailboxSpecialUseRepository;
179
180
 
180
181
  const config: MessageMoveConfig = {
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Empty Trash is an EXPUNGE with no undo, so it resolves its folder through
3
- * `findConfirmedTrashMailbox` — what the user appointed, or what the server
4
- * flagged `\Trash`. The name proposal that serves every other lookup is a
5
- * guess, and a wrong guess here destroys mail (#837, audit #841).
2
+ * Empty Trash is an EXPUNGE with no undo, so it demands confirmed evidence:
3
+ * what the user appointed, or what the server flagged `\Trash`. The name
4
+ * proposal that serves every other lookup is a guess, and a wrong guess here
5
+ * destroys mail (#837, audit #841). Each way that evidence can be missing
6
+ * refuses under its own reason, so the surface can name the repair (#887).
6
7
  */
7
8
  import assert from "node:assert/strict";
8
9
  import { describe, it } from "node:test";
@@ -13,20 +14,58 @@ import type {
13
14
  IMessageRepository,
14
15
  IThreadMessageRepository,
15
16
  } from "@remit/data-ports";
16
- import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
17
+ import type { RoleResolution } from "@remit/data-ports/folder-role";
18
+ import {
19
+ type MessageMoveConfig,
20
+ MessageMoveService,
21
+ NoTrashMailboxError,
22
+ StaleTrashAppointmentError,
23
+ UnconfirmedTrashMailboxError,
24
+ } from "./message-move.js";
17
25
 
18
26
  const ACCOUNT = "acc-1";
19
27
  const ACCOUNT_CONFIG = "cfg-1";
20
28
  const DELETED_FOLDER = "mbx-deleted";
21
29
  const REAL_TRASH = "mbx-trash";
22
30
 
31
+ type TrashMailbox = { mailboxId: string; fullPath: string };
32
+
33
+ const appointedTrash: RoleResolution<TrashMailbox> = {
34
+ kind: "appointed",
35
+ mailbox: { mailboxId: REAL_TRASH, fullPath: "[Gmail]/Trash" },
36
+ };
37
+
38
+ // A folder merely named `Deleted` is what the name proposal returns, and the
39
+ // confirmed gate is what this path asks for.
40
+ const proposedDeletedFolder: RoleResolution<TrashMailbox> = {
41
+ kind: "proposed",
42
+ mailbox: { mailboxId: DELETED_FOLDER, fullPath: "Deleted" },
43
+ };
44
+
45
+ interface TrashMessage {
46
+ messageId: string;
47
+ syncStatus: string;
48
+ }
49
+
50
+ interface EnqueuedEvent {
51
+ type: string;
52
+ schemaVersion?: number;
53
+ trashMailboxId?: string;
54
+ trashUidValidity?: number;
55
+ }
56
+
23
57
  const buildWorld = (
24
- confirmedTrash: { mailboxId: string; fullPath: string } | null,
58
+ trashResolution: RoleResolution<TrashMailbox>,
59
+ trashContents: TrashMessage[] = [
60
+ { messageId: "junk-1", syncStatus: "synced" },
61
+ ],
25
62
  ) => {
26
63
  const emptied: string[] = [];
27
- const messagesByMailbox = new Map<string, { messageId: string }[]>([
28
- [DELETED_FOLDER, [{ messageId: "keepsake-1" }]],
29
- [REAL_TRASH, [{ messageId: "junk-1" }]],
64
+ const markedDeleting: string[] = [];
65
+ const events: EnqueuedEvent[] = [];
66
+ const messagesByMailbox = new Map<string, TrashMessage[]>([
67
+ [DELETED_FOLDER, [{ messageId: "keepsake-1", syncStatus: "synced" }]],
68
+ [REAL_TRASH, trashContents],
30
69
  ]);
31
70
 
32
71
  const messageService = {
@@ -34,7 +73,9 @@ const buildWorld = (
34
73
  emptied.push(mailboxId);
35
74
  return messagesByMailbox.get(mailboxId) ?? [];
36
75
  },
37
- update: async () => {},
76
+ update: async (messageId: string) => {
77
+ markedDeleting.push(messageId);
78
+ },
38
79
  } as unknown as IMessageRepository;
39
80
 
40
81
  const threadMessageService = {
@@ -48,18 +89,17 @@ const buildWorld = (
48
89
 
49
90
  const config: MessageMoveConfig = {
50
91
  messageService,
51
- mailboxService: {} as unknown as IMailboxRepository,
92
+ mailboxService: {
93
+ get: async (_accountId: string, mailboxId: string) => ({
94
+ mailboxId,
95
+ uidValidity: 42,
96
+ }),
97
+ } as unknown as IMailboxRepository,
52
98
  addressService: {
53
99
  reconcileJunkOnlyForMessage: async () => {},
54
100
  } as unknown as IAddressRepository,
55
101
  mailboxSpecialUseService: {
56
- // A folder merely named `Deleted` is what the name proposal would
57
- // return; the confirmed lookup is what this path asks for.
58
- findTrashMailbox: async () => ({
59
- mailboxId: DELETED_FOLDER,
60
- fullPath: "Deleted",
61
- }),
62
- findConfirmedTrashMailbox: async () => confirmedTrash,
102
+ resolveTrashRole: async () => trashResolution,
63
103
  } as unknown as IMailboxSpecialUseRepository,
64
104
  threadMessageService,
65
105
  sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
@@ -67,18 +107,19 @@ const buildWorld = (
67
107
 
68
108
  const service = new MessageMoveService(config);
69
109
  (
70
- service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
71
- ).sqs = { send: async () => ({}) };
110
+ service as unknown as {
111
+ enqueueEvent: (event: EnqueuedEvent) => Promise<void>;
112
+ }
113
+ ).enqueueEvent = async (event) => {
114
+ events.push(event);
115
+ };
72
116
 
73
- return { service, emptied };
117
+ return { service, emptied, markedDeleting, events };
74
118
  };
75
119
 
76
120
  describe("MessageMoveService.emptyTrash", () => {
77
121
  it("empties the appointed Trash, never the user folder called Deleted", async () => {
78
- const { service, emptied } = buildWorld({
79
- mailboxId: REAL_TRASH,
80
- fullPath: "[Gmail]/Trash",
81
- });
122
+ const { service, emptied } = buildWorld(appointedTrash);
82
123
 
83
124
  await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
84
125
 
@@ -86,15 +127,116 @@ describe("MessageMoveService.emptyTrash", () => {
86
127
  });
87
128
 
88
129
  it("refuses and names the remedy when no folder is appointed or flagged", async () => {
89
- const { service, emptied } = buildWorld(null);
130
+ const { service, emptied } = buildWorld({ kind: "none" });
90
131
 
91
132
  await assert.rejects(
92
133
  service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
93
- /Appoint one under Settings/,
134
+ (error: unknown) =>
135
+ error instanceof NoTrashMailboxError &&
136
+ error.publicApiError?.details?.reason === "none",
94
137
  );
95
138
 
96
139
  // Nothing was read, so nothing was marked for deletion and no expunge
97
140
  // was enqueued: an unresolved Trash stops the operation dead.
98
141
  assert.deepEqual(emptied, []);
99
142
  });
143
+
144
+ it("refuses a Trash that only resolves by name, under its own reason", async () => {
145
+ // D18: a plausible folder nobody confirmed is a third answer, distinct
146
+ // from having none. The refusal is what mints the appointment.
147
+ const { service, emptied, markedDeleting } = buildWorld(
148
+ proposedDeletedFolder,
149
+ );
150
+
151
+ await assert.rejects(
152
+ service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
153
+ (error: unknown) =>
154
+ error instanceof UnconfirmedTrashMailboxError &&
155
+ error.statusCode === 409 &&
156
+ error.publicApiError?.details?.reason === "unconfirmed" &&
157
+ error.publicApiError?.details?.accountId === ACCOUNT,
158
+ );
159
+
160
+ assert.deepEqual(emptied, []);
161
+ assert.deepEqual(markedDeleting, []);
162
+ });
163
+
164
+ it("refuses a stale appointment rather than emptying its fallback", async () => {
165
+ const { service, emptied, markedDeleting } = buildWorld({
166
+ kind: "appointment_stale",
167
+ appointedMailboxId: "mbx-appointed-and-gone",
168
+ fallback: { kind: "flagged", mailbox: appointedTrash.mailbox },
169
+ });
170
+
171
+ await assert.rejects(
172
+ service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
173
+ (error: unknown) =>
174
+ error instanceof StaleTrashAppointmentError &&
175
+ error.publicApiError?.details?.reason === "stale",
176
+ );
177
+
178
+ assert.deepEqual(emptied, []);
179
+ assert.deepEqual(markedDeleting, []);
180
+ });
181
+
182
+ it("reports what it marked, from the one read that decided it", async () => {
183
+ const { service, markedDeleting } = buildWorld(appointedTrash, [
184
+ { messageId: "junk-1", syncStatus: "synced" },
185
+ { messageId: "junk-2", syncStatus: "synced" },
186
+ { messageId: "junk-3", syncStatus: "synced" },
187
+ ]);
188
+
189
+ const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
190
+
191
+ assert.equal(deletedCount, markedDeleting.length);
192
+ assert.equal(deletedCount, 3);
193
+ });
194
+
195
+ it("marks and counts a message whose move to Trash has not settled", async () => {
196
+ // The user saw the message in Trash and asked for the folder to be
197
+ // emptied. Skipping it reports a number the folder contradicts, and the
198
+ // queue is per-account FIFO, so the move has landed on the server before
199
+ // the expunge is even delivered.
200
+ const { service, markedDeleting } = buildWorld(appointedTrash, [
201
+ { messageId: "settled-1", syncStatus: "synced" },
202
+ { messageId: "still-moving-1", syncStatus: "pending" },
203
+ ]);
204
+
205
+ const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
206
+
207
+ assert.deepEqual(markedDeleting, ["settled-1", "still-moving-1"]);
208
+ assert.equal(deletedCount, 2);
209
+ });
210
+
211
+ it("reports the same count when pressed twice before the worker runs", async () => {
212
+ // The rows it marked are still in that folder, so N stays true and the
213
+ // re-mark is idempotent. Reporting 0 the second time while still
214
+ // enqueuing an expunge would read as success over an untouched Trash.
215
+ const { service, events } = buildWorld(appointedTrash, [
216
+ { messageId: "junk-1", syncStatus: "synced" },
217
+ { messageId: "junk-2", syncStatus: "synced" },
218
+ ]);
219
+
220
+ const first = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
221
+ const second = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
222
+
223
+ assert.equal(first.deletedCount, 2);
224
+ assert.equal(second.deletedCount, 2);
225
+ assert.deepEqual(
226
+ events.map((event) => event.type),
227
+ ["EMPTY_TRASH", "EMPTY_TRASH"],
228
+ );
229
+ });
230
+
231
+ it("carries the folder's identity as it stood at consent time", async () => {
232
+ // The worker compares this against what its own SELECT serves. Without
233
+ // it the expunge would be authorised by a path, and a path is reusable.
234
+ const { service, events } = buildWorld(appointedTrash);
235
+
236
+ await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
237
+
238
+ assert.equal(events[0]?.schemaVersion, 2);
239
+ assert.equal(events[0]?.trashMailboxId, REAL_TRASH);
240
+ assert.equal(events[0]?.trashUidValidity, 42);
241
+ });
100
242
  });
@@ -8,6 +8,7 @@ import type {
8
8
  IThreadMessageRepository,
9
9
  } from "@remit/data-ports";
10
10
  import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
11
+ import { trashRole } from "./test-helpers/folder-roles.js";
11
12
 
12
13
  const stubAddressService = (): IAddressRepository =>
13
14
  ({
@@ -66,6 +67,7 @@ const buildWorld = () => {
66
67
 
67
68
  const mailboxSpecialUseService = {
68
69
  findTrashMailbox: async () => null,
70
+ resolveTrashRole: async () => trashRole(null),
69
71
  } as unknown as IMailboxSpecialUseRepository;
70
72
 
71
73
  const config: MessageMoveConfig = {
@@ -12,8 +12,17 @@ import type {
12
12
  IThreadMessageRepository,
13
13
  } from "@remit/data-ports";
14
14
  import { FolderRoleUnresolvedError } from "@remit/data-ports/errors";
15
- import { NO_TRASH_FOLDER_REASON } from "@remit/data-ports/folder-role";
15
+ import {
16
+ type FolderRoleUnresolvedReason,
17
+ NO_TRASH_FOLDER_REASON,
18
+ type RoleResolution,
19
+ STALE_TRASH_FOLDER_REASON,
20
+ type TrashAssuranceLevel,
21
+ trashMailboxAt,
22
+ UNCONFIRMED_TRASH_FOLDER_REASON,
23
+ } from "@remit/data-ports/folder-role";
16
24
  import { deriveCopyMessageId } from "@remit/data-ports/id";
25
+ import { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
17
26
  import {
18
27
  CanonicalMailboxRole,
19
28
  MessageStatus,
@@ -27,6 +36,7 @@ import { createQueueProducer } from "@remit/sqs-client/producer";
27
36
  */
28
37
  interface MessageDeleteEvent {
29
38
  type: "MESSAGE_DELETE";
39
+ schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
30
40
  eventId: string;
31
41
  timestamp: number;
32
42
  accountId: string;
@@ -54,11 +64,14 @@ interface MessageMoveEvent {
54
64
 
55
65
  interface EmptyTrashEvent {
56
66
  type: "EMPTY_TRASH";
67
+ schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
57
68
  eventId: string;
58
69
  timestamp: number;
59
70
  accountId: string;
60
71
  trashMailboxId: string;
61
72
  trashMailboxPath: string;
73
+ /** The folder's UIDVALIDITY at consent time; the worker re-checks it. */
74
+ trashUidValidity: number;
62
75
  }
63
76
 
64
77
  interface MessageCopyEvent {
@@ -129,6 +142,74 @@ export class NoTrashMailboxError extends FolderRoleUnresolvedError {
129
142
  }
130
143
  }
131
144
 
145
+ /**
146
+ * The folder the user appointed as Trash is gone from the server. Distinct from
147
+ * having no Trash at all: there is a decision on file, and the repair is to
148
+ * point it at a folder that exists — not to let reader pick one instead.
149
+ */
150
+ export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
151
+ name = "StaleTrashAppointmentError";
152
+
153
+ constructor(accountId: string) {
154
+ super(
155
+ STALE_TRASH_FOLDER_REASON,
156
+ CanonicalMailboxRole.Trash,
157
+ "stale",
158
+ accountId,
159
+ );
160
+ }
161
+ }
162
+
163
+ /**
164
+ * A folder resolves as Trash by its name alone. Enough to move mail into,
165
+ * never enough to expunge — so only the Empty Trash path raises it.
166
+ */
167
+ export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
168
+ name = "UnconfirmedTrashMailboxError";
169
+
170
+ constructor(accountId: string) {
171
+ super(
172
+ UNCONFIRMED_TRASH_FOLDER_REASON,
173
+ CanonicalMailboxRole.Trash,
174
+ "unconfirmed",
175
+ accountId,
176
+ );
177
+ }
178
+ }
179
+
180
+ const REFUSAL_BY_REASON: Record<
181
+ FolderRoleUnresolvedReason,
182
+ new (
183
+ accountId: string,
184
+ ) => FolderRoleUnresolvedError
185
+ > = {
186
+ none: NoTrashMailboxError,
187
+ stale: StaleTrashAppointmentError,
188
+ unconfirmed: UnconfirmedTrashMailboxError,
189
+ };
190
+
191
+ /** The coded 409 for one refusal reason. The only place a reason becomes an error. */
192
+ const trashRefusal = (
193
+ reason: FolderRoleUnresolvedReason,
194
+ accountId: string,
195
+ ): FolderRoleUnresolvedError => new REFUSAL_BY_REASON[reason](accountId);
196
+
197
+ /**
198
+ * The Trash folder a verb may act on at the assurance it demands, or the coded
199
+ * refusal naming which of the three reasons stopped it. Every Trash gate goes
200
+ * through here — the delete and the expunge — so no two of them can tell a
201
+ * user different things about the same account.
202
+ */
203
+ export const requireTrashMailbox = <T>(
204
+ resolution: RoleResolution<T>,
205
+ level: TrashAssuranceLevel,
206
+ accountId: string,
207
+ ): T => {
208
+ const outcome = trashMailboxAt(resolution, level);
209
+ if (outcome.allowed) return outcome.mailbox;
210
+ throw trashRefusal(outcome.reason, accountId);
211
+ };
212
+
132
213
  /**
133
214
  * Options for delete operations
134
215
  */
@@ -226,18 +307,23 @@ export class MessageMoveService {
226
307
  const wantsPermanentDelete =
227
308
  options.permanent === true || options.toTrash === false;
228
309
 
229
- const trashMailbox = wantsPermanentDelete
310
+ const trashGate = wantsPermanentDelete
230
311
  ? null
231
- : await this.mailboxSpecialUseService.findTrashMailbox(accountId);
312
+ : trashMailboxAt(
313
+ await this.mailboxSpecialUseService.resolveTrashRole(accountId),
314
+ "resolved",
315
+ );
232
316
 
233
- if (!wantsPermanentDelete && !trashMailbox) {
317
+ if (trashGate && !trashGate.allowed) {
234
318
  this.log.error(
235
- { accountId, messageCount: messages.length },
236
- "Refused to delete: account resolves no Trash mailbox",
319
+ { accountId, messageCount: messages.length, reason: trashGate.reason },
320
+ "Refused to delete: this account's Trash is unresolved",
237
321
  );
238
- throw new NoTrashMailboxError(accountId);
322
+ throw trashRefusal(trashGate.reason, accountId);
239
323
  }
240
324
 
325
+ const trashMailbox = trashGate ? trashGate.mailbox : null;
326
+
241
327
  // Group messages by operation type
242
328
  const moveToTrashMessages: Array<{
243
329
  messageId: string;
@@ -301,6 +387,7 @@ export class MessageMoveService {
301
387
 
302
388
  events.push({
303
389
  type: "MESSAGE_DELETE",
390
+ schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
304
391
  eventId: randomUUID(),
305
392
  timestamp: Date.now(),
306
393
  accountId,
@@ -344,6 +431,7 @@ export class MessageMoveService {
344
431
 
345
432
  events.push({
346
433
  type: "MESSAGE_DELETE",
434
+ schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
347
435
  eventId: randomUUID(),
348
436
  timestamp: Date.now(),
349
437
  accountId,
@@ -659,26 +747,39 @@ export class MessageMoveService {
659
747
  /**
660
748
  * Empty the Trash mailbox: an EXPUNGE of everything in it, with no undo.
661
749
  *
662
- * Resolved through `findConfirmedTrashMailbox`, so the folder is the one the
663
- * user appointed or the one the server flagged \Trash — never one that
664
- * merely reads like a trash folder. A user with an ordinary folder called
665
- * `Deleted` would otherwise lose its contents permanently, and on a
666
- * `.`-delimited server the old whole-path name match resolved nothing at all
667
- * and the operation reported success over an empty count. Unresolved is a
668
- * refusal that names the remedy, not a silent no-op.
750
+ * Demands confirmed evidence: the folder the user appointed or the one the
751
+ * server flagged \Trash — never one that merely reads like a trash folder,
752
+ * and never the fallback behind an appointment that went missing. A user
753
+ * with an ordinary folder called `Deleted` would otherwise lose its contents
754
+ * permanently. Each of the three ways that evidence can be absent refuses
755
+ * under its own reason, so the surface can name the remedy instead of
756
+ * offering a silent no-op.
757
+ *
758
+ * Reports the rows it marked, read once here, so the number the caller shows
759
+ * a user is the number this operation acted on. Pressed twice before the
760
+ * worker runs it re-marks the same rows and reports the same number: the
761
+ * folder really does still hold them, and the second expunge finds nothing
762
+ * to do.
669
763
  */
670
764
  emptyTrash = async (
671
765
  accountConfigId: string,
672
766
  accountId: string,
673
- ): Promise<void> => {
674
- const trashMailbox =
675
- await this.mailboxSpecialUseService.findConfirmedTrashMailbox(accountId);
767
+ ): Promise<{ deletedCount: number }> => {
768
+ const trashMailbox = requireTrashMailbox(
769
+ await this.mailboxSpecialUseService.resolveTrashRole(accountId),
770
+ "confirmed",
771
+ accountId,
772
+ );
676
773
 
677
- if (!trashMailbox) {
678
- throw new NoTrashMailboxError(accountId);
679
- }
774
+ // The identity the user consented to, carried on the event so the worker
775
+ // can tell this folder from a different one that has since taken over its
776
+ // path. Read here rather than re-read there: the value at consent time is
777
+ // the whole point, and the stored row is rewritten by every sync.
778
+ const { uidValidity: trashUidValidity } = await this.mailboxService.get(
779
+ accountId,
780
+ trashMailbox.mailboxId,
781
+ );
680
782
 
681
- // Get all messages in Trash
682
783
  const messages = await this.messageService.listAllByMailbox(
683
784
  trashMailbox.mailboxId,
684
785
  );
@@ -708,14 +809,18 @@ export class MessageMoveService {
708
809
  // Enqueue single event for worker to handle batch
709
810
  const event: EmptyTrashEvent = {
710
811
  type: "EMPTY_TRASH",
812
+ schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
711
813
  eventId: randomUUID(),
712
814
  timestamp: Date.now(),
713
815
  accountId,
714
816
  trashMailboxId: trashMailbox.mailboxId,
715
817
  trashMailboxPath: trashMailbox.fullPath,
818
+ trashUidValidity,
716
819
  };
717
820
 
718
821
  await this.enqueueEvent(event);
822
+
823
+ return { deletedCount: messages.length };
719
824
  };
720
825
 
721
826
  /**
@@ -19,6 +19,7 @@ import {
19
19
  NoJunkMailboxError,
20
20
  SpamReportService,
21
21
  } from "./spam-report.js";
22
+ import { trashRole } from "./test-helpers/folder-roles.js";
22
23
 
23
24
  const ACCOUNT = "acc-1";
24
25
  const ACCOUNT_CONFIG = "cfg-1";
@@ -233,6 +234,7 @@ const buildWorld = (
233
234
  const mailboxSpecialUseService = {
234
235
  findJunkMailbox: async () => junkMailbox,
235
236
  findTrashMailbox: async () => null,
237
+ resolveTrashRole: async () => trashRole(null),
236
238
  } as unknown as IMailboxSpecialUseRepository;
237
239
 
238
240
  const mailboxService = {
@@ -1,4 +1,18 @@
1
1
  import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
2
+ import type { RoleResolution } from "@remit/data-ports/folder-role";
3
+
4
+ type RoleMailbox = { mailboxId: string; fullPath: string };
5
+
6
+ /**
7
+ * The evidence-carrying answer behind `findTrashMailbox`, so a stub that names
8
+ * a Trash folder answers both reads consistently. A named folder stands in as
9
+ * server-flagged: the suites using this are testing something other than how
10
+ * the role was decided.
11
+ */
12
+ export const trashRole = (
13
+ trash: RoleMailbox | null,
14
+ ): RoleResolution<RoleMailbox> =>
15
+ trash ? { kind: "flagged", mailbox: trash } : { kind: "none" };
2
16
 
3
17
  /**
4
18
  * A folder-role map for an account that has appointed nothing and whose server
@@ -9,23 +23,26 @@ import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
9
23
  export const noFolderRoles = {
10
24
  findJunkMailbox: async () => null,
11
25
  findTrashMailbox: async () => null,
26
+ resolveTrashRole: async () => trashRole(null),
12
27
  } as unknown as IMailboxSpecialUseRepository;
13
28
 
14
29
  /** A folder-role map naming the mailboxes that hold Junk and Trash. */
15
30
  export const folderRoles = (roles: {
16
31
  junkMailboxId?: string;
17
32
  trashMailboxId?: string;
18
- }): IMailboxSpecialUseRepository =>
19
- ({
33
+ }): IMailboxSpecialUseRepository => {
34
+ const trash = roles.trashMailboxId
35
+ ? { mailboxId: roles.trashMailboxId, fullPath: roles.trashMailboxId }
36
+ : null;
37
+ return {
20
38
  findJunkMailbox: async () =>
21
39
  roles.junkMailboxId
22
40
  ? { mailboxId: roles.junkMailboxId, fullPath: roles.junkMailboxId }
23
41
  : null,
24
- findTrashMailbox: async () =>
25
- roles.trashMailboxId
26
- ? { mailboxId: roles.trashMailboxId, fullPath: roles.trashMailboxId }
27
- : null,
28
- }) as unknown as IMailboxSpecialUseRepository;
42
+ findTrashMailbox: async () => trash,
43
+ resolveTrashRole: async () => trashRole(trash),
44
+ } as unknown as IMailboxSpecialUseRepository;
45
+ };
29
46
 
30
47
  /** The same account, as the resolved map `saveMessage` is handed. */
31
48
  export const NO_FOLDER_ROLES = {