@rapidmx/web-client 0.2.0 → 0.3.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 (59) hide show
  1. package/apps/admin/data-requests/index.tsx +485 -0
  2. package/apps/admin/escrow-scopes/[uid].tsx +237 -0
  3. package/apps/admin/escrow-scopes/index.tsx +124 -0
  4. package/apps/admin/escrow-scopes/new/index.tsx +128 -0
  5. package/apps/admin/mailboxes/[uid].tsx +66 -8
  6. package/apps/admin/retention-policy/index.tsx +132 -0
  7. package/apps/escrow/_layout.tsx +41 -0
  8. package/apps/escrow/audit-log/index.tsx +168 -0
  9. package/apps/escrow/index.tsx +139 -0
  10. package/apps/escrow/matters/[uid].tsx +521 -0
  11. package/apps/escrow/matters/new/index.tsx +173 -0
  12. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
  13. package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
  15. package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
  16. package/apps/shared/components/forms/StringListField.tsx +104 -0
  17. package/apps/shared/components/layout/AppShell.tsx +4 -0
  18. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
  20. package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
  21. package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
  22. package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
  23. package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
  24. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
  25. package/apps/www/index.tsx +122 -11
  26. package/apps/www/messages/[uid].tsx +15 -1
  27. package/apps/www/settings/encryption/index.tsx +664 -0
  28. package/apps/www/settings/labels/index.tsx +201 -0
  29. package/apps/www/settings/privacy/index.tsx +436 -0
  30. package/dist/apps/admin/data-requests/index.js +197 -0
  31. package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
  32. package/dist/apps/admin/escrow-scopes/index.js +31 -0
  33. package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
  34. package/dist/apps/admin/mailboxes/[uid].js +26 -2
  35. package/dist/apps/admin/retention-policy/index.js +65 -0
  36. package/dist/apps/escrow/_layout.js +10 -0
  37. package/dist/apps/escrow/audit-log/index.js +66 -0
  38. package/dist/apps/escrow/index.js +31 -0
  39. package/dist/apps/escrow/matters/[uid].js +234 -0
  40. package/dist/apps/escrow/matters/new/index.js +75 -0
  41. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
  42. package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
  43. package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
  44. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
  45. package/dist/apps/shared/components/forms/StringListField.js +39 -0
  46. package/dist/apps/shared/components/layout/AppShell.js +4 -0
  47. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
  48. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  49. package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
  50. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
  51. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
  52. package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
  54. package/dist/apps/www/index.js +110 -10
  55. package/dist/apps/www/messages/[uid].js +12 -2
  56. package/dist/apps/www/settings/encryption/index.js +330 -0
  57. package/dist/apps/www/settings/labels/index.js +95 -0
  58. package/dist/apps/www/settings/privacy/index.js +167 -0
  59. package/package.json +9 -3
