@rapidmx/web-client 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/apps/shared/components/admin/elevation.ts +61 -0
  3. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  4. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +86 -24
  6. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  7. package/apps/shared/components/layout/MailboxProvisioning.tsx +44 -6
  8. package/apps/shared/components/layout/UserMenu.tsx +48 -13
  9. package/apps/shared/components/mail/ConversationList.tsx +15 -6
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +2 -2
  11. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  12. package/apps/shared/components/mail/MessageDetailPane.tsx +11 -7
  13. package/apps/shared/components/mail/compose/ComposeWindow.tsx +40 -4
  14. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  15. package/apps/shared/components/mail/layout/MailShell.tsx +37 -10
  16. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  17. package/apps/shared/mail/useMailLiveUpdates.ts +224 -0
  18. package/apps/www/index.tsx +65 -2
  19. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  20. package/dist/apps/shared/components/admin/elevation.js +56 -0
  21. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  22. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  23. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  24. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  25. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  26. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  27. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  28. package/dist/apps/shared/components/layout/MailboxProvisioning.js +42 -7
  29. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -7
  30. package/dist/apps/shared/components/layout/UserMenu.js +39 -13
  31. package/dist/apps/shared/components/mail/ConversationList.js +7 -4
  32. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  33. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  34. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  35. package/dist/apps/shared/components/mail/MessageDetailPane.js +5 -3
  36. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +25 -6
  37. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  38. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  39. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +7 -0
  40. package/dist/apps/shared/components/mail/layout/MailShell.js +31 -11
  41. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  42. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  43. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +48 -0
  44. package/dist/apps/shared/mail/useMailLiveUpdates.js +180 -0
  45. package/dist/apps/www/index.js +63 -2
  46. package/package.json +2 -2
@@ -8,6 +8,7 @@ import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevron
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
9
  import { assembleDraft, assembleDraftRaw, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, sendMessage, setMessageRequestReceipt, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
10
10
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
11
+ import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
11
12
  import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
12
13
  import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
13
14
  import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
@@ -22,6 +23,7 @@ import RecipientInput from "./RecipientInput.js";
22
23
  import { parseRecipientList } from "./recipients.js";
23
24
  import RichTextEditor from "./RichTextEditor.js";
24
25
  import ScheduleSendPicker from "./ScheduleSendPicker.js";
26
+ import SendFailureAlert from "./SendFailureAlert.js";
25
27
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
26
28
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
27
29
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
@@ -59,6 +61,10 @@ const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so c
59
61
  const CHECKING_CLOSE_MESSAGE = "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
60
62
  const CRYPTO_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
61
63
  const NO_RECIPIENTS_CLOSE_MESSAGE = "This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
64
+ const SEND_FAILED_TITLE = "This message wasn't sent";
65
+ function sendFailedCloseMessage(reason) {
66
+ return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
67
+ }
62
68
  const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
63
69
  const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
64
70
  const MIN_WIDTH = 320;
@@ -126,6 +132,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
126
132
  const [attachments, setAttachments] = useState([]);
127
133
  const [attachError, setAttachError] = useState(null);
128
134
  const [sendError, setSendError] = useState(null);
135
+ // The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
136
+ const [sendDetails, setSendDetails] = useState([]);
129
137
  const [sending, setSending] = useState(false);
130
138
  const [requestReceipt, setRequestReceipt] = useState(false);
131
139
  const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
@@ -736,6 +744,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
736
744
  setSending(true);
737
745
  sendingRef.current = true;
738
746
  setSendError(null);
747
+ setSendDetails([]);
739
748
  setSecurityBlock(null);
740
749
  setEncryptionBlocked(null);
741
750
  pendingSaveRef.current = false;
@@ -756,8 +765,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
756
765
  onClose();
757
766
  }
