@rapidmx/web-client 0.3.0 → 0.4.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.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -1,595 +1,630 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useState } from "react";
6
- import DOMPurify from "dompurify";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import {
9
- Attachment,
10
- Message,
11
- MessageClassification,
12
- ReceiptType,
13
- approveReceipt,
14
- archiveMessage,
15
- attachmentContentUrl,
16
- cancelScheduledSend,
17
- classifyMessage,
18
- declineReceipt,
19
- getMessageRawContent,
20
- recallMessage,
21
- setMessageLabels,
22
- } from "@rapidmx/react-shared/mail/mailApi.js";
23
- import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
24
- import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
25
- import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
26
- import { MessageSecurityResult, evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
27
- import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
28
- import { useCompose } from "./compose/ComposeContext.js";
29
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
30
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
32
-
33
- /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
34
- * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
35
- * read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
36
- * all), and "Unprotected" MUST NOT read as an error - the class pairs below are chosen so those two
37
- * never share styling with each other or with the verified states. */
38
- const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string; className: string }> = {
39
- unprotected: { label: "Unprotected", className: "bg-surface-alt text-text-muted" },
40
- encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
41
- signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
42
- encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
43
- signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
44
- };
45
-
46
- function SecurityIndicator({ state }: { state: MessageSecurityResult["state"] }) {
47
- const { label, className } = SECURITY_INDICATOR[state];
48
- return <span className={`text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>{label}</span>;
49
- }
50
-
51
- export interface MessageDetailPaneProps {
52
- message: Message | null;
53
- attachments: Attachment[];
54
- /** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
55
- * on the desktop reading pane, which never navigates away (selecting a different message just swaps
56
- * `message` in place). */
57
- backHref?: string;
58
- /** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
59
- * Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
60
- * computes this from its own already-loaded folder list rather than this component fetching folders
61
- * itself. */
62
- isSentItems?: boolean;
63
- /** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
64
- * the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
65
- * `useMarkMessageRead`'s identical `onUpdated` callback. */
66
- onRecalled?: (updated: Message) => void;
67
- /** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
68
- * from. Each caller computes this the same way it computes `isSentItems`. */
69
- isOutbox?: boolean;
70
- /** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
71
- * scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
72
- * on why clearing `scheduledSendTime` alone doesn't do that). */
73
- draftsFolderUid?: string;
74
- /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
75
- * successfully canceling a scheduled send. */
76
- onScheduledSendCanceled?: (updated: Message) => void;
77
- /** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
78
- * concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
79
- * the "Move to Other"/"Move to Focused" control below only renders here, the same
80
- * each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
81
- isInbox?: boolean;
82
- /** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
83
- * successful classify — mirrors `onRecalled`'s identical shape. */
84
- onClassified?: (updated: Message) => void;
85
- /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
86
- * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
87
- * `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
88
- * only ever `true` on a real delivered copy, so the banner below is self-gating. */
89
- onReceiptHandled?: (updated: Message) => void;
90
- /** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
91
- * successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
92
- * message except one currently in Drafts or Outbox (mirrors `BaseMessageRoute.archive()`'s own
93
- * server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
94
- * for the Outbox-cancel flow) rather than a new prop. */
95
- onArchived?: (updated: Message) => void;
96
- /** Every label defined in this message's mailbox, for the label-assignment popover below — each
97
- * caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
98
- * (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
99
- labels?: Label[];
100
- /** Called with the server's updated copy (carrying the new `labelUids`) after successfully toggling
101
- * a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
102
- * changing labels never moves a message between folders. */
103
- onLabelsChanged?: (updated: Message) => void;
104
- }
105
-
106
- function formatBytes(bytes: number): string {
107
- if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
108
- if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
109
- return `${bytes} B`;
110
- }
111
-
112
- /**
113
- * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
114
- * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
115
- * the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
116
- * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
117
- * site for how `message`/`attachments`/`isSentItems` are sourced.
118
- */
119
- export default function MessageDetailPane({
120
- message,
121
- attachments,
122
- backHref,
123
- isSentItems,
124
- onRecalled,
125
- isOutbox,
126
- draftsFolderUid,
127
- onScheduledSendCanceled,
128
- isInbox,
129
- onClassified,
130
- onReceiptHandled,
131
- onArchived,
132
- labels,
133
- onLabelsChanged,
134
- }: MessageDetailPaneProps) {
135
- const { openCompose } = useCompose();
136
- const [confirming, setConfirming] = useState(false);
137
- const [recalling, setRecalling] = useState(false);
138
- const [canceling, setCanceling] = useState(false);
139
- const [archiving, setArchiving] = useState(false);
140
- const [archiveError, setArchiveError] = useState<string | null>(null);
141
- const [labelsOpen, setLabelsOpen] = useState(false);
142
- const [togglingLabelUid, setTogglingLabelUid] = useState<string | null>(null);
143
- const [labelsError, setLabelsError] = useState<string | null>(null);
144
- const [error, setError] = useState<string | null>(null);
145
- // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
146
- // pane rather than inside a confirmation modal — the two flows never need to share one message.
147
- const [cancelError, setCancelError] = useState<string | null>(null);
148
- const [classifying, setClassifying] = useState(false);
149
- const [classifyError, setClassifyError] = useState<string | null>(null);
150
- const [alwaysForSender, setAlwaysForSender] = useState(false);
151
- // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
152
- // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
153
- // wouldn't distinguish which row's buttons should show a loading state.
154
- const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
155
- const [receiptError, setReceiptError] = useState<string | null>(null);
156
- const [security, setSecurity] = useState<MessageSecurityResult | null>(null);
157
-
158
- // Evaluates every message's security state, not just ones flagged `encrypted` - a detached
159
- // `multipart/signed` message needs its raw MIME read too (a sanitized HTML body never carries the
160
- // signature part) to tell "Signed & verified" apart from plain "Unprotected". A fetch/parse failure
161
- // degrades to "Unprotected" rather than surfacing an error - the spec treats that as this module's
162
- // safe default, not a distinct failure state of its own.
163
- useEffect(() => {
164
- if (!message) {
165
- setSecurity(null);
166
- return;
167
- }
168
- let cancelled = false;
169
- setSecurity(null);
170
- getMessageRawContent(message.uid)
171
- .then((rawMime) => evaluateMessageSecurity(rawMime, getUnlockedKeys(message.mailboxUid)))
172
- .then((result) => {
173
- if (!cancelled) {
174
- setSecurity(result);
175
- }
176
- })
177
- .catch(() => {
178
- if (!cancelled) {
179
- setSecurity({ state: "unprotected" });
180
- }
181
- });
182
- return () => {
183
- cancelled = true;
184
- };
185
- }, [message?.uid]);
186
-
187
- if (!message) {
188
- return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
189
- }
190
-
191
- // Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
192
- // once `message` is loaded (the early return above covers the only other state) — the non-null
193
- // assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
194
- function handleReply() {
195
- openCompose({
196
- mailboxUid: message!.mailboxUid,
197
- to: message!.from.address,
198
- subject: replySubject(message!.subject),
199
- quotedHtml: buildReplyQuote(message!),
200
- signatureContext: "reply_forward",
201
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
202
- });
203
- }
204
-
205
- function handleReplyAll() {
206
- const cc = message!.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
207
- openCompose({
208
- mailboxUid: message!.mailboxUid,
209
- to: message!.from.address,
210
- cc: cc.join(", "),
211
- subject: replySubject(message!.subject),
212
- quotedHtml: buildReplyQuote(message!),
213
- signatureContext: "reply_forward",
214
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
215
- });
216
- }
217
-
218
- function handleForward() {
219
- openCompose({
220
- mailboxUid: message!.mailboxUid,
221
- subject: forwardSubject(message!.subject),
222
- quotedHtml: buildForwardQuote(message!),
223
- signatureContext: "reply_forward",
224
- });
225
- }
226
-
227
- async function handleRecall() {
228
- // Only ever invoked from the confirmation modal below, which itself only renders once `message`
229
- // is loaded (the early return above covers the only other state) — the non-null assertion
230
- // reflects that real invariant, matching this codebase's established pattern for the same class
231
- // of always-true-in-practice guard.
232
- setRecalling(true);
233
- setError(null);
234
- try {
235
- const updated = await recallMessage(message!.uid);
236
- setConfirming(false);
237
- onRecalled?.(updated);
238
- } catch (err) {
239
- setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
240
- } finally {
241
- setRecalling(false);
242
- }
243
- }
244
-
245
- // Only ever invoked from the "Cancel" button below, which itself only renders once `message` is
246
- // loaded and `isOutbox`/`message.scheduledSendTime` are both truthy — `draftsFolderUid` is required
247
- // by that same rendering guard (see the prop's own doc comment), so the non-null assertion reflects
248
- // a real invariant, matching `handleRecall`'s identical pattern just above.
249
- async function handleCancelScheduledSend() {
250
- setCanceling(true);
251
- setCancelError(null);
252
- try {
253
- const updated = await cancelScheduledSend(message!, draftsFolderUid!);
254
- onScheduledSendCanceled?.(updated);
255
- } catch (err) {
256
- setCancelError(err instanceof ApiRequestError ? err.message : "Could not cancel this scheduled send.");
257
- } finally {
258
- setCanceling(false);
259
- }
260
- }
261
-
262
- // Only ever invoked from the Archive button below, which itself only renders once `message` is
263
- // loaded and this isn't a Drafts/Outbox message (the button's own guard mirrors
264
- // `BaseMessageRoute.archive()`'s server-side 400) — same real invariant as `handleRecall` above.
265
- async function handleArchive() {
266
- setArchiving(true);
267
- setArchiveError(null);
268
- try {
269
- const updated = await archiveMessage(message!.uid);
270
- onArchived?.(updated);
271
- } catch (err) {
272
- setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
273
- } finally {
274
- setArchiving(false);
275
- }
276
- }
277
-
278
- // Only ever invoked from a checkbox in the Labels popover below, which itself only renders once
279
- // `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
280
- // new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
281
- // list rather than patching a single entry.
282
- async function handleToggleLabel(labelUid: string) {
283
- setTogglingLabelUid(labelUid);
284
- setLabelsError(null);
285
- try {
286
- const current = message!.labelUids ?? [];
287
- const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
288
- const updated = await setMessageLabels(message!, next);
289
- onLabelsChanged?.(updated);
290
- } catch (err) {
291
- setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
292
- } finally {
293
- setTogglingLabelUid(null);
294
- }
295
- }
296
-
297
- // Only ever invoked from the classification button below, which itself only renders once `message`
298
- // is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
299
- // `handleRecall`/`handleCancelScheduledSend` above.
300
- async function handleClassify(classifyAs: MessageClassification) {
301
- setClassifying(true);
302
- setClassifyError(null);
303
- try {
304
- const updated = await classifyMessage(message!.uid, classifyAs, alwaysForSender);
305
- onClassified?.(updated);
306
- } catch (err) {
307
- setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
308
- } finally {
309
- setClassifying(false);
310
- }
311
- }
312
-
313
- // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
314
- // is loaded — same real invariant as every other handler above.
315
- async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
316
- setReceiptBusy(type);
317
- setReceiptError(null);
318
- try {
319
- const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message!.uid, type);
320
- onReceiptHandled?.(updated);
321
- } catch (err) {
322
- setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
323
- } finally {
324
- setReceiptBusy(null);
325
- }
326
- }
327
-
328
- return (
329
- <div className="flex-1 min-w-0 flex flex-col">
330
- <div className="border-b border-border p-4">
331
- {backHref && (
332
- <a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
333
- &larr; Back to messages
334
- </a>
335
- )}
336
- <div className="flex items-start justify-between gap-3">
337
- <div className="flex items-center gap-2 min-w-0">
338
- <h1 className="text-lg font-bold tracking-tight truncate">{message.subject || "(no subject)"}</h1>
339
- {security && <SecurityIndicator state={security.state} />}
340
- </div>
341
- {isSentItems &&
342
- (message.recallRequestedAt ? (
343
- <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
344
- Recall requested
345
- </span>
346
- ) : (
347
- <Button
348
- type="button"
349
- variant="secondary"
350
- className="!w-auto shrink-0"
351
- onClick={() => setConfirming(true)}
352
- >
353
- Recall this message
354
- </Button>
355
- ))}
356
- {isOutbox && message.scheduledSendTime && (
357
- <div className="flex items-center gap-2 shrink-0">
358
- <span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
359
- Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
360
- </span>
361
- <Button
362
- type="button"
363
- variant="secondary"
364
- className="!w-auto"
365
- loading={canceling}
366
- disabled={canceling}
367
- onClick={handleCancelScheduledSend}
368
- >
369
- Cancel
370
- </Button>
371
- </div>
372
- )}
373
- </div>
374
- {cancelError && (
375
- <div className="mt-2">
376
- <Alert>{cancelError}</Alert>
377
- </div>
378
- )}
379
- {security?.headerTamperDetected && (
380
- <div className="mt-2">
381
- <Alert>
382
- This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or
383
- encrypted — an intermediary may have altered them after sending. Treat the fields shown above with
384
- caution.
385
- </Alert>
386
- </div>
387
- )}
388
- <p className="text-sm text-text-muted mt-1">
389
- From {message.from.displayName || message.from.address} &middot;{" "}
390
- {new Date(message.receivedDate).toLocaleString()}
391
- </p>
392
- <p className="text-sm text-text-muted">
393
- To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
394
- </p>
395
- <div className="flex gap-2 mt-3">
396
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleReply}>
397
- Reply
398
- </Button>
399
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleReplyAll}>
400
- Reply All
401
- </Button>
402
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
403
- Forward
404
- </Button>
405
- {!isOutbox && message.folderUid !== draftsFolderUid && (
406
- <Button
407
- type="button"
408
- variant="secondary"
409
- className="!w-auto"
410
- loading={archiving}
411
- disabled={archiving}
412
- onClick={handleArchive}
413
- >
414
- Archive
415
- </Button>
416
- )}
417
- {labels && labels.length > 0 && (
418
- <Button type="button" variant="secondary" className="!w-auto" onClick={() => setLabelsOpen(true)}>
419
- Labels
420
- </Button>
421
- )}
422
- </div>
423
- {archiveError && (
424
- <div className="mt-2">
425
- <Alert>{archiveError}</Alert>
426
- </div>
427
- )}
428
- {labels && (message.labelUids?.length ?? 0) > 0 && (
429
- <div className="flex flex-wrap items-center gap-1.5 mt-2">
430
- {message
431
- .labelUids!.map((uid) => labels.find((l) => l.uid === uid))
432
- .filter((l): l is Label => !!l)
433
- .map((l) => (
434
- <span
435
- key={l.uid}
436
- className="inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt"
437
- >
438
- <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
439
- {l.name}
440
- </span>
441
- ))}
442
- </div>
443
- )}
444
- {isInbox && (
445
- <div className="flex flex-wrap items-center gap-2 mt-2">
446
- <Button
447
- type="button"
448
- variant="text"
449
- loading={classifying}
450
- disabled={classifying}
451
- onClick={() =>
452
- handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
453
- }
454
- >
455
- {message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
456
- </Button>
457
- <label className="flex items-center gap-1.5 text-xs text-text-muted">
458
- <input
459
- type="checkbox"
460
- checked={alwaysForSender}
461
- onChange={(e) => setAlwaysForSender(e.target.checked)}
462
- />
463
- Always for this sender
464
- </label>
465
- </div>
466
- )}
467
- {classifyError && (
468
- <div className="mt-2">
469
- <Alert>{classifyError}</Alert>
470
- </div>
471
- )}
472
- {(["delivery", "read"] as const)
473
- .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
474
- .map((type) => (
475
- <div
476
- key={type}
477
- className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
478
- >
479
- <span>
480
- {message.from.displayName || message.from.address} requested a {type} receipt for this
481
- message.
482
- </span>
483
- <Button
484
- type="button"
485
- variant="secondary"
486
- className="!w-auto"
487
- loading={receiptBusy === type}
488
- disabled={receiptBusy !== null}
489
- onClick={() => handleReceipt(type, "approve")}
490
- >
491
- Send receipt
492
- </Button>
493
- <Button
494
- type="button"
495
- variant="text"
496
- disabled={receiptBusy !== null}
497
- onClick={() => handleReceipt(type, "decline")}
498
- >
499
- Decline
500
- </Button>
501
- </div>
502
- ))}
503
- {receiptError && (
504
- <div className="mt-2">
505
- <Alert>{receiptError}</Alert>
506
- </div>
507
- )}
508
- {attachments.length > 0 && (
509
- <ul className="flex flex-wrap gap-2 mt-3">
510
- {attachments.map((attachment) => (
511
- <li key={attachment.uid}>
512
- <a
513
- href={attachmentContentUrl(attachment.uid)}
514
- className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
515
- >
516
- {attachment.filename} ({formatBytes(attachment.sizeBytes)})
517
- </a>
518
- </li>
519
- ))}
520
- </ul>
521
- )}
522
- </div>
523
- {security?.decryptError && (
524
- <div className="px-4 pt-2">
525
- <Alert>{security.decryptError}</Alert>
526
- </div>
527
- )}
528
- {security?.html !== undefined ? (
529
- // A decrypted/verified body never came through the server's own sanitize-html pass (it
530
- // couldn't - the server never saw the plaintext) - DOMPurify sanitizes it here, client-side,
531
- // before it ever touches the DOM, on top of (not instead of) the iframe's own `sandbox=""`.
532
- <iframe
533
- key={message.uid}
534
- title={message.subject || "Message content"}
535
- srcDoc={DOMPurify.sanitize(security.html)}
536
- sandbox=""
537
- className="flex-1 w-full border-0"
538
- />
539
- ) : (
540
- <iframe
541
- key={message.uid}
542
- title={message.subject || "Message content"}
543
- src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
544
- sandbox=""
545
- className="flex-1 w-full border-0"
546
- />
547
- )}
548
-
549
- <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
550
- <p className="text-sm text-text-muted mb-4">
551
- This asks every original recipient's mail system to delete their copy, but only if it's
552
- still unread there — there's no way to guarantee it, and no confirmation once it either
553
- succeeds or fails. Recipients who already read the message will keep it.
554
- </p>
555
- {error && <Alert>{error}</Alert>}
556
- <div className="flex gap-3">
557
- <Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
558
- Recall message
559
- </Button>
560
- <Button
561
- type="button"
562
- variant="secondary"
563
- disabled={recalling}
564
- onClick={() => setConfirming(false)}
565
- className="!w-auto"
566
- >
567
- Cancel
568
- </Button>
569
- </div>
570
- </Modal>
571
- <Modal open={labelsOpen} onClose={() => setLabelsOpen(false)} title="Labels">
572
- {labelsError && <Alert>{labelsError}</Alert>}
573
- <ul className="flex flex-col gap-1">
574
- {(labels ?? []).map((l) => {
575
- const checked = message.labelUids?.includes(l.uid) ?? false;
576
- return (
577
- <li key={l.uid}>
578
- <label className="flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt">
579
- <input
580
- type="checkbox"
581
- checked={checked}
582
- disabled={togglingLabelUid === l.uid}
583
- onChange={() => handleToggleLabel(l.uid)}
584
- />
585
- <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
586
- {l.name}
587
- </label>
588
- </li>
589
- );
590
- })}
591
- </ul>
592
- </Modal>
593
- </div>
594
- );
595
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { HiOutlineLockClosed } from "react-icons/hi2";
7
+ import DOMPurify from "dompurify";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import {
10
+ Attachment,
11
+ Message,
12
+ MessageClassification,
13
+ ReceiptType,
14
+ approveReceipt,
15
+ archiveMessage,
16
+ attachmentContentUrl,
17
+ cancelScheduledSend,
18
+ classifyMessage,
19
+ declineReceipt,
20
+ getMessageRawContent,
21
+ recallMessage,
22
+ setMessageLabels,
23
+ } from "@rapidmx/react-shared/mail/mailApi.js";
24
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
25
+ import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
26
+ import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
27
+ import { MessageSecurityResult, evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
28
+ import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
29
+ import { useCompose } from "./compose/ComposeContext.js";
30
+ import { useMailShell } from "./layout/MailShell.js";
31
+ import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
32
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
33
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
34
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
35
+
36
+ /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
37
+ * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
38
+ * read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
39
+ * all), and "Unprotected" MUST NOT read as an error - the class pairs below are chosen so those two
40
+ * never share styling with each other or with the verified states. */
41
+ const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string; className: string }> = {
42
+ unprotected: { label: "Unprotected", className: "bg-surface-alt text-text-muted" },
43
+ encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
44
+ signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
45
+ encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
46
+ signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
47
+ };
48
+
49
+ function SecurityIndicator({ state }: { state: MessageSecurityResult["state"] }) {
50
+ const { label, className } = SECURITY_INDICATOR[state];
51
+ return <span className={`text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>{label}</span>;
52
+ }
53
+
54
+ export interface MessageDetailPaneProps {
55
+ message: Message | null;
56
+ attachments: Attachment[];
57
+ /** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
58
+ * on the desktop reading pane, which never navigates away (selecting a different message just swaps
59
+ * `message` in place). */
60
+ backHref?: string;
61
+ /** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
62
+ * Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
63
+ * computes this from its own already-loaded folder list rather than this component fetching folders
64
+ * itself. */
65
+ isSentItems?: boolean;
66
+ /** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
67
+ * the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
68
+ * `useMarkMessageRead`'s identical `onUpdated` callback. */
69
+ onRecalled?: (updated: Message) => void;
70
+ /** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
71
+ * from. Each caller computes this the same way it computes `isSentItems`. */
72
+ isOutbox?: boolean;
73
+ /** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
74
+ * scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
75
+ * on why clearing `scheduledSendTime` alone doesn't do that). */
76
+ draftsFolderUid?: string;
77
+ /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
78
+ * successfully canceling a scheduled send. */
79
+ onScheduledSendCanceled?: (updated: Message) => void;
80
+ /** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
81
+ * concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
82
+ * the "Move to Other"/"Move to Focused" control below only renders here, the same
83
+ * each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
84
+ isInbox?: boolean;
85
+ /** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
86
+ * successful classify — mirrors `onRecalled`'s identical shape. */
87
+ onClassified?: (updated: Message) => void;
88
+ /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
89
+ * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
90
+ * `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
91
+ * only ever `true` on a real delivered copy, so the banner below is self-gating. */
92
+ onReceiptHandled?: (updated: Message) => void;
93
+ /** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
94
+ * successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
95
+ * message except one currently in Drafts or Outbox (mirrors `BaseMessageRoute.archive()`'s own
96
+ * server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
97
+ * for the Outbox-cancel flow) rather than a new prop. */
98
+ onArchived?: (updated: Message) => void;
99
+ /** Every label defined in this message's mailbox, for the label-assignment popover below — each
100
+ * caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
101
+ * (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
102
+ labels?: Label[];
103
+ /** Called with the server's updated copy (carrying the new `labelUids`) after successfully toggling
104
+ * a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
105
+ * changing labels never moves a message between folders. */
106
+ onLabelsChanged?: (updated: Message) => void;
107
+ }
108
+
109
+ function formatBytes(bytes: number): string {
110
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
111
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
112
+ return `${bytes} B`;
113
+ }
114
+
115
+ /**
116
+ * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
117
+ * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
118
+ * the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
119
+ * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
120
+ * site for how `message`/`attachments`/`isSentItems` are sourced.
121
+ */
122
+ export default function MessageDetailPane({
123
+ message,
124
+ attachments,
125
+ backHref,
126
+ isSentItems,
127
+ onRecalled,
128
+ isOutbox,
129
+ draftsFolderUid,
130
+ onScheduledSendCanceled,
131
+ isInbox,
132
+ onClassified,
133
+ onReceiptHandled,
134
+ onArchived,
135
+ labels,
136
+ onLabelsChanged,
137
+ }: MessageDetailPaneProps) {
138
+ const { openCompose } = useCompose();
139
+ const [confirming, setConfirming] = useState(false);
140
+ const [recalling, setRecalling] = useState(false);
141
+ const [canceling, setCanceling] = useState(false);
142
+ const [archiving, setArchiving] = useState(false);
143
+ const [archiveError, setArchiveError] = useState<string | null>(null);
144
+ const [labelsOpen, setLabelsOpen] = useState(false);
145
+ const [togglingLabelUid, setTogglingLabelUid] = useState<string | null>(null);
146
+ const [labelsError, setLabelsError] = useState<string | null>(null);
147
+ const [error, setError] = useState<string | null>(null);
148
+ // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
149
+ // pane rather than inside a confirmation modal — the two flows never need to share one message.
150
+ const [cancelError, setCancelError] = useState<string | null>(null);
151
+ const [classifying, setClassifying] = useState(false);
152
+ const [classifyError, setClassifyError] = useState<string | null>(null);
153
+ const [alwaysForSender, setAlwaysForSender] = useState(false);
154
+ // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
155
+ // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
156
+ // wouldn't distinguish which row's buttons should show a loading state.
157
+ const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
158
+ const [receiptError, setReceiptError] = useState<string | null>(null);
159
+ const [security, setSecurity] = useState<MessageSecurityResult | null>(null);
160
+ const { mailboxes } = useMailShell();
161
+ const { requestUnlock } = useUnlockPrompt();
162
+ // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
163
+ // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
164
+ // state; see keySession.ts's own doc comment).
165
+ const [unlockRefresh, setUnlockRefresh] = useState(0);
166
+
167
+ // Evaluates every message's security state, not just ones flagged `encrypted` - a detached
168
+ // `multipart/signed` message needs its raw MIME read too (a sanitized HTML body never carries the
169
+ // signature part) to tell "Signed & verified" apart from plain "Unprotected". A fetch/parse failure
170
+ // degrades to "Unprotected" rather than surfacing an error - the spec treats that as this module's
171
+ // safe default, not a distinct failure state of its own.
172
+ useEffect(() => {
173
+ if (!message) {
174
+ setSecurity(null);
175
+ return;
176
+ }
177
+ let cancelled = false;
178
+ setSecurity(null);
179
+ getMessageRawContent(message.uid)
180
+ .then((rawMime) => evaluateMessageSecurity(rawMime, getUnlockedKeys(message.mailboxUid)))
181
+ .then((result) => {
182
+ if (!cancelled) {
183
+ setSecurity(result);
184
+ }
185
+ })
186
+ .catch(() => {
187
+ if (!cancelled) {
188
+ setSecurity({ state: "unprotected" });
189
+ }
190
+ });
191
+ return () => {
192
+ cancelled = true;
193
+ };
194
+ }, [message?.uid, unlockRefresh]);
195
+
196
+ // Offered only when this device genuinely has no unlocked session for this message's mailbox at all
197
+ // (as opposed to being unlocked but still unable to decrypt - a wrong/since-rotated key, which
198
+ // re-unlocking the same session can't fix) - see `evaluateMessageSecurity()`'s own doc comment on why
199
+ // `decryptError` alone can't distinguish those two cases.
200
+ async function handleUnlockToView() {
201
+ // Only reachable via the button below, which never renders while `message` is null.
202
+ const mailboxUid = message!.mailboxUid;
203
+ const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
204
+ try {
205
+ await requestUnlock(mailboxUid, mailboxKeys);
206
+ setUnlockRefresh((n) => n + 1);
207
+ } catch {
208
+ // User dismissed the unlock dialog - security state stays exactly as it was.
209
+ }
210
+ }
211
+
212
+ if (!message) {
213
+ return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
214
+ }
215
+
216
+ // Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
217
+ // once `message` is loaded (the early return above covers the only other state) — the non-null
218
+ // assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
219
+ function handleReply() {
220
+ openCompose({
221
+ mailboxUid: message!.mailboxUid,
222
+ to: message!.from.address,
223
+ subject: replySubject(message!.subject),
224
+ quotedHtml: buildReplyQuote(message!),
225
+ signatureContext: "reply_forward",
226
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
227
+ });
228
+ }
229
+
230
+ function handleReplyAll() {
231
+ const cc = message!.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
232
+ openCompose({
233
+ mailboxUid: message!.mailboxUid,
234
+ to: message!.from.address,
235
+ cc: cc.join(", "),
236
+ subject: replySubject(message!.subject),
237
+ quotedHtml: buildReplyQuote(message!),
238
+ signatureContext: "reply_forward",
239
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
240
+ });
241
+ }
242
+
243
+ function handleForward() {
244
+ openCompose({
245
+ mailboxUid: message!.mailboxUid,
246
+ subject: forwardSubject(message!.subject),
247
+ quotedHtml: buildForwardQuote(message!),
248
+ signatureContext: "reply_forward",
249
+ });
250
+ }
251
+
252
+ async function handleRecall() {
253
+ // Only ever invoked from the confirmation modal below, which itself only renders once `message`
254
+ // is loaded (the early return above covers the only other state) — the non-null assertion
255
+ // reflects that real invariant, matching this codebase's established pattern for the same class
256
+ // of always-true-in-practice guard.
257
+ setRecalling(true);
258
+ setError(null);
259
+ try {
260
+ const updated = await recallMessage(message!.uid);
261
+ setConfirming(false);
262
+ onRecalled?.(updated);
263
+ } catch (err) {
264
+ setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
265
+ } finally {
266
+ setRecalling(false);
267
+ }
268
+ }
269
+
270
+ // Only ever invoked from the "Cancel" button below, which itself only renders once `message` is
271
+ // loaded and `isOutbox`/`message.scheduledSendTime` are both truthy — `draftsFolderUid` is required
272
+ // by that same rendering guard (see the prop's own doc comment), so the non-null assertion reflects
273
+ // a real invariant, matching `handleRecall`'s identical pattern just above.
274
+ async function handleCancelScheduledSend() {
275
+ setCanceling(true);
276
+ setCancelError(null);
277
+ try {
278
+ const updated = await cancelScheduledSend(message!, draftsFolderUid!);
279
+ onScheduledSendCanceled?.(updated);
280
+ } catch (err) {
281
+ setCancelError(err instanceof ApiRequestError ? err.message : "Could not cancel this scheduled send.");
282
+ } finally {
283
+ setCanceling(false);
284
+ }
285
+ }
286
+
287
+ // Only ever invoked from the Archive button below, which itself only renders once `message` is
288
+ // loaded and this isn't a Drafts/Outbox message (the button's own guard mirrors
289
+ // `BaseMessageRoute.archive()`'s server-side 400) — same real invariant as `handleRecall` above.
290
+ async function handleArchive() {
291
+ setArchiving(true);
292
+ setArchiveError(null);
293
+ try {
294
+ const updated = await archiveMessage(message!.uid);
295
+ onArchived?.(updated);
296
+ } catch (err) {
297
+ setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
298
+ } finally {
299
+ setArchiving(false);
300
+ }
301
+ }
302
+
303
+ // Only ever invoked from a checkbox in the Labels popover below, which itself only renders once
304
+ // `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
305
+ // new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
306
+ // list rather than patching a single entry.
307
+ async function handleToggleLabel(labelUid: string) {
308
+ setTogglingLabelUid(labelUid);
309
+ setLabelsError(null);
310
+ try {
311
+ const current = message!.labelUids ?? [];
312
+ const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
313
+ const updated = await setMessageLabels(message!, next);
314
+ onLabelsChanged?.(updated);
315
+ } catch (err) {
316
+ setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
317
+ } finally {
318
+ setTogglingLabelUid(null);
319
+ }
320
+ }
321
+
322
+ // Only ever invoked from the classification button below, which itself only renders once `message`
323
+ // is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
324
+ // `handleRecall`/`handleCancelScheduledSend` above.
325
+ async function handleClassify(classifyAs: MessageClassification) {
326
+ setClassifying(true);
327
+ setClassifyError(null);
328
+ try {
329
+ const updated = await classifyMessage(message!.uid, classifyAs, alwaysForSender);
330
+ onClassified?.(updated);
331
+ } catch (err) {
332
+ setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
333
+ } finally {
334
+ setClassifying(false);
335
+ }
336
+ }
337
+
338
+ // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
339
+ // is loaded — same real invariant as every other handler above.
340
+ async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
341
+ setReceiptBusy(type);
342
+ setReceiptError(null);
343
+ try {
344
+ const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message!.uid, type);
345
+ onReceiptHandled?.(updated);
346
+ } catch (err) {
347
+ setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
348
+ } finally {
349
+ setReceiptBusy(null);
350
+ }
351
+ }
352
+
353
+ return (
354
+ <div className="flex-1 min-w-0 flex flex-col">
355
+ <div className="border-b border-border p-4">
356
+ {backHref && (
357
+ <a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
358
+ &larr; Back to messages
359
+ </a>
360
+ )}
361
+ <div className="flex items-start justify-between gap-3">
362
+ <div className="flex items-center gap-2 min-w-0">
363
+ <h1 className="text-lg font-bold tracking-tight truncate">{message.subject || "(no subject)"}</h1>
364
+ {security && <SecurityIndicator state={security.state} />}
365
+ </div>
366
+ {isSentItems &&
367
+ (message.recallRequestedAt ? (
368
+ <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
369
+ Recall requested
370
+ </span>
371
+ ) : (
372
+ <Button
373
+ type="button"
374
+ variant="secondary"
375
+ className="!w-auto shrink-0"
376
+ onClick={() => setConfirming(true)}
377
+ >
378
+ Recall this message
379
+ </Button>
380
+ ))}
381
+ {isOutbox && message.scheduledSendTime && (
382
+ <div className="flex items-center gap-2 shrink-0">
383
+ <span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
384
+ Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
385
+ </span>
386
+ <Button
387
+ type="button"
388
+ variant="secondary"
389
+ className="!w-auto"
390
+ loading={canceling}
391
+ disabled={canceling}
392
+ onClick={handleCancelScheduledSend}
393
+ >
394
+ Cancel
395
+ </Button>
396
+ </div>
397
+ )}
398
+ </div>
399
+ {cancelError && (
400
+ <div className="mt-2">
401
+ <Alert>{cancelError}</Alert>
402
+ </div>
403
+ )}
404
+ {security?.headerTamperDetected && (
405
+ <div className="mt-2">
406
+ <Alert>
407
+ This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or
408
+ encrypted — an intermediary may have altered them after sending. Treat the fields shown above with
409
+ caution.
410
+ </Alert>
411
+ </div>
412
+ )}
413
+ <p className="text-sm text-text-muted mt-1">
414
+ From {message.from.displayName || message.from.address} &middot;{" "}
415
+ {new Date(message.receivedDate).toLocaleString()}
416
+ </p>
417
+ <p className="text-sm text-text-muted">
418
+ To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
419
+ </p>
420
+ <div className="flex gap-2 mt-3">
421
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleReply}>
422
+ Reply
423
+ </Button>
424
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleReplyAll}>
425
+ Reply All
426
+ </Button>
427
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
428
+ Forward
429
+ </Button>
430
+ {!isOutbox && message.folderUid !== draftsFolderUid && (
431
+ <Button
432
+ type="button"
433
+ variant="secondary"
434
+ className="!w-auto"
435
+ loading={archiving}
436
+ disabled={archiving}
437
+ onClick={handleArchive}
438
+ >
439
+ Archive
440
+ </Button>
441
+ )}
442
+ {labels && labels.length > 0 && (
443
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setLabelsOpen(true)}>
444
+ Labels
445
+ </Button>
446
+ )}
447
+ </div>
448
+ {archiveError && (
449
+ <div className="mt-2">
450
+ <Alert>{archiveError}</Alert>
451
+ </div>
452
+ )}
453
+ {labels && (message.labelUids?.length ?? 0) > 0 && (
454
+ <div className="flex flex-wrap items-center gap-1.5 mt-2">
455
+ {message
456
+ .labelUids!.map((uid) => labels.find((l) => l.uid === uid))
457
+ .filter((l): l is Label => !!l)
458
+ .map((l) => (
459
+ <span
460
+ key={l.uid}
461
+ className="inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt"
462
+ >
463
+ <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
464
+ {l.name}
465
+ </span>
466
+ ))}
467
+ </div>
468
+ )}
469
+ {isInbox && (
470
+ <div className="flex flex-wrap items-center gap-2 mt-2">
471
+ <Button
472
+ type="button"
473
+ variant="text"
474
+ loading={classifying}
475
+ disabled={classifying}
476
+ onClick={() =>
477
+ handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
478
+ }
479
+ >
480
+ {message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
481
+ </Button>
482
+ <label className="flex items-center gap-1.5 text-xs text-text-muted">
483
+ <input
484
+ type="checkbox"
485
+ checked={alwaysForSender}
486
+ onChange={(e) => setAlwaysForSender(e.target.checked)}
487
+ />
488
+ Always for this sender
489
+ </label>
490
+ </div>
491
+ )}
492
+ {classifyError && (
493
+ <div className="mt-2">
494
+ <Alert>{classifyError}</Alert>
495
+ </div>
496
+ )}
497
+ {(["delivery", "read"] as const)
498
+ .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
499
+ .map((type) => (
500
+ <div
501
+ key={type}
502
+ className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
503
+ >
504
+ <span>
505
+ {message.from.displayName || message.from.address} requested a {type} receipt for this
506
+ message.
507
+ </span>
508
+ <Button
509
+ type="button"
510
+ variant="secondary"
511
+ className="!w-auto"
512
+ loading={receiptBusy === type}
513
+ disabled={receiptBusy !== null}
514
+ onClick={() => handleReceipt(type, "approve")}
515
+ >
516
+ Send receipt
517
+ </Button>
518
+ <Button
519
+ type="button"
520
+ variant="text"
521
+ disabled={receiptBusy !== null}
522
+ onClick={() => handleReceipt(type, "decline")}
523
+ >
524
+ Decline
525
+ </Button>
526
+ </div>
527
+ ))}
528
+ {receiptError && (
529
+ <div className="mt-2">
530
+ <Alert>{receiptError}</Alert>
531
+ </div>
532
+ )}
533
+ {attachments.length > 0 && (
534
+ <ul className="flex flex-wrap gap-2 mt-3">
535
+ {attachments.map((attachment) => (
536
+ <li key={attachment.uid}>
537
+ <a
538
+ href={attachmentContentUrl(attachment.uid)}
539
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
540
+ >
541
+ {attachment.filename} ({formatBytes(attachment.sizeBytes)})
542
+ </a>
543
+ </li>
544
+ ))}
545
+ </ul>
546
+ )}
547
+ </div>
548
+ {security?.decryptError && (
549
+ <div className="px-4 pt-2">
550
+ <Alert>{security.decryptError}</Alert>
551
+ {message && !getUnlockedKeys(message.mailboxUid) && (
552
+ <button
553
+ type="button"
554
+ onClick={handleUnlockToView}
555
+ className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
556
+ >
557
+ <HiOutlineLockClosed size={12} aria-hidden="true" />
558
+ Unlock to view this message
559
+ </button>
560
+ )}
561
+ </div>
562
+ )}
563
+ {security?.html !== undefined ? (
564
+ // A decrypted/verified body never came through the server's own sanitize-html pass (it
565
+ // couldn't - the server never saw the plaintext) - DOMPurify sanitizes it here, client-side,
566
+ // before it ever touches the DOM, on top of (not instead of) the iframe's own `sandbox=""`.
567
+ <iframe
568
+ key={message.uid}
569
+ title={message.subject || "Message content"}
570
+ srcDoc={DOMPurify.sanitize(security.html)}
571
+ sandbox=""
572
+ className="flex-1 w-full border-0"
573
+ />
574
+ ) : (
575
+ <iframe
576
+ key={message.uid}
577
+ title={message.subject || "Message content"}
578
+ src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
579
+ sandbox=""
580
+ className="flex-1 w-full border-0"
581
+ />
582
+ )}
583
+
584
+ <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
585
+ <p className="text-sm text-text-muted mb-4">
586
+ This asks every original recipient's mail system to delete their copy, but only if it's
587
+ still unread there — there's no way to guarantee it, and no confirmation once it either
588
+ succeeds or fails. Recipients who already read the message will keep it.
589
+ </p>
590
+ {error && <Alert>{error}</Alert>}
591
+ <div className="flex gap-3">
592
+ <Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
593
+ Recall message
594
+ </Button>
595
+ <Button
596
+ type="button"
597
+ variant="secondary"
598
+ disabled={recalling}
599
+ onClick={() => setConfirming(false)}
600
+ className="!w-auto"
601
+ >
602
+ Cancel
603
+ </Button>
604
+ </div>
605
+ </Modal>
606
+ <Modal open={labelsOpen} onClose={() => setLabelsOpen(false)} title="Labels">
607
+ {labelsError && <Alert>{labelsError}</Alert>}
608
+ <ul className="flex flex-col gap-1">
609
+ {(labels ?? []).map((l) => {
610
+ const checked = message.labelUids?.includes(l.uid) ?? false;
611
+ return (
612
+ <li key={l.uid}>
613
+ <label className="flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt">
614
+ <input
615
+ type="checkbox"
616
+ checked={checked}
617
+ disabled={togglingLabelUid === l.uid}
618
+ onChange={() => handleToggleLabel(l.uid)}
619
+ />
620
+ <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
621
+ {l.name}
622
+ </label>
623
+ </li>
624
+ );
625
+ })}
626
+ </ul>
627
+ </Modal>
628
+ </div>
629
+ );
630
+ }