@rapidmx/web-client 0.7.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 (49) 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/admin/settings/MailboxPolicyForm.tsx +51 -12
  7. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  8. package/apps/shared/components/layout/MailboxProvisioning.tsx +44 -6
  9. package/apps/shared/components/layout/UserMenu.tsx +48 -13
  10. package/apps/shared/components/mail/ConversationList.tsx +15 -6
  11. package/apps/shared/components/mail/ConversationThreadPane.tsx +2 -2
  12. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  13. package/apps/shared/components/mail/MessageDetailPane.tsx +11 -7
  14. package/apps/shared/components/mail/compose/ComposeWindow.tsx +40 -4
  15. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  16. package/apps/shared/components/mail/layout/MailShell.tsx +37 -10
  17. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  18. package/apps/shared/mail/useMailLiveUpdates.ts +224 -0
  19. package/apps/www/index.tsx +65 -2
  20. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  21. package/dist/apps/shared/components/admin/elevation.js +56 -0
  22. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  23. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  24. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  25. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  26. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +5 -1
  27. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +19 -3
  28. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  29. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  30. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  31. package/dist/apps/shared/components/layout/MailboxProvisioning.js +42 -7
  32. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -7
  33. package/dist/apps/shared/components/layout/UserMenu.js +39 -13
  34. package/dist/apps/shared/components/mail/ConversationList.js +7 -4
  35. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  36. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  37. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  38. package/dist/apps/shared/components/mail/MessageDetailPane.js +5 -3
  39. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +25 -6
  40. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  41. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  42. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +7 -0
  43. package/dist/apps/shared/components/mail/layout/MailShell.js +31 -11
  44. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  45. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  46. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +48 -0
  47. package/dist/apps/shared/mail/useMailLiveUpdates.js +180 -0
  48. package/dist/apps/www/index.js +63 -2
  49. package/package.json +2 -2
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { getDnsSetup, getDomain, verifyDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
11
12
  const RECORD_TYPE_LABELS = {
12
13
  ownership: "Ownership (TXT)",
13
14
  mx: "MX",
@@ -15,6 +16,23 @@ const RECORD_TYPE_LABELS = {
15
16
  dkim: "DKIM",
16
17
  dmarc: "DMARC",
17
18
  };
19
+ /** How each record is named in a Copy button's accessible name ("Copy value for the SPF record"). */
20
+ const RECORD_TYPE_NAMES = {
21
+ ownership: "ownership TXT",
22
+ mx: "MX",
23
+ spf: "SPF",
24
+ dkim: "DKIM",
25
+ dmarc: "DMARC",
26
+ };
27
+ /** An MX record's recommended value is `"<priority> <mail server>"` - two separate fields in a DNS provider's form. */
28
+ function splitMxValue(value) {
29
+ const match = /^(\d+)\s+(\S+)$/.exec(value.trim());
30
+ return match ? { priority: match[1], server: match[2] } : null;
31
+ }
32
+ /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a Copy button beside it. */
33
+ function CopyableValue({ value, copyLabel }) {
34
+ return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(CopyButton, { value: value, label: copyLabel })] }));
35
+ }
18
36
  /**
19
37
  * A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain
20
38
  * detail page and the setup wizard.
@@ -26,7 +44,6 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
26
44
  const [error, setError] = useState(null);
27
45
  const [verifying, setVerifying] = useState(false);
28
46
  const [verifyError, setVerifyError] = useState(null);
29
- const [copied, setCopied] = useState(false);
30
47
  function reload(id) {
31
48
  setLoading(true);
32
49
  setError(null);
@@ -62,22 +79,23 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
62
79
  setVerifying(false);
63
80
  }
64
81
  }
65
- async function handleCopy(value) {
66
- try {
67
- await navigator.clipboard.writeText(value);
68
- setCopied(true);
69
- setTimeout(() => setCopied(false), 2000);
70
- }
71
- catch {
72
- // Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
73
- }
74
- }
75
82
  if (loading) {
76
83
  return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
77
84
  }
78
85
  if (error || !domain) {
79
86
  return _jsx(Alert, { children: error ?? "Domain not found." });
80
87
  }
81
- const ownershipValue = dnsSetup.find((c) => c.type === "ownership")?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
82
- return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("code", { className: "flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap", children: ownershipValue }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => handleCopy(ownershipValue), children: copied ? "Copied" : "Copy" })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: RECORD_TYPE_LABELS[check.type] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: _jsx("code", { className: "text-xs", children: check.recommendedValue ?? "—" }) })] }, check.type))) })] }) })] }))] }));
88
+ const ownership = dnsSetup.find((c) => c.type === "ownership");
89
+ const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
90
+ const ownershipName = ownership?.recordName || domain.name;
91
+ return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _jsx("dt", { className: "text-text-muted py-2", children: "Name / host" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipName, copyLabel: "Copy name for the ownership TXT record" }) }), _jsx("dt", { className: "text-text-muted py-2", children: "Value" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Name / host", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] }))] }));
92
+ }
93
+ /** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), anything else whole. */
94
+ function DnsRecordValue({ check, value }) {
95
+ const recordName = RECORD_TYPE_NAMES[check.type];
96
+ const mx = check.type === "mx" ? splitMxValue(value) : null;
97
+ if (!mx) {
98
+ return _jsx(CopyableValue, { value: value, copyLabel: `Copy value for the ${recordName} record` });
99
+ }
100
+ return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Priority" }), _jsx(CopyableValue, { value: mx.priority, copyLabel: `Copy priority for the ${recordName} record` })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Mail server" }), _jsx(CopyableValue, { value: mx.server, copyLabel: `Copy mail server for the ${recordName} record` })] })] }));
83
101
  }