@@ -2,7 +2,8 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useState } from "react";
5
+ import React, { useEffect, useState } from "react";
6
+ import DOMPurify from "dompurify";
6
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
8
  import {
8
9
  Attachment,
@@ -10,18 +11,43 @@ import {
10
11
  MessageClassification,
11
12
  ReceiptType,
12
13
  approveReceipt,
14
+ archiveMessage,
13
15
  attachmentContentUrl,
14
16
  cancelScheduledSend,
15
17
  classifyMessage,
16
18
  declineReceipt,
19
+ getMessageRawContent,
17
20
  recallMessage,
21
+ setMessageLabels,
18
22
  } from "@rapidmx/react-shared/mail/mailApi.js";
23
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
19
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";
20
28
  import { useCompose } from "./compose/ComposeContext.js";
21
29
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
22
30
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
23
31
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
24
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
+
25
51
  export interface MessageDetailPaneProps {
26
52
  message: Message | null;
27
53
  attachments: Attachment[];
@@ -61,6 +87,20 @@ export interface MessageDetailPaneProps {
61
87
  * `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
62
88
  * only ever `true` on a real delivered copy, so the banner below is self-gating. */
63
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;
64
104
  }
65
105
 
66
106
  function formatBytes(bytes: number): string {
@@ -88,11 +128,19 @@ export default function MessageDetailPane({
88
128
  isInbox,
89
129
  onClassified,
90
130
  onReceiptHandled,
131
+ onArchived,
132
+ labels,
133
+ onLabelsChanged,
91
134
  }: MessageDetailPaneProps) {
92
135
  const { openCompose } = useCompose();
93
136
  const [confirming, setConfirming] = useState(false);
94
137
  const [recalling, setRecalling] = useState(false);
95
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);
96
144
  const [error, setError] = useState<string | null>(null);
97
145
  // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
98
146
  // pane rather than inside a confirmation modal — the two flows never need to share one message.
@@ -105,6 +153,36 @@ export default function MessageDetailPane({
105
153
  // wouldn't distinguish which row's buttons should show a loading state.
106
154
  const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
107
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]);
108
186
 
109
187
  if (!message) {
110
188
  return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
@@ -120,6 +198,7 @@ export default function MessageDetailPane({
120
198
  subject: replySubject(message!.subject),
121
199
  quotedHtml: buildReplyQuote(message!),
122
200
  signatureContext: "reply_forward",
201
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
123
202
  });
124
203
  }
125
204
 
@@ -132,6 +211,7 @@ export default function MessageDetailPane({
132
211
  subject: replySubject(message!.subject),
133
212
  quotedHtml: buildReplyQuote(message!),
134
213
  signatureContext: "reply_forward",
214
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
135
215
  });
136
216
  }
137
217
 
@@ -179,6 +259,41 @@ export default function MessageDetailPane({
179
259
  }
180
260
  }
181
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
+
182
297
  // Only ever invoked from the classification button below, which itself only renders once `message`
183
298
  // is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
184
299
  // `handleRecall`/`handleCancelScheduledSend` above.
@@ -219,7 +334,10 @@ export default function MessageDetailPane({
219
334
  </a>
220
335
  )}
221
336
  <div className="flex items-start justify-between gap-3">
222
- <h1 className="text-lg font-bold tracking-tight">{message.subject || "(no subject)"}</h1>
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>
223
341
  {isSentItems &&
224
342
  (message.recallRequestedAt ? (
225
343
  <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
@@ -258,6 +376,15 @@ export default function MessageDetailPane({
258
376
  <Alert>{cancelError}</Alert>
259
377
  </div>
260
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
+ )}
261
388
  <p className="text-sm text-text-muted mt-1">
262
389
  From {message.from.displayName || message.from.address} &middot;{" "}
263
390
  {new Date(message.receivedDate).toLocaleString()}
@@ -275,7 +402,45 @@ export default function MessageDetailPane({
275
402
  <Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
276
403
  Forward
277
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
+ )}
278
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
+ )}
279
444
  {isInbox && (
280
445
  <div className="flex flex-wrap items-center gap-2 mt-2">
281
446
  <Button
@@ -355,13 +520,31 @@ export default function MessageDetailPane({
355
520
  </ul>
356
521
  )}
357
522
  </div>
358
- <iframe
359
- key={message.uid}
360
- title={message.subject || "Message content"}
361
- src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
362
- sandbox=""
363
- className="flex-1 w-full border-0"
364
- />
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
+ )}
365
548
 
366
549
  <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
367
550
  <p className="text-sm text-text-muted mb-4">
@@ -385,6 +568,28 @@ export default function MessageDetailPane({
385
568
  </Button>
386
569
  </div>
387
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>
388
593
  </div>
389
594
  );
390
595
  }
@@ -18,6 +18,8 @@ export interface ComposeSession {
18
18
  /** Which of a signature's two "default" flags to resolve against — `"new"` (the default) uses
19
19
  * `isDefaultForNewMessages`, `"reply_forward"` uses `isDefaultForReplyForward`. */
20
20
  signatureContext: "new" | "reply_forward";
21
+ /** See `OpenComposeInput.suppressSigning`'s own doc comment. */
22
+ suppressSigning?: boolean;
21
23
  minimized: boolean;
22
24
  }
23
25
 
@@ -34,6 +36,12 @@ export interface OpenComposeInput {
34
36
  /** See `ComposeSession.signatureContext`'s own doc comment. Defaults to `"new"` — every existing
35
37
  * caller (Contacts' "Email" action, the folder-sidebar "Compose" button) is a fresh compose. */
36
38
  signatureContext?: "new" | "reply_forward";
39
+ /** `true` when the caller (`MessageDetailPane.tsx`'s Reply/Reply All) has already determined, via
40
+ * `composeSecurity.ts`'s `isLikelyMailingList()`, that the message being replied to came from a
41
+ * mailing list — a list that appends a footer after signing invalidates the signature (spec's own
42
+ * "Mailing lists" note), so the new compose window defaults its Sign toggle off rather than on.
43
+ * The user can still turn it back on manually; this only changes the *default*. */
44
+ suppressSigning?: boolean;
37
45
  }
38
46
 
39
47
  export interface ComposeContextValue {
@@ -59,7 +67,7 @@ export default function ComposeProvider({ children }: PropsWithChildren) {
59
67
  const [sessions, setSessions] = useState<ComposeSession[]>([]);
60
68
  const isMobile = useIsMobile();
61
69
 
62
- function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new" }: OpenComposeInput) {
70
+ function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning }: OpenComposeInput) {
63
71
  setSessions((prev) => [
64
72
  ...prev,
65
73
  {
@@ -70,6 +78,7 @@ export default function ComposeProvider({ children }: PropsWithChildren) {
70
78
  initialSubject: subject,
71
79
  initialQuotedHtml: quotedHtml,
72
80
  signatureContext,
81
+ suppressSigning,
73
82
  minimized: false,
74
83
  },
75
84
  ]);