@remit/web-client 0.0.321 → 0.0.323

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.
Files changed (190) hide show
  1. package/package.json +4 -4
  2. package/src/auth/SignInAgain.tsx +18 -0
  3. package/src/auth/amplify-config.test.ts +1 -107
  4. package/src/auth/auth-interceptor.test.ts +0 -6
  5. package/src/auth/cognito/cognito-token.test.ts +2 -4
  6. package/src/components/calendar/AgendaStrip.tsx +1 -1
  7. package/src/components/calendar/CalendarWorkspace.tsx +1 -1
  8. package/src/components/calendar/ChangeCalendarSuggestion.tsx +1 -1
  9. package/src/components/calendar/OpenCalendarEvent.tsx +1 -18
  10. package/src/components/compose/ComposeForm.tsx +11 -6
  11. package/src/components/compose/FullCompose.tsx +1 -1
  12. package/src/components/compose/MobileComposeSheet.tsx +1 -2
  13. package/src/components/compose/compose.stories.tsx +18 -0
  14. package/src/components/compose/draft-discard-failure.render.test.ts +138 -0
  15. package/src/components/config-import/config-import.fixtures.ts +8 -9
  16. package/src/components/config-import/config-import.stories.tsx +2 -2
  17. package/src/components/layout/MailTopBar.tsx +3 -0
  18. package/src/components/mail/BriefPane.tsx +0 -3
  19. package/src/components/mail/ConversationView.tsx +1 -3
  20. package/src/components/mail/DailyBrief.tsx +5 -28
  21. package/src/components/mail/FlaggedList.tsx +3 -4
  22. package/src/components/mail/FlaggedPane.tsx +0 -3
  23. package/src/components/mail/IntelligenceDrawer.tsx +0 -3
  24. package/src/components/mail/IntelligencePane.render.test.ts +0 -1
  25. package/src/components/mail/IntelligencePane.test.ts +1 -95
  26. package/src/components/mail/IntelligencePane.tsx +3 -93
  27. package/src/components/mail/MailListHeader.tsx +12 -43
  28. package/src/components/mail/MailNav.tsx +6 -2
  29. package/src/components/mail/MailViewChrome.tsx +1 -11
  30. package/src/components/mail/MailboxPane.tsx +4 -34
  31. package/src/components/mail/MessageActionMenu.tsx +6 -6
  32. package/src/components/mail/MessageAttachments.tsx +35 -0
  33. package/src/components/mail/MessageBody.tsx +1 -0
  34. package/src/components/mail/MessageBodyErrorBanner.test.ts +0 -30
  35. package/src/components/mail/MessageCard.tsx +16 -4
  36. package/src/components/mail/MessageList.run-reporting.test.ts +2 -46
  37. package/src/components/mail/MessageList.tsx +23 -11
  38. package/src/components/mail/MessageRow.render.test.ts +0 -7
  39. package/src/components/mail/MoveToTrigger.tsx +1 -1
  40. package/src/components/mail/RawMessageView.tsx +1 -1
  41. package/src/components/mail/SelectionWizardHost.search-entry.test.ts +0 -1
  42. package/src/components/mail/SelectionWizardHost.tsx +16 -102
  43. package/src/components/mail/authenticity-warning.stories.tsx +0 -21
  44. package/src/components/mail/daily-brief.stories.tsx +31 -17
  45. package/src/components/mail/enter-cursor-late-open.render.test.ts +197 -0
  46. package/src/components/mail/intelligence-drawer.stories.tsx +0 -2
  47. package/src/components/mail/message-gone.stories.tsx +44 -0
  48. package/src/components/mail/offer-times-reply.render.test.ts +25 -1
  49. package/src/components/mail/reclassify-remove-override.render.test.ts +2 -6
  50. package/src/components/overlay-escape.render.test.ts +7 -3
  51. package/src/components/settings/AccountFormPanel.render.test.ts +26 -3
  52. package/src/components/settings/AccountFormPanel.test.ts +1 -36
  53. package/src/components/settings/AccountFormPanel.tsx +5 -25
  54. package/src/components/settings/AccountServices.tsx +1 -1
  55. package/src/components/settings/AdvancedNavIcon.tsx +3 -3
  56. package/src/components/settings/CalendarFeedCard.tsx +2 -1
  57. package/src/components/settings/CalendarSubscribeCard.tsx +1 -1
  58. package/src/components/settings/CalendarSubscriptionStatus.tsx +1 -2
  59. package/src/components/settings/FilterActionError.tsx +1 -1
  60. package/src/components/settings/FilterEditor.render.test.ts +3 -27
  61. package/src/components/settings/FilterEditor.tsx +4 -24
  62. package/src/components/settings/FilterEditorSurface.tsx +0 -3
  63. package/src/components/settings/FiltersList.render.test.ts +5 -24
  64. package/src/components/settings/FiltersList.stories.tsx +0 -1
  65. package/src/components/settings/FiltersList.tsx +10 -35
  66. package/src/components/settings/WarningsNavIcon.tsx +12 -0
  67. package/src/components/settings/{QuarantinePanel.delimiter.test.ts → WarningsPanel.dom.test.ts} +64 -5
  68. package/src/components/settings/WarningsPanel.render.test.ts +56 -0
  69. package/src/components/settings/WarningsPanel.tsx +244 -0
  70. package/src/components/settings/accounts.stories.tsx +18 -0
  71. package/src/components/settings/advanced.stories.tsx +3 -125
  72. package/src/components/settings/settings-filter.stories.tsx +2 -42
  73. package/src/components/settings/warnings.stories.tsx +290 -0
  74. package/src/components/ui/ErrorBannerProvider.tsx +5 -3
  75. package/src/components/ui/error-banners.test.ts +1 -22
  76. package/src/components/ui/error-banners.ts +9 -20
  77. package/src/hooks/calendar/useCalendarCollectionWrites.ts +2 -1
  78. package/src/hooks/calendar/useCalendarSuggestions.ts +2 -1
  79. package/src/hooks/calendar/useCalendarWrites.ts +2 -1
  80. package/src/hooks/intelligence-calendar.render.test.ts +0 -1
  81. package/src/hooks/useDeleteMessages.test.ts +1 -42
  82. package/src/hooks/useFilters.test.ts +0 -8
  83. package/src/hooks/useFollowFocusOpen.render.test.ts +1 -11
  84. package/src/hooks/useIntelligenceData.test.ts +9 -47
  85. package/src/hooks/useIntelligenceData.ts +7 -100
  86. package/src/hooks/useLayoutTier.test.ts +0 -48
  87. package/src/hooks/useReportSpam.ts +1 -1
  88. package/src/hooks/useRescueCandidates.test.ts +0 -23
  89. package/src/hooks/useRulePreview.ts +8 -7
  90. package/src/hooks/useSearchTokenContext.ts +2 -3
  91. package/src/hooks/useStaleAccountSync.test.ts +0 -17
  92. package/src/hooks/useStarredThreads.test.ts +0 -10
  93. package/src/hooks/useTriggerSync.test.ts +0 -6
  94. package/src/hooks/useVisualViewport.render.test.ts +63 -0
  95. package/src/hooks/useWarningsAttention.ts +37 -0
  96. package/src/lib/account-order.test.ts +0 -10
  97. package/src/lib/adjacent-message.test.ts +0 -9
  98. package/src/lib/app-info.ts +0 -3
  99. package/src/lib/autodiscovery.test.ts +0 -47
  100. package/src/lib/bug-report.test.ts +38 -0
  101. package/src/lib/bug-report.ts +10 -7
  102. package/src/lib/bulk-action-copy.ts +1 -1
  103. package/src/lib/calendar-route.test.ts +0 -5
  104. package/src/lib/config-import.test.ts +3 -39
  105. package/src/lib/dedupe-thread-messages.test.ts +0 -4
  106. package/src/lib/desktop-breakpoint.test.ts +0 -8
  107. package/src/lib/drafts.test.ts +0 -23
  108. package/src/lib/fatal-error.test.ts +0 -19
  109. package/src/lib/folder-roles.test.ts +0 -4
  110. package/src/lib/hot-sync-window.test.ts +0 -5
  111. package/src/lib/list-header-chrome.ts +1 -1
  112. package/src/lib/mail-context.ts +1 -1
  113. package/src/lib/message-attachments.test.ts +77 -0
  114. package/src/lib/message-attachments.ts +31 -15
  115. package/src/lib/message-body-source.test.ts +21 -4
  116. package/src/lib/message-body-source.ts +5 -2
  117. package/src/lib/onboarding-completion.test.ts +0 -16
  118. package/src/lib/organize/filter-edit-model.test.ts +2 -32
  119. package/src/lib/organize/filter-edit-model.ts +6 -16
  120. package/src/lib/organize/organize-model.test.ts +7 -40
  121. package/src/lib/organize/organize-model.ts +16 -18
  122. package/src/lib/organize/rule-model.test.ts +24 -202
  123. package/src/lib/organize/rule-model.ts +19 -166
  124. package/src/lib/organize/search-to-rule.test.ts +2 -36
  125. package/src/lib/organize/search-to-rule.ts +1 -16
  126. package/src/lib/provider-presets.test.ts +1 -77
  127. package/src/lib/query-error-handler.test.ts +0 -61
  128. package/src/lib/query-escalation.integration.test.ts +4 -4
  129. package/src/lib/result-count.ts +1 -1
  130. package/src/lib/result-folder.ts +1 -1
  131. package/src/lib/rum-adapter.test.ts +0 -104
  132. package/src/lib/search-query.test.ts +0 -11
  133. package/src/lib/search-result.test.ts +2 -142
  134. package/src/lib/search-result.ts +3 -73
  135. package/src/lib/search-scope.test.ts +1 -92
  136. package/src/lib/search-scope.ts +0 -29
  137. package/src/lib/search-surface.ts +2 -2
  138. package/src/lib/search-token-index.test.ts +0 -8
  139. package/src/lib/search-tokens.ts +15 -16
  140. package/src/lib/starred-rows.test.ts +0 -4
  141. package/src/lib/sync-warning-entries.ts +33 -0
  142. package/src/lib/wizard-selection.ts +1 -1
  143. package/src/mocks/story-frame/mail-handlers.ts +12 -8
  144. package/src/mocks/story-frame/mail-world.ts +5 -0
  145. package/src/routeTree.gen.ts +21 -0
  146. package/src/routes/calendar/$view.$date/index.tsx +1 -1
  147. package/src/routes/calendar.tsx +2 -1
  148. package/src/routes/mail/brief.tsx +1 -1
  149. package/src/routes/mail/flagged.tsx +1 -1
  150. package/src/routes/mail.tsx +5 -3
  151. package/src/routes/settings/-accounts.test.ts +0 -5
  152. package/src/routes/settings/-filters.test.ts +0 -1
  153. package/src/routes/settings/-shared/help-copy.tsx +15 -0
  154. package/src/routes/settings/accounts.tsx +1 -1
  155. package/src/routes/settings/advanced.tsx +1 -4
  156. package/src/routes/settings/filters.tsx +1 -2
  157. package/src/routes/settings/folders.tsx +1 -1
  158. package/src/routes/settings/labels.tsx +1 -1
  159. package/src/routes/settings/senders.tsx +1 -1
  160. package/src/routes/settings/warnings.tsx +36 -0
  161. package/src/routes/settings.tsx +7 -0
  162. package/src/routing/wizard-history.test.ts +1 -45
  163. package/src/runtime-config.ts +5 -0
  164. package/src/test-support/fixtures.ts +1 -1
  165. package/src/components/compose/draft-discard-banner.test.ts +0 -76
  166. package/src/components/mail/ReadingPaneEmpty.render.test.ts +0 -42
  167. package/src/components/mail/SpamRescue.render.test.ts +0 -35
  168. package/src/components/mail/intelligence-semantic-off.render.test.ts +0 -111
  169. package/src/components/mail/selection-surface-rollcall.test.ts +0 -270
  170. package/src/components/settings/QuarantinePanel.render.test.ts +0 -71
  171. package/src/components/settings/QuarantinePanel.tsx +0 -178
  172. package/src/components/ui/DropdownMenu.tsx +0 -99
  173. package/src/components/ui/EmptyState.tsx +0 -11
  174. package/src/components/ui/ErrorBanner.render.test.ts +0 -104
  175. package/src/components/ui/ErrorBanner.stories.tsx +0 -132
  176. package/src/components/ui/ErrorBanner.tsx +0 -106
  177. package/src/components/ui/ErrorBannerStack.tsx +0 -35
  178. package/src/components/ui/ErrorState.render.test.ts +0 -82
  179. package/src/components/ui/ErrorState.tsx +0 -80
  180. package/src/components/ui/KeyboardShortcutsModal.tsx +0 -113
  181. package/src/hooks/useMoveMessages.test.ts +0 -63
  182. package/src/hooks/useOrganizeWiden.test.ts +0 -217
  183. package/src/hooks/useOrganizeWiden.ts +0 -112
  184. package/src/hooks/useSemanticSearch.ts +0 -132
  185. package/src/hooks/useSemanticSearchEnabled.ts +0 -23
  186. package/src/hooks/useVisualViewport.test.ts +0 -126
  187. package/src/lib/organize/sender-fallback.test.ts +0 -52
  188. package/src/lib/organize/sender-fallback.ts +0 -38
  189. package/src/lib/rescue-telemetry.test.ts +0 -75
  190. package/src/router.test.ts +0 -81
