@rapidmx/web-client 0.10.0 → 0.11.0
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/README.md +183 -2
- package/apps/admin/_layout.tsx +8 -2
- package/apps/admin/index.tsx +7 -1
- package/apps/admin/ingest-queue/index.tsx +2 -1
- package/apps/admin/mailboxes/[uid].tsx +23 -10
- package/apps/admin/quarantine/index.tsx +2 -1
- package/apps/admin/signing-certificates/index.tsx +343 -0
- package/apps/escrow/_layout.tsx +8 -2
- package/apps/shared/appearance/AppearanceForm.tsx +392 -0
- package/apps/shared/appearance/AppearanceHead.tsx +49 -0
- package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
- package/apps/shared/appearance/ColorField.tsx +107 -0
- package/apps/shared/appearance/appearanceCache.ts +106 -0
- package/apps/shared/appearance/appearanceContext.ts +77 -0
- package/apps/shared/appearance/bootScript.ts +35 -0
- package/apps/shared/appearance/color.ts +169 -0
- package/apps/shared/appearance/photo.ts +88 -0
- package/apps/shared/appearance/resolvedTheme.ts +67 -0
- package/apps/shared/appearance/theme.ts +355 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
- package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
- package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
- package/apps/shared/components/contacts/ContactForm.tsx +11 -5
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
- package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
- package/apps/shared/components/layout/AppShell.tsx +98 -32
- package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
- package/apps/shared/components/layout/RailIcon.tsx +100 -0
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
- package/apps/shared/components/layout/UserMenu.tsx +75 -6
- package/apps/shared/components/mail/ConversationList.tsx +4 -2
- package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
- package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
- package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
- package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
- package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
- package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
- package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
- package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
- package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
- package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
- package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
- package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
- package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
- package/apps/shared/components/mail/reading/color.ts +245 -0
- package/apps/shared/components/mail/reading/frameControl.ts +142 -0
- package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
- package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
- package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
- package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
- package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
- package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
- package/apps/shared/mail/folderCounts.ts +9 -3
- package/apps/shared/mail/folderTree.ts +143 -0
- package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
- package/apps/shared/mail/outbox/composeBridge.ts +69 -0
- package/apps/shared/mail/outbox/outboxState.ts +88 -0
- package/apps/shared/mail/outbox/pendingSends.ts +146 -0
- package/apps/shared/mail/outbox/sendDecision.ts +157 -0
- package/apps/shared/mail/outbox/sendJob.ts +427 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
- package/apps/shared/mail/outbox/sendState.ts +35 -0
- package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
- package/apps/shared/mail/useMailConnection.ts +81 -28
- package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
- package/apps/shared/mail/useNewMailNotifications.ts +47 -32
- package/apps/shared/navigation/AppRouter.tsx +2 -2
- package/apps/shared/notifications/NotificationCenter.tsx +215 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
- package/apps/shared/notifications/apiErrors.ts +98 -0
- package/apps/shared/notifications/headerOffset.ts +35 -0
- package/apps/shared/notifications/pushStatus.ts +56 -0
- package/apps/shared/notifications/store.ts +549 -0
- package/apps/shared/notifications/systemErrors.ts +63 -0
- package/apps/shared/notifications/useNotifications.ts +41 -0
- package/apps/shared/signing/enrollmentStorage.ts +33 -0
- package/apps/shared/signing/enrollmentTracker.ts +385 -0
- package/apps/shared/signing/enrollmentView.ts +251 -0
- package/apps/shared/signing/signingInfo.ts +58 -0
- package/apps/shared/signing/useNow.ts +19 -0
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
- package/apps/shared/styles/app.css +80 -0
- package/apps/www/_layout.tsx +8 -2
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +9 -14
- package/apps/www/contacts/[uid].tsx +5 -8
- package/apps/www/contacts/index.tsx +587 -584
- package/apps/www/index.tsx +38 -27
- package/apps/www/messages/[uid].tsx +3 -1
- package/apps/www/settings/appearance/index.tsx +21 -0
- package/apps/www/settings/auto-reply/index.tsx +3 -7
- package/apps/www/settings/encryption/index.tsx +151 -116
- package/apps/www/settings/filters/[uid].tsx +3 -1
- package/apps/www/settings/filters/new/index.tsx +3 -1
- package/apps/www/settings/privacy/index.tsx +3 -8
- package/apps/www/settings/read-receipts/index.tsx +3 -7
- package/apps/www/settings/sharing/index.tsx +44 -65
- package/apps/www/settings/signatures/[uid].tsx +3 -1
- package/apps/www/settings/signatures/new/index.tsx +3 -2
- package/apps/www/tasks/index.tsx +11 -16
- package/dist/apps/admin/_layout.d.ts +5 -1
- package/dist/apps/admin/_layout.js +3 -2
- package/dist/apps/admin/index.js +3 -2
- package/dist/apps/admin/ingest-queue/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +9 -6
- package/dist/apps/admin/quarantine/index.js +2 -1
- package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
- package/dist/apps/admin/signing-certificates/index.js +161 -0
- package/dist/apps/escrow/_layout.d.ts +5 -1
- package/dist/apps/escrow/_layout.js +3 -2
- package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
- package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
- package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
- package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
- package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
- package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
- package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
- package/dist/apps/shared/appearance/ColorField.js +40 -0
- package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceCache.js +80 -0
- package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceContext.js +29 -0
- package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
- package/dist/apps/shared/appearance/bootScript.js +33 -0
- package/dist/apps/shared/appearance/color.d.ts +65 -0
- package/dist/apps/shared/appearance/color.js +132 -0
- package/dist/apps/shared/appearance/photo.d.ts +17 -0
- package/dist/apps/shared/appearance/photo.js +79 -0
- package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
- package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
- package/dist/apps/shared/appearance/theme.d.ts +111 -0
- package/dist/apps/shared/appearance/theme.js +251 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
- package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
- package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
- package/dist/apps/shared/components/layout/AppShell.js +59 -13
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
- package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
- package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
- package/dist/apps/shared/components/layout/RailIcon.js +85 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
- package/dist/apps/shared/components/layout/UserMenu.js +41 -7
- package/dist/apps/shared/components/mail/ConversationList.js +3 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
- package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
- package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
- package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
- package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
- package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
- package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
- package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
- package/dist/apps/shared/components/mail/reading/color.js +201 -0
- package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
- package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
- package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
- package/dist/apps/shared/mail/folderCounts.js +9 -3
- package/dist/apps/shared/mail/folderTree.d.ts +55 -0
- package/dist/apps/shared/mail/folderTree.js +116 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
- package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
- package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
- package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
- package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
- package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
- package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
- package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
- package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
- package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
- package/dist/apps/shared/mail/outbox/sendState.js +8 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
- package/dist/apps/shared/mail/useMailConnection.js +64 -22
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
- package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
- package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
- package/dist/apps/shared/navigation/AppRouter.js +2 -2
- package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
- package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
- package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
- package/dist/apps/shared/notifications/apiErrors.js +86 -0
- package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
- package/dist/apps/shared/notifications/headerOffset.js +33 -0
- package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
- package/dist/apps/shared/notifications/pushStatus.js +54 -0
- package/dist/apps/shared/notifications/store.d.ts +136 -0
- package/dist/apps/shared/notifications/store.js +416 -0
- package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
- package/dist/apps/shared/notifications/systemErrors.js +58 -0
- package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
- package/dist/apps/shared/notifications/useNotifications.js +18 -0
- package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
- package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
- package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
- package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
- package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
- package/dist/apps/shared/signing/enrollmentView.js +214 -0
- package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
- package/dist/apps/shared/signing/signingInfo.js +48 -0
- package/dist/apps/shared/signing/useNow.d.ts +2 -0
- package/dist/apps/shared/signing/useNow.js +18 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
- package/dist/apps/shared/styles/app.css +80 -0
- package/dist/apps/www/_layout.d.ts +5 -1
- package/dist/apps/www/_layout.js +3 -2
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +3 -4
- package/dist/apps/www/contacts/[uid].js +5 -7
- package/dist/apps/www/contacts/index.js +14 -21
- package/dist/apps/www/index.js +37 -19
- package/dist/apps/www/messages/[uid].js +3 -1
- package/dist/apps/www/settings/appearance/index.d.ts +4 -0
- package/dist/apps/www/settings/appearance/index.js +13 -0
- package/dist/apps/www/settings/auto-reply/index.js +3 -6
- package/dist/apps/www/settings/encryption/index.js +103 -95
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/new/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +5 -8
- package/dist/apps/www/settings/read-receipts/index.js +3 -6
- package/dist/apps/www/settings/sharing/index.js +19 -33
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/new/index.js +3 -2
- package/dist/apps/www/tasks/index.js +8 -14
- package/package.json +2 -2
- package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
- package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { ApiRequestError, apiUrl } from "@rapidmx/react-shared/util/api.js";
|
|
6
|
+
import { attachmentContentUrl } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
/** How many bodies are remembered for the session - a thread that is expanded, collapsed and expanded again does not ask the server twice. */
|
|
8
|
+
const CACHE_LIMIT = 40;
|
|
9
|
+
/** What the pane asks `/content` for. */
|
|
10
|
+
export const ACCEPT = "text/html, text/plain;q=0.8";
|
|
11
|
+
const cache = new Map();
|
|
12
|
+
/** Forgets every remembered body (sign-out, and the tests). */
|
|
13
|
+
export function clearBodyContentCache() {
|
|
14
|
+
cache.clear();
|
|
15
|
+
}
|
|
16
|
+
/** The remembered body for a message at a version, if any. */
|
|
17
|
+
export function cachedBodyContent(uid, version) {
|
|
18
|
+
return cache.get(`${uid}:${version}`);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
|
|
22
|
+
* (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
|
|
23
|
+
* Successful answers are remembered per uid and version.
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchBodyContent(uid, version, signal) {
|
|
26
|
+
const cached = cachedBodyContent(uid, version);
|
|
27
|
+
if (cached) {
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
// Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
|
|
31
|
+
const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), {
|
|
32
|
+
credentials: "include",
|
|
33
|
+
headers: { Accept: ACCEPT },
|
|
34
|
+
signal,
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
38
|
+
const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
|
|
39
|
+
throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
|
|
40
|
+
}
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
const content = (res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
|
|
43
|
+
? { kind: "html", html: text }
|
|
44
|
+
: { kind: "text", text };
|
|
45
|
+
if (cache.size >= CACHE_LIMIT) {
|
|
46
|
+
cache.delete(cache.keys().next().value);
|
|
47
|
+
}
|
|
48
|
+
cache.set(`${uid}:${version}`, content);
|
|
49
|
+
return content;
|
|
50
|
+
}
|
|
51
|
+
/** The largest inline image embedded into a body from a signed or encrypted message. */
|
|
52
|
+
export const MAX_INLINE_IMAGE_BYTES = 5000000;
|
|
53
|
+
const INLINE_IMAGE_TYPES = /^image\/(?:png|jpe?g|gif|webp|avif|bmp|svg\+xml)$/;
|
|
54
|
+
function base64(bytes) {
|
|
55
|
+
let binary = "";
|
|
56
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
57
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
58
|
+
}
|
|
59
|
+
return btoa(binary);
|
|
60
|
+
}
|
|
61
|
+
/** The absolute URL of an attachment - the frame is a document of its own, where a relative URL would not mean what it does here. */
|
|
62
|
+
export function absoluteAttachmentUrl(uid) {
|
|
63
|
+
return new URL(attachmentContentUrl(uid), window.location.href).href;
|
|
64
|
+
}
|
|
65
|
+
/** The CSP source that allows exactly this server's attachment URLs and nothing else. */
|
|
66
|
+
export function attachmentSource() {
|
|
67
|
+
return new URL(apiUrl("/mail/attachments/"), window.location.href).href;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* How an inline `cid:` image is resolved to something the frame may load: for a message shown from the server, the attachment whose
|
|
71
|
+
* `Content-ID` matches (its URL on this server); for a decrypted or verified one, the part inside the signed/encrypted entity, embedded
|
|
72
|
+
* as a `data:` URI (raster and SVG images up to `MAX_INLINE_IMAGE_BYTES`). Anything else has no source, so the image is not shown.
|
|
73
|
+
*/
|
|
74
|
+
export function makeCidResolver(attachments, parts) {
|
|
75
|
+
return (contentId) => {
|
|
76
|
+
if (parts) {
|
|
77
|
+
const part = parts.find((candidate) => candidate.contentId === contentId);
|
|
78
|
+
const bytes = part && INLINE_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
|
|
79
|
+
return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ? `data:${part.contentType};base64,${base64(bytes)}` : undefined;
|
|
80
|
+
}
|
|
81
|
+
const attachment = attachments?.find((candidate) => candidate.contentId === contentId);
|
|
82
|
+
return attachment ? absoluteAttachmentUrl(attachment.uid) : undefined;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a message's HTML - which is hostile until proven otherwise - into the fragment that goes into its display frame.
|
|
3
|
+
*
|
|
4
|
+
* Four independent layers stand between a mail and script execution, and this file is two of them:
|
|
5
|
+
*
|
|
6
|
+
* 1. the server's own allow-list sanitizer (restapi's `ScanPipeline`), before the body is stored;
|
|
7
|
+
* 2. **this file**: DOMPurify (react-shared's `sanitizeMessageBodyHtml()` - scripts, `on*` handlers, `javascript:` URLs, and every
|
|
8
|
+
* remote resource reference removed) followed by a second, structural pass of our own that removes what DOMPurify's defaults
|
|
9
|
+
* allow but a mail body never needs (forms, frames, media, SVG's active elements, every non-`http(s)`/`mailto`/`tel` link);
|
|
10
|
+
* 3. the frame's Content-Security-Policy (`frameDocument.ts`): no script, no object, no frame, no form, nothing fetched;
|
|
11
|
+
* 4. the frame's `sandbox` attribute, which never has `allow-scripts`, so nothing executes even if all of the above failed.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here parses into the app's own document: every parse is a `DOMParser` document, which has no browsing context, runs no
|
|
14
|
+
* script and fetches nothing.
|
|
15
|
+
*/
|
|
16
|
+
/** A body longer than this many characters is not sanitized or shown inline - DOMPurify on megabytes of markup would freeze the tab. */
|
|
17
|
+
export declare const MAX_BODY_HTML_LENGTH = 1500000;
|
|
18
|
+
/** A body with more elements than this is not shown inline either: it cannot be laid out and adapted in a reasonable time. */
|
|
19
|
+
export declare const MAX_BODY_ELEMENTS = 20000;
|
|
20
|
+
export interface PreparedBody {
|
|
21
|
+
/** `too_large` means `html` is empty and the caller should offer the message on its own page instead. */
|
|
22
|
+
status: "ok" | "too_large";
|
|
23
|
+
/** The sanitized fragment: one wrapper `div.rr-msg` holding the message's own `<style>` elements and body. */
|
|
24
|
+
html: string;
|
|
25
|
+
/** The mail carries its own dark styles: a `color-scheme` meta or property, or a `prefers-color-scheme: dark` block. */
|
|
26
|
+
declaresDarkSupport: boolean;
|
|
27
|
+
/** How many `<img>`s point at an inline part (`cid:`) - the frame's CSP only needs an image source for the attachments then. */
|
|
28
|
+
inlineImages: number;
|
|
29
|
+
}
|
|
30
|
+
export interface PrepareOptions {
|
|
31
|
+
/** The URL an inline image's `cid:` reference stands for, or `undefined` when there is no such part. Only `data:` URIs of an
|
|
32
|
+
* image type and `http(s)` URLs are accepted; anything else is treated as unresolved. */
|
|
33
|
+
resolveCid?: (contentId: string) => string | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** CSS with the constructs above neutralised. */
|
|
36
|
+
export declare function sanitizeCss(css: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a mail's own markup declares that it supports a dark scheme: `<meta name="color-scheme">` naming dark,
|
|
39
|
+
* a `color-scheme` property naming dark in a stylesheet or a `style` attribute, or an `@media (prefers-color-scheme: dark)` block. Such a mail
|
|
40
|
+
* brings its own dark styles, which the display honours instead of adapting the mail's colours.
|
|
41
|
+
*/
|
|
42
|
+
export declare function declaresDarkSupport(doc: Document): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The display fragment for a message's HTML - see the file's header. `too_large` (and an empty `html`) for a body over
|
|
45
|
+
* `MAX_BODY_HTML_LENGTH` characters or `MAX_BODY_ELEMENTS` elements; an empty fragment where there is no DOM to parse with.
|
|
46
|
+
*/
|
|
47
|
+
export declare function prepareBodyHtml(raw: string, options?: PrepareOptions): PreparedBody;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
|
|
6
|
+
import { parseColour } from "./color.js";
|
|
7
|
+
/**
|
|
8
|
+
* Turning a message's HTML - which is hostile until proven otherwise - into the fragment that goes into its display frame.
|
|
9
|
+
*
|
|
10
|
+
* Four independent layers stand between a mail and script execution, and this file is two of them:
|
|
11
|
+
*
|
|
12
|
+
* 1. the server's own allow-list sanitizer (restapi's `ScanPipeline`), before the body is stored;
|
|
13
|
+
* 2. **this file**: DOMPurify (react-shared's `sanitizeMessageBodyHtml()` - scripts, `on*` handlers, `javascript:` URLs, and every
|
|
14
|
+
* remote resource reference removed) followed by a second, structural pass of our own that removes what DOMPurify's defaults
|
|
15
|
+
* allow but a mail body never needs (forms, frames, media, SVG's active elements, every non-`http(s)`/`mailto`/`tel` link);
|
|
16
|
+
* 3. the frame's Content-Security-Policy (`frameDocument.ts`): no script, no object, no frame, no form, nothing fetched;
|
|
17
|
+
* 4. the frame's `sandbox` attribute, which never has `allow-scripts`, so nothing executes even if all of the above failed.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here parses into the app's own document: every parse is a `DOMParser` document, which has no browsing context, runs no
|
|
20
|
+
* script and fetches nothing.
|
|
21
|
+
*/
|
|
22
|
+
/** A body longer than this many characters is not sanitized or shown inline - DOMPurify on megabytes of markup would freeze the tab. */
|
|
23
|
+
export const MAX_BODY_HTML_LENGTH = 1500000;
|
|
24
|
+
/** A body with more elements than this is not shown inline either: it cannot be laid out and adapted in a reasonable time. */
|
|
25
|
+
export const MAX_BODY_ELEMENTS = 20000;
|
|
26
|
+
/** What a stylesheet or a `style` attribute may not contain, whatever the sanitizers before it did. */
|
|
27
|
+
const STYLE_HAZARDS = [
|
|
28
|
+
// Fixed and sticky positioning could cover the frame (or, with a viewport-sized box, all of it) with a fake dialog.
|
|
29
|
+
[/position\s*:\s*(?:fixed|sticky)/gi, "position:relative"],
|
|
30
|
+
// A length in viewport-height units is the frame's own height, which is the message's height: `min-height: 100vh` would make the frame grow
|
|
31
|
+
// by what its content adds on every measurement. As plain pixels, nothing in the message depends on the size of the frame it is in.
|
|
32
|
+
[/(\d(?:\.\d+)?)\s*(?:[sld]?v(?:h|min|max|b))\b/gi, "$1px"],
|
|
33
|
+
[/expression\s*\(/gi, "none("],
|
|
34
|
+
[/-moz-binding\s*:/gi, "-x-binding:"],
|
|
35
|
+
[/behavior\s*:/gi, "x-behavior:"],
|
|
36
|
+
[/@import[^;]*;?/gi, ""],
|
|
37
|
+
];
|
|
38
|
+
/** CSS with the constructs above neutralised. */
|
|
39
|
+
export function sanitizeCss(css) {
|
|
40
|
+
return STYLE_HAZARDS.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), css);
|
|
41
|
+
}
|
|
42
|
+
/** Elements removed together with everything inside them. */
|
|
43
|
+
const REMOVED_ELEMENTS = new Set([
|
|
44
|
+
"script",
|
|
45
|
+
"noscript",
|
|
46
|
+
"object",
|
|
47
|
+
"embed",
|
|
48
|
+
"applet",
|
|
49
|
+
"iframe",
|
|
50
|
+
"frame",
|
|
51
|
+
"frameset",
|
|
52
|
+
"noembed",
|
|
53
|
+
"noframes",
|
|
54
|
+
"audio",
|
|
55
|
+
"video",
|
|
56
|
+
"source",
|
|
57
|
+
"track",
|
|
58
|
+
"canvas",
|
|
59
|
+
"dialog",
|
|
60
|
+
"template",
|
|
61
|
+
"slot",
|
|
62
|
+
"portal",
|
|
63
|
+
"bgsound",
|
|
64
|
+
"xml",
|
|
65
|
+
"base",
|
|
66
|
+
"meta",
|
|
67
|
+
"link",
|
|
68
|
+
"title",
|
|
69
|
+
"head",
|
|
70
|
+
"input",
|
|
71
|
+
"select",
|
|
72
|
+
"textarea",
|
|
73
|
+
"datalist",
|
|
74
|
+
"option",
|
|
75
|
+
"optgroup",
|
|
76
|
+
"math",
|
|
77
|
+
// SVG's active or reference-following elements: a script, a foreign document, a use of another element, and animation
|
|
78
|
+
// (which can set an `href` or an event handler's target).
|
|
79
|
+
"foreignobject",
|
|
80
|
+
"use",
|
|
81
|
+
"set",
|
|
82
|
+
"animate",
|
|
83
|
+
"animatemotion",
|
|
84
|
+
"animatetransform",
|
|
85
|
+
]);
|
|
86
|
+
/** Elements dropped but not their content: a message wrapped in an old ASP.NET `<form>` is still a message. */
|
|
87
|
+
const UNWRAPPED_ELEMENTS = new Set(["form", "fieldset", "legend", "label", "button", "output", "marquee", "blink"]);
|
|
88
|
+
/** Attributes no element of a mail may keep, on top of every `on*` handler. */
|
|
89
|
+
const REMOVED_ATTRIBUTES = new Set([
|
|
90
|
+
"name",
|
|
91
|
+
"srcdoc",
|
|
92
|
+
"formaction",
|
|
93
|
+
"form",
|
|
94
|
+
"ping",
|
|
95
|
+
"nonce",
|
|
96
|
+
"is",
|
|
97
|
+
"slot",
|
|
98
|
+
"contenteditable",
|
|
99
|
+
"accesskey",
|
|
100
|
+
"tabindex",
|
|
101
|
+
"autofocus",
|
|
102
|
+
"draggable",
|
|
103
|
+
"srcset",
|
|
104
|
+
"crossorigin",
|
|
105
|
+
"integrity",
|
|
106
|
+
"referrerpolicy",
|
|
107
|
+
"http-equiv",
|
|
108
|
+
"xlink:href",
|
|
109
|
+
]);
|
|
110
|
+
/** The `href`s of a link a reader can follow: nothing that runs, nothing relative to this app, nothing that reads a local file. */
|
|
111
|
+
const LINK_URL = /^(?:https?:\/\/|mailto:|tel:)/i;
|
|
112
|
+
/** Inline images: raster types and SVG (an image context cannot run script or load anything), embedded as data. */
|
|
113
|
+
const IMAGE_DATA_URI = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|svg\+xml)[;,]/i;
|
|
114
|
+
const CSS_ESCAPE = /\\([0-9a-f]{1,6})\s?|\\(.)/gi;
|
|
115
|
+
/** Decodes CSS escapes, so a check for `color-scheme` cannot be dodged with `col\6fr-scheme`. */
|
|
116
|
+
function unescapeCss(css) {
|
|
117
|
+
return css.replace(CSS_ESCAPE, (_match, hex, other) => hex ? String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)) : other);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Whether a mail's own markup declares that it supports a dark scheme: `<meta name="color-scheme">` naming dark,
|
|
121
|
+
* a `color-scheme` property naming dark in a stylesheet or a `style` attribute, or an `@media (prefers-color-scheme: dark)` block. Such a mail
|
|
122
|
+
* brings its own dark styles, which the display honours instead of adapting the mail's colours.
|
|
123
|
+
*/
|
|
124
|
+
export function declaresDarkSupport(doc) {
|
|
125
|
+
const meta = doc.querySelector('meta[name="color-scheme" i]');
|
|
126
|
+
if (/\bdark\b/i.test(meta?.getAttribute("content") ?? "")) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
// An element's text is never null, and a `[style]` element always has the attribute.
|
|
130
|
+
const sheets = Array.from(doc.querySelectorAll("style"), (style) => style.textContent);
|
|
131
|
+
const inline = Array.from(doc.querySelectorAll("[style]"), (element) => element.getAttribute("style"));
|
|
132
|
+
return [...sheets, ...inline].some((css) => {
|
|
133
|
+
const text = unescapeCss(css);
|
|
134
|
+
return /color-scheme\s*:[^;}]*\bdark\b/i.test(text) || /prefers-color-scheme\s*:\s*dark/i.test(text);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** A `bgcolor`/`text`/`link` attribute's value as an `rgb()` colour if we can read it - nothing else is ever copied into a style.
|
|
138
|
+
* `bgcolor="ffffff"` (no hash) is what old templates write and what a browser accepts. */
|
|
139
|
+
function attributeColour(value) {
|
|
140
|
+
const text = (value ?? "").trim();
|
|
141
|
+
const colour = parseColour(/^[0-9a-f]{6}$/i.test(text) ? `#${text}` : text);
|
|
142
|
+
return colour && colour.a > 0 ? `rgb(${Math.round(colour.r)}, ${Math.round(colour.g)}, ${Math.round(colour.b)})` : undefined;
|
|
143
|
+
}
|
|
144
|
+
/** The wrapper `div.rr-msg`: the message's head `<style>`s first (DOMPurify drops a leading one as head content), then its body, with
|
|
145
|
+
* what a `<body bgcolor text link>` said carried over as the styles those attributes stand for. */
|
|
146
|
+
function wrapMessage(doc) {
|
|
147
|
+
const wrapper = doc.createElement("div");
|
|
148
|
+
wrapper.className = "rr-msg";
|
|
149
|
+
for (const style of Array.from(doc.head.querySelectorAll("style"))) {
|
|
150
|
+
wrapper.appendChild(style.cloneNode(true));
|
|
151
|
+
}
|
|
152
|
+
const [background, text, link] = ["bgcolor", "text", "link"].map((name) => attributeColour(doc.body.getAttribute(name)));
|
|
153
|
+
// What the `<body>` said about itself moves to the wrapper, which stands in for it: its presentational colours first (a `style` beats
|
|
154
|
+
// them), then its own `style`, its classes (a template's stylesheet may select on them) and its direction.
|
|
155
|
+
const declarations = [background && `background-color:${background}`, text && `color:${text}`, doc.body.getAttribute("style")].filter(Boolean);
|
|
156
|
+
if (declarations.length > 0) {
|
|
157
|
+
wrapper.setAttribute("style", declarations.join(";"));
|
|
158
|
+
}
|
|
159
|
+
const bodyClass = doc.body.getAttribute("class");
|
|
160
|
+
if (bodyClass) {
|
|
161
|
+
wrapper.className += ` ${bodyClass}`;
|
|
162
|
+
}
|
|
163
|
+
const direction = doc.body.getAttribute("dir");
|
|
164
|
+
if (direction) {
|
|
165
|
+
wrapper.setAttribute("dir", direction);
|
|
166
|
+
}
|
|
167
|
+
if (link) {
|
|
168
|
+
const rule = doc.createElement("style");
|
|
169
|
+
rule.textContent = `:where(.rr-msg a:link){color:${link}}`;
|
|
170
|
+
wrapper.appendChild(rule);
|
|
171
|
+
}
|
|
172
|
+
wrapper.append(...Array.from(doc.body.childNodes));
|
|
173
|
+
return wrapper.outerHTML;
|
|
174
|
+
}
|
|
175
|
+
/** Replaces `element` with its children. */
|
|
176
|
+
function unwrap(element) {
|
|
177
|
+
element.replaceWith(...Array.from(element.childNodes));
|
|
178
|
+
}
|
|
179
|
+
function hardenLink(element) {
|
|
180
|
+
const href = element.getAttribute("href")?.trim() ?? "";
|
|
181
|
+
if (LINK_URL.test(href)) {
|
|
182
|
+
element.setAttribute("href", href);
|
|
183
|
+
element.setAttribute("target", "_blank");
|
|
184
|
+
element.setAttribute("rel", "noopener noreferrer nofollow");
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
// A link that goes nowhere a reader could safely follow stays as text, without the address it hid.
|
|
188
|
+
element.removeAttribute("href");
|
|
189
|
+
element.removeAttribute("target");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function hardenImage(element, options) {
|
|
193
|
+
const src = (element.getAttribute("src") ?? "").trim();
|
|
194
|
+
const inline = /^cid:/i.test(src);
|
|
195
|
+
const target = inline ? options.resolveCid?.(src.slice(4).replace(/^<|>$/g, "")) : src;
|
|
196
|
+
// Remote images are never fetched (they would tell the sender the message was read); an inline image resolves to an embedded
|
|
197
|
+
// image or to this server's own attachment URL, and one that doesn't resolve has nothing to show.
|
|
198
|
+
if (target !== undefined && (IMAGE_DATA_URI.test(target) || (inline && /^https?:\/\//i.test(target)))) {
|
|
199
|
+
element.setAttribute("src", target);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
element.removeAttribute("src");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The second, structural pass over already-sanitized markup: everything a mail body has no use for is removed or unwrapped, every
|
|
207
|
+
* link is forced to open in a new tab without an opener, and hazards in stylesheets are neutralised. Runs on a detached document.
|
|
208
|
+
*/
|
|
209
|
+
function harden(body, options) {
|
|
210
|
+
let inlineImages = 0;
|
|
211
|
+
const all = Array.from(body.querySelectorAll("*"));
|
|
212
|
+
for (const element of all) {
|
|
213
|
+
const tag = element.localName.toLowerCase();
|
|
214
|
+
if (REMOVED_ELEMENTS.has(tag)) {
|
|
215
|
+
element.remove();
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (UNWRAPPED_ELEMENTS.has(tag)) {
|
|
219
|
+
unwrap(element);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
223
|
+
const name = attribute.name.toLowerCase();
|
|
224
|
+
if (name.startsWith("on") || REMOVED_ATTRIBUTES.has(name)) {
|
|
225
|
+
element.removeAttribute(attribute.name);
|
|
226
|
+
}
|
|
227
|
+
else if (name === "style") {
|
|
228
|
+
element.setAttribute("style", sanitizeCss(attribute.value));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (tag === "style") {
|
|
232
|
+
element.textContent = sanitizeCss(element.textContent);
|
|
233
|
+
}
|
|
234
|
+
else if (tag === "a" || tag === "area") {
|
|
235
|
+
hardenLink(element);
|
|
236
|
+
}
|
|
237
|
+
else if (tag === "img") {
|
|
238
|
+
if (/^cid:/i.test(element.getAttribute("src") ?? "")) {
|
|
239
|
+
inlineImages++;
|
|
240
|
+
}
|
|
241
|
+
hardenImage(element, options);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { elements: all.length, inlineImages };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The display fragment for a message's HTML - see the file's header. `too_large` (and an empty `html`) for a body over
|
|
248
|
+
* `MAX_BODY_HTML_LENGTH` characters or `MAX_BODY_ELEMENTS` elements; an empty fragment where there is no DOM to parse with.
|
|
249
|
+
*/
|
|
250
|
+
export function prepareBodyHtml(raw, options = {}) {
|
|
251
|
+
const empty = { html: "", declaresDarkSupport: false, inlineImages: 0 };
|
|
252
|
+
if (raw.length > MAX_BODY_HTML_LENGTH) {
|
|
253
|
+
return { status: "too_large", ...empty };
|
|
254
|
+
}
|
|
255
|
+
if (typeof DOMParser === "undefined") {
|
|
256
|
+
return { status: "ok", ...empty };
|
|
257
|
+
}
|
|
258
|
+
const parsed = new DOMParser().parseFromString(raw, "text/html");
|
|
259
|
+
const darkSupport = declaresDarkSupport(parsed);
|
|
260
|
+
const sanitized = sanitizeMessageBodyHtml(wrapMessage(parsed));
|
|
261
|
+
const cleaned = new DOMParser().parseFromString(`<!doctype html><body>${sanitized}`, "text/html").body;
|
|
262
|
+
const { elements, inlineImages } = harden(cleaned, options);
|
|
263
|
+
if (elements > MAX_BODY_ELEMENTS) {
|
|
264
|
+
return { status: "too_large", ...empty };
|
|
265
|
+
}
|
|
266
|
+
return { status: "ok", html: cleaned.innerHTML, declaresDarkSupport: darkSupport, inlineImages };
|
|
267
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** A colour as the browser reports it: channels 0-255 and an alpha 0-1. */
|
|
2
|
+
export interface Rgba {
|
|
3
|
+
r: number;
|
|
4
|
+
g: number;
|
|
5
|
+
b: number;
|
|
6
|
+
a: number;
|
|
7
|
+
}
|
|
8
|
+
/** A colour with no transparency worth speaking of - what a contrast ratio is computed between. */
|
|
9
|
+
export type Rgb = Pick<Rgba, "r" | "g" | "b">;
|
|
10
|
+
/**
|
|
11
|
+
* Reads the colour notations a browser's computed style and an old-fashioned `bgcolor` attribute produce: `#rgb`, `#rgba`,
|
|
12
|
+
* `#rrggbb`, `#rrggbbaa`, `rgb()`/`rgba()` in the comma and the space syntax, `color(srgb ...)`, `transparent` and a few names.
|
|
13
|
+
* `undefined` for anything else (a gradient, `currentcolor`, `oklch()`), which callers treat as "unknown, leave it alone".
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseColour(input: string | undefined | null): Rgba | undefined;
|
|
16
|
+
/** `rgb(r, g, b)` for an opaque colour, `rgba(...)` otherwise - what goes into a generated stylesheet. */
|
|
17
|
+
export declare function formatColour(colour: Rgba | Rgb): string;
|
|
18
|
+
/** `top` painted over `bottom`, as the opaque colour that results. */
|
|
19
|
+
export declare function composite(top: Rgba, bottom: Rgb): Rgb;
|
|
20
|
+
/** WCAG relative luminance, 0 (black) to 1 (white). */
|
|
21
|
+
export declare function luminance(colour: Rgb): number;
|
|
22
|
+
/** WCAG contrast ratio between two opaque colours, 1 to 21. */
|
|
23
|
+
export declare function contrastRatio(a: Rgb, b: Rgb): number;
|
|
24
|
+
/** The lowest contrast ratio WCAG accepts for body text. */
|
|
25
|
+
export declare const MIN_TEXT_CONTRAST = 4.5;
|
|
26
|
+
export interface Hsl {
|
|
27
|
+
h: number;
|
|
28
|
+
s: number;
|
|
29
|
+
l: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function rgbToHsl({ r, g, b }: Rgb): Hsl;
|
|
32
|
+
export declare function hslToRgb({ h, s, l }: Hsl): Rgb;
|
|
33
|
+
/** Whether two colours are the same to the eye's tolerance - what "unchanged" means when deciding to emit a rule. */
|
|
34
|
+
export declare function sameColour(a: Rgb, b: Rgb): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* `colour` moved along its own hue and saturation - lighter over a dark background, darker over a light one - until it reaches
|
|
37
|
+
* `min` contrast against `background`. A colour that already has it is returned untouched; one that cannot get there (a mid-grey
|
|
38
|
+
* background) becomes whichever of white and black reads better.
|
|
39
|
+
*/
|
|
40
|
+
export declare function ensureContrast(colour: Rgb, background: Rgb, min?: number): Rgb;
|
|
41
|
+
/** The theme a message is shown in: the opaque surface behind the body, its text and link colours, and whether it is a dark one. */
|
|
42
|
+
export interface ThemeSurface {
|
|
43
|
+
background: Rgb;
|
|
44
|
+
text: Rgb;
|
|
45
|
+
link: Rgb;
|
|
46
|
+
dark: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Whichever of black and white reads better on `background`. */
|
|
49
|
+
export declare function blackOrWhite(background: Rgb): Rgb;
|
|
50
|
+
/**
|
|
51
|
+
* An authored text colour that cannot be read against the theme's background, adapted so it can. A neutral (black, white, a
|
|
52
|
+
* grey) is flipped to the other end of the scale - black becomes the theme's text colour, a dark grey a light one, and in a
|
|
53
|
+
* light theme white becomes dark text - by placing it between the theme's text and background colours according to how far it
|
|
54
|
+
* is from the one it must not be. Any other colour keeps its hue and saturation and only has its lightness moved, as little as
|
|
55
|
+
* it takes to reach 4.5:1 (`ensureContrast()`), so navy stays a navy and a brand red stays red. One that already reads is
|
|
56
|
+
* returned untouched.
|
|
57
|
+
*/
|
|
58
|
+
export declare function adaptForeground(colour: Rgb, surface: ThemeSurface): Rgb;
|
|
59
|
+
/**
|
|
60
|
+
* The first colour in a computed `background-image` (a gradient's first stop): the best guess at what text over a gradient sits
|
|
61
|
+
* on. `undefined` for an image (`url(...)`) or anything with no colour in it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function firstColourStop(backgroundImage: string): Rgba | undefined;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** The handful of named colours a `bgcolor`/`text` attribute is realistically written with. Anything else is not guessed at. */
|
|
6
|
+
const NAMED_COLOURS = {
|
|
7
|
+
white: [255, 255, 255],
|
|
8
|
+
black: [0, 0, 0],
|
|
9
|
+
red: [255, 0, 0],
|
|
10
|
+
green: [0, 128, 0],
|
|
11
|
+
blue: [0, 0, 255],
|
|
12
|
+
yellow: [255, 255, 0],
|
|
13
|
+
gray: [128, 128, 128],
|
|
14
|
+
grey: [128, 128, 128],
|
|
15
|
+
silver: [192, 192, 192],
|
|
16
|
+
navy: [0, 0, 128],
|
|
17
|
+
teal: [0, 128, 128],
|
|
18
|
+
maroon: [128, 0, 0],
|
|
19
|
+
purple: [128, 0, 128],
|
|
20
|
+
orange: [255, 165, 0],
|
|
21
|
+
lime: [0, 255, 0],
|
|
22
|
+
aqua: [0, 255, 255],
|
|
23
|
+
fuchsia: [255, 0, 255],
|
|
24
|
+
olive: [128, 128, 0],
|
|
25
|
+
};
|
|
26
|
+
function clamp(value, min, max) {
|
|
27
|
+
return Math.min(max, Math.max(min, value));
|
|
28
|
+
}
|
|
29
|
+
/** One `rgb()` channel: a number 0-255, or a percentage of it. */
|
|
30
|
+
function channel(token) {
|
|
31
|
+
return token.endsWith("%") ? (parseFloat(token) / 100) * 255 : parseFloat(token);
|
|
32
|
+
}
|
|
33
|
+
/** An alpha: a number 0-1, or a percentage. */
|
|
34
|
+
function alpha(token) {
|
|
35
|
+
if (token === undefined) {
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
return token.endsWith("%") ? parseFloat(token) / 100 : parseFloat(token);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reads the colour notations a browser's computed style and an old-fashioned `bgcolor` attribute produce: `#rgb`, `#rgba`,
|
|
42
|
+
* `#rrggbb`, `#rrggbbaa`, `rgb()`/`rgba()` in the comma and the space syntax, `color(srgb ...)`, `transparent` and a few names.
|
|
43
|
+
* `undefined` for anything else (a gradient, `currentcolor`, `oklch()`), which callers treat as "unknown, leave it alone".
|
|
44
|
+
*/
|
|
45
|
+
export function parseColour(input) {
|
|
46
|
+
const text = (input ?? "").trim().toLowerCase();
|
|
47
|
+
if (text === "transparent") {
|
|
48
|
+
return { r: 0, g: 0, b: 0, a: 0 };
|
|
49
|
+
}
|
|
50
|
+
if (text in NAMED_COLOURS) {
|
|
51
|
+
const [r, g, b] = NAMED_COLOURS[text];
|
|
52
|
+
return { r, g, b, a: 1 };
|
|
53
|
+
}
|
|
54
|
+
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text)?.[1];
|
|
55
|
+
if (hex) {
|
|
56
|
+
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
|
|
57
|
+
const [r, g, b, a] = [0, 2, 4, 6].map((i) => (full.length > i ? parseInt(full.slice(i, i + 2), 16) : 255));
|
|
58
|
+
return { r, g, b, a: a / 255 };
|
|
59
|
+
}
|
|
60
|
+
const functional = /^(rgba?|color)\(\s*(?:srgb\s+)?([^)]*)\)$/.exec(text);
|
|
61
|
+
if (functional) {
|
|
62
|
+
const parts = functional[2].split(/[\s,/]+/).filter(Boolean);
|
|
63
|
+
if (parts.length < 3 || parts.length > 4 || parts.some((part) => !/^[-+]?(\d+\.?\d*|\.\d+)%?$/.test(part))) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const scale = functional[1] === "color" ? 255 : 1;
|
|
67
|
+
const [r, g, b] = parts.slice(0, 3).map((part) => clamp(channel(part) * scale, 0, 255));
|
|
68
|
+
return { r, g, b, a: clamp(alpha(parts[3]), 0, 1) };
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** `rgb(r, g, b)` for an opaque colour, `rgba(...)` otherwise - what goes into a generated stylesheet. */
|
|
73
|
+
export function formatColour(colour) {
|
|
74
|
+
const { r, g, b } = colour;
|
|
75
|
+
const a = "a" in colour ? colour.a : 1;
|
|
76
|
+
const rgb = [r, g, b].map((value) => Math.round(clamp(value, 0, 255))).join(", ");
|
|
77
|
+
return a >= 1 ? `rgb(${rgb})` : `rgba(${rgb}, ${Math.round(a * 1000) / 1000})`;
|
|
78
|
+
}
|
|
79
|
+
/** `top` painted over `bottom`, as the opaque colour that results. */
|
|
80
|
+
export function composite(top, bottom) {
|
|
81
|
+
return {
|
|
82
|
+
r: top.r * top.a + bottom.r * (1 - top.a),
|
|
83
|
+
g: top.g * top.a + bottom.g * (1 - top.a),
|
|
84
|
+
b: top.b * top.a + bottom.b * (1 - top.a),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function linear(value) {
|
|
88
|
+
const c = value / 255;
|
|
89
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
90
|
+
}
|
|
91
|
+
/** WCAG relative luminance, 0 (black) to 1 (white). */
|
|
92
|
+
export function luminance(colour) {
|
|
93
|
+
return 0.2126 * linear(colour.r) + 0.7152 * linear(colour.g) + 0.0722 * linear(colour.b);
|
|
94
|
+
}
|
|
95
|
+
/** WCAG contrast ratio between two opaque colours, 1 to 21. */
|
|
96
|
+
export function contrastRatio(a, b) {
|
|
97
|
+
const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
98
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
99
|
+
}
|
|
100
|
+
/** The lowest contrast ratio WCAG accepts for body text. */
|
|
101
|
+
export const MIN_TEXT_CONTRAST = 4.5;
|
|
102
|
+
export function rgbToHsl({ r, g, b }) {
|
|
103
|
+
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
|
|
104
|
+
const max = Math.max(rn, gn, bn);
|
|
105
|
+
const min = Math.min(rn, gn, bn);
|
|
106
|
+
const l = (max + min) / 2;
|
|
107
|
+
if (max === min) {
|
|
108
|
+
return { h: 0, s: 0, l };
|
|
109
|
+
}
|
|
110
|
+
const d = max - min;
|
|
111
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
112
|
+
const h = max === rn ? (gn - bn) / d + (gn < bn ? 6 : 0) : max === gn ? (bn - rn) / d + 2 : (rn - gn) / d + 4;
|
|
113
|
+
return { h: h * 60, s, l };
|
|
114
|
+
}
|
|
115
|
+
export function hslToRgb({ h, s, l }) {
|
|
116
|
+
const a = s * Math.min(l, 1 - l);
|
|
117
|
+
const f = (n) => {
|
|
118
|
+
const k = (n + h / 30) % 12;
|
|
119
|
+
return 255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)));
|
|
120
|
+
};
|
|
121
|
+
return { r: f(0), g: f(8), b: f(4) };
|
|
122
|
+
}
|
|
123
|
+
/** Whether two colours are the same to the eye's tolerance - what "unchanged" means when deciding to emit a rule. */
|
|
124
|
+
export function sameColour(a, b) {
|
|
125
|
+
return Math.abs(a.r - b.r) < 1 && Math.abs(a.g - b.g) < 1 && Math.abs(a.b - b.b) < 1;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* `colour` moved along its own hue and saturation - lighter over a dark background, darker over a light one - until it reaches
|
|
129
|
+
* `min` contrast against `background`. A colour that already has it is returned untouched; one that cannot get there (a mid-grey
|
|
130
|
+
* background) becomes whichever of white and black reads better.
|
|
131
|
+
*/
|
|
132
|
+
export function ensureContrast(colour, background, min = MIN_TEXT_CONTRAST) {
|
|
133
|
+
// Judged as the whole-number colour it will be written as: a fractional colour that reads can round to one that does not.
|
|
134
|
+
const whole = roundColour(colour);
|
|
135
|
+
if (contrastRatio(whole, background) >= min) {
|
|
136
|
+
return whole;
|
|
137
|
+
}
|
|
138
|
+
const target = luminance(background) < 0.5 ? 1 : 0;
|
|
139
|
+
const hsl = rgbToHsl(colour);
|
|
140
|
+
if (contrastRatio(hslToRgb({ ...hsl, l: target }), background) < min) {
|
|
141
|
+
return blackOrWhite(background);
|
|
142
|
+
}
|
|
143
|
+
let [near, far] = [hsl.l, target];
|
|
144
|
+
for (let i = 0; i < 16; i++) {
|
|
145
|
+
const middle = (near + far) / 2;
|
|
146
|
+
if (contrastRatio(roundColour(hslToRgb({ ...hsl, l: middle })), background) >= min) {
|
|
147
|
+
far = middle;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
near = middle;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return roundColour(hslToRgb({ ...hsl, l: far }));
|
|
154
|
+
}
|
|
155
|
+
function roundColour({ r, g, b }) {
|
|
156
|
+
return { r: Math.round(r), g: Math.round(g), b: Math.round(b) };
|
|
157
|
+
}
|
|
158
|
+
/** How far from grey a colour may be and still count as a neutral (black, white, greys) rather than a colour with a hue to keep. */
|
|
159
|
+
const NEUTRAL_SATURATION = 0.12;
|
|
160
|
+
/** Whichever of black and white reads better on `background`. */
|
|
161
|
+
export function blackOrWhite(background) {
|
|
162
|
+
const [white, black] = [
|
|
163
|
+
{ r: 255, g: 255, b: 255 },
|
|
164
|
+
{ r: 0, g: 0, b: 0 },
|
|
165
|
+
];
|
|
166
|
+
return contrastRatio(white, background) >= contrastRatio(black, background) ? white : black;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* An authored text colour that cannot be read against the theme's background, adapted so it can. A neutral (black, white, a
|
|
170
|
+
* grey) is flipped to the other end of the scale - black becomes the theme's text colour, a dark grey a light one, and in a
|
|
171
|
+
* light theme white becomes dark text - by placing it between the theme's text and background colours according to how far it
|
|
172
|
+
* is from the one it must not be. Any other colour keeps its hue and saturation and only has its lightness moved, as little as
|
|
173
|
+
* it takes to reach 4.5:1 (`ensureContrast()`), so navy stays a navy and a brand red stays red. One that already reads is
|
|
174
|
+
* returned untouched.
|
|
175
|
+
*/
|
|
176
|
+
export function adaptForeground(colour, surface) {
|
|
177
|
+
if (contrastRatio(colour, surface.background) >= MIN_TEXT_CONTRAST) {
|
|
178
|
+
return colour;
|
|
179
|
+
}
|
|
180
|
+
const hsl = rgbToHsl(colour);
|
|
181
|
+
if (hsl.s >= NEUTRAL_SATURATION) {
|
|
182
|
+
return ensureContrast(colour, surface.background);
|
|
183
|
+
}
|
|
184
|
+
// The distance from the end of the scale that would have been readable: a dark theme wants light text, so it is how dark
|
|
185
|
+
// the colour is; a light theme, how light.
|
|
186
|
+
const t = surface.dark ? hsl.l : 1 - hsl.l;
|
|
187
|
+
const mixed = {
|
|
188
|
+
r: surface.text.r + (surface.background.r - surface.text.r) * t,
|
|
189
|
+
g: surface.text.g + (surface.background.g - surface.text.g) * t,
|
|
190
|
+
b: surface.text.b + (surface.background.b - surface.text.b) * t,
|
|
191
|
+
};
|
|
192
|
+
return ensureContrast(mixed, surface.background);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The first colour in a computed `background-image` (a gradient's first stop): the best guess at what text over a gradient sits
|
|
196
|
+
* on. `undefined` for an image (`url(...)`) or anything with no colour in it.
|
|
197
|
+
*/
|
|
198
|
+
export function firstColourStop(backgroundImage) {
|
|
199
|
+
const match = /rgba?\([^)]*\)|#[0-9a-f]{3,8}\b/i.exec(backgroundImage);
|
|
200
|
+
return match ? parseColour(match[0]) : undefined;
|
|
201
|
+
}
|