@@ -9,5 +9,9 @@ export interface MailboxPolicyFormProps {
9
9
  * lower-level heading. */
10
10
  embedded?: boolean;
11
11
  }
12
- /** The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. */
12
+ /**
13
+ * The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. Each field can be reset to what
14
+ * the server's config currently says (`policy.defaults`), which lets an administrator take a newly deployed value
15
+ * without typing it in; the reset fills the field in and is saved with the rest of the form.
16
+ */
13
17
  export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, embedded }: MailboxPolicyFormProps): React.JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
@@ -14,8 +14,24 @@ const GB = 1000000000;
14
14
  function toGb(bytes) {
15
15
  return String(bytes / GB);
16
16
  }
17
- /** The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. */
17
+ /** Whether a quota field's text is not the given number of bytes. */
18
+ function differsFromBytes(text, bytes) {
19
+ return Math.round(Number(text) * GB) !== bytes;
20
+ }
21
+ /** Puts a field back to the server's config value; nothing is saved until the form is. */
22
+ function ResetToDefault({ label, shown, onClick }) {
23
+ if (!shown) {
24
+ return null;
25
+ }
26
+ return (_jsxs(Button, { type: "button", variant: "text", className: "!w-auto self-start text-xs -mt-2", onClick: onClick, children: ["Reset to server default (", label, ")"] }));
27
+ }
28
+ /**
29
+ * The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. Each field can be reset to what
30
+ * the server's config currently says (`policy.defaults`), which lets an administrator take a newly deployed value
31
+ * without typing it in; the reset fills the field in and is saved with the rest of the form.
32
+ */
18
33
  export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, embedded = false }) {
34
+ const defaults = policy.defaults;
19
35
  const [defaultQuotaGb, setDefaultQuotaGb] = useState(toGb(policy.defaultQuotaBytes));
20
36
  const [autoProvisionEnabled, setAutoProvisionEnabled] = useState(policy.autoProvisionEnabled);
21
37
  const [autoProvisionQuotaGb, setAutoProvisionQuotaGb] = useState(toGb(policy.autoProvisionQuotaBytes));
@@ -70,5 +86,5 @@ export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, emb
70
86
  setSaving(false);
71
87
  }
72
88
  }
