@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,81 @@
|
|
|
1
|
+
import type { ServerSettings } from "./autodiscovery.js";
|
|
2
|
+
|
|
3
|
+
export interface ProviderPreset {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
username: "full-email";
|
|
7
|
+
imap: ServerSettings;
|
|
8
|
+
smtp: ServerSettings;
|
|
9
|
+
passwordHelp: { text: string; url: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|
13
|
+
{
|
|
14
|
+
id: "icloud",
|
|
15
|
+
label: "iCloud",
|
|
16
|
+
username: "full-email",
|
|
17
|
+
imap: { host: "imap.mail.me.com", port: 993, security: "tls" },
|
|
18
|
+
smtp: { host: "smtp.mail.me.com", port: 587, security: "starttls" },
|
|
19
|
+
passwordHelp: {
|
|
20
|
+
text: "iCloud requires an app-specific password, not your Apple ID password. Generate one under Sign-In & Security → App-Specific Passwords.",
|
|
21
|
+
url: "https://support.apple.com/en-us/102654",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "yahoo",
|
|
26
|
+
label: "Yahoo",
|
|
27
|
+
username: "full-email",
|
|
28
|
+
imap: { host: "imap.mail.yahoo.com", port: 993, security: "tls" },
|
|
29
|
+
smtp: { host: "smtp.mail.yahoo.com", port: 465, security: "tls" },
|
|
30
|
+
passwordHelp: {
|
|
31
|
+
text: "Yahoo requires an app password, not your login password. Generate one under Account Security.",
|
|
32
|
+
url: "https://help.yahoo.com/kb/generate-manage-third-party-passwords-sln15241.html",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "aol",
|
|
37
|
+
label: "AOL",
|
|
38
|
+
username: "full-email",
|
|
39
|
+
imap: { host: "imap.aol.com", port: 993, security: "tls" },
|
|
40
|
+
smtp: { host: "smtp.aol.com", port: 465, security: "tls" },
|
|
41
|
+
passwordHelp: {
|
|
42
|
+
text: "AOL requires an app password, not your login password. Generate one under Account Security.",
|
|
43
|
+
url: "https://login.aol.com/account/security",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "fastmail",
|
|
48
|
+
label: "Fastmail",
|
|
49
|
+
username: "full-email",
|
|
50
|
+
imap: { host: "imap.fastmail.com", port: 993, security: "tls" },
|
|
51
|
+
smtp: { host: "smtp.fastmail.com", port: 465, security: "tls" },
|
|
52
|
+
passwordHelp: {
|
|
53
|
+
text: "Fastmail requires an app password, not your login password. Create one under Settings → Privacy & Security → App passwords.",
|
|
54
|
+
url: "https://www.fastmail.help/hc/en-us/articles/360058752854",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export function getPresetById(id: string): ProviderPreset | undefined {
|
|
60
|
+
return PROVIDER_PRESETS.find((preset) => preset.id === id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DOMAIN_TO_PRESET: Record<string, string> = {
|
|
64
|
+
"icloud.com": "icloud",
|
|
65
|
+
"me.com": "icloud",
|
|
66
|
+
"mac.com": "icloud",
|
|
67
|
+
"yahoo.com": "yahoo",
|
|
68
|
+
"yahoo.co.uk": "yahoo",
|
|
69
|
+
"ymail.com": "yahoo",
|
|
70
|
+
"aol.com": "aol",
|
|
71
|
+
"fastmail.com": "fastmail",
|
|
72
|
+
"fastmail.fm": "fastmail",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Pre-select a provider preset from a known email domain; "" for Custom. */
|
|
76
|
+
export function presetIdForEmail(email: string): string {
|
|
77
|
+
const atIdx = email.indexOf("@");
|
|
78
|
+
if (atIdx === -1) return "";
|
|
79
|
+
const domain = email.slice(atIdx + 1).toLowerCase();
|
|
80
|
+
return DOMAIN_TO_PRESET[domain] ?? "";
|
|
81
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import type { Mutation, Query } from "@tanstack/react-query";
|
|
4
|
+
import { ApiError } from "./api";
|
|
5
|
+
import { __resetFatalError, subscribeFatalError } from "./fatal-error";
|
|
6
|
+
import {
|
|
7
|
+
handleMutationCacheError,
|
|
8
|
+
handleQueryCacheError,
|
|
9
|
+
} from "./query-error-handler";
|
|
10
|
+
|
|
11
|
+
const fakeQuery = (
|
|
12
|
+
meta?: Record<string, unknown>,
|
|
13
|
+
dataUpdatedAt = 0,
|
|
14
|
+
): Query<unknown, unknown, unknown> =>
|
|
15
|
+
({ meta, state: { dataUpdatedAt } }) as unknown as Query<
|
|
16
|
+
unknown,
|
|
17
|
+
unknown,
|
|
18
|
+
unknown
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
const fakeMutation = (
|
|
22
|
+
meta?: Record<string, unknown>,
|
|
23
|
+
): Mutation<unknown, unknown, unknown> =>
|
|
24
|
+
({ meta }) as unknown as Mutation<unknown, unknown, unknown>;
|
|
25
|
+
|
|
26
|
+
const abortError = (): Error => {
|
|
27
|
+
const error = new Error("aborted");
|
|
28
|
+
error.name = "AbortError";
|
|
29
|
+
return error;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const escalations = (): string[] => {
|
|
33
|
+
const seen: string[] = [];
|
|
34
|
+
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
35
|
+
return seen;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
__resetFatalError();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("handleQueryCacheError (fail-fast contract #1059)", () => {
|
|
43
|
+
it("escalates a 5xx that breaks the INITIAL load", () => {
|
|
44
|
+
const seen = escalations();
|
|
45
|
+
handleQueryCacheError(
|
|
46
|
+
new ApiError("internal", 500),
|
|
47
|
+
fakeQuery(undefined, 0),
|
|
48
|
+
);
|
|
49
|
+
assert.deepEqual(seen, ["internal"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("escalates a 5xx on a background REFETCH (the deleted dataUpdatedAt guard — the headline bug)", () => {
|
|
53
|
+
const seen = escalations();
|
|
54
|
+
// dataUpdatedAt !== 0 used to swallow this (PR #758). It must now escalate.
|
|
55
|
+
handleQueryCacheError(
|
|
56
|
+
new ApiError("internal", 500),
|
|
57
|
+
fakeQuery(undefined, 123456),
|
|
58
|
+
);
|
|
59
|
+
assert.deepEqual(seen, ["internal"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("escalates a 5xx EVEN when the query is marked meta.softError (rule 2 wins)", () => {
|
|
63
|
+
const seen = escalations();
|
|
64
|
+
handleQueryCacheError(
|
|
65
|
+
new ApiError("internal", 500),
|
|
66
|
+
fakeQuery({ softError: true }, 123456),
|
|
67
|
+
);
|
|
68
|
+
assert.deepEqual(seen, ["internal"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("escalates a 4xx by DEFAULT (no opt-out)", () => {
|
|
72
|
+
const seen = escalations();
|
|
73
|
+
handleQueryCacheError(new ApiError("not found", 404), fakeQuery());
|
|
74
|
+
assert.deepEqual(seen, ["not found"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does NOT escalate a 4xx the query opted out of via meta.softError", () => {
|
|
78
|
+
let escalated = false;
|
|
79
|
+
subscribeFatalError(() => {
|
|
80
|
+
escalated = true;
|
|
81
|
+
});
|
|
82
|
+
handleQueryCacheError(
|
|
83
|
+
new ApiError("not found", 404),
|
|
84
|
+
fakeQuery({ softError: true }),
|
|
85
|
+
);
|
|
86
|
+
assert.equal(escalated, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does NOT escalate an aborted request", () => {
|
|
90
|
+
let escalated = false;
|
|
91
|
+
subscribeFatalError(() => {
|
|
92
|
+
escalated = true;
|
|
93
|
+
});
|
|
94
|
+
handleQueryCacheError(abortError(), fakeQuery());
|
|
95
|
+
assert.equal(escalated, false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does NOT escalate a statusless network blip", () => {
|
|
99
|
+
let escalated = false;
|
|
100
|
+
subscribeFatalError(() => {
|
|
101
|
+
escalated = true;
|
|
102
|
+
});
|
|
103
|
+
handleQueryCacheError(new TypeError("Failed to fetch"), fakeQuery());
|
|
104
|
+
assert.equal(escalated, false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("handleMutationCacheError (fail-fast contract #1059)", () => {
|
|
109
|
+
it("escalates a mutation 5xx", () => {
|
|
110
|
+
const seen = escalations();
|
|
111
|
+
handleMutationCacheError(
|
|
112
|
+
new ApiError("mutation blew up", 503),
|
|
113
|
+
undefined,
|
|
114
|
+
undefined,
|
|
115
|
+
fakeMutation(),
|
|
116
|
+
);
|
|
117
|
+
assert.deepEqual(seen, ["mutation blew up"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("escalates a mutation 4xx by DEFAULT", () => {
|
|
121
|
+
const seen = escalations();
|
|
122
|
+
handleMutationCacheError(
|
|
123
|
+
new ApiError("conflict", 409),
|
|
124
|
+
undefined,
|
|
125
|
+
undefined,
|
|
126
|
+
fakeMutation(),
|
|
127
|
+
);
|
|
128
|
+
assert.deepEqual(seen, ["conflict"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does NOT escalate a mutation 4xx marked meta.softError", () => {
|
|
132
|
+
let escalated = false;
|
|
133
|
+
subscribeFatalError(() => {
|
|
134
|
+
escalated = true;
|
|
135
|
+
});
|
|
136
|
+
handleMutationCacheError(
|
|
137
|
+
new ApiError("conflict", 409),
|
|
138
|
+
undefined,
|
|
139
|
+
undefined,
|
|
140
|
+
fakeMutation({ softError: true }),
|
|
141
|
+
);
|
|
142
|
+
assert.equal(escalated, false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Mutation, Query } from "@tanstack/react-query";
|
|
2
|
+
import { shouldEscalate } from "./error-classifier";
|
|
3
|
+
import { reportFatalError } from "./fatal-error";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Global React Query error sink. Every query and mutation error flows through
|
|
7
|
+
* here (wired on the `QueryCache`/`MutationCache` in main.tsx). The fail-fast
|
|
8
|
+
* contract (#1059) lives in `shouldEscalate`: a non-2xx escalates to the
|
|
9
|
+
* full-screen red overlay by default; a 5xx ALWAYS escalates; only aborts,
|
|
10
|
+
* statusless network blips, and non-5xx errors a call site opted out of via
|
|
11
|
+
* `meta.softError` stay soft.
|
|
12
|
+
*
|
|
13
|
+
* This is the v5 equivalent of `defaultOptions.queries.onError` /
|
|
14
|
+
* `.mutations.onError` — v5 moved the global error hook onto the caches.
|
|
15
|
+
*/
|
|
16
|
+
export const handleQueryCacheError = (
|
|
17
|
+
error: Error,
|
|
18
|
+
query: Query<unknown, unknown, unknown>,
|
|
19
|
+
): void => {
|
|
20
|
+
if (shouldEscalate(error, query.meta)) {
|
|
21
|
+
reportFatalError(error);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const handleMutationCacheError = (
|
|
26
|
+
error: unknown,
|
|
27
|
+
_variables: unknown,
|
|
28
|
+
_onMutateResult: unknown,
|
|
29
|
+
mutation: Mutation<unknown, unknown, unknown>,
|
|
30
|
+
): void => {
|
|
31
|
+
if (shouldEscalate(error, mutation.meta)) {
|
|
32
|
+
reportFatalError(error);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration: prove the global React Query caches route a first-party error
|
|
3
|
+
* through the fail-fast handlers → `reportFatalError`, which is what drives the
|
|
4
|
+
* full-screen escalation overlay. React Query's core runs headless (no DOM),
|
|
5
|
+
* so this exercises the real `QueryCache`/`MutationCache` wiring from main.tsx
|
|
6
|
+
* without rendering.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { afterEach, describe, it } from "node:test";
|
|
11
|
+
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
12
|
+
import { ApiError } from "./api";
|
|
13
|
+
import { __resetFatalError, subscribeFatalError } from "./fatal-error";
|
|
14
|
+
import {
|
|
15
|
+
handleMutationCacheError,
|
|
16
|
+
handleQueryCacheError,
|
|
17
|
+
} from "./query-error-handler";
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
__resetFatalError();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const makeClient = () =>
|
|
24
|
+
new QueryClient({
|
|
25
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
26
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
27
|
+
defaultOptions: { queries: { retry: false } },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("global query/mutation escalation", () => {
|
|
31
|
+
it("a first-party 5xx from a query escalates to the fatal overlay seam", async () => {
|
|
32
|
+
const seen: string[] = [];
|
|
33
|
+
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
34
|
+
const client = makeClient();
|
|
35
|
+
|
|
36
|
+
await client
|
|
37
|
+
.fetchQuery({
|
|
38
|
+
queryKey: ["explode"],
|
|
39
|
+
queryFn: async () => {
|
|
40
|
+
throw new ApiError("internal server error", 500);
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {});
|
|
44
|
+
|
|
45
|
+
assert.deepEqual(seen, ["internal server error"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("a 5xx on a background REFETCH escalates — the exact #1059 bug (semantic-search-style query that 500s after first rendering data)", async () => {
|
|
49
|
+
const seen: string[] = [];
|
|
50
|
+
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
51
|
+
const client = makeClient();
|
|
52
|
+
const queryKey = ["semanticSearch"];
|
|
53
|
+
let calls = 0;
|
|
54
|
+
const queryFn = async () => {
|
|
55
|
+
calls += 1;
|
|
56
|
+
if (calls === 1) return { items: [] as string[] };
|
|
57
|
+
throw new ApiError("semantic search exploded", 500);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// First load succeeds → query now has data (dataUpdatedAt !== 0). Under the
|
|
61
|
+
// deleted guard this refetch 500 vanished into `data?.items ?? []`.
|
|
62
|
+
await client.fetchQuery({ queryKey, queryFn });
|
|
63
|
+
await client.refetchQueries({ queryKey }).catch(() => {});
|
|
64
|
+
|
|
65
|
+
assert.deepEqual(seen, ["semantic search exploded"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a first-party 5xx from a mutation escalates to the fatal overlay seam", async () => {
|
|
69
|
+
const seen: string[] = [];
|
|
70
|
+
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
71
|
+
const client = makeClient();
|
|
72
|
+
|
|
73
|
+
const mutation = client.getMutationCache().build(client, {
|
|
74
|
+
mutationFn: async () => {
|
|
75
|
+
throw new ApiError("mutation blew up", 503);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
await mutation.execute(undefined).catch(() => {});
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(seen, ["mutation blew up"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("a 404 on a query marked meta.softError does NOT escalate (call site owns the empty state)", async () => {
|
|
84
|
+
let escalated = false;
|
|
85
|
+
subscribeFatalError(() => {
|
|
86
|
+
escalated = true;
|
|
87
|
+
});
|
|
88
|
+
const client = makeClient();
|
|
89
|
+
|
|
90
|
+
await client
|
|
91
|
+
.fetchQuery({
|
|
92
|
+
queryKey: ["missing"],
|
|
93
|
+
queryFn: async () => {
|
|
94
|
+
throw new ApiError("not found", 404);
|
|
95
|
+
},
|
|
96
|
+
meta: { softError: true },
|
|
97
|
+
})
|
|
98
|
+
.catch(() => {});
|
|
99
|
+
|
|
100
|
+
assert.equal(escalated, false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("a 404 on a query that did NOT opt out escalates by default", async () => {
|
|
104
|
+
const seen: string[] = [];
|
|
105
|
+
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
106
|
+
const client = makeClient();
|
|
107
|
+
|
|
108
|
+
await client
|
|
109
|
+
.fetchQuery({
|
|
110
|
+
queryKey: ["unhandled-404"],
|
|
111
|
+
queryFn: async () => {
|
|
112
|
+
throw new ApiError("not found", 404);
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
.catch(() => {});
|
|
116
|
+
|
|
117
|
+
assert.deepEqual(seen, ["not found"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("a statusless `Failed to fetch` (offline blip) from a query does NOT escalate", async () => {
|
|
121
|
+
let escalated = false;
|
|
122
|
+
subscribeFatalError(() => {
|
|
123
|
+
escalated = true;
|
|
124
|
+
});
|
|
125
|
+
const client = makeClient();
|
|
126
|
+
|
|
127
|
+
await client
|
|
128
|
+
.fetchQuery({
|
|
129
|
+
queryKey: ["offline"],
|
|
130
|
+
queryFn: async () => {
|
|
131
|
+
throw new TypeError("Failed to fetch");
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
.catch(() => {});
|
|
135
|
+
|
|
136
|
+
assert.equal(escalated, false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { beforeEach, describe, test } from "node:test";
|
|
3
|
+
import { loadRecentSearches, saveRecentSearch } from "./recent-searches.js";
|
|
4
|
+
|
|
5
|
+
function installMemoryStorage(): void {
|
|
6
|
+
const store = new Map<string, string>();
|
|
7
|
+
globalThis.localStorage = {
|
|
8
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
9
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
10
|
+
removeItem: (k: string) => void store.delete(k),
|
|
11
|
+
clear: () => store.clear(),
|
|
12
|
+
key: () => null,
|
|
13
|
+
length: 0,
|
|
14
|
+
} as Storage;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("recent-searches", () => {
|
|
18
|
+
beforeEach(installMemoryStorage);
|
|
19
|
+
|
|
20
|
+
test("starts empty", () => {
|
|
21
|
+
assert.deepEqual(loadRecentSearches(), []);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("saves and loads a query", () => {
|
|
25
|
+
assert.deepEqual(saveRecentSearch("alice"), ["alice"]);
|
|
26
|
+
assert.deepEqual(loadRecentSearches(), ["alice"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("most recent first, deduplicated", () => {
|
|
30
|
+
saveRecentSearch("alice");
|
|
31
|
+
saveRecentSearch("bob");
|
|
32
|
+
assert.deepEqual(saveRecentSearch("alice"), ["alice", "bob"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("caps at five entries", () => {
|
|
36
|
+
for (const q of ["a", "b", "c", "d", "e", "f"]) saveRecentSearch(q);
|
|
37
|
+
assert.deepEqual(loadRecentSearches(), ["f", "e", "d", "c", "b"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("ignores blank queries", () => {
|
|
41
|
+
saveRecentSearch("alice");
|
|
42
|
+
assert.deepEqual(saveRecentSearch(" "), ["alice"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("trims before storing", () => {
|
|
46
|
+
assert.deepEqual(saveRecentSearch(" bob "), ["bob"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("survives corrupt storage", () => {
|
|
50
|
+
globalThis.localStorage.setItem("remit.recentSearches", "{not json");
|
|
51
|
+
assert.deepEqual(loadRecentSearches(), []);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recent search queries for the mobile search takeover, persisted in
|
|
3
|
+
* localStorage. Best-effort: storage failures (private mode, quota) degrade to
|
|
4
|
+
* an empty list rather than crashing the search surface.
|
|
5
|
+
*/
|
|
6
|
+
const RECENT_SEARCHES_KEY = "remit.recentSearches";
|
|
7
|
+
const MAX_RECENT = 5;
|
|
8
|
+
|
|
9
|
+
export function loadRecentSearches(): string[] {
|
|
10
|
+
try {
|
|
11
|
+
const raw = localStorage.getItem(RECENT_SEARCHES_KEY);
|
|
12
|
+
if (!raw) return [];
|
|
13
|
+
const parsed: unknown = JSON.parse(raw);
|
|
14
|
+
if (!Array.isArray(parsed)) return [];
|
|
15
|
+
return parsed.filter((q): q is string => typeof q === "string");
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveRecentSearch(query: string): string[] {
|
|
22
|
+
const trimmed = query.trim();
|
|
23
|
+
if (!trimmed) return loadRecentSearches();
|
|
24
|
+
const next = [
|
|
25
|
+
trimmed,
|
|
26
|
+
...loadRecentSearches().filter((q) => q !== trimmed),
|
|
27
|
+
].slice(0, MAX_RECENT);
|
|
28
|
+
try {
|
|
29
|
+
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(next));
|
|
30
|
+
} catch {
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import {
|
|
5
|
+
isRescueCandidate,
|
|
6
|
+
rescueCandidateReason,
|
|
7
|
+
} from "./rescue-candidates.js";
|
|
8
|
+
|
|
9
|
+
function thread(
|
|
10
|
+
overrides: Partial<RemitImapThreadMessageResponse> &
|
|
11
|
+
Pick<RemitImapThreadMessageResponse, "messageId" | "senderTrust">,
|
|
12
|
+
): RemitImapThreadMessageResponse {
|
|
13
|
+
return {
|
|
14
|
+
threadMessageId: `tm-${overrides.messageId}`,
|
|
15
|
+
threadId: `t-${overrides.messageId}`,
|
|
16
|
+
accountConfigId: "acc",
|
|
17
|
+
mailboxId: "mb-junk",
|
|
18
|
+
sentDate: 1_000_000,
|
|
19
|
+
isRead: false,
|
|
20
|
+
hasAttachment: false,
|
|
21
|
+
hasStars: false,
|
|
22
|
+
isDeleted: false,
|
|
23
|
+
createdAt: 1_000_000,
|
|
24
|
+
updatedAt: 1_000_000,
|
|
25
|
+
fromName: "Anna de Vries",
|
|
26
|
+
fromEmail: "anna@studio-noord.nl",
|
|
27
|
+
subject: "Re: invoice",
|
|
28
|
+
snippet: "Final files attached.",
|
|
29
|
+
...overrides,
|
|
30
|
+
} as RemitImapThreadMessageResponse;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("rescueCandidateReason", () => {
|
|
34
|
+
test("VIP senders read as someone you know", () => {
|
|
35
|
+
assert.equal(rescueCandidateReason("vip"), "A sender you know");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("well-known senders read as previously emailed", () => {
|
|
39
|
+
assert.equal(
|
|
40
|
+
rescueCandidateReason("wellknown"),
|
|
41
|
+
"You've emailed them before",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("anything else falls back to passed authentication", () => {
|
|
46
|
+
assert.equal(rescueCandidateReason("unknown"), "Passed authentication");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("never leaks authentication jargon", () => {
|
|
50
|
+
for (const trust of ["vip", "wellknown", "unknown"] as const) {
|
|
51
|
+
assert.doesNotMatch(rescueCandidateReason(trust), /DKIM|SPF|DMARC/i);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("isRescueCandidate (telemetry predicate)", () => {
|
|
57
|
+
test("includes verified senders with no DKIM mismatch", () => {
|
|
58
|
+
assert.equal(
|
|
59
|
+
isRescueCandidate(thread({ messageId: "m1", senderTrust: "wellknown" })),
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
assert.equal(
|
|
63
|
+
isRescueCandidate(thread({ messageId: "m2", senderTrust: "vip" })),
|
|
64
|
+
true,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("excludes unknown senders", () => {
|
|
69
|
+
assert.equal(
|
|
70
|
+
isRescueCandidate(thread({ messageId: "m3", senderTrust: "unknown" })),
|
|
71
|
+
false,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("excludes a verified sender that failed DKIM alignment", () => {
|
|
76
|
+
assert.equal(
|
|
77
|
+
isRescueCandidate(
|
|
78
|
+
thread({
|
|
79
|
+
messageId: "m4",
|
|
80
|
+
senderTrust: "vip",
|
|
81
|
+
authenticity: { fromDomain: "x.nl", dkimMismatch: true },
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
false,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("keeps a verified sender when authenticity is absent", () => {
|
|
89
|
+
assert.equal(
|
|
90
|
+
isRescueCandidate(thread({ messageId: "m5", senderTrust: "wellknown" })),
|
|
91
|
+
true,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapSenderTrust,
|
|
3
|
+
RemitImapThreadMessageResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Plain-language reason a message is safe to rescue, derived from the sender's
|
|
8
|
+
* trust tier. Never DKIM/SPF jargon — the user only sees why we trust them.
|
|
9
|
+
*/
|
|
10
|
+
export const rescueCandidateReason = (
|
|
11
|
+
senderTrust: RemitImapSenderTrust,
|
|
12
|
+
): string => {
|
|
13
|
+
if (senderTrust === "vip") return "A sender you know";
|
|
14
|
+
if (senderTrust === "wellknown") return "You've emailed them before";
|
|
15
|
+
return "Passed authentication";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns true when a thread's sender is VIP or well-known with no confirmed
|
|
20
|
+
* DKIM mismatch. Used only for telemetry (wasRescuable) when marking a message
|
|
21
|
+
* as junk — it records whether the user moved a message that was previously
|
|
22
|
+
* eligible for rescue under the client-side rule.
|
|
23
|
+
*
|
|
24
|
+
* Note: the backend rescue query uses dkimMismatch=false which also excludes
|
|
25
|
+
* senders with no authenticity signal (undefined), unlike this predicate which
|
|
26
|
+
* treats absent authenticity as eligible. The backend is stricter by design.
|
|
27
|
+
*/
|
|
28
|
+
export const isRescueCandidate = (
|
|
29
|
+
thread: RemitImapThreadMessageResponse,
|
|
30
|
+
): boolean => {
|
|
31
|
+
if (thread.senderTrust !== "vip" && thread.senderTrust !== "wellknown") {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return thread.authenticity?.dkimMismatch !== true;
|
|
35
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { Telemetry } from "@/lib/telemetry";
|
|
4
|
+
import {
|
|
5
|
+
recordRescueCandidatesSurfaced,
|
|
6
|
+
recordRescueCommitted,
|
|
7
|
+
recordRescueFlowOpened,
|
|
8
|
+
recordRescueSentToJunk,
|
|
9
|
+
} from "./rescue-telemetry";
|
|
10
|
+
|
|
11
|
+
interface RecordedEvent {
|
|
12
|
+
name: string;
|
|
13
|
+
attributes?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stubTelemetry(): { telemetry: Telemetry; events: RecordedEvent[] } {
|
|
17
|
+
const events: RecordedEvent[] = [];
|
|
18
|
+
const telemetry: Telemetry = {
|
|
19
|
+
recordPageView: () => undefined,
|
|
20
|
+
recordError: () => undefined,
|
|
21
|
+
recordTiming: () => undefined,
|
|
22
|
+
recordEvent: (name, attributes) => events.push({ name, attributes }),
|
|
23
|
+
};
|
|
24
|
+
return { telemetry, events };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("rescue telemetry", () => {
|
|
28
|
+
it("emits candidates surfaced with the count", () => {
|
|
29
|
+
const { telemetry, events } = stubTelemetry();
|
|
30
|
+
recordRescueCandidatesSurfaced(telemetry, 7);
|
|
31
|
+
assert.deepEqual(events, [
|
|
32
|
+
{ name: "rescue.candidates_surfaced", attributes: { count: "7" } },
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("emits flow opened with the count", () => {
|
|
37
|
+
const { telemetry, events } = stubTelemetry();
|
|
38
|
+
recordRescueFlowOpened(telemetry, 3);
|
|
39
|
+
assert.deepEqual(events, [
|
|
40
|
+
{ name: "rescue.flow_opened", attributes: { count: "3" } },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("emits committed with selected vs total and an inbox destination", () => {
|
|
45
|
+
const { telemetry, events } = stubTelemetry();
|
|
46
|
+
recordRescueCommitted(telemetry, { selected: 2, total: 5, toInbox: true });
|
|
47
|
+
assert.deepEqual(events, [
|
|
48
|
+
{
|
|
49
|
+
name: "rescue.committed",
|
|
50
|
+
attributes: { selected: "2", total: "5", destination: "inbox" },
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("marks a non-inbox destination as other", () => {
|
|
56
|
+
const { telemetry, events } = stubTelemetry();
|
|
57
|
+
recordRescueCommitted(telemetry, { selected: 1, total: 1, toInbox: false });
|
|
58
|
+
assert.equal(events[0]?.attributes?.destination, "other");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("emits the reverse signal with trust and rescuable flag", () => {
|
|
62
|
+
const { telemetry, events } = stubTelemetry();
|
|
63
|
+
recordRescueSentToJunk(telemetry, {
|
|
64
|
+
count: 1,
|
|
65
|
+
senderTrust: "vip",
|
|
66
|
+
wasRescuable: true,
|
|
67
|
+
});
|
|
68
|
+
assert.deepEqual(events, [
|
|
69
|
+
{
|
|
70
|
+
name: "rescue.sent_to_junk",
|
|
71
|
+
attributes: { count: "1", senderTrust: "vip", wasRescuable: "true" },
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
});
|