758
767
  catch (err) {
759
- const fallback = scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.";
760
- setSendError(err instanceof ApiRequestError ? err.message : fallback);
768
+ // The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
769
+ const failure = describeSendFailure(err, scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.");
770
+ setSendError(failure.message);
771
+ setSendDetails(failure.lines);
761
772
  }
762
773
  finally {
763
774
  sendingRef.current = false;
@@ -922,11 +933,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
922
933
  * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
923
934
  * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
924
935
  * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
925
- async function handleClose() {
936
+ async function handleClose(sendFailureAcknowledged = false) {
926
937
  if (!hasUserContent) {
927
938
  void discardNow();
928
939
  return;
929
940
  }
941
+ if (sendError && !sendFailureAcknowledged) {
942
+ // Closing would keep the draft and say nothing, which reads as if it had been sent.
943
+ setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
944
+ return;
945
+ }
930
946
  if (encryptionDecided) {
931
947
  setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
932
948
  return;
@@ -1126,16 +1142,19 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1126
1142
  }, [minimized, contentReady]);
1127
1143
  const title = subject.trim() || "New Message";
1128
1144
  const titleId = `compose-title-${id}`;
1129
- const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
1145
+ const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })), closePrompt?.closeAnyway && (_jsx(Button, { type: "button", onClick: () => {
1146
+ setClosePrompt(null);
1147
+ void handleClose(true);
1148
+ }, disabled: sending || closing, className: "!w-auto", children: "Close, keep draft" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
1130
1149
  if (minimized) {
1131
- return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { 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: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
1150
+ return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), sendError && _jsx("span", { className: "shrink-0 text-xs font-bold uppercase tracking-wide", children: "Not sent" }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
1132
1151
  }
1133
1152
  return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
1134
1153
  "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1135
1154
  isMobile
1136
1155
  ? "fixed inset-0 w-full h-full rounded-none border-0"
1137
1156
  : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1138
- ].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: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_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 }), discardError && _jsx(Alert, { children: discardError })] })), cryptoCheckUnavailable && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: CRYPTO_UNAVAILABLE_MESSAGE }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: retryCryptoContext, children: "Retry" })] }) })), 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", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: "Send without encryption" })] }) })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads || sending, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !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(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
1157
+ ].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: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(SendFailureAlert, { message: sendError, lines: sendDetails }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), cryptoCheckUnavailable && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: CRYPTO_UNAVAILABLE_MESSAGE }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: retryCryptoContext, children: "Retry" })] }) })), 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", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: "Send without encryption" })] }) })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads || sending, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !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(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
1139
1158
  const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1140
1159
  .map((r) => recipientStatuses[r.address])
1141
1160
  .filter((status) => !!status);
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+ export interface SendFailureAlertProps {
3
+ /** The plain, human message - always shown. */
4
+ message: string;
5
+ /** The technical facts behind it (`describeSendFailure().lines`): per-recipient SMTP results, a transport error.
6
+ * With none, only the message is shown. */
7
+ lines: string[];
8
+ }
9
+ /**
10
+ * The banner a compose window shows when a send (or scheduled send) fails: the plain message, prominent, and - when the
11
+ * server said more - a collapsed "Technical details" block of monospace lines, one per fact, that can be selected or
12
+ * copied in one go to send to whoever runs the server. Collapsed, because most people only need the message.
13
+ */
14
+ export default function SendFailureAlert({ message, lines }: SendFailureAlertProps): React.JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
3
+ import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
4
+ /**
5
+ * The banner a compose window shows when a send (or scheduled send) fails: the plain message, prominent, and - when the
6
+ * server said more - a collapsed "Technical details" block of monospace lines, one per fact, that can be selected or
7
+ * copied in one go to send to whoever runs the server. Collapsed, because most people only need the message.
8
+ */
9
+ export default function SendFailureAlert({ message, lines }) {
10
+ return (_jsx(Alert, { children: _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("p", { className: "font-semibold break-words", children: message }), lines.length > 0 && (_jsxs("details", { className: "mt-2", children: [_jsx("summary", { className: "cursor-pointer text-xs font-semibold", children: "Technical details" }), _jsx("ul", { "aria-label": "Technical details", className: "mt-2 max-h-40 overflow-auto rounded-sm border border-border bg-surface p-2 text-xs font-mono text-text select-text", children: lines.map((line, index) => (_jsx("li", { className: "whitespace-pre-wrap break-words", children: line }, index))) }), _jsx("div", { className: "mt-2", children: _jsx(CopyButton, { value: lines.join("\n"), label: "Copy technical details" }) })] }))] }) }));
11
+ }
@@ -1,6 +1,7 @@
1
1
  import React, { PropsWithChildren } from "react";
