@remit/ui 0.0.16 → 0.0.18

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.16",
3
+ "version": "0.0.18",
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
18
  failureCode: "UnterminatedMultipartBoundary",
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
70
  failureCode: "TruncatedBody",
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: "TruncatedBody",
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
  ];
@@ -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);
@@ -45,7 +45,12 @@ export type QuarantineFailureCode =
45
45
  | "TruncatedBody";
46
46
 
47
47
  /**
48
- * A node in the message's MIME tree.
48
+ * One node of the message's MIME tree, in a pre-order walk.
49
+ *
50
+ * The tree arrives flat with an explicit `depth` rather than as nested
51
+ * children: a self-referential model is not expressible in the schema this
52
+ * comes from, and the tree is only ever rendered as an indented list, which a
53
+ * pre-order walk reproduces exactly.
49
54
  *
50
55
  * `contentType` is `type/subtype` only. BODYSTRUCTURE hands the type and its
51
56
  * parameters over separately, so a node is built from `type`/`subtype` and
@@ -53,8 +58,8 @@ export type QuarantineFailureCode =
53
58
  * `filename=`, which name the user's attachments.
54
59
  */
55
60
  export interface QuarantineMimeNode {
61
+ depth: number;
56
62
  contentType: string;
57
- parts?: readonly QuarantineMimeNode[];
58
63
  }
59
64
 
60
65
  /**
@@ -70,10 +75,19 @@ export interface QuarantineEntry {
70
75
  quarantineId: string;
71
76
  accountId: string;
72
77
  mailboxId: string;
78
+ /**
79
+ * UIDVALIDITY of the mailbox when the message was set aside. A uid names a
80
+ * message only alongside it.
81
+ */
82
+ uidValidity: number;
73
83
  /** IMAP uid of the message that was not written. */
74
84
  uid: number;
75
- /** Canonical role of the folder it arrived in — travels in the report. */
76
- mailboxRole: FolderRole;
85
+ /**
86
+ * Canonical role of the folder it arrived in — travels in the report.
87
+ * Absent when the user appointed no role to that folder, which is the
88
+ * ordinary state of a plain folder.
89
+ */
90
+ mailboxRole?: FolderRole;
77
91
  /** The user's own folder name. Shown on screen, withheld from the report. */
78
92
  mailboxPath: string;
79
93
  failureStage: QuarantineFailureStage;
@@ -81,26 +95,36 @@ export interface QuarantineEntry {
81
95
  /** Parser error text. Shown on screen, never in the report. */
82
96
  failureMessage: string;
83
97
  /**
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.
98
+ * Dot-numbered part path the failure is attributable to. Absent when it is
99
+ * not attributable to one node — the case for a whole-body parse failure,
100
+ * which is every failure today.
86
101
  */
87
- failurePartPath: string | null;
102
+ failurePartPath?: string;
88
103
  /** Epoch millis the message was quarantined. */
89
104
  quarantinedAt: number;
90
105
  /** Rounds attempted before the message was set aside. */
91
106
  attempts: number;
92
- sizeBytes: number;
107
+ /**
108
+ * The message-shape fields all come off BODYSTRUCTURE and RFC822.SIZE in
109
+ * the same FETCH, so a message that failed before either was read carries
110
+ * none of them — they are absent together, and `structure` is empty.
111
+ */
112
+ sizeBytes?: number;
93
113
  /** 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;
114
+ contentType?: string;
115
+ transferEncoding?: string;
116
+ /** Declared charset. Absent when the message declared none. */
117
+ charset?: string;
118
+ /** The MIME tree, structure only, as a pre-order walk. */
119
+ structure: readonly QuarantineMimeNode[];
120
+ /**
121
+ * SHA-256 of the Message-ID, `sha256:` prefixed. Absent when the message
122
+ * declared no Message-ID — a shared hash of nothing would correlate
123
+ * unrelated reports.
124
+ */
125
+ messageIdHash?: string;
102
126
  /** Build of the worker that failed to parse — a parse bug belongs to it. */
103
- appVersion: string;
127
+ workerVersion: string;
104
128
  }
105
129
 
