@rapidmx/web-client 0.16.0 → 0.17.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 +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 +3 -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 +37 -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 +4 -2
- package/apps/shared/components/tasks/layout/TasksShell.tsx +3 -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 +43 -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 +3 -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 +27 -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 +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -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 +16 -0
- package/dist/apps/shared/navigation/index.js +34 -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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import React, { createContext, PropsWithChildren, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
6
6
|
import { HiOutlineBars3, HiOutlinePencilSquare } from "react-icons/hi2";
|
|
7
7
|
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
8
8
|
import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
@@ -10,7 +10,8 @@ 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, { AppShellProps } from "../../layout/AppShell.js";
|
|
12
12
|
import FloatingActionButton from "../../layout/FloatingActionButton.js";
|
|
13
|
-
import {
|
|
13
|
+
import { useLocation } from "@rapidrest/react/client";
|
|
14
|
+
import { useNavigate } from "../../../navigation/index.js";
|
|
14
15
|
import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
|
|
15
16
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
16
17
|
import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
|
|
@@ -101,6 +102,13 @@ export interface MailShellContextValue {
|
|
|
101
102
|
* context value, which is only ever read outside a real shell.
|
|
102
103
|
*/
|
|
103
104
|
trackMessageChange: (previous: Message, next: Message | null) => CountTracker;
|
|
105
|
+
/**
|
|
106
|
+
* Reads every mailbox's real folder counts back and shows them (after `delayMs`, 600 ms by default) - for a change too big to track message by
|
|
107
|
+
* message, such as emptying a folder. A no-op on the default context value, which is only ever read outside a real shell.
|
|
108
|
+
*/
|
|
109
|
+
refreshFolderCounts: (delayMs?: number) => void;
|
|
110
|
+
/** A folder's counts as the sidebar shows them: what the shell has worked out or been told since it loaded, else what the folder was listed with. */
|
|
111
|
+
folderCountOf: (folder: Folder) => FolderCount;
|
|
104
112
|
/**
|
|
105
113
|
* The phone layout's header row beside the folders button, for the page to put its search box in (`createPortal()`): the shell
|
|
106
114
|
* owns the row, the page owns the search state. `null` until the row has rendered, and on the default context value.
|
|
@@ -117,6 +125,8 @@ const MailShellContext = createContext<MailShellContextValue>({
|
|
|
117
125
|
noteFolderUids: () => undefined,
|
|
118
126
|
live: NO_LIVE_UPDATES,
|
|
119
127
|
trackMessageChange: () => NO_TRACKER,
|
|
128
|
+
refreshFolderCounts: () => undefined,
|
|
129
|
+
folderCountOf: (folder) => ({ unread: folder.unreadCount, total: folder.totalCount }),
|
|
120
130
|
mobileSearchSlot: null,
|
|
121
131
|
});
|
|
122
132
|
|
|
@@ -214,12 +224,10 @@ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
|
|
|
214
224
|
/**
|
|
215
225
|
* Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
|
|
216
226
|
* aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
|
|
217
|
-
* impersonation banner — see that component).
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
|
|
222
|
-
* match).
|
|
227
|
+
* impersonation banner — see that component). The selected mailbox/folder (or aggregate pseudo-folder) live
|
|
228
|
+
* in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read from the router's location
|
|
229
|
+
* (`@rapidrest/react`), which is the request's on the server and while hydrating and follows a shallow
|
|
230
|
+
* navigation (a folder link) without remounting the page.
|
|
223
231
|
*
|
|
224
232
|
* Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
|
|
225
233
|
* anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
|
|
@@ -228,8 +236,7 @@ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
|
|
|
228
236
|
* of folders" requirement.
|
|
229
237
|
*
|
|
230
238
|
* An address that names nothing opens "All Mailboxes > Inbox" when there is more than one mailbox (that is when the section exists), and
|
|
231
|
-
* the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins.
|
|
232
|
-
* has been read. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
|
|
239
|
+
* 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
|
|
233
240
|
* primary mailbox.
|
|
234
241
|
*/
|
|
235
242
|
export default function MailShell({
|
|
@@ -251,23 +258,16 @@ export default function MailShell({
|
|
|
251
258
|
const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, noteFolderUids, live, folderCounts, outbox: outboxStatuses } = hosted ?? own;
|
|
252
259
|
// The messages this tab is still handing to the server: the Outbox pill counts them at once.
|
|
253
260
|
const pendingSends = usePendingSends();
|
|
254
|
-
const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
|
|
255
|
-
const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
|
|
256
|
-
const [requestedAggregateType, setRequestedAggregateType] = useState<string | null>(null);
|
|
257
261
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
258
|
-
// Whether the URL has been read yet (see below): until then the selection is not known, and the sidebar sections have nothing to open for.
|
|
259
|
-
const [selectionRead, setSelectionRead] = useState(false);
|
|
260
262
|
|
|
261
|
-
// The selection lives in the URL, and the router (`
|
|
262
|
-
// clicked - so it is read from the router's location, which updates,
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
// The selection lives in the URL, and the router (`@rapidrest/react`) changes the URL without a page load when a folder link is
|
|
264
|
+
// clicked - a shallow navigation, which keeps this page and its state - so it is read from the router's location, which updates,
|
|
265
|
+
// rather than once from `window.location`. The location is the request's on the server and while hydrating (its `#fragment` is not
|
|
266
|
+
// 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.
|
|
267
|
+
const { search } = useLocation();
|
|
268
|
+
const { requestedMailboxUid, requestedFolderUid, requestedAggregateType } = useMemo(() => {
|
|
266
269
|
const params = new URLSearchParams(search);
|
|
267
|
-
|
|
268
|
-
setRequestedFolderUid(params.get("folderUid"));
|
|
269
|
-
setRequestedAggregateType(params.get("aggregate"));
|
|
270
|
-
setSelectionRead(true);
|
|
270
|
+
return { requestedMailboxUid: params.get("mailboxUid"), requestedFolderUid: params.get("folderUid"), requestedAggregateType: params.get("aggregate") };
|
|
271
271
|
}, [search]);
|
|
272
272
|
// Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
|
|
273
273
|
// with the page - is closed here.
|
|
@@ -275,12 +275,10 @@ export default function MailShell({
|
|
|
275
275
|
setDrawerOpen(false);
|
|
276
276
|
}, [search]);
|
|
277
277
|
|
|
278
|
-
// What the address asks for.
|
|
279
|
-
|
|
280
|
-
const requestedAggregate: AggregateFolderType | undefined =
|
|
281
|
-
selectionRead && isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
|
|
278
|
+
// What the address asks for.
|
|
279
|
+
const requestedAggregate: AggregateFolderType | undefined = isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
|
|
282
280
|
const requestedMailbox: string | undefined =
|
|
283
|
-
|
|
281
|
+
requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
|
|
284
282
|
|
|
285
283
|
// The mailbox the caller has not chosen: their own, never a shared one that merely sorts first. It is what compose, the new-message
|
|
286
284
|
// shortcut and everything else that needs a single mailbox use while an all-mailboxes view (which has none) is open.
|
|
@@ -288,10 +286,9 @@ export default function MailShell({
|
|
|
288
286
|
|
|
289
287
|
// With several mailboxes the address that names nothing opens "All Mailboxes > Inbox", the merged Inbox; with one there is no such
|
|
290
288
|
// section, so it opens that mailbox's own Inbox. A mailbox, a folder or an all-mailboxes view in the address wins over either.
|
|
291
|
-
const aggregateByDefault =
|
|
292
|
-
selectionRead && !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
|
|
289
|
+
const aggregateByDefault = !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
|
|
293
290
|
const aggregateFolderType: AggregateFolderType | undefined = requestedAggregate ?? (aggregateByDefault ? "inbox" : undefined);
|
|
294
|
-
const mailboxUid: string | undefined = aggregateFolderType
|
|
291
|
+
const mailboxUid: string | undefined = aggregateFolderType ? undefined : (requestedMailbox ?? defaultMailboxUid);
|
|
295
292
|
|
|
296
293
|
const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
|
|
297
294
|
const folderUid: string | undefined = aggregateFolderType
|
|
@@ -317,14 +314,16 @@ export default function MailShell({
|
|
|
317
314
|
|
|
318
315
|
const counts = folderCounts.counts;
|
|
319
316
|
const trackMessageChange = folderCounts.track;
|
|
317
|
+
const refreshFolderCounts = folderCounts.refresh;
|
|
318
|
+
const folderCountOf = useCallback((folder: Folder) => countOfFolder(folder, counts), [counts]);
|
|
320
319
|
// `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
|
|
321
320
|
useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
|
|
322
321
|
|
|
323
322
|
// 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.
|
|
324
323
|
const [mobileSearchSlot, setMobileSearchSlot] = useState<HTMLElement | null>(null);
|
|
325
324
|
const contextValue = useMemo<MailShellContextValue>(
|
|
326
|
-
() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot }),
|
|
327
|
-
[mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot],
|
|
325
|
+
() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, refreshFolderCounts, folderCountOf, mobileSearchSlot }),
|
|
326
|
+
[mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, refreshFolderCounts, folderCountOf, mobileSearchSlot],
|
|
328
327
|
);
|
|
329
328
|
|
|
330
329
|
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
|
|
@@ -384,6 +383,8 @@ export default function MailShell({
|
|
|
384
383
|
<a
|
|
385
384
|
key={type}
|
|
386
385
|
href={`/?aggregate=${encodeURIComponent(type)}`}
|
|
386
|
+
data-router-shallow=""
|
|
387
|
+
data-router-prefetch="false"
|
|
387
388
|
className={[
|
|
388
389
|
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
389
390
|
aggregateFolderType === type
|
|
@@ -442,6 +443,8 @@ export default function MailShell({
|
|
|
442
443
|
<a
|
|
443
444
|
key={folder.uid}
|
|
444
445
|
href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
|
|
446
|
+
data-router-shallow=""
|
|
447
|
+
data-router-prefetch="false"
|
|
445
448
|
className={[
|
|
446
449
|
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
447
450
|
folder.uid === folderUid
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import {
|
|
7
|
+
HiOutlineArrowDownTray,
|
|
8
|
+
HiOutlineArrowUturnLeft,
|
|
9
|
+
HiOutlineArrowUturnRight,
|
|
10
|
+
HiOutlineCodeBracket,
|
|
11
|
+
HiOutlineCog6Tooth,
|
|
12
|
+
HiOutlineDocumentText,
|
|
13
|
+
HiOutlineEllipsisHorizontal,
|
|
14
|
+
HiOutlineEnvelope,
|
|
15
|
+
HiOutlineEnvelopeOpen,
|
|
16
|
+
HiOutlineEye,
|
|
17
|
+
HiOutlineFlag,
|
|
18
|
+
HiOutlineFunnel,
|
|
19
|
+
HiOutlineHandRaised,
|
|
20
|
+
HiOutlineInboxArrowDown,
|
|
21
|
+
HiOutlineInformationCircle,
|
|
22
|
+
HiOutlineNoSymbol,
|
|
23
|
+
HiOutlinePrinter,
|
|
24
|
+
HiOutlineShieldCheck,
|
|
25
|
+
HiOutlineShieldExclamation,
|
|
26
|
+
HiOutlineTrash,
|
|
27
|
+
} from "react-icons/hi2";
|
|
28
|
+
import MenuButton, { MenuItemSpec, MenuSectionSpec } from "../MenuButton.js";
|
|
29
|
+
|
|
30
|
+
/** What each row of the menu does - the reading pane's own handlers. */
|
|
31
|
+
export interface MessageMenuActions {
|
|
32
|
+
replyAll: () => void;
|
|
33
|
+
forward: () => void;
|
|
34
|
+
deleteMessage: () => void;
|
|
35
|
+
toggleRead: () => void;
|
|
36
|
+
toggleFlag: () => void;
|
|
37
|
+
reportJunk: () => void;
|
|
38
|
+
reportPhishing: () => void;
|
|
39
|
+
notJunk: () => void;
|
|
40
|
+
notJunkAndTrust: () => void;
|
|
41
|
+
blockSender: () => void;
|
|
42
|
+
neverBlockSender: () => void;
|
|
43
|
+
print: () => void;
|
|
44
|
+
viewSource: () => void;
|
|
45
|
+
viewDetails: () => void;
|
|
46
|
+
saveAsEml: () => void;
|
|
47
|
+
createRule: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MessageMoreMenuProps {
|
|
51
|
+
actions: MessageMenuActions;
|
|
52
|
+
/** The sender's address the Block rows name. */
|
|
53
|
+
senderAddress: string;
|
|
54
|
+
read: boolean;
|
|
55
|
+
flagged: boolean;
|
|
56
|
+
/** The reader may change this mailbox's messages and filters (it is not a view-only share). */
|
|
57
|
+
writable: boolean;
|
|
58
|
+
/** Something is already being done to the message: its rows wait. */
|
|
59
|
+
busy: boolean;
|
|
60
|
+
inJunk: boolean;
|
|
61
|
+
/** The message is in Deleted Items: the Delete row reads "Delete permanently". */
|
|
62
|
+
inDeletedItems: boolean;
|
|
63
|
+
/** Mail the reader sent: it is not junk, and its sender is the reader. */
|
|
64
|
+
sent: boolean;
|
|
65
|
+
/** The sender is one of the reader's own addresses, which is not blocked. */
|
|
66
|
+
ownSender: boolean;
|
|
67
|
+
/** 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). */
|
|
68
|
+
canTrustSender: boolean;
|
|
69
|
+
/** Why the message cannot be printed right now (an encrypted one that is locked or still opening), or `undefined`. */
|
|
70
|
+
printReason: string | undefined;
|
|
71
|
+
/** The compose window is opening for a reply or forward: Reply all and Forward wait for it. */
|
|
72
|
+
composing: boolean;
|
|
73
|
+
/** The classes of the round "..." button - the reading pane's own icon-button look. */
|
|
74
|
+
triggerClassName: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const VIEW_ONLY = "View-only mailbox";
|
|
78
|
+
|
|
79
|
+
/** A row, disabled with `reason` (shown under the label, where a keyboard or phone user sees it as well) when there is one. */
|
|
80
|
+
function row(key: string, label: string, icon: React.ReactNode, onSelect: () => void, reason?: string, extra: Partial<MenuItemSpec> = {}): MenuItemSpec {
|
|
81
|
+
return { key, label, icon, onSelect, disabled: reason !== undefined, description: reason, ...extra } as MenuItemSpec;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The sections of the menu - Outlook's "More actions", cut to what this product really does. */
|
|
85
|
+
export function buildMessageMenu(props: MessageMoreMenuProps): MenuSectionSpec[] {
|
|
86
|
+
const { actions, read, flagged, writable, busy, inJunk, inDeletedItems, sent, ownSender, printReason, composing } = props;
|
|
87
|
+
// The reason a row that changes the message is unavailable, if it is: the first that applies wins.
|
|
88
|
+
const changeReason = !writable ? VIEW_ONLY : undefined;
|
|
89
|
+
const junkReason = changeReason ?? (sent ? "Not available for mail you sent" : inJunk ? "Already in Junk Email" : undefined);
|
|
90
|
+
const blockReason = changeReason ?? (sent ? "Not available for mail you sent" : ownSender ? "This is your own address" : undefined);
|
|
91
|
+
const waiting = (reason: string | undefined) => reason ?? (busy ? "Working on this message" : undefined);
|
|
92
|
+
const trustLabel = `Not junk, and always trust ${props.senderAddress}`;
|
|
93
|
+
const blockLabel = `Block ${props.senderAddress}`;
|
|
94
|
+
const neverBlockLabel = `Never block ${props.senderAddress}`;
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
key: "message",
|
|
98
|
+
items: [
|
|
99
|
+
{
|
|
100
|
+
key: "reply-actions",
|
|
101
|
+
label: "Other reply actions",
|
|
102
|
+
icon: <HiOutlineArrowUturnLeft size={16} />,
|
|
103
|
+
submenu: [
|
|
104
|
+
{
|
|
105
|
+
key: "reply-actions-items",
|
|
106
|
+
items: [
|
|
107
|
+
row("reply-all", "Reply all", <HiOutlineArrowUturnLeft size={16} />, actions.replyAll, composing ? "Opening the compose window" : undefined),
|
|
108
|
+
row("forward", "Forward", <HiOutlineArrowUturnRight size={16} />, actions.forward, composing ? "Opening the compose window" : undefined),
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
row(
|
|
114
|
+
"delete",
|
|
115
|
+
// In Deleted Items there is nowhere further to move it to: Delete is the permanent one, and asks first.
|
|
116
|
+
inDeletedItems ? "Delete permanently" : "Delete",
|
|
117
|
+
<HiOutlineTrash size={16} />,
|
|
118
|
+
actions.deleteMessage,
|
|
119
|
+
waiting(changeReason),
|
|
120
|
+
),
|
|
121
|
+
row(
|
|
122
|
+
"toggle-read",
|
|
123
|
+
read ? "Mark as unread" : "Mark as read",
|
|
124
|
+
read ? <HiOutlineEnvelope size={16} /> : <HiOutlineEnvelopeOpen size={16} />,
|
|
125
|
+
actions.toggleRead,
|
|
126
|
+
waiting(changeReason),
|
|
127
|
+
),
|
|
128
|
+
row("toggle-flag", flagged ? "Unflag" : "Flag", <HiOutlineFlag size={16} />, actions.toggleFlag, waiting(changeReason)),
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
key: "sender",
|
|
133
|
+
items: [
|
|
134
|
+
{
|
|
135
|
+
key: "report",
|
|
136
|
+
label: "Report",
|
|
137
|
+
icon: <HiOutlineNoSymbol size={16} />,
|
|
138
|
+
submenu: [
|
|
139
|
+
{
|
|
140
|
+
key: "report-items",
|
|
141
|
+
items: [
|
|
142
|
+
row("report-junk", "Report junk", <HiOutlineNoSymbol size={16} />, actions.reportJunk, waiting(junkReason)),
|
|
143
|
+
row("report-phishing", "Report phishing", <HiOutlineShieldExclamation size={16} />, actions.reportPhishing, waiting(junkReason)),
|
|
144
|
+
// In Junk Email the way back: to the Inbox, telling the spam filter it was wrong - and, when the reader may, trusting the sender.
|
|
145
|
+
...(inJunk
|
|
146
|
+
? [
|
|
147
|
+
row("not-junk", "Not junk", <HiOutlineInboxArrowDown size={16} />, actions.notJunk, waiting(changeReason)),
|
|
148
|
+
...(props.canTrustSender && !ownSender
|
|
149
|
+
? [
|
|
150
|
+
row("not-junk-trust", trustLabel, <HiOutlineShieldCheck size={16} />, actions.notJunkAndTrust, waiting(changeReason), {
|
|
151
|
+
title: trustLabel,
|
|
152
|
+
}),
|
|
153
|
+
]
|
|
154
|
+
: []),
|
|
155
|
+
]
|
|
156
|
+
: []),
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: "block",
|
|
163
|
+
label: "Block",
|
|
164
|
+
icon: <HiOutlineHandRaised size={16} />,
|
|
165
|
+
submenu: [
|
|
166
|
+
{
|
|
167
|
+
key: "block-items",
|
|
168
|
+
items: [
|
|
169
|
+
row("block-sender", blockLabel, <HiOutlineHandRaised size={16} />, actions.blockSender, waiting(blockReason), { title: blockLabel }),
|
|
170
|
+
row(
|
|
171
|
+
"never-block-sender",
|
|
172
|
+
neverBlockLabel,
|
|
173
|
+
<HiOutlineShieldCheck size={16} />,
|
|
174
|
+
actions.neverBlockSender,
|
|
175
|
+
waiting(blockReason),
|
|
176
|
+
{ title: neverBlockLabel },
|
|
177
|
+
),
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
key: "output",
|
|
186
|
+
items: [
|
|
187
|
+
row("print", "Print", <HiOutlinePrinter size={16} />, actions.print, printReason),
|
|
188
|
+
{
|
|
189
|
+
key: "view",
|
|
190
|
+
label: "View",
|
|
191
|
+
icon: <HiOutlineEye size={16} />,
|
|
192
|
+
submenu: [
|
|
193
|
+
{
|
|
194
|
+
key: "view-items",
|
|
195
|
+
items: [
|
|
196
|
+
row("view-source", "View message source", <HiOutlineCodeBracket size={16} />, actions.viewSource),
|
|
197
|
+
row("view-details", "Message details", <HiOutlineInformationCircle size={16} />, actions.viewDetails),
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
key: "save-as",
|
|
204
|
+
label: "Save as",
|
|
205
|
+
icon: <HiOutlineArrowDownTray size={16} />,
|
|
206
|
+
submenu: [
|
|
207
|
+
{
|
|
208
|
+
key: "save-as-items",
|
|
209
|
+
items: [
|
|
210
|
+
row("save-eml", "Save as .eml", <HiOutlineEnvelope size={16} />, actions.saveAsEml),
|
|
211
|
+
row("save-pdf", "Save as PDF", <HiOutlineDocumentText size={16} />, actions.print, printReason, {
|
|
212
|
+
description: printReason ?? "Opens the print dialog: choose Save as PDF",
|
|
213
|
+
}),
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
key: "advanced",
|
|
222
|
+
items: [
|
|
223
|
+
{
|
|
224
|
+
key: "advanced-actions",
|
|
225
|
+
label: "Advanced actions",
|
|
226
|
+
icon: <HiOutlineCog6Tooth size={16} />,
|
|
227
|
+
submenu: [
|
|
228
|
+
{
|
|
229
|
+
key: "advanced-items",
|
|
230
|
+
items: [row("create-rule", "Create rule", <HiOutlineFunnel size={16} />, actions.createRule, changeReason)],
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 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
|
|
241
|
+
* 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
|
|
242
|
+
* 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
|
|
243
|
+
* is disabled and says why under its label.
|
|
244
|
+
*/
|
|
245
|
+
export default function MessageMoreMenu(props: MessageMoreMenuProps) {
|
|
246
|
+
return (
|
|
247
|
+
<MenuButton
|
|
248
|
+
iconOnly
|
|
249
|
+
label="More actions"
|
|
250
|
+
aria-label="More actions"
|
|
251
|
+
title="More actions"
|
|
252
|
+
icon={<HiOutlineEllipsisHorizontal size={16} aria-hidden="true" />}
|
|
253
|
+
className={props.triggerClassName}
|
|
254
|
+
sections={buildMessageMenu(props)}
|
|
255
|
+
width={288}
|
|
256
|
+
/>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useMemo } from "react";
|
|
6
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
7
|
+
import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
|
|
8
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
9
|
+
import { MAX_SOURCE_DISPLAY_CHARS, headerText, sourceText } from "./messageExport.js";
|
|
10
|
+
|
|
11
|
+
/** What the dialog is showing a message's source for: the whole of it, or only its header lines ("Message details"). */
|
|
12
|
+
export type SourceMode = "source" | "headers";
|
|
13
|
+
|
|
14
|
+
/** What has been fetched of the raw message: on its way, here, or refused. */
|
|
15
|
+
export type SourceState = { status: "loading" } | { status: "ready"; raw: string } | { status: "error"; message: string };
|
|
16
|
+
|
|
17
|
+
export interface MessageSourceDialogProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
mode: SourceMode;
|
|
21
|
+
source: SourceState;
|
|
22
|
+
/** The message is end-to-end encrypted: what is shown is the ciphertext the server holds, and the dialog says so. */
|
|
23
|
+
encrypted: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TITLES: Record<SourceMode, string> = { source: "Message source", headers: "Message details" };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 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
|
|
30
|
+
* button. It is the RFC 5322 text exactly as the server stores it - for an encrypted message that is the encrypted message, never a
|
|
31
|
+
* decrypted one, and the dialog says so. A very long source is cut for display (`MAX_SOURCE_DISPLAY_CHARS`); Copy has all of it.
|
|
32
|
+
*/
|
|
33
|
+
export default function MessageSourceDialog({ open, onClose, mode, source, encrypted }: MessageSourceDialogProps) {
|
|
34
|
+
const text = useMemo(() => {
|
|
35
|
+
if (source.status !== "ready") {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
return mode === "headers" ? headerText(source.raw) : sourceText(source.raw);
|
|
39
|
+
}, [source, mode]);
|
|
40
|
+
const cut = text.length > MAX_SOURCE_DISPLAY_CHARS;
|
|
41
|
+
const title = TITLES[mode];
|
|
42
|
+
return (
|
|
43
|
+
<Drawer open={open} onClose={onClose} title={title} fullScreen>
|
|
44
|
+
<div className="flex flex-col gap-3 text-sm">
|
|
45
|
+
{encrypted && (
|
|
46
|
+
<p role="note" className="py-2 px-3 rounded-sm bg-surface-alt text-text">
|
|
47
|
+
This message is encrypted. This is the encrypted message exactly as the server stores it; it is not decrypted here.
|
|
48
|
+
</p>
|
|
49
|
+
)}
|
|
50
|
+
{source.status === "loading" && (
|
|
51
|
+
<p role="status" className="text-text-muted">
|
|
52
|
+
Loading the message…
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
{source.status === "error" && <Alert>{source.message}</Alert>}
|
|
56
|
+
{source.status === "ready" && (
|
|
57
|
+
<>
|
|
58
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
59
|
+
<CopyButton value={text} label={`Copy the ${mode === "headers" ? "message headers" : "message source"}`} />
|
|
60
|
+
{cut && (
|
|
61
|
+
<span className="text-xs text-text-muted">
|
|
62
|
+
Showing the first {MAX_SOURCE_DISPLAY_CHARS.toLocaleString()} of {text.length.toLocaleString()} characters. Copy and Save as
|
|
63
|
+
.eml keep all of it.
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
<pre
|
|
68
|
+
tabIndex={0}
|
|
69
|
+
aria-label={title}
|
|
70
|
+
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"
|
|
71
|
+
>
|
|
72
|
+
{cut ? text.slice(0, MAX_SOURCE_DISPLAY_CHARS) : text}
|
|
73
|
+
</pre>
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</Drawer>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* What the reading pane does with a message's raw RFC 5322 source (`getMessageRawContent()`, a binary string): show it, save it as a
|
|
9
|
+
* file, and read the From header out of it. Nothing here interprets the message's content beyond its header block.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** How much of a message's source the "View message source" dialog draws: a message with attachments is mostly base64, and a
|
|
13
|
+
* megabyte of it in a `<pre>` helps nobody. Saving as .eml and Copy always have all of it. */
|
|
14
|
+
export const MAX_SOURCE_DISPLAY_CHARS = 200_000;
|
|
15
|
+
|
|
16
|
+
/** The file name "Save as .eml" gives a message: its subject without what a file system refuses, or `message`. */
|
|
17
|
+
export function emlFileName(subject: string | undefined): string {
|
|
18
|
+
const cleaned = (subject ?? "")
|
|
19
|
+
.replace(/[\p{Cc}\\/:*?"<>|]+/gu, " ")
|
|
20
|
+
.replace(/\s+/g, " ")
|
|
21
|
+
.trim()
|
|
22
|
+
.slice(0, 100)
|
|
23
|
+
.replace(/^\.+/, "")
|
|
24
|
+
.replace(/[. ]+$/, "");
|
|
25
|
+
return `${cleaned || "message"}.eml`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Hands `bytes` to the browser as a download called `fileName`. */
|
|
29
|
+
export function downloadBytes(bytes: Uint8Array, fileName: string, type: string): void {
|
|
30
|
+
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type }));
|
|
31
|
+
const a = document.createElement("a");
|
|
32
|
+
a.href = url;
|
|
33
|
+
a.download = fileName;
|
|
34
|
+
document.body.appendChild(a);
|
|
35
|
+
a.click();
|
|
36
|
+
a.remove();
|
|
37
|
+
// Some browsers are still reading the blob after `click()` returns.
|
|
38
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Saves a raw message (a binary string, exactly as the server has it) as a `.eml` file, byte for byte. */
|
|
42
|
+
export function saveAsEml(raw: string, subject: string | undefined): void {
|
|
43
|
+
downloadBytes(binaryStringToBytes(raw), emlFileName(subject), "message/rfc822");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A raw message as text to read: its bytes decoded as UTF-8, an invalid byte shown as U+FFFD. */
|
|
47
|
+
export function sourceText(raw: string): string {
|
|
48
|
+
return new TextDecoder("utf-8").decode(binaryStringToBytes(raw));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The header block of a raw message, as text to read. */
|
|
52
|
+
export function headerText(raw: string): string {
|
|
53
|
+
return sourceText(parseMimeEntity(raw).rawHeaderBlock);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The address in a raw message's own From header (which is not always the envelope sender the server files the message under), lowercased. */
|
|
57
|
+
export function headerFromAddress(raw: string): string | undefined {
|
|
58
|
+
return extractAddresses(parseMimeEntity(raw).headers.from)[0];
|
|
59
|
+
}
|