@remit/ui 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/package.json +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- package/src/tokens.css +217 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Ban,
|
|
3
|
+
BellOff,
|
|
4
|
+
MailCheck,
|
|
5
|
+
MailX,
|
|
6
|
+
ShieldAlert,
|
|
7
|
+
ShieldCheck,
|
|
8
|
+
ShieldQuestion,
|
|
9
|
+
ShieldX,
|
|
10
|
+
Sparkles,
|
|
11
|
+
Star,
|
|
12
|
+
X,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import type { ReactElement, ReactNode } from "react";
|
|
15
|
+
import { cn } from "../lib/cn.js";
|
|
16
|
+
import { Avatar } from "./avatar.js";
|
|
17
|
+
import { Badge } from "./badge.js";
|
|
18
|
+
import { Button } from "./button.js";
|
|
19
|
+
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/* The fourth pane: mail intelligence. Driven by real backend signals */
|
|
22
|
+
/* (Address engagement counters, DKIM-mismatch category heuristics, */
|
|
23
|
+
/* semantic search) with reserved slots for local-LLM and Bedrock */
|
|
24
|
+
/* features. Silent when there is nothing to say; loudest element on */
|
|
25
|
+
/* the screen when authenticity fails. */
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
|
|
28
|
+
export type SenderTrustLevel = "unknown" | "wellknown" | "vip";
|
|
29
|
+
|
|
30
|
+
export interface SenderIntel {
|
|
31
|
+
name: string;
|
|
32
|
+
email: string;
|
|
33
|
+
trust: SenderTrustLevel;
|
|
34
|
+
/** Human label, e.g. "Jan 2025" or "today". */
|
|
35
|
+
firstSeenLabel: string;
|
|
36
|
+
/**
|
|
37
|
+
* Engagement counters. Optional: when the API does not expose them, omit
|
|
38
|
+
* both and the engagement clause is suppressed rather than rendering a
|
|
39
|
+
* misleading "0 received · you've never replied" next to an earned trust
|
|
40
|
+
* badge. Render the clause only when `inboundCount` is provided.
|
|
41
|
+
*/
|
|
42
|
+
inboundCount?: number;
|
|
43
|
+
replyCount?: number;
|
|
44
|
+
/**
|
|
45
|
+
* True when the sender address is missing or unparseable (no valid domain).
|
|
46
|
+
* Drives a red "couldn't verify the address" badge instead of the quiet grey
|
|
47
|
+
* "Unknown sender", and forces the authenticity verdict to the red tier.
|
|
48
|
+
*/
|
|
49
|
+
addressUnverified?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AuthenticityIntel {
|
|
53
|
+
/**
|
|
54
|
+
* Risk tier. `aligned` (green) — verified sender. `caution` (orange) — no
|
|
55
|
+
* verification signal available. `mismatch` (red) — impersonation or an
|
|
56
|
+
* unreadable sender address.
|
|
57
|
+
*/
|
|
58
|
+
verdict: "aligned" | "caution" | "mismatch";
|
|
59
|
+
fromDomain: string;
|
|
60
|
+
/** Signing domain the message was actually sent from, when known. */
|
|
61
|
+
dkimDomain?: string;
|
|
62
|
+
/** Brand the display name claims, when it differs from the mailbox. */
|
|
63
|
+
claimedBrand?: string;
|
|
64
|
+
/** Plain-language verdict shown to the user. */
|
|
65
|
+
summary: string;
|
|
66
|
+
/**
|
|
67
|
+
* True when the red tier is driven by an unreadable sender address rather
|
|
68
|
+
* than an impersonation mismatch — selects the headline and suppresses the
|
|
69
|
+
* domain-comparison line (there is no valid domain to show).
|
|
70
|
+
*/
|
|
71
|
+
addressUnreadable?: boolean;
|
|
72
|
+
/** Count of semantically similar messages (the campaign, not the instance). */
|
|
73
|
+
similarCount?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type MatchedChunk =
|
|
77
|
+
| "sender"
|
|
78
|
+
| "recipient"
|
|
79
|
+
| "subject"
|
|
80
|
+
| "attachment"
|
|
81
|
+
| "body"
|
|
82
|
+
| "entities";
|
|
83
|
+
|
|
84
|
+
export interface SimilarMessageIntel {
|
|
85
|
+
id: string;
|
|
86
|
+
/** Mailbox the message lives in — the route param for opening it. */
|
|
87
|
+
mailboxId: string;
|
|
88
|
+
fromName: string;
|
|
89
|
+
subject: string;
|
|
90
|
+
timeLabel: string;
|
|
91
|
+
matched: MatchedChunk;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Renders a similar-message row as a real anchor so middle-click /
|
|
96
|
+
* open-in-new-tab / deep-linking / screen-reader link semantics all work. The
|
|
97
|
+
* web-client passes a router `<Link>` builder; when omitted the row falls back
|
|
98
|
+
* to a non-navigating element that preserves the visual (static stories).
|
|
99
|
+
*/
|
|
100
|
+
export interface SimilarMessageLinkProps {
|
|
101
|
+
mailboxId: string;
|
|
102
|
+
messageId: string;
|
|
103
|
+
className: string;
|
|
104
|
+
ariaLabel?: string;
|
|
105
|
+
children: ReactNode;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type SimilarMessageLinkComponent = (
|
|
109
|
+
props: SimilarMessageLinkProps,
|
|
110
|
+
) => ReactElement;
|
|
111
|
+
|
|
112
|
+
export interface SenderFlagsIntel {
|
|
113
|
+
vip?: boolean;
|
|
114
|
+
muted?: boolean;
|
|
115
|
+
blocked?: boolean;
|
|
116
|
+
unsubscribed?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface IntelligenceData {
|
|
120
|
+
sender: SenderIntel;
|
|
121
|
+
authenticity: AuthenticityIntel;
|
|
122
|
+
category: { value: string; overridden?: boolean };
|
|
123
|
+
flags?: SenderFlagsIntel;
|
|
124
|
+
similar: SimilarMessageIntel[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface IntelligenceQuickActions {
|
|
128
|
+
onToggleVip?: () => void;
|
|
129
|
+
onToggleMute?: () => void;
|
|
130
|
+
/** Block navigates through a confirm dialog — the callback fires post-confirm. */
|
|
131
|
+
onToggleBlock?: () => void;
|
|
132
|
+
onToggleUnsubscribe?: () => void;
|
|
133
|
+
onReclassify?: () => void;
|
|
134
|
+
/**
|
|
135
|
+
* "Not spam": move the message out of Junk and promote the sender to
|
|
136
|
+
* Wellknown (issue #594). Wired only when the message is currently in Junk.
|
|
137
|
+
*/
|
|
138
|
+
onNotSpam?: () => void;
|
|
139
|
+
/**
|
|
140
|
+
* "Mark spam": move the message into Junk and strip the sender's trust
|
|
141
|
+
* (the inverse of "Not spam"). Wired only when the message is not in Junk.
|
|
142
|
+
*/
|
|
143
|
+
onMarkSpam?: () => void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Async state for the similar-messages section. The host computes these from
|
|
148
|
+
* the semantic-search query so the panel can show a skeleton while in flight
|
|
149
|
+
* and "similarity search unavailable" on failure (the rest of the sidebar
|
|
150
|
+
* still renders). Defaults to "ready" when omitted.
|
|
151
|
+
*/
|
|
152
|
+
export type SimilarState = "loading" | "error" | "ready";
|
|
153
|
+
|
|
154
|
+
export interface IntelligencePanelProps {
|
|
155
|
+
data: IntelligenceData;
|
|
156
|
+
onClose?: () => void;
|
|
157
|
+
onShowSimilar?: () => void;
|
|
158
|
+
/** Quick-action callbacks. When omitted the buttons are visual-only. */
|
|
159
|
+
actions?: IntelligenceQuickActions;
|
|
160
|
+
/** Loading/error state for the similar-messages section. */
|
|
161
|
+
similarState?: SimilarState;
|
|
162
|
+
/**
|
|
163
|
+
* Render-prop that wraps each similar-message row in a real router anchor so
|
|
164
|
+
* it opens that email (deep-link / middle-click / link a11y). When omitted the
|
|
165
|
+
* rows render as static, non-navigating elements (stories / SSR previews).
|
|
166
|
+
*/
|
|
167
|
+
similarLinkComponent?: SimilarMessageLinkComponent;
|
|
168
|
+
/** Layout overrides (e.g. when hosted inside a resizable panel). */
|
|
169
|
+
className?: string;
|
|
170
|
+
/**
|
|
171
|
+
* Suppress the panel's own close (X) button. Set when the panel is hosted
|
|
172
|
+
* inside a chrome that already provides a close affordance (e.g. the mobile
|
|
173
|
+
* Drawer header), so there is exactly one way back (#874).
|
|
174
|
+
*/
|
|
175
|
+
hideCloseButton?: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const trustLabel: Record<
|
|
179
|
+
SenderTrustLevel,
|
|
180
|
+
{ label: string; tone: "neutral" | "positive" | "accent" }
|
|
181
|
+
> = {
|
|
182
|
+
unknown: { label: "Unknown sender", tone: "neutral" },
|
|
183
|
+
wellknown: { label: "Known sender", tone: "positive" },
|
|
184
|
+
vip: { label: "VIP", tone: "accent" },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function Section({
|
|
188
|
+
label,
|
|
189
|
+
children,
|
|
190
|
+
className,
|
|
191
|
+
}: {
|
|
192
|
+
label: string;
|
|
193
|
+
children: ReactNode;
|
|
194
|
+
className?: string;
|
|
195
|
+
}) {
|
|
196
|
+
return (
|
|
197
|
+
<section
|
|
198
|
+
className={cn("border-b border-line px-row-inset py-3", className)}
|
|
199
|
+
>
|
|
200
|
+
<h3 className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
201
|
+
{label}
|
|
202
|
+
</h3>
|
|
203
|
+
<div className="mt-2">{children}</div>
|
|
204
|
+
</section>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function SenderCard({ sender }: { sender: SenderIntel }) {
|
|
209
|
+
const trust = trustLabel[sender.trust];
|
|
210
|
+
// Engagement clause renders only when counters are present. When the API
|
|
211
|
+
// does not expose inboundCount/replyCount we show first-seen alone rather
|
|
212
|
+
// than a misleading "0 received · you've never replied".
|
|
213
|
+
const engagement =
|
|
214
|
+
sender.inboundCount == null
|
|
215
|
+
? null
|
|
216
|
+
: (sender.replyCount ?? 0) > 0
|
|
217
|
+
? `${sender.inboundCount} received · you replied ${sender.replyCount}×`
|
|
218
|
+
: `${sender.inboundCount} received · you've never replied`;
|
|
219
|
+
return (
|
|
220
|
+
<div>
|
|
221
|
+
<div className="flex items-center gap-3">
|
|
222
|
+
<Avatar name={sender.name} email={sender.email} size="md" />
|
|
223
|
+
<div className="min-w-0">
|
|
224
|
+
<div className="truncate text-sm font-semibold text-fg">
|
|
225
|
+
{sender.name}
|
|
226
|
+
</div>
|
|
227
|
+
<div className="truncate text-2xs text-fg-subtle">{sender.email}</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<div className="mt-2 flex items-center gap-2">
|
|
231
|
+
{sender.addressUnverified ? (
|
|
232
|
+
<Badge tone="danger" dot>
|
|
233
|
+
Address couldn't be verified
|
|
234
|
+
</Badge>
|
|
235
|
+
) : (
|
|
236
|
+
<Badge tone={trust.tone} dot>
|
|
237
|
+
{trust.label}
|
|
238
|
+
</Badge>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
<p className="mt-2 text-xs text-fg-muted">
|
|
242
|
+
First seen {sender.firstSeenLabel}
|
|
243
|
+
{engagement ? ` · ${engagement}` : ""}
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function Authenticity({
|
|
250
|
+
auth,
|
|
251
|
+
onShowSimilar,
|
|
252
|
+
}: {
|
|
253
|
+
auth: AuthenticityIntel;
|
|
254
|
+
onShowSimilar?: () => void;
|
|
255
|
+
}) {
|
|
256
|
+
if (auth.verdict === "aligned") {
|
|
257
|
+
return (
|
|
258
|
+
<div className="flex items-start gap-2 text-xs text-fg-muted">
|
|
259
|
+
<ShieldCheck className="mt-0.5 size-3.5 shrink-0 text-positive" />
|
|
260
|
+
<span>{auth.summary}</span>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (auth.verdict === "caution") {
|
|
265
|
+
return (
|
|
266
|
+
<div className="flex items-start gap-2 rounded-md bg-warning/10 p-3 text-warning">
|
|
267
|
+
<ShieldQuestion className="mt-0.5 size-4 shrink-0" />
|
|
268
|
+
<p className="text-xs leading-relaxed">{auth.summary}</p>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const headline = auth.addressUnreadable
|
|
273
|
+
? "This sender can't be verified"
|
|
274
|
+
: "This message may be impersonating someone";
|
|
275
|
+
return (
|
|
276
|
+
<div className="rounded-md bg-danger-soft p-3">
|
|
277
|
+
<div className="flex items-start gap-2">
|
|
278
|
+
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-danger" />
|
|
279
|
+
<div className="min-w-0">
|
|
280
|
+
<p className="text-sm font-semibold text-danger">{headline}</p>
|
|
281
|
+
<p className="mt-1 text-xs leading-relaxed text-fg">{auth.summary}</p>
|
|
282
|
+
{!auth.addressUnreadable && auth.dkimDomain && (
|
|
283
|
+
<p className="mt-1 text-2xs text-fg-muted">
|
|
284
|
+
Sent from{" "}
|
|
285
|
+
<code className="rounded bg-surface px-1 py-0.5">
|
|
286
|
+
{auth.dkimDomain}
|
|
287
|
+
</code>
|
|
288
|
+
, claims to be{" "}
|
|
289
|
+
<code className="rounded bg-surface px-1 py-0.5">
|
|
290
|
+
{auth.fromDomain}
|
|
291
|
+
</code>
|
|
292
|
+
</p>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
{auth.similarCount != null && auth.similarCount > 0 && (
|
|
297
|
+
<Button
|
|
298
|
+
variant="secondary"
|
|
299
|
+
size="sm"
|
|
300
|
+
className="mt-2 w-full border-danger/30 text-danger hover:bg-danger-soft"
|
|
301
|
+
onClick={onShowSimilar}
|
|
302
|
+
>
|
|
303
|
+
{auth.similarCount} similar messages
|
|
304
|
+
</Button>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function QuickAction({
|
|
311
|
+
icon,
|
|
312
|
+
label,
|
|
313
|
+
active,
|
|
314
|
+
danger,
|
|
315
|
+
onClick,
|
|
316
|
+
}: {
|
|
317
|
+
icon: ReactNode;
|
|
318
|
+
label: string;
|
|
319
|
+
active?: boolean;
|
|
320
|
+
danger?: boolean;
|
|
321
|
+
onClick?: () => void;
|
|
322
|
+
}) {
|
|
323
|
+
return (
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
onClick={onClick}
|
|
327
|
+
className={cn(
|
|
328
|
+
"flex items-center gap-2 rounded-md border px-2 py-1 text-xs transition-colors",
|
|
329
|
+
active
|
|
330
|
+
? "border-accent-2 bg-accent-2-soft text-accent-2"
|
|
331
|
+
: "border-line text-fg-muted hover:border-line-strong hover:text-fg",
|
|
332
|
+
danger && !active && "text-danger hover:bg-danger-soft",
|
|
333
|
+
)}
|
|
334
|
+
>
|
|
335
|
+
{icon}
|
|
336
|
+
{label}
|
|
337
|
+
</button>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const matchTone: Record<MatchedChunk, string> = {
|
|
342
|
+
sender: "matched: sender",
|
|
343
|
+
recipient: "matched: recipient",
|
|
344
|
+
subject: "matched: subject",
|
|
345
|
+
attachment: "matched: attachment",
|
|
346
|
+
body: "matched: body",
|
|
347
|
+
entities: "matched: entities",
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
export function IntelligencePanel({
|
|
351
|
+
data,
|
|
352
|
+
onClose,
|
|
353
|
+
onShowSimilar,
|
|
354
|
+
actions,
|
|
355
|
+
similarState = "ready",
|
|
356
|
+
similarLinkComponent,
|
|
357
|
+
className,
|
|
358
|
+
hideCloseButton = false,
|
|
359
|
+
}: IntelligencePanelProps) {
|
|
360
|
+
const { sender, authenticity, category, flags = {}, similar } = data;
|
|
361
|
+
const suspicious = authenticity.verdict === "mismatch";
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<aside
|
|
365
|
+
className={cn(
|
|
366
|
+
"flex w-76 shrink-0 flex-col overflow-y-auto border-l border-line bg-surface",
|
|
367
|
+
className,
|
|
368
|
+
)}
|
|
369
|
+
>
|
|
370
|
+
{/* pane-header datum: aligns with list + reading pane headers */}
|
|
371
|
+
<header className="flex h-pane-header shrink-0 items-center justify-between border-b border-line px-row-inset">
|
|
372
|
+
<span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
373
|
+
Intelligence
|
|
374
|
+
</span>
|
|
375
|
+
{!hideCloseButton && (
|
|
376
|
+
<Button
|
|
377
|
+
variant="ghost"
|
|
378
|
+
size="sm"
|
|
379
|
+
icon={<X className="size-3.5" />}
|
|
380
|
+
onClick={onClose}
|
|
381
|
+
aria-label="Collapse intelligence sidebar"
|
|
382
|
+
/>
|
|
383
|
+
)}
|
|
384
|
+
</header>
|
|
385
|
+
|
|
386
|
+
<Section label="Sender">
|
|
387
|
+
<SenderCard sender={sender} />
|
|
388
|
+
</Section>
|
|
389
|
+
|
|
390
|
+
<Section label="Authenticity">
|
|
391
|
+
<Authenticity auth={authenticity} onShowSimilar={onShowSimilar} />
|
|
392
|
+
</Section>
|
|
393
|
+
|
|
394
|
+
<Section label="Category">
|
|
395
|
+
<div className="flex items-center gap-2">
|
|
396
|
+
<Badge tone={suspicious ? "danger" : "neutral"}>
|
|
397
|
+
{category.value}
|
|
398
|
+
</Badge>
|
|
399
|
+
{category.overridden && (
|
|
400
|
+
<span className="text-2xs text-fg-subtle">your override</span>
|
|
401
|
+
)}
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
className="ml-auto text-2xs text-accent hover:underline"
|
|
405
|
+
onClick={actions?.onReclassify}
|
|
406
|
+
>
|
|
407
|
+
reclassify
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
</Section>
|
|
411
|
+
|
|
412
|
+
<Section label="Quick actions">
|
|
413
|
+
<div className="flex flex-wrap gap-1.5">
|
|
414
|
+
<QuickAction
|
|
415
|
+
icon={<Star className="size-3.5" />}
|
|
416
|
+
label="VIP"
|
|
417
|
+
active={flags.vip}
|
|
418
|
+
onClick={actions?.onToggleVip}
|
|
419
|
+
/>
|
|
420
|
+
<QuickAction
|
|
421
|
+
icon={<BellOff className="size-3.5" />}
|
|
422
|
+
label="Mute"
|
|
423
|
+
active={flags.muted}
|
|
424
|
+
onClick={actions?.onToggleMute}
|
|
425
|
+
/>
|
|
426
|
+
<QuickAction
|
|
427
|
+
icon={<Ban className="size-3.5" />}
|
|
428
|
+
label="Block"
|
|
429
|
+
active={flags.blocked}
|
|
430
|
+
danger
|
|
431
|
+
onClick={actions?.onToggleBlock}
|
|
432
|
+
/>
|
|
433
|
+
<QuickAction
|
|
434
|
+
icon={<MailX className="size-3.5" />}
|
|
435
|
+
label="Unsubscribe"
|
|
436
|
+
active={flags.unsubscribed}
|
|
437
|
+
onClick={actions?.onToggleUnsubscribe}
|
|
438
|
+
/>
|
|
439
|
+
{actions?.onNotSpam && (
|
|
440
|
+
<QuickAction
|
|
441
|
+
icon={<MailCheck className="size-3.5" />}
|
|
442
|
+
label="Not spam"
|
|
443
|
+
onClick={actions.onNotSpam}
|
|
444
|
+
/>
|
|
445
|
+
)}
|
|
446
|
+
{actions?.onMarkSpam && (
|
|
447
|
+
<QuickAction
|
|
448
|
+
icon={<ShieldX className="size-3.5" />}
|
|
449
|
+
label="Mark spam"
|
|
450
|
+
danger
|
|
451
|
+
onClick={actions.onMarkSpam}
|
|
452
|
+
/>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
</Section>
|
|
456
|
+
|
|
457
|
+
{(similarState !== "ready" || similar.length > 0) && (
|
|
458
|
+
<Section label="Similar messages">
|
|
459
|
+
{similarState === "loading" ? (
|
|
460
|
+
<div className="animate-pulse space-y-2">
|
|
461
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
462
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
|
|
463
|
+
<div key={i} className="space-y-1">
|
|
464
|
+
<div className="h-3 w-3/4 rounded bg-surface-sunken" />
|
|
465
|
+
<div className="h-2.5 w-1/2 rounded bg-surface-sunken" />
|
|
466
|
+
</div>
|
|
467
|
+
))}
|
|
468
|
+
</div>
|
|
469
|
+
) : similarState === "error" ? (
|
|
470
|
+
<p className="text-xs text-fg-subtle">
|
|
471
|
+
Similarity search unavailable
|
|
472
|
+
</p>
|
|
473
|
+
) : (
|
|
474
|
+
<ul className="-mx-1 space-y-1">
|
|
475
|
+
{similar.map((s) => {
|
|
476
|
+
const rowClass =
|
|
477
|
+
"block w-full rounded-md px-1 py-1 text-left hover:bg-surface-sunken";
|
|
478
|
+
const ariaLabel = `Open message from ${s.fromName || "unknown sender"}: ${s.subject}`;
|
|
479
|
+
const inner = (
|
|
480
|
+
<>
|
|
481
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
482
|
+
<span className="truncate text-xs font-medium text-fg">
|
|
483
|
+
{s.fromName}
|
|
484
|
+
</span>
|
|
485
|
+
<span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
|
|
486
|
+
{s.timeLabel}
|
|
487
|
+
</span>
|
|
488
|
+
</div>
|
|
489
|
+
<div className="truncate text-xs text-fg-muted">
|
|
490
|
+
{s.subject}
|
|
491
|
+
</div>
|
|
492
|
+
<span className="mt-1 inline-block rounded-full bg-surface-sunken px-1.5 py-px text-2xs text-fg-subtle">
|
|
493
|
+
{matchTone[s.matched]}
|
|
494
|
+
</span>
|
|
495
|
+
</>
|
|
496
|
+
);
|
|
497
|
+
return (
|
|
498
|
+
<li key={s.id}>
|
|
499
|
+
{similarLinkComponent ? (
|
|
500
|
+
similarLinkComponent({
|
|
501
|
+
mailboxId: s.mailboxId,
|
|
502
|
+
messageId: s.id,
|
|
503
|
+
className: rowClass,
|
|
504
|
+
ariaLabel,
|
|
505
|
+
children: inner,
|
|
506
|
+
})
|
|
507
|
+
) : (
|
|
508
|
+
<div className={rowClass}>{inner}</div>
|
|
509
|
+
)}
|
|
510
|
+
</li>
|
|
511
|
+
);
|
|
512
|
+
})}
|
|
513
|
+
</ul>
|
|
514
|
+
)}
|
|
515
|
+
</Section>
|
|
516
|
+
)}
|
|
517
|
+
|
|
518
|
+
<Section label="Coming soon" className="border-b-0 opacity-70">
|
|
519
|
+
<div className="space-y-2">
|
|
520
|
+
<div className="flex items-start gap-2 rounded-md border border-dashed border-line p-2">
|
|
521
|
+
<Sparkles className="mt-0.5 size-3.5 shrink-0 text-fg-subtle" />
|
|
522
|
+
<div>
|
|
523
|
+
<div className="text-xs font-medium text-fg-muted">
|
|
524
|
+
Suggested actions
|
|
525
|
+
</div>
|
|
526
|
+
<div className="text-2xs text-fg-subtle">
|
|
527
|
+
"Create a filter for messages like this" — on-device model
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
<div className="flex items-start gap-2 rounded-md border border-dashed border-line p-2">
|
|
532
|
+
<Sparkles className="mt-0.5 size-3.5 shrink-0 text-fg-subtle" />
|
|
533
|
+
<div>
|
|
534
|
+
<div className="text-xs font-medium text-fg-muted">
|
|
535
|
+
Thread summary
|
|
536
|
+
</div>
|
|
537
|
+
<div className="text-2xs text-fg-subtle">
|
|
538
|
+
One-paragraph digest — Bedrock
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
</Section>
|
|
544
|
+
</aside>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildEmailSrcDoc,
|
|
5
|
+
DARK_OPT_IN_RE,
|
|
6
|
+
generateFramedEmailBaseCSS,
|
|
7
|
+
generatePlainEmailBaseCSS,
|
|
8
|
+
VIEWPORT_META,
|
|
9
|
+
} from "./email-frame-css.js";
|
|
10
|
+
import { computeFitScale, measureContentAxis } from "./isolated-email-frame.js";
|
|
11
|
+
|
|
12
|
+
describe("measureContentAxis (content-sizing)", () => {
|
|
13
|
+
it("takes the larger of body and documentElement scroll size", () => {
|
|
14
|
+
assert.equal(measureContentAxis(600, 900, 10_000), 900);
|
|
15
|
+
assert.equal(measureContentAxis(900, 600, 10_000), 900);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("rounds UP so a fractional content size never leaves a 1px phantom overflow", () => {
|
|
19
|
+
assert.equal(measureContentAxis(600.1, 0, 10_000), 601);
|
|
20
|
+
assert.equal(measureContentAxis(0, 899.4, 10_000), 900);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("caps at the supplied max so a hostile sender can't allocate unbounded layout", () => {
|
|
24
|
+
assert.equal(measureContentAxis(50_001, 0, 50_000), 50_000);
|
|
25
|
+
assert.equal(measureContentAxis(0, 25_000, 10_000), 10_000);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns an exact integer for already-integral content (no spurious +1)", () => {
|
|
29
|
+
assert.equal(measureContentAxis(672, 0, 10_000), 672);
|
|
30
|
+
assert.equal(measureContentAxis(0, 0, 10_000), 0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("computeFitScale (mobile fit-to-width #727)", () => {
|
|
35
|
+
it("does not scale content that already fits the container", () => {
|
|
36
|
+
assert.equal(computeFitScale(364, 364), 1);
|
|
37
|
+
assert.equal(computeFitScale(300, 364), 1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("downscales a fixed-width newsletter to the container width", () => {
|
|
41
|
+
// 648px Node-Weekly table into a 364px phone container.
|
|
42
|
+
assert.equal(computeFitScale(648, 364), 364 / 648);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("floors the scale so a pathologically wide email stays readable", () => {
|
|
46
|
+
assert.equal(computeFitScale(4000, 364), 0.4);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never upscales and never divides by an unknown width", () => {
|
|
50
|
+
assert.equal(computeFitScale(0, 364), 1);
|
|
51
|
+
assert.equal(computeFitScale(648, 0), 1);
|
|
52
|
+
assert.equal(computeFitScale(-10, 364), 1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("generatePlainEmailBaseCSS (theme tokens pinned)", () => {
|
|
57
|
+
it("injects the light-theme resolved tokens", () => {
|
|
58
|
+
const css = generatePlainEmailBaseCSS(false);
|
|
59
|
+
assert.match(css, /oklch\(0\.3 0\.025 235\)/);
|
|
60
|
+
assert.match(css, /oklch\(0\.975 0\.012 90\)/);
|
|
61
|
+
assert.match(css, /oklch\(0\.55 0\.14 150\)/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("injects the dark-theme resolved tokens", () => {
|
|
65
|
+
const css = generatePlainEmailBaseCSS(true);
|
|
66
|
+
assert.match(css, /oklch\(0\.88 0\.02 90\)/);
|
|
67
|
+
assert.match(css, /oklch\(0\.25 0\.025 220\)/);
|
|
68
|
+
assert.match(css, /oklch\(0\.78 0\.16 150\)/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("strips author colors and backgrounds from body descendants only", () => {
|
|
72
|
+
const css = generatePlainEmailBaseCSS(false);
|
|
73
|
+
assert.match(css, /body \*\s*\{[^}]*color:\s*inherit\s*!important/);
|
|
74
|
+
assert.match(
|
|
75
|
+
css,
|
|
76
|
+
/body \*\s*\{[^}]*background-color:\s*transparent\s*!important/,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("generateFramedEmailBaseCSS (K-9 dark strategy)", () => {
|
|
82
|
+
it("light theme renders as authored on a white canvas, no invert", () => {
|
|
83
|
+
const css = generateFramedEmailBaseCSS(false, false);
|
|
84
|
+
assert.match(css, /background-color:#ffffff/);
|
|
85
|
+
assert.match(css, /color-scheme:light/);
|
|
86
|
+
assert.doesNotMatch(css, /invert/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("dark theme without opt-in smart-inverts to darken into the pane", () => {
|
|
90
|
+
const css = generateFramedEmailBaseCSS(true, false);
|
|
91
|
+
assert.match(css, /filter:invert\(0\.92\) hue-rotate\(180deg\)/);
|
|
92
|
+
// Media re-inverted back to natural color.
|
|
93
|
+
assert.match(css, /img,picture,video[^{]*\{filter:invert/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("dark theme WITH opt-in preserves the author's own dark design (no invert)", () => {
|
|
97
|
+
const css = generateFramedEmailBaseCSS(true, true);
|
|
98
|
+
assert.match(css, /color-scheme:dark light/);
|
|
99
|
+
assert.doesNotMatch(css, /invert/);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("DARK_OPT_IN_RE", () => {
|
|
104
|
+
it("detects color-scheme: dark and prefers-color-scheme: dark", () => {
|
|
105
|
+
assert.ok(DARK_OPT_IN_RE.test(":root{color-scheme: dark}"));
|
|
106
|
+
assert.ok(
|
|
107
|
+
DARK_OPT_IN_RE.test("@media (prefers-color-scheme: dark){body{}}"),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not match a plain light email", () => {
|
|
112
|
+
assert.ok(!DARK_OPT_IN_RE.test("<p>hello</p>"));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("buildEmailSrcDoc", () => {
|
|
117
|
+
it("prepends the viewport meta so author width=device-width resolves to the frame", () => {
|
|
118
|
+
const doc = buildEmailSrcDoc("<p>x</p>", "plain", false);
|
|
119
|
+
assert.ok(doc.startsWith(VIEWPORT_META));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps the sanitized email body intact after the injected style", () => {
|
|
123
|
+
const body = '<style>.clamp{}</style><table width="600"></table>';
|
|
124
|
+
const doc = buildEmailSrcDoc(body, "framed", false);
|
|
125
|
+
assert.ok(doc.endsWith(body));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("uses the plain base CSS for the plain variant", () => {
|
|
129
|
+
const doc = buildEmailSrcDoc("<p>x</p>", "plain", false);
|
|
130
|
+
assert.match(doc, /Plain-email base/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("uses the framed smart-invert in dark mode for a non-opt-in framed email", () => {
|
|
134
|
+
const doc = buildEmailSrcDoc("<p>x</p>", "framed", true);
|
|
135
|
+
assert.match(doc, /filter:invert\(0\.92\)/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("preserves a framed email's own dark design without inverting", () => {
|
|
139
|
+
const doc = buildEmailSrcDoc(
|
|
140
|
+
"<style>:root{color-scheme:dark}</style><p>x</p>",
|
|
141
|
+
"framed",
|
|
142
|
+
true,
|
|
143
|
+
);
|
|
144
|
+
assert.match(doc, /color-scheme:dark light/);
|
|
145
|
+
assert.doesNotMatch(doc, /filter:invert/);
|
|
146
|
+
});
|
|
147
|
+
});
|