@remit/web-client 0.0.1
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/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { ThreadRowData } from "@remit/ui";
|
|
4
|
+
import {
|
|
5
|
+
groupBriefSections,
|
|
6
|
+
matchesBriefSearch,
|
|
7
|
+
matchesSearchTokens,
|
|
8
|
+
} from "./brief.js";
|
|
9
|
+
import type { SearchToken } from "./search-tokens.js";
|
|
10
|
+
|
|
11
|
+
function row(
|
|
12
|
+
overrides: Partial<ThreadRowData> & Pick<ThreadRowData, "id">,
|
|
13
|
+
): ThreadRowData {
|
|
14
|
+
return {
|
|
15
|
+
accountId: "acc_1",
|
|
16
|
+
fromName: "Sender",
|
|
17
|
+
fromEmail: "sender@example.com",
|
|
18
|
+
subject: "Subject",
|
|
19
|
+
snippet: "Snippet",
|
|
20
|
+
timeLabel: "09:00",
|
|
21
|
+
isRead: false,
|
|
22
|
+
hasAttachment: false,
|
|
23
|
+
starred: false,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("groupBriefSections", () => {
|
|
29
|
+
test("returns empty array when no rows", () => {
|
|
30
|
+
const sections = groupBriefSections([]);
|
|
31
|
+
assert.deepStrictEqual(sections, []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// --- One section per category ---
|
|
35
|
+
|
|
36
|
+
test("personal goes to the personal section", () => {
|
|
37
|
+
const r = row({ id: "1", category: "personal" });
|
|
38
|
+
const sections = groupBriefSections([r]);
|
|
39
|
+
assert.strictEqual(sections.length, 1);
|
|
40
|
+
assert.strictEqual(sections[0].id, "personal");
|
|
41
|
+
assert.strictEqual(sections[0].label, "Personal");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("transactional goes to the transactional section", () => {
|
|
45
|
+
const r = row({ id: "1", category: "transactional" });
|
|
46
|
+
const sections = groupBriefSections([r]);
|
|
47
|
+
assert.strictEqual(sections[0].id, "transactional");
|
|
48
|
+
assert.strictEqual(sections[0].label, "Transactional");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("newsletter goes to the newsletter section", () => {
|
|
52
|
+
const r = row({ id: "1", category: "newsletter" });
|
|
53
|
+
const sections = groupBriefSections([r]);
|
|
54
|
+
assert.strictEqual(sections[0].id, "newsletter");
|
|
55
|
+
assert.strictEqual(sections[0].label, "Newsletter");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("marketing goes to the marketing section", () => {
|
|
59
|
+
const r = row({ id: "1", category: "marketing" });
|
|
60
|
+
const sections = groupBriefSections([r]);
|
|
61
|
+
assert.strictEqual(sections[0].id, "marketing");
|
|
62
|
+
assert.strictEqual(sections[0].label, "Marketing");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("social goes to the social section", () => {
|
|
66
|
+
const r = row({ id: "1", category: "social" });
|
|
67
|
+
const sections = groupBriefSections([r]);
|
|
68
|
+
assert.strictEqual(sections[0].id, "social");
|
|
69
|
+
assert.strictEqual(sections[0].label, "Social");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("automated goes to the automated section", () => {
|
|
73
|
+
const r = row({ id: "1", category: "automated" });
|
|
74
|
+
const sections = groupBriefSections([r]);
|
|
75
|
+
assert.strictEqual(sections[0].id, "automated");
|
|
76
|
+
assert.strictEqual(sections[0].label, "Automated");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// --- Read state is not a routing signal ---
|
|
80
|
+
|
|
81
|
+
test("read and unread rows of a category share one section", () => {
|
|
82
|
+
const rows: ThreadRowData[] = [
|
|
83
|
+
row({ id: "1", isRead: false, category: "personal" }),
|
|
84
|
+
row({ id: "2", isRead: true, category: "personal" }),
|
|
85
|
+
];
|
|
86
|
+
const sections = groupBriefSections(rows);
|
|
87
|
+
assert.strictEqual(sections.length, 1);
|
|
88
|
+
assert.strictEqual(sections[0].id, "personal");
|
|
89
|
+
assert.strictEqual(sections[0].threads.length, 2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// --- Fallback to personal ---
|
|
93
|
+
|
|
94
|
+
test("missing category falls back to personal", () => {
|
|
95
|
+
const r = row({ id: "1" });
|
|
96
|
+
const sections = groupBriefSections([r]);
|
|
97
|
+
assert.strictEqual(sections.length, 1);
|
|
98
|
+
assert.strictEqual(sections[0].id, "personal");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// --- Starred is a row marker, not a section (Flagged lives in the nav) ---
|
|
102
|
+
|
|
103
|
+
test("a starred newsletter stays in the newsletter section", () => {
|
|
104
|
+
const r = row({ id: "1", starred: true, category: "newsletter" });
|
|
105
|
+
const sections = groupBriefSections([r]);
|
|
106
|
+
assert.strictEqual(sections.length, 1);
|
|
107
|
+
assert.strictEqual(sections[0].id, "newsletter");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a starred personal message stays in the personal section", () => {
|
|
111
|
+
const r = row({ id: "1", starred: true, category: "personal" });
|
|
112
|
+
const sections = groupBriefSections([r]);
|
|
113
|
+
assert.strictEqual(sections.length, 1);
|
|
114
|
+
assert.strictEqual(sections[0].id, "personal");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("starred mail never produces a flagged section", () => {
|
|
118
|
+
const rows: ThreadRowData[] = [
|
|
119
|
+
row({ id: "p", category: "personal" }),
|
|
120
|
+
row({ id: "f", starred: true, category: "automated" }),
|
|
121
|
+
];
|
|
122
|
+
const sections = groupBriefSections(rows);
|
|
123
|
+
assert.deepStrictEqual(
|
|
124
|
+
sections.map((s) => s.id),
|
|
125
|
+
["personal", "automated"],
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// --- Trust no longer sections ---
|
|
130
|
+
|
|
131
|
+
test("a vip newsletter stays in the newsletter section (trust does not section)", () => {
|
|
132
|
+
const r = row({ id: "1", trust: "vip", category: "newsletter" });
|
|
133
|
+
const sections = groupBriefSections([r]);
|
|
134
|
+
assert.strictEqual(sections.length, 1);
|
|
135
|
+
assert.strictEqual(sections[0].id, "newsletter");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a wellknown automated row stays in the automated section", () => {
|
|
139
|
+
const r = row({ id: "1", trust: "wellknown", category: "automated" });
|
|
140
|
+
const sections = groupBriefSections([r]);
|
|
141
|
+
assert.strictEqual(sections.length, 1);
|
|
142
|
+
assert.strictEqual(sections[0].id, "automated");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// --- Section order and omission ---
|
|
146
|
+
|
|
147
|
+
test("display order is: personal, transactional, newsletter, marketing, social, automated", () => {
|
|
148
|
+
const rows: ThreadRowData[] = [
|
|
149
|
+
row({ id: "auto", category: "automated" }),
|
|
150
|
+
row({ id: "social", category: "social" }),
|
|
151
|
+
row({ id: "mkt", category: "marketing" }),
|
|
152
|
+
row({ id: "news", category: "newsletter" }),
|
|
153
|
+
row({ id: "txn", category: "transactional" }),
|
|
154
|
+
row({ id: "pers", category: "personal" }),
|
|
155
|
+
row({ id: "star", starred: true, category: "automated" }),
|
|
156
|
+
];
|
|
157
|
+
const sections = groupBriefSections(rows);
|
|
158
|
+
assert.deepStrictEqual(
|
|
159
|
+
sections.map((s) => s.id),
|
|
160
|
+
[
|
|
161
|
+
"personal",
|
|
162
|
+
"transactional",
|
|
163
|
+
"newsletter",
|
|
164
|
+
"marketing",
|
|
165
|
+
"social",
|
|
166
|
+
"automated",
|
|
167
|
+
],
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("empty sections are omitted", () => {
|
|
172
|
+
const sections = groupBriefSections([row({ id: "1", category: "social" })]);
|
|
173
|
+
assert.deepStrictEqual(
|
|
174
|
+
sections.map((s) => s.id),
|
|
175
|
+
["social"],
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("each row appears in exactly one section", () => {
|
|
180
|
+
const rows: ThreadRowData[] = [
|
|
181
|
+
row({ id: "1", category: "personal" }),
|
|
182
|
+
row({ id: "2", starred: true, category: "automated" }),
|
|
183
|
+
row({ id: "3", category: "automated" }),
|
|
184
|
+
row({ id: "4", category: "transactional" }),
|
|
185
|
+
row({ id: "5", starred: true, category: "newsletter" }),
|
|
186
|
+
row({ id: "6", category: "newsletter" }),
|
|
187
|
+
row({ id: "7", category: "marketing" }),
|
|
188
|
+
];
|
|
189
|
+
const sections = groupBriefSections(rows);
|
|
190
|
+
const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
|
|
191
|
+
assert.strictEqual(allIds.length, rows.length);
|
|
192
|
+
const uniqueIds = new Set(allIds);
|
|
193
|
+
assert.strictEqual(uniqueIds.size, rows.length);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("matchesBriefSearch", () => {
|
|
198
|
+
const r = row({
|
|
199
|
+
id: "1",
|
|
200
|
+
fromName: "Alice Tan",
|
|
201
|
+
fromEmail: "alice@example.com",
|
|
202
|
+
subject: "Q3 roadmap",
|
|
203
|
+
snippet: "See the attached deck",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("matches on fromName (case-insensitive)", () => {
|
|
207
|
+
assert.strictEqual(matchesBriefSearch(r, "alice"), true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("matches on fromEmail", () => {
|
|
211
|
+
assert.strictEqual(matchesBriefSearch(r, "alice@example"), true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("matches on subject", () => {
|
|
215
|
+
assert.strictEqual(matchesBriefSearch(r, "roadmap"), true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("matches on snippet", () => {
|
|
219
|
+
assert.strictEqual(matchesBriefSearch(r, "deck"), true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("returns false when query matches nothing", () => {
|
|
223
|
+
assert.strictEqual(matchesBriefSearch(r, "zyxwvuts"), false);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("matchesSearchTokens", () => {
|
|
228
|
+
const from = (value: string): SearchToken => ({
|
|
229
|
+
type: "from",
|
|
230
|
+
raw: `from:${value}`,
|
|
231
|
+
value,
|
|
232
|
+
});
|
|
233
|
+
const hasAttachment: SearchToken = {
|
|
234
|
+
type: "hasAttachment",
|
|
235
|
+
raw: "has:attachment",
|
|
236
|
+
};
|
|
237
|
+
const isUnread: SearchToken = { type: "isUnread", raw: "is:unread" };
|
|
238
|
+
const after = (epochSeconds: number): SearchToken => ({
|
|
239
|
+
type: "after",
|
|
240
|
+
raw: "after:x",
|
|
241
|
+
value: "x",
|
|
242
|
+
epochSeconds,
|
|
243
|
+
});
|
|
244
|
+
const before = (epochSeconds: number): SearchToken => ({
|
|
245
|
+
type: "before",
|
|
246
|
+
raw: "before:x",
|
|
247
|
+
value: "x",
|
|
248
|
+
epochSeconds,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("returns true with no tokens", () => {
|
|
252
|
+
assert.strictEqual(matchesSearchTokens(row({ id: "1" }), []), true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("from: matches fromEmail or fromName, case-insensitively", () => {
|
|
256
|
+
const r = row({ id: "1", fromEmail: "alice@dhl.com", fromName: "DHL" });
|
|
257
|
+
assert.strictEqual(matchesSearchTokens(r, [from("DHL")]), true);
|
|
258
|
+
assert.strictEqual(matchesSearchTokens(r, [from("alice@dhl")]), true);
|
|
259
|
+
assert.strictEqual(matchesSearchTokens(r, [from("ups")]), false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("has:attachment requires hasAttachment true", () => {
|
|
263
|
+
assert.strictEqual(
|
|
264
|
+
matchesSearchTokens(row({ id: "1", hasAttachment: true }), [
|
|
265
|
+
hasAttachment,
|
|
266
|
+
]),
|
|
267
|
+
true,
|
|
268
|
+
);
|
|
269
|
+
assert.strictEqual(
|
|
270
|
+
matchesSearchTokens(row({ id: "1", hasAttachment: false }), [
|
|
271
|
+
hasAttachment,
|
|
272
|
+
]),
|
|
273
|
+
false,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("is:unread requires isRead falsy", () => {
|
|
278
|
+
assert.strictEqual(
|
|
279
|
+
matchesSearchTokens(row({ id: "1", isRead: false }), [isUnread]),
|
|
280
|
+
true,
|
|
281
|
+
);
|
|
282
|
+
assert.strictEqual(
|
|
283
|
+
matchesSearchTokens(row({ id: "1", isRead: true }), [isUnread]),
|
|
284
|
+
false,
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("after:/before: compare against sentDate (ms)", () => {
|
|
289
|
+
const jan15 = row({
|
|
290
|
+
id: "1",
|
|
291
|
+
sentDate: Date.parse("2024-01-15T00:00:00Z"),
|
|
292
|
+
});
|
|
293
|
+
assert.strictEqual(
|
|
294
|
+
matchesSearchTokens(jan15, [
|
|
295
|
+
after(Date.parse("2024-01-01T00:00:00Z") / 1000),
|
|
296
|
+
]),
|
|
297
|
+
true,
|
|
298
|
+
);
|
|
299
|
+
assert.strictEqual(
|
|
300
|
+
matchesSearchTokens(jan15, [
|
|
301
|
+
before(Date.parse("2024-01-01T00:00:00Z") / 1000),
|
|
302
|
+
]),
|
|
303
|
+
false,
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("a date token never matches a row with no sentDate", () => {
|
|
308
|
+
const r = row({ id: "1" });
|
|
309
|
+
assert.strictEqual(matchesSearchTokens(r, [after(0)]), false);
|
|
310
|
+
assert.strictEqual(matchesSearchTokens(r, [before(0)]), false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("in: matches on mailboxId", () => {
|
|
314
|
+
const r = row({ id: "1", mailboxId: "mb-archive" });
|
|
315
|
+
const inToken: SearchToken = {
|
|
316
|
+
type: "in",
|
|
317
|
+
raw: "in:archive",
|
|
318
|
+
value: "archive",
|
|
319
|
+
mailboxId: "mb-archive",
|
|
320
|
+
};
|
|
321
|
+
assert.strictEqual(matchesSearchTokens(r, [inToken]), true);
|
|
322
|
+
assert.strictEqual(
|
|
323
|
+
matchesSearchTokens(row({ id: "2", mailboxId: "mb-inbox" }), [inToken]),
|
|
324
|
+
false,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("account: matches on accountId", () => {
|
|
329
|
+
const r = row({ id: "1", accountId: "acc_work" });
|
|
330
|
+
const accountToken: SearchToken = {
|
|
331
|
+
type: "account",
|
|
332
|
+
raw: "account:work",
|
|
333
|
+
value: "work",
|
|
334
|
+
accountId: "acc_work",
|
|
335
|
+
};
|
|
336
|
+
assert.strictEqual(matchesSearchTokens(r, [accountToken]), true);
|
|
337
|
+
assert.strictEqual(
|
|
338
|
+
matchesSearchTokens(row({ id: "2", accountId: "acc_personal" }), [
|
|
339
|
+
accountToken,
|
|
340
|
+
]),
|
|
341
|
+
false,
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("all tokens must match (AND)", () => {
|
|
346
|
+
const r = row({
|
|
347
|
+
id: "1",
|
|
348
|
+
fromEmail: "alice@dhl.com",
|
|
349
|
+
hasAttachment: true,
|
|
350
|
+
isRead: false,
|
|
351
|
+
});
|
|
352
|
+
assert.strictEqual(
|
|
353
|
+
matchesSearchTokens(r, [from("dhl"), hasAttachment, isUnread]),
|
|
354
|
+
true,
|
|
355
|
+
);
|
|
356
|
+
assert.strictEqual(
|
|
357
|
+
matchesSearchTokens(r, [
|
|
358
|
+
from("dhl"),
|
|
359
|
+
hasAttachment,
|
|
360
|
+
isUnread,
|
|
361
|
+
from("ups"),
|
|
362
|
+
]),
|
|
363
|
+
false,
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
});
|
package/src/lib/brief.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily brief grouping logic.
|
|
3
|
+
*
|
|
4
|
+
* Pure function: takes a flat list of thread message rows and returns one
|
|
5
|
+
* section per message category, in a fixed display order:
|
|
6
|
+
*
|
|
7
|
+
* 1. Personal
|
|
8
|
+
* 2. Transactional
|
|
9
|
+
* 3. Newsletter
|
|
10
|
+
* 4. Marketing
|
|
11
|
+
* 5. Social
|
|
12
|
+
* 6. Automated
|
|
13
|
+
*
|
|
14
|
+
* Each row lands in the section for its category; a row with no category counts
|
|
15
|
+
* as `personal` (the classifier's own fallback). Starred mail is not a section —
|
|
16
|
+
* the star is a per-row marker, so a starred message stays in its category.
|
|
17
|
+
*
|
|
18
|
+
* Sender trust (vip/wellknown) no longer sections the brief — the signal is
|
|
19
|
+
* still carried on each row (see `toThreadRowData`) for future use, but it does
|
|
20
|
+
* not decide where a row lands. Read state is likewise not a routing signal: in
|
|
21
|
+
* a high-volume mailbox read≠handled and unread≠important; unread is a
|
|
22
|
+
* user-selectable filter chip instead.
|
|
23
|
+
*
|
|
24
|
+
* Muted senders (filtered by the caller) and empty sections are excluded.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
28
|
+
import { MessageCategory } from "@remit/domain-enums";
|
|
29
|
+
import type {
|
|
30
|
+
SenderTrustLevel,
|
|
31
|
+
ThreadCategory,
|
|
32
|
+
ThreadRowData,
|
|
33
|
+
ThreadSection,
|
|
34
|
+
} from "@remit/ui";
|
|
35
|
+
import { toDisplayCategory } from "./display-category.js";
|
|
36
|
+
import { formatEmailDate } from "./format.js";
|
|
37
|
+
import type { SearchToken } from "./search-tokens.js";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Map a `RemitImapThreadMessageResponse` to the `ThreadRowData` shape used by
|
|
41
|
+
* remit-ui row body components. Mirrors the logic in MessageListItem so the
|
|
42
|
+
* brief rows render identically to per-mailbox rows.
|
|
43
|
+
*/
|
|
44
|
+
export function toThreadRowData(
|
|
45
|
+
thread: RemitImapThreadMessageResponse,
|
|
46
|
+
): ThreadRowData {
|
|
47
|
+
const suspicious = thread.authenticity?.dkimMismatch === true;
|
|
48
|
+
return {
|
|
49
|
+
id: thread.messageId,
|
|
50
|
+
accountId: thread.accountId ?? thread.accountConfigId,
|
|
51
|
+
mailboxId: thread.mailboxId,
|
|
52
|
+
fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
53
|
+
fromEmail: thread.fromEmail ?? "",
|
|
54
|
+
subject: thread.subject ?? "(No subject)",
|
|
55
|
+
snippet: thread.snippet ?? "",
|
|
56
|
+
timeLabel: formatEmailDate(thread.sentDate),
|
|
57
|
+
sentDate: thread.sentDate,
|
|
58
|
+
isRead: thread.isRead,
|
|
59
|
+
hasAttachment: thread.hasAttachment,
|
|
60
|
+
starred:
|
|
61
|
+
thread.star != null && thread.star !== "none" && thread.hasStars === true,
|
|
62
|
+
trust: thread.senderTrust as SenderTrustLevel,
|
|
63
|
+
category: toDisplayCategory(thread.category),
|
|
64
|
+
suspicious,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Category sections in fixed display order. The `id`/`label` drive the rendered
|
|
70
|
+
* section; `category` is the row category that routes into it.
|
|
71
|
+
*/
|
|
72
|
+
const CATEGORY_SECTIONS: ReadonlyArray<{
|
|
73
|
+
id: string;
|
|
74
|
+
label: string;
|
|
75
|
+
category: ThreadCategory;
|
|
76
|
+
}> = [
|
|
77
|
+
{ id: "personal", label: "Personal", category: MessageCategory.personal },
|
|
78
|
+
{
|
|
79
|
+
id: "transactional",
|
|
80
|
+
label: "Transactional",
|
|
81
|
+
category: MessageCategory.transactional,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "newsletter",
|
|
85
|
+
label: "Newsletter",
|
|
86
|
+
category: MessageCategory.newsletter,
|
|
87
|
+
},
|
|
88
|
+
{ id: "marketing", label: "Marketing", category: MessageCategory.marketing },
|
|
89
|
+
{ id: "social", label: "Social", category: MessageCategory.social },
|
|
90
|
+
{ id: "automated", label: "Automated", category: MessageCategory.automated },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Group a flat list of thread row data into one section per message category.
|
|
95
|
+
* Rows should already be filtered for the selected account chip (if any) before
|
|
96
|
+
* calling this function.
|
|
97
|
+
*
|
|
98
|
+
* @param rows Flat array of ThreadRowData, sorted newest-first.
|
|
99
|
+
* @returns Array of ThreadSection in category display order — empty
|
|
100
|
+
* sections are omitted.
|
|
101
|
+
*/
|
|
102
|
+
export function groupBriefSections(rows: ThreadRowData[]): ThreadSection[] {
|
|
103
|
+
const byCategory = new Map<string, ThreadRowData[]>(
|
|
104
|
+
CATEGORY_SECTIONS.map((s) => [s.category, []]),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
const category = row.category ?? MessageCategory.personal;
|
|
109
|
+
const bucket =
|
|
110
|
+
byCategory.get(category) ?? byCategory.get(MessageCategory.personal);
|
|
111
|
+
bucket?.push(row);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sections: ThreadSection[] = [];
|
|
115
|
+
for (const section of CATEGORY_SECTIONS) {
|
|
116
|
+
const threads = byCategory.get(section.category);
|
|
117
|
+
if (threads && threads.length > 0)
|
|
118
|
+
sections.push({
|
|
119
|
+
id: section.id,
|
|
120
|
+
label: section.label,
|
|
121
|
+
threads,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return sections;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns true when `t` matches the free-text `query` (lower-cased).
|
|
129
|
+
* Checked against fromName, fromEmail, subject, and snippet.
|
|
130
|
+
*/
|
|
131
|
+
export function matchesBriefSearch(t: ThreadRowData, query: string): boolean {
|
|
132
|
+
return (
|
|
133
|
+
t.fromName.toLowerCase().includes(query) ||
|
|
134
|
+
t.fromEmail.toLowerCase().includes(query) ||
|
|
135
|
+
t.subject.toLowerCase().includes(query) ||
|
|
136
|
+
t.snippet.toLowerCase().includes(query)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns true when `t` satisfies every parsed filter token — the client-side
|
|
142
|
+
* counterpart of the params `threadOperationsSearchThreads` accepts, applied
|
|
143
|
+
* over the already-loaded brief/flagged rows (#428). A row without the data a
|
|
144
|
+
* token needs (e.g. no `sentDate` for `before:`/`after:`) never matches that
|
|
145
|
+
* token, so it drops out rather than showing under an unverifiable filter.
|
|
146
|
+
* `in:`/`account:` have no server param at all — this is the entire
|
|
147
|
+
* implementation of both, reusing the same per-account fan-out the daily
|
|
148
|
+
* brief already reads its rows from (`accountId`/`mailboxId` are already on
|
|
149
|
+
* every row).
|
|
150
|
+
*/
|
|
151
|
+
export function matchesSearchTokens(
|
|
152
|
+
t: ThreadRowData,
|
|
153
|
+
tokens: SearchToken[],
|
|
154
|
+
): boolean {
|
|
155
|
+
return tokens.every((token) => {
|
|
156
|
+
switch (token.type) {
|
|
157
|
+
case "from": {
|
|
158
|
+
const needle = token.value.toLowerCase();
|
|
159
|
+
return (
|
|
160
|
+
t.fromEmail.toLowerCase().includes(needle) ||
|
|
161
|
+
t.fromName.toLowerCase().includes(needle)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
case "hasAttachment":
|
|
165
|
+
return t.hasAttachment === true;
|
|
166
|
+
case "isUnread":
|
|
167
|
+
return !t.isRead;
|
|
168
|
+
case "after":
|
|
169
|
+
return t.sentDate != null && t.sentDate >= token.epochSeconds * 1000;
|
|
170
|
+
case "before":
|
|
171
|
+
return t.sentDate != null && t.sentDate < token.epochSeconds * 1000;
|
|
172
|
+
case "in":
|
|
173
|
+
return t.mailboxId === token.mailboxId;
|
|
174
|
+
case "account":
|
|
175
|
+
return t.accountId === token.accountId;
|
|
176
|
+
default:
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|