@remit/web-client 0.0.185 → 0.0.186

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.
@@ -1,7 +1,9 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
+ import type { DeleteTarget } from "./format.js";
3
4
  import {
4
5
  deleteConfirmationCopy,
6
+ deleteOutcomeFor,
5
7
  formatDate,
6
8
  formatDatePreset,
7
9
  formatDeleteToTrashTitle,
@@ -155,3 +157,183 @@ describe("deleteConfirmationCopy", () => {
155
157
  });
156
158
  });
157
159
  });
160
+
161
+ /**
162
+ * Issue #855. The failure branch is the one that matters: TanStack sets
163
+ * `status: "error"` with `data` undefined, so a config read that failed leaves
164
+ * an empty Trash set behind. Reading that as "this folder is not Trash" hands an
165
+ * expired session a "Move to Trash?" dialog over an expunge — #845 reinstated on
166
+ * the error path.
167
+ */
168
+ describe("deleteOutcomeFor", () => {
169
+ const trashByAccount = new Map([
170
+ ["acct-1", "mbx-trash"],
171
+ ["acct-2", undefined],
172
+ ]);
173
+ const settled = { trashByAccount, hasAppointments: true, isError: false };
174
+ // No default for the account: a default parameter is applied to an explicit
175
+ // `undefined` too, so "the row names no account" silently became "acct-1"
176
+ // and the case asserting it read back as an ordinary move to Trash.
177
+ const target = (
178
+ mailboxId: string,
179
+ accountId: string | undefined,
180
+ ): DeleteTarget => ({ accountId, mailboxId });
181
+
182
+ test("a row outside Trash is a reversible move", () => {
183
+ assert.strictEqual(
184
+ deleteOutcomeFor({
185
+ ...settled,
186
+ targets: [target("mbx-inbox", "acct-1")],
187
+ }),
188
+ "trash",
189
+ );
190
+ });
191
+
192
+ test("a row inside its own account's Trash is an expunge", () => {
193
+ assert.strictEqual(
194
+ deleteOutcomeFor({
195
+ ...settled,
196
+ targets: [target("mbx-trash", "acct-1")],
197
+ }),
198
+ "permanent",
199
+ );
200
+ });
201
+
202
+ test("one row inside Trash makes a mixed set permanent", () => {
203
+ assert.strictEqual(
204
+ deleteOutcomeFor({
205
+ ...settled,
206
+ targets: [target("mbx-inbox", "acct-1"), target("mbx-trash", "acct-1")],
207
+ }),
208
+ "permanent",
209
+ );
210
+ });
211
+
212
+ test("an account that appoints no Trash is its own answer, not a move", () => {
213
+ assert.strictEqual(
214
+ deleteOutcomeFor({
215
+ ...settled,
216
+ targets: [target("mbx-inbox", "acct-2")],
217
+ }),
218
+ "noTrash",
219
+ "the server refuses that delete rather than moving anything",
220
+ );
221
+ });
222
+
223
+ test("a refused account outranks an expunge in the same set", () => {
224
+ assert.strictEqual(
225
+ deleteOutcomeFor({
226
+ ...settled,
227
+ targets: [target("mbx-trash", "acct-1"), target("mbx-inbox", "acct-2")],
228
+ }),
229
+ "noTrash",
230
+ );
231
+ });
232
+
233
+ test("another account's Trash is not this row's Trash", () => {
234
+ assert.strictEqual(
235
+ deleteOutcomeFor({
236
+ ...settled,
237
+ trashByAccount: new Map([
238
+ ["acct-1", "mbx-trash"],
239
+ ["acct-2", "mbx-other-trash"],
240
+ ]),
241
+ targets: [target("mbx-trash", "acct-2")],
242
+ }),
243
+ "trash",
244
+ );
245
+ });
246
+
247
+ test("appointments that have not arrived commit to neither wording", () => {
248
+ assert.strictEqual(
249
+ deleteOutcomeFor({
250
+ targets: [target("mbx-inbox", "acct-1")],
251
+ trashByAccount: new Map(),
252
+ hasAppointments: false,
253
+ isError: false,
254
+ }),
255
+ "unknown",
256
+ "a paused offline query reports neither loading nor error",
257
+ );
258
+ });
259
+
260
+ test("an account nothing is known about yet is unknown, not a move", () => {
261
+ assert.strictEqual(
262
+ deleteOutcomeFor({
263
+ ...settled,
264
+ targets: [target("mbx-inbox", "acct-9")],
265
+ }),
266
+ "unknown",
267
+ );
268
+ });
269
+
270
+ test("a row with no account is unknown, not a move", () => {
271
+ assert.strictEqual(
272
+ deleteOutcomeFor({
273
+ ...settled,
274
+ targets: [target("mbx-inbox", undefined)],
275
+ }),
276
+ "unknown",
277
+ );
278
+ });
279
+
280
+ test("a failed read refuses the delete rather than promising a move", () => {
281
+ assert.strictEqual(
282
+ deleteOutcomeFor({
283
+ targets: [target("mbx-inbox", "acct-1")],
284
+ trashByAccount: new Map(),
285
+ hasAppointments: false,
286
+ isError: true,
287
+ }),
288
+ "unavailable",
289
+ );
290
+ });
291
+
292
+ test("a failed read outranks a settled appointment set", () => {
293
+ assert.strictEqual(
294
+ deleteOutcomeFor({
295
+ ...settled,
296
+ targets: [target("mbx-inbox", "acct-1")],
297
+ isError: true,
298
+ }),
299
+ "unavailable",
300
+ );
301
+ });
302
+
303
+ test("nothing pending is not an answer", () => {
304
+ assert.strictEqual(
305
+ deleteOutcomeFor({ ...settled, targets: [] }),
306
+ "unknown",
307
+ );
308
+ });
309
+ });
310
+
311
+ describe("deleteConfirmationCopy — the refusal", () => {
312
+ test("states what failed and offers the way back in", () => {
313
+ assert.deepStrictEqual(deleteConfirmationCopy(1, "unavailable"), {
314
+ title: "Can't delete 1 message",
315
+ description:
316
+ "reader couldn't read this account's folder settings, so it can't say whether this would move the mail to Trash or erase it. Nothing has been deleted.",
317
+ confirmLabel: "Sign in again",
318
+ });
319
+ });
320
+
321
+ test("never offers the reversible wording on the failure path", () => {
322
+ const copy = deleteConfirmationCopy(3, "unavailable");
323
+ assert.ok(!copy.title.includes("Move"));
324
+ assert.ok(!copy.description.includes("restore"));
325
+ });
326
+
327
+ test("sends an unappointed Trash to the screen that appoints one", () => {
328
+ const copy = deleteConfirmationCopy(3, "noTrash");
329
+ assert.strictEqual(copy.title, "Can't delete 3 messages");
330
+ assert.match(copy.description, /appointed as Trash/);
331
+ assert.strictEqual(copy.confirmLabel, "Open folder settings");
332
+ });
333
+
334
+ test("never promises a restore when no Trash is appointed", () => {
335
+ const copy = deleteConfirmationCopy(3, "noTrash");
336
+ assert.ok(!copy.title.includes("Move"));
337
+ assert.ok(!copy.description.includes("restore"));
338
+ });
339
+ });
package/src/lib/format.ts CHANGED
@@ -168,12 +168,85 @@ export interface DeleteConfirmationCopy {
168
168
  }