2
2
  import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
3
3
  import { AppShellProps } from "../../layout/AppShell.js";
4
+ import { LiveUpdates } from "../../../mail/useMailLiveUpdates.js";
4
5
  export type MailShellProps = Omit<AppShellProps, "active">;
5
6
  /** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
6
7
  * mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
@@ -41,6 +42,12 @@ export interface MailShellContextValue {
41
42
  * A no-op on the default context value, which is only ever read outside a real shell.
42
43
  */
43
44
  onFolderCreated: (folder: Folder) => void;
45
+ /**
46
+ * Bumped whenever new mail (or another change to a message) may have arrived - a push event, a reconnect or the safety-net
47
+ * poll - so the list on screen can quietly refetch its first page. See `useMailLiveUpdates()`. Never changes on the default
48
+ * context value, which is only ever read outside a real shell.
49
+ */
50
+ live: LiveUpdates;
44
51
  }
45
52
  /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
46
53
  export declare function useMailShell(): MailShellContextValue;
@@ -15,6 +15,7 @@ import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
15
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
16
  import { useCompose } from "../compose/ComposeContext.js";
17
17
  import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
18
+ import { NO_LIVE_UPDATES, useMailLiveUpdates } from "../../../mail/useMailLiveUpdates.js";
18
19
  /** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
19
20
  * mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
20
21
  * state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
@@ -28,6 +29,7 @@ const MailShellContext = createContext({
28
29
  mailboxes: [],
29
30
  mailboxFolders: [],
30
31
  onFolderCreated: () => undefined,
32
+ live: NO_LIVE_UPDATES,
31
33
  });
32
34
  /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
33
35
  export function useMailShell() {
@@ -58,8 +60,15 @@ function folderSortKey(folder) {
58
60
  function sortedFoldersOf(folders) {
59
61
  return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
60
62
  }
61
- function aggregateUnreadCount(mailboxFolders, type) {
62
- return mailboxFolders.reduce((total, mf) => total + (mf.folders.find((f) => f.type === type)?.unreadCount ?? 0), 0);
63
+ /** A folder's unread count: the refreshed one when live updates have read it since load, else the one it was listed with. */
64
+ function unreadOf(folder, unreadCounts) {
65
+ return unreadCounts[folder.uid] ?? folder.unreadCount;
66
+ }
67
+ function aggregateUnreadCount(mailboxFolders, type, unreadCounts) {
68
+ return mailboxFolders.reduce((total, mf) => {
69
+ const folder = mf.folders.find((f) => f.type === type);
70
+ return total + (folder ? unreadOf(folder, unreadCounts) : 0);
71
+ }, 0);
63
72
  }
64
73
  /** Page size of the one `listMailboxes()` call this shell makes. */
65
74
  export const MAILBOX_LIST_LIMIT = 100;
@@ -172,9 +181,17 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
172
181
  if (!MAIL_FOLDER_TYPES.has(folder.type)) {
173
182
  return;
174
183
  }
175
- setMailboxFolders((prev) => prev.map((entry) => entry.mailbox.uid === folder.mailboxUid ? { ...entry, folders: [...entry.folders, folder] } : entry));
184
+ setMailboxFolders((prev) => prev.map((entry) =>
185
+ // Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
186
+ entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
187
+ ? { ...entry, folders: [...entry.folders, folder] }
188
+ : entry));
176
189
  }, []);
177
- const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated]);
190
+ // New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders`
191
+ // rather than written into it, so a refreshed unread count never looks like a change of folders to the list on screen
192
+ // (which reloads, and forgets its selection, whenever the folders do).
193
+ const { live, unreadCounts } = useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated });
194
+ const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live]);
178
195
  // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
179
196
  // mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
180
197
  // `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
@@ -196,19 +213,22 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
196
213
  // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
197
214
  // unmounted).
198
215
  const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, {}) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
199
- const unread = aggregateUnreadCount(mailboxFolders, type);
216
+ const unread = aggregateUnreadCount(mailboxFolders, type, unreadCounts);
200
217
  return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
201
218
  "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
202
219
  aggregateFolderType === type
203
220
  ? "bg-primary/10 text-primary-dark font-semibold"
