@remit/web-client 0.0.273 → 0.0.275

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/BulkRunProvider.tsx +14 -1
  3. package/src/components/mail/MessageCard.blocked-sender.test.ts +7 -0
  4. package/src/components/mail/MessageList.run-reporting.test.ts +1 -0
  5. package/src/components/mail/MessageListItem.test.ts +59 -3
  6. package/src/components/mail/MessageSettlementAlert.render.test.ts +182 -0
  7. package/src/components/mail/MessageSettlementAlert.tsx +77 -19
  8. package/src/components/mail/bulk-run-state.test.ts +1 -0
  9. package/src/components/mail/conversation-reply-identity.render.test.ts +1 -0
  10. package/src/components/mail/conversation-reply-reach.render.test.ts +1 -0
  11. package/src/components/mail/selection-wizard-run-controls.test.ts +1 -0
  12. package/src/components/mail/two-window-listeners-one-key.render.test.ts +1 -0
  13. package/src/components/ui/placement-refusal.test.ts +7 -20
  14. package/src/components/ui/placement-refusal.ts +10 -12
  15. package/src/hooks/useDeleteMessages.ts +19 -0
  16. package/src/hooks/useInitialSyncProgress.render.test.ts +1 -0
  17. package/src/hooks/useMailboxAccount.ts +5 -0
  18. package/src/hooks/useToggleStar.test.ts +1 -0
  19. package/src/hooks/useToggleTrusted.test.ts +1 -0
  20. package/src/lib/brief.test.ts +1 -0
  21. package/src/lib/bulk-actions.test.ts +29 -1
  22. package/src/lib/bulk-actions.ts +59 -16
  23. package/src/lib/dedupe-thread-messages.test.ts +1 -0
  24. package/src/lib/mail-freshness.test.ts +64 -45
  25. package/src/lib/mail-freshness.tsx +43 -32
  26. package/src/lib/row-settlement.ts +21 -8
  27. package/src/lib/thread-search-tokens.test.ts +1 -0
  28. package/src/test-support/fixtures.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.273",
3
+ "version": "0.0.275",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -149,7 +149,7 @@ export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
149
149
  // itself that does not make `start` its own dependency.
150
150
  const startRef = useRef<BulkRunContextValue["start"]>(async () => ({
151
151
  kind: "ran",
152
- outcome: { done: 0, failedIds: [], cancelled: false },
152
+ outcome: { done: 0, failedIds: [], refused: 0, cancelled: false },
153
153
  }));
154
154
 