@@ -0,0 +1,37 @@
1
+ import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapQuarantineListResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { isUnauthenticated, softErrorStatuses } from "@/lib/error-classifier";
5
+
6
+ const WARNINGS_LIST_META = softErrorStatuses(401);
7
+
8
+ export function useWarningsList() {
9
+ return useQuery({
10
+ ...meOperationsListQuarantineOptions(),
11
+ meta: WARNINGS_LIST_META,
12
+ });
13
+ }
14
+
15
+ export const WARNINGS_ATTENTION = {
16
+ listed: "Sync warnings",
17
+ unread: "Warnings could not be read",
18
+ signedOut: "Session ended",
19
+ } as const;
20
+
21
+ export function warningsAttentionOf(
22
+ data: RemitImapQuarantineListResponse | undefined,
23
+ error: unknown,
24
+ ): string | undefined {
25
+ if (isUnauthenticated(error)) return WARNINGS_ATTENTION.signedOut;
26
+ if (error) return WARNINGS_ATTENTION.unread;
27
+ if (!data) return undefined;
28
+ if ((data.entries?.length ?? 0) > 0 || (data.warnings?.length ?? 0) > 0) {
29
+ return WARNINGS_ATTENTION.listed;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ export function useWarningsAttention(): string | undefined {
35
+ const list = useWarningsList();
36
+ return warningsAttentionOf(list.data, list.error);
37
+ }
@@ -39,12 +39,6 @@ describe("compareAccounts", () => {
39
39
  assert.ok(compareAccounts(a, b) < 0);
40
40
  assert.ok(compareAccounts(b, a) > 0);
41
41
  });
42
-
43
- test("identical accounts compare equal", () => {
44
- const a = account({ accountId: "x", createdAt: 1000 });
45
- const b = account({ accountId: "x", createdAt: 1000 });
46
- assert.strictEqual(compareAccounts(a, b), 0);
47
- });
48
42
  });
