@remit/mailbox-service 0.0.66 → 0.0.67

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.67",
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,56 @@ 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
+ trashMailboxId?: string;
53
+ }
54
+
23
55
  const buildWorld = (
24
- confirmedTrash: { mailboxId: string; fullPath: string } | null,
56
+ trashResolution: RoleResolution<TrashMailbox>,
57
+ trashContents: TrashMessage[] = [
58
+ { messageId: "junk-1", syncStatus: "synced" },
59
+ ],
25
60
  ) => {
26
61
  const emptied: string[] = [];
27
- const messagesByMailbox = new Map<string, { messageId: string }[]>([
28
- [DELETED_FOLDER, [{ messageId: "keepsake-1" }]],
29
- [REAL_TRASH, [{ messageId: "junk-1" }]],
62
+ const markedDeleting: string[] = [];
63
+ const events: EnqueuedEvent[] = [];
64
+ const messagesByMailbox = new Map<string, TrashMessage[]>([
65
+ [DELETED_FOLDER, [{ messageId: "keepsake-1", syncStatus: "synced" }]],
66
+ [REAL_TRASH, trashContents],
30
67
  ]);
31
68
 
32
69
  const messageService = {
@@ -34,7 +71,9 @@ const buildWorld = (
34
71
  emptied.push(mailboxId);
35
72
  return messagesByMailbox.get(mailboxId) ?? [];
36
73
  },
37
- update: async () => {},
74
+ update: async (messageId: string) => {
75
+ markedDeleting.push(messageId);
76
+ },
38
77
  } as unknown as IMessageRepository;
39
78
 
40
79
  const threadMessageService = {
@@ -53,13 +92,7 @@ const buildWorld = (
53
92
  reconcileJunkOnlyForMessage: async () => {},
54
93
  } as unknown as IAddressRepository,
55
94
  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,
95
+ resolveTrashRole: async () => trashResolution,
63
96
  } as unknown as IMailboxSpecialUseRepository,
64
97
  threadMessageService,
65
98
  sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
@@ -67,18 +100,19 @@ const buildWorld = (
67
100
 
68
101
  const service = new MessageMoveService(config);
69
102
  (
70
- service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
71
- ).sqs = { send: async () => ({}) };
103
+ service as unknown as {
104
+ enqueueEvent: (event: EnqueuedEvent) => Promise<void>;
105
+ }
106
+ ).enqueueEvent = async (event) => {
107
+ events.push(event);
108
+ };
72
109
 
73
- return { service, emptied };
110
+ return { service, emptied, markedDeleting, events };
74
111
  };
75
112
 
76
113
  describe("MessageMoveService.emptyTrash", () => {
77
114
  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
- });
115
+ const { service, emptied } = buildWorld(appointedTrash);
82
116
 
83
117
  await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
84
118
 
@@ -86,15 +120,104 @@ describe("MessageMoveService.emptyTrash", () => {
86
120
  });
87
121
 
88
122
  it("refuses and names the remedy when no folder is appointed or flagged", async () => {
89
- const { service, emptied } = buildWorld(null);
123
+ const { service, emptied } = buildWorld({ kind: "none" });
90
124
 
91
125
  await assert.rejects(
92
126
  service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
93
- /Appoint one under Settings/,
127
+ (error: unknown) =>
128
+ error instanceof NoTrashMailboxError &&
129
+ error.publicApiError?.details?.reason === "none",
94
130
  );
95
131
 
96
132
  // Nothing was read, so nothing was marked for deletion and no expunge
97
133
  // was enqueued: an unresolved Trash stops the operation dead.
98
134
  assert.deepEqual(emptied, []);
99
135
  });
136
+
137
+ it("refuses a Trash that only resolves by name, under its own reason", async () => {
138
+ // D18: a plausible folder nobody confirmed is a third answer, distinct
139
+ // from having none. The refusal is what mints the appointment.
140
+ const { service, emptied, markedDeleting } = buildWorld(
141
+ proposedDeletedFolder,
142
+ );
143
+
144
+ await assert.rejects(
145
+ service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
146
+ (error: unknown) =>
147
+ error instanceof UnconfirmedTrashMailboxError &&
148
+ error.statusCode === 409 &&
149
+ error.publicApiError?.details?.reason === "unconfirmed" &&
150
+ error.publicApiError?.details?.accountId === ACCOUNT,
151
+ );
152
+
153
+ assert.deepEqual(emptied, []);
154
+ assert.deepEqual(markedDeleting, []);
155
+ });
156
+
157
+ it("refuses a stale appointment rather than emptying its fallback", async () => {
158
+ const { service, emptied, markedDeleting } = buildWorld({
159
+ kind: "appointment_stale",
160
+ appointedMailboxId: "mbx-appointed-and-gone",
161
+ fallback: { kind: "flagged", mailbox: appointedTrash.mailbox },
162
+ });
163
+
164
+ await assert.rejects(
165
+ service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
166
+ (error: unknown) =>
167
+ error instanceof StaleTrashAppointmentError &&
168
+ error.publicApiError?.details?.reason === "stale",
169
+ );
170
+
171
+ assert.deepEqual(emptied, []);
172
+ assert.deepEqual(markedDeleting, []);
173
+ });
174
+
175
+ it("reports what it marked, from the one read that decided it", async () => {
176
+ const { service, markedDeleting } = buildWorld(appointedTrash, [
177
+ { messageId: "junk-1", syncStatus: "synced" },
178
+ { messageId: "junk-2", syncStatus: "synced" },
179
+ { messageId: "junk-3", syncStatus: "synced" },
180
+ ]);
181
+
182
+ const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
183
+
184
+ assert.equal(deletedCount, markedDeleting.length);
185
+ assert.equal(deletedCount, 3);
186
+ });
187
+
188
+ it("marks and counts a message whose move to Trash has not settled", async () => {
189
+ // The user saw the message in Trash and asked for the folder to be
190
+ // emptied. Skipping it reports a number the folder contradicts, and the
191
+ // queue is per-account FIFO, so the move has landed on the server before
192
+ // the expunge is even delivered.
193
+ const { service, markedDeleting } = buildWorld(appointedTrash, [
194
+ { messageId: "settled-1", syncStatus: "synced" },
195
+ { messageId: "still-moving-1", syncStatus: "pending" },
196
+ ]);
197
+
198
+ const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
199
+
200
+ assert.deepEqual(markedDeleting, ["settled-1", "still-moving-1"]);
201
+ assert.equal(deletedCount, 2);
202
+ });
203
+
204
+ it("reports the same count when pressed twice before the worker runs", async () => {
205
+ // The rows it marked are still in that folder, so N stays true and the
206
+ // re-mark is idempotent. Reporting 0 the second time while still
207
+ // enqueuing an expunge would read as success over an untouched Trash.
208
+ const { service, events } = buildWorld(appointedTrash, [
209
+ { messageId: "junk-1", syncStatus: "synced" },
210
+ { messageId: "junk-2", syncStatus: "synced" },
211
+ ]);
212
+
213
+ const first = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
214
+ const second = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
215
+
216
+ assert.equal(first.deletedCount, 2);
217
+ assert.equal(second.deletedCount, 2);
218
+ assert.deepEqual(
219
+ events.map((event) => event.type),
220
+ ["EMPTY_TRASH", "EMPTY_TRASH"],
221
+ );
222
+ });
100
223
  });