155
155
  /**
@@ -282,6 +282,19 @@ export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
282
282
  outcome.error,
283
283
  ),
284
284
  );
285
+ } else if (outcome.refused > 0) {
286
+ // One banner for the run, not one per chunk: a select-all is walked a
287
+ // hundred ids at a time, and a refusal in each would stack banners
288
+ // until the surface caps them. The count is the run's own total.
289
+ pushError({
290
+ severity: "warning",
291
+ title:
292
+ outcome.refused > 1
293
+ ? `Couldn't delete ${outcome.refused} of ${request.matched} messages yet`
294
+ : "Couldn't delete this message yet",
295
+ detail:
296
+ "Something else moved them while the delete was being prepared. They are back where they now sit — try again.",
297
+ });
285
298
  }
286
299
  // Nobody is left showing this run, so the ending is said where the user
287
300
  // now is. A screen still reporting on it says it in place instead.
@@ -160,6 +160,13 @@ const mount = async (flags: RemitImapAddressFlags): Promise<DomHarness> => {
160
160
  if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) {
161
161
  return describeMessage(flags);
162
162
  }
163
+ // `/config` answers its own shape. `accounts` is required by the contract,
164
+ // so a mock that hands back `{items: []}` for it is a body the API cannot
165
+ // produce, and anything reading it faithfully throws on a fixture rather
166
+ // than on a fault.
167
+ if (call.path.endsWith("/config")) {
168
+ return { accountConfig: {}, accounts: [] };
169
+ }
163
170
  return { items: [] };
164
171
  });
165
172
 
@@ -34,6 +34,7 @@ const expunging: DeleteOutcome[] = ["permanent", "unconfirmed"];
34
34
  const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
35
35
  done: 100,
36
36
  failedIds: [],
37
+ refused: 0,
37
38
  cancelled: false,
38
39
  ...over,
39
40
  });
@@ -21,6 +21,7 @@ const baseThread = (
21
21
  star: "None",
22
22
  isDeleted: false,
23
23
  senderTrust: "unknown",
24
+ abandonedMutation: "none",
24
25
  createdAt: 0,
25
26
  updatedAt: 0,
26
27
  ...overrides,
@@ -47,11 +48,58 @@ describe("threadToRowData — labels", () => {
47
48
  describe("threadToRowData — settlement (issue #1002)", () => {
48
49
  it("marks a row whose delete gave up and came back", () => {
49
50
  const row = threadToRowData(
50
- baseThread({ status: "active", syncStatus: "failed" }),
51
+ baseThread({
52
+ status: "active",
53
+ syncStatus: "abandoned",
54
+ abandonedMutation: "delete",
55
+ }),
51
56
  );
52
57
  assert.equal(row.settlement, "delete_failed");
53
58
  });
54
59
 
60
+ // Issue #1229. Both give-ups hand the row back the same way, so before the
61
+ // row named the mutation this rendered "Not deleted" over a move.
62
+ it("marks a row whose move gave up as a move, not a delete", () => {
63
+ const row = threadToRowData(
64
+ baseThread({
65
+ status: "active",
66
+ syncStatus: "abandoned",
67
+ abandonedMutation: "move",
68
+ }),
69
+ );
70
+ assert.equal(row.settlement, "move_failed");
71
+ });
72
+
73
+ it("says nothing about a copy that gave up, whose row no listing carries", () => {
74
+ const row = threadToRowData(
75
+ baseThread({
76
+ status: "deleted",
77
+ syncStatus: "abandoned",
78
+ abandonedMutation: "copy",
79
+ }),
80
+ );
81
+ assert.equal(row.settlement, undefined);
82
+ });
83
+
84
+ // `syncStatus` is the gate: a marker a later settle left behind says nothing.
85
+ it("says nothing about a settled row still carrying a stale marker", () => {
86
+ const row = threadToRowData(
87
+ baseThread({
88
+ status: "active",
89
+ syncStatus: "synced",
90
+ abandonedMutation: "delete",
91
+ }),
92
+ );
93
+ assert.equal(row.settlement, undefined);
94
+ });
95
+
96
+ it("says nothing about a transient attempt on an active row", () => {
97
+ const row = threadToRowData(
98
+ baseThread({ status: "active", syncStatus: "failed" }),
99
+ );
100
+ assert.equal(row.settlement, undefined);
101
+ });
102
+
55
103
  it("says nothing about a move mid-retry, which writes the same failed flag", () => {
56
104
  const row = threadToRowData(
57
105
  baseThread({ status: "moving", syncStatus: "failed" }),
@@ -77,14 +125,22 @@ describe("threadToRowData — settlement (issue #1002)", () => {
77
125
  describe("the mobile and drafts rows carry the same mark", () => {
78
126
  it("marks the swipeable mobile row", () => {
79
127
  const row = swipeableRowData(
80
- baseThread({ status: "active", syncStatus: "failed" }),
128
+ baseThread({
129
+ status: "active",
130
+ syncStatus: "abandoned",
131
+ abandonedMutation: "delete",
132
+ }),
81
133
  );
82
134
  assert.equal(row.settlement, "delete_failed");
83
135
  });
84
136
 
85
137
  it("marks the drafts row", () => {
86
138
  const row = toImapDraftRowData(
87
- baseThread({ status: "active", syncStatus: "failed" }),
139
+ baseThread({
140
+ status: "active",
141
+ syncStatus: "abandoned",
142
+ abandonedMutation: "delete",
143
+ }),
88
144
  );
89
145
  assert.equal(row.settlement, "delete_failed");
90
146
  });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The reading-pane notice names the mutation that gave up and offers the way
3
+ * out for that one (#1229).
4
+ *
5
+ * A delete retries through the ordinary delete endpoint. A move cannot — the
6
+ * destination the give-up discarded is on no row — so its retry is the folder
7
+ * picker, which needs an account to scope its list to. The caller's `accountId`
8
+ * is absent on rows from per-mailbox endpoints and for as long as the
9
+ * conversation's own lookup is in flight, and a notice that names a failure and
10
+ * then offers nothing is the same dead end as one that offers the wrong thing.
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import { afterEach, beforeEach, describe, it } from "node:test";
14
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
15
+ import { QueryClientProvider } from "@tanstack/react-query";
16
+ import { createElement } from "react";
17
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
18
+ import { type HttpMock, mockFetch } from "../../test-support/http";
19
+ import { ErrorBannerProvider } from "../ui/ErrorBannerProvider";
20
+ import { MessageSettlementAlert } from "./MessageSettlementAlert";
21
+ import { RoleAppointmentPromptProvider } from "./RoleAppointmentPromptProvider";
22
+
23
+ const ACCOUNT = "acc-1";
24
+ const MAILBOX = "mbx-inbox";
25
+
26
+ let harness: DomHarness | undefined;
27
+ let http: HttpMock;
28
+
29
+ const CONFIG = {
30
+ accounts: [{ accountId: ACCOUNT, email: "a@example.com" }],
31
+ };
32
+
33
+ const MAILBOXES = {
34
+ items: [
35
+ {
36
+ mailboxId: MAILBOX,
37
+ accountId: ACCOUNT,
38
+ fullPath: "INBOX",
39
+ hierarchyDelimiter: "/",
40
+ messageCount: 1,
41
+ },
42
+ ],
43
+ };
44
+
45
+ const respond = (path: string): unknown => {
46
+ if (path.endsWith("/config")) return CONFIG;
47
+ if (path.endsWith("/mailboxes")) return MAILBOXES;
48
+ return {};
49
+ };
50
+
51
+ const thread = (
52
+ over: Partial<RemitImapThreadMessageResponse>,
53
+ ): RemitImapThreadMessageResponse =>
54
+ ({
55
+ threadMessageId: "tm-1",
56
+ threadId: "th-1",
57
+ messageId: "msg-1",
58
+ accountConfigId: "cfg-1",
59
+ mailboxId: MAILBOX,
60
+ sentDate: 0,
61
+ isRead: true,
62
+ hasAttachment: false,
63
+ hasStars: false,
64
+ star: "None",
65
+ isDeleted: false,
66
+ senderTrust: "unknown",
67
+ category: "uncategorized",
68
+ status: "active",
69
+ syncStatus: "abandoned",
70
+ abandonedMutation: "none",
71
+ createdAt: 0,
72
+ updatedAt: 0,
73
+ ...over,
74
+ }) as RemitImapThreadMessageResponse;
75
+
76
+ const mount = (
77
+ threadMessage: RemitImapThreadMessageResponse,
78
+ accountId: string | undefined,
79
+ ): void => {
80
+ harness = createDomHarness();
81
+ harness.render(
82
+ createElement(
83
+ QueryClientProvider,
84
+ { client: harness.queryClient },
85
+ createElement(
86
+ ErrorBannerProvider,
87
+ null,
88
+ createElement(
89
+ RoleAppointmentPromptProvider,
90
+ null,
91
+ createElement(MessageSettlementAlert, { threadMessage, accountId }),
92
+ ),
93
+ ),
94
+ ),
95
+ );
96
+ };
97
+
98
+ const settle = async (done: () => boolean = () => false): Promise<void> => {
99
+ if (!harness) throw new Error("nothing mounted");
100
+ for (let round = 0; round < 40; round += 1) {
101
+ await harness.flush();
102
+ await harness.wait(0);
103
+ if (done()) return;
104
+ }
105
+ };
106
+
107
+ const labels = (): string[] =>
108
+ (harness?.queryAll("button") ?? []).map(
109
+ (button) => button.getAttribute("aria-label") ?? button.textContent ?? "",
110
+ );
111
+
112
+ const notice = (): string => harness?.text() ?? "";
113
+
114
+ beforeEach(() => {
115
+ http = mockFetch((call) => respond(call.path));
116
+ });
117
+
118
+ afterEach(() => {
119
+ harness?.close();
120
+ harness = undefined;
121
+ http.restore();
122
+ });
123
+
124
+ describe("MessageSettlementAlert", () => {
125
+ it("says nothing about a row no mutation gave up on", async () => {
126
+ mount(
127
+ thread({ syncStatus: "synced", abandonedMutation: "delete" }),
128
+ ACCOUNT,
129
+ );
130
+ await settle();
131
+
132
+ assert.equal(notice(), "");
133
+ });
134
+
135
+ it("offers Delete again for a delete that gave up", async () => {
136
+ mount(thread({ abandonedMutation: "delete" }), ACCOUNT);
137
+ await settle();
138
+
139
+ assert.match(notice(), /was not deleted/i);
140
+ assert.ok(labels().some((label) => /delete again/i.test(label)));
141
+ });
142
+
143
+ /**
144
+ * The account lookup fans out to `/config` and then to every account's
145
+ * mailbox list. Asking for it from each expanded message put that behind
146
+ * every open row in the product — and it did, until it was scoped to the one
147
+ * case that needs it. Pinned on the wire, because the cost is a request and
148
+ * only the request proves it is not being made.
149
+ */
150
+ it("asks for no configuration at all unless a move gave up", async () => {
151
+ mount(thread({ abandonedMutation: "delete" }), ACCOUNT);
152
+ await settle();
153
+
154
+ assert.deepEqual(http.to("/config"), []);
155
+ });
156
+
157
+ it("asks for none on a healthy row either", async () => {
158
+ mount(thread({ syncStatus: "synced" }), undefined);
159
+ await settle();
160
+
161
+ assert.deepEqual(http.to("/config"), []);
162
+ });
163
+
164
+ it("offers a folder picker for a move that gave up, never Delete again", async () => {
165
+ mount(thread({ abandonedMutation: "move" }), ACCOUNT);
166
+ await settle(() => labels().some((label) => /move again/i.test(label)));
167
+
168
+ assert.match(notice(), /was not moved/i);
169
+ assert.ok(labels().some((label) => /move again/i.test(label)));
170
+ assert.ok(!labels().some((label) => /delete again/i.test(label)));
171
+ });
172
+
173
+ it("resolves the account from the row's mailbox when the caller has none", async () => {
174
+ mount(thread({ abandonedMutation: "move" }), undefined);
175
+ await settle(() => labels().some((label) => /move again/i.test(label)));
176
+
177
+ assert.ok(
178
+ labels().some((label) => /move again/i.test(label)),
179
+ "a notice that names a failure always carries its way out",
180
+ );
181
+ });
182
+ });
@@ -1,19 +1,34 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
- import { hasAbandonedDelete } from "@remit/data-ports/message-settlement";
2
+ import { abandonedMutationOf } from "@remit/data-ports/message-settlement";
3
+ import { MessageMutation } from "@remit/domain-enums";
3
4
  import { MessageSettlementNotice, messageSettlementCopy } from "@remit/ui";
