@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.
Files changed (184) hide show
  1. package/README.md +36 -28
  2. package/apps/admin/distribution-lists/new/index.tsx +3 -1
  3. package/apps/admin/domains/[uid].tsx +3 -1
  4. package/apps/admin/domains/new/index.tsx +3 -1
  5. package/apps/admin/escrow-scopes/[uid].tsx +3 -1
  6. package/apps/admin/escrow-scopes/new/index.tsx +3 -1
  7. package/apps/admin/index.tsx +3 -1
  8. package/apps/admin/mailboxes/[uid].tsx +3 -1
  9. package/apps/admin/mailboxes/new/index.tsx +3 -1
  10. package/apps/admin/transport-rules/new/index.tsx +3 -1
  11. package/apps/escrow/matters/new/index.tsx +3 -1
  12. package/apps/shared/components/admin/layout/AdminShell.tsx +4 -1
  13. package/apps/shared/components/admin/setup/SetupWizard.tsx +3 -1
  14. package/apps/shared/components/calendar/layout/CalendarShell.tsx +2 -2
  15. package/apps/shared/components/contacts/layout/ContactsShell.tsx +2 -2
  16. package/apps/shared/components/layout/AppShell.tsx +24 -43
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -0
  18. package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
  19. package/apps/shared/components/mail/MailSelectionBar.tsx +19 -15
  20. package/apps/shared/components/mail/MenuButton.tsx +31 -11
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +202 -4
  22. package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +36 -34
  24. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
  25. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
  26. package/apps/shared/components/mail/reading/messageExport.ts +59 -0
  27. package/apps/shared/components/mail/reading/printMessage.ts +99 -0
  28. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
  29. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -2
  30. package/apps/shared/components/tasks/layout/TasksShell.tsx +2 -2
  31. package/apps/shared/keyboard/GlobalShortcuts.tsx +1 -1
  32. package/apps/shared/mail/folderOfType.ts +49 -0
  33. package/apps/shared/mail/permanentDelete.ts +160 -0
  34. package/apps/shared/mail/reportNotices.ts +66 -0
  35. package/apps/shared/mail/senderBlocking.ts +141 -0
  36. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
  37. package/apps/shared/mail/usePermanentDelete.tsx +160 -0
  38. package/apps/shared/navigation/frameContext.tsx +1 -1
  39. package/apps/shared/navigation/index.ts +70 -0
  40. package/apps/shared/navigation/pageTitle.ts +19 -0
  41. package/apps/www/_shell.tsx +82 -0
  42. package/apps/www/calendar/index.tsx +5 -2
  43. package/apps/www/contacts/[uid].tsx +6 -3
  44. package/apps/www/contacts/index.tsx +8 -3
  45. package/apps/www/index.tsx +146 -31
  46. package/apps/www/messages/[uid].tsx +8 -4
  47. package/apps/www/settings/appearance/index.tsx +5 -2
  48. package/apps/www/settings/auto-reply/index.tsx +5 -2
  49. package/apps/www/settings/blocked-senders/index.tsx +303 -0
  50. package/apps/www/settings/encryption/index.tsx +5 -2
  51. package/apps/www/settings/filters/[uid].tsx +5 -2
  52. package/apps/www/settings/filters/index.tsx +5 -2
  53. package/apps/www/settings/filters/new/index.tsx +30 -5
  54. package/apps/www/settings/labels/index.tsx +5 -2
  55. package/apps/www/settings/privacy/index.tsx +5 -2
  56. package/apps/www/settings/profile/index.tsx +5 -2
  57. package/apps/www/settings/read-receipts/index.tsx +5 -2
  58. package/apps/www/settings/sharing/index.tsx +5 -2
  59. package/apps/www/settings/signatures/[uid].tsx +5 -2
  60. package/apps/www/settings/signatures/index.tsx +5 -2
  61. package/apps/www/settings/signatures/new/index.tsx +6 -3
  62. package/apps/www/tasks/index.tsx +7 -2
  63. package/dist/apps/admin/distribution-lists/new/index.js +3 -1
  64. package/dist/apps/admin/domains/[uid].js +3 -1
  65. package/dist/apps/admin/domains/new/index.js +3 -1
  66. package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
  67. package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
  68. package/dist/apps/admin/index.js +3 -1
  69. package/dist/apps/admin/mailboxes/[uid].js +3 -1
  70. package/dist/apps/admin/mailboxes/new/index.js +3 -1
  71. package/dist/apps/admin/transport-rules/new/index.js +3 -1
  72. package/dist/apps/escrow/matters/new/index.js +3 -1
  73. package/dist/apps/shared/components/admin/layout/AdminShell.js +4 -1
  74. package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
  75. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  76. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  77. package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
  78. package/dist/apps/shared/components/layout/AppShell.js +24 -33
  79. package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
  80. package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
  81. package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
  82. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +9 -4
  83. package/dist/apps/shared/components/mail/MailSelectionBar.js +10 -12
  84. package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
  85. package/dist/apps/shared/components/mail/MenuButton.js +15 -11
  86. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
  88. package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
  89. package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
  90. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +13 -9
  91. package/dist/apps/shared/components/mail/layout/MailShell.js +26 -33
  92. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
  93. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
  94. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
  95. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
  96. package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
  97. package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
  98. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
  99. package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
  100. package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
  101. package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
  102. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  103. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  104. package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
  105. package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
  106. package/dist/apps/shared/mail/folderOfType.js +40 -0
  107. package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
  108. package/dist/apps/shared/mail/permanentDelete.js +127 -0
  109. package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
  110. package/dist/apps/shared/mail/reportNotices.js +47 -0
  111. package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
  112. package/dist/apps/shared/mail/senderBlocking.js +92 -0
  113. package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
  114. package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
  115. package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
  116. package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
  117. package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
  118. package/dist/apps/shared/navigation/index.d.ts +27 -0
  119. package/dist/apps/shared/navigation/index.js +58 -0
  120. package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
  121. package/dist/apps/shared/navigation/pageTitle.js +12 -0
  122. package/dist/apps/www/_shell.d.ts +26 -0
  123. package/dist/apps/www/_shell.js +31 -0
  124. package/dist/apps/www/calendar/index.d.ts +7 -2
  125. package/dist/apps/www/calendar/index.js +4 -2
  126. package/dist/apps/www/contacts/[uid].d.ts +14 -3
  127. package/dist/apps/www/contacts/[uid].js +5 -3
  128. package/dist/apps/www/contacts/index.d.ts +7 -2
  129. package/dist/apps/www/contacts/index.js +6 -4
  130. package/dist/apps/www/index.d.ts +7 -2
  131. package/dist/apps/www/index.js +117 -19
  132. package/dist/apps/www/messages/[uid].d.ts +14 -3
  133. package/dist/apps/www/messages/[uid].js +7 -5
  134. package/dist/apps/www/settings/appearance/index.d.ts +8 -2
  135. package/dist/apps/www/settings/appearance/index.js +4 -2
  136. package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
  137. package/dist/apps/www/settings/auto-reply/index.js +4 -2
  138. package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
  139. package/dist/apps/www/settings/blocked-senders/index.js +134 -0
  140. package/dist/apps/www/settings/encryption/index.d.ts +7 -2
  141. package/dist/apps/www/settings/encryption/index.js +4 -2
  142. package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
  143. package/dist/apps/www/settings/filters/[uid].js +4 -2
  144. package/dist/apps/www/settings/filters/index.d.ts +7 -2
  145. package/dist/apps/www/settings/filters/index.js +4 -2
  146. package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
  147. package/dist/apps/www/settings/filters/new/index.js +28 -5
  148. package/dist/apps/www/settings/labels/index.d.ts +7 -2
  149. package/dist/apps/www/settings/labels/index.js +4 -2
  150. package/dist/apps/www/settings/privacy/index.d.ts +7 -2
  151. package/dist/apps/www/settings/privacy/index.js +4 -2
  152. package/dist/apps/www/settings/profile/index.d.ts +7 -2
  153. package/dist/apps/www/settings/profile/index.js +4 -2
  154. package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
  155. package/dist/apps/www/settings/read-receipts/index.js +4 -2
  156. package/dist/apps/www/settings/sharing/index.d.ts +7 -2
  157. package/dist/apps/www/settings/sharing/index.js +4 -2
  158. package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
  159. package/dist/apps/www/settings/signatures/[uid].js +4 -2
  160. package/dist/apps/www/settings/signatures/index.d.ts +7 -2
  161. package/dist/apps/www/settings/signatures/index.js +4 -2
  162. package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
  163. package/dist/apps/www/settings/signatures/new/index.js +5 -3
  164. package/dist/apps/www/tasks/index.d.ts +7 -2
  165. package/dist/apps/www/tasks/index.js +5 -3
  166. package/package.json +4 -2
  167. package/apps/shared/navigation/AppRouter.tsx +0 -300
  168. package/apps/shared/navigation/idle.ts +0 -45
  169. package/apps/shared/navigation/routerContext.tsx +0 -83
  170. package/apps/shared/navigation/routes.ts +0 -72
  171. package/apps/www/_routedPage.tsx +0 -24
  172. package/apps/www/_routes.ts +0 -37
  173. package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
  174. package/dist/apps/shared/navigation/AppRouter.js +0 -242
  175. package/dist/apps/shared/navigation/idle.d.ts +0 -14
  176. package/dist/apps/shared/navigation/idle.js +0 -43
  177. package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
  178. package/dist/apps/shared/navigation/routerContext.js +0 -56
  179. package/dist/apps/shared/navigation/routes.d.ts +0 -32
  180. package/dist/apps/shared/navigation/routes.js +0 -37
  181. package/dist/apps/www/_routedPage.d.ts +0 -12
  182. package/dist/apps/www/_routedPage.js +0 -19
  183. package/dist/apps/www/_routes.d.ts +0 -11
  184. 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 { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
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). There is no client-side router in this framework (see
115
- * `ReactRoute`'s file-convention resolver) — the selected mailbox/folder (or aggregate pseudo-folder) live
116
- * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read once on mount (never during
117
- * the initial render itself, matching every other query-param reader in this codebase — e.g.
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. Nothing is selected until the address
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
- // 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.
147
- const [selectionRead, setSelectionRead] = useState(false);
148
- // The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
149
- // clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
150
- // in an effect, never during the first render, so the server render and the hydrating render agree.
151
- const search = useLocationSearch();
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
- setRequestedMailboxUid(params.get("mailboxUid"));
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. Nothing is selected until it has been read (the first render, before the effect above): choosing a
165
- // default then would list a folder - the most expensive being every mailbox's - only to throw it away for the one the address names.
166
- const requestedAggregate = selectionRead && isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
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 = selectionRead && !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
164
+ const aggregateByDefault = !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
174
165
  const aggregateFolderType = requestedAggregate ?? (aggregateByDefault ? "inbox" : undefined);
175
- const mailboxUid = aggregateFolderType || !selectionRead ? undefined : (requestedMailbox ?? defaultMailboxUid);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
+ }