@remit/ui 0.0.14 → 0.0.16
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/button.render.test.ts +29 -0
- package/src/components/button.tsx +44 -2
- package/src/components/primitives.stories.tsx +6 -0
- package/src/components/progress-bar.render.test.ts +46 -0
- package/src/components/progress-bar.stories.tsx +35 -0
- package/src/components/progress-bar.tsx +62 -0
- package/src/components/quarantine-bug-dialog.tsx +72 -0
- package/src/components/quarantine-entry-row.tsx +71 -0
- package/src/components/quarantine-fixtures.ts +81 -0
- package/src/components/quarantine-report.test.ts +151 -0
- package/src/components/quarantine-report.ts +221 -0
- package/src/components/quarantine-section.render.test.ts +101 -0
- package/src/components/quarantine-section.stories.tsx +74 -0
- package/src/components/quarantine-section.tsx +71 -0
- package/src/components/selection-top-bar.render.test.ts +167 -17
- package/src/components/selection-top-bar.stories.tsx +106 -16
- package/src/components/selection-top-bar.tsx +117 -64
- package/src/components/swipeable-row.render.test.ts +29 -0
- package/src/components/swipeable-row.tsx +42 -2
- package/src/components/touch-list-body.render.test.ts +25 -0
- package/src/components/touch-list-body.stories.tsx +15 -0
- package/src/components/touch-list.tsx +26 -15
- package/src/index.ts +37 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { FolderRole } from "./folder-role.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pipeline step that refused the message.
|
|
5
|
+
*
|
|
6
|
+
* One member, because **no catch site on the sync path can currently tell a
|
|
7
|
+
* parse failure from an infrastructure failure**. The per-message frame in
|
|
8
|
+
* `body-sync.ts` wraps the S3 body write, the parsed-body cache write, the
|
|
9
|
+
* body-part `pMap` and DynamoDB upsert, the placement move (SQS + DynamoDB)
|
|
10
|
+
* and the label and counter writes, alongside the `simpleParser` call; only
|
|
11
|
+
* connection drops are filtered out of it.
|
|
12
|
+
*
|
|
13
|
+
* **Precondition on Phase 3**: narrow the try block to the parse call before
|
|
14
|
+
* quarantining anything. Quarantining at the frame as it stands would set a
|
|
15
|
+
* message aside for a DynamoDB throttle, tell the user it was unreadable, and
|
|
16
|
+
* invite a public GitHub issue for an outage — the same defect as naming an
|
|
17
|
+
* S3 write `AttachmentExtract`, one layer up.
|
|
18
|
+
*
|
|
19
|
+
* There are three distinct parse sites, and a quarantine would have to
|
|
20
|
+
* attribute them differently: the fresh-fetch path, `backfillClassification`
|
|
21
|
+
* (its own parse over a different result list), and the header parse in
|
|
22
|
+
* `imapflow-connection.ts`, which is swallowed and treated as "no thread
|
|
23
|
+
* parent". Stages are added as those sites are separated — never ahead of it.
|
|
24
|
+
*
|
|
25
|
+
* The stages this replaced could not fire at all: an unparseable Date falls
|
|
26
|
+
* back to INTERNALDATE, and an unrecognized MIME type maps to a safe default
|
|
27
|
+
* rather than throwing. Unparseable addresses are dropped with a `continue`
|
|
28
|
+
* and the message is written anyway — so that defect is reached but discarded,
|
|
29
|
+
* never raised, which is a reason to have no `AddressParse` stage but not the
|
|
30
|
+
* reason that it cannot happen.
|
|
31
|
+
*/
|
|
32
|
+
export type QuarantineFailureStage = "BodyParse";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The specific defect within a stage.
|
|
36
|
+
*
|
|
37
|
+
* Closed, because `quarantineIssueTitle` interpolates it into a public issue
|
|
38
|
+
* title: a `string` here would make the one field whose publishability is
|
|
39
|
+
* asserted rather than derived into free text. Grows with the narrowing
|
|
40
|
+
* described on `QuarantineFailureStage`, and no faster.
|
|
41
|
+
*/
|
|
42
|
+
export type QuarantineFailureCode =
|
|
43
|
+
| "UnterminatedMultipartBoundary"
|
|
44
|
+
| "UnknownCharset"
|
|
45
|
+
| "TruncatedBody";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A node in the message's MIME tree.
|
|
49
|
+
*
|
|
50
|
+
* `contentType` is `type/subtype` only. BODYSTRUCTURE hands the type and its
|
|
51
|
+
* parameters over separately, so a node is built from `type`/`subtype` and
|
|
52
|
+
* never from a raw content-type line — parameters carry `name=` and
|
|
53
|
+
* `filename=`, which name the user's attachments.
|
|
54
|
+
*/
|
|
55
|
+
export interface QuarantineMimeNode {
|
|
56
|
+
contentType: string;
|
|
57
|
+
parts?: readonly QuarantineMimeNode[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A quarantined message as the settings surface sees it.
|
|
62
|
+
*
|
|
63
|
+
* Two fields are stored and shown on screen but never travel in the report:
|
|
64
|
+
* `mailboxPath` (the user's own folder names can be personal) and
|
|
65
|
+
* `failureMessage` (parser errors quote the input that broke them, and
|
|
66
|
+
* redacting arbitrary parser text is not solvable). Everything else is safe to
|
|
67
|
+
* paste into a public issue without reading it first.
|
|
68
|
+
*/
|
|
69
|
+
export interface QuarantineEntry {
|
|
70
|
+
quarantineId: string;
|
|
71
|
+
accountId: string;
|
|
72
|
+
mailboxId: string;
|
|
73
|
+
/** IMAP uid of the message that was not written. */
|
|
74
|
+
uid: number;
|
|
75
|
+
/** Canonical role of the folder it arrived in — travels in the report. */
|
|
76
|
+
mailboxRole: FolderRole;
|
|
77
|
+
/** The user's own folder name. Shown on screen, withheld from the report. */
|
|
78
|
+
mailboxPath: string;
|
|
79
|
+
failureStage: QuarantineFailureStage;
|
|
80
|
+
failureCode: QuarantineFailureCode;
|
|
81
|
+
/** Parser error text. Shown on screen, never in the report. */
|
|
82
|
+
failureMessage: string;
|
|
83
|
+
/**
|
|
84
|
+
* Dot-numbered part path the failure is attributable to, or null when it is
|
|
85
|
+
* not attributable to one node — the case for a whole-body parse failure.
|
|
86
|
+
*/
|
|
87
|
+
failurePartPath: string | null;
|
|
88
|
+
/** Epoch millis the message was quarantined. */
|
|
89
|
+
quarantinedAt: number;
|
|
90
|
+
/** Rounds attempted before the message was set aside. */
|
|
91
|
+
attempts: number;
|
|
92
|
+
sizeBytes: number;
|
|
93
|
+
/** Top-level Content-Type, `type/subtype` only. */
|
|
94
|
+
contentType: string;
|
|
95
|
+
transferEncoding: string;
|
|
96
|
+
/** Declared charset, or null when the message declared none. */
|
|
97
|
+
charset: string | null;
|
|
98
|
+
/** The MIME tree, structure only. */
|
|
99
|
+
structure: QuarantineMimeNode;
|
|
100
|
+
/** SHA-256 of the Message-ID, `sha256:` prefixed. */
|
|
101
|
+
messageIdHash: string;
|
|
102
|
+
/** Build of the worker that failed to parse — a parse bug belongs to it. */
|
|
103
|
+
appVersion: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const stageSummaries: Record<QuarantineFailureStage, string> = {
|
|
107
|
+
BodyParse: "The message is built in a way Remit could not read.",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Plain-language one-liner for a row. The detail lives in the report. */
|
|
111
|
+
export function quarantineSummary(stage: QuarantineFailureStage): string {
|
|
112
|
+
return stageSummaries[stage];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Guards the node contract where the report is rendered, so a node built from
|
|
117
|
+
* a raw content-type line cannot put an attachment filename in the report even
|
|
118
|
+
* if Phase 3 gets the construction wrong.
|
|
119
|
+
*/
|
|
120
|
+
function stripParameters(contentType: string): string {
|
|
121
|
+
return contentType.split(";")[0].trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** RFC 2045 token characters — what a charset or encoding is allowed to be. */
|
|
125
|
+
const TOKEN = /^[A-Za-z0-9!#$%&'*+._-]+$/;
|
|
126
|
+
const MEDIA_TYPE = /^[A-Za-z0-9!#$%&'*+._-]+\/[A-Za-z0-9!#$%&'*+._-]+$/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `charset`, `transferEncoding` and `contentType` are BODYSTRUCTURE strings —
|
|
130
|
+
* arbitrary quoted text chosen by whoever sent the message, echoed into an
|
|
131
|
+
* issue filed under the user's own account. A conforming value renders as
|
|
132
|
+
* itself; anything else is JSON-escaped, so newlines and backticks cannot
|
|
133
|
+
* close the code span and inject markdown. The malformed value is kept rather
|
|
134
|
+
* than dropped, because a malformed value is usually the bug.
|
|
135
|
+
*/
|
|
136
|
+
function renderToken(value: string, pattern: RegExp): string {
|
|
137
|
+
if (pattern.test(value)) return `\`${value}\``;
|
|
138
|
+
const escaped = JSON.stringify(value).replace(/`/g, "\\u0060");
|
|
139
|
+
return `\`${escaped}\` (malformed)`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Inside a fence the only escape is a lone fence line, so tokens are enough. */
|
|
143
|
+
function renderNodeType(contentType: string): string {
|
|
144
|
+
const stripped = stripParameters(contentType);
|
|
145
|
+
if (MEDIA_TYPE.test(stripped)) return stripped;
|
|
146
|
+
return JSON.stringify(stripped);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderStructure(node: QuarantineMimeNode, depth = 0): string[] {
|
|
150
|
+
const line = `${" ".repeat(depth)}- ${renderNodeType(node.contentType)}`;
|
|
151
|
+
const children = node.parts ?? [];
|
|
152
|
+
return [
|
|
153
|
+
line,
|
|
154
|
+
...children.flatMap((part) => renderStructure(part, depth + 1)),
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const QUARANTINE_REPORT_DISCLAIMER =
|
|
159
|
+
"_No message content, addresses, subject, attachment names or parser output are included._";
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The report split into the parts a bug report assembles.
|
|
163
|
+
*
|
|
164
|
+
* `structure` is the only unbounded section, so it is handed over unfenced:
|
|
165
|
+
* the URL budget truncates it and fences it afterwards, the same shape a
|
|
166
|
+
* stacktrace already uses. Fencing here instead would let the binary search
|
|
167
|
+
* cut inside the fence, leaving every following section rendered inside one
|
|
168
|
+
* code block — and the first line lost would be the disclaimer, so the one
|
|
169
|
+
* report that is truncated would be the one with no statement of what was
|
|
170
|
+
* withheld.
|
|
171
|
+
*/
|
|
172
|
+
export interface QuarantineReportSections {
|
|
173
|
+
head: string;
|
|
174
|
+
structure: string;
|
|
175
|
+
disclaimer: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function quarantineReportSections(
|
|
179
|
+
entry: QuarantineEntry,
|
|
180
|
+
): QuarantineReportSections {
|
|
181
|
+
const head = [
|
|
182
|
+
`### Message quarantined at \`${entry.failureStage}\``,
|
|
183
|
+
"",
|
|
184
|
+
`- **Failure**: \`${entry.failureCode}\``,
|
|
185
|
+
`- **Folder role**: ${entry.mailboxRole}`,
|
|
186
|
+
`- **Failing part**: ${entry.failurePartPath === null ? "_whole message_" : `\`${entry.failurePartPath}\``}`,
|
|
187
|
+
`- **Attempts before quarantine**: ${entry.attempts}`,
|
|
188
|
+
`- **Worker build**: ${entry.appVersion}`,
|
|
189
|
+
`- **Message-ID hash**: \`${entry.messageIdHash}\``,
|
|
190
|
+
"",
|
|
191
|
+
"#### Message shape",
|
|
192
|
+
"",
|
|
193
|
+
`- **Content-Type**: ${renderToken(stripParameters(entry.contentType), MEDIA_TYPE)}`,
|
|
194
|
+
`- **Content-Transfer-Encoding**: ${renderToken(entry.transferEncoding, TOKEN)}`,
|
|
195
|
+
`- **Charset**: ${entry.charset === null ? "_not declared_" : renderToken(entry.charset, TOKEN)}`,
|
|
196
|
+
`- **Size**: ${entry.sizeBytes} bytes`,
|
|
197
|
+
"",
|
|
198
|
+
"MIME structure:",
|
|
199
|
+
].join("\n");
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
head,
|
|
203
|
+
structure: renderStructure(entry.structure).join("\n"),
|
|
204
|
+
disclaimer: QUARANTINE_REPORT_DISCLAIMER,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The whole report as one string — what the dialog shows and the copy action
|
|
210
|
+
* copies. The published issue is assembled from the sections instead, so that
|
|
211
|
+
* a long MIME tree can be truncated without breaking the fence.
|
|
212
|
+
*/
|
|
213
|
+
export function formatQuarantineReport(entry: QuarantineEntry): string {
|
|
214
|
+
const { head, structure, disclaimer } = quarantineReportSections(entry);
|
|
215
|
+
return [head, "", "```", structure, "```", "", disclaimer].join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Issue title. Closed vocabulary only, so it is safe to publish. */
|
|
219
|
+
export function quarantineIssueTitle(entry: QuarantineEntry): string {
|
|
220
|
+
return `Message quarantined: ${entry.failureCode} at ${entry.failureStage}`;
|
|
221
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { QuarantineBugDialog } from "./quarantine-bug-dialog.js";
|
|
6
|
+
import { quarantineDemoEntries } from "./quarantine-fixtures.js";
|
|
7
|
+
import type { QuarantineEntry } from "./quarantine-report.js";
|
|
8
|
+
import { QuarantineSection } from "./quarantine-section.js";
|
|
9
|
+
|
|
10
|
+
const noop = () => {};
|
|
11
|
+
const [base, second] = quarantineDemoEntries;
|
|
12
|
+
const ISSUE_URL = "https://github.com/remit-mail/reader/issues/new?title=x";
|
|
13
|
+
|
|
14
|
+
const render = (entries: readonly QuarantineEntry[]) =>
|
|
15
|
+
renderToString(createElement(QuarantineSection, { entries, onCutBug: noop }));
|
|
16
|
+
|
|
17
|
+
describe("QuarantineSection", () => {
|
|
18
|
+
it("reassures when nothing is set aside", () => {
|
|
19
|
+
const html = render([]);
|
|
20
|
+
assert.match(html, /Nothing is set aside/);
|
|
21
|
+
assert.doesNotMatch(html, /role="alert"/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("shows a single entry as a fact, without an alert", () => {
|
|
25
|
+
const html = render([base]);
|
|
26
|
+
assert.match(html, /uid 40217/);
|
|
27
|
+
assert.doesNotMatch(html, /role="alert"/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("raises an alert once more than one message is set aside", () => {
|
|
31
|
+
const html = render([base, second]);
|
|
32
|
+
assert.match(html, /role="alert"/);
|
|
33
|
+
assert.match(html, /2 messages could not be read/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("shows the parser's own words on screen, where the report will not", () => {
|
|
37
|
+
const html = render([base]);
|
|
38
|
+
assert.match(html, /multipart boundary was never closed/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("offers reporting as the only per-row action", () => {
|
|
42
|
+
const html = render([base]);
|
|
43
|
+
assert.match(html, /Cut a bug/);
|
|
44
|
+
assert.doesNotMatch(html, /Try again|Retry/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("QuarantineBugDialog", () => {
|
|
49
|
+
it("shows the whole report before anything is filed", () => {
|
|
50
|
+
const html = renderToString(
|
|
51
|
+
createElement(QuarantineBugDialog, {
|
|
52
|
+
entry: base,
|
|
53
|
+
onClose: noop,
|
|
54
|
+
onCopy: noop,
|
|
55
|
+
issueUrl: ISSUE_URL,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
assert.match(html, /UnterminatedMultipartBoundary/);
|
|
59
|
+
assert.match(html, /attachment names, or the parser's own error text/);
|
|
60
|
+
assert.match(html, /Copy report/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("never renders the parser's own error text into the report", () => {
|
|
64
|
+
const html = renderToString(
|
|
65
|
+
createElement(QuarantineBugDialog, {
|
|
66
|
+
entry: base,
|
|
67
|
+
onClose: noop,
|
|
68
|
+
onCopy: noop,
|
|
69
|
+
issueUrl: ISSUE_URL,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
// The row shows it; the report must not. Locked at the render boundary,
|
|
73
|
+
// which is where the regression would actually happen.
|
|
74
|
+
assert.doesNotMatch(html, /multipart boundary was never closed/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("files through the supplied url with hardened external rel", () => {
|
|
78
|
+
const html = renderToString(
|
|
79
|
+
createElement(QuarantineBugDialog, {
|
|
80
|
+
entry: base,
|
|
81
|
+
onClose: noop,
|
|
82
|
+
onCopy: noop,
|
|
83
|
+
issueUrl: ISSUE_URL,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
assert.match(html, /rel="noopener noreferrer"/);
|
|
87
|
+
assert.match(html, /focus-visible:ring-2/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("renders nothing without an entry", () => {
|
|
91
|
+
const html = renderToString(
|
|
92
|
+
createElement(QuarantineBugDialog, {
|
|
93
|
+
entry: null,
|
|
94
|
+
onClose: noop,
|
|
95
|
+
onCopy: noop,
|
|
96
|
+
issueUrl: ISSUE_URL,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
assert.equal(html, "");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { QuarantineBugDialog } from "./quarantine-bug-dialog.js";
|
|
4
|
+
import { quarantineDemoEntries } from "./quarantine-fixtures.js";
|
|
5
|
+
import type { QuarantineEntry } from "./quarantine-report.js";
|
|
6
|
+
import { QuarantineSection } from "./quarantine-section.js";
|
|
7
|
+
|
|
8
|
+
const [unterminatedBoundary, unknownCharset, truncatedBody] =
|
|
9
|
+
quarantineDemoEntries;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stands in for the app's shared bug-report helper, which owns the URL budget
|
|
13
|
+
* and the repository constant.
|
|
14
|
+
*/
|
|
15
|
+
const demoIssueUrl = "https://github.com/remit-mail/reader/issues/new";
|
|
16
|
+
|
|
17
|
+
const meta: Meta<typeof QuarantineSection> = {
|
|
18
|
+
title: "Settings/Quarantine",
|
|
19
|
+
component: QuarantineSection,
|
|
20
|
+
parameters: { layout: "padded" },
|
|
21
|
+
args: { onCutBug: () => {} },
|
|
22
|
+
decorators: [
|
|
23
|
+
(Story) => (
|
|
24
|
+
<div className="mx-auto max-w-2xl">
|
|
25
|
+
<Story />
|
|
26
|
+
</div>
|
|
27
|
+
),
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
export default meta;
|
|
31
|
+
|
|
32
|
+
type Story = StoryObj<typeof QuarantineSection>;
|
|
33
|
+
|
|
34
|
+
export const Empty: Story = {
|
|
35
|
+
args: { entries: [] },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const OneEntry: Story = {
|
|
39
|
+
args: { entries: [unterminatedBoundary] },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const AlertState: Story = {
|
|
43
|
+
args: { entries: [unterminatedBoundary, unknownCharset, truncatedBody] },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const CutABugFlow: Story = {
|
|
47
|
+
render: () => {
|
|
48
|
+
const [open, setOpen] = useState<QuarantineEntry | null>(null);
|
|
49
|
+
const [copied, setCopied] = useState(false);
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
<QuarantineSection entries={quarantineDemoEntries} onCutBug={setOpen} />
|
|
53
|
+
{copied && <p className="mt-3 text-xs text-positive">Report copied.</p>}
|
|
54
|
+
<QuarantineBugDialog
|
|
55
|
+
entry={open}
|
|
56
|
+
issueUrl={demoIssueUrl}
|
|
57
|
+
onClose={() => setOpen(null)}
|
|
58
|
+
onCopy={() => setCopied(true)}
|
|
59
|
+
/>
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const BugReport: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<QuarantineBugDialog
|
|
68
|
+
entry={unterminatedBoundary}
|
|
69
|
+
issueUrl={demoIssueUrl}
|
|
70
|
+
onClose={() => {}}
|
|
71
|
+
onCopy={() => {}}
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CheckCircle2, TriangleAlert } from "lucide-react";
|
|
2
|
+
import { Banner } from "./banner.js";
|
|
3
|
+
import { QuarantineEntryRow } from "./quarantine-entry-row.js";
|
|
4
|
+
import type { QuarantineEntry } from "./quarantine-report.js";
|
|
5
|
+
|
|
6
|
+
export interface QuarantineSectionProps {
|
|
7
|
+
entries: readonly QuarantineEntry[];
|
|
8
|
+
onCutBug: (entry: QuarantineEntry) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The quarantine list in settings.
|
|
13
|
+
*
|
|
14
|
+
* A message that could not be read was never written, so it cannot be found
|
|
15
|
+
* anywhere else — this list is the only record that it existed. One entry is a
|
|
16
|
+
* fact and reads as one. More than one is a pattern, and a pattern is a bug, so
|
|
17
|
+
* it raises an alert.
|
|
18
|
+
*/
|
|
19
|
+
export function QuarantineSection({
|
|
20
|
+
entries,
|
|
21
|
+
onCutBug,
|
|
22
|
+
}: QuarantineSectionProps) {
|
|
23
|
+
return (
|
|
24
|
+
<section className="space-y-3">
|
|
25
|
+
<header className="space-y-1">
|
|
26
|
+
<h2 className="text-sm font-semibold text-fg">Messages set aside</h2>
|
|
27
|
+
<p className="text-xs text-fg-muted">
|
|
28
|
+
Mail Remit could not read is set aside here instead of being skipped,
|
|
29
|
+
so nothing goes missing quietly. The rest of the folder keeps syncing.
|
|
30
|
+
Recovering a set-aside message is a re-sync, not a per-row action.
|
|
31
|
+
</p>
|
|
32
|
+
</header>
|
|
33
|
+
|
|
34
|
+
{entries.length === 0 && (
|
|
35
|
+
<div className="flex items-center gap-2 rounded-sm border border-line bg-surface px-row-inset py-3">
|
|
36
|
+
<CheckCircle2 className="size-4 shrink-0 text-positive" aria-hidden />
|
|
37
|
+
<p className="text-sm text-fg-muted">
|
|
38
|
+
Every message has been read successfully. Nothing is set aside.
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
|
|
43
|
+
{entries.length > 1 && (
|
|
44
|
+
<Banner tone="warning">
|
|
45
|
+
<p className="flex items-start gap-2">
|
|
46
|
+
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
47
|
+
<span>
|
|
48
|
+
<span className="font-semibold">
|
|
49
|
+
{`${entries.length} messages could not be read.`}
|
|
50
|
+
</span>{" "}
|
|
51
|
+
More than one means something is wrong with how Remit reads mail,
|
|
52
|
+
not with the mail. Reporting one of these gets it fixed.
|
|
53
|
+
</span>
|
|
54
|
+
</p>
|
|
55
|
+
</Banner>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
{entries.length > 0 && (
|
|
59
|
+
<ul className="rounded-sm border border-line bg-surface">
|
|
60
|
+
{entries.map((entry) => (
|
|
61
|
+
<QuarantineEntryRow
|
|
62
|
+
key={entry.quarantineId}
|
|
63
|
+
entry={entry}
|
|
64
|
+
onCutBug={onCutBug}
|
|
65
|
+
/>
|
|
66
|
+
))}
|
|
67
|
+
</ul>
|
|
68
|
+
)}
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
}
|