@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 { 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
  }
@@ -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
+ }
@@ -23,6 +23,8 @@ import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKe
23
23
  import { useCompose } from "./compose/ComposeContext.js";
24
24
  import { loadOriginalMessage } from "./compose/quotedBody.js";
25
25
  import { formatRecipient } from "./compose/recipients.js";
26
+ import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
27
+ import { RecipientLine } from "./MailAddress.js";
26
28
  import { useMailShell } from "./layout/MailShell.js";
27
29
  import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
28
30
  import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
@@ -627,8 +629,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
627
629
  const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
628
630
  const senderName = message.from.displayName;
629
631
  const senderNameCheck = checkSenderName(senderName, senderAddress);
630
- const showSenderAddress = !!senderName && (signatureShown || senderNameCheck.looksLikeAddress);
631
- const senderLabel = showSenderAddress ? `${senderName} <${senderAddress}>` : senderName || senderAddress;
632
+ // Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
633
+ const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
632
634
  // Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
633
635
  // A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
634
636
  const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
@@ -668,7 +670,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
668
670
  source: recordedConflict?.source,
669
671
  }, certificate: keyChanged.signerCertificate, canReject: recordedConflict !== undefined, canResolve: canUpdateMailbox, onResolved: (action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null), onPinnedKeyChanged: () => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE) })] })), security?.state === "signature_failed" && !keyChanged && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: security.signatureFailureReason
670
672
  ? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
671
- : GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", senderLabel, " \u00B7 ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsxs("p", { className: "text-sm text-text-muted", children: ["To ", message.recipients.map((r) => r.displayName || r.address).join(", ")] }), _jsxs("div", { className: "flex flex-wrap gap-1 mt-3", children: [_jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward") }), !inOutbox && message.folderUid !== draftsFolderUid && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", busy: archiving, disabled: archiving, onClick: handleArchive })), folders && folders.length > 0 && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", onClick: () => setMovePrompt(true) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labelsError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: labelsError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
673
+ : GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1 break-words", children: ["From ", _jsx("span", { className: "font-medium text-text", children: senderLabel }), " \u00B7", " ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc") }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc") }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc") }), _jsxs("div", { className: "flex flex-wrap gap-1 mt-3", children: [_jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward") }), !inOutbox && message.folderUid !== draftsFolderUid && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", busy: archiving, disabled: archiving, onClick: handleArchive })), folders && folders.length > 0 && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", onClick: () => setMovePrompt(true) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labelsError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: labelsError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
672
674
  .labelUids.map((uid) => labels.find((l) => l.uid === uid))
673
675
  .filter((l) => !!l)
674
676
  .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]