@@ -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,7 +12,15 @@ 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";
17
25
  import {
18
26
  CanonicalMailboxRole,
@@ -129,6 +137,74 @@ export class NoTrashMailboxError extends FolderRoleUnresolvedError {
129
137
  }
130
138
  }
131
139
 
140
+ /**
141
+ * The folder the user appointed as Trash is gone from the server. Distinct from
142
+ * having no Trash at all: there is a decision on file, and the repair is to
143
+ * point it at a folder that exists — not to let reader pick one instead.
144
+ */
145
+ export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
146
+ name = "StaleTrashAppointmentError";
147
+
148
+ constructor(accountId: string) {
149
+ super(
150
+ STALE_TRASH_FOLDER_REASON,
151
+ CanonicalMailboxRole.Trash,
152
+ "stale",
153
+ accountId,
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * A folder resolves as Trash by its name alone. Enough to move mail into,
160
+ * never enough to expunge — so only the Empty Trash path raises it.
161
+ */
162
+ export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
163
+ name = "UnconfirmedTrashMailboxError";
164
+
165
+ constructor(accountId: string) {
166
+ super(
167
+ UNCONFIRMED_TRASH_FOLDER_REASON,
168
+ CanonicalMailboxRole.Trash,
169
+ "unconfirmed",
170
+ accountId,
171
+ );
172
+ }
173
+ }
174
+
175
+ const REFUSAL_BY_REASON: Record<
176
+ FolderRoleUnresolvedReason,
177
+ new (
178
+ accountId: string,
179
+ ) => FolderRoleUnresolvedError
180
+ > = {
181
+ none: NoTrashMailboxError,
182
+ stale: StaleTrashAppointmentError,
183
+ unconfirmed: UnconfirmedTrashMailboxError,
184
+ };
185
+
186
+ /** The coded 409 for one refusal reason. The only place a reason becomes an error. */
187
+ const trashRefusal = (
188
+ reason: FolderRoleUnresolvedReason,
189
+ accountId: string,
190
+ ): FolderRoleUnresolvedError => new REFUSAL_BY_REASON[reason](accountId);
191
+
192
+ /**
193
+ * The Trash folder a verb may act on at the assurance it demands, or the coded
194
+ * refusal naming which of the three reasons stopped it. Every Trash gate goes
195
+ * through here — the delete and the expunge — so no two of them can tell a
196
+ * user different things about the same account.
197
+ */
198
+ export const requireTrashMailbox = <T>(
199
+ resolution: RoleResolution<T>,
200
+ level: TrashAssuranceLevel,
201
+ accountId: string,
202
+ ): T => {
203
+ const outcome = trashMailboxAt(resolution, level);
204
+ if (outcome.allowed) return outcome.mailbox;
205
+ throw trashRefusal(outcome.reason, accountId);
206
+ };
207
+
132
208
  /**
133
209
  * Options for delete operations
134
210
  */
@@ -226,18 +302,23 @@ export class MessageMoveService {
226
302
  const wantsPermanentDelete =
227
303
  options.permanent === true || options.toTrash === false;
228
304
 
229
- const trashMailbox = wantsPermanentDelete
305
+ const trashGate = wantsPermanentDelete
230
306
  ? null
231
- : await this.mailboxSpecialUseService.findTrashMailbox(accountId);
307
+ : trashMailboxAt(
308
+ await this.mailboxSpecialUseService.resolveTrashRole(accountId),
309
+ "resolved",
310
+ );
232
311
 
233
- if (!wantsPermanentDelete && !trashMailbox) {
312
+ if (trashGate && !trashGate.allowed) {
234
313
  this.log.error(
235
- { accountId, messageCount: messages.length },
236
- "Refused to delete: account resolves no Trash mailbox",
314
+ { accountId, messageCount: messages.length, reason: trashGate.reason },
315
+ "Refused to delete: this account's Trash is unresolved",
237
316
  );
238
- throw new NoTrashMailboxError(accountId);
317
+ throw trashRefusal(trashGate.reason, accountId);
239
318
  }
240
319
 
320
+ const trashMailbox = trashGate ? trashGate.mailbox : null;
321
+
241
322
  // Group messages by operation type
242
323
  const moveToTrashMessages: Array<{
243
324
  messageId: string;
@@ -659,26 +740,30 @@ export class MessageMoveService {
659
740
  /**
660
741
  * Empty the Trash mailbox: an EXPUNGE of everything in it, with no undo.
661
742
  *
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.
743
+ * Demands confirmed evidence: the folder the user appointed or the one the
744
+ * server flagged \Trash — never one that merely reads like a trash folder,
745
+ * and never the fallback behind an appointment that went missing. A user
746
+ * with an ordinary folder called `Deleted` would otherwise lose its contents
747
+ * permanently. Each of the three ways that evidence can be absent refuses
748
+ * under its own reason, so the surface can name the remedy instead of
749
+ * offering a silent no-op.
750
+ *
751
+ * Reports the rows it marked, read once here, so the number the caller shows
752
+ * a user is the number this operation acted on. Pressed twice before the
753
+ * worker runs it re-marks the same rows and reports the same number: the
754
+ * folder really does still hold them, and the second expunge finds nothing
755
+ * to do.
669
756
  */
670
757
  emptyTrash = async (
671
758
  accountConfigId: string,
672
759
  accountId: string,
673
- ): Promise<void> => {
674
- const trashMailbox =
675
- await this.mailboxSpecialUseService.findConfirmedTrashMailbox(accountId);
676
-
677
- if (!trashMailbox) {
678
- throw new NoTrashMailboxError(accountId);
679
- }
760
+ ): Promise<{ deletedCount: number }> => {
761
+ const trashMailbox = requireTrashMailbox(
762
+ await this.mailboxSpecialUseService.resolveTrashRole(accountId),
763
+ "confirmed",
764
+ accountId,
765
+ );
680
766
 
681
- // Get all messages in Trash
682
767
  const messages = await this.messageService.listAllByMailbox(
683
768
  trashMailbox.mailboxId,
684
769
  );
@@ -716,6 +801,8 @@ export class MessageMoveService {
716
801
  };
717
802
 
718
803
  await this.enqueueEvent(event);
804
+
805
+ return { deletedCount: messages.length };
719
806
  };
720
807
 
721
808
  /**
@@ -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 = {