204
221
  : "text-text hover:bg-surface-alt",
205
222
  ].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[type] }), unread > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: unread }))] }, type));
206
- }) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: sortedFoldersOf(folders).map((folder) => (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
207
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
208
- folder.uid === folderUid
209
- ? "bg-primary/10 text-primary-dark font-semibold"
210
- : "text-text hover:bg-surface-alt",
211
- ].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))) })] }, mailbox.uid)))] }))] }));
223
+ }) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: sortedFoldersOf(folders).map((folder) => {
224
+ const unread = unreadOf(folder, unreadCounts);
225
+ return (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
226
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
227
+ folder.uid === folderUid
228
+ ? "bg-primary/10 text-primary-dark font-semibold"
229
+ : "text-text hover:bg-surface-alt",
230
+ ].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }), unread > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: unread }))] }, folder.uid));
231
+ }) })] }, mailbox.uid)))] }))] }));
212
232
  inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _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 })] })] }));
213
233
  }
214
234
  // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
@@ -0,0 +1,23 @@
1
+ /** What `mergeFirstPage()` made of a fresh first page and the rows already on screen. */
2
+ export interface MergedPage<T> {
3
+ /** The rows to show. */
4
+ rows: T[];
5
+ /** How many of the fresh page's rows were not on screen yet - the mail that arrived since. */
6
+ added: number;
7
+ /** `true` when `rows` is exactly the fresh page, because the whole listing fits on it. */
8
+ complete: boolean;
9
+ }
10
+ /**
11
+ * Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
12
+ * added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
13
+ * re-scrolled or re-selected the way a full reload would.
14
+ *
15
+ * - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
16
+ * deleted or moved elsewhere.
17
+ * - A full page is only the top of a longer listing. Its rows go first, in the server's order, followed by the loaded rows
18
+ * it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
19
+ * stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
20
+ * - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
21
+ * just changed (a higher `version`) is not put back by a fetch that started before the change landed.
22
+ */
23
+ export declare function mergeFirstPage<T>(current: T[], fresh: T[], idOf: (row: T) => string, pageSize: number, pick?: (current: T, fresh: T) => T): MergedPage<T>;
@@ -0,0 +1,30 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
7
+ * added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
8
+ * re-scrolled or re-selected the way a full reload would.
9
+ *
10
+ * - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
11
+ * deleted or moved elsewhere.
12
+ * - A full page is only the top of a longer listing. Its rows go first, in the server's order, followed by the loaded rows
13
+ * it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
14
+ * stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
15
+ * - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
16
+ * just changed (a higher `version`) is not put back by a fetch that started before the change landed.
17
+ */
18
+ export function mergeFirstPage(current, fresh, idOf, pageSize, pick = (_current, next) => next) {
19
+ const currentById = new Map(current.map((row) => [idOf(row), row]));
20
+ const added = fresh.filter((row) => !currentById.has(idOf(row))).length;
21
+ const rows = fresh.map((row) => {
22
+ const existing = currentById.get(idOf(row));
23
+ return existing ? pick(existing, row) : row;
24
+ });
25
+ if (fresh.length < pageSize) {
26
+ return { rows, added, complete: true };
27
+ }
28
+ const freshIds = new Set(fresh.map(idOf));
29
+ return { rows: [...rows, ...current.filter((row) => !freshIds.has(idOf(row)))], added, complete: false };
30
+ }
@@ -0,0 +1,48 @@
1
+ import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
2
+ import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
3
+ /** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
4
+ * that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
5
+ export declare const LIVE_POLL_INTERVAL_MS = 45000;
6
+ /** Events arriving this close together are answered with one refresh - a burst of new mail is one refetch, not one each. */
7
+ export declare const LIVE_EVENT_DEBOUNCE_MS = 400;
8
+ /** What changed, as far as a list on screen is concerned. */
9
+ export interface LiveUpdates {
10
+ /** Bumped each time the list should quietly refetch its first page. `0` until the first bump. */
11
+ tick: number;
12
+ /** The folders the events behind the latest bump touched - `null` when that isn't known (a poll, a reconnect, the tab
13
+ * coming back to the front), meaning any folder may have changed. */
14
+ folderUids: ReadonlySet<string> | null;
15
+ }
16
+ export declare const NO_LIVE_UPDATES: LiveUpdates;
17
+ /**
18
+ * The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
19
+ * mailbox's Inbox - where mail lands - then each mailbox's other folders in the sidebar's own order, then the mailbox uids
20
+ * themselves (which announce new folders). A folder is a channel of its own; subscribing to a mailbox does not deliver its
21
+ * folders' events.
22
+ */
23
+ export declare function pushChannelsFor(mailboxFolders: MailboxFolders[], mailboxes: Mailbox[]): string[];
24
+ export interface UseMailLiveUpdatesOptions {
25
+ /** Nothing runs without a signed-in user. */
26
+ userUid?: string;
27
+ mailboxes: Mailbox[];
28
+ mailboxFolders: MailboxFolders[];
29
+ /** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
30
+ onFolderCreated: (folder: Folder) => void;
31
+ }
32
+ export interface MailLiveUpdates {
33
+ live: LiveUpdates;
34
+ /** The latest unread count of each folder that has been refreshed since load, by folder uid - what the sidebar's badges show
35
+ * instead of the count the folder list was loaded with. */
36
+ unreadCounts: Record<string, number>;
37
+ }
38
+ /**
39
+ * Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
40
+ * folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
41
+ * `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
42
+ * tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
43
+ * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's unread count is re-read.
44
+ *
45
+ * Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
46
+ * Renders nothing and does nothing where there is no window (server-side rendering).
47
+ */
48
+ export declare function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates;
@@ -0,0 +1,180 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+ import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
7
+ import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
8
+ import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
9
+ /** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
10
+ * that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
11
+ export const LIVE_POLL_INTERVAL_MS = 45000;
12
+ /** Events arriving this close together are answered with one refresh - a burst of new mail is one refetch, not one each. */
13
+ export const LIVE_EVENT_DEBOUNCE_MS = 400;
14
+ export const NO_LIVE_UPDATES = { tick: 0, folderUids: null };
15
+ /** The class names `NotificationUtils.sendMessage()` publishes are the model's (`MessageMongo`, `MessageSQL`, `FolderMongo`). */
16
+ const MESSAGE_EVENT = /^Message/;
17
+ const FOLDER_EVENT = /^Folder/;
18
+ const MESSAGE_ACTIONS = new Set(["create", "update", "delete"]);
19
+ /**
20
+ * The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
21
+ * mailbox's Inbox - where mail lands - then each mailbox's other folders in the sidebar's own order, then the mailbox uids
22
+ * themselves (which announce new folders). A folder is a channel of its own; subscribing to a mailbox does not deliver its
23
+ * folders' events.
24
+ */
25
+ export function pushChannelsFor(mailboxFolders, mailboxes) {
26
+ const inboxes = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type === "inbox"));
27
+ const others = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type !== "inbox"));
28
+ return [...new Set([...inboxes, ...others].map((folder) => folder.uid).concat(mailboxes.map((mailbox) => mailbox.uid)))];
29
+ }
30
+ function isFolder(value) {
31
+ const folder = value;
32
+ return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
33
+ }
34
+ /** The folder a message event is about: the message's own `folderUid`, else the channel it arrived on (when the server said). */
35
+ function folderOfMessageEvent(event) {
36
+ const data = event.data;
37
+ if (typeof data?.folderUid === "string") {
38
+ return data.folderUid;
39
+ }
40
+ return event.channel;
41
+ }
42
+ /**
43
+ * Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
44
+ * folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
45
+ * `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
46
+ * tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
47
+ * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's unread count is re-read.
48
+ *
49
+ * Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
50
+ * Renders nothing and does nothing where there is no window (server-side rendering).
51
+ */
52
+ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }) {
53
+ const [live, setLive] = useState(NO_LIVE_UPDATES);
54
+ const [unreadCounts, setUnreadCounts] = useState({});
55
+ // The latest inputs, for the long-lived listeners below.
56
+ const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated });
57
+ latestRef.current = { mailboxes, mailboxFolders, onFolderCreated };
58
+ const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
59
+ const channelKey = channels.join("|");
60
+ const channelsRef = useRef(channels);
61
+ channelsRef.current = channels;
62
+ useEffect(() => {
63
+ if (!userUid) {
64
+ return;
65
+ }
66
+ const client = getPushClient();
67
+ let stopped = false;
68
+ let timer;
69
+ let pendingFolders = new Set();
70
+ let pendingUnknown = false;
71
+ let countsRun = 0;
72
+ let everOpen = false;
73
+ async function refreshCounts() {
74
+ const run = ++countsRun;
75
+ const results = await Promise.all(latestRef.current.mailboxes.map((mailbox) => listFolders(mailbox.uid).catch(() => undefined)));
76
+ if (stopped || run !== countsRun) {
77
+ return;
78
+ }
79
+ const fresh = {};
80
+ for (const folders of results) {
81
+ for (const folder of folders ?? []) {
82
+ fresh[folder.uid] = folder.unreadCount;
83
+ }
84
+ }
85
+ setUnreadCounts((previous) => {
86
+ const changed = Object.entries(fresh).some(([uid, count]) => previous[uid] !== count);
87
+ return changed ? { ...previous, ...fresh } : previous;
88
+ });
89
+ }
90
+ // Never runs once `stopped`: whatever sets it clears the timer first.
91
+ function flush() {
92
+ timer = undefined;
93
+ const folderUids = pendingUnknown ? null : new Set(pendingFolders);
94
+ pendingFolders = new Set();
95
+ pendingUnknown = false;
96
+ setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
97
+ void refreshCounts();
98
+ }
99
+ /** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
100
+ function schedule(folderUid) {
101
+ if (stopped) {
102
+ return;
103
+ }
104
+ if (folderUid) {
105
+ pendingFolders.add(folderUid);
106
+ }
107
+ else {
108
+ pendingUnknown = true;
109
+ }
110
+ timer ?? (timer = setTimeout(flush, LIVE_EVENT_DEBOUNCE_MS));
111
+ }
112
+ const offEvent = client.onEvent((event) => {
113
+ if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
114
+ schedule(folderOfMessageEvent(event));
115
+ }
116
+ else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
117
+ const folder = event.data;
118
+ const known = latestRef.current.mailboxFolders.some((entry) => entry.folders.some((f) => f.uid === folder.uid));
119
+ if (!known) {
120
+ latestRef.current.onFolderCreated(folder);
121
+ }
122
+ }
123
+ });
124
+ // Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
125
+ const offStatus = client.onStatus((status) => {
126
+ if (status === "open") {
127
+ if (everOpen) {
128
+ schedule();
129
+ }
130
+ everOpen = true;
131
+ }
132
+ });
133
+ const poll = setInterval(() => {
134
+ if (document.visibilityState === "visible") {
135
+ schedule();
136
+ }
137
+ }, LIVE_POLL_INTERVAL_MS);
138
+ function refreshNow() {
139
+ if (document.visibilityState === "visible") {
140
+ client.reconnectNow();
141
+ schedule();
142
+ }
143
+ }
144
+ function handleOnline() {
145
+ client.reconnectNow();
146
+ schedule();
147
+ }
148
+ document.addEventListener("visibilitychange", refreshNow);
149
+ window.addEventListener("focus", refreshNow);
150
+ window.addEventListener("online", handleOnline);
151
+ // A sign-out, in this tab or another, ended the session behind the socket: close it for good and stop refreshing.
152
+ const signOut = typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(SIGN_OUT_CHANNEL);
153
+ signOut?.addEventListener("message", (event) => {
154
+ if (event.data?.type === "sign-out") {
155
+ stopped = true;
156
+ clearTimeout(timer);
157
+ client.close();
158
+ }
159
+ });
160
+ client.setChannels(channelsRef.current);
161
+ client.start();
162
+ return () => {
163
+ stopped = true;
164
+ clearTimeout(timer);
165
+ clearInterval(poll);
166
+ offEvent();
167
+ offStatus();
168
+ document.removeEventListener("visibilitychange", refreshNow);
169
+ window.removeEventListener("focus", refreshNow);
170
+ window.removeEventListener("online", handleOnline);
171
+ signOut?.close();
172
+ };
173
+ }, [userUid]);
174
+ useEffect(() => {
175
+ if (userUid) {
176
+ getPushClient().setChannels(channels);
177
+ }
178
+ }, [userUid, channelKey]);
179
+ return { live, unreadCounts };
180
+ }