73
- return (_jsxs("div", { className: "max-w-xl", children: [embedded ? (_jsx("h3", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Mailboxes" })) : (_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "Mailboxes" })), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Defaults for new mailboxes on this server." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Default quota (GB)" }), _jsx("input", { "aria-label": "Default quota (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: defaultQuotaGb, onChange: (e) => setDefaultQuotaGb(e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "The storage a mailbox starts with when an administrator creates it." })] }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: autoProvisionEnabled, onChange: (e) => setAutoProvisionEnabled(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Let people create their own mailbox" }), _jsx("span", { className: "text-xs text-text-muted", children: "Someone who signs in without a mailbox can create one, using their username on one of this server\u2019s verified domains." })] })] }), autoProvisionEnabled && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Quota for self-created mailboxes (GB)" }), _jsx("input", { "aria-label": "Quota for self-created mailboxes (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: autoProvisionQuotaGb, onChange: (e) => setAutoProvisionQuotaGb(e.target.value) })] })), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
89
+ return (_jsxs("div", { className: "max-w-xl", children: [embedded ? (_jsx("h3", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Mailboxes" })) : (_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "Mailboxes" })), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Defaults for new mailboxes on this server." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Default quota (GB)" }), _jsx("input", { "aria-label": "Default quota (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: defaultQuotaGb, onChange: (e) => setDefaultQuotaGb(e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "The storage a mailbox starts with when an administrator creates it." })] }), _jsx(ResetToDefault, { label: defaults ? `${toGb(defaults.defaultQuotaBytes)} GB` : "", shown: !!defaults && differsFromBytes(defaultQuotaGb, defaults.defaultQuotaBytes), onClick: () => setDefaultQuotaGb(toGb(defaults.defaultQuotaBytes)) }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: autoProvisionEnabled, onChange: (e) => setAutoProvisionEnabled(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Let people create their own mailbox" }), _jsx("span", { className: "text-xs text-text-muted", children: "Someone who signs in without a mailbox can create one, using their username on one of this server\u2019s verified domains." })] })] }), _jsx(ResetToDefault, { label: defaults?.autoProvisionEnabled ? "on" : "off", shown: !!defaults && autoProvisionEnabled !== defaults.autoProvisionEnabled, onClick: () => setAutoProvisionEnabled(defaults.autoProvisionEnabled) }), autoProvisionEnabled && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Quota for self-created mailboxes (GB)" }), _jsx("input", { "aria-label": "Quota for self-created mailboxes (GB)", type: "number", min: 0, step: "any", className: INPUT_CLASS, value: autoProvisionQuotaGb, onChange: (e) => setAutoProvisionQuotaGb(e.target.value) })] }), _jsx(ResetToDefault, { label: defaults ? `${toGb(defaults.autoProvisionQuotaBytes)} GB` : "", shown: !!defaults && differsFromBytes(autoProvisionQuotaGb, defaults.autoProvisionQuotaBytes), onClick: () => setAutoProvisionQuotaGb(toGb(defaults.autoProvisionQuotaBytes)) })] })), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
74
90
  }
@@ -7,8 +7,9 @@ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
7
7
  */
8
8
  export declare function sanitizeBrandingHtml(html: string): string;
9
9
  /**
10
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
11
- * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
10
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized. The webmail
11
+ * chrome (`AppShell`) shows both; the admin console shows only the footer - its header is the console's own - and the
12
+ * escrow console deliberately renders no branding HTML at all.
12
13
  */
