@rapidmx/web-client 0.23.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/apps/admin/mailboxes/[uid].tsx +2 -1
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
  3. package/apps/shared/components/admin/layout/railPreference.ts +25 -0
  4. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
  5. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
  6. package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
  7. package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
  8. package/apps/shared/components/calendar/EventDetails.tsx +15 -6
  9. package/apps/shared/components/calendar/EventEditor.tsx +5 -2
  10. package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
  11. package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
  12. package/apps/shared/components/calendar/FindATime.tsx +3 -2
  13. package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
  14. package/apps/shared/components/calendar/eventFormat.ts +15 -0
  15. package/apps/shared/components/calendar/timePicker.ts +106 -0
  16. package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
  17. package/apps/shared/components/layout/UserMenu.tsx +30 -10
  18. package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
  19. package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
  20. package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
  21. package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
  22. package/apps/shared/navigation/appHrefs.ts +3 -3
  23. package/apps/www/calendar/index.tsx +5 -2
  24. package/apps/www/settings/profile/index.tsx +8 -12
  25. package/dist/apps/admin/mailboxes/[uid].js +2 -1
  26. package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
  27. package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
  28. package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
  29. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
  30. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
  31. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
  32. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
  33. package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
  34. package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
  35. package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
  36. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
  37. package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
  38. package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
  39. package/dist/apps/shared/components/calendar/FindATime.js +3 -2
  40. package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
  41. package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
  42. package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
  43. package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
  44. package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
  45. package/dist/apps/shared/components/calendar/timePicker.js +89 -0
  46. package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
  47. package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
  48. package/dist/apps/shared/components/layout/UserMenu.js +4 -4
  49. package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
  50. package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
  51. package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
  52. package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  54. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
  55. package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
  56. package/dist/apps/shared/navigation/appHrefs.js +3 -3
  57. package/dist/apps/www/calendar/index.js +5 -2
  58. package/dist/apps/www/settings/profile/index.js +6 -5
  59. package/dist/lib/auth/session.d.ts +14 -1
  60. package/dist/lib/auth/session.js +40 -3
  61. package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
  62. package/dist/lib/components/pickers/TimeZonePicker.js +109 -0
  63. package/dist/lib/util/api.d.ts +6 -0
  64. package/dist/lib/util/api.js +22 -1
  65. package/dist/lib/util/timeZone.d.ts +19 -1
  66. package/dist/lib/util/timeZone.js +59 -2
  67. package/lib/auth/session.ts +48 -3
  68. package/lib/components/pickers/TimeZonePicker.tsx +185 -0
  69. package/lib/util/api.ts +24 -1
  70. package/lib/util/timeZone.ts +65 -2
  71. package/package.json +1 -1
@@ -187,6 +187,21 @@ export function formatStoredWhen(startDate: string, endDate: string, allDay: boo
187
187
  return formatWhen(new Date(startDate), new Date(endDate), false, now);
188
188
  }
189
189
 
