@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,298 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
import type { CidResolver } from "./cid-resolver.js";
|
|
3
|
+
import { generateLayoutClampCSS } from "./email-layout-clamp.js";
|
|
4
|
+
|
|
5
|
+
export type { CidResolver } from "./cid-resolver.js";
|
|
6
|
+
export { buildCidResolver } from "./cid-resolver.js";
|
|
7
|
+
export { generateLayoutClampCSS } from "./email-layout-clamp.js";
|
|
8
|
+
|
|
9
|
+
export interface SanitizeOptions {
|
|
10
|
+
allowExternalImages?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a `cid:CONTENT_ID` reference to a fetchable URL. The renderer
|
|
13
|
+
* looks up the body part with a matching `contentId` and returns its
|
|
14
|
+
* CloudFront `contentUrl` (#224 PR 2). When the resolver returns
|
|
15
|
+
* `undefined`, the original `cid:` URL is left in place so the failure
|
|
16
|
+
* is visible (broken image icon) — never silently substitute a blocked-
|
|
17
|
+
* placeholder, that would mask a real backend mismatch.
|
|
18
|
+
*/
|
|
19
|
+
resolveCid?: CidResolver;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Output of the sanitize pipeline. `hasAuthorBackground` tells the renderer
|
|
24
|
+
* whether the email author specified its own background — either via an
|
|
25
|
+
* inline `style="background…"`, a legacy `bgcolor="…"` attribute, or inside
|
|
26
|
+
* a `<style>` block. When true, the renderer pins the subtree to light-mode
|
|
27
|
+
* so author colors survive (newsletter / designed mail). When false, the
|
|
28
|
+
* subtree inherits the app theme so plain mail blends with dark chrome
|
|
29
|
+
* instead of becoming a bright slab (#375).
|
|
30
|
+
*/
|
|
31
|
+
export interface SanitizedEmail {
|
|
32
|
+
html: string;
|
|
33
|
+
hasAuthorBackground: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* CSS values that represent "no background" — finding `background[-color]:`
|
|
38
|
+
* set to one of these in a `<style>` block does NOT constitute an author
|
|
39
|
+
* background. Values are matched case-insensitively.
|
|
40
|
+
*/
|
|
41
|
+
const NO_BACKGROUND_VALUES = /^(none|transparent|inherit|initial|unset)\s*$/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract all `<style>…</style>` block contents from the raw HTML string.
|
|
45
|
+
*/
|
|
46
|
+
const extractStyleBlocks = (html: string): string[] => {
|
|
47
|
+
const blocks: string[] = [];
|
|
48
|
+
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
49
|
+
let match: RegExpExecArray | null;
|
|
50
|
+
match = re.exec(html);
|
|
51
|
+
while (match !== null) {
|
|
52
|
+
blocks.push(match[1]);
|
|
53
|
+
match = re.exec(html);
|
|
54
|
+
}
|
|
55
|
+
return blocks;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Return true if the CSS text contains a `background` or `background-color`
|
|
60
|
+
* declaration with a value that is not one of the "no-op" keywords
|
|
61
|
+
* (`none`, `transparent`, `inherit`, `initial`, `unset`). A light
|
|
62
|
+
* declaration scan — no full CSS parser needed.
|
|
63
|
+
*
|
|
64
|
+
* Note: this intentionally matches any selector (including class selectors
|
|
65
|
+
* like `.foo { background: red }`). Determining whether a given rule is
|
|
66
|
+
* "applied" to any element requires DOM access and a full cascade, which is
|
|
67
|
+
* beyond the scope of a pre-sanitization heuristic. The main fixes this
|
|
68
|
+
* closes are reset stylesheets (`background: none / transparent`) and
|
|
69
|
+
* `background: inherit` resets that were spuriously triggering the framed
|
|
70
|
+
* treatment (#483). A class rule with a real color value is treated as an
|
|
71
|
+
* author background on the conservative side.
|
|
72
|
+
*/
|
|
73
|
+
const styleBlockHasBackground = (css: string): boolean => {
|
|
74
|
+
// Match `background:` or `background-color:` followed by its value
|
|
75
|
+
// (everything up to the next `;`, `}`, or end of string).
|
|
76
|
+
const re = /background(?:-color)?\s*:\s*([^;}\n]+)/gi;
|
|
77
|
+
let match: RegExpExecArray | null;
|
|
78
|
+
match = re.exec(css);
|
|
79
|
+
while (match !== null) {
|
|
80
|
+
const value = match[1].trim();
|
|
81
|
+
if (!NO_BACKGROUND_VALUES.test(value)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
match = re.exec(css);
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Detect whether the input HTML contains an author-specified background.
|
|
91
|
+
* Runs over the raw HTML string before sanitization — a real CSS parser is
|
|
92
|
+
* overkill here because the false-positive surface is "the literal word
|
|
93
|
+
* 'background' inside a `<style>` block or a `style=` attribute", and that's
|
|
94
|
+
* exactly the content we'd parse anyway. Match on:
|
|
95
|
+
* - any `style="…background…"` (or `style='…'`)
|
|
96
|
+
* - any `bgcolor=` attribute
|
|
97
|
+
* - any `<style>` block that contains a `background[-color]:` declaration
|
|
98
|
+
* with a non-trivial value (not `none`, `transparent`, `inherit`,
|
|
99
|
+
* `initial`, or `unset`) — tightened from the bare substring match that
|
|
100
|
+
* was over-matching reset stylesheets and `background: none` rules (#483)
|
|
101
|
+
*
|
|
102
|
+
* Author *text* colour alone (no background) does not trigger — that's the
|
|
103
|
+
* point: a plain `<p style="color:#666">…</p>` mail still blends with the
|
|
104
|
+
* app theme.
|
|
105
|
+
*/
|
|
106
|
+
export const detectAuthorBackground = (html: string): boolean => {
|
|
107
|
+
if (/\bstyle\s*=\s*["'][^"']*background/i.test(html)) return true;
|
|
108
|
+
if (/\bbgcolor\s*=/i.test(html)) return true;
|
|
109
|
+
for (const block of extractStyleBlocks(html)) {
|
|
110
|
+
if (styleBlockHasBackground(block)) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const FORBIDDEN_TAGS = [
|
|
116
|
+
"script",
|
|
117
|
+
"iframe",
|
|
118
|
+
"object",
|
|
119
|
+
"embed",
|
|
120
|
+
"form",
|
|
121
|
+
"input",
|
|
122
|
+
"button",
|
|
123
|
+
"textarea",
|
|
124
|
+
"select",
|
|
125
|
+
"meta",
|
|
126
|
+
"link",
|
|
127
|
+
"base",
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const FORBIDDEN_ATTR = [
|
|
131
|
+
// Event handlers
|
|
132
|
+
"onerror",
|
|
133
|
+
"onload",
|
|
134
|
+
"onclick",
|
|
135
|
+
"onmouseover",
|
|
136
|
+
"onmouseout",
|
|
137
|
+
"onfocus",
|
|
138
|
+
"onblur",
|
|
139
|
+
"onsubmit",
|
|
140
|
+
"onkeydown",
|
|
141
|
+
"onkeyup",
|
|
142
|
+
// Dangerous attributes
|
|
143
|
+
"formaction",
|
|
144
|
+
"xlink:href",
|
|
145
|
+
"data-bind",
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
// Placeholder for blocked images
|
|
149
|
+
const PLACEHOLDER_IMAGE =
|
|
150
|
+
"data:image/svg+xml," +
|
|
151
|
+
encodeURIComponent(`
|
|
152
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
|
153
|
+
<rect fill="#f0f0f0" width="100" height="100"/>
|
|
154
|
+
<text x="50" y="50" text-anchor="middle" dy=".3em" fill="#999" font-size="12">
|
|
155
|
+
Image blocked
|
|
156
|
+
</text>
|
|
157
|
+
</svg>
|
|
158
|
+
`);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Strip CSS constructs that double as injection vectors from an inline
|
|
162
|
+
* `style="..."` value:
|
|
163
|
+
* - `url(...)` — author can reference remote backgrounds, which leaks the
|
|
164
|
+
* read event the same way an `<img>` would. Privacy.
|
|
165
|
+
* - `expression(...)` — legacy IE CSS-as-JS escape hatch. XSS.
|
|
166
|
+
* - `-moz-binding: ...` — legacy Firefox CSS-to-XBL escape hatch. XSS.
|
|
167
|
+
*
|
|
168
|
+
* Author colors/backgrounds pass through unchanged — the email body is
|
|
169
|
+
* rendered as a light-mode island (see `IsolatedEmailFrame`'s
|
|
170
|
+
* `color-scheme: light`), so author CSS can stand on its own.
|
|
171
|
+
*/
|
|
172
|
+
export const sanitizeInlineStyle = (style: string): string => {
|
|
173
|
+
return style
|
|
174
|
+
.replace(/url\s*\([^)]*\)/gi, "none")
|
|
175
|
+
.replace(/expression\s*\([^)]*\)/gi, "")
|
|
176
|
+
.replace(/-moz-binding\s*:[^;]*/gi, "");
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Same security scrub as `sanitizeInlineStyle`, plus `@import` neutering for
|
|
181
|
+
* `<style>` block contents — `@import` can pull in remote stylesheets and
|
|
182
|
+
* leaks the read event.
|
|
183
|
+
*
|
|
184
|
+
* Crucially, this does NOT wrap the author's CSS in a media query: the email
|
|
185
|
+
* subtree is pinned to light-mode at the wrapper level, so author CSS that
|
|
186
|
+
* targets `body { background: white; color: black; }` keeps applying.
|
|
187
|
+
*/
|
|
188
|
+
export const sanitizeStyleElementCss = (css: string): string => {
|
|
189
|
+
return css
|
|
190
|
+
.replace(/@import[^;]*;/gi, "/* @import blocked */")
|
|
191
|
+
.replace(/url\s*\([^)]*\)/gi, "none")
|
|
192
|
+
.replace(/expression\s*\([^)]*\)/gi, "")
|
|
193
|
+
.replace(/-moz-binding\s*:[^;]*/gi, "");
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ============================================
|
|
197
|
+
// Main Sanitizer
|
|
198
|
+
// ============================================
|
|
199
|
+
|
|
200
|
+
export const createEmailSanitizer = (options: SanitizeOptions = {}) => {
|
|
201
|
+
const purify = DOMPurify();
|
|
202
|
+
|
|
203
|
+
const config = {
|
|
204
|
+
FORBID_TAGS: FORBIDDEN_TAGS,
|
|
205
|
+
FORBID_ATTR: FORBIDDEN_ATTR,
|
|
206
|
+
ALLOW_DATA_ATTR: false,
|
|
207
|
+
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Hook: Process elements after attribute sanitization
|
|
211
|
+
purify.addHook("afterSanitizeAttributes", (node) => {
|
|
212
|
+
// Handle images
|
|
213
|
+
if (node.tagName === "IMG") {
|
|
214
|
+
const src = node.getAttribute("src") || "";
|
|
215
|
+
|
|
216
|
+
if (src.startsWith("data:")) {
|
|
217
|
+
// Allow data URIs (inline images)
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (src.startsWith("cid:")) {
|
|
222
|
+
// `cid:` references inline body parts. Look up the matching
|
|
223
|
+
// body part by Content-ID and rewrite to the CloudFront
|
|
224
|
+
// `contentUrl` (#224 PR 2). The Content-ID in the URL may
|
|
225
|
+
// or may not be wrapped in angle brackets — strip them so
|
|
226
|
+
// the comparison is consistent across mail clients.
|
|
227
|
+
const raw = src.slice(4);
|
|
228
|
+
const stripped = raw.replace(/^<|>$/g, "");
|
|
229
|
+
const resolved = options.resolveCid?.(stripped);
|
|
230
|
+
if (resolved) {
|
|
231
|
+
node.setAttribute("src", resolved);
|
|
232
|
+
node.classList.add("inline-content");
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!options.allowExternalImages) {
|
|
238
|
+
// Store original src for "load images" feature
|
|
239
|
+
node.setAttribute("data-blocked-src", src);
|
|
240
|
+
node.setAttribute("src", PLACEHOLDER_IMAGE);
|
|
241
|
+
node.setAttribute("alt", "[Blocked image]");
|
|
242
|
+
node.classList.add("blocked-image");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Handle links
|
|
247
|
+
if (node.tagName === "A") {
|
|
248
|
+
const href = node.getAttribute("href") || "";
|
|
249
|
+
|
|
250
|
+
// Block javascript: and data: URLs
|
|
251
|
+
if (/^(javascript|data):/i.test(href)) {
|
|
252
|
+
node.removeAttribute("href");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Make external links safe
|
|
257
|
+
node.setAttribute("target", "_blank");
|
|
258
|
+
node.setAttribute("rel", "noopener noreferrer nofollow");
|
|
259
|
+
|
|
260
|
+
// Add visual indicator for external links
|
|
261
|
+
node.classList.add("external-link");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Sanitize inline styles — security only, colors pass through.
|
|
265
|
+
if (node.hasAttribute("style")) {
|
|
266
|
+
const style = node.getAttribute("style") || "";
|
|
267
|
+
node.setAttribute("style", sanitizeInlineStyle(style));
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Hook: Process style elements
|
|
272
|
+
purify.addHook("uponSanitizeElement", (node, data) => {
|
|
273
|
+
if (data.tagName === "style") {
|
|
274
|
+
const css = node.textContent || "";
|
|
275
|
+
node.textContent = sanitizeStyleElementCss(css);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return (html: string): SanitizedEmail => {
|
|
280
|
+
// Detect BEFORE sanitization: DOMPurify keeps `style=` / `bgcolor=` /
|
|
281
|
+
// author `<style>` content for us (we deliberately preserve them in
|
|
282
|
+
// #375), but running the detector on the raw input is simpler and
|
|
283
|
+
// not sensitive to any future hook rewrites.
|
|
284
|
+
const hasAuthorBackground = detectAuthorBackground(html);
|
|
285
|
+
|
|
286
|
+
const sanitized = purify.sanitize(html, config);
|
|
287
|
+
|
|
288
|
+
// Layout clamp always applies — it keeps wide author markup (fixed-width
|
|
289
|
+
// tables, oversized images, long URLs) from breaking out of the column
|
|
290
|
+
// and unlocking horizontal page scroll on mobile (#374). Independent of
|
|
291
|
+
// any color concerns because it touches layout only.
|
|
292
|
+
const layoutCss = generateLayoutClampCSS();
|
|
293
|
+
return {
|
|
294
|
+
html: `<style>${layoutCss}</style>${sanitized}`,
|
|
295
|
+
hasAuthorBackground,
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isSelfRowActivation } from "./row-keyboard.js";
|
|
4
|
+
|
|
5
|
+
// Two distinct nodes standing in for the row (currentTarget) and a nested
|
|
6
|
+
// control such as the trailing star / Clear button (a descendant target).
|
|
7
|
+
const row = { id: "row" } as unknown as EventTarget;
|
|
8
|
+
const innerButton = { id: "star" } as unknown as EventTarget;
|
|
9
|
+
|
|
10
|
+
describe("isSelfRowActivation (#1232 keyboard twin-action guard)", () => {
|
|
11
|
+
it("activates on Enter/Space that originate on the row itself", () => {
|
|
12
|
+
for (const key of ["Enter", " "]) {
|
|
13
|
+
assert.equal(
|
|
14
|
+
isSelfRowActivation({ key, target: row, currentTarget: row }),
|
|
15
|
+
true,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("ignores Enter/Space bubbled up from a nested control (no twin action)", () => {
|
|
21
|
+
for (const key of ["Enter", " "]) {
|
|
22
|
+
assert.equal(
|
|
23
|
+
isSelfRowActivation({
|
|
24
|
+
key,
|
|
25
|
+
target: innerButton,
|
|
26
|
+
currentTarget: row,
|
|
27
|
+
}),
|
|
28
|
+
false,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("ignores other keys even on the row itself", () => {
|
|
34
|
+
for (const key of ["Tab", "a", "ArrowDown", "Escape"]) {
|
|
35
|
+
assert.equal(
|
|
36
|
+
isSelfRowActivation({ key, target: row, currentTarget: row }),
|
|
37
|
+
false,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `role="button"` row that holds its own nested controls (a star toggle, a
|
|
3
|
+
* Clear button) can't be a native `<button>` — button-in-button is invalid HTML
|
|
4
|
+
* (#1232). The row wires Enter/Space by hand, but a keydown on a focused inner
|
|
5
|
+
* button bubbles up to the row's `onKeyDown`. Without a guard the row would
|
|
6
|
+
* activate too, firing two actions from one keypress (a native `<button>`
|
|
7
|
+
* ancestor never did this).
|
|
8
|
+
*
|
|
9
|
+
* Act only on keys that originate on the row itself (`target === currentTarget`),
|
|
10
|
+
* so a key bubbled from a descendant control is ignored. This is the row-level
|
|
11
|
+
* twin of the child button's `stopPropagation()` on click.
|
|
12
|
+
*/
|
|
13
|
+
export function isSelfRowActivation(e: {
|
|
14
|
+
key: string;
|
|
15
|
+
target: EventTarget | null;
|
|
16
|
+
currentTarget: EventTarget | null;
|
|
17
|
+
}): boolean {
|
|
18
|
+
if (e.target !== e.currentTarget) return false;
|
|
19
|
+
return e.key === "Enter" || e.key === " ";
|
|
20
|
+
}
|
package/src/tokens.css
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* Self-hosted variable fonts (no CDN). Swap the active UI font in ONE
|
|
4
|
+
line below: change --font-ui to one of the three @theme aliases. */
|
|
5
|
+
@import "@fontsource-variable/geist";
|
|
6
|
+
@import "@fontsource-variable/hanken-grotesk";
|
|
7
|
+
@import "@fontsource-variable/inter";
|
|
8
|
+
|
|
9
|
+
/* Tailwind v4 dark variant: dark: utilities apply when any ancestor
|
|
10
|
+
carries the .dark class. The workbench theme decorator toggles
|
|
11
|
+
.dark on a wrapper; an app would toggle it on <html>. */
|
|
12
|
+
@custom-variant dark (&:is(.dark *));
|
|
13
|
+
|
|
14
|
+
/* @theme inline aliases Tailwind utilities (bg-surface, text-fg, etc.)
|
|
15
|
+
to the semantic CSS variables declared on :root / .dark below. The
|
|
16
|
+
:root / .dark blocks MUST come after this @theme block — otherwise
|
|
17
|
+
Tailwind hoists them and the dark scope collapses. */
|
|
18
|
+
@theme inline {
|
|
19
|
+
/* font candidates — A/B by repointing --font-ui */
|
|
20
|
+
--font-geist: "Geist Variable", system-ui, sans-serif;
|
|
21
|
+
--font-hanken: "Hanken Grotesk Variable", system-ui, sans-serif;
|
|
22
|
+
--font-inter: "Inter Variable", system-ui, sans-serif;
|
|
23
|
+
|
|
24
|
+
/* the single active-font token — change this one line to swap */
|
|
25
|
+
--font-ui: var(--font-geist);
|
|
26
|
+
--font-sans: var(--font-ui);
|
|
27
|
+
|
|
28
|
+
/* surfaces & text */
|
|
29
|
+
--color-canvas: var(--canvas);
|
|
30
|
+
--color-surface: var(--surface);
|
|
31
|
+
--color-surface-raised: var(--surface-raised);
|
|
32
|
+
--color-surface-sunken: var(--surface-sunken);
|
|
33
|
+
--color-fg: var(--fg);
|
|
34
|
+
--color-fg-muted: var(--fg-muted);
|
|
35
|
+
--color-fg-subtle: var(--fg-subtle);
|
|
36
|
+
|
|
37
|
+
/* lines */
|
|
38
|
+
--color-line: var(--line);
|
|
39
|
+
--color-line-strong: var(--line-strong);
|
|
40
|
+
|
|
41
|
+
/* brand accent (phosphor green): primary actions, links, unread */
|
|
42
|
+
--color-accent: var(--accent);
|
|
43
|
+
--color-accent-hover: var(--accent-hover);
|
|
44
|
+
--color-accent-fg: var(--accent-fg);
|
|
45
|
+
--color-accent-soft: var(--accent-soft);
|
|
46
|
+
|
|
47
|
+
/* secondary accent (cyan): selection states & informational chips */
|
|
48
|
+
--color-accent-2: var(--accent-2);
|
|
49
|
+
--color-accent-2-soft: var(--accent-2-soft);
|
|
50
|
+
|
|
51
|
+
/* status */
|
|
52
|
+
--color-positive: var(--positive);
|
|
53
|
+
--color-warning: var(--warning);
|
|
54
|
+
--color-danger: var(--danger);
|
|
55
|
+
--color-danger-soft: var(--danger-soft);
|
|
56
|
+
|
|
57
|
+
/* focus ring */
|
|
58
|
+
--color-ring: var(--ring);
|
|
59
|
+
|
|
60
|
+
/* radii — slight rounding only: hairline borders + subtle radius */
|
|
61
|
+
--radius-xs: 2px;
|
|
62
|
+
--radius-sm: 3px;
|
|
63
|
+
--radius-md: 5px;
|
|
64
|
+
--radius-lg: 7px;
|
|
65
|
+
--radius-xl: 10px;
|
|
66
|
+
|
|
67
|
+
/* vertical rhythm: 4px base unit, modular 1:2:3:5 scale.
|
|
68
|
+
Allowed vertical spacing steps: 4 / 8 / 12 / 20px (32px = 8 units
|
|
69
|
+
as the one outlier for big section breaks). In Tailwind terms:
|
|
70
|
+
py-1 / py-2 / py-3 / py-5 (py-8). Row heights land on the 4px
|
|
71
|
+
grid: compact row 32px, pane header 40px, section/expando row 48px,
|
|
72
|
+
comfortable message row 72px (section:message = 2:3). */
|
|
73
|
+
|
|
74
|
+
/* shared pane-header datum: every pane's first header row (mail list,
|
|
75
|
+
reading pane, intelligence, settings nav/content/help) is exactly
|
|
76
|
+
this tall so one continuous hairline runs across all panes.
|
|
77
|
+
40px = 10 grid units. */
|
|
78
|
+
--spacing-pane-header: 2.5rem;
|
|
79
|
+
|
|
80
|
+
/* section-row: the fixed height for section headers and expandos in
|
|
81
|
+
the inbox list. 48px = 2/3 of the 72px comfortable message row,
|
|
82
|
+
establishing the 2:3 rhythm. 12 grid units. */
|
|
83
|
+
--spacing-section-row: 3rem;
|
|
84
|
+
|
|
85
|
+
/* shared horizontal inset for dense list rows (mail list, brief,
|
|
86
|
+
senders table): content is inset (12px = 3 units), hover/active
|
|
87
|
+
highlight bleeds to the full pane width. */
|
|
88
|
+
--spacing-row-inset: 0.75rem;
|
|
89
|
+
|
|
90
|
+
/* type scale (1.20 minor third off 14px base) */
|
|
91
|
+
--text-2xs: 0.6875rem;
|
|
92
|
+
--text-xs: 0.75rem;
|
|
93
|
+
--text-sm: 0.8125rem;
|
|
94
|
+
--text-base: 0.875rem;
|
|
95
|
+
--text-md: 0.9375rem;
|
|
96
|
+
--text-lg: 1.0625rem;
|
|
97
|
+
--text-xl: 1.3125rem;
|
|
98
|
+
--text-2xl: 1.625rem;
|
|
99
|
+
|
|
100
|
+
/* spacing rhythm leans on Tailwind defaults; density is "calm" */
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ------------------------------------------------------------------ */
|
|
104
|
+
/* "Hipster modern retro" (2026), balanced for tired eyes. */
|
|
105
|
+
/* Solarized-inspired tinted neutrals with deliberately better text */
|
|
106
|
+
/* contrast than actual Solarized: warm cream paper in light, deep */
|
|
107
|
+
/* slate-teal in dark, cream-on-teal text pairing, phosphor-green */
|
|
108
|
+
/* accent, cyan secondary for selection/info. Danger stays the ONLY */
|
|
109
|
+
/* warm color in the UI so phishing banners are the loudest thing on */
|
|
110
|
+
/* screen. No pure white, no pure black anywhere. */
|
|
111
|
+
/* ------------------------------------------------------------------ */
|
|
112
|
+
|
|
113
|
+
/* ---------- LIGHT: warm paper ---------- */
|
|
114
|
+
:root {
|
|
115
|
+
/* warm cream, base3 direction but less yellow */
|
|
116
|
+
--canvas: oklch(0.96 0.015 90);
|
|
117
|
+
--surface: oklch(0.975 0.012 90);
|
|
118
|
+
--surface-raised: oklch(0.985 0.009 90);
|
|
119
|
+
--surface-sunken: oklch(0.935 0.018 90);
|
|
120
|
+
|
|
121
|
+
/* cool dark ink, never black (~12:1 on canvas) */
|
|
122
|
+
--fg: oklch(0.3 0.025 235);
|
|
123
|
+
--fg-muted: oklch(0.45 0.022 235);
|
|
124
|
+
--fg-subtle: oklch(0.55 0.02 235);
|
|
125
|
+
|
|
126
|
+
/* warm-tinted hairlines */
|
|
127
|
+
--line: oklch(0.89 0.015 90);
|
|
128
|
+
--line-strong: oklch(0.82 0.018 90);
|
|
129
|
+
|
|
130
|
+
/* desaturated-neon phosphor green, darkened for cream */
|
|
131
|
+
--accent: oklch(0.55 0.14 150);
|
|
132
|
+
--accent-hover: oklch(0.49 0.14 150);
|
|
133
|
+
--accent-fg: oklch(0.975 0.012 90);
|
|
134
|
+
--accent-soft: oklch(0.91 0.05 150);
|
|
135
|
+
|
|
136
|
+
/* slightly saturated cyan: selection & informational chips */
|
|
137
|
+
--accent-2: oklch(0.55 0.1 205);
|
|
138
|
+
--accent-2-soft: oklch(0.91 0.045 205);
|
|
139
|
+
|
|
140
|
+
/* positive converges toward the green family */
|
|
141
|
+
--positive: oklch(0.55 0.12 155);
|
|
142
|
+
/* muted amber, used sparingly */
|
|
143
|
+
--warning: oklch(0.65 0.13 80);
|
|
144
|
+
/* muted coral — the only warm color in the UI */
|
|
145
|
+
--danger: oklch(0.56 0.16 25);
|
|
146
|
+
--danger-soft: oklch(0.93 0.04 25);
|
|
147
|
+
|
|
148
|
+
--ring: oklch(0.55 0.14 150);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ---------- DARK: deep slate-teal, lifted off black ---------- */
|
|
152
|
+
.dark {
|
|
153
|
+
/* base03 direction */
|
|
154
|
+
--canvas: oklch(0.22 0.025 220);
|
|
155
|
+
--surface: oklch(0.25 0.025 220);
|
|
156
|
+
--surface-raised: oklch(0.28 0.025 220);
|
|
157
|
+
--surface-sunken: oklch(0.19 0.025 220);
|
|
158
|
+
|
|
159
|
+
/* warm off-white: cream text on teal slate (~12:1 on canvas) */
|
|
160
|
+
--fg: oklch(0.88 0.02 90);
|
|
161
|
+
--fg-muted: oklch(0.72 0.02 95);
|
|
162
|
+
--fg-subtle: oklch(0.58 0.022 110);
|
|
163
|
+
|
|
164
|
+
--line: oklch(0.3 0.025 220);
|
|
165
|
+
--line-strong: oklch(0.38 0.025 220);
|
|
166
|
+
|
|
167
|
+
/* phosphor green, brighter on slate */
|
|
168
|
+
--accent: oklch(0.78 0.16 150);
|
|
169
|
+
--accent-hover: oklch(0.83 0.15 150);
|
|
170
|
+
--accent-fg: oklch(0.2 0.04 150);
|
|
171
|
+
--accent-soft: oklch(0.32 0.06 150);
|
|
172
|
+
|
|
173
|
+
--accent-2: oklch(0.75 0.1 200);
|
|
174
|
+
--accent-2-soft: oklch(0.32 0.05 205);
|
|
175
|
+
|
|
176
|
+
--positive: oklch(0.74 0.13 155);
|
|
177
|
+
--warning: oklch(0.75 0.12 80);
|
|
178
|
+
--danger: oklch(0.68 0.15 25);
|
|
179
|
+
--danger-soft: oklch(0.32 0.07 25);
|
|
180
|
+
|
|
181
|
+
--ring: oklch(0.78 0.16 150);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
html {
|
|
185
|
+
color-scheme: light;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
html.dark,
|
|
189
|
+
.dark {
|
|
190
|
+
color-scheme: dark;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
body {
|
|
194
|
+
margin: 0;
|
|
195
|
+
background-color: var(--color-canvas);
|
|
196
|
+
color: var(--color-fg);
|
|
197
|
+
font-family: var(--font-ui);
|
|
198
|
+
-webkit-font-smoothing: antialiased;
|
|
199
|
+
text-rendering: optimizeLegibility;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* thin, unobtrusive scrollbars — calm density */
|
|
203
|
+
* {
|
|
204
|
+
scrollbar-width: thin;
|
|
205
|
+
scrollbar-color: var(--color-line-strong) transparent;
|
|
206
|
+
}
|
|
207
|
+
*::-webkit-scrollbar {
|
|
208
|
+
width: 8px;
|
|
209
|
+
height: 8px;
|
|
210
|
+
}
|
|
211
|
+
*::-webkit-scrollbar-thumb {
|
|
212
|
+
background-color: var(--color-line-strong);
|
|
213
|
+
border-radius: 9999px;
|
|
214
|
+
}
|
|
215
|
+
*::-webkit-scrollbar-track {
|
|
216
|
+
background: transparent;
|
|
217
|
+
}
|