@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,123 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { resolveSimilarState, resolveSpamAction } from "./IntelligencePane";
|
|
4
|
+
|
|
5
|
+
const junkId = "junk-mailbox";
|
|
6
|
+
const inboxId = "inbox-mailbox";
|
|
7
|
+
|
|
8
|
+
describe("resolveSpamAction (#594)", () => {
|
|
9
|
+
test("offers Not spam when the message is in Junk and Inbox is resolved", () => {
|
|
10
|
+
assert.equal(
|
|
11
|
+
resolveSpamAction({
|
|
12
|
+
mailboxId: junkId,
|
|
13
|
+
junkMailboxId: junkId,
|
|
14
|
+
inboxMailboxId: inboxId,
|
|
15
|
+
}),
|
|
16
|
+
"notSpam",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("offers Mark spam when the message is outside Junk and Junk is resolved", () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
resolveSpamAction({
|
|
23
|
+
mailboxId: inboxId,
|
|
24
|
+
junkMailboxId: junkId,
|
|
25
|
+
inboxMailboxId: inboxId,
|
|
26
|
+
}),
|
|
27
|
+
"markSpam",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("offers nothing when the move source mailbox is unknown", () => {
|
|
32
|
+
assert.equal(
|
|
33
|
+
resolveSpamAction({
|
|
34
|
+
mailboxId: undefined,
|
|
35
|
+
junkMailboxId: junkId,
|
|
36
|
+
inboxMailboxId: inboxId,
|
|
37
|
+
}),
|
|
38
|
+
null,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("offers nothing in Junk when the Inbox target hasn't loaded", () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
resolveSpamAction({
|
|
45
|
+
mailboxId: junkId,
|
|
46
|
+
junkMailboxId: junkId,
|
|
47
|
+
inboxMailboxId: undefined,
|
|
48
|
+
}),
|
|
49
|
+
null,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("offers nothing outside Junk when the Junk target hasn't loaded", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
resolveSpamAction({
|
|
56
|
+
mailboxId: inboxId,
|
|
57
|
+
junkMailboxId: undefined,
|
|
58
|
+
inboxMailboxId: inboxId,
|
|
59
|
+
}),
|
|
60
|
+
null,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("the two actions are mutually exclusive", () => {
|
|
65
|
+
const inJunk = resolveSpamAction({
|
|
66
|
+
mailboxId: junkId,
|
|
67
|
+
junkMailboxId: junkId,
|
|
68
|
+
inboxMailboxId: inboxId,
|
|
69
|
+
});
|
|
70
|
+
const elsewhere = resolveSpamAction({
|
|
71
|
+
mailboxId: inboxId,
|
|
72
|
+
junkMailboxId: junkId,
|
|
73
|
+
inboxMailboxId: inboxId,
|
|
74
|
+
});
|
|
75
|
+
assert.notEqual(inJunk, elsewhere);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("resolveSimilarState — fail-fast on similar-messages search", () => {
|
|
80
|
+
test("a fatal 5xx does NOT show the grey label — it escalates instead (ready)", () => {
|
|
81
|
+
assert.equal(
|
|
82
|
+
resolveSimilarState({
|
|
83
|
+
similarError: new Error("500"),
|
|
84
|
+
similarErrorIsFatal: true,
|
|
85
|
+
isSimilarLoading: false,
|
|
86
|
+
}),
|
|
87
|
+
"ready",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a soft (non-fatal) error keeps the muted error state", () => {
|
|
92
|
+
assert.equal(
|
|
93
|
+
resolveSimilarState({
|
|
94
|
+
similarError: new Error("404"),
|
|
95
|
+
similarErrorIsFatal: false,
|
|
96
|
+
isSimilarLoading: false,
|
|
97
|
+
}),
|
|
98
|
+
"error",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("an empty result (no error) is a soft ready state", () => {
|
|
103
|
+
assert.equal(
|
|
104
|
+
resolveSimilarState({
|
|
105
|
+
similarError: null,
|
|
106
|
+
similarErrorIsFatal: false,
|
|
107
|
+
isSimilarLoading: false,
|
|
108
|
+
}),
|
|
109
|
+
"ready",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("loading wins when there is no error", () => {
|
|
114
|
+
assert.equal(
|
|
115
|
+
resolveSimilarState({
|
|
116
|
+
similarError: null,
|
|
117
|
+
similarErrorIsFatal: false,
|
|
118
|
+
isSimilarLoading: true,
|
|
119
|
+
}),
|
|
120
|
+
"loading",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import {
|
|
3
|
+
IntelligencePanel,
|
|
4
|
+
type IntelligenceQuickActions,
|
|
5
|
+
type SimilarMessageLinkComponent,
|
|
6
|
+
type SimilarState,
|
|
7
|
+
} from "@remit/ui";
|
|
8
|
+
import { Link } from "@tanstack/react-router";
|
|
9
|
+
import { Sparkles } from "lucide-react";
|
|
10
|
+
import { useCallback, useState } from "react";
|
|
11
|
+
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
12
|
+
import { useInboxMailbox, useJunkMailbox } from "@/hooks/useArchiveMailbox";
|
|
13
|
+
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
14
|
+
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
15
|
+
import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
16
|
+
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
17
|
+
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
18
|
+
import { useTelemetry } from "@/lib/telemetry-context";
|
|
19
|
+
|
|
20
|
+
export interface IntelligencePaneProps {
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* The currently selected thread row. When present, the real
|
|
24
|
+
* `IntelligencePanel` renders with wired data; when absent the pane shows
|
|
25
|
+
* the empty-state placeholder.
|
|
26
|
+
*/
|
|
27
|
+
thread?: RemitImapThreadMessageResponse;
|
|
28
|
+
/** The mailbox the message list is currently showing — the move source. */
|
|
29
|
+
mailboxId?: string;
|
|
30
|
+
/** Account that owns `mailboxId`; resolves the Junk/Inbox move targets. */
|
|
31
|
+
accountId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Hide the panel's own close (X). Set when hosted inside the mobile Drawer,
|
|
34
|
+
* whose header already renders a close button — one way back, not two (#874).
|
|
35
|
+
*/
|
|
36
|
+
hideCloseButton?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Category-override values accepted by the `AddressFlags.category` override
|
|
41
|
+
* (PATCH /addresses/{id}). Matches `MessageCategory` — the full taxonomy the
|
|
42
|
+
* user can assign as a sender-level override.
|
|
43
|
+
*/
|
|
44
|
+
const CATEGORY_OVERRIDES = [
|
|
45
|
+
"personal",
|
|
46
|
+
"newsletter",
|
|
47
|
+
"marketing",
|
|
48
|
+
"automated",
|
|
49
|
+
"transactional",
|
|
50
|
+
"social",
|
|
51
|
+
] as const;
|
|
52
|
+
type CategoryOverride = (typeof CATEGORY_OVERRIDES)[number];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decide which spam quick-action to offer for the current message (issue #594).
|
|
56
|
+
*
|
|
57
|
+
* The two buttons are symmetric and mutually exclusive:
|
|
58
|
+
* - In the Junk mailbox → offer **Not spam** (move out, promote sender), but
|
|
59
|
+
* only when an Inbox destination is resolved.
|
|
60
|
+
* - Anywhere else → offer **Mark spam** (move in, demote sender), but only when
|
|
61
|
+
* the Junk destination is resolved.
|
|
62
|
+
*
|
|
63
|
+
* Returns `"notSpam"`, `"markSpam"`, or `null` (no actionable button — e.g. the
|
|
64
|
+
* move source isn't known, or the needed target mailbox hasn't loaded). Pure so
|
|
65
|
+
* the wiring decision can be unit-tested without rendering.
|
|
66
|
+
*/
|
|
67
|
+
export const resolveSpamAction = (input: {
|
|
68
|
+
mailboxId?: string;
|
|
69
|
+
junkMailboxId?: string;
|
|
70
|
+
inboxMailboxId?: string;
|
|
71
|
+
}): "notSpam" | "markSpam" | null => {
|
|
72
|
+
const { mailboxId, junkMailboxId, inboxMailboxId } = input;
|
|
73
|
+
if (!mailboxId) return null;
|
|
74
|
+
const isInJunk = Boolean(junkMailboxId && mailboxId === junkMailboxId);
|
|
75
|
+
if (isInJunk) return inboxMailboxId ? "notSpam" : null;
|
|
76
|
+
return junkMailboxId ? "markSpam" : null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide the "Similar messages" section state from the semantic-search query.
|
|
81
|
+
*
|
|
82
|
+
* The fail-fast rule: a fatal first-party 5xx must NEVER degrade to the benign
|
|
83
|
+
* grey "Similarity search unavailable" label — it escalates to the global red
|
|
84
|
+
* overlay instead, so this returns `"ready"` for it (the section then renders
|
|
85
|
+
* nothing rather than a misleading soft state). A soft (non-fatal) failure
|
|
86
|
+
* keeps the muted `"error"` state; an empty result is `"ready"` (no error).
|
|
87
|
+
* Pure so the classification can be unit-tested without rendering.
|
|
88
|
+
*/
|
|
89
|
+
export const resolveSimilarState = (input: {
|
|
90
|
+
similarError: unknown;
|
|
91
|
+
similarErrorIsFatal: boolean;
|
|
92
|
+
isSimilarLoading: boolean;
|
|
93
|
+
}): SimilarState => {
|
|
94
|
+
const { similarError, similarErrorIsFatal, isSimilarLoading } = input;
|
|
95
|
+
if (similarError && !similarErrorIsFatal) return "error";
|
|
96
|
+
if (isSimilarLoading) return "loading";
|
|
97
|
+
return "ready";
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function IntelligenceSkeleton() {
|
|
101
|
+
return (
|
|
102
|
+
<aside className="flex h-full w-full flex-col bg-surface-sunken">
|
|
103
|
+
<header className="flex h-pane-header shrink-0 items-center gap-1.5 border-b border-line px-row-inset">
|
|
104
|
+
<Sparkles className="size-3.5 text-fg-subtle" />
|
|
105
|
+
<span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
106
|
+
Intelligence
|
|
107
|
+
</span>
|
|
108
|
+
</header>
|
|
109
|
+
<div className="flex-1 animate-pulse space-y-4 p-4">
|
|
110
|
+
<div className="space-y-2">
|
|
111
|
+
<div className="h-2.5 rounded bg-surface w-1/4" />
|
|
112
|
+
<div className="flex gap-3">
|
|
113
|
+
<div className="size-10 rounded-full bg-surface shrink-0" />
|
|
114
|
+
<div className="flex-1 space-y-1.5">
|
|
115
|
+
<div className="h-3 rounded bg-surface w-3/4" />
|
|
116
|
+
<div className="h-2.5 rounded bg-surface w-1/2" />
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="space-y-2">
|
|
121
|
+
<div className="h-2.5 rounded bg-surface w-1/4" />
|
|
122
|
+
<div className="h-8 rounded bg-surface" />
|
|
123
|
+
</div>
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<div className="h-2.5 rounded bg-surface w-1/4" />
|
|
126
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
127
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
128
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
|
|
129
|
+
<div key={i} className="h-6 w-16 rounded bg-surface" />
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</aside>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reclassify picker: a small modal listing the category-override options. On
|
|
140
|
+
* selection it PATCHes `AddressFlags.category` for the sender.
|
|
141
|
+
*/
|
|
142
|
+
function ReclassifyDialog({
|
|
143
|
+
isOpen,
|
|
144
|
+
current,
|
|
145
|
+
onSelect,
|
|
146
|
+
onCancel,
|
|
147
|
+
}: {
|
|
148
|
+
isOpen: boolean;
|
|
149
|
+
current: string;
|
|
150
|
+
onSelect: (category: CategoryOverride) => void;
|
|
151
|
+
onCancel: () => void;
|
|
152
|
+
}) {
|
|
153
|
+
if (!isOpen) return null;
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
onClick={onCancel}
|
|
159
|
+
>
|
|
160
|
+
<div className="absolute inset-0 bg-canvas/80 backdrop-blur-sm" />
|
|
161
|
+
<div
|
|
162
|
+
role="dialog"
|
|
163
|
+
aria-modal="true"
|
|
164
|
+
aria-label="Reclassify sender"
|
|
165
|
+
className="relative z-10 w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg"
|
|
166
|
+
onClick={(e) => e.stopPropagation()}
|
|
167
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
168
|
+
>
|
|
169
|
+
<h2 className="text-lg font-semibold">Reclassify this sender</h2>
|
|
170
|
+
<p className="mt-2 text-sm text-fg-muted">
|
|
171
|
+
Set a category override for future messages from this sender.
|
|
172
|
+
</p>
|
|
173
|
+
<div className="mt-4 flex flex-col gap-1">
|
|
174
|
+
{CATEGORY_OVERRIDES.map((cat) => (
|
|
175
|
+
<button
|
|
176
|
+
key={cat}
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={() => onSelect(cat)}
|
|
179
|
+
className={`flex min-h-11 items-center justify-between rounded px-3 text-left text-sm capitalize transition-colors hover:bg-surface-raised ${
|
|
180
|
+
cat === current ? "font-semibold text-accent" : "text-fg"
|
|
181
|
+
}`}
|
|
182
|
+
>
|
|
183
|
+
{cat}
|
|
184
|
+
{cat === current && (
|
|
185
|
+
<span className="text-2xs text-fg-subtle">current</span>
|
|
186
|
+
)}
|
|
187
|
+
</button>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
<div className="mt-6 flex justify-end">
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={onCancel}
|
|
194
|
+
className="inline-flex min-h-11 items-center justify-center rounded border border-line px-4 text-sm font-medium transition-colors hover:bg-surface-raised"
|
|
195
|
+
>
|
|
196
|
+
Cancel
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface WiredPanelProps {
|
|
205
|
+
thread: RemitImapThreadMessageResponse;
|
|
206
|
+
onClose: () => void;
|
|
207
|
+
mailboxId?: string;
|
|
208
|
+
accountId?: string;
|
|
209
|
+
hideCloseButton?: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Inner panel that resolves intelligence data and wires quick-action mutations.
|
|
214
|
+
*/
|
|
215
|
+
function WiredPanel({
|
|
216
|
+
thread,
|
|
217
|
+
onClose,
|
|
218
|
+
mailboxId,
|
|
219
|
+
accountId,
|
|
220
|
+
hideCloseButton,
|
|
221
|
+
}: WiredPanelProps) {
|
|
222
|
+
const {
|
|
223
|
+
data,
|
|
224
|
+
addressId,
|
|
225
|
+
isSimilarLoading,
|
|
226
|
+
similarError,
|
|
227
|
+
similarErrorIsFatal,
|
|
228
|
+
} = useIntelligenceData(thread, mailboxId);
|
|
229
|
+
const [confirmBlock, setConfirmBlock] = useState(false);
|
|
230
|
+
const [reclassifyOpen, setReclassifyOpen] = useState(false);
|
|
231
|
+
const senderEmail = thread.fromEmail ?? undefined;
|
|
232
|
+
|
|
233
|
+
const { updateFlags, isPending } = useUpdateAddressFlags({
|
|
234
|
+
addressId,
|
|
235
|
+
senderEmail,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// "Not spam" / "Mark spam" move the message across the Junk boundary; the
|
|
239
|
+
// backend's moveMessage then promotes (out of Junk) or demotes (into Junk)
|
|
240
|
+
// the sender's trust (issue #594). Only one button is wired at a time,
|
|
241
|
+
// depending on whether the message is currently sitting in Junk.
|
|
242
|
+
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
243
|
+
const { inboxMailboxId } = useInboxMailbox(accountId);
|
|
244
|
+
const { moveMessages } = useMoveMessages({
|
|
245
|
+
mailboxId: mailboxId ?? "",
|
|
246
|
+
threadId: thread.threadId,
|
|
247
|
+
accountId,
|
|
248
|
+
});
|
|
249
|
+
const telemetry = useTelemetry();
|
|
250
|
+
|
|
251
|
+
const spamAction = resolveSpamAction({
|
|
252
|
+
mailboxId,
|
|
253
|
+
junkMailboxId,
|
|
254
|
+
inboxMailboxId,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const handleNotSpam = useCallback(() => {
|
|
258
|
+
if (!inboxMailboxId) return;
|
|
259
|
+
moveMessages([thread.messageId], inboxMailboxId);
|
|
260
|
+
}, [inboxMailboxId, moveMessages, thread.messageId]);
|
|
261
|
+
|
|
262
|
+
const handleMarkSpam = useCallback(() => {
|
|
263
|
+
if (!junkMailboxId) return;
|
|
264
|
+
recordRescueSentToJunk(telemetry, {
|
|
265
|
+
count: 1,
|
|
266
|
+
senderTrust: thread.senderTrust,
|
|
267
|
+
wasRescuable: isRescueCandidate(thread),
|
|
268
|
+
});
|
|
269
|
+
moveMessages([thread.messageId], junkMailboxId);
|
|
270
|
+
}, [junkMailboxId, moveMessages, thread, telemetry]);
|
|
271
|
+
|
|
272
|
+
const handleShowSimilar = useCallback(() => {
|
|
273
|
+
// The similar-messages section scrolls into view automatically when
|
|
274
|
+
// data.similar is populated. This handler is for the "N similar" button
|
|
275
|
+
// inside the authenticity panel; a scroll-to implementation can be added
|
|
276
|
+
// in a future pass without breaking the contract.
|
|
277
|
+
}, []);
|
|
278
|
+
|
|
279
|
+
const handleToggleVip = useCallback(() => {
|
|
280
|
+
const next = !(data?.flags?.vip === true);
|
|
281
|
+
updateFlags({ vip: { value: next } });
|
|
282
|
+
}, [data?.flags?.vip, updateFlags]);
|
|
283
|
+
|
|
284
|
+
const handleToggleMute = useCallback(() => {
|
|
285
|
+
const next = !(data?.flags?.muted === true);
|
|
286
|
+
updateFlags({ muted: { value: next } });
|
|
287
|
+
}, [data?.flags?.muted, updateFlags]);
|
|
288
|
+
|
|
289
|
+
const handleToggleBlock = useCallback(() => {
|
|
290
|
+
if (data?.flags?.blocked === true) {
|
|
291
|
+
// Unblock — no confirm needed
|
|
292
|
+
updateFlags({ blocked: { value: false } });
|
|
293
|
+
} else {
|
|
294
|
+
setConfirmBlock(true);
|
|
295
|
+
}
|
|
296
|
+
}, [data?.flags?.blocked, updateFlags]);
|
|
297
|
+
|
|
298
|
+
const handleBlockConfirm = useCallback(() => {
|
|
299
|
+
setConfirmBlock(false);
|
|
300
|
+
updateFlags({ blocked: { value: true } });
|
|
301
|
+
}, [updateFlags]);
|
|
302
|
+
|
|
303
|
+
const handleToggleUnsubscribe = useCallback(() => {
|
|
304
|
+
const next = !(data?.flags?.unsubscribed === true);
|
|
305
|
+
updateFlags({ unsubscribed: { value: next } });
|
|
306
|
+
}, [data?.flags?.unsubscribed, updateFlags]);
|
|
307
|
+
|
|
308
|
+
const handleReclassifySelect = useCallback(
|
|
309
|
+
(category: CategoryOverride) => {
|
|
310
|
+
setReclassifyOpen(false);
|
|
311
|
+
updateFlags({ category: { value: category } });
|
|
312
|
+
},
|
|
313
|
+
[updateFlags],
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// Per-sender flag toggles are always wired. They stay active even before the
|
|
317
|
+
// address record resolves: `updateFlags` surfaces feedback when `addressId`
|
|
318
|
+
// is missing rather than letting the button look active but do nothing.
|
|
319
|
+
const actions: IntelligenceQuickActions = {
|
|
320
|
+
onToggleVip: handleToggleVip,
|
|
321
|
+
onToggleMute: handleToggleMute,
|
|
322
|
+
onToggleBlock: handleToggleBlock,
|
|
323
|
+
onToggleUnsubscribe: handleToggleUnsubscribe,
|
|
324
|
+
onReclassify: () => setReclassifyOpen(true),
|
|
325
|
+
onNotSpam: spamAction === "notSpam" ? handleNotSpam : undefined,
|
|
326
|
+
onMarkSpam: spamAction === "markSpam" ? handleMarkSpam : undefined,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// Each similar-message row is a real router anchor — same link shape as a
|
|
330
|
+
// message-list row — so it opens the message in the reading pane while
|
|
331
|
+
// preserving deep-linking, middle-click, and existing search params.
|
|
332
|
+
const similarLinkComponent: SimilarMessageLinkComponent = ({
|
|
333
|
+
mailboxId: rowMailboxId,
|
|
334
|
+
messageId,
|
|
335
|
+
className,
|
|
336
|
+
ariaLabel,
|
|
337
|
+
children,
|
|
338
|
+
}) => (
|
|
339
|
+
<Link
|
|
340
|
+
to="/mail/$mailboxId"
|
|
341
|
+
params={{ mailboxId: rowMailboxId }}
|
|
342
|
+
search={(prev) => ({ ...prev, selectedMessageId: messageId })}
|
|
343
|
+
className={className}
|
|
344
|
+
aria-label={ariaLabel}
|
|
345
|
+
>
|
|
346
|
+
{children}
|
|
347
|
+
</Link>
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
if (!data) {
|
|
351
|
+
return <IntelligenceSkeleton />;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const similarState = resolveSimilarState({
|
|
355
|
+
similarError,
|
|
356
|
+
similarErrorIsFatal,
|
|
357
|
+
isSimilarLoading,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<>
|
|
362
|
+
<IntelligencePanel
|
|
363
|
+
data={data}
|
|
364
|
+
onClose={onClose}
|
|
365
|
+
hideCloseButton={hideCloseButton}
|
|
366
|
+
onShowSimilar={handleShowSimilar}
|
|
367
|
+
actions={actions}
|
|
368
|
+
similarState={similarState}
|
|
369
|
+
similarLinkComponent={similarLinkComponent}
|
|
370
|
+
// No left border: the ResizableHandle to our left already draws the
|
|
371
|
+
// hairline seam. The remit-ui IntelligencePanel default `border-l`
|
|
372
|
+
// would double it to 2px.
|
|
373
|
+
className="border-l-0 h-full w-full"
|
|
374
|
+
/>
|
|
375
|
+
<ConfirmDialog
|
|
376
|
+
isOpen={confirmBlock}
|
|
377
|
+
title="Block this sender?"
|
|
378
|
+
description={`Messages from ${senderEmail ?? "this sender"} will never load images and will be flagged. You can undo this in Settings → Senders.`}
|
|
379
|
+
confirmLabel="Block sender"
|
|
380
|
+
destructive
|
|
381
|
+
isBusy={isPending}
|
|
382
|
+
onConfirm={handleBlockConfirm}
|
|
383
|
+
onCancel={() => setConfirmBlock(false)}
|
|
384
|
+
/>
|
|
385
|
+
<ReclassifyDialog
|
|
386
|
+
isOpen={reclassifyOpen}
|
|
387
|
+
current={data.category.value}
|
|
388
|
+
onSelect={handleReclassifySelect}
|
|
389
|
+
onCancel={() => setReclassifyOpen(false)}
|
|
390
|
+
/>
|
|
391
|
+
</>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Pane 4 of the 4-pane shell: the intelligence sidebar. Wired to real data via
|
|
397
|
+
* `useIntelligenceData`; renders the `IntelligencePanel` from `@remit/ui`.
|
|
398
|
+
*
|
|
399
|
+
* Data flow:
|
|
400
|
+
* - Sender basics + trust + authenticity + category: from the thread row (instant,
|
|
401
|
+
* already loaded by the message list).
|
|
402
|
+
* - Address flags (VIP/muted/blocked/etc.) + first-seen: `GET /addresses/search`.
|
|
403
|
+
* - Similar messages: `GET /search/semantic` (last; panel still renders on failure).
|
|
404
|
+
*/
|
|
405
|
+
export const IntelligencePane = ({
|
|
406
|
+
onClose,
|
|
407
|
+
thread,
|
|
408
|
+
mailboxId,
|
|
409
|
+
accountId,
|
|
410
|
+
hideCloseButton,
|
|
411
|
+
}: IntelligencePaneProps) => {
|
|
412
|
+
if (!thread) {
|
|
413
|
+
return (
|
|
414
|
+
<aside className="flex h-full w-full flex-col bg-surface-sunken">
|
|
415
|
+
<header className="flex h-pane-header shrink-0 items-center gap-1.5 border-b border-line px-row-inset">
|
|
416
|
+
<Sparkles className="size-3.5 text-fg-subtle" />
|
|
417
|
+
<span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
418
|
+
Intelligence
|
|
419
|
+
</span>
|
|
420
|
+
</header>
|
|
421
|
+
<div className="flex flex-1 flex-col items-center justify-center px-row-inset text-center">
|
|
422
|
+
<Sparkles className="size-8 text-fg-subtle" />
|
|
423
|
+
<p className="mt-3 text-sm text-fg-muted">Intelligence</p>
|
|
424
|
+
<p className="mt-1 text-2xs text-fg-subtle">
|
|
425
|
+
Sender trust, authenticity and similar messages will appear here.
|
|
426
|
+
</p>
|
|
427
|
+
</div>
|
|
428
|
+
</aside>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<WiredPanel
|
|
434
|
+
thread={thread}
|
|
435
|
+
onClose={onClose}
|
|
436
|
+
mailboxId={mailboxId}
|
|
437
|
+
accountId={accountId}
|
|
438
|
+
hideCloseButton={hideCloseButton}
|
|
439
|
+
/>
|
|
440
|
+
);
|
|
441
|
+
};
|