@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.
@@ -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
+ }));