@remit/ui 0.0.17 → 0.0.19
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/quarantine-entry-row.tsx +5 -1
- package/src/components/quarantine-fixtures.ts +50 -23
- package/src/components/quarantine-report.test.ts +65 -16
- package/src/components/quarantine-report.ts +79 -62
- package/src/components/quarantine-section.render.test.ts +8 -2
- package/src/components/quarantine-section.stories.tsx +17 -1
package/package.json
CHANGED
|
@@ -41,7 +41,11 @@ export function QuarantineEntryRow({
|
|
|
41
41
|
{entry.failureMessage}
|
|
42
42
|
</p>
|
|
43
43
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-fg-subtle">
|
|
44
|
-
|
|
44
|
+
{entry.mailboxRole && (
|
|
45
|
+
<Badge tone="warning">
|
|
46
|
+
{canonicalRoleLabel(entry.mailboxRole)}
|
|
47
|
+
</Badge>
|
|
48
|
+
)}
|
|
45
49
|
<span className="truncate" title={entry.mailboxPath}>
|
|
46
50
|
{providerLeaf(entry.mailboxPath)}
|
|
47
51
|
</span>
|
|
@@ -3,43 +3,42 @@ import type { QuarantineEntry } from "./quarantine-report.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Demo entries backing the quarantine stories. Exported so the kit's own
|
|
5
5
|
* stories and the workbench settings screen render the same data — they had
|
|
6
|
-
* drifted on
|
|
6
|
+
* drifted on the worker build when each file carried its own copy.
|
|
7
7
|
*/
|
|
8
8
|
export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
9
9
|
{
|
|
10
10
|
quarantineId: "q-1",
|
|
11
11
|
accountId: "acct-1",
|
|
12
12
|
mailboxId: "mbx-inbox",
|
|
13
|
+
uidValidity: 1_712_000_000,
|
|
13
14
|
uid: 40217,
|
|
14
15
|
mailboxRole: "inbox",
|
|
15
16
|
mailboxPath: "INBOX",
|
|
16
17
|
failureStage: "BodyParse",
|
|
17
|
-
failureCode: "
|
|
18
|
+
failureCode: "UnreadableBody",
|
|
18
19
|
failureMessage: "multipart boundary was never closed",
|
|
19
|
-
failurePartPath:
|
|
20
|
+
failurePartPath: undefined,
|
|
20
21
|
quarantinedAt: Date.parse("2026-07-18T09:12:00Z"),
|
|
21
22
|
attempts: 3,
|
|
22
23
|
sizeBytes: 184_233,
|
|
23
24
|
contentType: "multipart/mixed",
|
|
24
25
|
transferEncoding: "7bit",
|
|
25
26
|
charset: "utf-8",
|
|
26
|
-
structure:
|
|
27
|
-
contentType: "multipart/mixed",
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{ contentType: "application/pdf" },
|
|
34
|
-
],
|
|
35
|
-
},
|
|
27
|
+
structure: [
|
|
28
|
+
{ depth: 0, contentType: "multipart/mixed" },
|
|
29
|
+
{ depth: 1, contentType: "multipart/alternative" },
|
|
30
|
+
{ depth: 2, contentType: "text/plain" },
|
|
31
|
+
{ depth: 2, contentType: "text/html" },
|
|
32
|
+
{ depth: 1, contentType: "application/pdf" },
|
|
33
|
+
],
|
|
36
34
|
messageIdHash: "sha256:6f1c4a9d20",
|
|
37
|
-
|
|
35
|
+
workerVersion: "worker 1.0.0",
|
|
38
36
|
},
|
|
39
37
|
{
|
|
40
38
|
quarantineId: "q-2",
|
|
41
39
|
accountId: "acct-1",
|
|
42
40
|
mailboxId: "mbx-archive",
|
|
41
|
+
uidValidity: 1_712_000_100,
|
|
43
42
|
uid: 40219,
|
|
44
43
|
mailboxRole: "archive",
|
|
45
44
|
mailboxPath: "Archive/2026",
|
|
@@ -53,29 +52,57 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
|
53
52
|
contentType: "text/plain",
|
|
54
53
|
transferEncoding: "quoted-printable",
|
|
55
54
|
charset: "x-user-defined",
|
|
56
|
-
structure: { contentType: "text/plain" },
|
|
55
|
+
structure: [{ depth: 0, contentType: "text/plain" }],
|
|
57
56
|
messageIdHash: "sha256:b31e0744af",
|
|
58
|
-
|
|
57
|
+
workerVersion: "worker 1.0.0",
|
|
59
58
|
},
|
|
60
59
|
{
|
|
61
60
|
quarantineId: "q-3",
|
|
62
61
|
accountId: "acct-1",
|
|
63
62
|
mailboxId: "mbx-junk",
|
|
63
|
+
uidValidity: 1_712_000_200,
|
|
64
64
|
uid: 40251,
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
// A folder the user never appointed a role to — the ordinary state of a
|
|
66
|
+
// plain folder, and the reason mailboxRole is optional.
|
|
67
|
+
mailboxRole: undefined,
|
|
68
|
+
mailboxPath: "Clients/Acme Holdings",
|
|
67
69
|
failureStage: "BodyParse",
|
|
68
|
-
failureCode: "
|
|
70
|
+
failureCode: "UnreadableBody",
|
|
69
71
|
failureMessage: "stream ended before the declared body length",
|
|
70
|
-
failurePartPath:
|
|
72
|
+
failurePartPath: undefined,
|
|
71
73
|
quarantinedAt: Date.parse("2026-07-19T06:03:00Z"),
|
|
72
74
|
attempts: 1,
|
|
73
75
|
sizeBytes: 2_140,
|
|
74
76
|
contentType: "text/html",
|
|
75
77
|
transferEncoding: "base64",
|
|
76
|
-
charset:
|
|
77
|
-
structure: { contentType: "text/html" },
|
|
78
|
+
charset: undefined,
|
|
79
|
+
structure: [{ depth: 0, contentType: "text/html" }],
|
|
78
80
|
messageIdHash: "sha256:0a77de1c05",
|
|
79
|
-
|
|
81
|
+
workerVersion: "worker 1.0.0",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
// The message shape is absent as a set: a message that failed before its
|
|
85
|
+
// BODYSTRUCTURE was read carries no content type, encoding, size or
|
|
86
|
+
// tree, and one that declared no Message-ID carries no hash either.
|
|
87
|
+
quarantineId: "q-4",
|
|
88
|
+
accountId: "acct-1",
|
|
89
|
+
mailboxId: "mbx-inbox",
|
|
90
|
+
uidValidity: 1_712_000_000,
|
|
91
|
+
uid: 40263,
|
|
92
|
+
mailboxRole: "inbox",
|
|
93
|
+
mailboxPath: "INBOX",
|
|
94
|
+
failureStage: "BodyParse",
|
|
95
|
+
failureCode: "UnreadableBody",
|
|
96
|
+
failureMessage: "connection closed mid-body",
|
|
97
|
+
failurePartPath: undefined,
|
|
98
|
+
quarantinedAt: Date.parse("2026-07-19T11:27:00Z"),
|
|
99
|
+
attempts: 2,
|
|
100
|
+
sizeBytes: undefined,
|
|
101
|
+
contentType: undefined,
|
|
102
|
+
transferEncoding: undefined,
|
|
103
|
+
charset: undefined,
|
|
104
|
+
structure: [],
|
|
105
|
+
messageIdHash: undefined,
|
|
106
|
+
workerVersion: "worker 1.0.0",
|
|
80
107
|
},
|
|
81
108
|
];
|
|
@@ -20,7 +20,7 @@ describe("formatQuarantineReport", () => {
|
|
|
20
20
|
it("names the stage, the code and the build that failed", () => {
|
|
21
21
|
const report = formatQuarantineReport(entry);
|
|
22
22
|
assert.match(report, /BodyParse/);
|
|
23
|
-
assert.match(report, /
|
|
23
|
+
assert.match(report, /UnreadableBody/);
|
|
24
24
|
assert.match(report, /worker 1\.0\.0/);
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -48,15 +48,13 @@ describe("formatQuarantineReport", () => {
|
|
|
48
48
|
const report = formatQuarantineReport({
|
|
49
49
|
...entry,
|
|
50
50
|
contentType: 'multipart/mixed; boundary="=_a1b2"',
|
|
51
|
-
structure:
|
|
52
|
-
contentType: "multipart/mixed",
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
],
|
|
59
|
-
},
|
|
51
|
+
structure: [
|
|
52
|
+
{ depth: 0, contentType: "multipart/mixed" },
|
|
53
|
+
{
|
|
54
|
+
depth: 1,
|
|
55
|
+
contentType: 'application/octet-stream; name="Q3 payroll — Acme.pdf"',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
60
58
|
});
|
|
61
59
|
assert.doesNotMatch(report, /payroll/);
|
|
62
60
|
assert.doesNotMatch(report, /boundary/);
|
|
@@ -71,10 +69,39 @@ describe("formatQuarantineReport", () => {
|
|
|
71
69
|
);
|
|
72
70
|
});
|
|
73
71
|
|
|
72
|
+
it("says no role was appointed rather than inventing one", () => {
|
|
73
|
+
const report = formatQuarantineReport({
|
|
74
|
+
...entry,
|
|
75
|
+
mailboxRole: undefined,
|
|
76
|
+
});
|
|
77
|
+
assert.match(report, /Folder role.*none appointed/);
|
|
78
|
+
});
|
|
79
|
+
|
|
74
80
|
it("marks an undeclared charset rather than omitting it", () => {
|
|
75
|
-
const report = formatQuarantineReport({ ...entry, charset:
|
|
81
|
+
const report = formatQuarantineReport({ ...entry, charset: undefined });
|
|
76
82
|
assert.match(report, /Charset.*not declared/);
|
|
77
83
|
});
|
|
84
|
+
|
|
85
|
+
// Absent is the wire shape: the API omits an optional diagnostic and the
|
|
86
|
+
// repo maps a null column to undefined. A report that printed `undefined`
|
|
87
|
+
// into a public issue would do it on the common case — a whole-body parse
|
|
88
|
+
// failure has no failing part, which is every failure today.
|
|
89
|
+
it("never prints undefined for a diagnostic the message did not carry", () => {
|
|
90
|
+
const report = formatQuarantineReport({
|
|
91
|
+
...entry,
|
|
92
|
+
failurePartPath: undefined,
|
|
93
|
+
charset: undefined,
|
|
94
|
+
contentType: undefined,
|
|
95
|
+
transferEncoding: undefined,
|
|
96
|
+
sizeBytes: undefined,
|
|
97
|
+
messageIdHash: undefined,
|
|
98
|
+
structure: [],
|
|
99
|
+
});
|
|
100
|
+
assert.doesNotMatch(report, /undefined|null/);
|
|
101
|
+
assert.match(report, /Failing part.*whole message/);
|
|
102
|
+
assert.match(report, /Message-ID hash.*none declared/);
|
|
103
|
+
assert.match(report, /no MIME structure was read/);
|
|
104
|
+
});
|
|
78
105
|
});
|
|
79
106
|
|
|
80
107
|
describe("sender-controlled BODYSTRUCTURE strings", () => {
|
|
@@ -109,16 +136,38 @@ describe("sender-controlled BODYSTRUCTURE strings", () => {
|
|
|
109
136
|
it("keeps a hostile node type from closing the MIME fence", () => {
|
|
110
137
|
const report = formatQuarantineReport({
|
|
111
138
|
...entry,
|
|
112
|
-
structure:
|
|
113
|
-
contentType: "multipart/mixed",
|
|
114
|
-
|
|
115
|
-
|
|
139
|
+
structure: [
|
|
140
|
+
{ depth: 0, contentType: "multipart/mixed" },
|
|
141
|
+
{ depth: 1, contentType: "```\n## Injected heading" },
|
|
142
|
+
],
|
|
116
143
|
});
|
|
117
144
|
assert.equal((report.match(/^```/gm) ?? []).length, 2);
|
|
118
145
|
assert.doesNotMatch(report, /^## Injected heading/m);
|
|
119
146
|
});
|
|
120
147
|
});
|
|
121
148
|
|
|
149
|
+
describe("a message that failed before its shape was read", () => {
|
|
150
|
+
const shapeless: QuarantineEntry = {
|
|
151
|
+
...entry,
|
|
152
|
+
contentType: undefined,
|
|
153
|
+
transferEncoding: undefined,
|
|
154
|
+
sizeBytes: undefined,
|
|
155
|
+
structure: [],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
it("says the shape was never read rather than guessing one", () => {
|
|
159
|
+
const report = formatQuarantineReport(shapeless);
|
|
160
|
+
assert.match(report, /Content-Type.*not read/);
|
|
161
|
+
assert.match(report, /Content-Transfer-Encoding.*not read/);
|
|
162
|
+
assert.match(report, /Size.*not read/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("keeps the fence balanced with an empty tree", () => {
|
|
166
|
+
const report = formatQuarantineReport(shapeless);
|
|
167
|
+
assert.equal((report.match(/^```/gm) ?? []).length, 2);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
122
171
|
describe("quarantineReportSections", () => {
|
|
123
172
|
it("hands the MIME tree over unfenced, so it can be truncated safely", () => {
|
|
124
173
|
const { head, structure, disclaimer } = quarantineReportSections(entry);
|
|
@@ -137,7 +186,7 @@ describe("quarantineReportSections", () => {
|
|
|
137
186
|
describe("quarantineIssueTitle", () => {
|
|
138
187
|
it("carries only the closed vocabulary", () => {
|
|
139
188
|
const title = quarantineIssueTitle(entry);
|
|
140
|
-
assert.match(title, /
|
|
189
|
+
assert.match(title, /UnreadableBody/);
|
|
141
190
|
assert.match(title, /BodyParse/);
|
|
142
191
|
assert.doesNotMatch(title, /joan@/);
|
|
143
192
|
});
|
|
@@ -3,31 +3,17 @@ import type { FolderRole } from "./folder-role.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* The pipeline step that refused the message.
|
|
5
5
|
*
|
|
6
|
-
* One member,
|
|
7
|
-
* parse
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* One member, behind a real narrowing rather than a promise of one: the body
|
|
7
|
+
* parse has its own try block around the parse call and nothing else, so an
|
|
8
|
+
* error out of it can only be the message. S3, queue and database failures
|
|
9
|
+
* propagate and never become a record here — quarantining one would set a
|
|
10
|
+
* message aside for an outage, tell the user it was unreadable, and invite a
|
|
11
|
+
* public GitHub issue about it.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
13
|
+
* A message that arrives with no ENVELOPE is deliberately not a member:
|
|
14
|
+
* nothing can tell that apart from the FETCH row glitching, and the IMAP
|
|
15
|
+
* client is far more often the cause than the message. The sync path holds its
|
|
16
|
+
* cursor and retries instead.
|
|
31
17
|
*/
|
|
32
18
|
export type QuarantineFailureStage = "BodyParse";
|
|
33
19
|
|
|
@@ -36,16 +22,23 @@ export type QuarantineFailureStage = "BodyParse";
|
|
|
36
22
|
*
|
|
37
23
|
* Closed, because `quarantineIssueTitle` interpolates it into a public issue
|
|
38
24
|
* title: a `string` here would make the one field whose publishability is
|
|
39
|
-
* asserted rather than derived into free text.
|
|
40
|
-
*
|
|
25
|
+
* asserted rather than derived into free text.
|
|
26
|
+
*
|
|
27
|
+
* Total and small. The body parser reports its refusals as free text with no
|
|
28
|
+
* stable type or code, so a member exists only where the writer can identify
|
|
29
|
+
* the defect without reading parser prose; anything it cannot name is
|
|
30
|
+
* `UnreadableBody`, and the parser's own words stay in `failureMessage`, which
|
|
31
|
+
* is shown on screen and never published.
|
|
41
32
|
*/
|
|
42
|
-
export type QuarantineFailureCode =
|
|
43
|
-
| "UnterminatedMultipartBoundary"
|
|
44
|
-
| "UnknownCharset"
|
|
45
|
-
| "TruncatedBody";
|
|
33
|
+
export type QuarantineFailureCode = "UnknownCharset" | "UnreadableBody";
|
|
46
34
|
|
|
47
35
|
/**
|
|
48
|
-
*
|
|
36
|
+
* One node of the message's MIME tree, in a pre-order walk.
|
|
37
|
+
*
|
|
38
|
+
* The tree arrives flat with an explicit `depth` rather than as nested
|
|
39
|
+
* children: a self-referential model is not expressible in the schema this
|
|
40
|
+
* comes from, and the tree is only ever rendered as an indented list, which a
|
|
41
|
+
* pre-order walk reproduces exactly.
|
|
49
42
|
*
|
|
50
43
|
* `contentType` is `type/subtype` only. BODYSTRUCTURE hands the type and its
|
|
51
44
|
* parameters over separately, so a node is built from `type`/`subtype` and
|
|
@@ -53,8 +46,8 @@ export type QuarantineFailureCode =
|
|
|
53
46
|
* `filename=`, which name the user's attachments.
|
|
54
47
|
*/
|
|
55
48
|
export interface QuarantineMimeNode {
|
|
49
|
+
depth: number;
|
|
56
50
|
contentType: string;
|
|
57
|
-
parts?: readonly QuarantineMimeNode[];
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
/**
|
|
@@ -70,10 +63,19 @@ export interface QuarantineEntry {
|
|
|
70
63
|
quarantineId: string;
|
|
71
64
|
accountId: string;
|
|
72
65
|
mailboxId: string;
|
|
66
|
+
/**
|
|
67
|
+
* UIDVALIDITY of the mailbox when the message was set aside. A uid names a
|
|
68
|
+
* message only alongside it.
|
|
69
|
+
*/
|
|
70
|
+
uidValidity: number;
|
|
73
71
|
/** IMAP uid of the message that was not written. */
|
|
74
72
|
uid: number;
|
|
75
|
-
/**
|
|
76
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Canonical role of the folder it arrived in — travels in the report.
|
|
75
|
+
* Absent when the user appointed no role to that folder, which is the
|
|
76
|
+
* ordinary state of a plain folder.
|
|
77
|
+
*/
|
|
78
|
+
mailboxRole?: FolderRole;
|
|
77
79
|
/** The user's own folder name. Shown on screen, withheld from the report. */
|
|
78
80
|
mailboxPath: string;
|
|
79
81
|
failureStage: QuarantineFailureStage;
|
|
@@ -81,26 +83,36 @@ export interface QuarantineEntry {
|
|
|
81
83
|
/** Parser error text. Shown on screen, never in the report. */
|
|
82
84
|
failureMessage: string;
|
|
83
85
|
/**
|
|
84
|
-
* Dot-numbered part path the failure is attributable to
|
|
85
|
-
* not attributable to one node — the case for a whole-body parse failure
|
|
86
|
+
* Dot-numbered part path the failure is attributable to. Absent when it is
|
|
87
|
+
* not attributable to one node — the case for a whole-body parse failure,
|
|
88
|
+
* which is every failure today.
|
|
86
89
|
*/
|
|
87
|
-
failurePartPath
|
|
90
|
+
failurePartPath?: string;
|
|
88
91
|
/** Epoch millis the message was quarantined. */
|
|
89
92
|
quarantinedAt: number;
|
|
90
93
|
/** Rounds attempted before the message was set aside. */
|
|
91
94
|
attempts: number;
|
|
92
|
-
|
|
95
|
+
/**
|
|
96
|
+
* The message-shape fields all come off BODYSTRUCTURE and RFC822.SIZE in
|
|
97
|
+
* the same FETCH, so a message that failed before either was read carries
|
|
98
|
+
* none of them — they are absent together, and `structure` is empty.
|
|
99
|
+
*/
|
|
100
|
+
sizeBytes?: number;
|
|
93
101
|
/** Top-level Content-Type, `type/subtype` only. */
|
|
94
|
-
contentType
|
|
95
|
-
transferEncoding
|
|
96
|
-
/** Declared charset
|
|
97
|
-
charset
|
|
98
|
-
/** The MIME tree, structure only. */
|
|
99
|
-
structure: QuarantineMimeNode;
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
+
contentType?: string;
|
|
103
|
+
transferEncoding?: string;
|
|
104
|
+
/** Declared charset. Absent when the message declared none. */
|
|
105
|
+
charset?: string;
|
|
106
|
+
/** The MIME tree, structure only, as a pre-order walk. */
|
|
107
|
+
structure: readonly QuarantineMimeNode[];
|
|
108
|
+
/**
|
|
109
|
+
* SHA-256 of the Message-ID, `sha256:` prefixed. Absent when the message
|
|
110
|
+
* declared no Message-ID — a shared hash of nothing would correlate
|
|
111
|
+
* unrelated reports.
|
|
112
|
+
*/
|
|
113
|
+
messageIdHash?: string;
|
|
102
114
|
/** Build of the worker that failed to parse — a parse bug belongs to it. */
|
|
103
|
-
|
|
115
|
+
workerVersion: string;
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
const stageSummaries: Record<QuarantineFailureStage, string> = {
|
|
@@ -146,13 +158,18 @@ function renderNodeType(contentType: string): string {
|
|
|
146
158
|
return JSON.stringify(stripped);
|
|
147
159
|
}
|
|
148
160
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
/**
|
|
162
|
+
* What a message-shape field reads as when the message failed before its
|
|
163
|
+
* BODYSTRUCTURE was read. Distinct from "not declared", which is a statement
|
|
164
|
+
* about the message rather than about how far sync got.
|
|
165
|
+
*/
|
|
166
|
+
const NOT_READ = "_not read_";
|
|
167
|
+
|
|
168
|
+
function renderStructure(nodes: readonly QuarantineMimeNode[]): string[] {
|
|
169
|
+
if (nodes.length === 0) return ["(no MIME structure was read)"];
|
|
170
|
+
return nodes.map(
|
|
171
|
+
(node) => `${" ".repeat(node.depth)}- ${renderNodeType(node.contentType)}`,
|
|
172
|
+
);
|
|
156
173
|
}
|
|
157
174
|
|
|
158
175
|
export const QUARANTINE_REPORT_DISCLAIMER =
|
|
@@ -182,18 +199,18 @@ export function quarantineReportSections(
|
|
|
182
199
|
`### Message quarantined at \`${entry.failureStage}\``,
|
|
183
200
|
"",
|
|
184
201
|
`- **Failure**: \`${entry.failureCode}\``,
|
|
185
|
-
`- **Folder role**: ${entry.mailboxRole}`,
|
|
186
|
-
`- **Failing part**: ${entry.failurePartPath
|
|
202
|
+
`- **Folder role**: ${entry.mailboxRole ?? "_none appointed_"}`,
|
|
203
|
+
`- **Failing part**: ${entry.failurePartPath == null ? "_whole message_" : `\`${entry.failurePartPath}\``}`,
|
|
187
204
|
`- **Attempts before quarantine**: ${entry.attempts}`,
|
|
188
|
-
`- **Worker build**: ${entry.
|
|
189
|
-
`- **Message-ID hash**:
|
|
205
|
+
`- **Worker build**: ${entry.workerVersion}`,
|
|
206
|
+
`- **Message-ID hash**: ${entry.messageIdHash == null ? "_none declared_" : `\`${entry.messageIdHash}\``}`,
|
|
190
207
|
"",
|
|
191
208
|
"#### Message shape",
|
|
192
209
|
"",
|
|
193
|
-
`- **Content-Type**: ${renderToken(stripParameters(entry.contentType), MEDIA_TYPE)}`,
|
|
194
|
-
`- **Content-Transfer-Encoding**: ${renderToken(entry.transferEncoding, TOKEN)}`,
|
|
195
|
-
`- **Charset**: ${entry.charset
|
|
196
|
-
`- **Size**: ${entry.sizeBytes} bytes`,
|
|
210
|
+
`- **Content-Type**: ${entry.contentType == null ? NOT_READ : renderToken(stripParameters(entry.contentType), MEDIA_TYPE)}`,
|
|
211
|
+
`- **Content-Transfer-Encoding**: ${entry.transferEncoding == null ? NOT_READ : renderToken(entry.transferEncoding, TOKEN)}`,
|
|
212
|
+
`- **Charset**: ${entry.charset == null ? "_not declared_" : renderToken(entry.charset, TOKEN)}`,
|
|
213
|
+
`- **Size**: ${entry.sizeBytes == null ? NOT_READ : `${entry.sizeBytes} bytes`}`,
|
|
197
214
|
"",
|
|
198
215
|
"MIME structure:",
|
|
199
216
|
].join("\n");
|
|
@@ -8,7 +8,7 @@ import type { QuarantineEntry } from "./quarantine-report.js";
|
|
|
8
8
|
import { QuarantineSection } from "./quarantine-section.js";
|
|
9
9
|
|
|
10
10
|
const noop = () => {};
|
|
11
|
-
const [base, second] = quarantineDemoEntries;
|
|
11
|
+
const [base, second, unappointedFolder] = quarantineDemoEntries;
|
|
12
12
|
const ISSUE_URL = "https://github.com/remit-mail/reader/issues/new?title=x";
|
|
13
13
|
|
|
14
14
|
const render = (entries: readonly QuarantineEntry[]) =>
|
|
@@ -38,6 +38,12 @@ describe("QuarantineSection", () => {
|
|
|
38
38
|
assert.match(html, /multipart boundary was never closed/);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("names no role for a folder the user never appointed one to", () => {
|
|
42
|
+
const html = render([unappointedFolder]);
|
|
43
|
+
assert.doesNotMatch(html, /Inbox|Spam|Archive/);
|
|
44
|
+
assert.match(html, /Acme Holdings/);
|
|
45
|
+
});
|
|
46
|
+
|
|
41
47
|
it("offers reporting as the only per-row action", () => {
|
|
42
48
|
const html = render([base]);
|
|
43
49
|
assert.match(html, /Cut a bug/);
|
|
@@ -55,7 +61,7 @@ describe("QuarantineBugDialog", () => {
|
|
|
55
61
|
issueUrl: ISSUE_URL,
|
|
56
62
|
}),
|
|
57
63
|
);
|
|
58
|
-
assert.match(html, /
|
|
64
|
+
assert.match(html, /UnreadableBody/);
|
|
59
65
|
assert.match(html, /attachment names, or the parser's own error text/);
|
|
60
66
|
assert.match(html, /Copy report/);
|
|
61
67
|
});
|
|
@@ -5,7 +5,7 @@ import { quarantineDemoEntries } from "./quarantine-fixtures.js";
|
|
|
5
5
|
import type { QuarantineEntry } from "./quarantine-report.js";
|
|
6
6
|
import { QuarantineSection } from "./quarantine-section.js";
|
|
7
7
|
|
|
8
|
-
const [unterminatedBoundary, unknownCharset, truncatedBody] =
|
|
8
|
+
const [unterminatedBoundary, unknownCharset, truncatedBody, shapeUnread] =
|
|
9
9
|
quarantineDemoEntries;
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -62,6 +62,22 @@ export const CutABugFlow: Story = {
|
|
|
62
62
|
},
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* A message that failed before its BODYSTRUCTURE was read carries no content
|
|
67
|
+
* type, encoding, size or MIME tree, and one that declared no Message-ID
|
|
68
|
+
* carries no hash. The report says so rather than printing an empty value.
|
|
69
|
+
*/
|
|
70
|
+
export const BugReportWithoutMessageShape: Story = {
|
|
71
|
+
render: () => (
|
|
72
|
+
<QuarantineBugDialog
|
|
73
|
+
entry={shapeUnread}
|
|
74
|
+
issueUrl={demoIssueUrl}
|
|
75
|
+
onClose={() => {}}
|
|
76
|
+
onCopy={() => {}}
|
|
77
|
+
/>
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
|
|
65
81
|
export const BugReport: Story = {
|
|
66
82
|
render: () => (
|
|
67
83
|
<QuarantineBugDialog
|