@remit/web-client 0.0.134 → 0.0.136

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.134",
3
+ "version": "0.0.136",
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": {
@@ -3,7 +3,6 @@ import {
3
3
  outboxDetailOperationsDeleteOutboxMessageMutation,
4
4
  outboxDetailOperationsGetOutboxMessageOptions,
5
5
  outboxDetailOperationsSendOutboxMessageMutation,
6
- outboxOperationsCreateOutboxMessageMutation,
7
6
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
7
  import type {
9
8
  RemitImapAccountResponse,
@@ -442,10 +441,11 @@ export const ComposeForm = ({
442
441
  [setOutboxMessageId],
443
442
  );
444
443
 
445
- const { saveStatus, saveError, saveDraft, stopAutoSave } = useSaveDraft({
446
- outboxMessageId,
447
- onDraftCreated: adoptCreatedDraft,
448
- });
444
+ const { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave } =
445
+ useSaveDraft({
446
+ outboxMessageId,
447
+ onDraftCreated: adoptCreatedDraft,
448
+ });
449
449
 
450
450
  // Auto-save runs on a debounce, so a failure has no inline call site to
451
451
  // surface it. Push the real error detail to a banner instead of leaving only
@@ -460,10 +460,6 @@ export const ComposeForm = ({
460
460
  });
461
461
  }, [saveError, pushError]);
462
462
 
463
- const createMutation = useMutation(
464
- outboxOperationsCreateOutboxMessageMutation(),
465
- );
466
-
467
463
  const sendMutation = useMutation(
468
464
  outboxDetailOperationsSendOutboxMessageMutation(),
469
465
  );
@@ -496,7 +492,11 @@ export const ComposeForm = ({
496
492
  ? accountIsMissingSmtp(selectedAccount)
497
493
  : false;
498
494
 
499
- const isSending = createMutation.isPending || sendMutation.isPending;
495
+ // The action bar refuses a second press while one is in flight, but the
496
+ // editor's own Cmd+Enter goes straight to `handleSend`, and the write that
497
+ // now precedes the request widens the window a second press lands in.
498
+ const sendInFlightRef = useRef(false);
499
+ const [isSending, setIsSending] = useState(false);
500
500
  const canSend =
501
501
  toAddresses.length > 0 &&
502
502
  !!selectedAccountId &&
@@ -538,76 +538,79 @@ export const ComposeForm = ({
538
538
  ]);
539
539
 
540
540
  const handleSend = useCallback(async () => {
541
+ if (sendInFlightRef.current) return;
541
542
  if (!selectedAccountId || toAddresses.length === 0) return;
542
543
 
543
- stopAutoSave();
544
-
545
- const replyData =
546
- sourceMessage && (mode === "reply" || mode === "reply_all")
547
- ? getReferences(sourceMessage)
548
- : {};
544
+ sendInFlightRef.current = true;
545
+ setIsSending(true);
546
+ try {
547
+ stopAutoSave();
549
548
 
550
- let messageId = outboxMessageId;
551
- let createdThisAttempt = false;
549
+ const replyData =
550
+ sourceMessage && (mode === "reply" || mode === "reply_all")
551
+ ? getReferences(sourceMessage)
552
+ : {};
552
553
 
553
- if (!messageId) {
554
554
  const { html: htmlBody, text: textBody } = body;
555
+ const createdThisAttempt = !outboxMessageId;
556
+
557
+ // The debounce dropped above may have been holding the last two seconds
558
+ // of typing, and an existing entry would otherwise go out as the server
559
+ // last saw it (#674). What is on screen is written first, and a write
560
+ // that fails stops the send rather than transmitting the older copy.
561
+ const flushed = await saveImmediately({
562
+ accountId: selectedAccountId,
563
+ toAddresses: toAddresses.map((a) => a.email),
564
+ ccAddresses:
565
+ ccAddresses.length > 0 ? ccAddresses.map((a) => a.email) : undefined,
566
+ bccAddresses:
567
+ bccAddresses.length > 0
568
+ ? bccAddresses.map((a) => a.email)
569
+ : undefined,
570
+ subject: subject || undefined,
571
+ textBody: textBody || undefined,
572
+ htmlBody: htmlBody || undefined,
573
+ ...replyData,
574
+ });
555
575
 
556
- const outboxMessage = await createMutation
576
+ if (flushed.outcome === "failed") {
577
+ pushError({
578
+ title: "Couldn't send message",
579
+ detail:
580
+ formatErrorDetail(flushed.error) ??
581
+ "Saving the message failed, so nothing was sent. Try again.",
582
+ error: flushed.error,
583
+ });
584
+ return;
585
+ }
586
+
587
+ const messageId = flushed.outboxMessageId;
588
+
589
+ const sent = await sendMutation
557
590
  .mutateAsync({
558
- body: {
559
- accountId: selectedAccountId,
560
- toAddresses: toAddresses.map((a) => a.email),
561
- ccAddresses:
562
- ccAddresses.length > 0
563
- ? ccAddresses.map((a) => a.email)
564
- : undefined,
565
- bccAddresses:
566
- bccAddresses.length > 0
567
- ? bccAddresses.map((a) => a.email)
568
- : undefined,
569
- subject: subject || undefined,
570
- textBody: textBody || undefined,
571
- htmlBody: htmlBody || undefined,
572
- sendImmediately: false,
573
- ...replyData,
574
- },
591
+ path: { outboxMessageId: messageId },
575
592
  })
576
593
  .catch((error: unknown) => {
577
594
  pushError({
578
595
  title: "Couldn't send message",
579
- detail: formatErrorDetail(error) ?? "Saving the draft failed.",
596
+ detail:
597
+ formatErrorDetail(error) ??
598
+ (createdThisAttempt
599
+ ? "The draft was saved but the send request failed. Try again from the Outbox."
600
+ : "The send request failed. Try again."),
580
601
  error,
581
602
  });
582
603
  return null;
583
604
  });
584
- if (outboxMessage === null) return;
585
- messageId = outboxMessage.outboxMessageId;
586
- createdThisAttempt = true;
587
- setOutboxMessageId(messageId);
605
+ if (sent === null) return;
606
+
607
+ stopAutoSave(messageId);
608
+ startSendPolling(messageId);
609
+ onClose();
610
+ } finally {
611
+ sendInFlightRef.current = false;
612
+ setIsSending(false);
588
613
  }
589
-
590
- const sent = await sendMutation
591
- .mutateAsync({
592
- path: { outboxMessageId: messageId },
593
- })
594
- .catch((error: unknown) => {
595
- pushError({
596
- title: "Couldn't send message",
597
- detail:
598
- formatErrorDetail(error) ??
599
- (createdThisAttempt
600
- ? "The draft was saved but the send request failed. Try again from the Outbox."
601
- : "The send request failed. Try again."),
602
- error,
603
- });
604
- return null;
605
- });
606
- if (sent === null) return;
607
-
608
- stopAutoSave(messageId);
609
- startSendPolling(messageId);
610
- onClose();
611
614
  }, [
612
615
  selectedAccountId,
613
616
  toAddresses,
@@ -618,11 +621,10 @@ export const ComposeForm = ({
618
621
  mode,
619
622
  sourceMessage,
620
623
  outboxMessageId,
621
- createMutation,
624
+ saveImmediately,
622
625
  sendMutation,
623
626
  stopAutoSave,
624
627
  startSendPolling,
625
- setOutboxMessageId,
626
628
  pushError,
627
629
  onClose,
628
630
  ]);
@@ -14,6 +14,12 @@
14
14
  * then sends it — and creating it as already-queued makes that send a second
15
15
  * dispatch, which the server refuses.
16
16
  *
17
+ * Issue #674 is the boundary between the two: pressing Send inside the two
18
+ * seconds the debounce is still counting down. The cancelled timer was carrying
19
+ * the last edits, and the entry went out as the server last saw it. So the send
20
+ * writes what is on screen first, and a write that fails takes the send with it
21
+ * rather than transmitting the older copy behind the user's back.
22
+ *
17
23
  * The stub holds the outbox rule the API holds: only a draft accepts a send.
18
24
  */
19
25
 
@@ -81,8 +87,18 @@ afterEach(() => {
81
87
  const callsTo = (suffix: string) =>
82
88
  (http?.calls ?? []).filter((call) => call.path.endsWith(suffix));
83
89
 
84
- const patchCount = (): number =>
85
- (http?.calls ?? []).filter((call) => call.method === "PATCH").length;
90
+ const patches = () =>
91
+ (http?.calls ?? []).filter((call) => call.method === "PATCH");
92
+
93
+ const patchCount = (): number => patches().length;
94
+
95
+ const patchesAfterTheSend = (): number => {
96
+ const calls = http?.calls ?? [];
97
+ const sendIndex = calls.findIndex((call) => call.path.endsWith("/send"));
98
+ if (sendIndex === -1) throw new Error("the send never went out");
99
+ return calls.slice(sendIndex + 1).filter((call) => call.method === "PATCH")
100
+ .length;
101
+ };
86
102
 
87
103
  const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
88
104
  const { state, openCompose } = useCompose();
@@ -108,6 +124,8 @@ interface MountOptions {
108
124
  outboxMessageId?: string;
109
125
  /** Hold the PATCH response open so a save is still in flight at send time. */
110
126
  gatePatch?: boolean;
127
+ /** Refuse every PATCH, so the write compose makes before sending fails. */
128
+ failPatch?: boolean;
111
129
  }
112
130
 
113
131
  const mount = async (
@@ -140,6 +158,7 @@ const mount = async (
140
158
 
141
159
  if (call.method === "PATCH") {
142
160
  await patchGate;
161
+ if (options.failPatch) return httpError(409, "The draft moved on.");
143
162
  return outboxEntry(status);
144
163
  }
145
164
 
@@ -172,7 +191,7 @@ const sendButton = (): HTMLElement => {
172
191
  return button;
173
192
  };
174
193
 
175
- describe("compose and the outbox entry it is sending (#604)", () => {
194
+ describe("compose and the outbox entry it is sending (#604, #674)", () => {
176
195
  it("writes no further PATCH to an entry once it has been sent", async () => {
177
196
  const { releasePatch } = await mount({
178
197
  outboxMessageId: OUTBOX_MESSAGE_ID,
@@ -193,7 +212,76 @@ describe("compose and the outbox entry it is sending (#604)", () => {
193
212
  await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
194
213
 
195
214
  assert.equal(callsTo("/send").length, 1, "the send went out");
196
- assert.equal(patchCount(), 1, "no autosave PATCH followed the send");
215
+ assert.equal(patchesAfterTheSend(), 0, "no write followed the send");
216
+ // The in-flight autosave and the write before the send are the two, in
217
+ // that order: a second write issued alongside the first could land behind
218
+ // it and put the older body back.
219
+ assert.equal(
220
+ patchCount(),
221
+ 2,
222
+ "the two writes went out one after the other",
223
+ );
224
+ });
225
+
226
+ it("writes the edit made inside the debounce window before it sends", async () => {
227
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID });
228
+
229
+ harness?.type(subjectField(), "Re: Lunch tomorrow");
230
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 100);
231
+ assert.equal(patchCount(), 1, "the typing burst autosaves once");
232
+
233
+ // Inside the two seconds the next autosave would have waited.
234
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
235
+ harness?.click(sendButton());
236
+ await harness?.flush();
237
+ await harness?.wait(100);
238
+
239
+ assert.equal(callsTo("/send").length, 1, "the send went out");
240
+ const written = patches().at(-1);
241
+ assert.equal(
242
+ written?.body?.subject,
243
+ "Re: Lunch on Thursday",
244
+ "the entry carries the edit the debounce was still holding",
245
+ );
246
+ // Autosave never writes these, so an entry autosaved before Send used to
247
+ // go out unchained from the message it replies to.
248
+ assert.equal(written?.body?.inReplyTo, "<m1@example.com>");
249
+ assert.deepEqual(written?.body?.references, ["<m1@example.com>"]);
250
+ assert.equal(closed, 1, "compose closed on a successful send");
251
+ });
252
+
253
+ it("sends nothing, and says so, when that write fails", async () => {
254
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, failPatch: true });
255
+
256
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
257
+ harness?.click(sendButton());
258
+ await harness?.flush();
259
+ await harness?.wait(100);
260
+
261
+ assert.equal(patchCount(), 1, "the write was attempted");
262
+ assert.equal(callsTo("/send").length, 0, "the older copy stayed put");
263
+ assert.equal(closed, 0, "compose stayed open");
264
+ assert.match(harness?.text() ?? "", /Couldn't send message/);
265
+ });
266
+
267
+ it("dispatches once when Send is pressed twice over the write", async () => {
268
+ const { releasePatch } = await mount({
269
+ outboxMessageId: OUTBOX_MESSAGE_ID,
270
+ gatePatch: true,
271
+ });
272
+
273
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
274
+ harness?.click(sendButton());
275
+ harness?.click(sendButton());
276
+ await harness?.flush();
277
+
278
+ releasePatch();
279
+ await harness?.flush();
280
+ await harness?.wait(100);
281
+
282
+ assert.equal(patchCount(), 1, "the entry was written once");
283
+ assert.equal(callsTo("/send").length, 1, "the message went out once");
284
+ assert.equal(closed, 1, "compose closed on a successful send");
197
285
  });
198
286
 
199
287
  it("sends a reply pressed before the first autosave, in one dispatch", async () => {
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The attachment list wired to the real download (#683): a click fetches the
3
+ * part's `contentUrl` and hands the bytes to the browser under the sanitized
4
+ * name, and a fetch that fails says what failed instead of doing nothing.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { afterEach, describe, it } from "node:test";
9
+ import { messageOperationsDescribeMessageQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
+ import type { RemitImapBodyPartResponse } from "@remit/api-http-client/types.gen.ts";
11
+ import { createElement } from "react";
12
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
13
+ import { MessageAttachments } from "./MessageAttachments";
14
+
15
+ let harness: DomHarness | undefined;
16
+ const originalFetch = globalThis.fetch;
17
+ const originalCreateElement = document.createElement.bind(document);
18
+ const objectUrls = globalThis.URL as unknown as {
19
+ createObjectURL?: (blob: Blob) => string;
20
+ revokeObjectURL?: (value: string) => void;
21
+ };
22
+ const originalCreateObjectURL = objectUrls.createObjectURL;
23
+ const originalRevokeObjectURL = objectUrls.revokeObjectURL;
24
+
25
+ afterEach(() => {
26
+ harness?.close();
27
+ harness = undefined;
28
+ globalThis.fetch = originalFetch;
29
+ document.createElement = originalCreateElement;
30
+ objectUrls.createObjectURL = originalCreateObjectURL;
31
+ objectUrls.revokeObjectURL = originalRevokeObjectURL;
32
+ });
33
+
34
+ const bodyParts = (
35
+ ...overrides: Partial<RemitImapBodyPartResponse>[]
36
+ ): RemitImapBodyPartResponse[] =>
37
+ overrides.map(
38
+ (override, index) =>
39
+ ({
40
+ bodyPartId: `part-${index + 1}`,
41
+ mediaType: "APPLICATION",
42
+ mediaSubtype: "PDF",
43
+ sizeOctets: 4096,
44
+ disposition: "attachment",
45
+ dispositionFilename: "report.pdf",
46
+ contentUrl: `https://cdn.test/content/parts/${index + 2}`,
47
+ isMultipart: false,
48
+ ...override,
49
+ }) as RemitImapBodyPartResponse,
50
+ );
51
+
52
+ interface SavedFile {
53
+ filename: string;
54
+ url: string;
55
+ }
56
+
57
+ const captureSaves = (): SavedFile[] => {
58
+ const saved: SavedFile[] = [];
59
+ objectUrls.createObjectURL = () => "blob:test";
60
+ objectUrls.revokeObjectURL = () => undefined;
61
+
62
+ document.createElement = ((tag: string) => {
63
+ const element = originalCreateElement(tag);
64
+ if (tag === "a") {
65
+ const anchor = element as HTMLAnchorElement;
66
+ anchor.click = () =>
67
+ saved.push({ filename: anchor.download, url: anchor.href });
68
+ }
69
+ return element;
70
+ }) as typeof document.createElement;
71
+ return saved;
72
+ };
73
+
74
+ const MESSAGE_ID = "msg-1";
75
+
76
+ const mount = (parts?: RemitImapBodyPartResponse[], hasAttachment = true) => {
77
+ harness = createDomHarness();
78
+ harness.renderApp(
79
+ createElement(MessageAttachments, {
80
+ messageId: MESSAGE_ID,
81
+ bodyParts: parts,
82
+ hasAttachment,
83
+ }),
84
+ );
85
+ return harness;
86
+ };
87
+
88
+ describe("MessageAttachments", () => {
89
+ it("lists each attachment part with a download control", () => {
90
+ const dom = mount(
91
+ bodyParts(
92
+ { dispositionFilename: "board-pack.pdf" },
93
+ {
94
+ dispositionFilename: "site-plan.png",
95
+ mediaType: "IMAGE",
96
+ mediaSubtype: "PNG",
97
+ sizeOctets: 1024,
98
+ },
99
+ ),
100
+ );
101
+
102
+ assert.match(dom.text(), /2 attachments/);
103
+ assert.ok(dom.byLabel("Download board-pack.pdf"));
104
+ assert.ok(dom.byLabel("Download site-plan.png"));
105
+ assert.match(dom.text(), /PNG · 1 KB/);
106
+ });
107
+
108
+ it("renders nothing for a message with no attachments", () => {
109
+ const dom = mount(
110
+ bodyParts({ disposition: "inline", mediaSubtype: "HTML" }),
111
+ false,
112
+ );
113
+ assert.equal(dom.html(), "");
114
+ });
115
+
116
+ it("downloads the part's content URL and saves it under the sanitized name", async () => {
117
+ const requested: string[] = [];
118
+ globalThis.fetch = (async (url: string) => {
119
+ requested.push(String(url));
120
+ return new Response("payload", { status: 200 });
121
+ }) as unknown as typeof fetch;
122
+ const saved = captureSaves();
123
+
124
+ const dom = mount(
125
+ bodyParts({
126
+ dispositionFilename: "../../../etc/passwd",
127
+ contentUrl: "https://cdn.test/content/parts/2",
128
+ }),
129
+ );
130
+ dom.click(dom.byLabel("Download passwd"));
131
+ await dom.flush();
132
+
133
+ assert.deepEqual(requested, ["https://cdn.test/content/parts/2"]);
134
+ assert.deepEqual(saved, [{ filename: "passwd", url: "blob:test" }]);
135
+ });
136
+
137
+ it("states what failed and offers a retry instead of doing nothing", async () => {
138
+ globalThis.fetch = (async () =>
139
+ new Response("gone", {
140
+ status: 404,
141
+ statusText: "Not Found",
142
+ })) as unknown as typeof fetch;
143
+
144
+ const dom = mount(bodyParts({ dispositionFilename: "board-pack.pdf" }));
145
+ dom.click(dom.byLabel("Download board-pack.pdf"));
146
+ await dom.flush();
147
+
148
+ const alert = dom.query('[data-testid="attachment-error"]');
149
+ assert.ok(alert, "a failed download must render an alert");
150
+ assert.match(alert.textContent ?? "", /missing from storage/);
151
+ assert.match(alert.textContent ?? "", /Re-sync the account/);
152
+ assert.match(alert.textContent ?? "", /Try again/);
153
+ assert.match(alert.innerHTML, /issues\/new/);
154
+ });
155
+
156
+ it("retries the same part when the failure's retry is taken", async () => {
157
+ let attempts = 0;
158
+ globalThis.fetch = (async () => {
159
+ attempts += 1;
160
+ return attempts === 1
161
+ ? new Response("gone", { status: 404, statusText: "Not Found" })
162
+ : new Response("payload", { status: 200 });
163
+ }) as unknown as typeof fetch;
164
+ const saved = captureSaves();
165
+
166
+ const dom = mount(bodyParts({ dispositionFilename: "board-pack.pdf" }));
167
+ dom.click(dom.byLabel("Download board-pack.pdf"));
168
+ await dom.flush();
169
+
170
+ dom.click(dom.byText("button", "Try again"));
171
+ await dom.flush();
172
+
173
+ assert.equal(attempts, 2);
174
+ assert.deepEqual(saved, [{ filename: "board-pack.pdf", url: "blob:test" }]);
175
+ assert.equal(dom.query('[data-testid="attachment-error"]'), null);
176
+ });
177
+
178
+ // The signed content URL outlives neither an hour nor a re-signing, and a
179
+ // deferred part is materialized by the describe read itself. Re-hitting the
180
+ // same URL cannot fix either, so the retry goes back through describeMessage
181
+ // and uses the URL that read minted.
182
+ it("renews the content URL through describeMessage when the link expired", async () => {
183
+ const requested: string[] = [];
184
+ globalThis.fetch = (async (url: string) => {
185
+ requested.push(String(url));
186
+ return requested.length === 1
187
+ ? new Response("expired", {
188
+ status: 403,
189
+ headers: { "x-remit-403-reason": "expired" },
190
+ })
191
+ : new Response("payload", { status: 200 });
192
+ }) as unknown as typeof fetch;
193
+ const saved = captureSaves();
194
+
195
+ const dom = mount(
196
+ bodyParts({ contentUrl: "https://cdn.test/parts/2?exp=1&sig=old" }),
197
+ );
198
+ dom.queryClient.setQueryData(
199
+ messageOperationsDescribeMessageQueryKey({
200
+ path: { messageId: MESSAGE_ID },
201
+ }),
202
+ {
203
+ bodyParts: bodyParts({
204
+ contentUrl: "https://cdn.test/parts/2?exp=2&sig=new",
205
+ }),
206
+ },
207
+ );
208
+
209
+ dom.click(dom.byLabel("Download report.pdf"));
210
+ await dom.flush();
211
+ await dom.flush();
212
+
213
+ assert.deepEqual(requested, [
214
+ "https://cdn.test/parts/2?exp=1&sig=old",
215
+ "https://cdn.test/parts/2?exp=2&sig=new",
216
+ ]);
217
+ assert.equal(saved.length, 1);
218
+ assert.equal(dom.query('[data-testid="attachment-error"]'), null);
219
+ });
220
+
221
+ it("does not re-read the message for a failure a re-read cannot fix", async () => {
222
+ let attempts = 0;
223
+ globalThis.fetch = (async () => {
224
+ attempts += 1;
225
+ return new Response("gone", { status: 404, statusText: "Not Found" });
226
+ }) as unknown as typeof fetch;
227
+
228
+ const dom = mount(bodyParts({}));
229
+ dom.click(dom.byLabel("Download report.pdf"));
230
+ await dom.flush();
231
+
232
+ assert.equal(attempts, 1);
233
+ assert.ok(dom.query('[data-testid="attachment-error"]'));
234
+ });
235
+
236
+ it("says so when the server flags an attachment no part describes", () => {
237
+ const dom = mount([], true);
238
+ assert.match(dom.text(), /none of its parts describe one/);
239
+ });
240
+
241
+ it("claims nothing while the message is still loading", () => {
242
+ const dom = mount(undefined, true);
243
+ assert.equal(dom.html(), "");
244
+ });
245
+ });