49
43
 
50
44
  describe("sortAccountsByCreatedAt", () => {
@@ -77,8 +71,4 @@ describe("sortAccountsByCreatedAt", () => {
77
71
  ["a", "m", "z"],
78
72
  );
79
73
  });
80
-
81
- test("empty input returns empty array", () => {
82
- assert.deepStrictEqual(sortAccountsByCreatedAt([]), []);
83
- });
84
74
  });
@@ -31,13 +31,4 @@ describe("adjacentMessageId", () => {
31
31
  test("returns null when there is no current id", () => {
32
32
  assert.strictEqual(adjacentMessageId(ids, undefined, "next"), null);
33
33
  });
34
-
35
- test("returns null for a single-item list at both ends", () => {
36
- assert.strictEqual(adjacentMessageId(["only"], "only", "next"), null);
37
- assert.strictEqual(adjacentMessageId(["only"], "only", "previous"), null);
38
- });
39
-
40
- test("returns null for an empty list", () => {
41
- assert.strictEqual(adjacentMessageId([], "a", "next"), null);
42
- });
43
34
  });
@@ -30,6 +30,3 @@ export const APP_SHA_IS_COMMIT = /^[0-9a-f]{40}$/.test(APP_SHA);
30
30
  export const GITHUB_COMMIT_URL: string | undefined = APP_SHA_IS_COMMIT