190
+ /** The date the form's start falls on, for reading a zone's offset as it will be for the event (daylight saving); now while there is no date. */
191
+ export function zoneDate(start: string): Date {
192
+ const day = new Date(`${start.slice(0, 10)}T12:00:00Z`);
193
+ return Number.isNaN(day.getTime()) ? new Date() : day;
194
+ }
195
+
196
+ /** `formatWhen()` for a stored timed event as `zone`'s clock reads it, whatever zone the viewer is in. */
197
+ export function formatStoredWhenInZone(startDate: string, endDate: string, zone: string, now: Date = new Date()): string {
198
+ const onZoneClock = (iso: string) => {
199
+ const wall = new Date(toEventWallClock(new Date(iso).getTime(), zone, false));
200
+ return new Date(wall.getUTCFullYear(), wall.getUTCMonth(), wall.getUTCDate(), wall.getUTCHours(), wall.getUTCMinutes());
201
+ };
202
+ return formatWhen(onZoneClock(startDate), onZoneClock(endDate), false, now);
203
+ }
204
+
190
205
  /** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
191
206
  * `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
192
207
  * `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
@@ -0,0 +1,106 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { format } from "date-fns";
6
+
7
+ /** The step between the times a time field offers. */
8
+ export const TIME_STEP_MINUTES = 15;
9
+ const MS_PER_MINUTE = 60_000;
10
+
11
+ /**
12
+ * The start a new event gets when nothing was clicked: the next half hour (11:12 becomes 11:30; a time already on the half hour stays), with the
13
+ * event lasting an hour from there.
14
+ */
15
+ export function nextHalfHour(now: Date): Date {
16
+ const slot = 30 * MS_PER_MINUTE;
17
+ return new Date(Math.ceil(now.getTime() / slot) * slot);
18
+ }
19
+
20
+ /** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
21
+ export function formatTimeOfDay(time: string): string {
22
+ // A cleared date leaves no time to read: the field is empty, not an error.
23
+ if (!/^\d\d:\d\d$/.test(time)) {
24
+ return "";
25
+ }
26
+ const [hours, minutes] = time.split(":").map(Number);
27
+ return format(new Date(2000, 0, 1, hours, minutes), "h:mm a");
28
+ }
29
+
30
+ /**
31
+ * What was typed into a time field as `HH:mm`, or `null` when it isn't a time. Takes 24 hour and 12 hour readings, with or without the colon and
32
+ * the space: "15:45", "3:45 pm", "3:45p", "345pm", "1130" and a bare hour such as "9" (9:00) are all times.
33
+ */
34
+ export function parseTimeInput(text: string): string | null {
35
+ const match = /^\s*(\d{1,2})(?::?(\d{2}))?\s*(?:([ap])\.?m?\.?)?\s*$/i.exec(text);
36
+ if (!match) {
37
+ return null;
38
+ }
39
+ let hours = Number(match[1]);
40
+ const minutes = match[2] === undefined ? 0 : Number(match[2]);
41
+ const meridiem = match[3]?.toLowerCase();
42
+ if (minutes > 59) {
43
+ return null;
44
+ }
45
+ if (meridiem) {
46
+ if (hours < 1 || hours > 12) {
47
+ return null;
48
+ }
49
+ hours = (hours % 12) + (meridiem === "p" ? 12 : 0);
50
+ } else if (hours > 23) {
51
+ return null;
52
+ }
53
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
54
+ }
55
+
56
+ /** Every time of a day, a quarter hour apart, as `HH:mm`. */
57
+ export function dayTimes(): string[] {
58
+ return Array.from({ length: (24 * 60) / TIME_STEP_MINUTES }, (_, i) => {
59
+ const minutes = i * TIME_STEP_MINUTES;
60
+ return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
61
+ });
62
+ }
63
+
64
+ /** A length of time as a menu says it: "15 mins", "1 hr", "1.5 hrs", "2 hrs". */
65
+ export function formatDuration(minutes: number): string {
66
+ if (minutes < 60) {
67
+ return `${minutes} min${minutes === 1 ? "" : "s"}`;
68
+ }
69
+ const hours = Math.round((minutes / 60) * 100) / 100;
70
+ return `${hours} ${hours === 1 ? "hr" : "hrs"}`;
71
+ }
72
+
73
+ /** `YYYY-MM-DDTHH:mm` read as a wall clock, in minutes since the epoch (no zone: the daylight saving changes of a zone are not this field's business). */
74
+ function wallMinutes(value: string): number {
75
+ return Date.parse(`${value.slice(0, 16)}:00Z`) / MS_PER_MINUTE;
76
+ }
77
+
78
+ function wallString(minutes: number): string {
79
+ return new Date(minutes * MS_PER_MINUTE).toISOString().slice(0, 16);
80
+ }
81
+
82
+ export interface EndTimeOption {
83
+ /** The end as `YYYY-MM-DDTHH:mm`. */
84
+ value: string;
85
+ /** The time of day, as a 12 hour clock reading. */
86
+ label: string;
87
+ /** How long the event would last, "(1.5 hrs)". */
88
+ hint: string;
89
+ }
90
+
91
+ /**
92
+ * The ends a time field offers for an event starting at `start` (`YYYY-MM-DDTHH:mm`): each quarter hour on the clock after the start, up to a
93
+ * day later, with the length of the event that end would make.
94
+ */
95
+ export function endTimeOptions(start: string): EndTimeOption[] {
96
+ if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(start)) {
97
+ return [];
98
+ }
99
+ const startMinutes = wallMinutes(start);
100
+ const options: EndTimeOption[] = [];
101
+ for (let at = Math.floor(startMinutes / TIME_STEP_MINUTES + 1) * TIME_STEP_MINUTES; at <= startMinutes + 24 * 60; at += TIME_STEP_MINUTES) {
102
+ const value = wallString(at);
103
+ options.push({ value, label: formatTimeOfDay(value.slice(11, 16)), hint: `(${formatDuration(at - startMinutes)})` });
104
+ }
105
+ return options;
106
+ }
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useContext, useId, useRef } from "react";
6
- import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlineSun } from "react-icons/hi2";
6
+ import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlinePaintBrush, HiOutlineSun } from "react-icons/hi2";
7
7
  import type { AppearanceMode } from "../../../../lib/appearance/preferencesApi.js";
