@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
@@ -0,0 +1,41 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiOutlineTrash } from "react-icons/hi2";
7
+
8
+ export interface EmptyFolderBarProps {
9
+ /** The folder the list is showing ("Deleted Items", "Junk Email"). */
10
+ folderName: string;
11
+ /** How many messages it holds, when that is known. */
12
+ count?: number;
13
+ /** Nothing to empty, or not allowed to. */
14
+ disabled: boolean;
15
+ /** Why, shown as the button's tooltip while it is disabled. */
16
+ disabledReason?: string;
17
+ onEmpty: () => void;
18
+ }
19
+
20
+ /**
21
+ * Outlook's "Empty folder", at the top of the list of Deleted Items and of Junk Email: the whole folder is permanently deleted, after the
22
+ * page has asked (this only reports the click). A row of its own under the toolbar rather than a toolbar button, so the toolbar keeps the
23
+ * width a phone gives it and the action is never taken for one of the list's arrangement controls.
24
+ */
25
+ export default function EmptyFolderBar({ folderName, count, disabled, disabledReason, onEmpty }: EmptyFolderBarProps) {
26
+ return (
27
+ <div className="flex items-center justify-between gap-2 px-3 py-1 border-b border-border text-xs text-text-muted">
28
+ <span>{count === undefined ? "" : `${count} ${count === 1 ? "item" : "items"}`}</span>
29
+ <button
30
+ type="button"
31
+ onClick={onEmpty}
32
+ disabled={disabled}
33
+ title={disabled ? disabledReason : `Permanently delete everything in ${folderName}`}
34
+ className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-sm text-danger hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent"
35
+ >
36
+ <HiOutlineTrash size={16} aria-hidden="true" className="shrink-0" />
37
+ {`Empty ${folderName}`}
38
+ </button>
39
+ </div>
40
+ );
41
+ }
@@ -46,7 +46,12 @@ export interface MailSelectionBarProps {
46
46
  * prompt, which would be claiming the move simply didn't happen. */
47
47
  onMoveTo: (folderUid: string) => Promise<void>;
48
48
  onReportJunk: () => void;
49
+ /** Delete: moves the selection to Deleted Items - or, when `deletesPermanently`, permanently deletes it (the page asks first). */
49
50
  onDelete: () => void;
51
+ /** The selection is in Deleted Items, so Delete is the permanent one: the button says "Delete permanently" and is styled as the destructive
52
+ * action it is. The page decides (a search over several mailboxes lists messages from more than one folder, so the folder being viewed is not
53
+ * always the answer). */
54
+ deletesPermanently?: boolean;
50
55
  /** A bulk action is in flight - every action is held until it settles, since the next one would send
51
56
  * `version`s the first has already superseded. */
52
57
  busy: boolean;
@@ -60,8 +65,8 @@ export interface MailSelectionBarProps {
60
65
  labelsDisabledReason?: string;
61
66
  }
62
67
 
63
- function actionClassName(): string {
64
- return "px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
68
+ function actionClassName(destructive = false): string {
69
+ return `px-2 py-1 rounded-md text-sm ${destructive ? "text-danger font-semibold" : "text-text"} hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent`;
65
70
  }
66
71
 
67
72
  /**
@@ -69,9 +74,9 @@ function actionClassName(): string {
69
74
  * select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
70
75
  * archive, move, report junk and delete.
71
76
  *
72
- * "Delete" means *move to Deleted Items*, never a permanent erase: there is no bulk permanent-delete API,
73
- * and the collection-level DELETE truncates the folder rather than removing a selection. An action whose
74
- * target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
77
+ * "Delete" means *move to Deleted Items* - except on messages that are already in Deleted Items (`deletesPermanently`),
78
+ * where it is "Delete permanently": the page confirms, then erases them for good, as Outlook's Delete does there. An
79
+ * action whose target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
75
80
  * of actions doesn't shift around between folders.
76
81
  */
77
82
  export default function MailSelectionBar({
@@ -94,17 +99,16 @@ export default function MailSelectionBar({
94
99
  onMoveTo,
95
100
  onReportJunk,
96
101
  onDelete,
102
+ deletesPermanently = false,
97
103
  busy,
98
104
  shortcuts,
99
105
  moveDisabledReason,
100
106
  labelsDisabledReason,
101
107
  }: MailSelectionBarProps) {
102
108
  const env = useKeyEnvironment();
103
- /** `title` and `aria-keyshortcuts` for an action the keyboard also does; `reason` (why it is disabled) wins the tooltip. */
104
- const hint = (label: string, shortcut: ShortcutDef, reason?: string) =>
105
- shortcuts
106
- ? { title: reason ?? withHint(label, shortcut, env), "aria-keyshortcuts": ariaKeyShortcuts(shortcut, env) }
107
- : { title: reason };
109
+ /** `title` and `aria-keyshortcuts` for an action the keyboard also does. */
110
+ const hint = (label: string, shortcut: ShortcutDef) =>
111
+ shortcuts ? { title: withHint(label, shortcut, env), "aria-keyshortcuts": ariaKeyShortcuts(shortcut, env) } : {};
108
112
  const selectedCount = totals?.selected ?? selected.length;
109
113
  const listedCount = totals?.listed ?? listed.length;
110
114
  // Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
@@ -122,7 +126,7 @@ export default function MailSelectionBar({
122
126
  // them is already being in the folder they would move to.
123
127
  const archiveReason = currentType === "archive" ? "These messages are already in Archive" : undefined;
124
128
  const junkReason = currentType === "junk" ? "These messages are already in Junk" : undefined;
125
- const deleteReason = currentType === "deleted_items" ? "These messages are already in Deleted Items" : undefined;
129
+ const deleteLabel = deletesPermanently ? "Delete permanently" : "Delete";
126
130
 
127
131
  // A label every selected message already carries starts ticked; one only some of them carry starts
128
132
  // partially applied, and is left exactly as it is unless the reader touches that row.
@@ -224,11 +228,11 @@ export default function MailSelectionBar({
224
228
  <button
225
229
  type="button"
226
230
  onClick={onDelete}
227
- disabled={none || busy || !!deleteReason}
228
- className={actionClassName()}
229
- {...hint("Delete", SHORTCUTS.mail.delete, deleteReason)}
231
+ disabled={none || busy}
232
+ className={actionClassName(deletesPermanently)}
233
+ {...hint(deleteLabel, SHORTCUTS.mail.delete)}
230
234
  >
231
- Delete
235
+ {deleteLabel}
232
236
  </button>
233
237
  </div>
234
238
  </div>
@@ -19,6 +19,10 @@ interface MenuItemBase {
19
19
  checked?: boolean | "mixed";
20
20
  /** A colour swatch before the label - a label's own colour. */
21
21
  swatchColor?: string;
22
+ /** An icon before the label, for a menu whose rows are commands (the reading pane's "More actions"). */
23
+ icon?: ReactNode;
24
+ /** A tooltip for the row - the full text of a label the row truncates. */
25
+ title?: string;
22
26
  disabled?: boolean;
23
27
  /** Leaves the menu open after choosing this row, for a multi-select list where several rows are ticked
24
28
  * before one command commits them all. */
@@ -97,8 +101,11 @@ export function menuHeight(sections: MenuSectionSpec[], width: number = DEFAULT_
97
101
  }
98
102
 
99
103
  export interface MenuButtonProps {
100
- /** The trigger's visible label. */
104
+ /** The trigger's visible label. Not drawn (nor is the chevron) with `iconOnly`, where `aria-label` is all the trigger says. */
101
105
  label: ReactNode;
106
+ /** The trigger is just its `icon` - a round icon button like the reading pane's own command row, whose `className` it takes over
107
+ * completely. `aria-label` (and `title`) name it. */
108
+ iconOnly?: boolean;
102
109
  /** The trigger's accessible name, which also names the menu itself - includes the current selection
103
110
  * where there is one ("Filter: Unread"), so the button says what it's set to, not just what it does. */
104
111
  "aria-label": string;
@@ -127,6 +134,7 @@ export interface MenuButtonProps {
127
134
  */
128
135
  export default function MenuButton({
129
136
  label,
137
+ iconOnly = false,
130
138
  icon,
131
139
  disabled,
132
140
  title,
@@ -190,8 +198,10 @@ export default function MenuButton({
190
198
 
191
199
  function enterSubmenu(item: MenuSubmenuSpec) {
192
200
  setSubmenuKey(item.key);
193
- // Past the Back row, onto the first row of the submenu itself.
194
- setActiveIndex(1 + initialActiveIndex(item.submenu.flatMap((section) => section.items)));
201
+ const rows = item.submenu.flatMap((section) => section.items);
202
+ // Past the Back row, onto the first row of the submenu itself - or, when none of them can take focus (every row is unavailable right now), onto
203
+ // the Back row, so the keyboard still has a place to be.
204
+ setActiveIndex(rows.some((row) => !row.disabled) ? 1 + initialActiveIndex(rows) : 0);
195
205
  }
196
206
 
197
207
  function leaveSubmenu() {
@@ -285,16 +295,20 @@ export default function MenuButton({
285
295
  disabled={disabled}
286
296
  onClick={() => (open ? closeMenu(false) : openMenu())}
287
297
  onKeyDown={handleTriggerKeyDown}
288
- className={[
289
- // `min-w-0` so a long label ("Filter: Has attachments") truncates instead of pushing
290
- // whatever sits beside it in the toolbar off the edge of a 384px list column.
291
- "inline-flex min-w-0 items-center gap-1 px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
292
- className,
293
- ].join(" ")}
298
+ className={
299
+ iconOnly
300
+ ? className
301
+ : [
302
+ // `min-w-0` so a long label ("Filter: Has attachments") truncates instead of pushing
303
+ // whatever sits beside it in the toolbar off the edge of a 384px list column.
304
+ "inline-flex min-w-0 items-center gap-1 px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
305
+ className,
306
+ ].join(" ")
307
+ }
294
308
  >
295
309
  {icon}
296
- <span className="truncate">{label}</span>
297
- <HiChevronDown size={14} aria-hidden="true" className="shrink-0 text-text-muted" />
310
+ {!iconOnly && <span className="truncate">{label}</span>}
311
+ {!iconOnly && <HiChevronDown size={14} aria-hidden="true" className="shrink-0 text-text-muted" />}
298
312
  </button>
299
313
  {open && (
300
314
  <PopoverPortal
@@ -332,6 +346,7 @@ export default function MenuButton({
332
346
  type="button"
333
347
  role={role}
334
348
  disabled={item.disabled}
349
+ title={item.title}
335
350
  tabIndex={index === activeIndex ? 0 : -1}
336
351
  {...(role === "menuitem"
337
352
  ? {}
@@ -352,6 +367,11 @@ export default function MenuButton({
352
367
  </span>
353
368
  <span className="min-w-0 flex-1">
354
369
  <span className="flex h-5 items-center gap-1.5">
370
+ {item.icon && (
371
+ <span aria-hidden="true" className="shrink-0 text-text-muted">
372
+ {item.icon}
373
+ </span>
374
+ )}
355
375
  {item.swatchColor && (
356
376
  <span
357
377
  aria-hidden="true"
@@ -10,8 +10,10 @@ import {
10
10
  HiOutlineCheck,
11
11
  HiOutlineExclamationTriangle,
12
12
  HiOutlineFolderArrowDown,
13
+ HiOutlineInboxArrowDown,
13
14
  HiOutlineLockClosed,
14
15
  HiOutlineMoon,
16
+ HiOutlineNoSymbol,
15
17
  HiOutlineSun,
16
18
  } from "react-icons/hi2";
17
19
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
@@ -68,6 +70,10 @@ import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
68
70
  import { useShortcut } from "../../keyboard/useShortcut.js";
69
71
  import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
70
72
  import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
73
+ import { useNavigate } from "../../navigation/index.js";
74
+ import { notify } from "../../notifications/store.js";
75
+ import { notifyApiError } from "../../notifications/apiErrors.js";
76
+ import { useMailboxUpdateAccess } from "../../mail/useMailboxUpdateAccess.js";
71
77
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
72
78
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
73
79
  import LabelMenuButton from "./labelMenu.js";
@@ -76,7 +82,12 @@ import EncryptedBody from "./reading/EncryptedBody.js";
76
82
  import { displaySubject } from "./reading/EncryptedPreview.js";
77
83
  import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
78
84
  import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
79
- import type { BodyContent } from "./reading/bodyContent.js";
85
+ import MessageMoreMenu, { MessageMenuActions } from "./reading/MessageMoreMenu.js";
86
+ import MessageSourceDialog, { SourceMode, SourceState } from "./reading/MessageSourceDialog.js";
87
+ import { useMessageActions } from "./reading/useMessageActions.js";
88
+ import { saveAsEml } from "./reading/messageExport.js";
89
+ import { buildPrintDocument, printDocument } from "./reading/printMessage.js";
90
+ import { type BodyContent, fetchBodyContent } from "./reading/bodyContent.js";
80
91
  import { useViewOriginal } from "./reading/viewOriginal.js";
81
92
  import { ROW_FOCUS_CLASS, UnreadLabel, dateClass, senderClass } from "./unreadStyle.js";
82
93
 
@@ -198,11 +209,16 @@ function downloadMimeAttachment(attachment: MimeAttachment): void {
198
209
  * A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
199
210
  * text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
200
211
  */
212
+ /** The look of the card's round icon buttons, shared with the "More actions" trigger. */
213
+ const ICON_BUTTON_CLASS =
214
+ "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
215
+
201
216
  function IconAction({
202
217
  icon,
203
218
  label,
204
219
  onClick,
205
220
  disabled,
221
+ reason,
206
222
  busy,
207
223
  onPrefetch,
208
224
  shortcut,
@@ -211,6 +227,8 @@ function IconAction({
211
227
  label: string;
212
228
  onClick: () => void;
213
229
  disabled?: boolean;
230
+ /** Why the button is disabled, when it is: the tooltip says so instead of naming the action. */
231
+ reason?: string;
214
232
  busy?: boolean;
215
233
  /** Called when the pointer or keyboard reaches the button - the moment to start fetching what a click will need. */
216
234
  onPrefetch?: () => void;
@@ -223,14 +241,14 @@ function IconAction({
223
241
  <button
224
242
  type="button"
225
243
  aria-label={label}
226
- title={shortcut ? withHint(label, shortcut, env) : label}
244
+ title={reason ?? (shortcut ? withHint(label, shortcut, env) : label)}
227
245
  aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut, env) : undefined}
228
246
  aria-busy={busy || undefined}
229
247
  disabled={disabled}
230
248
  onClick={onClick}
231
249
  onPointerEnter={onPrefetch}
232
250
  onFocus={onPrefetch}
233
- className="inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent"
251
+ className={ICON_BUTTON_CLASS}
234
252
  >
235
253
  {icon}
236
254
  </button>
@@ -333,6 +351,11 @@ export interface MessageDetailPaneProps {
333
351
  * server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
334
352
  * for the Outbox-cancel flow) rather than a new prop. */
335
353
  onArchived?: (updated: Message) => void;
354
+ /** Called with the server's updated copy (and the copy it replaces) after the card's More actions menu marked the message read or unread, or
355
+ * flagged or unflagged it - it stays where it is, so a caller patches it in place, as it does for `onLabelsChanged`. The menu's Delete, Report
356
+ * junk and Block move the message out of its folder and are reported through `onMoved`, which is what makes a thread's card leave it and the
357
+ * pane advance, exactly as after Archive. */
358
+ onChanged?: (updated: Message, previous?: Message) => void;
336
359
  /** Every label defined in this message's mailbox, for the label-assignment popover below — each
337
360
  * caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
338
361
  * (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
@@ -369,9 +392,14 @@ export interface MessageDetailPaneProps {
369
392
  shortcuts?: boolean;
370
393
  }
371
394
 
395
+ /** A subject without its \`Re:\`/\`Fwd:\` prefixes. */
396
+ function withoutSubjectPrefixes(subject: string | undefined): string {
397
+ return (subject ?? "").replace(/^(\s*(re|fw|fwd|aw|sv)\s*:)+/i, "").trim();
398
+ }
399
+
372
400
  /** A subject without its \`Re:\`/\`Fwd:\` prefixes, lower-cased - what makes two messages of one thread the same subject. */
373
401
  function subjectCore(subject: string | undefined): string {
374
- return (subject ?? "").replace(/^(\s*(re|fw|fwd|aw|sv)\s*:)+/i, "").trim().toLowerCase();
402
+ return withoutSubjectPrefixes(subject).toLowerCase();
375
403
  }
376
404
 
377
405
  /** Whether a message's subject says nothing its thread's subject doesn't. */
@@ -425,6 +453,7 @@ function MessageDetailContent({
425
453
  onFolderCreated,
426
454
  onReceiptHandled,
427
455
  onArchived,
456
+ onChanged,
428
457
  labels,
429
458
  onLabelsChanged,
430
459
  onLabelCreated,
@@ -922,6 +951,37 @@ function MessageDetailContent({
922
951
  useShortcut(SHORTCUTS.mail.archive, () => void (!archiving && handleArchive()), { enabled: keyboard && archivable });
923
952
  useShortcut(SHORTCUTS.mail.move, () => setMovePrompt(true), { enabled: keyboard && movable });
924
953
 
954
+ // The card's Report junk button and "More actions" menu: they act on this message, in the mailbox it is in. Nothing of them applies to a message
955
+ // being written or sent (Drafts, Outbox), and Report junk and Block are not for mail the reader sent.
956
+ const folderType = folders?.find((folder) => folder.uid === message.folderUid)?.type;
957
+ const inJunk = folderType === "junk";
958
+ const inDeletedItems = folderType === "deleted_items";
959
+ const reportable = archivable && !isSentItems;
960
+ const writable = useMailboxUpdateAccess(readerMailbox);
961
+ const remember = (updated: Message) => {
962
+ latestLabelsMessageRef.current = updated;
963
+ setLabelsMessage(updated);
964
+ };
965
+ // Every address the reader owns, in any of their mailboxes: a sender among them is not blocked.
966
+ const readerOwnAddresses = mailboxes.flatMap((mb) => [mb.primarySmtpAddress, ...(mb.aliasAddresses ?? [])]).filter((address) => !!address).map((address) => address.toLowerCase());
967
+ const messageActions = useMessageActions({
968
+ message,
969
+ ownAddresses: readerOwnAddresses,
970
+ newest: () => (message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current),
971
+ remember,
972
+ folders,
973
+ inJunk,
974
+ inDeletedItems,
975
+ trackMessageChange,
976
+ onMoved,
977
+ onChanged,
978
+ onFolderCreated,
979
+ });
980
+ const navigate = useNavigate();
981
+ const [sourceView, setSourceView] = useState<{ mode: SourceMode; source: SourceState } | null>(null);
982
+ // Which "View source" request the dialog is waiting for: one answering after the dialog was closed (or another was opened) is not shown.
983
+ const sourceRequestRef = useRef(0);
984
+
925
985
  // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
926
986
  // is loaded — same real invariant as every other handler above.
927
987
  async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
@@ -1040,6 +1100,108 @@ function MessageDetailContent({
1040
1100
  </>
1041
1101
  );
1042
1102
 
1103
+ // ---- The "More actions" menu's own rows (Print, View, Save as, Create rule); the rest are `messageActions`. ----
1104
+
1105
+ /** Prints this one message: its header lines and the body the pane shows (what was decrypted or verified here, else the server's sanitized
1106
+ * body), in a frame of its own - see `printMessage.ts`. */
1107
+ async function handlePrint() {
1108
+ try {
1109
+ const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
1110
+ const format = (list: Recipient[]) => list.map((r) => formatMailAddress(r)).join(", ");
1111
+ const printable = buildPrintDocument({
1112
+ subject: shownSubject,
1113
+ headers: [
1114
+ { name: "From", value: senderLabel },
1115
+ { name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
1116
+ { name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
1117
+ { name: "Date", value: new Date(message.receivedDate).toLocaleString() },
1118
+ ],
1119
+ content,
1120
+ attachments,
1121
+ inlineParts: innerAttachments,
1122
+ });
1123
+ if (printable === undefined) {
1124
+ notify({ kind: "warning", title: "This message is too large to print here" });
1125
+ return;
1126
+ }
1127
+ printDocument(printable);
1128
+ } catch (err) {
1129
+ notifyApiError(err, "Couldn't print this message");
1130
+ }
1131
+ }
1132
+
1133
+ /** Opens the source (or just the headers) of the message as the server has it - for an encrypted message, its ciphertext. */
1134
+ function openSource(mode: SourceMode) {
1135
+ const request = ++sourceRequestRef.current;
1136
+ setSourceView({ mode, source: { status: "loading" } });
1137
+ getMessageRawContent(message.uid).then(
1138
+ (raw) => {
1139
+ if (sourceRequestRef.current === request) {
1140
+ setSourceView({ mode, source: { status: "ready", raw } });
1141
+ }
1142
+ },
1143
+ (err) => {
1144
+ if (sourceRequestRef.current === request) {
1145
+ setSourceView({ mode, source: { status: "error", message: err instanceof ApiRequestError ? err.message : "Could not load this message's source." } });
1146
+ }
1147
+ },
1148
+ );
1149
+ }
1150
+
1151
+ function closeSource() {
1152
+ sourceRequestRef.current++;
1153
+ setSourceView(null);
1154
+ }
1155
+
1156
+ async function handleSaveAsEml() {
1157
+ try {
1158
+ saveAsEml(await getMessageRawContent(message.uid), protectedSubject ?? displaySubject(message.subject));
1159
+ } catch (err) {
1160
+ notifyApiError(err, "Couldn't save this message");
1161
+ }
1162
+ }
1163
+
1164
+ /** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
1165
+ function handleCreateRule() {
1166
+ const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
1167
+ const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
1168
+ if (subject) {
1169
+ params.set("subject", subject);
1170
+ }
1171
+ navigate(`/settings/filters/new?${params.toString()}`);
1172
+ }
1173
+
1174
+ const menuActions: MessageMenuActions = {
1175
+ replyAll: () => void handleReplyOrForward("replyAll"),
1176
+ forward: () => void handleReplyOrForward("forward"),
1177
+ deleteMessage: () => void messageActions.deleteMessage(),
1178
+ toggleRead: () => void messageActions.toggleRead(),
1179
+ toggleFlag: () => void messageActions.toggleFlag(),
1180
+ reportJunk: () => void messageActions.reportJunk(),
1181
+ reportPhishing: () => void messageActions.reportPhishing(),
1182
+ notJunk: () => void messageActions.notJunk(),
1183
+ notJunkAndTrust: () => void messageActions.notJunkAndTrust(),
1184
+ blockSender: () => void messageActions.blockSender(),
1185
+ neverBlockSender: () => void messageActions.neverBlockSender(),
1186
+ print: () => void handlePrint(),
1187
+ viewSource: () => openSource("source"),
1188
+ viewDetails: () => openSource("headers"),
1189
+ saveAsEml: () => void handleSaveAsEml(),
1190
+ createRule: handleCreateRule,
1191
+ };
1192
+ // An encrypted message can be printed once it has been opened here: while it is still opening, or when it can't be, there is nothing to print.
1193
+ const printReason = !message.encrypted
1194
+ ? undefined
1195
+ : security === null
1196
+ ? "Still opening this message"
1197
+ : security.decryptError === undefined
1198
+ ? undefined
1199
+ : lockedRef.current
1200
+ ? "Unlock this message to print it"
1201
+ : "This message can't be read, so it can't be printed";
1202
+ // In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
1203
+ const reportReason = !writable ? "View-only mailbox" : undefined;
1204
+
1043
1205
  const card = (
1044
1206
  <CardShell unread={cardUnread}>
1045
1207
  {/* Header row: who it is from, when, and what can be done with it. Wraps: on a phone the actions drop under the sender. */}
@@ -1153,6 +1315,16 @@ function MessageDetailContent({
1153
1315
  onClick={() => setMovePrompt(true)}
1154
1316
  />
1155
1317
  )}
1318
+ {reportable && (
1319
+ <IconAction
1320
+ icon={inJunk ? <HiOutlineInboxArrowDown size={16} aria-hidden="true" /> : <HiOutlineNoSymbol size={16} aria-hidden="true" />}
1321
+ label={inJunk ? "Not junk" : "Report junk"}
1322
+ busy={messageActions.busy}
1323
+ disabled={messageActions.busy || reportReason !== undefined}
1324
+ reason={reportReason}
1325
+ onClick={() => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk())}
1326
+ />
1327
+ )}
1156
1328
  {labels && labels.length > 0 && (
1157
1329
  <LabelMenuButton
1158
1330
  aria-label="Labels"
@@ -1170,6 +1342,24 @@ function MessageDetailContent({
1170
1342
  clear={{ label: "Remove all labels" }}
1171
1343
  />
1172
1344
  )}
1345
+ {archivable && (
1346
+ <MessageMoreMenu
1347
+ actions={menuActions}
1348
+ senderAddress={message.from.address}
1349
+ read={currentLabelsMessage.flags.read === true}
1350
+ flagged={currentLabelsMessage.flags.flagged === true}
1351
+ writable={writable}
1352
+ busy={messageActions.busy}
1353
+ inJunk={inJunk}
1354
+ inDeletedItems={inDeletedItems}
1355
+ sent={!!isSentItems}
1356
+ ownSender={readerOwnAddresses.includes(message.from.address.toLowerCase())}
1357
+ canTrustSender={writable}
1358
+ printReason={printReason}
1359
+ composing={preparingCompose}
1360
+ triggerClassName={ICON_BUTTON_CLASS}
1361
+ />
1362
+ )}
1173
1363
  </div>
1174
1364
  </div>
1175
1365
 
@@ -1487,6 +1677,14 @@ function MessageDetailContent({
1487
1677
  onMove={handleMove}
1488
1678
  onFolderCreated={onFolderCreated}
1489
1679
  />
1680
+ {messageActions.dialog}
1681
+ <MessageSourceDialog
1682
+ open={sourceView !== null}
1683
+ onClose={closeSource}
1684
+ mode={sourceView?.mode ?? "source"}
1685
+ source={sourceView?.source ?? { status: "loading" }}
1686
+ encrypted={!!message.encrypted}
1687
+ />
1490
1688
  <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
1491
1689
  <p className="text-sm text-text-muted mb-4">
1492
1690
  This asks every original recipient's mail system to delete their copy, but only if it's
@@ -0,0 +1,47 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
7
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
8
+
9
+ export interface PermanentDeleteDialogProps {
10
+ open: boolean;
11
+ title: string;
12
+ /** The question, ending in what cannot be undone. */
13
+ message: string;
14
+ /** The destructive button's own words ("Delete permanently"). */
15
+ confirmLabel: string;
16
+ /** The delete is on the wire: the dialog stays up, and can be neither confirmed again nor dismissed. */
17
+ busy: boolean;
18
+ onConfirm: () => void;
19
+ onCancel: () => void;
20
+ }
21
+
22
+ /**
23
+ * The confirmation in front of every permanent delete. A `Modal`, so Escape and the Cancel button dismiss it and Tab stays inside it. Focus
24
+ * starts on the dialog itself, never on the destructive button, so pressing Enter (or the Delete key again) on a dialog the reader did not
25
+ * mean to open keeps their mail. It remembers nothing: every permanent delete asks.
26
+ */
27
+ export default function PermanentDeleteDialog({ open, title, message, confirmLabel, busy, onConfirm, onCancel }: PermanentDeleteDialogProps) {
28
+ return (
29
+ <Modal open={open} onClose={() => !busy && onCancel()} title={title}>
30
+ <p className="text-sm mb-5">{message}</p>
31
+ <div className="flex gap-3 justify-end mt-5">
32
+ <Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={onCancel}>
33
+ Cancel
34
+ </Button>
35
+ <Button
36
+ type="button"
37
+ className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
38
+ loading={busy}
39
+ disabled={busy}
40
+ onClick={onConfirm}
41
+ >
42
+ {confirmLabel}
43
+ </Button>
44
+ </div>
45
+ </Modal>
46
+ );
47
+ }