@rapidmx/web-client 0.2.0 → 0.3.1

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
@@ -16,10 +16,13 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
16
16
  import {
17
17
  Attachment,
18
18
  ComposeRecipientInput,
19
+ Mailbox,
19
20
  Message,
20
21
  assembleDraft,
22
+ assembleDraftRaw,
21
23
  attachmentContentUrl,
22
24
  createDraft,
25
+ getMailbox,
23
26
  listFolders,
24
27
  sendMessage,
25
28
  setMessageRequestReceipt,
@@ -27,11 +30,17 @@ import {
27
30
  uploadAttachment,
28
31
  } from "@rapidmx/react-shared/mail/mailApi.js";
29
32
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
33
+ import { decideMessageEncryption, resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
34
+ import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
35
+ import { EncryptionPolicy, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
36
+ import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
37
+ import { ProtectedHeaders, applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
30
38
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
31
39
  import type { ComposeSession } from "./ComposeContext.js";
32
40
  import RichTextEditor from "./RichTextEditor.js";
33
41
  import ScheduleSendPicker from "./ScheduleSendPicker.js";
34
42
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
43
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
35
44
 
36
45
  export interface ComposeWindowProps {
37
46
  session: ComposeSession;
@@ -96,7 +105,7 @@ function HeaderButton({ label, onClick, icon: Icon }: { label: string; onClick:
96
105
  * once.
97
106
  */
98
107
  export default function ComposeWindow({ session, onClose, onToggleMinimize }: ComposeWindowProps) {
99
- const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, minimized } = session;
108
+ const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, suppressSigning, minimized } = session;
100
109
  const isMobile = useIsMobile();
101
110
 
102
111
  const windowRef = useRef<HTMLDivElement>(null);
@@ -120,6 +129,63 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
120
129
  const [requestReceipt, setRequestReceipt] = useState(false);
121
130
  const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
122
131
  const scheduleButtonRef = useRef<HTMLButtonElement>(null);
132
+ const [mailbox, setMailbox] = useState<Mailbox | null>(null);
133
+ const [encryptionPolicy, setEncryptionPolicy] = useState<EncryptionPolicy | null>(null);
134
+ // Defaults off when replying to a detected mailing list (spec's own "Mailing lists" note under
135
+ // Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
136
+ // `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
137
+ // still turn it back on; this only changes the default.
138
+ const [signEnabled, setSignEnabled] = useState(!suppressSigning);
139
+ const [encryptRequested, setEncryptRequested] = useState(false);
140
+ const [encryptionBlocked, setEncryptionBlocked] = useState<RecipientEncryptionStatus[] | null>(null);
141
+ // Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
142
+ // recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
143
+ // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
144
+ // never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
145
+ const [recipientStatuses, setRecipientStatuses] = useState<Record<string, RecipientEncryptionStatus>>({});
146
+
147
+ // Best-effort, same as the signature-list fetch below: a mailbox with no keys enrolled yet (or a
148
+ // failed fetch) just means sign/encrypt stay unavailable for this compose session, never a blocking
149
+ // error - encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
150
+ // `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
151
+ // send that happens to race this fetch can't silently skip encryption the spec says should apply.
152
+ const [cryptoContextReady, setCryptoContextReady] = useState(false);
153
+ useEffect(() => {
154
+ let cancelled = false;
155
+ setCryptoContextReady(false);
156
+ let mailboxSettled = false;
157
+ let policySettled = false;
158
+ function checkReady() {
159
+ if (!cancelled && mailboxSettled && policySettled) {
160
+ setCryptoContextReady(true);
161
+ }
162
+ }
163
+ getMailbox(mailboxUid)
164
+ .then((result) => {
165
+ if (!cancelled) {
166
+ setMailbox(result);
167
+ }
168
+ })
169
+ .catch(() => undefined)
170
+ .finally(() => {
171
+ mailboxSettled = true;
172
+ checkReady();
173
+ });
174
+ getEncryptionPolicy()
175
+ .then((result) => {
176
+ if (!cancelled) {
177
+ setEncryptionPolicy(result);
178
+ }
179
+ })
180
+ .catch(() => undefined)
181
+ .finally(() => {
182
+ policySettled = true;
183
+ checkReady();
184
+ });
185
+ return () => {
186
+ cancelled = true;
187
+ };
188
+ }, [mailboxUid]);
123
189
 
124
190
  useEffect(() => {
125
191
  listFolders(mailboxUid)
@@ -127,6 +193,31 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
127
193
  .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder."));
128
194
  }, [mailboxUid]);
129
195
 
196
+ /**
197
+ * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
198
+ * caching the result so the small per-recipient indicator below the recipient fields can update
199
+ * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
200
+ * and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
201
+ * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
202
+ * A failed lookup or a mailbox/policy fetch that hasn't resolved yet just leaves that address with no
203
+ * indicator rather than surfacing an error over what's a cosmetic nicety.
204
+ */
205
+ function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
206
+ if (!mailbox || !encryptionPolicy) {
207
+ return;
208
+ }
209
+ const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
210
+ const unchecked = addresses.filter((r) => !(r.address in recipientStatuses));
211
+ for (const recipient of unchecked) {
212
+ lookupKeys(mailboxUid, recipient.address)
213
+ .catch(() => undefined)
214
+ .then((lookup) => {
215
+ const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, lookup);
216
+ setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
217
+ });
218
+ }
219
+ }
220
+
130
221
  // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
131
222
  // plus any quoted original message, before `RichTextEditor` ever mounts (gated by `contentReady`
132
223
  // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
@@ -239,9 +330,94 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
239
330
  }
240
331
  }
241
332
 
333
+ /**
334
+ * Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
335
+ * `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
336
+ * Shared by `handleSend()`/`handleScheduleSend()` (and the "Send without encryption" conflict button
337
+ * rendered below, via `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
338
+ *
339
+ * Returns `"blocked"` (never throws for this case) when encryption was requested but not every
340
+ * recipient can currently be encrypted to — the spec's "Multiple Recipients" all-or-nothing rule.
341
+ * Sets `encryptionBlocked` as a side effect so the window can render that prompt; callers must stop
342
+ * (not send) when they get this back.
343
+ *
344
+ * Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
345
+ * pre-send step — not live as recipients are typed. A compose-time recipient-entry indicator (key
346
+ * icons, live discovery as each address is entered) is Phase 4's "Discovery & contacts UI" work, not
347
+ * this pass; this still satisfies the spec's "MUST be called lazily at compose time, not on receipt"
348
+ * requirement, just without a live UI reflecting it before Send is clicked.
349
+ */
350
+ async function assembleForSend(
351
+ toRecipients: ComposeRecipientInput[],
352
+ ccRecipients: ComposeRecipientInput[],
353
+ bccRecipients: ComposeRecipientInput[],
354
+ forcePlaintext: boolean,
355
+ ): Promise<Message | "blocked"> {
356
+ const unlocked = getUnlockedKeys(mailboxUid);
357
+ // `mailbox` is required to build protected headers (own From address) whenever signing or
358
+ // encrypting - not just guarded on the encryption branch below, since a signed-only message
359
+ // needs it too. A mailbox fetch failure just means no crypto for this send, never a crash.
360
+ const canSign = signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer;
361
+ const canEncryptSelf = !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer;
362
+ const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
363
+
364
+ let wantEncrypt = false;
365
+ let recipientCertDers: Uint8Array[] = [];
366
+ if (!forcePlaintext && canEncryptSelf && mailbox && encryptionPolicy && allRecipients.length > 0) {
367
+ const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
368
+ const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid, r.address).catch(() => undefined)));
369
+ const statuses = allRecipients.map((r, i) =>
370
+ resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]),
371
+ );
372
+ const decision = decideMessageEncryption(statuses);
373
+ wantEncrypt = encryptRequested || decision.autoEncrypt;
374
+ if (wantEncrypt) {
375
+ if (!decision.canEncryptAll) {
376
+ setEncryptionBlocked(decision.blockedRecipients);
377
+ return "blocked";
378
+ }
379
+ recipientCertDers = [unlocked!.encryptionCertDer!, ...statuses.map((s) => fromBase64(s.encryptCert!.publicKey))];
380
+ }
381
+ }
382
+
383
+ if ((wantEncrypt || canSign) && attachments.length > 0) {
384
+ // Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
385
+ // clearer explanation than that route's generic 400 rather than via a round trip.
386
+ throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
387
+ }
388
+
389
+ if (!wantEncrypt && !canSign) {
390
+ // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
391
+ // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
392
+ // client-side pass is done here either.
393
+ return assembleDraft(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
394
+ }
395
+
396
+ const domain = mailbox!.primarySmtpAddress.split("@")[1] ?? "localhost";
397
+ const protectedHeaders: ProtectedHeaders = {
398
+ from: mailbox!.displayName
399
+ ? `"${mailbox!.displayName.replace(/"/g, '\\"')}" <${mailbox!.primarySmtpAddress}>`
400
+ : mailbox!.primarySmtpAddress,
401
+ to: toRecipients.map((r) => r.address).join(", "),
402
+ cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
403
+ date: new Date().toUTCString(),
404
+ subject,
405
+ messageId: `<${crypto.randomUUID()}@${domain}>`,
406
+ };
407
+ const bodyContentType = 'text/html; charset="utf-8"';
408
+ const signing = canSign ? { certDer: unlocked!.signingCertDer!, privateKey: unlocked!.signingPrivateKey! } : undefined;
409
+
410
+ const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
411
+ const mimePart = wantEncrypt
412
+ ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
413
+ : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
414
+ const rawMime = assembleOutboundMime(outerHeaders, mimePart);
415
+ return assembleDraftRaw(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
416
+ }
417
+
242
418
  // Same reasoning as `handleFilesSelected` above: the Send button is itself `disabled` until `draft`
243
419
  // resolves, so this is never reachable with a null `draft`.
244
- async function handleSend() {
420
+ async function handleSend(forcePlaintext = false) {
245
421
  const toRecipients = parseAddresses(to);
246
422
  if (toRecipients.length === 0) {
247
423
  setSendError("At least one recipient is required.");
@@ -251,16 +427,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
251
427
  setSending(true);
252
428
  setSendError(null);
253
429
  try {
254
- // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
255
- // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
256
- // client-side pass is done here either.
257
- const assembled = await assembleDraft(draft!.uid, {
258
- to: toRecipients,
259
- cc: parseAddresses(cc),
260
- bcc: parseAddresses(bcc),
261
- subject,
262
- html,
263
- });
430
+ const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
431
+ if (assembled === "blocked") {
432
+ return;
433
+ }
264
434
  const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
265
435
  await sendMessage(withReceipt.uid);
266
436
  onClose();
@@ -287,13 +457,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
287
457
  setSending(true);
288
458
  setSendError(null);
289
459
  try {
290
- const assembled = await assembleDraft(draft!.uid, {
291
- to: toRecipients,
292
- cc: parseAddresses(cc),
293
- bcc: parseAddresses(bcc),
294
- subject,
295
- html,
296
- });
460
+ const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), false);
461
+ if (assembled === "blocked") {
462
+ return;
463
+ }
297
464
  const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
298
465
  const scheduled = await setMessageScheduledSendTime(withReceipt, scheduledSendTimeIso);
299
466
  await sendMessage(scheduled.uid);
@@ -305,6 +472,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
305
472
  }
306
473
  }
307
474
 
475
+ // A plain read from keySession.ts's module-level session store, not React state - see that module's
476
+ // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
477
+ const unlockedKeys = getUnlockedKeys(mailboxUid);
478
+
308
479
  const title = subject.trim() || "New Message";
309
480
  const titleId = `compose-title-${id}`;
310
481
 
@@ -393,6 +564,28 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
393
564
  </div>
394
565
  )}
