@remit/mailbox-service 0.0.72 → 0.0.73

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.72",
3
+ "version": "0.0.73",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -13,6 +13,7 @@ import {
13
13
  MessageMoveService,
14
14
  NoTrashMailboxError,
15
15
  StaleTrashAppointmentError,
16
+ UnconfirmedTrashMailboxError,
16
17
  } from "./message-move.js";
17
18
 
18
19
  const ACCOUNT = "acc-1";
@@ -200,14 +201,34 @@ describe("delete only expunges when it was asked to", () => {
200
201
  );
201
202
  });
202
203
 
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.
206
- const { service, events } = buildWorld(
204
+ it("refuses to expunge a message already inside a Trash that only resolves by name", async () => {
205
+ // #876: a message already sitting in the guessed folder used to fall
206
+ // straight through to an expunge, on the same name guess Empty Trash
207
+ // refuses to act on. Deleting it is exactly as unrecoverable, so it
208
+ // demands the same confirmed evidence — nobody, neither the user nor the
209
+ // server, ever said this folder is Trash.
210
+ const { service, patches, events, message } = buildWorld(
207
211
  { kind: "proposed", mailbox: { mailboxId: TRASH, fullPath: "Trash" } },
208
212
  TRASH,
209
213
  );
210
214
 
215
+ await assert.rejects(
216
+ () => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
217
+ (error: unknown) =>
218
+ error instanceof UnconfirmedTrashMailboxError &&
219
+ error.statusCode === 409 &&
220
+ error.publicApiError?.details?.reason === "unconfirmed" &&
221
+ error.publicApiError?.details?.accountId === ACCOUNT,
222
+ );
223
+
224
+ assert.deepEqual(events, []);
225
+ assert.deepEqual(patches, []);
226
+ assert.equal(message.mailboxId, TRASH);
227
+ });
228
+
229
+ it("expunges a message already inside a confirmed Trash", async () => {
230
+ const { service, events } = buildWorld(flaggedTrash, TRASH);
231
+
211
232
  await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
212
233
 
213
234
  assert.deepEqual(
@@ -162,7 +162,8 @@ export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
162
162
 
163
163
  /**
164
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.
165
+ * never enough to expunge — raised by Empty Trash and by a delete that would
166
+ * expunge a message already sitting in that folder.
166
167
  */
167
168
  export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
168
169
  name = "UnconfirmedTrashMailboxError";
@@ -307,12 +308,13 @@ export class MessageMoveService {
307
308
  const wantsPermanentDelete =
308
309
  options.permanent === true || options.toTrash === false;
309
310
 
310
- const trashGate = wantsPermanentDelete
311
+ const trashResolution = wantsPermanentDelete
311
312
  ? null
312
- : trashMailboxAt(
313
- await this.mailboxSpecialUseService.resolveTrashRole(accountId),
314
- "resolved",
315
- );
313
+ : await this.mailboxSpecialUseService.resolveTrashRole(accountId);
314
+
315
+ const trashGate = trashResolution
316
+ ? trashMailboxAt(trashResolution, "resolved")
317
+ : null;
316
318
 
317
319
  if (trashGate && !trashGate.allowed) {
318
320
  this.log.error(
@@ -324,6 +326,34 @@ export class MessageMoveService {
324
326
 
325
327
  const trashMailbox = trashGate ? trashGate.mailbox : null;
326
328
 
329
+ // A message already sitting in the folder resolved as Trash deletes by
330
+ // expunging it, not by moving it — the same unrecoverable act Empty
331
+ // Trash performs, so it demands the same confirmed evidence. The name
332
+ // guess ("resolved") is enough to file mail into; it is never enough to
333
+ // destroy mail that is already there. Checked once, before any local
334
+ // write, so a batch that touches such a message is refused whole rather
335
+ // than partially expunged.
336
+ if (!wantsPermanentDelete && trashResolution && trashMailbox) {
337
+ const deletesInPlace = messages.some(
338
+ (message) => message.mailboxId === trashMailbox.mailboxId,
339
+ );
340
+
341
+ if (deletesInPlace) {
342
+ const confirmedTrashGate = trashMailboxAt(trashResolution, "confirmed");
343
+ if (!confirmedTrashGate.allowed) {
344
+ this.log.error(
345
+ {
346
+ accountId,
347
+ messageCount: messages.length,
348
+ reason: confirmedTrashGate.reason,
349
+ },
350
+ "Refused to delete: this account's Trash is not confirmed",
351
+ );
352
+ throw trashRefusal(confirmedTrashGate.reason, accountId);
353
+ }
354
+ }
355
+ }
356
+
327
357
  // Group messages by operation type
328
358
  const moveToTrashMessages: Array<{
329
359
  messageId: string;