@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.
- 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
|
@@ -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
|
-
<
|
|
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} ·{" "}
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
]);
|