13
14
  export declare function BrandingHeader({ branding }: {
14
15
  branding: Branding | null;
@@ -42,8 +42,9 @@ function BrandingHtml({ html }) {
42
42
  return _jsx("div", { dangerouslySetInnerHTML: { __html: clean } });
43
43
  }
44
44
  /**
45
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
46
- * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
45
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized. The webmail
46
+ * chrome (`AppShell`) shows both; the admin console shows only the footer - its header is the console's own - and the
47
+ * escrow console deliberately renders no branding HTML at all.
47
48
  */
48
49
  export function BrandingHeader({ branding }) {
49
50
  return _jsx(BrandingHtml, { html: branding?.headerHtml });
@@ -7,7 +7,10 @@ import React from "react";
7
7
  * auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
8
8
  * for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
9
9
  * (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
10
- * unconditionally in that place: any failure (disabled, no registered username, the identity service
11
- * unreachable) just falls back to the same plain "no mailbox" message this replaces.
10
+ * unconditionally in that place: a failure ends in one of two screens. A 502 (the identity service couldn't be
11
+ * reached) or 503 (the server couldn't read its provisioning policy) is transient and offers "Retry". Anything
12
+ * else is "No mailbox available - ask an administrator", now with the server's own reason above that advice when it
13
+ * refused deliberately (a 4xx, e.g. "Automatic mailbox provisioning is not enabled.") so the caller and the
14
+ * administrator they ask can tell why - see `unavailableReason()`.
12
15
  */
13
16
  export default function MailboxProvisioning(): React.JSX.Element;
@@ -8,6 +8,25 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { autoProvisionMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ /** What the "Retry" screen says: the server couldn't read its provisioning policy (503) ... */
12
+ const RETRY_POLICY_TEXT = "We couldn\u2019t check whether a mailbox can be set up for you. Please try again.";
13
+ /** ... or couldn't reach the identity service (auth-server) it looks the caller's username up in (502). */
14
+ const RETRY_IDENTITY_TEXT = "We couldn\u2019t reach the identity service to set up your mailbox. Please try again.";
15
+ /** The longest server message shown as the reason a mailbox isn't available - a real reason is a sentence. */
16
+ const MAX_REASON_LENGTH = 200;
17
+ /**
18
+ * The reason to show under "No mailbox available": the server's own message, for a refusal it made on purpose - a
19
+ * 4xx such as 404 "Automatic mailbox provisioning is not enabled." or 404 "No username is registered for this account."
20
+ * Never for a 5xx (whose message is whatever went wrong inside the server) or a failure that never reached it (a network
21
+ * error's message is browser jargon), and only its first line, capped in length. `null` when there's nothing safe to show.
22
+ */
23
+ function unavailableReason(err) {
24
+ if (!(err instanceof ApiRequestError) || err.status < 400 || err.status >= 500) {
25
+ return null;
26
+ }
27
+ const line = err.message.split(/\r?\n/)[0].trim();
28
+ return line ? line.slice(0, MAX_REASON_LENGTH) : null;
29
+ }
11
30
  /**
12
31
  * Rendered by each app's shell (Mail/Calendar/Contacts/Tasks) *instead of* the normal `AppShell`
13
32
  * chrome (icon rail, header, folder tree, ...) when the caller has no mailbox — a full-screen,
@@ -16,14 +35,20 @@ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
16
35
  * auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
17
36
  * for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
18
37
  * (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
19
- * unconditionally in that place: any failure (disabled, no registered username, the identity service
20
- * unreachable) just falls back to the same plain "no mailbox" message this replaces.
38
+ * unconditionally in that place: a failure ends in one of two screens. A 502 (the identity service couldn't be
39
+ * reached) or 503 (the server couldn't read its provisioning policy) is transient and offers "Retry". Anything
40
+ * else is "No mailbox available - ask an administrator", now with the server's own reason above that advice when it
41
+ * refused deliberately (a 4xx, e.g. "Automatic mailbox provisioning is not enabled.") so the caller and the
42
+ * administrator they ask can tell why - see `unavailableReason()`.
21
43
  */
22
44
  export default function MailboxProvisioning() {
23
45
  const [status, setStatus] = useState("checking");
24
46
  const [options, setOptions] = useState([]);
25
47
  const [selected, setSelected] = useState("");
26
48
  const [error, setError] = useState(null);
49
+ // Why the caller has no mailbox (see `unavailableReason()`), and what the retry screen says.
50
+ const [reason, setReason] = useState(null);
51
+ const [retryText, setRetryText] = useState(RETRY_POLICY_TEXT);
27
52
  // Bumped by "Retry" to re-run the check below.
28
53
  const [attempt, setAttempt] = useState(0);
29
54
  useEffect(() => {
@@ -45,9 +70,19 @@ export default function MailboxProvisioning() {
45
70
  window.location.reload();
46
71
  }
47
72
  })
48
- // A 503 means the server couldn't read its own provisioning policy right now - not that there's no
49
- // mailbox to be had - so it gets a retry instead of the permanent "ask an administrator" message.
50
- .catch((err) => setStatus(err instanceof ApiRequestError && err.status === 503 ? "retryable" : "unavailable"));
73
+ // A 503 means the server couldn't read its own provisioning policy right now, and a 502 that it couldn't
74
+ // reach the identity service - neither says there's no mailbox to be had - so they get a retry instead of
75
+ // the permanent "ask an administrator" message.
76
+ .catch((err) => {
77
+ if (err instanceof ApiRequestError && (err.status === 503 || err.status === 502)) {
78
+ setRetryText(err.status === 502 ? RETRY_IDENTITY_TEXT : RETRY_POLICY_TEXT);
79
+ setStatus("retryable");
80
+ }
81
+ else {
82
+ setReason(unavailableReason(err));
83
+ setStatus("unavailable");
84
+ }
85
+ });
51
86
  }, [attempt]);
52
87
  async function handleConfirm() {
53
88
  // `selected` only ever takes a value from `options` itself (the initial default, or the
@@ -74,10 +109,10 @@ export default function MailboxProvisioning() {
74
109
  content = (_jsxs("div", { className: "w-full flex flex-col gap-3", children: [_jsx("p", { className: "text-sm text-text-muted text-center", children: "Choose your mailbox address to get started:" }), error && _jsx(Alert, { children: error }), _jsx("select", { "aria-label": "Mailbox address", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: selected, onChange: (e) => setSelected(e.target.value), disabled: creating, children: options.map((option) => (_jsx("option", { value: option.primarySmtpAddress, children: option.primarySmtpAddress }, option.primarySmtpAddress))) }), _jsx(Button, { type: "button", onClick: handleConfirm, loading: creating, disabled: creating, className: "!w-auto self-center", children: "Continue" })] }));
75
110
  }
76
111
  else if (status === "retryable") {
77
- content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "Couldn\u2019t check right now" }), _jsx("p", { className: "text-sm text-text-muted", children: "We couldn\u2019t check whether a mailbox can be set up for you. Please try again." }), _jsx(Button, { type: "button", onClick: () => setAttempt((n) => n + 1), className: "!w-auto self-center", children: "Retry" })] }));
112
+ content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "Couldn\u2019t check right now" }), _jsx("p", { className: "text-sm text-text-muted", children: retryText }), _jsx(Button, { type: "button", onClick: () => setAttempt((n) => n + 1), className: "!w-auto self-center", children: "Retry" })] }));
78
113
  }
