@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
- <Badge tone="warning">{canonicalRoleLabel(entry.mailboxRole)}</Badge>
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 `appVersion` when each file carried its own copy.
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: "UnterminatedMultipartBoundary",
18
+ failureCode: "UnreadableBody",
18
19
  failureMessage: "multipart boundary was never closed",
19
- failurePartPath: null,
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
- parts: [
29
- {
30
- contentType: "multipart/alternative",
31
- parts: [{ contentType: "text/plain" }, { contentType: "text/html" }],
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
- appVersion: "worker 1.0.0",
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
- appVersion: "worker 1.0.0",
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
- mailboxRole: "junk",
66
- mailboxPath: "Junk",
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: "TruncatedBody",
70
+ failureCode: "UnreadableBody",
69
71
  failureMessage: "stream ended before the declared body length",
70
- failurePartPath: null,
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: null,
77
- structure: { contentType: "text/html" },
78
+ charset: undefined,
79
+ structure: [{ depth: 0, contentType: "text/html" }],
78
80
  messageIdHash: "sha256:0a77de1c05",
79
- appVersion: "worker 1.0.0",
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, /UnterminatedMultipartBoundary/);
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
- parts: [
54
- {
55
- contentType:
56
- 'application/octet-stream; name="Q3 payroll — Acme.pdf"',
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: null });
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
- parts: [{ contentType: "```\n## Injected heading" }],
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, /UnterminatedMultipartBoundary/);
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, 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.
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
- * **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.
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. Grows with the narrowing
40
- * described on `QuarantineFailureStage`, and no faster.
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
- * A node in the message's MIME tree.
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
- /** Canonical role of the folder it arrived in — travels in the report. */
76
- mailboxRole: FolderRole;
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, or null when it is
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: string | null;
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
- sizeBytes: number;
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: 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
+ 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
- appVersion: string;
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
- 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
- ];
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 === null ? "_whole message_" : `\`${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.appVersion}`,
189
- `- **Message-ID hash**: \`${entry.messageIdHash}\``,
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 === null ? "_not declared_" : renderToken(entry.charset, TOKEN)}`,
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, /UnterminatedMultipartBoundary/);
64
+ assert.match(html, /UnreadableBody/);
59
65
  assert.match(html, /attachment names, or the parser&#x27;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