169
169
 
170
170
  /**
171
- * What a delete will actually do to the selected mail. `unknown` is the state
172
- * before the account's Trash appointment has resolved — a real state on a cold
173
- * open, and the one the copy must not guess at, because guessing "move to
174
- * Trash" over an expunge is the dishonesty this whole flow exists to remove.
171
+ * What a delete will actually do to the selected mail.
172
+ *
173
+ * `unknown` is the state before the account's Trash appointment has resolved a
174
+ * real state on a cold open, and the one the copy must not guess at, because
175
+ * guessing "move to Trash" over an expunge is the dishonesty this whole flow
176
+ * exists to remove.
177
+ *
178
+ * `noTrash` is a resolved answer of "none": the account appoints no Trash, so
179
+ * the server refuses the delete outright (#846) rather than moving anything.
180
+ * It is a different fact from "this row is not in Trash" and must never share
181
+ * an outcome with it — one promises a restore that can happen, the other a
182
+ * delete that will not.
183
+ *
184
+ * `unavailable` is the appointment failing to resolve at all. A read that
185
+ * cannot answer is not an answer: treating an errored `/config` as "no Trash
186
+ * here" reinstates the same lie on the failure path, where an expired session
187
+ * would collect "Move to Trash?" over an expunge. The delete is refused and the
188
+ * failure is stated instead.
175
189
  */