79
114
  else {
80
- content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "No mailbox available" }), _jsx("p", { className: "text-sm text-text-muted", children: "Ask an administrator to create one for you." })] }));
115
+ content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "No mailbox available" }), reason && _jsx("p", { className: "text-sm", children: reason }), _jsx("p", { className: "text-sm text-text-muted", children: "Ask an administrator to create one for you." })] }));
81
116
  }
82
117
  return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8 flex flex-col items-center gap-4 text-center", children: [_jsx("img", { src: "/images/wordmark.png", height: "128", alt: "" }), content] }) }));
83
118
  }
@@ -1,7 +1,8 @@
1
1
  import React from "react";
2
2
  export interface UserMenuProps {
3
3
  userUid: string;
4
- /** auth-server's base URL — where the caller's own profile (name/avatar) is fetched from, if configured. */
4
+ /** auth-server's base URL — where the caller's own profile (name/avatar) and username are fetched from, and what
5
+ * the "Account" item links into, if configured. Without it the menu shows the bare uid and has no "Account" item. */
5
6
  authServerUrl?: string;
6
7
  onSignOut: () => void;
7
8
  /** Shows an "Admin" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller. */
@@ -13,11 +14,13 @@ export interface UserMenuProps {
13
14
  showSettingsLink?: boolean;
14
15
  }
15
16
  /**
16
- * The top-right "who am I" menu shared by `MailShell` and `AdminShell`: an avatar-button trigger that opens a
17
- * dropdown showing the caller's avatar/name, an optional "Admin" link (trusted-role callers only), and
18
- * "Sign Out". Name/avatar come from auth-server's own profile endpoint (`GET /api/profiles/me`) — this
19
- * service has no local user directory (see `.claude/NOTES.md`) — and fall back to the bare uid/its first
20
- * letter when unset or unreachable, which is a fully valid, expected state for most password-registered
21
- * accounts (only OIDC sign-in currently populates a real avatar).
17
+ * The top-right "who am I" menu shared by every shell (`AppShell`, `AdminShell`, `EscrowShell`): an avatar-button
18
+ * trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
19
+ * an optional "Settings" and "Admin" link, and "Sign Out". This service has no local user directory (see
20
+ * `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
21
+ * falls back down a chain: the profile's name (`GET /api/profiles/me`), else the caller's username - their first
22
+ * verified `name` alias (`GET /api/aliases?type=name`, asked for only when the profile gave no name) - else the bare
23
+ * uid. Either lookup can fail (auth-server's CORS list not including this origin, or no profile document at all - a
24
+ * 404 for some accounts) and neither ever surfaces as an error. The avatar image comes from the profile alone.
22
25
  */
23
26
  export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }: UserMenuProps): React.JSX.Element;
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useRef, useState } from "react";
7
- import { formatProfileName, getMyProfile, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
7
+ import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
8
8
  function Avatar({ profile, initials, large }) {
9
9
  const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
10
10
  if (profile?.avatar) {
@@ -13,24 +13,48 @@ function Avatar({ profile, initials, large }) {
13
13
  return (_jsx("span", { className: `${sizeClass} rounded-full bg-primary text-white font-semibold flex items-center justify-center shrink-0`, children: initials }));
14
14
  }
15
15
  /**
16
- * The top-right "who am I" menu shared by `MailShell` and `AdminShell`: an avatar-button trigger that opens a
17
- * dropdown showing the caller's avatar/name, an optional "Admin" link (trusted-role callers only), and
18
- * "Sign Out". Name/avatar come from auth-server's own profile endpoint (`GET /api/profiles/me`) — this
19
- * service has no local user directory (see `.claude/NOTES.md`) — and fall back to the bare uid/its first
20
- * letter when unset or unreachable, which is a fully valid, expected state for most password-registered
21
- * accounts (only OIDC sign-in currently populates a real avatar).
16
+ * The top-right "who am I" menu shared by every shell (`AppShell`, `AdminShell`, `EscrowShell`): an avatar-button
17
+ * trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
18
+ * an optional "Settings" and "Admin" link, and "Sign Out". This service has no local user directory (see
19
+ * `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
20
+ * falls back down a chain: the profile's name (`GET /api/profiles/me`), else the caller's username - their first
21
+ * verified `name` alias (`GET /api/aliases?type=name`, asked for only when the profile gave no name) - else the bare
22
+ * uid. Either lookup can fail (auth-server's CORS list not including this origin, or no profile document at all - a
23
+ * 404 for some accounts) and neither ever surfaces as an error. The avatar image comes from the profile alone.
22
24
  */
23
25
  export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }) {
24
26
  const [open, setOpen] = useState(false);
25
27
  const [profile, setProfile] = useState(undefined);
28
+ const [username, setUsername] = useState(undefined);
26
29
  const containerRef = useRef(null);
27
30
  useEffect(() => {
28
31
  if (!authServerUrl) {
29
32
  return;
30
33
  }
31
- getMyProfile(authServerUrl)
32
- .then(setProfile)
33
- .catch(() => undefined);
34
+ let cancelled = false;
35
+ void (async () => {
36
+ let loaded;
37
+ try {
38
+ loaded = await getMyProfile(authServerUrl);
39
+ }
40
+ catch {
41
+ // Unreachable, blocked, or no profile document (404) - fall through to the username.
42
+ }
43
+ if (cancelled) {
44
+ return;
45
+ }
46
+ setProfile(loaded);
47
+ // The username is only the fallback for a missing name - no second request when the profile has one.
48
+ if (!formatProfileName(loaded)) {
49
+ const alias = await getMyUsername(authServerUrl);
50
+ if (!cancelled) {
51
+ setUsername(alias);
52
+ }
53
+ }
54
+ })();
55
+ return () => {
56
+ cancelled = true;
57
+ };
34
58
  }, [authServerUrl]);
35
59
  useEffect(() => {
36
60
  if (!open) {
@@ -53,7 +77,9 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
53
77
  document.removeEventListener("keydown", handleKeyDown);
54
78
  };
55
79
  }, [open]);
56
- const name = formatProfileName(profile) ?? userUid;
57
- const initials = profileInitials(profile, userUid);
58
- return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { role: "menu", className: "absolute right-0 top-full mt-2 w-60 bg-surface border border-border rounded-md shadow-modal py-1.5 z-10", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showSettingsLink && (_jsx("a", { role: "menuitem", href: "/settings/auto-reply", className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showAdminLink && (_jsx("a", { role: "menuitem", href: "/admin", className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Admin" })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }))] }));
80
+ const name = formatProfileName(profile) ?? username ?? userUid;
81
+ const initials = profileInitials(profile, userUid, username);
82
+ // Tolerates a configured URL with a trailing slash, which would otherwise produce `//account`.
83
+ const accountUrl = authServerUrl ? `${authServerUrl.replace(/\/+$/, "")}/account` : undefined;
84
+ return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { role: "menu", className: "absolute right-0 top-full mt-2 w-60 bg-surface border border-border rounded-md shadow-modal py-1.5 z-10", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), accountUrl && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: "/settings/auto-reply", className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showAdminLink && (_jsx("a", { role: "menuitem", href: "/admin", className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Admin" })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }))] }));
59
85
  }
@@ -6,9 +6,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  import { useState } from "react";
7
7
  import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
10
+ import MailAddress from "./MailAddress.js";
9
11
  import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
10
- function participantNames(conversation) {
11
- return conversation.participants.map((participant) => participant.displayName || participant.address).join(", ");
12
+ /** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
13
+ function participantList(conversation) {
14
+ return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
12
15
  }
13
16
  /**
14
17
  * The nested, Outlook-style conversation list: one parent row per conversation - its participants, subject,
@@ -79,13 +82,13 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
79
82
  conversation.latestMessageUid === selectedUid || ticked
80
83
  ? "bg-primary/10"
81
84
  : "hover:bg-surface-alt",
82
- ].join(" "), children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: "shrink-0 px-2 text-text-muted hover:text-text", children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", unread ? "font-semibold" : ""].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: participantNames(conversation) }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
85
+ ].join(" "), children: [selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: "shrink-0 px-2 text-text-muted hover:text-text", children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", unread ? "font-semibold" : ""].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: "flex-1" }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
83
86
  const message = messageOverrides?.[fetched.uid] ?? fetched;
84
87
  return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onOpenMessage(conversation, message.uid), className: [
85
88
  "w-full text-left pl-8 pr-4 py-2 border-b border-border",
86
89
  message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
87
90
  message.flags.read ? "" : "font-semibold",
88
- ].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() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }) }, message.uid));
91
+ ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }) }, message.uid));
89
92
  })] })] }, id));
90
93
  }) }));
91
94
  }
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
9
9
  import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import MailAddress from "./MailAddress.js";
11
12
  import MessageDetailPane from "./MessageDetailPane.js";
12
13
  /** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
13
14
  export const THREAD_PAGE_SIZE = 100;
@@ -288,7 +289,6 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
288
289
  const uid = message.uid;
289
290
  const expanded = expandedUids.has(uid);
290
291
  const bodyId = `thread-message-${uid}`;
291
- const sender = message.from.displayName || message.from.address;
292
292
  return (_jsxs("li", { ref: (node) => {
293
293
  rowRefs.current[uid] = node;
294
294
  },
@@ -302,6 +302,6 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
302
302
  }, onClick: () => toggleExpanded(uid), "aria-expanded": expanded, "aria-controls": bodyId, className: [
303
303
  "w-full text-left px-4 py-3 hover:bg-surface-alt",
304
304
  message.flags.read ? "" : "font-semibold",
305
- ].join(" "), children: [_jsxs("span", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: sender }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleString() })] }), !expanded && (_jsx("span", { className: "block text-xs text-text-muted truncate font-normal", children: message.bodyPreview }))] }) }), _jsx("div", { id: bodyId, hidden: !expanded, className: expanded ? "flex-1 min-h-0 flex" : undefined, children: expanded && (_jsx(MessageDetailPane, { inThread: true, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) })] }, uid));
305
+ ].join(" "), children: [_jsxs("span", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleString() })] }), !expanded && (_jsx("span", { className: "block text-xs text-text-muted truncate font-normal", children: message.bodyPreview }))] }) }), _jsx("div", { id: bodyId, hidden: !expanded, className: expanded ? "flex-1 min-h-0 flex" : undefined, children: expanded && (_jsx(MessageDetailPane, { inThread: true, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) })] }, uid));
306
306
  }) })] }));
307
307
  }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { MailAddressLike } from "@rapidmx/react-shared/mail/mailAddress.js";
3
+ export interface MailAddressProps {
4
+ /** A message's `from`, a recipient, a conversation participant. */
5
+ recipient: MailAddressLike;
6
+ className?: string;
7
+ }
8
+ /**
9
+ * A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
10
+ * type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
11
+ * and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
12
+ * address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
13
+ * `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
14
+ * technology), so a screen reader hears one address, not fragments.
15
+ */
16
+ export default function MailAddress({ recipient, className }: MailAddressProps): React.JSX.Element;
17
+ /** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
18
+ export declare const RECIPIENT_LINE_LIMIT = 3;
19
+ /**
20
+ * A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
21
+ * onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
22
+ * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
23
+ */
24
+ export declare function RecipientLine({ label, recipients }: {
25
+ label: string;
26
+ recipients: MailAddressLike[];
27
+ }): React.JSX.Element | null;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import React, { useState } from "react";
7
+ import { formatMailAddress, splitMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
8
+ /**
9
+ * A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
10
+ * type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
11
+ * and when the row is too narrow for both the name gives way first (it shrinks a thousand times faster), then the
12
+ * address's own local part - the `@domain`, which is what says who really sent it, keeps its room to the end. The full
13
+ * `Name <address>` is the tooltip and, once, the accessible text (the two visible halves are hidden from assistive
14
+ * technology), so a screen reader hears one address, not fragments.
15
+ */
16
+ export default function MailAddress({ recipient, className }) {
17
+ const { name, address } = splitMailAddress(recipient);
18
+ const full = formatMailAddress(recipient);
19
+ const at = address.lastIndexOf("@");
20
+ const local = at > 0 ? address.slice(0, at) : address;
21
+ const domain = at > 0 ? address.slice(at) : "";
22
+ return (_jsxs("span", { className: ["flex min-w-0 items-baseline gap-1.5", className].filter(Boolean).join(" "), title: full, children: [_jsx("span", { className: "sr-only", children: full }), name && (_jsx("span", { "aria-hidden": "true", className: "truncate min-w-0", style: { flexShrink: 1000 }, children: name })), _jsxs("span", { "aria-hidden": "true", className: ["flex min-w-0 items-baseline", name ? "text-xs font-normal text-text-muted" : ""].filter(Boolean).join(" "), children: [name && _jsx("span", { children: "<" }), _jsx("span", { className: "truncate min-w-0", children: local }), domain && _jsx("span", { className: "shrink-0", children: domain }), name && _jsx("span", { children: ">" })] })] }));
23
+ }
24
+ /** How many recipients a `RecipientLine` shows before folding the rest behind "and N more". */
25
+ export const RECIPIENT_LINE_LIMIT = 3;
26
+ /**
27
+ * A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
28
+ * onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
29
+ * "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
30
+ */
31
+ export function RecipientLine({ label, recipients }) {
32
+ const [expanded, setExpanded] = useState(false);
33
+ if (recipients.length === 0) {
34
+ return null;
35
+ }
36
+ const folded = recipients.length > RECIPIENT_LINE_LIMIT;
37
+ const shown = folded && !expanded ? recipients.slice(0, RECIPIENT_LINE_LIMIT) : recipients;
38
+ return (_jsxs("p", { className: "text-sm text-text-muted break-words", children: [_jsx("span", { children: label }), " ", shown.map((recipient, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx("span", { children: formatMailAddress(recipient) })] }, index))), folded && (_jsxs(_Fragment, { children: [" ", _jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((open) => !open), className: "text-xs font-semibold text-primary-dark underline hover:no-underline", children: expanded ? "Show fewer" : `and ${recipients.length - RECIPIENT_LINE_LIMIT} more` })] }))] }));
39
+ }