4
5
  import { useCallback, useMemo } from "react";
5
6
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
7
+ import { useMailboxAccount } from "@/hooks/useMailboxAccount";
8
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
6
9
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
10
+ import { MoveToTrigger } from "./MoveToTrigger";
7
11
 
8
12
  /**
9
- * States, on the open message, that Remit abandoned its delete (issue #1002) —
10
- * the failure the list can only chip.
13
+ * States, on the open message, that Remit gave up on a mutation (issue #1002) —
14
+ * the failure the list can only chip — and offers the way out for THAT
15
+ * mutation, never a different one (issue #1229).
11
16
  *
12
- * The retry is the ordinary delete endpoint, not a new one: abandoning put
13
- * `status` back to `active`, so `settledPlacement` passes the row through and
17
+ * A delete retries through the ordinary delete endpoint: giving up put `status`
18
+ * back to `active`, so `settledPlacement` passes the row through and
14
19
  * `POST /messages/delete` re-drives it against the folder the server actually
15
- * holds it in. Every other give-up gets no treatment — see `hasAbandonedDelete`
16
- * for the three the two fields cannot tell from a healthy row.
20
+ * holds it in. A move cannot retry that way — the destination the give-up
21
+ * discarded is on no row — so its retry is the same folder picker every other
22
+ * move goes through, which asks for one. Every other give-up gets no treatment;
23
+ * `abandonedMutationOf` says which and why.
24
+ *
25
+ * The picker needs an account to scope its folder list to, and the caller's
26
+ * `accountId` is absent on every row that came from a per-mailbox endpoint and
27
+ * for as long as the conversation's own lookup is in flight. It is resolved
28
+ * from the row's mailbox here rather than left out, because a notice that names
29
+ * a failure and offers no way to undo it is the same defect as one that offers
30
+ * the wrong way (#1229) — the resolver is the one `MessageActionMenu` already
31
+ * uses for exactly this.
17
32
  */