31
31
  ? `https://github.com/remit-mail/reader/commit/${APP_SHA}`
32
32
  : undefined;
33
-
34
- export const GITHUB_NEW_ISSUE_URL =
35
- "https://github.com/remit-mail/reader/issues/new";
@@ -8,53 +8,6 @@ import {
8
8
 
9
9
  // Provider table unit tests for top 5 providers (no network calls)
10
10
  describe("autodiscovery provider table", () => {
11
- it("resolves Gmail", async () => {
12
- const result = await discoverSettings("user@gmail.com");
13
- assert.ok(result, "should find Gmail");
14
- assert.equal(result.source, "provider-table");
15
- assert.equal(result.imap.host, "imap.gmail.com");
16
- assert.equal(result.imap.port, 993);
17
- assert.equal(result.imap.security, "tls");
18
- assert.equal(result.smtp.host, "smtp.gmail.com");
19
- assert.equal(result.smtp.port, 587);
20
- assert.equal(result.smtp.security, "starttls");
21
- });
22
-
23
- it("resolves iCloud", async () => {
24
- const result = await discoverSettings("user@icloud.com");
25
- assert.ok(result, "should find iCloud");
26
- assert.equal(result.source, "provider-table");
27
- assert.equal(result.imap.host, "imap.mail.me.com");
28
- assert.equal(result.imap.port, 993);
29
- assert.equal(result.smtp.security, "starttls");
30
- });
31
-
32
- it("resolves Fastmail", async () => {
33
- const result = await discoverSettings("user@fastmail.com");
34
- assert.ok(result, "should find Fastmail");
35
- assert.equal(result.source, "provider-table");
36
- assert.equal(result.imap.host, "imap.fastmail.com");
37
- assert.equal(result.smtp.host, "smtp.fastmail.com");
38
- assert.equal(result.smtp.port, 465);
39
- assert.equal(result.smtp.security, "tls");
40
- });
41
-
42
- it("resolves Outlook", async () => {
43
- const result = await discoverSettings("user@outlook.com");
44
- assert.ok(result, "should find Outlook");
45
- assert.equal(result.source, "provider-table");
46
- assert.equal(result.imap.host, "outlook.office365.com");
47
- assert.equal(result.smtp.host, "smtp.office365.com");
48
- });
49
-
50
- it("resolves Yahoo", async () => {
51
- const result = await discoverSettings("user@yahoo.com");
52
- assert.ok(result, "should find Yahoo");
53
- assert.equal(result.source, "provider-table");
54
- assert.equal(result.imap.host, "imap.mail.yahoo.com");
55
- assert.equal(result.smtp.security, "tls");
56
- });
57
-
58
11
  it("returns heuristic for unknown domain", async () => {
59
12
  // Use a clearly fake domain — network calls will fail/timeout
60
13
  // In the test environment there's no network, so it falls to heuristic
@@ -1,11 +1,14 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
+ formatSyncWarningReport,
4
5
  QUARANTINE_REPORT_DISCLAIMER,
5
6
  type QuarantineEntry,
6
7
  quarantineDemoEntries,
7
8
  quarantineIssueTitle,
8
9
  quarantineReportSections,
10
+ syncWarningDemoEntries,
11
+ syncWarningIssueTitle,
9
12
  } from "@remit/ui";
10
13
  import type { BugReportContext } from "./bug-report";
11
14
  import { buildBugReportDetails, buildGitHubIssueUrl } from "./bug-report";
@@ -45,6 +48,20 @@ describe("buildGitHubIssueUrl", () => {
45
48
  );
46
49
  });
47
50
 
51
+ it("points at the issues URL the runtime config names", () => {
52
+ globalThis.__REMIT_CONFIG__ = {
53
+ issuesUrl: "https://github.com/remit-mail/reader-cloudflare/issues/new",
54
+ };
55
+ const url = buildGitHubIssueUrl(baseCtx);
56
+ globalThis.__REMIT_CONFIG__ = undefined;
57
+ assert.ok(
58
+ url.startsWith(
59
+ "https://github.com/remit-mail/reader-cloudflare/issues/new?",
60
+ ),
61
+ `Expected the configured issues URL, got: ${url}`,
62
+ );
63
+ });
64
+
48
65
  it("prefills a title param", () => {
49
66
  const url = buildGitHubIssueUrl(baseCtx);
50
67
  assert.ok(url.includes("title="), "Expected a title param");
@@ -447,3 +464,24 @@ describe("quarantine reports", () => {
447
464
  );
448
465
  });
449
466
  });
467
+
468
+ describe("buildGitHubIssueUrl for a sync warning", () => {
469
+ const entry = {
470
+ ...syncWarningDemoEntries[1],
471
+ folderLabel: "Tax return 2026",
472
+ };
473
+
474
+ it("files the stage, state and attempts without the folder name", () => {
475
+ const decoded = decode(
476
+ buildGitHubIssueUrl({
477
+ ...baseCtx,
478
+ title: syncWarningIssueTitle(entry),
479
+ syncWarning: formatSyncWarningReport(entry),
480
+ }),
481
+ );
482
+ assert.ok(decoded.includes("Message failed to sync: Retrying at Header"));
483
+ assert.ok(decoded.includes("**Attempts**: 7"));
484
+ assert.ok(decoded.includes("abcdef1"));
485
+ assert.ok(!decoded.includes("Tax return 2026"));
486
+ });
487
+ });
@@ -1,10 +1,6 @@
1
1
  import type { QuarantineReportSections } from "@remit/ui";
2
- import {
3
- APP_BUILD_TIME,
4
- APP_SHORT_SHA,
5
- GITHUB_COMMIT_URL,
6
- GITHUB_NEW_ISSUE_URL,
7
- } from "./app-info";
2
+ import { getRuntimeConfig } from "../runtime-config";
3
+ import { APP_BUILD_TIME, APP_SHORT_SHA, GITHUB_COMMIT_URL } from "./app-info";
8
4
  import { getRecentErrors } from "./console-errors";
9
5
  import {
10
6
  getFailingRequest,
@@ -53,6 +49,7 @@ export interface BugReportContext {
53
49
  * @remit/ui so the MIME tree is fenced after truncation, never before.
54
50
  */
55
51
  quarantine?: QuarantineReportSections;
52
+ syncWarning?: string;
56
53
  /** Overrides the derived title, e.g. for a quarantine report. */
57
54
  title?: string;
58
55
  }
@@ -63,6 +60,7 @@ export interface BugReportSeed {
63
60
  stack?: string;
64
61
  componentStack?: string;
65
62
  quarantine?: QuarantineReportSections;
63
+ syncWarning?: string;
66
64
  /** Overrides the derived title, e.g. for a quarantine report. */
67
65
  title?: string;
68
66
  }
@@ -85,6 +83,7 @@ export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
85
83
  stack: seed?.stack,
86
84
  componentStack: seed?.componentStack,
87
85
  quarantine: seed?.quarantine,
86
+ syncWarning: seed?.syncWarning,
88
87
  title: seed?.title,
89
88
  };
90
89
  }
@@ -194,6 +193,10 @@ function buildIssueBody(
194
193
  );
195
194
  }