8
8
  import { AppearanceContext, INERT_APPEARANCE } from "../../appearance/appearanceContext.js";
9
9
 
@@ -49,7 +49,8 @@ export default function ThemeSwitch() {
49
49
 
50
50
  return (
51
51
  <div className="flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border">
52
- <span id={labelId} className="text-sm text-text">
52
+ <span id={labelId} className="flex items-center gap-2 text-sm text-text">
53
+ <HiOutlinePaintBrush size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
53
54
  Theme
54
55
  </span>
55
56
  <div role="radiogroup" aria-labelledby={labelId} className="inline-flex rounded-sm border border-border bg-surface-alt p-0.5">
@@ -5,7 +5,17 @@
5
5
  import React, { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
6
6
  import { createPortal } from "react-dom";
7
7
  import useIsMobile from "../../../../lib/util/useIsMobile.js";
8
- import { HiOutlineArrowUturnLeft, HiOutlineShieldCheck } from "react-icons/hi2";
8
+ import {
9
+ HiOutlineArrowRightOnRectangle,
10
+ HiOutlineArrowUturnLeft,
11
+ HiOutlineBell,
12
+ HiOutlineBellAlert,
13
+ HiOutlineClock,
14
+ HiOutlineCog6Tooth,
15
+ HiOutlineCommandLine,
16
+ HiOutlineShieldCheck,
17
+ HiOutlineUserCircle,
18
+ } from "react-icons/hi2";
9
19
  import { formatProfileName, getMyProfile, getMyUsername, Profile, profileInitials } from "../../../../lib/auth/profileApi.js";
10
20
  import { listMailboxes, Mailbox } from "../../../../lib/mail/mailApi.js";
11
21
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
@@ -46,8 +56,7 @@ export interface UserMenuProps {
46
56
  trustedRoles?: readonly string[];
47
57
  /** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
48
58
  * a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
49
- * `/settings/auto-reply`, the only settings section that exists today; repoint this at a real
50
- * `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
59
+ * `/settings/profile`, the first settings section; repoint this at a real `/settings` landing page once one is worth building. */
51
60
  showSettingsLink?: boolean;
52
61
  /** Shows the notification controls, above "Admin Console"/"Sign Out": a "Notifications" on/off switch for every pop-up and,
53
62
  * while the browser hasn't been asked, "Turn on desktop notifications". Both are per browser (see `notifications/preferences.ts`
@@ -346,8 +355,9 @@ export default function UserMenu({
346
355
  <a
347
356
  role="menuitem"
348
357
  href={accountUrl}
349
- className="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
358
+ className="flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
350
359
  >
360
+ <HiOutlineUserCircle size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
351
361
  Account
352
362
  </a>
353
363
  )}
@@ -355,8 +365,9 @@ export default function UserMenu({
355
365
  <a
356
366
  role="menuitem"
357
367
  href={SETTINGS_HREF}
358
- className="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
368
+ className="flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
359
369
  >
370
+ <HiOutlineCog6Tooth size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
360
371
  Settings
361
372
  </a>
362
373
  )}
@@ -368,7 +379,10 @@ export default function UserMenu({
368
379
  onClick={togglePopups}
369
380
  className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
370
381
  >
371
- <span>Notifications</span>
382
+ <span className="flex items-center gap-2">
383
+ <HiOutlineBell size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
384
+ Notifications
385
+ </span>
372
386
  <span className="text-xs font-semibold text-text-muted">{popups ? "On" : "Off"}</span>
373
387
  </button>
374
388
  )}
@@ -377,8 +391,9 @@ export default function UserMenu({
377
391
  role="menuitem"
378
392
  type="button"
379
393
  onClick={() => void enableDesktopNotifications()}
380
- className="block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
394
+ className="flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
381
395
  >
396
+ <HiOutlineBellAlert size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
382
397
  Turn on desktop notifications
383
398
  </button>
384
399
  )}
@@ -392,7 +407,10 @@ export default function UserMenu({
392
407
  }}
393
408
  className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
394
409
  >
395
- <span>Recent notifications</span>
410
+ <span className="flex items-center gap-2">
411
+ <HiOutlineClock size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
412
+ Recent notifications
413
+ </span>
396
414
  {unseenErrors > 0 && (
397
415
  <span className="rounded-pill bg-danger px-1.5 text-xs font-bold text-white">
398
416
  <span aria-hidden="true">{unseenErrors}</span>
@@ -410,8 +428,9 @@ export default function UserMenu({
410
428
  setOpen(false);
411
429
  onShowShortcuts();
412
430
  }}
413
- className="block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
431
+ className="flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
414
432
  >
433
+ <HiOutlineCommandLine size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
415
434
  Keyboard shortcuts
416
435
  </button>
417
436
  )}
@@ -429,8 +448,9 @@ export default function UserMenu({
429
448
  role="menuitem"
430
449
  type="button"
431
450
  onClick={onSignOut}
432
- className="block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
451
+ className="flex w-full items-center gap-2 text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
433
452
  >
453
+ <HiOutlineArrowRightOnRectangle size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
434
454
  Sign Out
435
455
  </button>
436
456
  </div>,
@@ -4,6 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import type { Attachment } from "../../../../../lib/mail/mailApi.js";
6
6
  import type { InviteResponse, MessageInvite } from "../../../../../lib/calendar/inviteApi.js";
7
+ import { describeTimeZone, deviceTimeZone } from "../../../../../lib/util/timeZone.js";
7
8
  import { APP_HREFS } from "../../../navigation/appHrefs.js";
8
9
 
9
10
  /** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
@@ -53,15 +54,15 @@ export function formatInviteWhen(invite: Pick<MessageInvite, "startDate" | "endD
53
54
  }
54
55
  const date = new Intl.DateTimeFormat(undefined, DAY_FORMAT);
55
56
  const time = new Intl.DateTimeFormat(undefined, CLOCK_FORMAT);
56
- // The zone is named once, after the last time shown: the times are the reader's own, not the organizer's.
57
- const timeAndZone = new Intl.DateTimeFormat(undefined, { ...CLOCK_FORMAT, timeZoneName: "short" });
57
+ // The zone is named once, in full, after the last time shown: the times are the reader's own, not the organizer's.
58
+ const zone = describeTimeZone(deviceTimeZone(), end ?? start);
58
59
  const startDay = date.format(start);
59
60
  if (!end) {
60
- return `${startDay}, ${timeAndZone.format(start)}`;
61
+ return `${startDay}, ${time.format(start)} • ${zone}`;
61
62
  }
62
63
  return startDay === date.format(end)
63
- ? `${startDay}, ${time.format(start)} - ${timeAndZone.format(end)}`
64
- : `${startDay}, ${time.format(start)} - ${date.format(end)}, ${timeAndZone.format(end)}`;
64
+ ? `${startDay}, ${time.format(start)} - ${time.format(end)} • ${zone}`
65
+ : `${startDay}, ${time.format(start)} - ${date.format(end)}, ${time.format(end)} • ${zone}`;
65
66
  }
66
67
 
67
68
  /** The meeting's start the way a list row writes it - `Thu 9/24/2026 1:00 PM` (dates only for an all-day event), in the reader's zone. */
@@ -30,6 +30,7 @@ import {
30
30
  } from "../../../mail/folderCounts.js";
31
31
  import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
32
32
  import FolderBadgeChip from "./FolderBadgeChip.js";
33
+ import { FolderTypeIcon, SharedMailboxMark } from "./folderIcons.js";
33
34
  import SidebarSection from "./SidebarSection.js";
34
35
  import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
35
36
  import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
@@ -391,7 +392,10 @@ export default function MailShell({
391
392
  : "text-text hover:bg-surface-alt",
392
393
  ].join(" ")}
393
394
  >
394
- <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>{FOLDER_LABELS[type]}</span>
395
+ <span className={["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" ")}>
396
+ <FolderTypeIcon type={type} />
397
+ <span className="truncate">{FOLDER_LABELS[type]}</span>
398
+ </span>
395
399
  {badge && <FolderBadgeChip badge={badge} />}
396
400
  </a>
397
401
  );
@@ -405,7 +409,7 @@ export default function MailShell({
405
409
  label={
406
410
  <>
407
411
  {mailbox.displayName}
408
- {mailbox.ownerUserUid ? "" : " (shared)"}
412
+ {!mailbox.ownerUserUid && <SharedMailboxMark />}
409
413
  </>
410
414
  }
411
415
  collapsible={collapsible}
@@ -431,7 +435,10 @@ export default function MailShell({
431
435
  data-folder-placeholder={row.type}
432
436
  className="flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted"
433
437
  >
434
- <span>{FOLDER_LABELS[row.type]}</span>
438
+ <span className="flex items-center gap-2.5 min-w-0">
439
+ <FolderTypeIcon type={row.type} />
440
+ <span className="truncate">{FOLDER_LABELS[row.type]}</span>
441
+ </span>
435
442
  {row.type === "outbox" && <OutboxBadge total={sending} pendingHere={sending} />}
436
443
  </div>
437
444
  );
@@ -451,8 +458,9 @@ export default function MailShell({
451
458
  : "text-text hover:bg-surface-alt",
452
459
  ].join(" ")}
453
460
  >
454
- <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>
455
- {FOLDER_LABELS[folder.type] ?? folder.name}
461
+ <span className={["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" ")}>
462
+ <FolderTypeIcon type={folder.type} />
463
+ <span className="truncate">{FOLDER_LABELS[folder.type] ?? folder.name}</span>
456
464
  </span>
457
465
  {badge &&
458
466
  (folder.type === "outbox" ? (
@@ -0,0 +1,54 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import type { IconType } from "react-icons";
7
+ import {
8
+ HiOutlineArchiveBox,
9
+ HiOutlineArrowUpTray,
10
+ HiOutlineBookOpen,
11
+ HiOutlineCalendarDays,
12
+ HiOutlineClipboardDocumentList,
13
+ HiOutlineFolder,
14
+ HiOutlineInbox,
15
+ HiOutlinePaperAirplane,
16
+ HiOutlinePencilSquare,
17
+ HiOutlineShieldExclamation,
18
+ HiOutlineTrash,
19
+ HiOutlineUsers,
20
+ HiUserGroup,
21
+ } from "react-icons/hi2";
22
+ import type { FolderType } from "../../../../../lib/mail/mailApi.js";
23
+
24
+ const FOLDER_ICONS: Record<FolderType, IconType> = {
25
+ inbox: HiOutlineInbox,
26
+ drafts: HiOutlinePencilSquare,
27
+ sent_items: HiOutlinePaperAirplane,
28
+ outbox: HiOutlineArrowUpTray,
29
+ junk: HiOutlineShieldExclamation,
30
+ archive: HiOutlineArchiveBox,
31
+ deleted_items: HiOutlineTrash,
32
+ calendar: HiOutlineCalendarDays,
33
+ contacts: HiOutlineUsers,
34
+ tasks: HiOutlineClipboardDocumentList,
35
+ notes: HiOutlineBookOpen,
36
+ user: HiOutlineFolder,
37
+ };
38
+
39
+ /** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
40
+ export function FolderTypeIcon({ type }: { type: FolderType | string }) {
41
+ const Icon = FOLDER_ICONS[type as FolderType] ?? HiOutlineFolder;
42
+ return <Icon size={18} aria-hidden="true" className="shrink-0 text-text-muted" />;
43
+ }
44
+
45
+ /** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
46
+ export function SharedMailboxMark() {
47
+ return (
48
+ <>
49
+ <HiUserGroup size={14} aria-hidden="true" className="inline-block ml-1.5 align-[-2px] shrink-0 text-text-muted" />
50
+ {" "}
51
+ <span className="sr-only">(shared)</span>
52
+ </>
53
+ );
54
+ }
@@ -3,7 +3,22 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
- import { HiOutlineBars3 } from "react-icons/hi2";
6
+ import type { IconType } from "react-icons";
7
+ import {
8
+ HiOutlineArrowUturnLeft,
9
+ HiOutlineBars3,
10
+ HiOutlineEnvelopeOpen,
11
+ HiOutlineFunnel,
12
+ HiOutlineLockClosed,
13
+ HiOutlineNoSymbol,
14
+ HiOutlinePaintBrush,
15
+ HiOutlinePencilSquare,
16
+ HiOutlinePuzzlePiece,
17
+ HiOutlineShieldCheck,
18
+ HiOutlineTag,
19
+ HiOutlineUserCircle,
20
+ HiOutlineUsers,
21
+ } from "react-icons/hi2";
7
22
  import { ApiRequestError } from "../../../../../lib/util/api.js";
8
23
  import Drawer from "../../../../../lib/components/overlays/Drawer.js";
9
24
  import { Mailbox, listMailboxes } from "../../../../../lib/mail/mailApi.js";
@@ -20,21 +35,23 @@ export interface SettingsSectionDef {
20
35
  id: string;
21
36
  href: string;
22
37
  label: string;
38
+ /** Drawn beside the label in the sidebar. */
39
+ icon: IconType;
23
40
  }
24
41
 
25
42
  /** Every settings section with its own sidebar entry. */
26
43
  export const SETTINGS_SECTIONS: SettingsSectionDef[] = [
27
- { id: "profile", href: "/settings/profile", label: "Profile" },
28
- { id: "appearance", href: "/settings/appearance", label: "Appearance" },
29
- { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
30
- { id: "filters", href: "/settings/filters", label: "Mail Filters" },
31
- { id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders" },
32
- { id: "signatures", href: "/settings/signatures", label: "Signatures" },
33
- { id: "labels", href: "/settings/labels", label: "Labels" },
34
- { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
35
- { id: "encryption", href: "/settings/encryption", label: "Encryption" },
36
- { id: "sharing", href: "/settings/sharing", label: "Sharing" },
37
- { id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
44
+ { id: "profile", href: "/settings/profile", label: "Profile", icon: HiOutlineUserCircle },
45
+ { id: "appearance", href: "/settings/appearance", label: "Appearance", icon: HiOutlinePaintBrush },
46
+ { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies", icon: HiOutlineArrowUturnLeft },
47
+ { id: "filters", href: "/settings/filters", label: "Mail Filters", icon: HiOutlineFunnel },
48
+ { id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders", icon: HiOutlineNoSymbol },
49
+ { id: "signatures", href: "/settings/signatures", label: "Signatures", icon: HiOutlinePencilSquare },
50
+ { id: "labels", href: "/settings/labels", label: "Labels", icon: HiOutlineTag },
51
+ { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts", icon: HiOutlineEnvelopeOpen },
52
+ { id: "encryption", href: "/settings/encryption", label: "Encryption", icon: HiOutlineLockClosed },
53
+ { id: "sharing", href: "/settings/sharing", label: "Sharing", icon: HiOutlineUsers },
54
+ { id: "privacy", href: "/settings/privacy", label: "Privacy & Data", icon: HiOutlineShieldCheck },
38
55
  ];
39
56
 
40
57
  /** A `SETTINGS_SECTIONS` id, or a plugin's `settingsSections` item id (see `PluginNav`). */
@@ -47,6 +64,7 @@ export function settingsSections(pluginNav?: PluginNav): SettingsSectionDef[] {
47
64
  id,
48
65
  href,
49
66
  label,
67
+ icon: HiOutlinePuzzlePiece,
50
68
  }));
51
69
  }
52
70
 
@@ -207,10 +225,11 @@ export default function SettingsShell({
207
225
  href={`${section.href}?mailboxUid=${encodeURIComponent(mailboxUid!)}`}
208
226
  aria-current={section.id === active ? "page" : undefined}
209
227
  className={[
210
- "block px-2.5 py-1.5 rounded-sm text-sm",
228
+ "flex items-center gap-2.5 px-2.5 py-1.5 rounded-sm text-sm",
211
229
  section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
212
230
  ].join(" ")}
213
231
  >
232
+ <section.icon size={18} aria-hidden="true" className="shrink-0" />
214
233
  {section.label}
215
234
  </a>
216
235
  ))}
@@ -12,10 +12,10 @@ export const APP_HREFS = {
12
12
  } as const;
13
13
 
14
14
  /**
15
- * Settings is reached from the account menu, not the rail. It links straight to the first settings section, the only one that always
16
- * exists - repoint this at a real `/settings` landing page once there is one.
15
+ * Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
16
+ * always exists - repoint this at a real `/settings` landing page once there is one.
17
17
  */
18
- export const SETTINGS_HREF = "/settings/auto-reply";
18
+ export const SETTINGS_HREF = "/settings/profile";
19
19
 
20
20
  /** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
21
21
  export function isSettingsPath(pathname: string): boolean {
@@ -43,6 +43,7 @@ import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
43
43
  import Alert from "../../../lib/components/feedback/Alert.js";
44
44
  import Button from "../../../lib/components/buttons/Button.js";
45
45
  import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
46
+ import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
46
47
  import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
47
48
  import { useEnterSlide } from "../../shared/gestures/useEnterSlide.js";
48
49
  import { useWheelPaging, type WheelEdge } from "../../shared/gestures/useWheelPaging.js";
@@ -357,10 +358,12 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
357
358
  if (!mailboxUid || !folderUid) {
358
359
  return;
359
360
  }
361
+ // With nothing clicked, the next half hour, for an hour.
362
+ const from = start ?? nextHalfHour(new Date());
360
363
  setModal({
361
364
  occurrence: null,
362
- initialStart: start ?? new Date(),
363
- initialEnd: end ?? new Date(Date.now() + 30 * 60_000),
365
+ initialStart: from,
366
+ initialEnd: end ?? new Date(from.getTime() + 60 * 60_000),
364
367
  targetFolderUid,
365
368
  anchor,
366
369
  initialAllDay,
@@ -5,7 +5,8 @@
5
5
  import { pageTitle } from "../../../shared/navigation/pageTitle.js";
6
6
  import React, { FormEvent, useMemo, useState } from "react";
7
7
  import { FreeBusyVisibility, freeBusyVisibilityOf, isSharedWithMe, updateMailbox } from "../../../../lib/mail/mailApi.js";
8
- import { DEFAULT_TIME_ZONE, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
8
+ import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
9
+ import { DEFAULT_TIME_ZONE, describeTimeZone, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
9
10
  import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
10
11
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
11
12
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -169,22 +170,17 @@ function ProfileContent() {
169
170
 
170
171
  <FormField label="Time zone" htmlFor="profile-time-zone">
171
172
  <div className="flex flex-wrap items-center gap-2">
172
- <select
173
+ <TimeZonePicker
173
174
  id="profile-time-zone"
174
175
  className={SELECT_CLASS}
175
176
  value={timezone}
176
- onChange={(e) => {
177
- setTimezone(e.target.value);
177
+ zones={zones}
178
+ onChange={(zone) => {
179
+ setTimezone(zone);
178
180
  setSaved(false);
179
181
  }}
180
182
  aria-describedby="profile-time-zone-help"
181
- >
182
- {zones.map((zone) => (
183
- <option key={zone} value={zone}>
184
- {zone}
185
- </option>
186
- ))}
187
- </select>
183
+ />
188
184
  {timezone !== deviceZone && (
189
185
  <Button
190
186
  type="button"
@@ -195,7 +191,7 @@ function ProfileContent() {
195
191
  setSaved(false);
196
192
  }}
197
193
  >
198
- Use this device&rsquo;s time zone ({deviceZone})
194
+ Use this device&rsquo;s time zone ({describeTimeZone(deviceZone)})
199
195
  </Button>
200
196
  )}
201
197
  </div>
@@ -13,6 +13,7 @@ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/Resour
13
13
  import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
14
14
  import Alert from "../../../lib/components/feedback/Alert.js";
15
15
  import Button from "../../../lib/components/buttons/Button.js";
16
+ import { describeTimeZone } from "../../../lib/util/timeZone.js";
16
17
  import Modal from "../../../lib/components/overlays/Modal.js";
17
18
  function formatBytes(bytes) {
18
19
  if (bytes >= 1000000000)
@@ -107,7 +108,7 @@ function MailboxDetailContent({ uid, impersonationBaseUrl, currentUserUid, }) {
107
108
  const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
108
109
  return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All mailboxes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: mailbox.primarySmtpAddress })] }), _jsxs("div", { className: "flex gap-3 shrink-0", children: [mailbox.ownerUserUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingAccess(true), children: "Impersonate this user" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete mailbox" })] })] }), _jsxs("p", { className: "text-sm text-text-muted", children: ["The console shows administrative details only - never a mailbox\u2019s mail or settings.", mailbox.ownerUserUid
109
110
  ? " To see this mailbox as its owner sees it, impersonate them: that is recorded, and you can stop at any time."
110
- : " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _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: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
111
+ : " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _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: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: describeTimeZone(mailbox.timezone) }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
111
112
  setEraseData(e.target.checked);
112
113
  setTypedAddress("");
113
114
  } }), _jsx("span", { children: "Also erase all of its data now (permanent)" })] }), eraseData && (_jsxs("div", { className: "mb-3", children: [_jsx("p", { className: "text-sm font-semibold text-danger mb-2", children: "Everything in this mailbox will be permanently erased, and this cannot be undone." }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsxs("span", { className: "font-semibold", children: ["Type ", _jsx("span", { className: "break-all", children: mailbox.primarySmtpAddress }), " to confirm"] }), _jsx("input", { "aria-label": "Type the address to confirm", className: "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary", value: typedAddress, disabled: deleting, autoComplete: "off", spellCheck: false, onChange: (e) => setTypedAddress(e.target.value) })] })] })), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting || (eraseData && typedAddress.trim().toLowerCase() !== mailbox.primarySmtpAddress.trim().toLowerCase()), onClick: handleDelete, children: eraseData ? "Delete and erase all its data" : "Delete" })] })] })] }));