106
130
  const stageSummaries: Record<QuarantineFailureStage, string> = {
@@ -146,13 +170,18 @@ function renderNodeType(contentType: string): string {
146
170
  return JSON.stringify(stripped);
147
171
  }
148
172
 
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
- ];
173
+ /**
174
+ * What a message-shape field reads as when the message failed before its
175
+ * BODYSTRUCTURE was read. Distinct from "not declared", which is a statement
176
+ * about the message rather than about how far sync got.
177
+ */
178
+ const NOT_READ = "_not read_";
179
+
180
+ function renderStructure(nodes: readonly QuarantineMimeNode[]): string[] {
181
+ if (nodes.length === 0) return ["(no MIME structure was read)"];
182
+ return nodes.map(
183
+ (node) => `${" ".repeat(node.depth)}- ${renderNodeType(node.contentType)}`,
184
+ );
156
185
  }
157
186
 
158
187
  export const QUARANTINE_REPORT_DISCLAIMER =
@@ -182,18 +211,18 @@ export function quarantineReportSections(
182
211
  `### Message quarantined at \`${entry.failureStage}\``,
183
212
  "",
184
213
  `- **Failure**: \`${entry.failureCode}\``,
185
- `- **Folder role**: ${entry.mailboxRole}`,
186
- `- **Failing part**: ${entry.failurePartPath === null ? "_whole message_" : `\`${entry.failurePartPath}\``}`,
214
+ `- **Folder role**: ${entry.mailboxRole ?? "_none appointed_"}`,
215
+ `- **Failing part**: ${entry.failurePartPath == null ? "_whole message_" : `\`${entry.failurePartPath}\``}`,
187
216
  `- **Attempts before quarantine**: ${entry.attempts}`,
188
- `- **Worker build**: ${entry.appVersion}`,
189
- `- **Message-ID hash**: \`${entry.messageIdHash}\``,
217
+ `- **Worker build**: ${entry.workerVersion}`,
218
+ `- **Message-ID hash**: ${entry.messageIdHash == null ? "_none declared_" : `\`${entry.messageIdHash}\``}`,
190
219
  "",
191
220
  "#### Message shape",
192
221
  "",
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`,
222
+ `- **Content-Type**: ${entry.contentType == null ? NOT_READ : renderToken(stripParameters(entry.contentType), MEDIA_TYPE)}`,
223
+ `- **Content-Transfer-Encoding**: ${entry.transferEncoding == null ? NOT_READ : renderToken(entry.transferEncoding, TOKEN)}`,
224
+ `- **Charset**: ${entry.charset == null ? "_not declared_" : renderToken(entry.charset, TOKEN)}`,
225
+ `- **Size**: ${entry.sizeBytes == null ? NOT_READ : `${entry.sizeBytes} bytes`}`,
197
226
  "",
198
227
  "MIME structure:",
199
228
  ].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/);
@@ -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
@@ -74,9 +74,15 @@ export const AllSelected: Story = {
74
74
 
75
75
  /**
76
76
  * The search has more matches than are loaded: an escalation notice offers a
77
- * real button (not prose) naming the total. Tapping it is what flips the
78
- * selection's identity from an id set to the search query (out of scope for
79
- * this kit the caller supplies the count once paging resolves it).
77
+ * real button (not prose) naming the scope. Tapping it is what flips the
78
+ * selection's identity from an id set to the search query (`useEscalatedDelete`
79
+ * in web-client). No count in the label yet the real client's own read path
80
+ * (`ThreadOperations.searchThreads`) only counts within a capped recency
81
+ * window short of paging the whole result set, and paging it just to seed a
82
+ * button label the user hasn't asked for yet would burn a request on every
83
+ * render of "all loaded selected" for a number that goes stale the moment new
84
+ * mail arrives. Tapping the button is what pays for the real count, via the
85
+ * counting state below.
80
86
  */
81
87
  export const EscalationAvailable: Story = {
82
88
  args: {
@@ -90,7 +96,7 @@ export const EscalationAvailable: Story = {
90
96
  tone: "info",
91
97
  text: "",
92
98
  action: {
93
- label: 'Select all 3,412 matching "npm"',
99
+ label: 'Select all matching "npm"',
94
100
  onClick: () => undefined,
95
101
  },
96
102
  },