196
195
 
196
+ if (ctx.syncWarning) {
197
+ lines.push(ctx.syncWarning, "");
198
+ }
199
+
197
200
  const resolvedStack = overrides?.stack ?? ctx.stack;
198
201
  if (resolvedStack) {
199
202
  lines.push("## Stacktrace", fencedBlock(resolvedStack), "");
@@ -236,7 +239,7 @@ function issueUrl(ctx: BugReportContext, overrides?: BodyOverrides): string {
236
239
  title: buildIssueTitle(ctx),
237
240
  body: buildIssueBody(ctx, overrides),
238
241
  });
239
- return `${GITHUB_NEW_ISSUE_URL}?${params.toString()}`;
242
+ return `${getRuntimeConfig().issuesUrl}?${params.toString()}`;
240
243
  }
241
244
 
242
245
  /**
@@ -1,4 +1,4 @@
1
- import type { ErrorBannerSeverity } from "@/components/ui/error-banners";
1
+ import type { ErrorBannerSeverity } from "@remit/ui";
2
2
  import type { BulkRunOutcome } from "@/lib/bulk-actions";
3
3
  import { type DeleteOutcome, deleteExpunges, formatNumber } from "@/lib/format";
4
4
 
@@ -5,7 +5,6 @@
5
5
  */
6
6
  import assert from "node:assert/strict";
7
7
  import { describe, it } from "node:test";
