@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
|
@@ -6,12 +6,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
-
import { assembleDraft, attachmentContentUrl, createDraft, listFolders, sendMessage, setMessageRequestReceipt, setMessageScheduledSendTime, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { assembleDraft, assembleDraftRaw, attachmentContentUrl, createDraft, getMailbox, listFolders, sendMessage, setMessageRequestReceipt, setMessageScheduledSendTime, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
10
|
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
11
|
+
import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
12
|
+
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
13
|
+
import { getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
14
|
+
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
15
|
+
import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
|
|
11
16
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
12
17
|
import RichTextEditor from "./RichTextEditor.js";
|
|
13
18
|
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
14
19
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
20
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
15
21
|
const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
|
|
16
22
|
const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
|
|
17
23
|
const MIN_WIDTH = 320;
|
|
@@ -48,7 +54,7 @@ function HeaderButton({ label, onClick, icon: Icon }) {
|
|
|
48
54
|
* once.
|
|
49
55
|
*/
|
|
50
56
|
export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
|
|
51
|
-
const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, minimized } = session;
|
|
57
|
+
const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, suppressSigning, minimized } = session;
|
|
52
58
|
const isMobile = useIsMobile();
|
|
53
59
|
const windowRef = useRef(null);
|
|
54
60
|
const [draftsFolderUid, setDraftsFolderUid] = useState();
|
|
@@ -71,11 +77,91 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
|
|
|
71
77
|
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
72
78
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
73
79
|
const scheduleButtonRef = useRef(null);
|
|
80
|
+
const [mailbox, setMailbox] = useState(null);
|
|
81
|
+
const [encryptionPolicy, setEncryptionPolicy] = useState(null);
|
|
82
|
+
// Defaults off when replying to a detected mailing list (spec's own "Mailing lists" note under
|
|
83
|
+
// Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
|
|
84
|
+
// `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
|
|
85
|
+
// still turn it back on; this only changes the default.
|
|
86
|
+
const [signEnabled, setSignEnabled] = useState(!suppressSigning);
|
|
87
|
+
const [encryptRequested, setEncryptRequested] = useState(false);
|
|
88
|
+
const [encryptionBlocked, setEncryptionBlocked] = useState(null);
|
|
89
|
+
// Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
|
|
90
|
+
// recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
|
|
91
|
+
// just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
|
|
92
|
+
// never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
|
|
93
|
+
const [recipientStatuses, setRecipientStatuses] = useState({});
|
|
94
|
+
// Best-effort, same as the signature-list fetch below: a mailbox with no keys enrolled yet (or a
|
|
95
|
+
// failed fetch) just means sign/encrypt stay unavailable for this compose session, never a blocking
|
|
96
|
+
// error - encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
|
|
97
|
+
// `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
|
|
98
|
+
// send that happens to race this fetch can't silently skip encryption the spec says should apply.
|
|
99
|
+
const [cryptoContextReady, setCryptoContextReady] = useState(false);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
setCryptoContextReady(false);
|
|
103
|
+
let mailboxSettled = false;
|
|
104
|
+
let policySettled = false;
|
|
105
|
+
function checkReady() {
|
|
106
|
+
if (!cancelled && mailboxSettled && policySettled) {
|
|
107
|
+
setCryptoContextReady(true);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
getMailbox(mailboxUid)
|
|
111
|
+
.then((result) => {
|
|
112
|
+
if (!cancelled) {
|
|
113
|
+
setMailbox(result);
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
.catch(() => undefined)
|
|
117
|
+
.finally(() => {
|
|
118
|
+
mailboxSettled = true;
|
|
119
|
+
checkReady();
|
|
120
|
+
});
|
|
121
|
+
getEncryptionPolicy()
|
|
122
|
+
.then((result) => {
|
|
123
|
+
if (!cancelled) {
|
|
124
|
+
setEncryptionPolicy(result);
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
.catch(() => undefined)
|
|
128
|
+
.finally(() => {
|
|
129
|
+
policySettled = true;
|
|
130
|
+
checkReady();
|
|
131
|
+
});
|
|
132
|
+
return () => {
|
|
133
|
+
cancelled = true;
|
|
134
|
+
};
|
|
135
|
+
}, [mailboxUid]);
|
|
74
136
|
useEffect(() => {
|
|
75
137
|
listFolders(mailboxUid)
|
|
76
138
|
.then((folders) => setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid))
|
|
77
139
|
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder."));
|
|
78
140
|
}, [mailboxUid]);
|
|
141
|
+
/**
|
|
142
|
+
* Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
|
|
143
|
+
* caching the result so the small per-recipient indicator below the recipient fields can update
|
|
144
|
+
* without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
|
|
145
|
+
* and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
|
|
146
|
+
* best-effort UI hint, not something a stale value here should be trusted to skip at send time).
|
|
147
|
+
* A failed lookup or a mailbox/policy fetch that hasn't resolved yet just leaves that address with no
|
|
148
|
+
* indicator rather than surfacing an error over what's a cosmetic nicety.
|
|
149
|
+
*/
|
|
150
|
+
function checkRecipientDiscovery(addresses) {
|
|
151
|
+
if (!mailbox || !encryptionPolicy) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
|
|
155
|
+
const unchecked = addresses.filter((r) => !(r.address in recipientStatuses));
|
|
156
|
+
for (const recipient of unchecked) {
|
|
157
|
+
lookupKeys(mailboxUid, recipient.address)
|
|
158
|
+
.catch(() => undefined)
|
|
159
|
+
.then((lookup) => {
|
|
160
|
+
const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, lookup);
|
|
161
|
+
setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
79
165
|
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
|
|
80
166
|
// plus any quoted original message, before `RichTextEditor` ever mounts (gated by `contentReady`
|
|
81
167
|
// below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
|
|
@@ -181,9 +267,81 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
|
|
|
181
267
|
}
|
|
182
268
|
}
|
|
183
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
|
|
272
|
+
* `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
|
|
273
|
+
* Shared by `handleSend()`/`handleScheduleSend()` (and the "Send without encryption" conflict button
|
|
274
|
+
* rendered below, via `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
|
|
275
|
+
*
|
|
276
|
+
* Returns `"blocked"` (never throws for this case) when encryption was requested but not every
|
|
277
|
+
* recipient can currently be encrypted to — the spec's "Multiple Recipients" all-or-nothing rule.
|
|
278
|
+
* Sets `encryptionBlocked` as a side effect so the window can render that prompt; callers must stop
|
|
279
|
+
* (not send) when they get this back.
|
|
280
|
+
*
|
|
281
|
+
* Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
|
|
282
|
+
* pre-send step — not live as recipients are typed. A compose-time recipient-entry indicator (key
|
|
283
|
+
* icons, live discovery as each address is entered) is Phase 4's "Discovery & contacts UI" work, not
|
|
284
|
+
* this pass; this still satisfies the spec's "MUST be called lazily at compose time, not on receipt"
|
|
285
|
+
* requirement, just without a live UI reflecting it before Send is clicked.
|
|
286
|
+
*/
|
|
287
|
+
async function assembleForSend(toRecipients, ccRecipients, bccRecipients, forcePlaintext) {
|
|
288
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
289
|
+
// `mailbox` is required to build protected headers (own From address) whenever signing or
|
|
290
|
+
// encrypting - not just guarded on the encryption branch below, since a signed-only message
|
|
291
|
+
// needs it too. A mailbox fetch failure just means no crypto for this send, never a crash.
|
|
292
|
+
const canSign = signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer;
|
|
293
|
+
const canEncryptSelf = !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer;
|
|
294
|
+
const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
|
|
295
|
+
let wantEncrypt = false;
|
|
296
|
+
let recipientCertDers = [];
|
|
297
|
+
if (!forcePlaintext && canEncryptSelf && mailbox && encryptionPolicy && allRecipients.length > 0) {
|
|
298
|
+
const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
|
|
299
|
+
const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid, r.address).catch(() => undefined)));
|
|
300
|
+
const statuses = allRecipients.map((r, i) => resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]));
|
|
301
|
+
const decision = decideMessageEncryption(statuses);
|
|
302
|
+
wantEncrypt = encryptRequested || decision.autoEncrypt;
|
|
303
|
+
if (wantEncrypt) {
|
|
304
|
+
if (!decision.canEncryptAll) {
|
|
305
|
+
setEncryptionBlocked(decision.blockedRecipients);
|
|
306
|
+
return "blocked";
|
|
307
|
+
}
|
|
308
|
+
recipientCertDers = [unlocked.encryptionCertDer, ...statuses.map((s) => fromBase64(s.encryptCert.publicKey))];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if ((wantEncrypt || canSign) && attachments.length > 0) {
|
|
312
|
+
// Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
|
|
313
|
+
// clearer explanation than that route's generic 400 rather than via a round trip.
|
|
314
|
+
throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
|
|
315
|
+
}
|
|
316
|
+
if (!wantEncrypt && !canSign) {
|
|
317
|
+
// See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
|
|
318
|
+
// gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
|
|
319
|
+
// client-side pass is done here either.
|
|
320
|
+
return assembleDraft(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
|
|
321
|
+
}
|
|
322
|
+
const domain = mailbox.primarySmtpAddress.split("@")[1] ?? "localhost";
|
|
323
|
+
const protectedHeaders = {
|
|
324
|
+
from: mailbox.displayName
|
|
325
|
+
? `"${mailbox.displayName.replace(/"/g, '\\"')}" <${mailbox.primarySmtpAddress}>`
|
|
326
|
+
: mailbox.primarySmtpAddress,
|
|
327
|
+
to: toRecipients.map((r) => r.address).join(", "),
|
|
328
|
+
cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
|
|
329
|
+
date: new Date().toUTCString(),
|
|
330
|
+
subject,
|
|
331
|
+
messageId: `<${crypto.randomUUID()}@${domain}>`,
|
|
332
|
+
};
|
|
333
|
+
const bodyContentType = 'text/html; charset="utf-8"';
|
|
334
|
+
const signing = canSign ? { certDer: unlocked.signingCertDer, privateKey: unlocked.signingPrivateKey } : undefined;
|
|
335
|
+
const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
|
|
336
|
+
const mimePart = wantEncrypt
|
|
337
|
+
? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
|
|
338
|
+
: await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing.certDer, signing.privateKey);
|
|
339
|
+
const rawMime = assembleOutboundMime(outerHeaders, mimePart);
|
|
340
|
+
return assembleDraftRaw(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
|
|
341
|
+
}
|
|
184
342
|
// Same reasoning as `handleFilesSelected` above: the Send button is itself `disabled` until `draft`
|
|
185
343
|
// resolves, so this is never reachable with a null `draft`.
|
|
186
|
-
async function handleSend() {
|
|
344
|
+
async function handleSend(forcePlaintext = false) {
|
|
187
345
|
const toRecipients = parseAddresses(to);
|
|
188
346
|
if (toRecipients.length === 0) {
|
|
189
347
|
setSendError("At least one recipient is required.");
|
|
@@ -192,16 +350,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
|
|
|
192
350
|
setSending(true);
|
|
193
351
|
setSendError(null);
|
|
194
352
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
to: toRecipients,
|
|
200
|
-
cc: parseAddresses(cc),
|
|
201
|
-
bcc: parseAddresses(bcc),
|
|
202
|
-
subject,
|
|
203
|
-
html,
|
|
204
|
-
});
|
|
353
|
+
const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
|
|
354
|
+
if (assembled === "blocked") {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
205
357
|
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
206
358
|
await sendMessage(withReceipt.uid);
|
|
207
359
|
onClose();
|
|
@@ -228,13 +380,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
|
|
|
228
380
|
setSending(true);
|
|
229
381
|
setSendError(null);
|
|
230
382
|
try {
|
|
231
|
-
const assembled = await
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
subject,
|
|
236
|
-
html,
|
|
237
|
-
});
|
|
383
|
+
const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), false);
|
|
384
|
+
if (assembled === "blocked") {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
238
387
|
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
239
388
|
const scheduled = await setMessageScheduledSendTime(withReceipt, scheduledSendTimeIso);
|
|
240
389
|
await sendMessage(scheduled.uid);
|
|
@@ -247,6 +396,9 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
|
|
|
247
396
|
setSending(false);
|
|
248
397
|
}
|
|
249
398
|
}
|
|
399
|
+
// A plain read from keySession.ts's module-level session store, not React state - see that module's
|
|
400
|
+
// own doc comment. Cheap enough to read fresh on every render rather than caching in state.
|
|
401
|
+
const unlockedKeys = getUnlockedKeys(mailboxUid);
|
|
250
402
|
const title = subject.trim() || "New Message";
|
|
251
403
|
const titleId = `compose-title-${id}`;
|
|
252
404
|
if (minimized) {
|
|
@@ -257,5 +409,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
|
|
|
257
409
|
isMobile
|
|
258
410
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
259
411
|
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
260
|
-
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: onClose, icon: HiOutlineXMark })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError })] })),
|
|
412
|
+
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: onClose, icon: HiOutlineXMark })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError })] })), encryptionBlocked && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["This message can\u2019t be encrypted for everyone: ", encryptionBlocked.map((r) => r.address).join(", "), encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file", ". Remove ", encryptionBlocked.length > 1 ? "these recipients" : "this recipient", " from To/Cc/Bcc, or send the whole message in plaintext."] }), _jsx(Button, { type: "button", onClick: () => {
|
|
413
|
+
setEncryptionBlocked(null);
|
|
414
|
+
void handleSend(true);
|
|
415
|
+
}, children: "Send without encryption" })] }) })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx("input", { id: `compose-to-${id}`, type: "text", className: FIELD_INPUT, value: to, onChange: (e) => setTo(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(to)) }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx("input", { id: `compose-cc-${id}`, type: "text", className: FIELD_INPUT, value: cc, onChange: (e) => setCc(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(cc)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx("input", { id: `compose-bcc-${id}`, type: "text", className: FIELD_INPUT, value: bcc, onChange: (e) => setBcc(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(bcc)) })] })] })), (() => {
|
|
416
|
+
const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
|
|
417
|
+
.map((r) => recipientStatuses[r.address])
|
|
418
|
+
.filter((status) => !!status);
|
|
419
|
+
if (knownRecipients.length === 0) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
|
|
423
|
+
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && _jsx(RichTextEditor, { value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage }) }), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => handleSend(), disabled: !draft || sending || !cryptoContextReady, 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", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending || !cryptoContextReady, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: onClose, className: "ml-auto w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }));
|
|
261
424
|
}
|
|
@@ -11,6 +11,7 @@ import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.j
|
|
|
11
11
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
12
|
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
13
13
|
import AppShell from "../../layout/AppShell.js";
|
|
14
|
+
import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
|
|
14
15
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
16
|
import { useCompose } from "../compose/ComposeContext.js";
|
|
16
17
|
const MailShellContext = createContext({ mailboxes: [], folders: [] });
|
|
@@ -24,10 +25,11 @@ const FOLDER_LABELS = {
|
|
|
24
25
|
drafts: "Drafts",
|
|
25
26
|
outbox: "Outbox",
|
|
26
27
|
junk: "Junk Email",
|
|
28
|
+
archive: "Archive",
|
|
27
29
|
deleted_items: "Deleted Items",
|
|
28
30
|
};
|
|
29
31
|
/** Well-known folders sort first, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
|
|
30
|
-
const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "deleted_items"];
|
|
32
|
+
const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "archive", "deleted_items"];
|
|
31
33
|
/**
|
|
32
34
|
* A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see
|
|
33
35
|
* `CalendarShell`/`ContactsShell`/`TasksShell`), not Mail — `listFolders()` returns every well-known
|
|
@@ -135,5 +137,12 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
135
137
|
].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }), folder.unreadCount > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: folder.unreadCount }))] }, folder.uid)))) })] }));
|
|
136
138
|
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
137
139
|
}
|
|
138
|
-
|
|
140
|
+
// Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
|
|
141
|
+
// resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
|
|
142
|
+
// mailboxUid is supplied (see its own doc comment). Wrapping only once ready (a conditional tree
|
|
143
|
+
// position) would make AppShell itself remount the moment mailboxUid resolves, tearing down
|
|
144
|
+
// whatever state/effects it had already started (confirmed by direct reproduction: the
|
|
145
|
+
// impersonation banner's own internal state was lost exactly at that transition).
|
|
146
|
+
const selectedMailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
|
|
147
|
+
return (_jsx(KeyEnrollmentGate, { mailboxUid: mailboxUid, mailboxAddress: selectedMailbox?.primarySmtpAddress, mailboxKeys: selectedMailbox?.keys, children: _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }) }));
|
|
139
148
|
}
|
|
@@ -17,9 +17,12 @@ export const SETTINGS_SECTIONS = [
|
|
|
17
17
|
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
|
|
18
18
|
{ id: "filters", href: "/settings/filters", label: "Mail Filters" },
|
|
19
19
|
{ id: "signatures", href: "/settings/signatures", label: "Signatures" },
|
|
20
|
+
{ id: "labels", href: "/settings/labels", label: "Labels" },
|
|
20
21
|
{ id: "focused-inbox", href: "/settings/focused-inbox", label: "Focused Inbox" },
|
|
21
22
|
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
|
|
22
23
|
{ id: "booking-types", href: "/settings/booking-types", label: "Booking Links" },
|
|
24
|
+
{ id: "encryption", href: "/settings/encryption", label: "Encryption" },
|
|
25
|
+
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
|
|
23
26
|
];
|
|
24
27
|
const SettingsShellContext = createContext({ mailboxes: [] });
|
|
25
28
|
/** Reads the mailbox Settings is currently scoped to, as resolved by the enclosing `SettingsShell`. */
|
package/dist/apps/www/index.js
CHANGED
|
@@ -6,8 +6,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { getMessage, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
9
10
|
import { listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
10
11
|
import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
|
|
12
|
+
import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
13
|
+
import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
|
|
14
|
+
import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
|
|
15
|
+
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
11
16
|
import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
12
17
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
13
18
|
import MailShell, { useMailShell } from "../shared/components/mail/layout/MailShell.js";
|
|
@@ -17,14 +22,88 @@ import ConversationThreadPane from "../shared/components/mail/ConversationThread
|
|
|
17
22
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
18
23
|
const MESSAGE_PAGE_SIZE = 50;
|
|
19
24
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
/** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message) and Tier 3 (decrypted,
|
|
26
|
+
* content-verified) results into one ranked list, per `specs/search.md` §7's "client MUST re-score all
|
|
27
|
+
* results it can see... normalise into the same space rather than interleaving raw scores": both sides
|
|
28
|
+
* are normalized independently via `normalizeServerScores()` before merging, since a Postgres/OpenSearch
|
|
29
|
+
* score and this module's own term-count score occupy unrelated ranges. A uid present in both lists
|
|
30
|
+
* (Tier 1 found it via a `participants` coincidence, Tier 3 then genuinely verified its content) keeps
|
|
31
|
+
* only the Tier 3 entry - real content verification supersedes a metadata guess for the same message.
|
|
32
|
+
* Deliberately not the spec's full skeleton/reordering "Progressive Results" UX (§_Progressive
|
|
33
|
+
* Results_) - that's real, separate UI work; this returns one final merged list once both tiers
|
|
34
|
+
* resolve, same as how the search box already waits on one round-trip today. */
|
|
35
|
+
function mergeSearchResults(tier1, tier3) {
|
|
36
|
+
const normalizedTier1 = normalizeServerScores(tier1);
|
|
37
|
+
const normalizedTier3 = normalizeServerScores(tier3);
|
|
38
|
+
const merged = new Map();
|
|
39
|
+
for (const entry of normalizedTier1) {
|
|
40
|
+
merged.set(entry.result.entityUid, entry);
|
|
41
|
+
}
|
|
42
|
+
for (const entry of normalizedTier3) {
|
|
43
|
+
merged.set(entry.result.entityUid, entry);
|
|
44
|
+
}
|
|
45
|
+
return Array.from(merged.values())
|
|
46
|
+
.sort((a, b) => b.normalizedScore - a.normalizedScore)
|
|
47
|
+
.map((entry) => entry.result);
|
|
48
|
+
}
|
|
49
|
+
/** Resolves one page of search hits into full `Message` records for display, plus each hit's own
|
|
50
|
+
* `snippet` (keyed by message uid) for rendering in place of the plain `bodyPreview` while searching.
|
|
51
|
+
*
|
|
52
|
+
* `rawQuery` is parsed once, client-side, via `queryGrammar.ts`'s `parseSearchQuery()` — the operator
|
|
53
|
+
* grammar (`from:`/`to:`/`subject:`/`has:attachment`/`before:`/`after:`/`in:`/`is:`/`label:`/`type:`,
|
|
54
|
+
* `specs/search.md` §14) is extracted into structured filters passed to the server, while the
|
|
55
|
+
* remaining free text (quotes, `-` negation, `OR` all preserved) still drives ranking as `q`.
|
|
56
|
+
* `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
|
|
57
|
+
* (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
|
|
58
|
+
* dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
|
|
59
|
+
* (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
|
|
60
|
+
* larger UI project outside this pass).
|
|
61
|
+
*
|
|
62
|
+
* Tier 3 (`searchTier3.ts#searchEncryptedCandidates()`) runs alongside Tier 1 only for the first page
|
|
63
|
+
* (`cursor` absent) - it has no pagination wiring yet (a deliberate scope trim, see that module's own
|
|
64
|
+
* doc comment), so a `loadMore()` continuation stays Tier-1-only. Silently contributes nothing when
|
|
65
|
+
* this mailbox has no unlocked keys this session (`getUnlockedKeys()` returns `undefined`) - nothing
|
|
66
|
+
* for it to decrypt, same as `MessageDetailPane`'s own encrypted-message handling elsewhere. */
|
|
67
|
+
async function searchMessages(mailboxUid, rawQuery, cursor) {
|
|
68
|
+
const parsed = parseSearchQuery(rawQuery);
|
|
69
|
+
const [page, tier3Results] = await Promise.all([
|
|
70
|
+
searchMailbox(parsed.text, {
|
|
71
|
+
types: parsed.entityTypes ?? ["message"],
|
|
72
|
+
cursor,
|
|
73
|
+
limit: MESSAGE_PAGE_SIZE,
|
|
74
|
+
from: parsed.from,
|
|
75
|
+
to: parsed.to,
|
|
76
|
+
cc: parsed.cc,
|
|
77
|
+
subject: parsed.subject,
|
|
78
|
+
hasAttachment: parsed.hasAttachment,
|
|
79
|
+
before: parsed.before,
|
|
80
|
+
after: parsed.after,
|
|
81
|
+
folderUid: parsed.folderUid,
|
|
82
|
+
flags: parsed.flags,
|
|
83
|
+
labels: parsed.labels,
|
|
84
|
+
}),
|
|
85
|
+
cursor ? Promise.resolve([]) : searchEncryptedCandidates(parsed, getUnlockedKeys(mailboxUid)),
|
|
86
|
+
]);
|
|
87
|
+
const mergedResults = mergeSearchResults(page.results, tier3Results);
|
|
88
|
+
const resolved = await Promise.all(mergedResults.map(async (hit) => {
|
|
89
|
+
const message = await getMessage(hit.entityUid).catch(() => null);
|
|
90
|
+
return message ? { message, snippet: hit.snippet } : null;
|
|
91
|
+
}));
|
|
24
92
|
// A search hit can briefly outlive the message it points to (index updates are eventually consistent,
|
|
25
93
|
// and a message can be deleted after being indexed) - drop anything that no longer resolves rather than
|
|
26
94
|
// rendering a broken entry.
|
|
27
|
-
|
|
95
|
+
const messages = [];
|
|
96
|
+
const snippets = {};
|
|
97
|
+
for (const entry of resolved) {
|
|
98
|
+
if (!entry) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
messages.push(entry.message);
|
|
102
|
+
if (entry.snippet) {
|
|
103
|
+
snippets[entry.message.uid] = entry.snippet;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { messages, nextCursor: page.nextCursor, snippets };
|
|
28
107
|
}
|
|
29
108
|
export default function InboxPage(props) {
|
|
30
109
|
return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, {}) }));
|
|
@@ -44,11 +123,24 @@ function InboxContent() {
|
|
|
44
123
|
const [classificationFilter, setClassificationFilter] = useState("all");
|
|
45
124
|
const [searchInput, setSearchInput] = useState("");
|
|
46
125
|
const [searchQuery, setSearchQuery] = useState("");
|
|
126
|
+
const [snippets, setSnippets] = useState({});
|
|
127
|
+
const [labels, setLabels] = useState([]);
|
|
47
128
|
const isSearching = viewMode === "date" && searchQuery.length > 0;
|
|
48
129
|
const pageRef = useRef(0);
|
|
49
130
|
const cursorRef = useRef(undefined);
|
|
50
131
|
const scrollContainerRef = useRef(null);
|
|
51
132
|
const sentinelRef = useRef(null);
|
|
133
|
+
// Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
|
|
134
|
+
// handed to every `MessageDetailPane` instance below. A failure here just means the Labels control
|
|
135
|
+
// stays hidden (an empty `labels` array) rather than blocking the rest of the inbox - it's a small
|
|
136
|
+
// enhancement, not critical path the way the message list itself is. No `mailboxUid` guard needed -
|
|
137
|
+
// `MailShell` never renders this component at all until `mailboxUid` has resolved (same invariant
|
|
138
|
+
// `searchMessages(mailboxUid!, ...)` below already relies on).
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
listLabels(mailboxUid, { limit: 200 })
|
|
141
|
+
.then(setLabels)
|
|
142
|
+
.catch(() => setLabels([]));
|
|
143
|
+
}, [mailboxUid]);
|
|
52
144
|
// Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
|
|
53
145
|
useEffect(() => {
|
|
54
146
|
const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
|
|
@@ -84,9 +176,10 @@ function InboxContent() {
|
|
|
84
176
|
setLoading(true);
|
|
85
177
|
setError(null);
|
|
86
178
|
if (isSearching) {
|
|
87
|
-
searchMessages(searchQuery)
|
|
88
|
-
.then(({ messages: results, nextCursor }) => {
|
|
179
|
+
searchMessages(mailboxUid, searchQuery)
|
|
180
|
+
.then(({ messages: results, nextCursor, snippets: newSnippets }) => {
|
|
89
181
|
setMessages(results);
|
|
182
|
+
setSnippets(newSnippets);
|
|
90
183
|
setHasMore(!!nextCursor);
|
|
91
184
|
cursorRef.current = nextCursor;
|
|
92
185
|
})
|
|
@@ -109,8 +202,9 @@ function InboxContent() {
|
|
|
109
202
|
setLoadingMore(true);
|
|
110
203
|
try {
|
|
111
204
|
if (isSearching) {
|
|
112
|
-
const { messages: more, nextCursor } = await searchMessages(searchQuery, cursorRef.current);
|
|
205
|
+
const { messages: more, nextCursor, snippets: moreSnippets } = await searchMessages(mailboxUid, searchQuery, cursorRef.current);
|
|
113
206
|
setMessages((prev) => [...prev, ...more]);
|
|
207
|
+
setSnippets((prev) => ({ ...prev, ...moreSnippets }));
|
|
114
208
|
setHasMore(!!nextCursor);
|
|
115
209
|
cursorRef.current = nextCursor;
|
|
116
210
|
}
|
|
@@ -218,11 +312,17 @@ function InboxContent() {
|
|
|
218
312
|
"w-full text-left px-4 py-3 border-b border-border",
|
|
219
313
|
message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
|
|
220
314
|
message.flags.read ? "" : "font-semibold",
|
|
221
|
-
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: message.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: message.bodyPreview })] }) }, message.uid))) }), hasMore && (_jsx("div", { ref: sentinelRef, className: "p-4 text-center text-xs text-text-muted", children: loadingMore ? "Loading more…" : "" }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0", children: viewMode === "conversation" ? (_jsx(ConversationThreadPane, { conversation: selectedConversation, folders: folders })) : (_jsx(MessageDetailPane, { message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), isOutbox: isOutbox, isInbox: isInbox, onClassified: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), onReceiptHandled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), draftsFolderUid: draftsFolderUid, onScheduledSendCanceled: (updated) => {
|
|
315
|
+
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: message.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: snippets[message.uid] || message.bodyPreview })] }) }, message.uid))) }), hasMore && (_jsx("div", { ref: sentinelRef, className: "p-4 text-center text-xs text-text-muted", children: loadingMore ? "Loading more…" : "" }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0", children: viewMode === "conversation" ? (_jsx(ConversationThreadPane, { conversation: selectedConversation, folders: folders, labels: labels })) : (_jsx(MessageDetailPane, { message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), isOutbox: isOutbox, isInbox: isInbox, onClassified: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), onReceiptHandled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), draftsFolderUid: draftsFolderUid, onScheduledSendCanceled: (updated) => {
|
|
222
316
|
// The message moved out of the currently-viewed Outbox folder (into Drafts)
|
|
223
317
|
// — unlike a recall, which patches a message in place, this removes it from
|
|
224
318
|
// the list entirely, matching what a real folder switch would show.
|
|
225
319
|
setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
|
|
226
320
|
setSelectedUid(null);
|
|
227
|
-
}
|
|
321
|
+
}, onArchived: (updated) => {
|
|
322
|
+
// Same reasoning as onScheduledSendCanceled above — the message moved out of
|
|
323
|
+
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
324
|
+
// from the list rather than patched in place.
|
|
325
|
+
setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
|
|
326
|
+
setSelectedUid(null);
|
|
327
|
+
}, labels: labels, onLabelsChanged: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))) })) })] }));
|
|
228
328
|
}
|
|
@@ -6,6 +6,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
6
6
|
import { useEffect, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { getMessage } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
9
10
|
import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
10
11
|
import MailShell, { useMailShell } from "../../shared/components/mail/layout/MailShell.js";
|
|
11
12
|
import MessageDetailPane from "../../shared/components/mail/MessageDetailPane.js";
|
|
@@ -18,10 +19,11 @@ export default function MessageDetailPage(props) {
|
|
|
18
19
|
return (_jsx(MailShell, { ...props, children: _jsx(MessageDetailContent, { uid: props.params.uid }) }));
|
|
19
20
|
}
|
|
20
21
|
function MessageDetailContent({ uid }) {
|
|
21
|
-
const { folders } = useMailShell();
|
|
22
|
+
const { folders, mailboxUid } = useMailShell();
|
|
22
23
|
const [message, setMessage] = useState(null);
|
|
23
24
|
const [loading, setLoading] = useState(true);
|
|
24
25
|
const [error, setError] = useState(null);
|
|
26
|
+
const [labels, setLabels] = useState([]);
|
|
25
27
|
useEffect(() => {
|
|
26
28
|
setLoading(true);
|
|
27
29
|
setError(null);
|
|
@@ -30,6 +32,14 @@ function MessageDetailContent({ uid }) {
|
|
|
30
32
|
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this message."))
|
|
31
33
|
.finally(() => setLoading(false));
|
|
32
34
|
}, [uid]);
|
|
35
|
+
// See `apps/www/index.tsx`'s identical effect's own doc comment - a failure here just hides the
|
|
36
|
+
// Labels control rather than blocking the rest of the page. No `mailboxUid` guard needed - `MailShell`
|
|
37
|
+
// never renders this component at all until `mailboxUid` has resolved.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
listLabels(mailboxUid, { limit: 200 })
|
|
40
|
+
.then(setLabels)
|
|
41
|
+
.catch(() => setLabels([]));
|
|
42
|
+
}, [mailboxUid]);
|
|
33
43
|
const attachments = useMessageAttachments(message);
|
|
34
44
|
useMarkMessageRead(message, setMessage);
|
|
35
45
|
if (loading) {
|
|
@@ -43,5 +53,5 @@ function MessageDetailContent({ uid }) {
|
|
|
43
53
|
const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
|
|
44
54
|
const isInbox = folders.find((f) => f.uid === message.folderUid)?.type === "inbox";
|
|
45
55
|
const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
|
|
46
|
-
return (_jsx(MessageDetailPane, { message: message, attachments: attachments, backHref: backHref, isSentItems: isSentItems, onRecalled: setMessage, isOutbox: isOutbox, isInbox: isInbox, onClassified: setMessage, onReceiptHandled: setMessage, draftsFolderUid: draftsFolderUid, onScheduledSendCanceled: setMessage }));
|
|
56
|
+
return (_jsx(MessageDetailPane, { message: message, attachments: attachments, backHref: backHref, isSentItems: isSentItems, onRecalled: setMessage, isOutbox: isOutbox, isInbox: isInbox, onClassified: setMessage, onReceiptHandled: setMessage, draftsFolderUid: draftsFolderUid, onScheduledSendCanceled: setMessage, onArchived: setMessage, labels: labels, onLabelsChanged: setMessage }));
|
|
47
57
|
}
|