18
33
  export function MessageSettlementAlert({
19
34
  threadMessage,
@@ -24,43 +39,86 @@ export function MessageSettlementAlert({
24
39
  accountId: string | undefined;
25
40
  className?: string;
26
41
  }) {
27
- const abandoned = hasAbandonedDelete(threadMessage);
28
- const { deleteMessages, isPending } = useDeleteMessages({
42
+ const abandoned = abandonedMutationOf(threadMessage);
43
+ const settlement =
44
+ abandoned === MessageMutation.delete
45
+ ? ("delete_failed" as const)
46
+ : abandoned === MessageMutation.move
47
+ ? ("move_failed" as const)
48
+ : undefined;
49
+
50
+ // Only a move that gave up, and only where the caller has no account of its
51
+ // own, has anything to resolve — so every other row subscribes to nothing.
52
+ // Asking unconditionally would pull `/config` and every account's mailbox
53
+ // list into each expanded message, for a case almost none of them are.
54
+ const { accountId: mailboxAccountId } = useMailboxAccount(
55
+ settlement === "move_failed" && !accountId
56
+ ? threadMessage.mailboxId
57
+ : undefined,
58
+ );
59
+ const scopedAccountId = accountId ?? mailboxAccountId;
60
+
61
+ const { deleteMessages, isPending: isDeleting } = useDeleteMessages({
29
62
  mailboxId: threadMessage.mailboxId,
30
63
  threadId: threadMessage.threadId,
31
- accountId,
64
+ accountId: scopedAccountId,
32
65
  messages: [threadMessage],
33
66
  });
67
+ const { moveMessages, isPending: isMoving } = useMoveMessages({
68
+ mailboxId: threadMessage.mailboxId,
69
+ threadId: threadMessage.threadId,
70
+ accountId: scopedAccountId,
71
+ });
34
72
 
35
- const onRetry = useCallback(() => {
73
+ const onDeleteAgain = useCallback(() => {
36
74
  deleteMessages([threadMessage.messageId]);
37
75
  }, [deleteMessages, threadMessage.messageId]);
38
76
 
77
+ const onMoveAgain = useCallback(
78
+ (destinationMailboxId: string) => {
79
+ moveMessages([threadMessage.messageId], destinationMailboxId);
80
+ },
81
+ [moveMessages, threadMessage.messageId],
82
+ );
83
+
39
84
  const reportHref = useMemo(
40
85
  () =>
41
- abandoned
86
+ settlement
42
87
  ? buildGitHubIssueUrl(
43
88
  buildBugReportContext({
44
- title: messageSettlementCopy.delete_failed.title,
45
- errorMessage: `Message ${threadMessage.messageId}: status=${threadMessage.status} syncStatus=${threadMessage.syncStatus}`,
89
+ title: messageSettlementCopy[settlement].title,
90
+ errorMessage: `Message ${threadMessage.messageId}: status=${threadMessage.status} syncStatus=${threadMessage.syncStatus} abandonedMutation=${threadMessage.abandonedMutation}`,
46
91
  }),
47
92
  )
48
93
  : undefined,
49
94
  [
50
- abandoned,
95
+ settlement,
51
96
  threadMessage.messageId,
52
97
  threadMessage.status,
53
98
  threadMessage.syncStatus,
99
+ threadMessage.abandonedMutation,
54
100
  ],
55
101
  );
56
102
 
57
- if (!abandoned) return null;
103
+ if (!settlement) return null;
58
104
 
59
105
  return (
60
106
  <MessageSettlementNotice
61
- settlement="delete_failed"
62
- onRetry={onRetry}
63
- retryPending={isPending}
107
+ settlement={settlement}
108
+ onRetry={settlement === "delete_failed" ? onDeleteAgain : undefined}
109
+ action={
110
+ settlement === "move_failed" && scopedAccountId ? (
111
+ <MoveToTrigger
112
+ accountId={scopedAccountId}
113
+ currentMailboxId={threadMessage.mailboxId}
114
+ onMove={onMoveAgain}
115
+ disabled={isMoving}
116
+ variant="compact"
117
+ label={messageSettlementCopy.move_failed.retryLabel}
118
+ />
119
+ ) : undefined
120
+ }
121
+ retryPending={settlement === "delete_failed" ? isDeleting : isMoving}
64
122
  reportHref={reportHref}
65
123
  className={className}
66
124
  />
@@ -6,6 +6,7 @@ import { bulkRunReport } from "./bulk-run-state";
6
6
  const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
7
7
  done: 0,
8
8
  failedIds: [],
9
+ refused: 0,
9
10
  cancelled: false,
10
11
  ...over,
11
12
  });
@@ -97,6 +97,7 @@ const describeMessage: RemitImapDescribeMessageResponse = {
97
97
  internalDate: 1_767_225_600_000,
98
98
  status: "active",
99
99
  syncStatus: "pending",
100
+ abandonedMutation: "none",
100
101
  },
101
102
  envelope: {
102
103
  messageId: MESSAGE_ID,
@@ -80,6 +80,7 @@ const describeMessage: RemitImapDescribeMessageResponse = {
80
80
  internalDate: 1_767_225_600_000,
81
81
  status: "active",
82
82
  syncStatus: "pending",
83
+ abandonedMutation: "none",
83
84
  },
84
85
  envelope: {
85
86
  messageId: MESSAGE_ID,
@@ -145,6 +145,7 @@ describe("whether the run screen offers a way to end the run", () => {
145
145
  const outcome: BulkRunOutcome = {
146
146
  done: 2,
147
147
  failedIds: [],
148
+ refused: 0,
148
149
  cancelled: false,
149
150
  };
150
151
 
@@ -94,6 +94,7 @@ const describeMessage: RemitImapDescribeMessageResponse = {
94
94
  internalDate: LATEST_SENT,
95
95
  status: "active",
96
96
  syncStatus: "pending",
97
+ abandonedMutation: "none",
97
98
  },
98
99
  envelope: {
99
100
  messageId: MESSAGE_ID,
@@ -56,16 +56,6 @@ describe("placementRefusalBanner", () => {
56
56
  assert.match(banner.detail ?? "", /try again in a moment/i);
57
57
  });
58
58
 
59
- it("tells an abandoned move to resync, not to wait", () => {
60
- const banner = placementRefusalBanner(
61
- { reason: "unverified", messageId: "msg-1" },
62
- 1,
63
- "delete",
64
- );
65
- assert.match(banner.detail ?? "", /sync the folder/i);
66
- assert.doesNotMatch(banner.detail ?? "", /try again in a moment/i);
67
- });
68
-
69
59
  it("counts the selection in the title", () => {
70
60
  assert.match(
71
61
  placementRefusalBanner(
@@ -90,22 +80,19 @@ describe("placementRefusalBanner", () => {
90
80
 
91
81
  const detail =
92
82
  placementRefusalBanner(
93
- { reason: "unverified", messageId: "msg-1" },
83
+ { reason: "in_flight", messageId: "msg-1" },
94
84
  1,
95
85
  "move",
96
86
  ).detail ?? "";
97
- assert.match(detail, /move it again/i);
98
87
  assert.doesNotMatch(detail, /delete/i);
99
88
  });
100
89
 
101
90
  it("never repeats the server's uuid at the user", () => {
102
- for (const reason of ["in_flight", "unverified"] as const) {
103
- const banner = placementRefusalBanner(
104
- { reason, messageId: "msg-1" },
105
- 1,
106
- "delete",
107
- );
108
- assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
109
- }
91
+ const banner = placementRefusalBanner(
92
+ { reason: "in_flight", messageId: "msg-1" },
93
+ 1,
94
+ "delete",
95
+ );
96
+ assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
110
97
  });
111
98
  });
@@ -9,11 +9,13 @@ import type { PushErrorInput } from "@/components/ui/error-banners";
9
9
  import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
10
10
 
11
11
  /**
12
- * `in_flight` clears on its own once the mail server confirms the move;
13
- * `unverified` does not, because the move gave up without confirming and
14
- * nothing routine repairs the row.
12
+ * One reason, because one state produces the refusal: a mutation is still in
13
+ * flight and the pair clears on its own, so the copy words a wait. A mutation
14
+ * that gave up hands the row back to a placement the mail server confirmed
15
+ * before it stops (imap-mutations R3), and that row is acted on rather than
16
+ * refused — there is no second reason left to word.
15
17
  */
16
- export type PlacementRefusalReason = "in_flight" | "unverified";
18
+ export type PlacementRefusalReason = "in_flight";
17
19
 
18
20
  export interface PlacementRefusal {
19
21
  reason: PlacementRefusalReason;
@@ -22,7 +24,6 @@ export interface PlacementRefusal {
22
24
 
23
25
  const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
24
26
  "in_flight",
25
- "unverified",
26
27
  ]);
27
28
 
28
29
  const stringAt = (
@@ -53,11 +54,11 @@ export const isPlacementRefusal = (
53
54
  export type PlacementRefusalAction = "delete" | "move";
54
55
 
55
56
  /**
56
- * The banner copy. Both reasons say what happened and what to do; neither is a
57
- * dead end, and neither repeats the server's uuid at the user.
57
+ * The banner copy. It says what happened and what to do, is not a dead end, and
58
+ * does not repeat the server's uuid at the user.
58
59
  */
59
60
  export const placementRefusalBanner = (
60
- refusal: PlacementRefusal,
61
+ _refusal: PlacementRefusal,
61
62
  count: number,
62
63
  action: PlacementRefusalAction,
63
64
  ): PushErrorInput => ({
@@ -66,8 +67,5 @@ export const placementRefusalBanner = (
66
67
  count > 1
67
68
  ? `Couldn't ${action} ${count} messages yet`
68
69
  : `Couldn't ${action} this message yet`,
69
- detail:
70
- refusal.reason === "in_flight"
71
- ? "It is still being moved on the mail server. Try again in a moment."
72
- : `An earlier move never finished, so where this message sits is unknown. Sync the folder, then ${action} it again.`,
70
+ detail: "It is still being moved on the mail server. Try again in a moment.",
73
71
  });
@@ -161,6 +161,25 @@ export const useDeleteMessages = ({
161
161
  previousThreadsList,
162
162
  };
163
163
  },
164
+ // A batch that partly applied answers 200 with a non-zero `failureCount`:
165
+ // the rows whose placement changed under it were never claimed
166
+ // (imap-mutations R3). `onSettled` invalidates, so those rows come back on
167
+ // their own — this is what stops them coming back unexplained, which is
168
+ // the dead-button failure wearing a different face (#1229).
169
+ onSuccess: (data, vars) => {
170
+ const refused = data?.failureCount ?? 0;
171
+ if (refused === 0) return;
172
+ const asked = vars.body.messageIds?.length ?? 0;
173
+ pushError({
174
+ severity: "warning",
175
+ title:
176
+ refused > 1
177
+ ? `Couldn't delete ${refused} of ${asked} messages yet`
178
+ : "Couldn't delete this message yet",
179
+ detail:
180
+ "Something else moved it while the delete was being prepared. It is back where it now sits — try again.",
181
+ });
182
+ },
164
183
  onError: (err, vars, context) => {
165
184
  if (context) {
166
185
  for (const entry of context.previousThreadMessages) {
@@ -55,6 +55,7 @@ const status = (
55
55
  phase: "syncing",
56
56
  messagesSynced: mailbox.synced,
57
57
  messagesTotal: mailbox.total,
58
+ highWaterMarkUid: mailbox.synced,
58
59
  })),
59
60
  });
60
61
 
@@ -25,9 +25,14 @@ interface MailboxAccountResolution {
25
25
  export const useMailboxAccount = (
26
26
  mailboxId: string | undefined,
27
27
  ): MailboxAccountResolution => {
28
+ // Disabled where there is no mailbox to resolve, the way the fan-out below
29
+ // already is. A caller that already knows its account passes `undefined` and
30
+ // this subscribes to nothing — which keeps the hook from pulling `/config`
31
+ // into trees that never asked for it.
28
32
  const { data: config, isLoading: isConfigLoading } = useQuery({
29
33
  ...configOperationsGetConfigOptions(),
30
34
  staleTime: Infinity,
35
+ enabled: !!mailboxId,
31
36
  });
32
37
 
33
38
  const accountIds = useMemo(