176
- export type DeleteOutcome = "trash" | "permanent" | "unknown";
190
+ export type DeleteOutcome =
191
+ | "trash"
192
+ | "permanent"
193
+ | "noTrash"
194
+ | "unknown"
195
+ | "unavailable";
196
+
197
+ /** A row about to be deleted, and the account whose Trash decides its fate. */
198
+ export interface DeleteTarget {
199
+ accountId: string | undefined;
200
+ mailboxId: string;
201
+ }
202
+
203
+ export interface DeleteOutcomeInput {
204
+ targets: readonly DeleteTarget[];
205
+ /**
206
+ * Each account's appointed Trash. A key present with `undefined` is an
207
+ * account that appoints none — an answer, not a gap. A key absent is an
208
+ * account nothing is known about yet.
209
+ */
210
+ trashByAccount: ReadonlyMap<string, string | undefined>;
211
+ /**
212
+ * The appointments have actually arrived. Never `!isLoading`: React Query
213
+ * v5 leaves a paused offline query pending-but-not-fetching, which reads as
214
+ * loaded while `data` is still undefined.
215
+ */
216
+ hasAppointments: boolean;
217
+ /** The read for them failed. */
218
+ isError: boolean;
219
+ }
220
+
221
+ /**
222
+ * The outcome of deleting `targets`.
223
+ *
224
+ * One row bound for an expunge makes the whole delete unrecoverable, so a mixed
225
+ * set is permanent: the wording may overstate what is destroyed, never what is
226
+ * kept. One row on an account with no Trash refuses the whole call, so that
227
+ * outranks both. Pure, so every branch — the failure ones above all — is
228
+ * testable without a DOM.
229
+ */
230
+ export const deleteOutcomeFor = ({
231
+ targets,
232
+ trashByAccount,
233
+ hasAppointments,
234
+ isError,
235
+ }: DeleteOutcomeInput): DeleteOutcome => {
236
+ if (isError) return "unavailable";
237
+ if (!hasAppointments) return "unknown";
238
+ if (targets.length === 0) return "unknown";
239
+
240
+ let expunges = false;
241
+ for (const target of targets) {
242
+ if (target.accountId === undefined) return "unknown";
243
+ if (!trashByAccount.has(target.accountId)) return "unknown";
244
+ const trashMailboxId = trashByAccount.get(target.accountId);
245
+ if (trashMailboxId === undefined) return "noTrash";
246
+ if (trashMailboxId === target.mailboxId) expunges = true;
247
+ }
248
+ return expunges ? "permanent" : "trash";
249
+ };
177
250
 
178
251
  /**
179
252
  * The confirmation for a delete, worded for what the delete actually does.
@@ -181,6 +254,13 @@ export type DeleteOutcome = "trash" | "permanent" | "unknown";
181
254
  * nothing survives that, so it is asked as a permanent delete — a dialog that
182
255
  * says "Move to Trash" over an expunge collects an answer to a question the
183
256
  * user was never asked.
257
+ *
258
+ * `noTrash` and `unavailable` are not confirmations at all but refusals:
259
+ * nothing is deleted, and the label names the way out rather than the delete.
260
+ * The caller wires each to its own remedy — folder settings for the missing
261
+ * appointment, re-authentication for the failed read, because an account read
262
+ * that fails is a session that ended under the reader far more often than it is
263
+ * anything else.
184
264
  */
185
265
  export const deleteConfirmationCopy = (
186
266
  count: number,
@@ -189,6 +269,22 @@ export const deleteConfirmationCopy = (
189
269
  const quantity = count === 1 ? "1" : formatNumber(count);
190
270
  const noun = count === 1 ? "message" : "messages";
191
271
 
272
+ if (outcome === "noTrash") {
273
+ return {
274
+ title: `Can't delete ${quantity} ${noun}`,
275
+ description:
276
+ "No folder on this account is appointed as Trash, so there is nowhere to move the mail — and deleting it would erase it from the server instead. Appoint a Trash folder to delete from here.",
277
+ confirmLabel: "Open folder settings",
278
+ };
279
+ }
280
+ if (outcome === "unavailable") {
281
+ return {
282
+ title: `Can't delete ${quantity} ${noun}`,
283
+ description:
284
+ "reader couldn't read this account's folder settings, so it can't say whether this would move the mail to Trash or erase it. Nothing has been deleted.",
285
+ confirmLabel: "Sign in again",
286
+ };
287
+ }
192
288
  if (outcome === "unknown") {
193
289
  return {
194
290
  title: `Delete ${quantity} ${noun}?`,
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type {
8
8
  RemitImapAccountResponse,
9
+ RemitImapConfigDescriptionResponse,
9
10
  RemitImapMailboxResponse,
10
11
  RemitImapThreadMessageResponse,
11
12
  } from "@remit/api-http-client/types.gen.ts";
@@ -83,3 +84,16 @@ export const makeThreadMessage = (
83
84
  updatedAt: 0,
84
85
  ...overrides,
85
86
  });
87
+
88
+ export const makeConfig = (
89
+ accounts: RemitImapAccountResponse[],
90
+ ): RemitImapConfigDescriptionResponse => ({
91
+ accountConfig: {
92
+ accountConfigId: "cfg-1",
93
+ userId: "user-1",
94
+ state: "active",
95
+ createdAt: 0,
96
+ updatedAt: 0,
97
+ },
98
+ accounts,
99
+ });