8
- import { CALENDAR_DENSITY_KEY } from "./calendar-density.js";
9
8
  import {
10
9
  calendarSearchSchema,
11
10
  calendarViewMountsAgenda,
@@ -219,10 +218,6 @@ describe("calendarSearchSchema", () => {
219
218
  });
220
219
 
221
220
  describe("density is not URL state", () => {
222
- it("is stored against the device, under a key of its own", () => {
223
- assert.equal(CALENDAR_DENSITY_KEY, "remit:calendar-density");
224
- });
225
-
226
221
  it("has no reading in the query", () => {
227
222
  assert.deepEqual(calendarSearchSchema.parse({ density: "compact" }), {});
228
223
  });
@@ -135,7 +135,7 @@ test("a stopped import splits the write order into landed, failed and never reac
135
135
  {
136
136
  code: "import_write_failed",
137
137
  message:
138
- "the filter store refused the write The items above it were written and remain; the import stopped here.",
138
+ "the filter store refused the write The items above it were written and remain; import the same file again with merge to finish.",
139
139
  details: { section: "filters", key: "Receipts" },
140
140
  },
141
141
  ],
@@ -151,38 +151,7 @@ test("a stopped import splits the write order into landed, failed and never reac
151
151
  assert.equal(byId.get("settings")?.state, "not-attempted");
152
152
  });
153
153
 
154
- test("an import the server says wrote nothing marks no section landed, whatever ran before the stop", () => {
155
- const results = sectionResults(
156
- reportOf({
157
- applied: false,
158
- items: [
159
- item("filters", "Receipts", "rejected", "the store refused the write"),
160
- ],
161
- errors: [
162
- {
163
- code: "import_write_failed",
164
- message:
165
- "the store refused the write Nothing was written; the import stopped here.",
166
- details: { section: "filters", key: "Receipts" },
167
- },
168
- ],
169
- }),
170
- );
171
-
172
- const byId = new Map(results.map((result) => [result.section, result]));
173
- assert.equal(byId.get("accounts")?.state, "not-landed");
174
- assert.match(
175
- byId.get("accounts")?.detail ?? "",
176
- /Nothing from this section was written/,
177
- );
178
- assert.equal(byId.get("labels")?.state, "not-landed");
179
- assert.equal(byId.get("filters")?.state, "failed");
180
- assert.equal(byId.get("addressFlags")?.state, "not-attempted");
181
- assert.equal(byId.get("settings")?.state, "not-attempted");
182
- assert.equal(results.filter((result) => result.state === "landed").length, 0);
183
- });
184
-
185
- test("a first write that fails on a store with no transaction lands nothing either", () => {
154
+ test("a first write that fails lands nothing", () => {
186
155
  const results = sectionResults(
187
156
  reportOf({
188
157
  applied: false,
@@ -193,7 +162,7 @@ test("a first write that fails on a store with no transaction lands nothing eith
193
162
  {
194
163
  code: "import_write_failed",
195
164
  message:
196
- "the store refused The items above it were written and remain; the import stopped here.",
165
+ "the store refused The items above it were written and remain; import the same file again with merge to finish.",
197
166
  details: { section: "accounts", key: "a@example.test" },
198
167
  },
199
168
  ],
@@ -239,11 +208,6 @@ test("a pending folder names the setting waiting on it, and repeats are folded",
239
208
  message: "not here yet",
240
209
  details: { folderPath: "Raadsstukken", accountId: "acct-1" },
241
210
  },
242
- {
243
- code: "anchor_not_embedded",
244
- message: "carried as text",
245
- details: { filter: "Waternet" },
246
- },
247
211
  ],
248
212
  }),
249
213
  );
@@ -76,8 +76,4 @@ describe("dedupeThreadMessages (#166)", () => {
76
76
 
77
77
  assert.deepStrictEqual(got, items);
78
78
  });
79
-
80
- test("returns an empty array for empty input", () => {
81
- assert.deepStrictEqual(dedupeThreadMessages([]), []);
82
- });
83
79
  });
@@ -73,12 +73,4 @@ describe("the lg variant and the desktop hooks gate on the same condition", () =
73
73
  );
74
74
  });
75
75
  }
76
-
77
- it("excludes a coarse pointer held upright", () => {
78
- assert.match(DESKTOP_MEDIA_QUERY, /min-width: 1024px/);
79
- assert.match(
80
- DESKTOP_MEDIA_QUERY,
81
- /not \(\(orientation: portrait\) and \(pointer: coarse\)\)/,
82
- );
83
- });
84
76
  });
@@ -187,29 +187,6 @@ describe("groupDraftSections", () => {
187
187
  assert.deepStrictEqual(sections, []);
188
188
  });
189
189
 
