@remit/web-client 0.0.135 → 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 +1 -1
- package/src/components/mail/MessageAttachments.interaction.test.ts +245 -0
- package/src/components/mail/MessageAttachments.tsx +153 -0
- package/src/components/mail/MessageCard.tsx +15 -6
- package/src/lib/attachment-download.test.ts +207 -0
- package/src/lib/attachment-download.ts +195 -0
- package/src/lib/message-attachments.test.ts +86 -0
- package/src/lib/message-attachments.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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": {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { messageOperationsDescribeMessageQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapBodyPartResponse,
|
|
4
|
+
RemitImapDescribeMessageResponse,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
import { type AttachmentDownloadState, AttachmentList } from "@remit/ui";
|
|
7
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
import { useMemo, useState } from "react";
|
|
9
|
+
import { useAuthProvider } from "@/auth/provider";
|
|
10
|
+
import {
|
|
11
|
+
attachmentFailureContent,
|
|
12
|
+
attachmentReportUrl,
|
|
13
|
+
extractAttachmentFailureDetail,
|
|
14
|
+
extractAttachmentFailureReason,
|
|
15
|
+
fetchAttachment,
|
|
16
|
+
isRepairableByRefetch,
|
|
17
|
+
saveBlob,
|
|
18
|
+
} from "@/lib/attachment-download";
|
|
19
|
+
import {
|
|
20
|
+
type MessageAttachment,
|
|
21
|
+
selectMessageAttachments,
|
|
22
|
+
} from "@/lib/message-attachments";
|
|
23
|
+
|
|
24
|
+
interface MessageAttachmentsProps {
|
|
25
|
+
messageId: string;
|
|
26
|
+
/** Body parts from `describeMessage`; absent while the message is loading. */
|
|
27
|
+
bodyParts?: readonly RemitImapBodyPartResponse[];
|
|
28
|
+
/** The thread row's server-side attachment flag. */
|
|
29
|
+
hasAttachment?: boolean;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const IDLE: AttachmentDownloadState = { status: "idle" };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The attachments carried by an open message, and the download behind each row.
|
|
37
|
+
*
|
|
38
|
+
* Download state is per row and lives here rather than in a query: a download is
|
|
39
|
+
* a one-shot user action with no cached result to hold, and the bytes are handed
|
|
40
|
+
* to the browser rather than rendered.
|
|
41
|
+
*/
|
|
42
|
+
export const MessageAttachments = ({
|
|
43
|
+
messageId,
|
|
44
|
+
bodyParts,
|
|
45
|
+
hasAttachment = false,
|
|
46
|
+
className,
|
|
47
|
+
}: MessageAttachmentsProps) => {
|
|
48
|
+
const { getToken } = useAuthProvider();
|
|
49
|
+
const queryClient = useQueryClient();
|
|
50
|
+
const [downloads, setDownloads] = useState<
|
|
51
|
+
Record<string, AttachmentDownloadState>
|
|
52
|
+
>({});
|
|
53
|
+
|
|
54
|
+
const attachments = useMemo(
|
|
55
|
+
() => selectMessageAttachments(bodyParts ?? []),
|
|
56
|
+
[bodyParts],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const describeKey = messageOperationsDescribeMessageQueryKey({
|
|
60
|
+
path: { messageId },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A stale signature and an unmaterialized part are both repaired by re-reading
|
|
65
|
+
* the message: `describeMessage` re-signs every `contentUrl`, and it
|
|
66
|
+
* materializes the deferred per-part objects on its way through. Re-hitting
|
|
67
|
+
* the same URL alone cannot fix either (remit-mail/remit#1240), so the retry
|
|
68
|
+
* goes through the read path and then uses the URL it just minted.
|
|
69
|
+
*/
|
|
70
|
+
const fetchThroughRefreshedDescribe = (
|
|
71
|
+
attachment: MessageAttachment,
|
|
72
|
+
): Promise<Blob> =>
|
|
73
|
+
fetchAttachment(attachment.contentUrl, getToken).catch(
|
|
74
|
+
(error: unknown): Promise<Blob> => {
|
|
75
|
+
if (!isRepairableByRefetch(extractAttachmentFailureReason(error))) {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
return queryClient
|
|
79
|
+
.refetchQueries({ queryKey: describeKey })
|
|
80
|
+
.then(() => {
|
|
81
|
+
const refreshed =
|
|
82
|
+
queryClient.getQueryData<RemitImapDescribeMessageResponse>(
|
|
83
|
+
describeKey,
|
|
84
|
+
);
|
|
85
|
+
const renewed = refreshed
|
|
86
|
+
? selectMessageAttachments(refreshed.bodyParts).find(
|
|
87
|
+
(candidate) => candidate.bodyPartId === attachment.bodyPartId,
|
|
88
|
+
)
|
|
89
|
+
: undefined;
|
|
90
|
+
return fetchAttachment(
|
|
91
|
+
renewed?.contentUrl ?? attachment.contentUrl,
|
|
92
|
+
getToken,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const setState = (id: string, state: AttachmentDownloadState) =>
|
|
99
|
+
setDownloads((current) => ({ ...current, [id]: state }));
|
|
100
|
+
|
|
101
|
+
const download = (bodyPartId: string) => {
|
|
102
|
+
const attachment = attachments.find(
|
|
103
|
+
(candidate) => candidate.bodyPartId === bodyPartId,
|
|
104
|
+
);
|
|
105
|
+
if (!attachment) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`No attachment part ${bodyPartId} on message ${messageId}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setState(bodyPartId, { status: "downloading" });
|
|
112
|
+
fetchThroughRefreshedDescribe(attachment)
|
|
113
|
+
.then((blob) => {
|
|
114
|
+
saveBlob(blob, attachment.filename);
|
|
115
|
+
setState(bodyPartId, IDLE);
|
|
116
|
+
})
|
|
117
|
+
.catch((error: unknown) => {
|
|
118
|
+
const reason = extractAttachmentFailureReason(error);
|
|
119
|
+
const fallback = extractAttachmentFailureDetail(error);
|
|
120
|
+
const { title, detail } = attachmentFailureContent(
|
|
121
|
+
reason,
|
|
122
|
+
attachment.filename,
|
|
123
|
+
fallback,
|
|
124
|
+
);
|
|
125
|
+
setState(bodyPartId, {
|
|
126
|
+
status: "failed",
|
|
127
|
+
title,
|
|
128
|
+
detail,
|
|
129
|
+
reportUrl: attachmentReportUrl(reason, attachment.filename, fallback),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Only claim an attachment is unaccounted for once the parts have arrived;
|
|
135
|
+
// while `describeMessage` is in flight there is nothing to contradict.
|
|
136
|
+
const hasUnlistedAttachment =
|
|
137
|
+
hasAttachment && bodyParts !== undefined && attachments.length === 0;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<AttachmentList
|
|
141
|
+
className={className}
|
|
142
|
+
attachments={attachments.map((attachment) => ({
|
|
143
|
+
attachmentId: attachment.bodyPartId,
|
|
144
|
+
filename: attachment.filename,
|
|
145
|
+
typeLabel: attachment.typeLabel,
|
|
146
|
+
sizeOctets: attachment.sizeOctets,
|
|
147
|
+
download: downloads[attachment.bodyPartId] ?? IDLE,
|
|
148
|
+
}))}
|
|
149
|
+
onDownload={download}
|
|
150
|
+
hasUnlistedAttachment={hasUnlistedAttachment}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
@@ -16,6 +16,7 @@ import { formatDatePreset } from "@/lib/format";
|
|
|
16
16
|
import { cn } from "@/lib/utils";
|
|
17
17
|
import { AutoMovedIndicator } from "./AutoMovedIndicator";
|
|
18
18
|
import { MessageActionMenu } from "./MessageActionMenu";
|
|
19
|
+
import { MessageAttachments } from "./MessageAttachments";
|
|
19
20
|
import { MessageBody } from "./MessageBody";
|
|
20
21
|
import { MobileMessageBar } from "./MobileMessageBar";
|
|
21
22
|
import { RawMessageView } from "./RawMessageView";
|
|
@@ -134,7 +135,11 @@ const CollapsedCard = ({
|
|
|
134
135
|
trailing={
|
|
135
136
|
<>
|
|
136
137
|
{threadMessage.hasAttachment && (
|
|
137
|
-
<Paperclip
|
|
138
|
+
<Paperclip
|
|
139
|
+
className="size-3 shrink-0 text-fg-subtle"
|
|
140
|
+
role="img"
|
|
141
|
+
aria-label="Has an attachment"
|
|
142
|
+
/>
|
|
138
143
|
)}
|
|
139
144
|
<StarButton
|
|
140
145
|
isStarred={threadMessage.hasStars}
|
|
@@ -238,11 +243,6 @@ const ExpandedCard = ({
|
|
|
238
243
|
onToggleStar={onToggleStar}
|
|
239
244
|
isStarPending={isStarPending}
|
|
240
245
|
/>
|
|
241
|
-
{threadMessage.hasAttachment && (
|
|
242
|
-
<span className="text-fg-subtle p-0.5">
|
|
243
|
-
<Paperclip className="size-3.5" />
|
|
244
|
-
</span>
|
|
245
|
-
)}
|
|
246
246
|
</div>
|
|
247
247
|
{isUnread && (
|
|
248
248
|
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on decorative indicator provides useful context for assistive tech
|
|
@@ -324,6 +324,15 @@ const ExpandedCard = ({
|
|
|
324
324
|
isTrusted={isTrusted}
|
|
325
325
|
category={toDisplayCategory(threadMessage.category)}
|
|
326
326
|
/>
|
|
327
|
+
{/* The body slot runs edge to edge on a phone; the attachment
|
|
328
|
+
list is app chrome, not part of the email, so it takes the
|
|
329
|
+
gutter back. */}
|
|
330
|
+
<MessageAttachments
|
|
331
|
+
messageId={threadMessage.messageId}
|
|
332
|
+
bodyParts={messageData?.bodyParts}
|
|
333
|
+
hasAttachment={threadMessage.hasAttachment}
|
|
334
|
+
className="mt-4 px-2 lg:px-0"
|
|
335
|
+
/>
|
|
327
336
|
</div>
|
|
328
337
|
)
|
|
329
338
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
AttachmentFetchError,
|
|
5
|
+
attachmentFailureContent,
|
|
6
|
+
attachmentReportUrl,
|
|
7
|
+
classifyAttachmentFailure,
|
|
8
|
+
extractAttachmentFailureDetail,
|
|
9
|
+
extractAttachmentFailureReason,
|
|
10
|
+
fetchAttachment,
|
|
11
|
+
isRepairableByRefetch,
|
|
12
|
+
saveBlob,
|
|
13
|
+
} from "./attachment-download";
|
|
14
|
+
|
|
15
|
+
const originalFetch = globalThis.fetch;
|
|
16
|
+
|
|
17
|
+
const respondWith = (
|
|
18
|
+
init: {
|
|
19
|
+
status: number;
|
|
20
|
+
statusText?: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
},
|
|
23
|
+
body: string = "bytes",
|
|
24
|
+
) => {
|
|
25
|
+
globalThis.fetch = (async () =>
|
|
26
|
+
new Response(init.status === 204 ? null : body, {
|
|
27
|
+
status: init.status,
|
|
28
|
+
statusText: init.statusText ?? "",
|
|
29
|
+
headers: init.headers,
|
|
30
|
+
})) as typeof fetch;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
globalThis.fetch = originalFetch;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("classifyAttachmentFailure", () => {
|
|
38
|
+
it("reads an edge denial as an expired session", () => {
|
|
39
|
+
assert.equal(classifyAttachmentFailure(401, null), "auth");
|
|
40
|
+
assert.equal(classifyAttachmentFailure(403, "tenant-mismatch"), "auth");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reads an origin 403/404 as a missing object", () => {
|
|
44
|
+
assert.equal(classifyAttachmentFailure(403, null), "missing");
|
|
45
|
+
assert.equal(classifyAttachmentFailure(404, null), "missing");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// A stale signature is not a stale session — telling the user to sign in
|
|
49
|
+
// again would send them somewhere that cannot help.
|
|
50
|
+
it("separates an aged-out content signature from an expired session", () => {
|
|
51
|
+
assert.equal(classifyAttachmentFailure(403, "expired"), "link-expired");
|
|
52
|
+
assert.equal(isRepairableByRefetch("link-expired"), true);
|
|
53
|
+
assert.equal(isRepairableByRefetch("not-ready"), true);
|
|
54
|
+
assert.equal(isRepairableByRefetch("auth"), false);
|
|
55
|
+
assert.equal(isRepairableByRefetch("missing"), false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reads anything else as generic", () => {
|
|
59
|
+
assert.equal(classifyAttachmentFailure(500, null), "generic");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("fetchAttachment", () => {
|
|
64
|
+
it("returns the bytes on a 200", async () => {
|
|
65
|
+
respondWith({ status: 200 }, "payload");
|
|
66
|
+
const blob = await fetchAttachment("https://cdn.test/x", async () => null);
|
|
67
|
+
assert.equal(await blob.text(), "payload");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("sends the session token when there is one", async () => {
|
|
71
|
+
let seen: string | null = null;
|
|
72
|
+
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
|
73
|
+
seen = new Headers(init?.headers).get("Authorization") ?? null;
|
|
74
|
+
return new Response("payload", { status: 200 });
|
|
75
|
+
}) as unknown as typeof fetch;
|
|
76
|
+
await fetchAttachment("https://cdn.test/x", async () => "tok");
|
|
77
|
+
assert.equal(seen, "Bearer tok");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("treats a 202 as not-ready rather than as an empty file", async () => {
|
|
81
|
+
respondWith({ status: 202 });
|
|
82
|
+
await assert.rejects(
|
|
83
|
+
fetchAttachment("https://cdn.test/x", async () => null),
|
|
84
|
+
(error: unknown) =>
|
|
85
|
+
error instanceof AttachmentFetchError && error.reason === "not-ready",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("throws with the classified reason on a failure status", async () => {
|
|
90
|
+
respondWith({ status: 404, statusText: "Not Found" });
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
fetchAttachment("https://cdn.test/x", async () => null),
|
|
93
|
+
(error: unknown) =>
|
|
94
|
+
error instanceof AttachmentFetchError &&
|
|
95
|
+
error.reason === "missing" &&
|
|
96
|
+
error.status === 404,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("attachmentFailureContent", () => {
|
|
102
|
+
it("tells an expired session what to do, and names the file", () => {
|
|
103
|
+
const content = attachmentFailureContent(
|
|
104
|
+
"auth",
|
|
105
|
+
"report.pdf",
|
|
106
|
+
"irrelevant",
|
|
107
|
+
);
|
|
108
|
+
assert.match(content.title, /session expired/i);
|
|
109
|
+
assert.match(content.detail, /report\.pdf/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("distinguishes a missing object from a slow one", () => {
|
|
113
|
+
assert.notEqual(
|
|
114
|
+
attachmentFailureContent("missing", "x", "f").title,
|
|
115
|
+
attachmentFailureContent("not-ready", "x", "f").title,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("surfaces the underlying error for an unclassified failure", () => {
|
|
120
|
+
const content = attachmentFailureContent(
|
|
121
|
+
"generic",
|
|
122
|
+
"x",
|
|
123
|
+
"Failed to download attachment (500 )",
|
|
124
|
+
);
|
|
125
|
+
assert.equal(content.detail, "Failed to download attachment (500 )");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("failure extraction", () => {
|
|
130
|
+
it("reads the reason off an AttachmentFetchError", () => {
|
|
131
|
+
assert.equal(
|
|
132
|
+
extractAttachmentFailureReason(
|
|
133
|
+
new AttachmentFetchError("missing", "gone", 404),
|
|
134
|
+
),
|
|
135
|
+
"missing",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("treats an unrecognised throw as generic", () => {
|
|
140
|
+
assert.equal(extractAttachmentFailureReason("boom"), "generic");
|
|
141
|
+
assert.equal(extractAttachmentFailureDetail("boom"), "boom");
|
|
142
|
+
assert.equal(
|
|
143
|
+
extractAttachmentFailureDetail(new Error("transport lost")),
|
|
144
|
+
"transport lost",
|
|
145
|
+
);
|
|
146
|
+
assert.match(extractAttachmentFailureDetail({}), /unexpected error/);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("attachmentReportUrl", () => {
|
|
151
|
+
it("offers a prefilled issue for a failure the user cannot fix", () => {
|
|
152
|
+
const url = attachmentReportUrl("missing", "report.pdf", "gone");
|
|
153
|
+
assert.ok(url, "a reportable failure must carry an issue URL");
|
|
154
|
+
assert.ok(
|
|
155
|
+
url.startsWith("https://github.com/remit-mail/reader/issues/new?"),
|
|
156
|
+
);
|
|
157
|
+
assert.match(decodeURIComponent(url), /report\.pdf/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("offers none for an expired session, which is not a bug", () => {
|
|
161
|
+
assert.equal(
|
|
162
|
+
attachmentReportUrl("auth", "report.pdf", "denied"),
|
|
163
|
+
undefined,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("saveBlob", () => {
|
|
169
|
+
it("saves under the name it was given and cleans up the anchor", async () => {
|
|
170
|
+
const created: string[] = [];
|
|
171
|
+
const revoked: string[] = [];
|
|
172
|
+
const url = globalThis.URL as unknown as {
|
|
173
|
+
createObjectURL?: (blob: Blob) => string;
|
|
174
|
+
revokeObjectURL?: (value: string) => void;
|
|
175
|
+
};
|
|
176
|
+
url.createObjectURL = () => {
|
|
177
|
+
created.push("blob:test");
|
|
178
|
+
return "blob:test";
|
|
179
|
+
};
|
|
180
|
+
url.revokeObjectURL = (value) => revoked.push(value);
|
|
181
|
+
|
|
182
|
+
let clickedName: string | null = null;
|
|
183
|
+
const anchor = document.createElement("a");
|
|
184
|
+
anchor.click = () => {
|
|
185
|
+
clickedName = anchor.download;
|
|
186
|
+
};
|
|
187
|
+
const originalCreateElement = document.createElement.bind(document);
|
|
188
|
+
document.createElement = ((tag: string) =>
|
|
189
|
+
tag === "a"
|
|
190
|
+
? anchor
|
|
191
|
+
: originalCreateElement(tag)) as typeof document.createElement;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
saveBlob(new Blob(["x"]), "report.pdf");
|
|
195
|
+
} finally {
|
|
196
|
+
document.createElement = originalCreateElement;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
assert.equal(clickedName, "report.pdf");
|
|
200
|
+
assert.deepEqual(created, ["blob:test"]);
|
|
201
|
+
assert.equal(document.body.contains(anchor), false);
|
|
202
|
+
assert.deepEqual(revoked, [], "revoking in the click's task cancels it");
|
|
203
|
+
|
|
204
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
205
|
+
assert.deepEqual(revoked, ["blob:test"]);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { classifyBodyFetchFailure } from "@/hooks/useMessageBodyContent";
|
|
2
|
+
import { buildBugReportContext, buildGitHubIssueUrl } from "./bug-report";
|
|
3
|
+
import { taggedFetch } from "./network-error";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Why an attachment download failed. Same edge/origin discrimination the body
|
|
7
|
+
* fetch performs (`classifyBodyFetchFailure`) — the bytes travel the same
|
|
8
|
+
* `/content/*` route — narrowed to the outcomes a download can act on.
|
|
9
|
+
*
|
|
10
|
+
* - `auth` — the edge denied the request; the session has to be renewed.
|
|
11
|
+
* - `link-expired` — the self-host signature on the URL aged out (1 hour, and
|
|
12
|
+
* `describeMessage` is cached for 30 minutes, so an open message outlives it).
|
|
13
|
+
* Fixed by re-reading the message, which mints a fresh signature.
|
|
14
|
+
* - `missing` — the request passed auth and storage had no object at the key.
|
|
15
|
+
* - `not-ready` — 202: the part has not been stored yet and a cue was re-armed.
|
|
16
|
+
* - `generic` — anything else, including a transport failure.
|
|
17
|
+
*/
|
|
18
|
+
export type AttachmentFetchReason =
|
|
19
|
+
| "auth"
|
|
20
|
+
| "link-expired"
|
|
21
|
+
| "missing"
|
|
22
|
+
| "not-ready"
|
|
23
|
+
| "generic";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The failures a fresh `describeMessage` read repairs on its own: it re-signs
|
|
27
|
+
* every `contentUrl`, and materializing the deferred per-part objects is a side
|
|
28
|
+
* effect of that same read.
|
|
29
|
+
*/
|
|
30
|
+
export const isRepairableByRefetch = (reason: AttachmentFetchReason): boolean =>
|
|
31
|
+
reason === "link-expired" || reason === "not-ready";
|
|
32
|
+
|
|
33
|
+
export class AttachmentFetchError extends Error {
|
|
34
|
+
readonly reason: AttachmentFetchReason;
|
|
35
|
+
readonly status?: number;
|
|
36
|
+
|
|
37
|
+
constructor(reason: AttachmentFetchReason, message: string, status?: number) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "AttachmentFetchError";
|
|
40
|
+
this.reason = reason;
|
|
41
|
+
this.status = status;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const REASON_HEADER = "x-remit-403-reason";
|
|
46
|
+
|
|
47
|
+
/** The `verifyContentSignature` failure the self-host `/content` route reports. */
|
|
48
|
+
const EXPIRED_SIGNATURE_REASON = "expired";
|
|
49
|
+
|
|
50
|
+
export const classifyAttachmentFailure = (
|
|
51
|
+
status: number,
|
|
52
|
+
reasonHeader: string | null,
|
|
53
|
+
): AttachmentFetchReason => {
|
|
54
|
+
if (reasonHeader?.trim() === EXPIRED_SIGNATURE_REASON) return "link-expired";
|
|
55
|
+
const bodyReason = classifyBodyFetchFailure(status, reasonHeader);
|
|
56
|
+
if (bodyReason === "auth") return "auth";
|
|
57
|
+
if (bodyReason === "body-missing") return "missing";
|
|
58
|
+
return "generic";
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetch one attachment's bytes from its `contentUrl`.
|
|
63
|
+
*
|
|
64
|
+
* The self-host stack authorizes `/content/*` with the signature already in the
|
|
65
|
+
* URL; AWS authorizes it with the session bearer token at the edge. Sending the
|
|
66
|
+
* token when there is one covers both, and matches `fetchBodyContent`.
|
|
67
|
+
*
|
|
68
|
+
* Throws on anything that is not a 200 so the caller renders the failure. A
|
|
69
|
+
* download that silently produces no file is the bug this whole change exists
|
|
70
|
+
* to remove — including the silence of a transfer that stalls, which the
|
|
71
|
+
* timeout converts into a stated failure.
|
|
72
|
+
*/
|
|
73
|
+
export const ATTACHMENT_FETCH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
74
|
+
|
|
75
|
+
export const fetchAttachment = async (
|
|
76
|
+
url: string,
|
|
77
|
+
getToken: () => Promise<string | null>,
|
|
78
|
+
): Promise<Blob> => {
|
|
79
|
+
const headers: Record<string, string> = {};
|
|
80
|
+
const token = await getToken();
|
|
81
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
82
|
+
|
|
83
|
+
const response = await taggedFetch(url, {
|
|
84
|
+
headers,
|
|
85
|
+
signal: AbortSignal.timeout(ATTACHMENT_FETCH_TIMEOUT_MS),
|
|
86
|
+
});
|
|
87
|
+
if (response.status === 202) {
|
|
88
|
+
throw new AttachmentFetchError(
|
|
89
|
+
"not-ready",
|
|
90
|
+
"Attachment is still being fetched from the mail server",
|
|
91
|
+
202,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new AttachmentFetchError(
|
|
96
|
+
classifyAttachmentFailure(
|
|
97
|
+
response.status,
|
|
98
|
+
response.headers.get(REASON_HEADER),
|
|
99
|
+
),
|
|
100
|
+
`Failed to download attachment (${response.status} ${response.statusText})`,
|
|
101
|
+
response.status,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return response.blob();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export interface AttachmentFailureContent {
|
|
108
|
+
title: string;
|
|
109
|
+
detail: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const attachmentFailureContent = (
|
|
113
|
+
reason: AttachmentFetchReason,
|
|
114
|
+
filename: string,
|
|
115
|
+
fallback: string,
|
|
116
|
+
): AttachmentFailureContent => {
|
|
117
|
+
switch (reason) {
|
|
118
|
+
case "auth":
|
|
119
|
+
return {
|
|
120
|
+
title: "Your session expired",
|
|
121
|
+
detail: `Sign in again, then download ${filename} once more. Other parts of the app may also stop responding until you do.`,
|
|
122
|
+
};
|
|
123
|
+
case "link-expired":
|
|
124
|
+
return {
|
|
125
|
+
title: "This download link expired and could not be renewed",
|
|
126
|
+
detail: `Reload the page, then download ${filename} again. If that keeps failing, this is worth reporting.`,
|
|
127
|
+
};
|
|
128
|
+
case "missing":
|
|
129
|
+
return {
|
|
130
|
+
title: "This attachment is missing from storage",
|
|
131
|
+
detail:
|
|
132
|
+
"Remit has the message but not the file. Re-sync the account from Settings, then try again.",
|
|
133
|
+
};
|
|
134
|
+
case "not-ready":
|
|
135
|
+
return {
|
|
136
|
+
title: "Still fetching this attachment",
|
|
137
|
+
detail:
|
|
138
|
+
"Remit is downloading it from the mail server now. Try again in a few seconds.",
|
|
139
|
+
};
|
|
140
|
+
default:
|
|
141
|
+
return { title: "Couldn't download this attachment", detail: fallback };
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const extractAttachmentFailureReason = (
|
|
146
|
+
error: unknown,
|
|
147
|
+
): AttachmentFetchReason =>
|
|
148
|
+
error instanceof AttachmentFetchError ? error.reason : "generic";
|
|
149
|
+
|
|
150
|
+
export const extractAttachmentFailureDetail = (error: unknown): string => {
|
|
151
|
+
if (error instanceof Error) return error.message;
|
|
152
|
+
if (typeof error === "string") return error;
|
|
153
|
+
return "An unexpected error occurred while downloading this attachment.";
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A prefilled issue URL for a download failure, so the user has somewhere to go
|
|
158
|
+
* that is not "try again forever". Not offered for an expired session, which is
|
|
159
|
+
* the user's to fix and not a bug.
|
|
160
|
+
*/
|
|
161
|
+
export const attachmentReportUrl = (
|
|
162
|
+
reason: AttachmentFetchReason,
|
|
163
|
+
filename: string,
|
|
164
|
+
detail: string,
|
|
165
|
+
): string | undefined => {
|
|
166
|
+
if (reason === "auth") return undefined;
|
|
167
|
+
return buildGitHubIssueUrl(
|
|
168
|
+
buildBugReportContext({
|
|
169
|
+
title: `Bug: attachment download failed (${reason})`,
|
|
170
|
+
errorMessage: `Downloading "${filename}" failed: ${detail}`,
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Hand the fetched bytes to the browser's download machinery under the
|
|
177
|
+
* sanitized name.
|
|
178
|
+
*
|
|
179
|
+
* A plain `<a href={contentUrl} download>` cannot do this job: on AWS the
|
|
180
|
+
* content URL is a different origin, where the `download` attribute is ignored
|
|
181
|
+
* and the browser navigates instead. The object URL is same-origin, so the
|
|
182
|
+
* attribute holds and the filename we chose is the filename that lands.
|
|
183
|
+
*/
|
|
184
|
+
export const saveBlob = (blob: Blob, filename: string): void => {
|
|
185
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
186
|
+
const anchor = document.createElement("a");
|
|
187
|
+
anchor.href = objectUrl;
|
|
188
|
+
anchor.download = filename;
|
|
189
|
+
anchor.rel = "noopener";
|
|
190
|
+
document.body.append(anchor);
|
|
191
|
+
anchor.click();
|
|
192
|
+
anchor.remove();
|
|
193
|
+
// Revoking in the same task as the click cancels the download in WebKit.
|
|
194
|
+
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
|
195
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapBodyPartResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { selectMessageAttachments } from "./message-attachments";
|
|
5
|
+
|
|
6
|
+
const part = (
|
|
7
|
+
overrides: Partial<RemitImapBodyPartResponse>,
|
|
8
|
+
): RemitImapBodyPartResponse =>
|
|
9
|
+
({
|
|
10
|
+
bodyPartId: "part-1",
|
|
11
|
+
mediaType: "APPLICATION",
|
|
12
|
+
mediaSubtype: "PDF",
|
|
13
|
+
sizeOctets: 2048,
|
|
14
|
+
disposition: "attachment",
|
|
15
|
+
dispositionFilename: "report.pdf",
|
|
16
|
+
contentUrl: "https://cdn.test/content/parts/2",
|
|
17
|
+
isMultipart: false,
|
|
18
|
+
...overrides,
|
|
19
|
+
}) as RemitImapBodyPartResponse;
|
|
20
|
+
|
|
21
|
+
describe("selectMessageAttachments", () => {
|
|
22
|
+
it("keeps only attachment-disposition leaves", () => {
|
|
23
|
+
const parts = [
|
|
24
|
+
part({ bodyPartId: "body", disposition: "inline", mediaSubtype: "HTML" }),
|
|
25
|
+
part({ bodyPartId: "container", isMultipart: true }),
|
|
26
|
+
part({ bodyPartId: "file" }),
|
|
27
|
+
];
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
selectMessageAttachments(parts).map((a) => a.bodyPartId),
|
|
30
|
+
["file"],
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("drops a part with no content URL to fetch", () => {
|
|
35
|
+
assert.deepEqual(selectMessageAttachments([part({ contentUrl: "" })]), []);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("preserves the order the message declares", () => {
|
|
39
|
+
const parts = [
|
|
40
|
+
part({ bodyPartId: "a", dispositionFilename: "a.pdf" }),
|
|
41
|
+
part({ bodyPartId: "b", dispositionFilename: "b.pdf" }),
|
|
42
|
+
];
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
selectMessageAttachments(parts).map((a) => a.filename),
|
|
45
|
+
["a.pdf", "b.pdf"],
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("carries filename, size and content URL through", () => {
|
|
50
|
+
const [attachment] = selectMessageAttachments([
|
|
51
|
+
part({ sizeOctets: 4096, contentUrl: "https://cdn.test/x" }),
|
|
52
|
+
]);
|
|
53
|
+
assert.equal(attachment.filename, "report.pdf");
|
|
54
|
+
assert.equal(attachment.typeLabel, "PDF");
|
|
55
|
+
assert.equal(attachment.sizeOctets, 4096);
|
|
56
|
+
assert.equal(attachment.contentUrl, "https://cdn.test/x");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("sanitizes a filename that tries to escape the download directory", () => {
|
|
60
|
+
const [attachment] = selectMessageAttachments([
|
|
61
|
+
part({ dispositionFilename: "../../../etc/passwd" }),
|
|
62
|
+
]);
|
|
63
|
+
assert.equal(attachment.filename, "passwd");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("names an unnamed attachment after its subtype", () => {
|
|
67
|
+
const [attachment] = selectMessageAttachments([
|
|
68
|
+
part({ dispositionFilename: undefined }),
|
|
69
|
+
]);
|
|
70
|
+
assert.equal(attachment.filename, "attachment.pdf");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("falls back to a bare name when the subtype yields no extension", () => {
|
|
74
|
+
const [attachment] = selectMessageAttachments([
|
|
75
|
+
part({ dispositionFilename: undefined, mediaSubtype: "-" }),
|
|
76
|
+
]);
|
|
77
|
+
assert.equal(attachment.filename, "attachment");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("labels an undifferentiated binary as a file rather than as its subtype", () => {
|
|
81
|
+
const [attachment] = selectMessageAttachments([
|
|
82
|
+
part({ mediaSubtype: "OCTET-STREAM" }),
|
|
83
|
+
]);
|
|
84
|
+
assert.equal(attachment.typeLabel, "FILE");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RemitImapBodyPartResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { ContentDisposition } from "@remit/domain-enums";
|
|
3
|
+
import { sanitizeAttachmentFilename } from "@remit/ui";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The attachment-disposition body parts of a message, in the order the message
|
|
7
|
+
* declares them. `pickRenderablePart` (message-body-source.ts) drops exactly
|
|
8
|
+
* these parts so the renderer never treats a PDF as a body; this is the other
|
|
9
|
+
* half of that split.
|
|
10
|
+
*/
|
|
11
|
+
export interface MessageAttachment {
|
|
12
|
+
bodyPartId: string;
|
|
13
|
+
/** Display and save name — sanitized once, used for both. */
|
|
14
|
+
filename: string;
|
|
15
|
+
typeLabel: string;
|
|
16
|
+
sizeOctets: number;
|
|
17
|
+
contentUrl: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const typeLabelFor = (mediaSubtype: string): string => {
|
|
21
|
+
const label = mediaSubtype.toUpperCase();
|
|
22
|
+
return label === "OCTET-STREAM" ? "FILE" : label;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `application/pdf` → `attachment.pdf`. Only used when the sender declared no
|
|
27
|
+
* filename; a subtype with nothing alphanumeric in it yields a bare
|
|
28
|
+
* `attachment`.
|
|
29
|
+
*/
|
|
30
|
+
const fallbackFilenameFor = (mediaSubtype: string): string => {
|
|
31
|
+
const extension = mediaSubtype.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
32
|
+
return extension.length > 0 ? `attachment.${extension}` : "attachment";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const selectMessageAttachments = (
|
|
36
|
+
parts: readonly RemitImapBodyPartResponse[],
|
|
37
|
+
): MessageAttachment[] =>
|
|
38
|
+
parts
|
|
39
|
+
.filter(
|
|
40
|
+
(part) =>
|
|
41
|
+
!part.isMultipart &&
|
|
42
|
+
part.disposition === ContentDisposition.Attachment &&
|
|
43
|
+
part.contentUrl.length > 0,
|
|
44
|
+
)
|
|
45
|
+
.map((part) => ({
|
|
46
|
+
bodyPartId: part.bodyPartId,
|
|
47
|
+
filename: sanitizeAttachmentFilename(
|
|
48
|
+
part.dispositionFilename ?? "",
|
|
49
|
+
fallbackFilenameFor(part.mediaSubtype),
|
|
50
|
+
),
|
|
51
|
+
typeLabel: typeLabelFor(part.mediaSubtype),
|
|
52
|
+
sizeOctets: part.sizeOctets,
|
|
53
|
+
contentUrl: part.contentUrl,
|
|
54
|
+
}));
|