395
566
 
567
+ {encryptionBlocked && (
568
+ <div className="px-3 pt-2">
569
+ <Alert>
570
+ <p className="mb-2">
571
+ This message can&rsquo;t be encrypted for everyone: {encryptionBlocked.map((r) => r.address).join(", ")}
572
+ {encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file"}.
573
+ Remove {encryptionBlocked.length > 1 ? "these recipients" : "this recipient"} from To/Cc/Bcc, or send the
574
+ whole message in plaintext.
575
+ </p>
576
+ <Button
577
+ type="button"
578
+ onClick={() => {
579
+ setEncryptionBlocked(null);
580
+ void handleSend(true);
581
+ }}
582
+ >
583
+ Send without encryption
584
+ </Button>
585
+ </Alert>
586
+ </div>
587
+ )}
588
+
396
589
  <div className={FIELD_ROW}>
397
590
  <label htmlFor={`compose-to-${id}`} className="text-xs text-text-muted shrink-0">
398
591
  To
@@ -403,6 +596,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
403
596
  className={FIELD_INPUT}
404
597
  value={to}
405
598
  onChange={(e) => setTo(e.target.value)}
599
+ onBlur={() => checkRecipientDiscovery(parseAddresses(to))}
406
600
  />
407
601
  {!showCcBcc && (
408
602
  <button
@@ -421,17 +615,54 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
421
615
  <label htmlFor={`compose-cc-${id}`} className="text-xs text-text-muted shrink-0">
422
616
  Cc
423
617
  </label>
424
- <input id={`compose-cc-${id}`} type="text" className={FIELD_INPUT} value={cc} onChange={(e) => setCc(e.target.value)} />
618
+ <input
619
+ id={`compose-cc-${id}`}
620
+ type="text"
621
+ className={FIELD_INPUT}
622
+ value={cc}
623
+ onChange={(e) => setCc(e.target.value)}
624
+ onBlur={() => checkRecipientDiscovery(parseAddresses(cc))}
625
+ />
425
626
  </div>
426
627
  <div className={FIELD_ROW}>
427
628
  <label htmlFor={`compose-bcc-${id}`} className="text-xs text-text-muted shrink-0">
428
629
  Bcc
429
630
  </label>
430
- <input id={`compose-bcc-${id}`} type="text" className={FIELD_INPUT} value={bcc} onChange={(e) => setBcc(e.target.value)} />
631
+ <input
632
+ id={`compose-bcc-${id}`}
633
+ type="text"
634
+ className={FIELD_INPUT}
635
+ value={bcc}
636
+ onChange={(e) => setBcc(e.target.value)}
637
+ onBlur={() => checkRecipientDiscovery(parseAddresses(bcc))}
638
+ />
431
639
  </div>
432
640
  </>
433
641
  )}
434
642
 
643
+ {(() => {
644
+ const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
645
+ .map((r) => recipientStatuses[r.address])
646
+ .filter((status): status is RecipientEncryptionStatus => !!status);
647
+ if (knownRecipients.length === 0) {
648
+ return null;
649
+ }
650
+ return (
651
+ <ul className="flex flex-wrap gap-1.5 px-3 pt-1.5" aria-label="Recipient encryption availability">
652
+ {knownRecipients.map((status) => (
653
+ <li
654
+ key={status.address}
655
+ className={`text-xs font-medium py-0.5 px-2 rounded-pill ${
656
+ status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"
657
+ }`}
658
+ >
659
+ {status.address} {status.canEncrypt ? "supports encryption" : "no encryption key found"}
660
+ </li>
661
+ ))}
662
+ </ul>
663
+ );
664
+ })()}
665
+
435
666
  <div className={FIELD_ROW}>
436
667
  <input
437
668
  aria-label="Subject"
@@ -462,12 +693,28 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
462
693
  Request a read receipt
463
694
  </label>
464
695
 
696
+ {/* Only rendered once this session actually has a usable signing/encryption key - see
697
+ `assembleForSend()`'s own doc comment for why signing rarely shows today (no signing
698
+ certificate exists until RFC 8823 ACME enrollment lands). */}
699
+ {unlockedKeys?.signingPrivateKey && (
700
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
701
+ <input type="checkbox" checked={signEnabled} onChange={(e) => setSignEnabled(e.target.checked)} />
702
+ Digitally sign this message
703
+ </label>
704
+ )}
705
+ {unlockedKeys?.encryptionPrivateKey && (
706
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
707
+ <input type="checkbox" checked={encryptRequested} onChange={(e) => setEncryptRequested(e.target.checked)} />
708
+ Encrypt this message
709
+ </label>
710
+ )}
711
+
465
712
  <div className="shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border">
466
713
  <div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
467
714
  <button
468
715
  type="button"
469
- onClick={handleSend}
470
- disabled={!draft || sending}
716
+ onClick={() => handleSend()}
717
+ disabled={!draft || sending || !cryptoContextReady}
471
718
  className="py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
472
719
  >
473
720
  {sending ? "Sending…" : "Send"}
@@ -478,7 +725,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }: Co
478
725
  aria-label="Send later"
479
726
  aria-haspopup="true"
480
727
  aria-expanded={schedulePickerOpen}
481
- disabled={!draft || sending}
728
+ disabled={!draft || sending || !cryptoContextReady}
482
729
  onClick={() => setSchedulePickerOpen((o) => !o)}
483
730
  className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
484
731
  >