@remit/web-client 0.0.121 → 0.0.123

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/web-client",
3
- "version": "0.0.121",
3
+ "version": "0.0.123",
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": {
@@ -405,7 +405,7 @@ export const ComposeForm = ({
405
405
  sourceMessage?.envelope.from[0]?.displayName ??
406
406
  sourceMessage?.envelope.from[0]?.normalizedEmail;
407
407
 
408
- const { saveStatus, saveError, saveDraft, cancelAutoSave } = useSaveDraft({
408
+ const { saveStatus, saveError, saveDraft, stopAutoSave } = useSaveDraft({
409
409
  outboxMessageId,
410
410
  onDraftCreated: setOutboxMessageId,
411
411
  });
@@ -504,7 +504,7 @@ export const ComposeForm = ({
504
504
  const handleSend = useCallback(async () => {
505
505
  if (!selectedAccountId || toAddresses.length === 0) return;
506
506
 
507
- cancelAutoSave();
507
+ stopAutoSave();
508
508
 
509
509
  const replyData =
510
510
  sourceMessage && (mode === "reply" || mode === "reply_all")
@@ -534,7 +534,7 @@ export const ComposeForm = ({
534
534
  subject: subject || undefined,
535
535
  textBody: textBody || undefined,
536
536
  htmlBody: htmlBody || undefined,
537
- sendImmediately: true,
537
+ sendImmediately: false,
538
538
  ...replyData,
539
539
  },
540
540
  })
@@ -570,6 +570,7 @@ export const ComposeForm = ({
570
570
  });
571
571
  if (sent === null) return;
572
572
 
573
+ stopAutoSave(messageId);
573
574
  startSendPolling(messageId);
574
575
  onClose();
575
576
  }, [
@@ -584,7 +585,7 @@ export const ComposeForm = ({
584
585
  outboxMessageId,
585
586
  createMutation,
586
587
  sendMutation,
587
- cancelAutoSave,
588
+ stopAutoSave,
588
589
  startSendPolling,
589
590
  setOutboxMessageId,
590
591
  pushError,
@@ -592,14 +593,14 @@ export const ComposeForm = ({
592
593
  ]);
593
594
 
594
595
  const handleDiscard = useCallback(() => {
595
- cancelAutoSave();
596
+ stopAutoSave(outboxMessageId);
596
597
  if (outboxMessageId) {
597
598
  deleteMutation.mutate({
598
599
  path: { outboxMessageId },
599
600
  });
600
601
  }
601
602
  onClose();
602
- }, [cancelAutoSave, outboxMessageId, deleteMutation, onClose]);
603
+ }, [stopAutoSave, outboxMessageId, deleteMutation, onClose]);
603
604
 
604
605
  const handleAccountChange = useCallback(
605
606
  (acct: RemitImapAccountResponse) => {
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Issue #604: two seconds after a reply was sent, the draft editor fired one
3
+ * more autosave PATCH at the outbox entry the send had just moved out of draft,
4
+ * and the fatal-error overlay went up over a message that had gone out fine.
5
+ *
6
+ * `handleSend` already dropped the pending autosave timer, but the autosave
7
+ * effect reschedules whenever the save mutation settles — and the last PATCH of
8
+ * a typing burst settles after the send request has left. So the guarantee
9
+ * asserted here is about the entry, not the timer: once it has been sent,
10
+ * nothing writes to it again.
11
+ *
12
+ * The second case is the same rule met from the other side. Send pressed before
13
+ * the first autosave has run has no draft to send, so compose creates one and
14
+ * then sends it — and creating it as already-queued makes that send a second
15
+ * dispatch, which the server refuses.
16
+ *
17
+ * The stub holds the outbox rule the API holds: only a draft accepts a send.
18
+ */
19
+
20
+ import assert from "node:assert/strict";
21
+ import { afterEach, describe, it } from "node:test";
22
+ import type {
23
+ RemitImapAccountResponse,
24
+ RemitImapDescribeMessageResponse,
25
+ } from "@remit/api-http-client/types.gen.ts";
26
+ import { createElement, useEffect } from "react";
27
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
28
+ import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
29
+ import { ComposeForm } from "./ComposeForm";
30
+ import { ComposeProvider, useCompose } from "./ComposeProvider";
31
+
32
+ const ACCOUNT_ID = "acc-1";
33
+ const OUTBOX_MESSAGE_ID = "ob-604";
34
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
35
+
36
+ const account = {
37
+ accountId: ACCOUNT_ID,
38
+ email: "me@example.com",
39
+ smtpEnabled: true,
40
+ } as unknown as RemitImapAccountResponse;
41
+
42
+ const sourceMessage = {
43
+ message: { messageId: "msg-1" },
44
+ envelope: {
45
+ subject: "Lunch",
46
+ messageIdValue: "<m1@example.com>",
47
+ from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
48
+ replyTo: [],
49
+ to: [],
50
+ cc: [],
51
+ },
52
+ references: [],
53
+ bodyParts: [],
54
+ } as unknown as RemitImapDescribeMessageResponse;
55
+
56
+ const outboxEntry = (status: string) => ({
57
+ outboxMessageId: OUTBOX_MESSAGE_ID,
58
+ accountId: ACCOUNT_ID,
59
+ fromAddress: account.email,
60
+ toAddresses: ["them@example.com"],
61
+ ccAddresses: [],
62
+ bccAddresses: [],
63
+ references: [],
64
+ subject: "Re: Lunch",
65
+ textBody: "yes",
66
+ status,
67
+ });
68
+
69
+ let harness: DomHarness | undefined;
70
+ let http: HttpMock | undefined;
71
+ let closed = 0;
72
+
73
+ afterEach(() => {
74
+ harness?.close();
75
+ harness = undefined;
76
+ http?.restore();
77
+ http = undefined;
78
+ closed = 0;
79
+ });
80
+
81
+ const callsTo = (suffix: string) =>
82
+ (http?.calls ?? []).filter((call) => call.path.endsWith(suffix));
83
+
84
+ const patchCount = (): number =>
85
+ (http?.calls ?? []).filter((call) => call.method === "PATCH").length;
86
+
87
+ const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
88
+ const { state, openCompose } = useCompose();
89
+
90
+ useEffect(() => {
91
+ openCompose({ mode: "reply", account, sourceMessage, outboxMessageId });
92
+ }, [openCompose, outboxMessageId]);
93
+
94
+ if (!state.isOpen) return null;
95
+
96
+ return createElement(ComposeForm, {
97
+ mode: "reply",
98
+ account,
99
+ sourceMessage,
100
+ onClose: () => {
101
+ closed += 1;
102
+ },
103
+ });
104
+ };
105
+
106
+ interface MountOptions {
107
+ /** The draft compose opens on, when the user is resuming one. */
108
+ outboxMessageId?: string;
109
+ /** Hold the PATCH response open so a save is still in flight at send time. */
110
+ gatePatch?: boolean;
111
+ }
112
+
113
+ const mount = async (
114
+ options: MountOptions = {},
115
+ ): Promise<{ releasePatch: () => void }> => {
116
+ let release = (): void => {};
117
+ const patchGate = options.gatePatch
118
+ ? new Promise<void>((resolve) => {
119
+ release = resolve;
120
+ })
121
+ : Promise.resolve();
122
+
123
+ let status = options.outboxMessageId ? "draft" : "absent";
124
+
125
+ http = mockFetch(async (call) => {
126
+ if (call.path.endsWith("/config")) return { accounts: [account] };
127
+
128
+ if (call.method === "POST" && call.path.endsWith("/outbox")) {
129
+ status = call.body?.sendImmediately === true ? "queued" : "draft";
130
+ return outboxEntry(status);
131
+ }
132
+
133
+ if (call.path.endsWith("/send")) {
134
+ if (status !== "draft") {
135
+ return httpError(409, `This message is already ${status}.`);
136
+ }
137
+ status = "queued";
138
+ return outboxEntry(status);
139
+ }
140
+
141
+ if (call.method === "PATCH") {
142
+ await patchGate;
143
+ return outboxEntry(status);
144
+ }
145
+
146
+ return outboxEntry(status);
147
+ });
148
+
149
+ harness = createDomHarness();
150
+ harness.renderApp(
151
+ createElement(
152
+ ComposeProvider,
153
+ null,
154
+ createElement(Opened, { outboxMessageId: options.outboxMessageId }),
155
+ ),
156
+ );
157
+ await harness.flush();
158
+ await harness.wait(50);
159
+
160
+ return { releasePatch: release };
161
+ };
162
+
163
+ const subjectField = (): HTMLElement => {
164
+ const field = harness?.query("[data-subject-field]");
165
+ if (!field) throw new Error("the compose subject field is not mounted");
166
+ return field;
167
+ };
168
+
169
+ const sendButton = (): HTMLElement => {
170
+ const button = harness?.byText("button", "Send");
171
+ if (!button) throw new Error("the compose send button is not mounted");
172
+ return button;
173
+ };
174
+
175
+ describe("compose and the outbox entry it is sending (#604)", () => {
176
+ it("writes no further PATCH to an entry once it has been sent", async () => {
177
+ const { releasePatch } = await mount({
178
+ outboxMessageId: OUTBOX_MESSAGE_ID,
179
+ gatePatch: true,
180
+ });
181
+
182
+ harness?.type(subjectField(), "Re: Lunch tomorrow");
183
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 100);
184
+ assert.equal(patchCount(), 1, "the typing burst autosaves once");
185
+
186
+ harness?.click(sendButton());
187
+ await harness?.flush();
188
+
189
+ // The autosave that was still in flight settles after the send, which is
190
+ // what used to re-arm the debounce.
191
+ releasePatch();
192
+ await harness?.flush();
193
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
194
+
195
+ assert.equal(callsTo("/send").length, 1, "the send went out");
196
+ assert.equal(patchCount(), 1, "no autosave PATCH followed the send");
197
+ });
198
+
199
+ it("sends a reply pressed before the first autosave, in one dispatch", async () => {
200
+ await mount();
201
+
202
+ harness?.click(sendButton());
203
+ await harness?.flush();
204
+ await harness?.wait(100);
205
+
206
+ const created = callsTo("/outbox").filter((call) => call.method === "POST");
207
+ assert.equal(created.length, 1, "the message was created once");
208
+ assert.equal(created[0]?.body?.sendImmediately, false);
209
+ assert.equal(callsTo("/send").length, 1, "the send went out");
210
+ assert.equal(closed, 1, "compose closed on a successful send");
211
+ });
212
+ });
@@ -61,6 +61,30 @@ describe("resolveSpamAction (#594)", () => {
61
61
  );
62
62
  });
63
63
 
64
+ test("offers nothing once the panel has moved this message", () => {
65
+ assert.equal(
66
+ resolveSpamAction({
67
+ mailboxId: junkId,
68
+ junkMailboxId: junkId,
69
+ inboxMailboxId: inboxId,
70
+ moveApplied: true,
71
+ }),
72
+ null,
73
+ );
74
+ });
75
+
76
+ test("offers Not spam again when the move came back failed", () => {
77
+ assert.equal(
78
+ resolveSpamAction({
79
+ mailboxId: junkId,
80
+ junkMailboxId: junkId,
81
+ inboxMailboxId: inboxId,
82
+ moveApplied: false,
83
+ }),
84
+ "notSpam",
85
+ );
86
+ });
87
+
64
88
  test("the two actions are mutually exclusive", () => {
65
89
  const inJunk = resolveSpamAction({
66
90
  mailboxId: junkId,
@@ -25,9 +25,9 @@ export interface IntelligencePaneProps {
25
25
  * the empty-state placeholder.
26
26
  */
27
27
  thread?: RemitImapThreadMessageResponse;
28
- /** The mailbox the message list is currently showing — the move source. */
28
+ /** The mailbox the message list is currently showing. */
29
29
  mailboxId?: string;
30
- /** Account that owns `mailboxId`; resolves the Junk/Inbox move targets. */
30
+ /** Account that owns the open message; resolves the Junk/Inbox move targets. */
31
31
  accountId?: string;
32
32
  /**
33
33
  * Hide the panel's own close (X). Set when hosted inside the mobile Drawer,
@@ -54,12 +54,20 @@ type CategoryOverride = (typeof CATEGORY_OVERRIDES)[number];
54
54
  /**
55
55
  * Decide which spam quick-action to offer for the current message (issue #594).
56
56
  *
57
+ * `mailboxId` is the mailbox the MESSAGE is in, which is what the move acts on.
57
58
  * The two buttons are symmetric and mutually exclusive:
58
59
  * - In the Junk mailbox → offer **Not spam** (move out, promote sender), but
59
60
  * only when an Inbox destination is resolved.
60
61
  * - Anywhere else → offer **Mark spam** (move in, demote sender), but only when
61
62
  * the Junk destination is resolved.
62
63
  *
64
+ * `moveApplied` says the panel has already moved this message and the row it is
65
+ * rendering has not caught up. The list drops the row on the move, but the
66
+ * reading pane keeps the message open from the row it was opened with, whose
67
+ * mailbox is still the source — so without this the button survives its own
68
+ * press, and a second press asks the server to move the message to where it now
69
+ * already is.
70
+ *
63
71
  * Returns `"notSpam"`, `"markSpam"`, or `null` (no actionable button — e.g. the
64
72
  * move source isn't known, or the needed target mailbox hasn't loaded). Pure so
65
73
  * the wiring decision can be unit-tested without rendering.
@@ -68,8 +76,10 @@ export const resolveSpamAction = (input: {
68
76
  mailboxId?: string;
69
77
  junkMailboxId?: string;
70
78
  inboxMailboxId?: string;
79
+ moveApplied?: boolean;
71
80
  }): "notSpam" | "markSpam" | null => {
72
- const { mailboxId, junkMailboxId, inboxMailboxId } = input;
81
+ const { mailboxId, junkMailboxId, inboxMailboxId, moveApplied } = input;
82
+ if (moveApplied) return null;
73
83
  if (!mailboxId) return null;
74
84
  const isInJunk = Boolean(junkMailboxId && mailboxId === junkMailboxId);
75
85
  if (isInJunk) return inboxMailboxId ? "notSpam" : null;
@@ -235,27 +245,39 @@ function WiredPanel({
235
245
  senderEmail,
236
246
  });
237
247
 
238
- // "Not spam" / "Mark spam" move the message across the Junk boundary; the
239
- // backend's moveMessage then promotes (out of Junk) or demotes (into Junk)
240
- // the sender's trust (issue #594). Only one button is wired at a time,
241
- // depending on whether the message is currently sitting in Junk.
248
+ // "Not spam" / "Mark spam" move the message across the Junk boundary. Only
249
+ // one is wired at a time, decided by the mailbox the message itself is in —
250
+ // not by the list the user happens to be looking at, which for a search or a
251
+ // cross-account view names a different folder (issue #594).
242
252
  const { junkMailboxId } = useJunkMailbox(accountId);
243
253
  const { inboxMailboxId } = useInboxMailbox(accountId);
244
- const { moveMessages } = useMoveMessages({
245
- mailboxId: mailboxId ?? "",
254
+ const { moveMessages, isError: moveFailed } = useMoveMessages({
255
+ mailboxId: thread.mailboxId,
246
256
  threadId: thread.threadId,
247
257
  accountId,
248
258
  });
249
259
  const telemetry = useTelemetry();
260
+ const [lastMove, setLastMove] = useState<
261
+ { messageId: string; mailboxId: string } | undefined
262
+ >(undefined);
250
263
 
264
+ // Spent while the row this panel is rendering still names the mailbox the
265
+ // move took the message OUT of. It resolves itself: a row that has caught up
266
+ // with the move offers the actions again, and a move that comes back failed
267
+ // is rolled back with a banner, so the retry is against the same button.
251
268
  const spamAction = resolveSpamAction({
252
- mailboxId,
269
+ mailboxId: thread.mailboxId,
253
270
  junkMailboxId,
254
271
  inboxMailboxId,
272
+ moveApplied:
273
+ !moveFailed &&
274
+ lastMove?.messageId === thread.messageId &&
275
+ lastMove.mailboxId !== thread.mailboxId,
255
276
  });
256
277
 
257
278
  const handleNotSpam = useCallback(() => {
258
279
  if (!inboxMailboxId) return;
280
+ setLastMove({ messageId: thread.messageId, mailboxId: inboxMailboxId });
259
281
  moveMessages([thread.messageId], inboxMailboxId);
260
282
  }, [inboxMailboxId, moveMessages, thread.messageId]);
261
283
 
@@ -266,6 +288,7 @@ function WiredPanel({
266
288
  senderTrust: thread.senderTrust,
267
289
  wasRescuable: isRescueCandidate(thread),
268
290
  });
291
+ setLastMove({ messageId: thread.messageId, mailboxId: junkMailboxId });
269
292
  moveMessages([thread.messageId], junkMailboxId);
270
293
  }, [junkMailboxId, moveMessages, thread, telemetry]);
271
294
 
@@ -71,7 +71,7 @@ export const useMoveMessages = ({
71
71
  const queryClient = useQueryClient();
72
72
  const { pushError } = useErrorBanners();
73
73
 
74
- const { mutateAsync, isPending } = useMutation({
74
+ const { mutateAsync, isPending, isError } = useMutation({
75
75
  ...messageBulkOperationsMoveMessagesMutation(),
76
76
  onMutate: async (variables): Promise<MoveContext> => {
77
77
  const messageIds = new Set(variables.body.messageIds ?? []);
@@ -189,5 +189,5 @@ export const useMoveMessages = ({
189
189
  [mutateAsync],
190
190
  );
191
191
 
192
- return { moveMessages, isPending };
192
+ return { moveMessages, isPending, isError };
193
193
  };
@@ -32,6 +32,7 @@ export const useSaveDraft = ({
32
32
  const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
33
33
  const [saveError, setSaveError] = useState<unknown>(null);
34
34
  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
35
+ const closedIdsRef = useRef<Set<string>>(new Set());
35
36
  const queryClient = useQueryClient();
36
37
 
37
38
  const createMutation = useMutation(
@@ -88,6 +89,7 @@ export const useSaveDraft = ({
88
89
 
89
90
  const saveDraft = useCallback(
90
91
  (data: DraftData) => {
92
+ if (outboxMessageId && closedIdsRef.current.has(outboxMessageId)) return;
91
93
  if (timerRef.current) clearTimeout(timerRef.current);
92
94
  timerRef.current = setTimeout(() => {
93
95
  // Keep the real error, not just a vague "error" status — the caller
@@ -99,7 +101,7 @@ export const useSaveDraft = ({
99
101
  });
100
102
  }, 2000);
101
103
  },
102
- [executeSave],
104
+ [executeSave, outboxMessageId],
103
105
  );
104
106
 
105
107
  const saveImmediately = useCallback(
@@ -110,9 +112,15 @@ export const useSaveDraft = ({
110
112
  [executeSave],
111
113
  );
112
114
 
113
- const cancelAutoSave = useCallback(() => {
115
+ // Called with an id, the entry is closed to autosave for good. Sending and
116
+ // discarding both take the entry out of draft, and the compose effect that
117
+ // schedules autosaves re-runs on every mutation settling — so dropping the
118
+ // pending timer alone leaves the next render free to schedule another write
119
+ // against an entry the server will refuse (#604).
120
+ const stopAutoSave = useCallback((closedOutboxMessageId?: string) => {
114
121
  if (timerRef.current) clearTimeout(timerRef.current);
122
+ if (closedOutboxMessageId) closedIdsRef.current.add(closedOutboxMessageId);
115
123
  }, []);
116
124
 
117
- return { saveStatus, saveError, saveDraft, saveImmediately, cancelAutoSave };
125
+ return { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave };
118
126
  };