@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.
- package/apps/admin/data-requests/index.tsx +485 -0
- package/apps/admin/escrow-scopes/[uid].tsx +237 -0
- package/apps/admin/escrow-scopes/index.tsx +124 -0
- package/apps/admin/escrow-scopes/new/index.tsx +128 -0
- package/apps/admin/mailboxes/[uid].tsx +66 -8
- package/apps/admin/retention-policy/index.tsx +132 -0
- package/apps/escrow/_layout.tsx +41 -0
- package/apps/escrow/audit-log/index.tsx +168 -0
- package/apps/escrow/index.tsx +139 -0
- package/apps/escrow/matters/[uid].tsx +521 -0
- package/apps/escrow/matters/new/index.tsx +173 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
- package/apps/shared/components/forms/StringListField.tsx +104 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
- package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
- package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
- package/apps/www/index.tsx +122 -11
- package/apps/www/messages/[uid].tsx +15 -1
- package/apps/www/settings/encryption/index.tsx +664 -0
- package/apps/www/settings/labels/index.tsx +201 -0
- package/apps/www/settings/privacy/index.tsx +436 -0
- package/dist/apps/admin/data-requests/index.js +197 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
- package/dist/apps/admin/escrow-scopes/index.js +31 -0
- package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
- package/dist/apps/admin/mailboxes/[uid].js +26 -2
- package/dist/apps/admin/retention-policy/index.js +65 -0
- package/dist/apps/escrow/_layout.js +10 -0
- package/dist/apps/escrow/audit-log/index.js +66 -0
- package/dist/apps/escrow/index.js +31 -0
- package/dist/apps/escrow/matters/[uid].js +234 -0
- package/dist/apps/escrow/matters/new/index.js +75 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
- package/dist/apps/shared/components/forms/StringListField.js +39 -0
- package/dist/apps/shared/components/layout/AppShell.js +4 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
- package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
- package/dist/apps/www/index.js +110 -10
- package/dist/apps/www/messages/[uid].js +12 -2
- package/dist/apps/www/settings/encryption/index.js +330 -0
- package/dist/apps/www/settings/labels/index.js +95 -0
- package/dist/apps/www/settings/privacy/index.js +167 -0
- 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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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’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
|
|
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
|
|
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
|
>
|