@rapidmx/web-client 0.16.0 → 0.17.2
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 +36 -28
- package/apps/admin/distribution-lists/new/index.tsx +3 -1
- package/apps/admin/domains/[uid].tsx +3 -1
- package/apps/admin/domains/new/index.tsx +3 -1
- package/apps/admin/escrow-scopes/[uid].tsx +3 -1
- package/apps/admin/escrow-scopes/new/index.tsx +3 -1
- package/apps/admin/index.tsx +3 -1
- package/apps/admin/mailboxes/[uid].tsx +3 -1
- package/apps/admin/mailboxes/new/index.tsx +3 -1
- package/apps/admin/transport-rules/new/index.tsx +3 -1
- package/apps/escrow/matters/new/index.tsx +3 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +4 -1
- package/apps/shared/components/admin/setup/SetupWizard.tsx +3 -1
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +2 -2
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +2 -2
- package/apps/shared/components/layout/AppShell.tsx +24 -43
- package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -0
- package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +19 -15
- package/apps/shared/components/mail/MenuButton.tsx +31 -11
- package/apps/shared/components/mail/MessageDetailPane.tsx +202 -4
- package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +36 -34
- package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
- package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
- package/apps/shared/components/mail/reading/messageExport.ts +59 -0
- package/apps/shared/components/mail/reading/printMessage.ts +99 -0
- package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -2
- package/apps/shared/components/tasks/layout/TasksShell.tsx +2 -2
- package/apps/shared/keyboard/GlobalShortcuts.tsx +1 -1
- package/apps/shared/mail/folderOfType.ts +49 -0
- package/apps/shared/mail/permanentDelete.ts +160 -0
- package/apps/shared/mail/reportNotices.ts +66 -0
- package/apps/shared/mail/senderBlocking.ts +141 -0
- package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
- package/apps/shared/mail/usePermanentDelete.tsx +160 -0
- package/apps/shared/navigation/frameContext.tsx +1 -1
- package/apps/shared/navigation/index.ts +70 -0
- package/apps/shared/navigation/pageTitle.ts +19 -0
- package/apps/www/_shell.tsx +82 -0
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/contacts/[uid].tsx +6 -3
- package/apps/www/contacts/index.tsx +8 -3
- package/apps/www/index.tsx +146 -31
- package/apps/www/messages/[uid].tsx +8 -4
- package/apps/www/settings/appearance/index.tsx +5 -2
- package/apps/www/settings/auto-reply/index.tsx +5 -2
- package/apps/www/settings/blocked-senders/index.tsx +303 -0
- package/apps/www/settings/encryption/index.tsx +5 -2
- package/apps/www/settings/filters/[uid].tsx +5 -2
- package/apps/www/settings/filters/index.tsx +5 -2
- package/apps/www/settings/filters/new/index.tsx +30 -5
- package/apps/www/settings/labels/index.tsx +5 -2
- package/apps/www/settings/privacy/index.tsx +5 -2
- package/apps/www/settings/profile/index.tsx +5 -2
- package/apps/www/settings/read-receipts/index.tsx +5 -2
- package/apps/www/settings/sharing/index.tsx +5 -2
- package/apps/www/settings/signatures/[uid].tsx +5 -2
- package/apps/www/settings/signatures/index.tsx +5 -2
- package/apps/www/settings/signatures/new/index.tsx +6 -3
- package/apps/www/tasks/index.tsx +7 -2
- package/dist/apps/admin/distribution-lists/new/index.js +3 -1
- package/dist/apps/admin/domains/[uid].js +3 -1
- package/dist/apps/admin/domains/new/index.js +3 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
- package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
- package/dist/apps/admin/index.js +3 -1
- package/dist/apps/admin/mailboxes/[uid].js +3 -1
- package/dist/apps/admin/mailboxes/new/index.js +3 -1
- package/dist/apps/admin/transport-rules/new/index.js +3 -1
- package/dist/apps/escrow/matters/new/index.js +3 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +4 -1
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
- package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
- package/dist/apps/shared/components/layout/AppShell.js +24 -33
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
- package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
- package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +9 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +10 -12
- package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
- package/dist/apps/shared/components/mail/MenuButton.js +15 -11
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +13 -9
- package/dist/apps/shared/components/mail/layout/MailShell.js +26 -33
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
- package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
- package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
- package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
- package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
- package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
- package/dist/apps/shared/mail/folderOfType.js +40 -0
- package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
- package/dist/apps/shared/mail/permanentDelete.js +127 -0
- package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
- package/dist/apps/shared/mail/reportNotices.js +47 -0
- package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
- package/dist/apps/shared/mail/senderBlocking.js +92 -0
- package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
- package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
- package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
- package/dist/apps/shared/navigation/index.d.ts +27 -0
- package/dist/apps/shared/navigation/index.js +58 -0
- package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
- package/dist/apps/shared/navigation/pageTitle.js +12 -0
- package/dist/apps/www/_shell.d.ts +26 -0
- package/dist/apps/www/_shell.js +31 -0
- package/dist/apps/www/calendar/index.d.ts +7 -2
- package/dist/apps/www/calendar/index.js +4 -2
- package/dist/apps/www/contacts/[uid].d.ts +14 -3
- package/dist/apps/www/contacts/[uid].js +5 -3
- package/dist/apps/www/contacts/index.d.ts +7 -2
- package/dist/apps/www/contacts/index.js +6 -4
- package/dist/apps/www/index.d.ts +7 -2
- package/dist/apps/www/index.js +117 -19
- package/dist/apps/www/messages/[uid].d.ts +14 -3
- package/dist/apps/www/messages/[uid].js +7 -5
- package/dist/apps/www/settings/appearance/index.d.ts +8 -2
- package/dist/apps/www/settings/appearance/index.js +4 -2
- package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
- package/dist/apps/www/settings/auto-reply/index.js +4 -2
- package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
- package/dist/apps/www/settings/blocked-senders/index.js +134 -0
- package/dist/apps/www/settings/encryption/index.d.ts +7 -2
- package/dist/apps/www/settings/encryption/index.js +4 -2
- package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
- package/dist/apps/www/settings/filters/[uid].js +4 -2
- package/dist/apps/www/settings/filters/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/index.js +4 -2
- package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/new/index.js +28 -5
- package/dist/apps/www/settings/labels/index.d.ts +7 -2
- package/dist/apps/www/settings/labels/index.js +4 -2
- package/dist/apps/www/settings/privacy/index.d.ts +7 -2
- package/dist/apps/www/settings/privacy/index.js +4 -2
- package/dist/apps/www/settings/profile/index.d.ts +7 -2
- package/dist/apps/www/settings/profile/index.js +4 -2
- package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
- package/dist/apps/www/settings/read-receipts/index.js +4 -2
- package/dist/apps/www/settings/sharing/index.d.ts +7 -2
- package/dist/apps/www/settings/sharing/index.js +4 -2
- package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
- package/dist/apps/www/settings/signatures/[uid].js +4 -2
- package/dist/apps/www/settings/signatures/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/index.js +4 -2
- package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/new/index.js +5 -3
- package/dist/apps/www/tasks/index.d.ts +7 -2
- package/dist/apps/www/tasks/index.js +5 -3
- package/package.json +4 -2
- package/apps/shared/navigation/AppRouter.tsx +0 -300
- package/apps/shared/navigation/idle.ts +0 -45
- package/apps/shared/navigation/routerContext.tsx +0 -83
- package/apps/shared/navigation/routes.ts +0 -72
- package/apps/www/_routedPage.tsx +0 -24
- package/apps/www/_routes.ts +0 -37
- package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
- package/dist/apps/shared/navigation/AppRouter.js +0 -242
- package/dist/apps/shared/navigation/idle.d.ts +0 -14
- package/dist/apps/shared/navigation/idle.js +0 -43
- package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
- package/dist/apps/shared/navigation/routerContext.js +0 -56
- package/dist/apps/shared/navigation/routes.d.ts +0 -32
- package/dist/apps/shared/navigation/routes.js +0 -37
- package/dist/apps/www/_routedPage.d.ts +0 -12
- package/dist/apps/www/_routedPage.js +0 -19
- package/dist/apps/www/_routes.d.ts +0 -11
- package/dist/apps/www/_routes.js +0 -31
|
@@ -3,14 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
7
7
|
import { HiOutlineBars3, HiOutlinePencilSquare } from "react-icons/hi2";
|
|
8
8
|
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
9
9
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
10
|
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
11
11
|
import AppShell from "../../layout/AppShell.js";
|
|
12
12
|
import FloatingActionButton from "../../layout/FloatingActionButton.js";
|
|
13
|
-
import {
|
|
13
|
+
import { useLocation, useNavigate } from "../../../navigation/index.js";
|
|
14
14
|
import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
|
|
15
15
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
16
16
|
import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
|
|
@@ -47,6 +47,8 @@ const MailShellContext = createContext({
|
|
|
47
47
|
noteFolderUids: () => undefined,
|
|
48
48
|
live: NO_LIVE_UPDATES,
|
|
49
49
|
trackMessageChange: () => NO_TRACKER,
|
|
50
|
+
refreshFolderCounts: () => undefined,
|
|
51
|
+
folderCountOf: (folder) => ({ unread: folder.unreadCount, total: folder.totalCount }),
|
|
50
52
|
mobileSearchSlot: null,
|
|
51
53
|
});
|
|
52
54
|
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
@@ -111,12 +113,10 @@ function MailShortcuts({ mailboxUid }) {
|
|
|
111
113
|
/**
|
|
112
114
|
* Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
|
|
113
115
|
* aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
|
|
114
|
-
* impersonation banner — see that component).
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
|
|
119
|
-
* match).
|
|
116
|
+
* impersonation banner — see that component). The selected mailbox/folder (or aggregate pseudo-folder) live
|
|
117
|
+
* in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read from the router's location
|
|
118
|
+
* (`@rapidrest/react`), which is the request's on the server and while hydrating and follows a shallow
|
|
119
|
+
* navigation (a folder link) without remounting the page.
|
|
120
120
|
*
|
|
121
121
|
* Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
|
|
122
122
|
* anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
|
|
@@ -125,8 +125,7 @@ function MailShortcuts({ mailboxUid }) {
|
|
|
125
125
|
* of folders" requirement.
|
|
126
126
|
*
|
|
127
127
|
* An address that names nothing opens "All Mailboxes > Inbox" when there is more than one mailbox (that is when the section exists), and
|
|
128
|
-
* the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins.
|
|
129
|
-
* has been read. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
|
|
128
|
+
* the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
|
|
130
129
|
* primary mailbox.
|
|
131
130
|
*/
|
|
132
131
|
export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
|
|
@@ -139,40 +138,32 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
139
138
|
const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, noteFolderUids, live, folderCounts, outbox: outboxStatuses } = hosted ?? own;
|
|
140
139
|
// The messages this tab is still handing to the server: the Outbox pill counts them at once.
|
|
141
140
|
const pendingSends = usePendingSends();
|
|
142
|
-
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
143
|
-
const [requestedFolderUid, setRequestedFolderUid] = useState(null);
|
|
144
|
-
const [requestedAggregateType, setRequestedAggregateType] = useState(null);
|
|
145
141
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
useEffect(() => {
|
|
142
|
+
// The selection lives in the URL, and the router (`@rapidrest/react`) changes the URL without a page load when a folder link is
|
|
143
|
+
// clicked - a shallow navigation, which keeps this page and its state - so it is read from the router's location, which updates,
|
|
144
|
+
// rather than once from `window.location`. The location is the request's on the server and while hydrating (its `#fragment` is not
|
|
145
|
+
// sent, and no one here reads it), so the server render and the hydrating render agree and the selection is known from the first render.
|
|
146
|
+
const { search } = useLocation();
|
|
147
|
+
const { requestedMailboxUid, requestedFolderUid, requestedAggregateType } = useMemo(() => {
|
|
153
148
|
const params = new URLSearchParams(search);
|
|
154
|
-
|
|
155
|
-
setRequestedFolderUid(params.get("folderUid"));
|
|
156
|
-
setRequestedAggregateType(params.get("aggregate"));
|
|
157
|
-
setSelectionRead(true);
|
|
149
|
+
return { requestedMailboxUid: params.get("mailboxUid"), requestedFolderUid: params.get("folderUid"), requestedAggregateType: params.get("aggregate") };
|
|
158
150
|
}, [search]);
|
|
159
151
|
// Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
|
|
160
152
|
// with the page - is closed here.
|
|
161
153
|
useEffect(() => {
|
|
162
154
|
setDrawerOpen(false);
|
|
163
155
|
}, [search]);
|
|
164
|
-
// What the address asks for.
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
const requestedMailbox = selectionRead && requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
|
|
156
|
+
// What the address asks for.
|
|
157
|
+
const requestedAggregate = isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
|
|
158
|
+
const requestedMailbox = requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
|
|
168
159
|
// The mailbox the caller has not chosen: their own, never a shared one that merely sorts first. It is what compose, the new-message
|
|
169
160
|
// shortcut and everything else that needs a single mailbox use while an all-mailboxes view (which has none) is open.
|
|
170
161
|
const defaultMailboxUid = primaryMailboxUid(mailboxes, userUid);
|
|
171
162
|
// With several mailboxes the address that names nothing opens "All Mailboxes > Inbox", the merged Inbox; with one there is no such
|
|
172
163
|
// section, so it opens that mailbox's own Inbox. A mailbox, a folder or an all-mailboxes view in the address wins over either.
|
|
173
|
-
const aggregateByDefault =
|
|
164
|
+
const aggregateByDefault = !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
|
|
174
165
|
const aggregateFolderType = requestedAggregate ?? (aggregateByDefault ? "inbox" : undefined);
|
|
175
|
-
const mailboxUid = aggregateFolderType
|
|
166
|
+
const mailboxUid = aggregateFolderType ? undefined : (requestedMailbox ?? defaultMailboxUid);
|
|
176
167
|
const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
|
|
177
168
|
const folderUid = aggregateFolderType
|
|
178
169
|
? undefined
|
|
@@ -194,11 +185,13 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
194
185
|
const collapsible = mailboxes.length > 1;
|
|
195
186
|
const counts = folderCounts.counts;
|
|
196
187
|
const trackMessageChange = folderCounts.track;
|
|
188
|
+
const refreshFolderCounts = folderCounts.refresh;
|
|
189
|
+
const folderCountOf = useCallback((folder) => countOfFolder(folder, counts), [counts]);
|
|
197
190
|
// `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
|
|
198
191
|
useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
|
|
199
192
|
// A state rather than a ref: the page reads it during its render, so it has to re-render once the row it names is in the document.
|
|
200
193
|
const [mobileSearchSlot, setMobileSearchSlot] = useState(null);
|
|
201
|
-
const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot]);
|
|
194
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, refreshFolderCounts, folderCountOf, mobileSearchSlot }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, refreshFolderCounts, folderCountOf, mobileSearchSlot]);
|
|
202
195
|
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
|
|
203
196
|
// mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
|
|
204
197
|
// `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
|
|
@@ -222,7 +215,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
222
215
|
// `idPrefix` keeps each section's folder list's id (which its heading's `aria-controls` names) unique between the two.
|
|
223
216
|
const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, { mailboxUid: defaultMailboxUid }) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${ALL_MAILBOXES_SECTION}`, label: "All Mailboxes", collapsible: collapsible, expanded: sections.isExpanded(ALL_MAILBOXES_SECTION), onToggle: () => sections.toggle(ALL_MAILBOXES_SECTION), unread: unreadBadgeTotal(mailboxFolders.flatMap((mf) => mf.folders), counts), children: AGGREGATE_FOLDER_TYPES.map((type) => {
|
|
224
217
|
const badge = aggregateBadge(mailboxFolders, type, counts);
|
|
225
|
-
return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
|
|
218
|
+
return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: [
|
|
226
219
|
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
227
220
|
aggregateFolderType === type
|
|
228
221
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
@@ -237,7 +230,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
237
230
|
}
|
|
238
231
|
const folder = row.folder;
|
|
239
232
|
const badge = badgeFor(folder.type, countOfFolder(folder, counts));
|
|
240
|
-
return (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
|
|
233
|
+
return (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: [
|
|
241
234
|
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
242
235
|
folder.uid === folderUid
|
|
243
236
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MenuSectionSpec } from "../MenuButton.js";
|
|
3
|
+
/** What each row of the menu does - the reading pane's own handlers. */
|
|
4
|
+
export interface MessageMenuActions {
|
|
5
|
+
replyAll: () => void;
|
|
6
|
+
forward: () => void;
|
|
7
|
+
deleteMessage: () => void;
|
|
8
|
+
toggleRead: () => void;
|
|
9
|
+
toggleFlag: () => void;
|
|
10
|
+
reportJunk: () => void;
|
|
11
|
+
reportPhishing: () => void;
|
|
12
|
+
notJunk: () => void;
|
|
13
|
+
notJunkAndTrust: () => void;
|
|
14
|
+
blockSender: () => void;
|
|
15
|
+
neverBlockSender: () => void;
|
|
16
|
+
print: () => void;
|
|
17
|
+
viewSource: () => void;
|
|
18
|
+
viewDetails: () => void;
|
|
19
|
+
saveAsEml: () => void;
|
|
20
|
+
createRule: () => void;
|
|
21
|
+
}
|
|
22
|
+
export interface MessageMoreMenuProps {
|
|
23
|
+
actions: MessageMenuActions;
|
|
24
|
+
/** The sender's address the Block rows name. */
|
|
25
|
+
senderAddress: string;
|
|
26
|
+
read: boolean;
|
|
27
|
+
flagged: boolean;
|
|
28
|
+
/** The reader may change this mailbox's messages and filters (it is not a view-only share). */
|
|
29
|
+
writable: boolean;
|
|
30
|
+
/** Something is already being done to the message: its rows wait. */
|
|
31
|
+
busy: boolean;
|
|
32
|
+
inJunk: boolean;
|
|
33
|
+
/** The message is in Deleted Items: the Delete row reads "Delete permanently". */
|
|
34
|
+
inDeletedItems: boolean;
|
|
35
|
+
/** Mail the reader sent: it is not junk, and its sender is the reader. */
|
|
36
|
+
sent: boolean;
|
|
37
|
+
/** The sender is one of the reader's own addresses, which is not blocked. */
|
|
38
|
+
ownSender: boolean;
|
|
39
|
+
/** The reader may add a sender to this mailbox's Safe Senders: they have full access, or the server has not said which access they have (it then refuses, with its reason). */
|
|
40
|
+
canTrustSender: boolean;
|
|
41
|
+
/** Why the message cannot be printed right now (an encrypted one that is locked or still opening), or `undefined`. */
|
|
42
|
+
printReason: string | undefined;
|
|
43
|
+
/** The compose window is opening for a reply or forward: Reply all and Forward wait for it. */
|
|
44
|
+
composing: boolean;
|
|
45
|
+
/** The classes of the round "..." button - the reading pane's own icon-button look. */
|
|
46
|
+
triggerClassName: string;
|
|
47
|
+
}
|
|
48
|
+
/** The sections of the menu - Outlook's "More actions", cut to what this product really does. */
|
|
49
|
+
export declare function buildMessageMenu(props: MessageMoreMenuProps): MenuSectionSpec[];
|
|
50
|
+
/**
|
|
51
|
+
* The "..." at the end of a message card's icon row: Outlook's More actions menu, holding the actions there is no room for beside the
|
|
52
|
+
* message and the ones that are less often wanted. An ARIA menu (`MenuButton`): arrows, Home and End move, Right or Enter opens a submenu, Left or
|
|
53
|
+
* Escape leaves it (Escape at the top closes the menu and the focus goes back to this button), and a click outside closes it. A row that cannot be used right now
|
|
54
|
+
* is disabled and says why under its label.
|
|
55
|
+
*/
|
|
56
|
+
export default function MessageMoreMenu(props: MessageMoreMenuProps): React.JSX.Element;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { HiOutlineArrowDownTray, HiOutlineArrowUturnLeft, HiOutlineArrowUturnRight, HiOutlineCodeBracket, HiOutlineCog6Tooth, HiOutlineDocumentText, HiOutlineEllipsisHorizontal, HiOutlineEnvelope, HiOutlineEnvelopeOpen, HiOutlineEye, HiOutlineFlag, HiOutlineFunnel, HiOutlineHandRaised, HiOutlineInboxArrowDown, HiOutlineInformationCircle, HiOutlineNoSymbol, HiOutlinePrinter, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineTrash, } from "react-icons/hi2";
|
|
3
|
+
import MenuButton from "../MenuButton.js";
|
|
4
|
+
const VIEW_ONLY = "View-only mailbox";
|
|
5
|
+
/** A row, disabled with `reason` (shown under the label, where a keyboard or phone user sees it as well) when there is one. */
|
|
6
|
+
function row(key, label, icon, onSelect, reason, extra = {}) {
|
|
7
|
+
return { key, label, icon, onSelect, disabled: reason !== undefined, description: reason, ...extra };
|
|
8
|
+
}
|
|
9
|
+
/** The sections of the menu - Outlook's "More actions", cut to what this product really does. */
|
|
10
|
+
export function buildMessageMenu(props) {
|
|
11
|
+
const { actions, read, flagged, writable, busy, inJunk, inDeletedItems, sent, ownSender, printReason, composing } = props;
|
|
12
|
+
// The reason a row that changes the message is unavailable, if it is: the first that applies wins.
|
|
13
|
+
const changeReason = !writable ? VIEW_ONLY : undefined;
|
|
14
|
+
const junkReason = changeReason ?? (sent ? "Not available for mail you sent" : inJunk ? "Already in Junk Email" : undefined);
|
|
15
|
+
const blockReason = changeReason ?? (sent ? "Not available for mail you sent" : ownSender ? "This is your own address" : undefined);
|
|
16
|
+
const waiting = (reason) => reason ?? (busy ? "Working on this message" : undefined);
|
|
17
|
+
const trustLabel = `Not junk, and always trust ${props.senderAddress}`;
|
|
18
|
+
const blockLabel = `Block ${props.senderAddress}`;
|
|
19
|
+
const neverBlockLabel = `Never block ${props.senderAddress}`;
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
key: "message",
|
|
23
|
+
items: [
|
|
24
|
+
{
|
|
25
|
+
key: "reply-actions",
|
|
26
|
+
label: "Other reply actions",
|
|
27
|
+
icon: _jsx(HiOutlineArrowUturnLeft, { size: 16 }),
|
|
28
|
+
submenu: [
|
|
29
|
+
{
|
|
30
|
+
key: "reply-actions-items",
|
|
31
|
+
items: [
|
|
32
|
+
row("reply-all", "Reply all", _jsx(HiOutlineArrowUturnLeft, { size: 16 }), actions.replyAll, composing ? "Opening the compose window" : undefined),
|
|
33
|
+
row("forward", "Forward", _jsx(HiOutlineArrowUturnRight, { size: 16 }), actions.forward, composing ? "Opening the compose window" : undefined),
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
row("delete",
|
|
39
|
+
// In Deleted Items there is nowhere further to move it to: Delete is the permanent one, and asks first.
|
|
40
|
+
inDeletedItems ? "Delete permanently" : "Delete", _jsx(HiOutlineTrash, { size: 16 }), actions.deleteMessage, waiting(changeReason)),
|
|
41
|
+
row("toggle-read", read ? "Mark as unread" : "Mark as read", read ? _jsx(HiOutlineEnvelope, { size: 16 }) : _jsx(HiOutlineEnvelopeOpen, { size: 16 }), actions.toggleRead, waiting(changeReason)),
|
|
42
|
+
row("toggle-flag", flagged ? "Unflag" : "Flag", _jsx(HiOutlineFlag, { size: 16 }), actions.toggleFlag, waiting(changeReason)),
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
key: "sender",
|
|
47
|
+
items: [
|
|
48
|
+
{
|
|
49
|
+
key: "report",
|
|
50
|
+
label: "Report",
|
|
51
|
+
icon: _jsx(HiOutlineNoSymbol, { size: 16 }),
|
|
52
|
+
submenu: [
|
|
53
|
+
{
|
|
54
|
+
key: "report-items",
|
|
55
|
+
items: [
|
|
56
|
+
row("report-junk", "Report junk", _jsx(HiOutlineNoSymbol, { size: 16 }), actions.reportJunk, waiting(junkReason)),
|
|
57
|
+
row("report-phishing", "Report phishing", _jsx(HiOutlineShieldExclamation, { size: 16 }), actions.reportPhishing, waiting(junkReason)),
|
|
58
|
+
// In Junk Email the way back: to the Inbox, telling the spam filter it was wrong - and, when the reader may, trusting the sender.
|
|
59
|
+
...(inJunk
|
|
60
|
+
? [
|
|
61
|
+
row("not-junk", "Not junk", _jsx(HiOutlineInboxArrowDown, { size: 16 }), actions.notJunk, waiting(changeReason)),
|
|
62
|
+
...(props.canTrustSender && !ownSender
|
|
63
|
+
? [
|
|
64
|
+
row("not-junk-trust", trustLabel, _jsx(HiOutlineShieldCheck, { size: 16 }), actions.notJunkAndTrust, waiting(changeReason), {
|
|
65
|
+
title: trustLabel,
|
|
66
|
+
}),
|
|
67
|
+
]
|
|
68
|
+
: []),
|
|
69
|
+
]
|
|
70
|
+
: []),
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: "block",
|
|
77
|
+
label: "Block",
|
|
78
|
+
icon: _jsx(HiOutlineHandRaised, { size: 16 }),
|
|
79
|
+
submenu: [
|
|
80
|
+
{
|
|
81
|
+
key: "block-items",
|
|
82
|
+
items: [
|
|
83
|
+
row("block-sender", blockLabel, _jsx(HiOutlineHandRaised, { size: 16 }), actions.blockSender, waiting(blockReason), { title: blockLabel }),
|
|
84
|
+
row("never-block-sender", neverBlockLabel, _jsx(HiOutlineShieldCheck, { size: 16 }), actions.neverBlockSender, waiting(blockReason), { title: neverBlockLabel }),
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "output",
|
|
93
|
+
items: [
|
|
94
|
+
row("print", "Print", _jsx(HiOutlinePrinter, { size: 16 }), actions.print, printReason),
|
|
95
|
+
{
|
|
96
|
+
key: "view",
|
|
97
|
+
label: "View",
|
|
98
|
+
icon: _jsx(HiOutlineEye, { size: 16 }),
|
|
99
|
+
submenu: [
|
|
100
|
+
{
|
|
101
|
+
key: "view-items",
|
|
102
|
+
items: [
|
|
103
|
+
row("view-source", "View message source", _jsx(HiOutlineCodeBracket, { size: 16 }), actions.viewSource),
|
|
104
|
+
row("view-details", "Message details", _jsx(HiOutlineInformationCircle, { size: 16 }), actions.viewDetails),
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: "save-as",
|
|
111
|
+
label: "Save as",
|
|
112
|
+
icon: _jsx(HiOutlineArrowDownTray, { size: 16 }),
|
|
113
|
+
submenu: [
|
|
114
|
+
{
|
|
115
|
+
key: "save-as-items",
|
|
116
|
+
items: [
|
|
117
|
+
row("save-eml", "Save as .eml", _jsx(HiOutlineEnvelope, { size: 16 }), actions.saveAsEml),
|
|
118
|
+
row("save-pdf", "Save as PDF", _jsx(HiOutlineDocumentText, { size: 16 }), actions.print, printReason, {
|
|
119
|
+
description: printReason ?? "Opens the print dialog: choose Save as PDF",
|
|
120
|
+
}),
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "advanced",
|
|
129
|
+
items: [
|
|
130
|
+
{
|
|
131
|
+
key: "advanced-actions",
|
|
132
|
+
label: "Advanced actions",
|
|
133
|
+
icon: _jsx(HiOutlineCog6Tooth, { size: 16 }),
|
|
134
|
+
submenu: [
|
|
135
|
+
{
|
|
136
|
+
key: "advanced-items",
|
|
137
|
+
items: [row("create-rule", "Create rule", _jsx(HiOutlineFunnel, { size: 16 }), actions.createRule, changeReason)],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The "..." at the end of a message card's icon row: Outlook's More actions menu, holding the actions there is no room for beside the
|
|
147
|
+
* message and the ones that are less often wanted. An ARIA menu (`MenuButton`): arrows, Home and End move, Right or Enter opens a submenu, Left or
|
|
148
|
+
* Escape leaves it (Escape at the top closes the menu and the focus goes back to this button), and a click outside closes it. A row that cannot be used right now
|
|
149
|
+
* is disabled and says why under its label.
|
|
150
|
+
*/
|
|
151
|
+
export default function MessageMoreMenu(props) {
|
|
152
|
+
return (_jsx(MenuButton, { iconOnly: true, label: "More actions", "aria-label": "More actions", title: "More actions", icon: _jsx(HiOutlineEllipsisHorizontal, { size: 16, "aria-hidden": "true" }), className: props.triggerClassName, sections: buildMessageMenu(props), width: 288 }));
|
|
153
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/** What the dialog is showing a message's source for: the whole of it, or only its header lines ("Message details"). */
|
|
3
|
+
export type SourceMode = "source" | "headers";
|
|
4
|
+
/** What has been fetched of the raw message: on its way, here, or refused. */
|
|
5
|
+
export type SourceState = {
|
|
6
|
+
status: "loading";
|
|
7
|
+
} | {
|
|
8
|
+
status: "ready";
|
|
9
|
+
raw: string;
|
|
10
|
+
} | {
|
|
11
|
+
status: "error";
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
export interface MessageSourceDialogProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
mode: SourceMode;
|
|
18
|
+
source: SourceState;
|
|
19
|
+
/** The message is end-to-end encrypted: what is shown is the ciphertext the server holds, and the dialog says so. */
|
|
20
|
+
encrypted: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A message's raw source (or just its headers) in a full-window dialog, in a monospace block that can be scrolled and selected, with a Copy
|
|
24
|
+
* button. It is the RFC 5322 text exactly as the server stores it - for an encrypted message that is the encrypted message, never a
|
|
25
|
+
* decrypted one, and the dialog says so. A very long source is cut for display (`MAX_SOURCE_DISPLAY_CHARS`); Copy has all of it.
|
|
26
|
+
*/
|
|
27
|
+
export default function MessageSourceDialog({ open, onClose, mode, source, encrypted }: MessageSourceDialogProps): React.JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useMemo } from "react";
|
|
7
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
8
|
+
import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
|
|
9
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
10
|
+
import { MAX_SOURCE_DISPLAY_CHARS, headerText, sourceText } from "./messageExport.js";
|
|
11
|
+
const TITLES = { source: "Message source", headers: "Message details" };
|
|
12
|
+
/**
|
|
13
|
+
* A message's raw source (or just its headers) in a full-window dialog, in a monospace block that can be scrolled and selected, with a Copy
|
|
14
|
+
* button. It is the RFC 5322 text exactly as the server stores it - for an encrypted message that is the encrypted message, never a
|
|
15
|
+
* decrypted one, and the dialog says so. A very long source is cut for display (`MAX_SOURCE_DISPLAY_CHARS`); Copy has all of it.
|
|
16
|
+
*/
|
|
17
|
+
export default function MessageSourceDialog({ open, onClose, mode, source, encrypted }) {
|
|
18
|
+
const text = useMemo(() => {
|
|
19
|
+
if (source.status !== "ready") {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
return mode === "headers" ? headerText(source.raw) : sourceText(source.raw);
|
|
23
|
+
}, [source, mode]);
|
|
24
|
+
const cut = text.length > MAX_SOURCE_DISPLAY_CHARS;
|
|
25
|
+
const title = TITLES[mode];
|
|
26
|
+
return (_jsx(Drawer, { open: open, onClose: onClose, title: title, fullScreen: true, children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [encrypted && (_jsx("p", { role: "note", className: "py-2 px-3 rounded-sm bg-surface-alt text-text", children: "This message is encrypted. This is the encrypted message exactly as the server stores it; it is not decrypted here." })), source.status === "loading" && (_jsx("p", { role: "status", className: "text-text-muted", children: "Loading the message\u2026" })), source.status === "error" && _jsx(Alert, { children: source.message }), source.status === "ready" && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(CopyButton, { value: text, label: `Copy the ${mode === "headers" ? "message headers" : "message source"}` }), cut && (_jsxs("span", { className: "text-xs text-text-muted", children: ["Showing the first ", MAX_SOURCE_DISPLAY_CHARS.toLocaleString(), " of ", text.length.toLocaleString(), " characters. Copy and Save as .eml keep all of it."] }))] }), _jsx("pre", { tabIndex: 0, "aria-label": title, className: "m-0 max-h-[70vh] overflow-auto rounded-sm border border-border bg-surface-alt p-3 font-mono text-xs whitespace-pre-wrap break-all", children: cut ? text.slice(0, MAX_SOURCE_DISPLAY_CHARS) : text })] }))] }) }));
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the reading pane does with a message's raw RFC 5322 source (`getMessageRawContent()`, a binary string): show it, save it as a
|
|
3
|
+
* file, and read the From header out of it. Nothing here interprets the message's content beyond its header block.
|
|
4
|
+
*/
|
|
5
|
+
/** How much of a message's source the "View message source" dialog draws: a message with attachments is mostly base64, and a
|
|
6
|
+
* megabyte of it in a `<pre>` helps nobody. Saving as .eml and Copy always have all of it. */
|
|
7
|
+
export declare const MAX_SOURCE_DISPLAY_CHARS = 200000;
|
|
8
|
+
/** The file name "Save as .eml" gives a message: its subject without what a file system refuses, or `message`. */
|
|
9
|
+
export declare function emlFileName(subject: string | undefined): string;
|
|
10
|
+
/** Hands `bytes` to the browser as a download called `fileName`. */
|
|
11
|
+
export declare function downloadBytes(bytes: Uint8Array, fileName: string, type: string): void;
|
|
12
|
+
/** Saves a raw message (a binary string, exactly as the server has it) as a `.eml` file, byte for byte. */
|
|
13
|
+
export declare function saveAsEml(raw: string, subject: string | undefined): void;
|
|
14
|
+
/** A raw message as text to read: its bytes decoded as UTF-8, an invalid byte shown as U+FFFD. */
|
|
15
|
+
export declare function sourceText(raw: string): string;
|
|
16
|
+
/** The header block of a raw message, as text to read. */
|
|
17
|
+
export declare function headerText(raw: string): string;
|
|
18
|
+
/** The address in a raw message's own From header (which is not always the envelope sender the server files the message under), lowercased. */
|
|
19
|
+
export declare function headerFromAddress(raw: string): string | undefined;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { binaryStringToBytes, extractAddresses, parseMimeEntity } from "@rapidmx/react-shared/crypto/mime.js";
|
|
6
|
+
/**
|
|
7
|
+
* What the reading pane does with a message's raw RFC 5322 source (`getMessageRawContent()`, a binary string): show it, save it as a
|
|
8
|
+
* file, and read the From header out of it. Nothing here interprets the message's content beyond its header block.
|
|
9
|
+
*/
|
|
10
|
+
/** How much of a message's source the "View message source" dialog draws: a message with attachments is mostly base64, and a
|
|
11
|
+
* megabyte of it in a `<pre>` helps nobody. Saving as .eml and Copy always have all of it. */
|
|
12
|
+
export const MAX_SOURCE_DISPLAY_CHARS = 200000;
|
|
13
|
+
/** The file name "Save as .eml" gives a message: its subject without what a file system refuses, or `message`. */
|
|
14
|
+
export function emlFileName(subject) {
|
|
15
|
+
const cleaned = (subject ?? "")
|
|
16
|
+
.replace(/[\p{Cc}\\/:*?"<>|]+/gu, " ")
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim()
|
|
19
|
+
.slice(0, 100)
|
|
20
|
+
.replace(/^\.+/, "")
|
|
21
|
+
.replace(/[. ]+$/, "");
|
|
22
|
+
return `${cleaned || "message"}.eml`;
|
|
23
|
+
}
|
|
24
|
+
/** Hands `bytes` to the browser as a download called `fileName`. */
|
|
25
|
+
export function downloadBytes(bytes, fileName, type) {
|
|
26
|
+
const url = URL.createObjectURL(new Blob([bytes], { type }));
|
|
27
|
+
const a = document.createElement("a");
|
|
28
|
+
a.href = url;
|
|
29
|
+
a.download = fileName;
|
|
30
|
+
document.body.appendChild(a);
|
|
31
|
+
a.click();
|
|
32
|
+
a.remove();
|
|
33
|
+
// Some browsers are still reading the blob after `click()` returns.
|
|
34
|
+
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
35
|
+
}
|
|
36
|
+
/** Saves a raw message (a binary string, exactly as the server has it) as a `.eml` file, byte for byte. */
|
|
37
|
+
export function saveAsEml(raw, subject) {
|
|
38
|
+
downloadBytes(binaryStringToBytes(raw), emlFileName(subject), "message/rfc822");
|
|
39
|
+
}
|
|
40
|
+
/** A raw message as text to read: its bytes decoded as UTF-8, an invalid byte shown as U+FFFD. */
|
|
41
|
+
export function sourceText(raw) {
|
|
42
|
+
return new TextDecoder("utf-8").decode(binaryStringToBytes(raw));
|
|
43
|
+
}
|
|
44
|
+
/** The header block of a raw message, as text to read. */
|
|
45
|
+
export function headerText(raw) {
|
|
46
|
+
return sourceText(parseMimeEntity(raw).rawHeaderBlock);
|
|
47
|
+
}
|
|
48
|
+
/** The address in a raw message's own From header (which is not always the envelope sender the server files the message under), lowercased. */
|
|
49
|
+
export function headerFromAddress(raw) {
|
|
50
|
+
return extractAddresses(parseMimeEntity(raw).headers.from)[0];
|
|
51
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Attachment } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
import type { MimeAttachment } from "@rapidmx/react-shared/crypto/mime.js";
|
|
3
|
+
import { BodyContent } from "./bodyContent.js";
|
|
4
|
+
/**
|
|
5
|
+
* Printing one message: a document of its own - the message's header lines, and under them the same sanitized body the reading pane draws -
|
|
6
|
+
* shown in a frame that nobody sees and printed from there, so the print is the message and not the app around it.
|
|
7
|
+
*
|
|
8
|
+
* The frame is as isolated as the reading pane's own (`frameDocument.ts`): the body has been through `prepareBodyHtml()`, the document
|
|
9
|
+
* carries the same Content-Security-Policy (no script, nothing fetched from anywhere but this server's own attachment URLs), and the
|
|
10
|
+
* sandbox runs no code. `allow-same-origin` only lets the app call `print()` on it, and `allow-modals` is what a browser asks of a
|
|
11
|
+
* sandboxed document for the print dialog to open at all. Paper is white, so the message is drawn as its author wrote it.
|
|
12
|
+
*/
|
|
13
|
+
export interface PrintableMessage {
|
|
14
|
+
subject: string;
|
|
15
|
+
/** The header lines above the body - `From`, `To`, `Cc`, `Date` - in the order shown; a line with no value is left out. */
|
|
16
|
+
headers: {
|
|
17
|
+
name: string;
|
|
18
|
+
value: string;
|
|
19
|
+
}[];
|
|
20
|
+
content: BodyContent;
|
|
21
|
+
attachments?: Attachment[];
|
|
22
|
+
inlineParts?: MimeAttachment[];
|
|
23
|
+
}
|
|
24
|
+
export declare function escapeHtml(text: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildPrintDocument(message: PrintableMessage): string | undefined;
|
|
29
|
+
/** Prints `html` (from `buildPrintDocument()`) from a frame nobody sees, and removes the frame when the print is over. */
|
|
30
|
+
export declare function printDocument(html: string): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { attachmentSource, makeCidResolver } from "./bodyContent.js";
|
|
2
|
+
import { prepareBodyHtml } from "./bodyHtml.js";
|
|
3
|
+
import { frameCsp } from "./frameDocument.js";
|
|
4
|
+
const PRINT_CSS = `
|
|
5
|
+
html{font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;color:#000;background:#fff}
|
|
6
|
+
body{margin:0;padding:24px}
|
|
7
|
+
h1{font-size:20px;margin:0 0 12px}
|
|
8
|
+
dl{margin:0 0 12px;display:grid;grid-template-columns:max-content 1fr;gap:2px 12px}
|
|
9
|
+
dt{font-weight:600;color:#444}
|
|
10
|
+
dd{margin:0;overflow-wrap:anywhere}
|
|
11
|
+
hr{border:0;border-top:1px solid #999;margin:0 0 16px}
|
|
12
|
+
#rr-body{overflow-wrap:break-word}
|
|
13
|
+
#rr-body img{max-width:100%;height:auto}
|
|
14
|
+
#rr-body pre,pre.rr-text{white-space:pre-wrap;font:inherit;margin:0}
|
|
15
|
+
`;
|
|
16
|
+
export function escapeHtml(text) {
|
|
17
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
|
|
21
|
+
*/
|
|
22
|
+
export function buildPrintDocument(message) {
|
|
23
|
+
let body;
|
|
24
|
+
let imageSource;
|
|
25
|
+
if (message.content.kind === "html") {
|
|
26
|
+
const prepared = prepareBodyHtml(message.content.html, { resolveCid: makeCidResolver(message.attachments, message.inlineParts) });
|
|
27
|
+
if (prepared.status === "too_large") {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const source = prepared.inlineImages > 0 ? attachmentSource() : undefined;
|
|
31
|
+
imageSource = source && prepared.html.includes(source) ? source : undefined;
|
|
32
|
+
body = `<div id="rr-body">${prepared.html}</div>`;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
body = `<pre class="rr-text">${escapeHtml(message.content.text)}</pre>`;
|
|
36
|
+
}
|
|
37
|
+
const lines = message.headers
|
|
38
|
+
.filter((header) => header.value !== "")
|
|
39
|
+
.map((header) => `<dt>${escapeHtml(header.name)}</dt><dd>${escapeHtml(header.value)}</dd>`)
|
|
40
|
+
.join("");
|
|
41
|
+
return [
|
|
42
|
+
"<!doctype html>",
|
|
43
|
+
'<html lang="en"><head><meta charset="utf-8">',
|
|
44
|
+
`<meta http-equiv="Content-Security-Policy" content="${frameCsp(imageSource)}">`,
|
|
45
|
+
`<title>${escapeHtml(message.subject)}</title>`,
|
|
46
|
+
`<style>${PRINT_CSS}</style></head>`,
|
|
47
|
+
`<body><h1>${escapeHtml(message.subject)}</h1><dl>${lines}</dl><hr>${body}</body></html>`,
|
|
48
|
+
].join("");
|
|
49
|
+
}
|
|
50
|
+
/** How long a print frame is kept when the browser never says it has finished with it (`afterprint`). */
|
|
51
|
+
const FRAME_LIFETIME_MS = 60000;
|
|
52
|
+
/** Prints `html` (from `buildPrintDocument()`) from a frame nobody sees, and removes the frame when the print is over. */
|
|
53
|
+
export function printDocument(html) {
|
|
54
|
+
const frame = document.createElement("iframe");
|
|
55
|
+
frame.setAttribute("sandbox", "allow-same-origin allow-modals");
|
|
56
|
+
frame.setAttribute("aria-hidden", "true");
|
|
57
|
+
frame.tabIndex = -1;
|
|
58
|
+
frame.title = "Message to print";
|
|
59
|
+
frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
|
|
60
|
+
const cleanup = () => frame.remove();
|
|
61
|
+
frame.addEventListener("load", () => {
|
|
62
|
+
const view = frame.contentWindow;
|
|
63
|
+
view.addEventListener("afterprint", cleanup);
|
|
64
|
+
view.focus();
|
|
65
|
+
view.print();
|
|
66
|
+
setTimeout(cleanup, FRAME_LIFETIME_MS);
|
|
67
|
+
});
|
|
68
|
+
frame.srcdoc = html;
|
|
69
|
+
document.body.appendChild(frame);
|
|
70
|
+
}
|