190
- test("section labels match spec", () => {
191
- const draft = outboxMessage({
192
- outboxMessageId: "d1",
193
- accountId: "acc-1",
194
- status: "draft",
195
- });
196
- const thread = threadMessage({
197
- threadMessageId: "tm1",
198
- threadId: "t1",
199
- messageId: "m1",
200
- accountConfigId: "acc-1",
201
- });
202
- const sections = groupDraftSections({
203
- outboxMessages: [draft],
204
- accountId: "acc-1",
205
- imapThreads: [thread],
206
- });
207
- assert.deepStrictEqual(
208
- sections.map((s) => s.label),
209
- ["Not yet sent (Remit)", "On the server"],
210
- );
211
- });
212
-
213
190
  test("Remit-drafts section row id equals outboxMessageId", () => {
214
191
  const draft = outboxMessage({
215
192
  outboxMessageId: "outbox-abc",
@@ -6,7 +6,6 @@ import {
6
6
  __resetFatalError,
7
7
  getCurrentFatalError,
8
8
  reportFatalError,
9
- setFatalErrorTelemetry,
10
9
  subscribeFatalError,
11
10
  } from "./fatal-error";
12
11
 
@@ -39,24 +38,6 @@ describe("reportFatalError", () => {
39
38
  assert.equal(getCurrentFatalError(), fatal);
40
39
  });
41
40
 
42
- it("emits a telemetry fatal-error event through the registered sink", () => {
43
- const recorded: Array<{ message: string; ctx?: Record<string, string> }> =
44
- [];
45
- setFatalErrorTelemetry({
46
- recordPageView: () => {},
47
- recordError: (error, ctx) =>
48
- recorded.push({ message: error.message, ctx }),
49
- recordEvent: () => {},
50
- recordTiming: () => {},
51
- });
52
-
53
- reportFatalError(new Error("telemetry please"));
54
-
55
- assert.equal(recorded.length, 1);
56
- assert.equal(recorded[0].message, "telemetry please");
57
- assert.equal(recorded[0].ctx?.fatal, "true");
58
- });
59
-
60
41
  it("unsubscribe stops further notifications", () => {
61
42
  let count = 0;
62
43
  const unsubscribe = subscribeFatalError(() => {
@@ -36,10 +36,6 @@ describe("buildMailboxRoleMap", () => {
36
36
  ]);
37
37
  assert.equal(map.get("mb-1"), "inbox");
38
38
  });
39
-
40
- test("empty appointments produce an empty map", () => {
41
- assert.equal(buildMailboxRoleMap([]).size, 0);
42
- });
43
39
  });
44
40
 
45
41
  describe("labelForMailbox", () => {
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
2
2
  import { afterEach, describe, test } from "node:test";
3
3
  import {
4
4
  __resetHotSyncWindow,
5
- HOT_POLL_INTERVAL_MS,
6
5
  HOT_WINDOW_MS,
7
6
  isHotSyncWindowActive,
8
7
  startHotSyncWindow,
@@ -57,8 +56,4 @@ describe("hot sync window", () => {
57
56
  startHotSyncWindow(NOW);
58
57
  assert.equal(presses, 1);
59
58
  });
60
-
61
- test("the hot cadence is thirty seconds", () => {
62
- assert.equal(HOT_POLL_INTERVAL_MS, 30_000);
63
- });
64
59
  });
@@ -28,7 +28,7 @@ export interface ListHeaderChrome {
28
28
  /** The expanded field, which takes the title's place for as long as it is up. */
29
29
  searchField: ReactNode;
30
30
  /**
31
- * The read-only two-engine results panel, when a query is being typed and
31
+ * The read-only results panel, when a query is being typed and
32
32
  * this view answers it with the panel rather than its own rows. The body
33
33
  * renders it in place of its list; `MailListHeader` cannot swap the body
34
34
  * out from above, because the header is inside it.
@@ -21,7 +21,7 @@ export interface MailContextValue {
21
21
  /**
22
22
  * Name indexes `parseSearchTokens` resolves `in:`/`account:` chips against
23
23
  * (#428 follow-up). Computed once here so every consumer — chip rendering,
24
- * the brief/flagged token filters, `useSemanticSearch` — agrees on the same
24
+ * the brief/flagged token filters — agrees on the same
25
25
  * resolution. See `lib/search-token-index.ts`.
26
26
  */
27
27
  mailboxNameIndex: ReadonlyMap<string, string>;
@@ -8,6 +8,7 @@ const part = (
8
8
  ): RemitImapBodyPartResponse =>
9
9
  ({
10
10
  bodyPartId: "part-1",
11
+ partPath: "2",
11
12
  mediaType: "APPLICATION",
12
13
  mediaSubtype: "PDF",
13
14
  sizeOctets: 2048,
@@ -83,4 +84,80 @@ describe("selectMessageAttachments", () => {
83
84
  ]);
84
85
  assert.equal(attachment.typeLabel, "FILE");
85
86
  });
87
+
88
+ describe("a forwarded message", () => {
89
+ const forwarded = part({
90
+ bodyPartId: "forwarded",
91
+ partPath: "2",
92
+ mediaType: "MESSAGE",
93
+ mediaSubtype: "rfc822",
94
+ dispositionFilename: "forwarded.eml",
95
+ });
96
+ const inner = [
97
+ part({
98
+ bodyPartId: "inner-root",
99
+ partPath: "2.0",
100
+ mediaType: "MULTIPART",
101
+ mediaSubtype: "mixed",
102
+ isMultipart: true,
103
+ disposition: undefined,
104
+ }),
105
+ part({
106
+ bodyPartId: "inner-text",
107
+ partPath: "2.1",
108
+ mediaType: "TEXT",
109
+ mediaSubtype: "plain",
110
+ disposition: undefined,
111
+ }),
112
+ part({
113
+ bodyPartId: "inner-pdf",
114
+ partPath: "2.2",
115
+ dispositionFilename: "original.pdf",
116
+ }),
117
+ ];
118
+
119
+ it("is listed as a message, and the attachment inside it is not", () => {
120
+ const attachments = selectMessageAttachments([
121
+ forwarded,
122
+ ...inner,
123
+ part({ bodyPartId: "outer-pdf", partPath: "3" }),
124
+ ]);
125
+ assert.deepEqual(
126
+ attachments.map((a) => [a.bodyPartId, a.kind, a.filename]),
127
+ [
128
+ ["forwarded", "message", "forwarded.eml"],
129
+ ["outer-pdf", "file", "report.pdf"],
130
+ ],
131
+ );
132
+ });
133
+
134
+ it("is listed whatever disposition the sender gave it", () => {
135
+ const [attachment] = selectMessageAttachments([
136
+ { ...forwarded, disposition: undefined },
137
+ ]);
138
+ assert.equal(attachment?.kind, "message");
139
+ assert.equal(attachment?.filename, "forwarded.eml");
140
+ assert.equal(attachment?.typeLabel, "MESSAGE");
141
+ });
142
+
143
+ it("lists its own attachment once it is the message being read", () => {
144
+ assert.deepEqual(
145
+ selectMessageAttachments(inner).map((a) => a.bodyPartId),
146
+ ["inner-pdf"],
147
+ );
148
+ });
149
+
150
+ it("is not confused with a delivery report", () => {
151
+ assert.deepEqual(
152
+ selectMessageAttachments([
153
+ part({
154
+ mediaType: "MESSAGE",
155
+ mediaSubtype: "delivery-status",
156
+ disposition: undefined,
157
+ }),
158
+ ]),
159
+ [],
160
+ );
161
+ });
162
+ });
86
163
  });
@@ -1,15 +1,22 @@
1
1
  import type { RemitImapBodyPartResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import {
3
+ isEncapsulatedMessage,
4
+ ownParts,
5
+ } from "@remit/data-ports/message-parts";
2
6
  import { ContentDisposition } from "@remit/domain-enums";
3
- import { sanitizeAttachmentFilename } from "@remit/ui";
7
+ import { type AttachmentKind, sanitizeAttachmentFilename } from "@remit/ui";
4
8
 
5
9
  /**
6
10
  * The attachment-disposition body parts of a message, in the order the message
7
11
  * declares them. `pickRenderablePart` (message-body-source.ts) drops exactly
8
12
  * these parts so the renderer never treats a PDF as a body; this is the other
9
- * half of that split.
13
+ * half of that split. A forwarded message is listed whatever its disposition,
14
+ * and the parts inside it are its own, not the carrying message's.
10
15
  */
11
16
  export interface MessageAttachment {
12
17
  bodyPartId: string;
18
+ partPath: string;
19
+ kind: AttachmentKind;
13
20
  /** Display and save name — sanitized once, used for both. */
14
21
  filename: string;
15
22
  typeLabel: string;
@@ -17,7 +24,8 @@ export interface MessageAttachment {
17
24
  contentUrl: string;
18
25
  }
19
26
 
20
- const typeLabelFor = (mediaSubtype: string): string => {
27
+ const typeLabelFor = (kind: AttachmentKind, mediaSubtype: string): string => {
28
+ if (kind === "message") return "MESSAGE";
21
29
  const label = mediaSubtype.toUpperCase();
22
30
  return label === "OCTET-STREAM" ? "FILE" : label;
23
31
  };
@@ -35,20 +43,28 @@ const fallbackFilenameFor = (mediaSubtype: string): string => {
35
43
  export const selectMessageAttachments = (
36
44
  parts: readonly RemitImapBodyPartResponse[],
37
45
  ): MessageAttachment[] =>
38
- parts
46
+ ownParts(parts)
39
47
  .filter(
40
48
  (part) =>
41
49
  !part.isMultipart &&
42
- part.disposition === ContentDisposition.Attachment &&
50
+ (part.disposition === ContentDisposition.Attachment ||
51
+ isEncapsulatedMessage(part)) &&
43
52
  part.contentUrl.length > 0,
44
53
  )
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
- }));
54
+ .map((part) => {
55
+ const kind: AttachmentKind = isEncapsulatedMessage(part)
56
+ ? "message"
57
+ : "file";
58
+ return {
59
+ bodyPartId: part.bodyPartId,
60
+ partPath: part.partPath,
61
+ kind,
62
+ filename: sanitizeAttachmentFilename(
63
+ part.dispositionFilename ?? "",
64
+ fallbackFilenameFor(part.mediaSubtype),
65
+ ),
66
+ typeLabel: typeLabelFor(kind, part.mediaSubtype),
67
+ sizeOctets: part.sizeOctets,
68
+ contentUrl: part.contentUrl,
69
+ };
70
+ });