@rapidmx/web-client 0.2.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 (168) hide show
  1. package/LICENSE +382 -0
  2. package/apps/admin/_404.tsx +9 -0
  3. package/apps/admin/_500.tsx +9 -0
  4. package/apps/admin/_layout.tsx +41 -0
  5. package/apps/admin/audit-log/index.tsx +187 -0
  6. package/apps/admin/branding/index.tsx +404 -0
  7. package/apps/admin/distribution-lists/[uid].tsx +70 -0
  8. package/apps/admin/distribution-lists/index.tsx +115 -0
  9. package/apps/admin/distribution-lists/new/index.tsx +114 -0
  10. package/apps/admin/domains/[uid].tsx +259 -0
  11. package/apps/admin/domains/index.tsx +125 -0
  12. package/apps/admin/domains/new/index.tsx +84 -0
  13. package/apps/admin/index.tsx +82 -0
  14. package/apps/admin/ingest-queue/index.tsx +116 -0
  15. package/apps/admin/mailboxes/[uid].tsx +126 -0
  16. package/apps/admin/mailboxes/new/index.tsx +291 -0
  17. package/apps/admin/quarantine/index.tsx +171 -0
  18. package/apps/admin/transport-rules/[uid].tsx +143 -0
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
  20. package/apps/admin/transport-rules/index.tsx +117 -0
  21. package/apps/admin/transport-rules/new/index.tsx +105 -0
  22. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
  23. package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
  24. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
  25. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
  26. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
  27. package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
  29. package/apps/shared/components/calendar/EventModal.tsx +513 -0
  30. package/apps/shared/components/calendar/MonthView.tsx +139 -0
  31. package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
  32. package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
  33. package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
  34. package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
  35. package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
  36. package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
  37. package/apps/shared/components/contacts/ContactForm.tsx +314 -0
  38. package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
  40. package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
  41. package/apps/shared/components/layout/AppShell.tsx +165 -0
  42. package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
  44. package/apps/shared/components/layout/UserMenu.tsx +150 -0
  45. package/apps/shared/components/mail/ConversationList.tsx +61 -0
  46. package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
  49. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
  50. package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
  51. package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
  52. package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
  53. package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
  54. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
  55. package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
  56. package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
  57. package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
  58. package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
  59. package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
  60. package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
  61. package/apps/shared/styles/app.css +265 -0
  62. package/apps/www/_404.tsx +9 -0
  63. package/apps/www/_500.tsx +9 -0
  64. package/apps/www/_layout.tsx +45 -0
  65. package/apps/www/calendar/index.tsx +407 -0
  66. package/apps/www/contacts/[uid].tsx +88 -0
  67. package/apps/www/contacts/index.tsx +436 -0
  68. package/apps/www/index.tsx +375 -0
  69. package/apps/www/messages/[uid].tsx +70 -0
  70. package/apps/www/settings/auto-reply/index.tsx +129 -0
  71. package/apps/www/settings/booking-types/[uid].tsx +300 -0
  72. package/apps/www/settings/booking-types/index.tsx +98 -0
  73. package/apps/www/settings/booking-types/new/index.tsx +212 -0
  74. package/apps/www/settings/filters/[uid].tsx +155 -0
  75. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
  76. package/apps/www/settings/filters/index.tsx +99 -0
  77. package/apps/www/settings/filters/new/index.tsx +126 -0
  78. package/apps/www/settings/focused-inbox/index.tsx +148 -0
  79. package/apps/www/settings/read-receipts/index.tsx +125 -0
  80. package/apps/www/settings/signatures/[uid].tsx +167 -0
  81. package/apps/www/settings/signatures/index.tsx +85 -0
  82. package/apps/www/settings/signatures/new/index.tsx +129 -0
  83. package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
  84. package/apps/www/tasks/index.tsx +530 -0
  85. package/dist/apps/admin/_404.js +4 -0
  86. package/dist/apps/admin/_500.js +4 -0
  87. package/dist/apps/admin/_layout.js +10 -0
  88. package/dist/apps/admin/audit-log/index.js +66 -0
  89. package/dist/apps/admin/branding/index.js +126 -0
  90. package/dist/apps/admin/distribution-lists/[uid].js +34 -0
  91. package/dist/apps/admin/distribution-lists/index.js +31 -0
  92. package/dist/apps/admin/distribution-lists/new/index.js +51 -0
  93. package/dist/apps/admin/domains/[uid].js +102 -0
  94. package/dist/apps/admin/domains/index.js +31 -0
  95. package/dist/apps/admin/domains/new/index.js +41 -0
  96. package/dist/apps/admin/index.js +32 -0
  97. package/dist/apps/admin/ingest-queue/index.js +48 -0
  98. package/dist/apps/admin/mailboxes/[uid].js +59 -0
  99. package/dist/apps/admin/mailboxes/new/index.js +93 -0
  100. package/dist/apps/admin/quarantine/index.js +66 -0
  101. package/dist/apps/admin/transport-rules/[uid].js +87 -0
  102. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
  103. package/dist/apps/admin/transport-rules/index.js +32 -0
  104. package/dist/apps/admin/transport-rules/new/index.js +50 -0
  105. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
  106. package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
  107. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
  108. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
  109. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
  110. package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
  111. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
  112. package/dist/apps/shared/components/calendar/EventModal.js +167 -0
  113. package/dist/apps/shared/components/calendar/MonthView.js +38 -0
  114. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
  115. package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
  116. package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
  117. package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
  118. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
  119. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
  120. package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
  121. package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
  122. package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
  123. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
  124. package/dist/apps/shared/components/layout/AppShell.js +68 -0
  125. package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
  126. package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
  127. package/dist/apps/shared/components/layout/UserMenu.js +59 -0
  128. package/dist/apps/shared/components/mail/ConversationList.js +17 -0
  129. package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
  130. package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
  131. package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
  132. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
  133. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
  134. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
  135. package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
  136. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
  137. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
  138. package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
  139. package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
  140. package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
  141. package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
  142. package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
  143. package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
  144. package/dist/apps/shared/styles/app.css +265 -0
  145. package/dist/apps/www/_404.js +4 -0
  146. package/dist/apps/www/_500.js +4 -0
  147. package/dist/apps/www/_layout.js +8 -0
  148. package/dist/apps/www/calendar/index.js +228 -0
  149. package/dist/apps/www/contacts/[uid].js +62 -0
  150. package/dist/apps/www/contacts/index.js +262 -0
  151. package/dist/apps/www/index.js +228 -0
  152. package/dist/apps/www/messages/[uid].js +47 -0
  153. package/dist/apps/www/settings/auto-reply/index.js +54 -0
  154. package/dist/apps/www/settings/booking-types/[uid].js +129 -0
  155. package/dist/apps/www/settings/booking-types/index.js +29 -0
  156. package/dist/apps/www/settings/booking-types/new/index.js +86 -0
  157. package/dist/apps/www/settings/filters/[uid].js +94 -0
  158. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
  159. package/dist/apps/www/settings/filters/index.js +30 -0
  160. package/dist/apps/www/settings/filters/new/index.js +61 -0
  161. package/dist/apps/www/settings/focused-inbox/index.js +69 -0
  162. package/dist/apps/www/settings/read-receipts/index.js +51 -0
  163. package/dist/apps/www/settings/signatures/[uid].js +96 -0
  164. package/dist/apps/www/settings/signatures/index.js +34 -0
  165. package/dist/apps/www/settings/signatures/new/index.js +63 -0
  166. package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
  167. package/dist/apps/www/tasks/index.js +280 -0
  168. package/package.json +87 -0
@@ -0,0 +1,116 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { IngestQueueEntry, listIngestQueue } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+
11
+ export function readMailboxUid(): string | null {
12
+ if (typeof window === "undefined") return null;
13
+ return new URLSearchParams(window.location.search).get("mailboxUid");
14
+ }
15
+
16
+ const STATUS_STYLES: Record<IngestQueueEntry["status"], string> = {
17
+ pending: "bg-surface-alt text-text-muted",
18
+ scanning: "bg-surface-alt text-text-muted",
19
+ delivered: "bg-success text-white",
20
+ failed: "bg-danger text-white",
21
+ };
22
+
23
+ export default function IngestQueuePage(props: Omit<AdminShellProps, "active">) {
24
+ return (
25
+ <AdminShell {...props} active="ingestQueue">
26
+ <IngestQueueContent />
27
+ </AdminShell>
28
+ );
29
+ }
30
+
31
+ function IngestQueueContent() {
32
+ const [mailboxUid, setMailboxUid] = useState<string | null>(null);
33
+ const [entries, setEntries] = useState<IngestQueueEntry[]>([]);
34
+ const [loading, setLoading] = useState(true);
35
+ const [error, setError] = useState<string | null>(null);
36
+
37
+ useEffect(() => {
38
+ setMailboxUid(readMailboxUid());
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ if (!mailboxUid) {
43
+ return;
44
+ }
45
+ setLoading(true);
46
+ setError(null);
47
+ listIngestQueue(mailboxUid)
48
+ .then(setEntries)
49
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load the ingest queue."))
50
+ .finally(() => setLoading(false));
51
+ }, [mailboxUid]);
52
+
53
+ if (!mailboxUid) {
54
+ return <Alert>No mailbox specified. Open a mailbox's detail page and choose "View ingest queue".</Alert>;
55
+ }
56
+
57
+ return (
58
+ <div className="max-w-4xl flex flex-col gap-4">
59
+ <div>
60
+ <a
61
+ href={`/admin/mailboxes/${encodeURIComponent(mailboxUid)}`}
62
+ className="text-sm text-primary-dark hover:underline"
63
+ >
64
+ &larr; Back to mailbox
65
+ </a>
66
+ <h1 className="text-xl font-bold uppercase tracking-wide mt-1">Ingest queue</h1>
67
+ </div>
68
+
69
+ {error && <Alert>{error}</Alert>}
70
+
71
+ {loading ? (
72
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
73
+ ) : entries.length === 0 ? (
74
+ <p className="text-sm text-text-muted">Nothing pending or failed for this mailbox.</p>
75
+ ) : (
76
+ <div className="overflow-x-auto">
77
+ <table className="w-full text-sm border-collapse">
78
+ <thead>
79
+ <tr>
80
+ {["From", "To", "Received", "Status", "Error"].map((h) => (
81
+ <th
82
+ key={h}
83
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
84
+ >
85
+ {h}
86
+ </th>
87
+ ))}
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ {entries.map((entry) => (
92
+ <tr key={entry.uid}>
93
+ <td className="py-2.5 px-2.5 border-b border-border">{entry.envelopeFrom}</td>
94
+ <td className="py-2.5 px-2.5 border-b border-border">{entry.envelopeTo.join(", ")}</td>
95
+ <td className="py-2.5 px-2.5 border-b border-border">
96
+ {new Date(entry.dateCreated).toLocaleString()}
97
+ </td>
98
+ <td className="py-2.5 px-2.5 border-b border-border">
99
+ <span
100
+ className={`inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${STATUS_STYLES[entry.status]}`}
101
+ >
102
+ {entry.status}
103
+ </span>
104
+ </td>
105
+ <td className="py-2.5 px-2.5 border-b border-border text-text-muted">
106
+ {entry.errorMessage ?? ""}
107
+ </td>
108
+ </tr>
109
+ ))}
110
+ </tbody>
111
+ </table>
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,126 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { getMailbox, impersonateUser, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
+ import ShareAccessCard from "../../shared/components/admin/mailboxes/ShareAccessCard.js";
10
+ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/ResourceSettingsCard.js";
11
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
+
14
+ function formatBytes(bytes: number): string {
15
+ if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
16
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
17
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
18
+ return `${bytes} B`;
19
+ }
20
+
21
+ export default function MailboxDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
22
+ return (
23
+ <AdminShell {...props} active="mailboxes">
24
+ <MailboxDetailContent uid={props.params.uid} impersonationBaseUrl={props.impersonationBaseUrl} />
25
+ </AdminShell>
26
+ );
27
+ }
28
+
29
+ function MailboxDetailContent({ uid, impersonationBaseUrl }: { uid: string } & Pick<AdminShellProps, "impersonationBaseUrl">) {
30
+ const [mailbox, setMailbox] = useState<Mailbox | null>(null);
31
+ const [loading, setLoading] = useState(true);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [impersonating, setImpersonating] = useState(false);
34
+
35
+ async function handleAccessMailbox(ownerUserUid: string) {
36
+ setImpersonating(true);
37
+ try {
38
+ await impersonateUser(impersonationBaseUrl ?? "", ownerUserUid);
39
+ window.location.href = "/";
40
+ } catch (err) {
41
+ setError(err instanceof ApiRequestError ? err.message : "Could not access this mailbox.");
42
+ setImpersonating(false);
43
+ }
44
+ }
45
+
46
+ useEffect(() => {
47
+ setLoading(true);
48
+ setError(null);
49
+ getMailbox(uid)
50
+ .then(setMailbox)
51
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox."))
52
+ .finally(() => setLoading(false));
53
+ }, [uid]);
54
+
55
+ if (loading) {
56
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
57
+ }
58
+ if (error || !mailbox) {
59
+ return <Alert>{error ?? "Mailbox not found."}</Alert>;
60
+ }
61
+
62
+ const quarantineHref = `/admin/quarantine?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
63
+ const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
64
+
65
+ return (
66
+ <div className="max-w-3xl flex flex-col gap-5">
67
+ <div className="flex items-start justify-between gap-4">
68
+ <div>
69
+ <a href="/admin" className="text-sm text-primary-dark hover:underline">
70
+ &larr; All mailboxes
71
+ </a>
72
+ <h1 className="text-xl font-bold tracking-tight mt-1">{mailbox.primarySmtpAddress}</h1>
73
+ </div>
74
+ {mailbox.ownerUserUid && (
75
+ <Button
76
+ type="button"
77
+ variant="secondary"
78
+ className="!w-auto shrink-0"
79
+ loading={impersonating}
80
+ disabled={impersonating}
81
+ onClick={() => handleAccessMailbox(mailbox.ownerUserUid as string)}
82
+ >
83
+ Access this mailbox
84
+ </Button>
85
+ )}
86
+ </div>
87
+
88
+ <div className="bg-surface border border-border rounded-md p-6">
89
+ <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
90
+ <dt className="text-text-muted">Display name</dt>
91
+ <dd>{mailbox.displayName}</dd>
92
+ <dt className="text-text-muted">Owner</dt>
93
+ <dd>{mailbox.ownerUserUid ?? "None (shared mailbox)"}</dd>
94
+ <dt className="text-text-muted">Timezone</dt>
95
+ <dd>{mailbox.timezone}</dd>
96
+ <dt className="text-text-muted">Quota</dt>
97
+ <dd>
98
+ {formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
99
+ </dd>
100
+ <dt className="text-text-muted">Alias addresses</dt>
101
+ <dd>{mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None"}</dd>
102
+ {mailbox.isResource && (
103
+ <>
104
+ <dt className="text-text-muted">Resource type</dt>
105
+ <dd className="capitalize">{mailbox.resourceType ?? "room"}</dd>
106
+ </>
107
+ )}
108
+ <dt className="text-text-muted">Created</dt>
109
+ <dd>{new Date(mailbox.dateCreated).toLocaleString()}</dd>
110
+ </dl>
111
+ <div className="flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium">
112
+ <a href={quarantineHref} className="text-primary-dark hover:underline">
113
+ View quarantine
114
+ </a>
115
+ <a href={ingestQueueHref} className="text-primary-dark hover:underline">
116
+ View ingest queue
117
+ </a>
118
+ </div>
119
+ </div>
120
+
121
+ <ShareAccessCard mailboxUid={mailbox.uid} />
122
+
123
+ {mailbox.isResource && <ResourceSettingsCard mailbox={mailbox} onUpdate={setMailbox} />}
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,291 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { createMailbox, listMailboxDomains } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
+
13
+ const INPUT_CLASS =
14
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
+ const SELECT_CLASS =
16
+ "text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
+
18
+ export default function NewMailboxPage(props: Omit<AdminShellProps, "active">) {
19
+ return (
20
+ <AdminShell {...props} active="mailboxes">
21
+ <NewMailboxForm />
22
+ </AdminShell>
23
+ );
24
+ }
25
+
26
+ function NewMailboxForm() {
27
+ const [primarySmtpAddress, setPrimarySmtpAddress] = useState("");
28
+ const [localPart, setLocalPart] = useState("");
29
+ const [domains, setDomains] = useState<string[]>([]);
30
+ const [domain, setDomain] = useState("");
31
+ const [displayName, setDisplayName] = useState("");
32
+ const [ownerUserUid, setOwnerUserUid] = useState("");
33
+ const [timezone, setTimezone] = useState("UTC");
34
+ const [quotaGb, setQuotaGb] = useState(5);
35
+ const [isResource, setIsResource] = useState(false);
36
+ const [resourceType, setResourceType] = useState<"room" | "equipment">("room");
37
+ const [resourceCapacity, setResourceCapacity] = useState("");
38
+ const [autoAcceptBookings, setAutoAcceptBookings] = useState(false);
39
+ const [allowConflicts, setAllowConflicts] = useState(false);
40
+ const [bookingWindowDays, setBookingWindowDays] = useState("");
41
+ const [maxDurationMinutes, setMaxDurationMinutes] = useState("");
42
+ const [error, setError] = useState<string | null>(null);
43
+ const [saving, setSaving] = useState(false);
44
+
45
+ // An empty `mail:domains` list (the default) means this server enforces no domain restriction —
46
+ // the address field stays free text, exactly as before. Once an admin configures at least one
47
+ // domain, every mailbox this server creates must be on one of them (enforced server-side too, in
48
+ // `BaseMailboxRoute.create()`), so the form switches to a local-part input + domain dropdown to
49
+ // make that restriction visible rather than let an admin discover it only via a rejected submit.
50
+ useEffect(() => {
51
+ listMailboxDomains()
52
+ .then((list) => {
53
+ setDomains(list);
54
+ setDomain(list[0] ?? "");
55
+ })
56
+ .catch(() => undefined);
57
+ }, []);
58
+
59
+ const constrained = domains.length > 0;
60
+
61
+ async function handleSubmit(e: FormEvent) {
62
+ e.preventDefault();
63
+ setError(null);
64
+
65
+ const address = constrained ? `${localPart.trim()}@${domain}` : primarySmtpAddress.trim();
66
+ if (constrained ? !localPart.trim() : !primarySmtpAddress.trim()) {
67
+ setError("A primary SMTP address is required.");
68
+ return;
69
+ }
70
+ if (!displayName.trim()) {
71
+ setError("A display name is required.");
72
+ return;
73
+ }
74
+
75
+ setSaving(true);
76
+ try {
77
+ // ownerUserUid left blank creates a true ownerless shared mailbox (e.g. support@example.com);
78
+ // delegates are then granted access from the mailbox's detail page. A resource mailbox is
79
+ // ownerless the same way — leave "Owner user uid" blank for one too.
80
+ const mailbox = await createMailbox({
81
+ primarySmtpAddress: address,
82
+ displayName: displayName.trim(),
83
+ ownerUserUid: ownerUserUid.trim() || undefined,
84
+ timezone,
85
+ quotaBytes: Math.round(quotaGb * 1_000_000_000),
86
+ ...(isResource && {
87
+ isResource: true,
88
+ resourceType,
89
+ resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
90
+ autoAcceptBookings,
91
+ allowConflicts,
92
+ bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
93
+ maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
94
+ }),
95
+ });
96
+ window.location.href = `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`;
97
+ } catch (err) {
98
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the mailbox.");
99
+ } finally {
100
+ setSaving(false);
101
+ }
102
+ }
103
+
104
+ return (
105
+ <div className="max-w-xl">
106
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">New mailbox</h1>
107
+ <p className="text-sm text-text-muted mb-5">
108
+ Leave "Owner user uid" blank to create a true ownerless shared mailbox (the Exchange "shared
109
+ mailbox" concept) — access is then granted entirely to delegates afterward, not to a single
110
+ owner. Set an owner to create a mailbox for a specific user instead.
111
+ </p>
112
+
113
+ {error && <Alert>{error}</Alert>}
114
+
115
+ <form onSubmit={handleSubmit} className="bg-surface border border-border rounded-md p-6">
116
+ {constrained ? (
117
+ <FormField label="Local part" htmlFor="localPart">
118
+ <div className="flex gap-2 items-center">
119
+ <input
120
+ id="localPart"
121
+ type="text"
122
+ className={INPUT_CLASS}
123
+ value={localPart}
124
+ onChange={(e) => setLocalPart(e.target.value)}
125
+ placeholder="support"
126
+ />
127
+ <span className="text-text-muted shrink-0">@</span>
128
+ <select
129
+ aria-label="Domain"
130
+ className={SELECT_CLASS}
131
+ value={domain}
132
+ onChange={(e) => setDomain(e.target.value)}
133
+ >
134
+ {domains.map((d) => (
135
+ <option key={d} value={d}>
136
+ {d}
137
+ </option>
138
+ ))}
139
+ </select>
140
+ </div>
141
+ </FormField>
142
+ ) : (
143
+ <FormField label="Primary SMTP address" htmlFor="primarySmtpAddress">
144
+ <input
145
+ id="primarySmtpAddress"
146
+ type="email"
147
+ className={INPUT_CLASS}
148
+ value={primarySmtpAddress}
149
+ onChange={(e) => setPrimarySmtpAddress(e.target.value)}
150
+ placeholder="support@example.com"
151
+ />
152
+ </FormField>
153
+ )}
154
+
155
+ <FormField label="Display name" htmlFor="displayName">
156
+ <input
157
+ id="displayName"
158
+ type="text"
159
+ className={INPUT_CLASS}
160
+ value={displayName}
161
+ onChange={(e) => setDisplayName(e.target.value)}
162
+ placeholder="Support"
163
+ />
164
+ </FormField>
165
+
166
+ <FormField label="Owner user uid (optional)" htmlFor="ownerUserUid">
167
+ <input
168
+ id="ownerUserUid"
169
+ type="text"
170
+ className={INPUT_CLASS}
171
+ value={ownerUserUid}
172
+ onChange={(e) => setOwnerUserUid(e.target.value)}
173
+ placeholder="Leave blank for a shared mailbox"
174
+ />
175
+ </FormField>
176
+
177
+ <FormField label="Timezone" htmlFor="timezone">
178
+ <input
179
+ id="timezone"
180
+ type="text"
181
+ className={INPUT_CLASS}
182
+ value={timezone}
183
+ onChange={(e) => setTimezone(e.target.value)}
184
+ placeholder="America/Los_Angeles"
185
+ />
186
+ </FormField>
187
+
188
+ <FormField label="Quota (GB)" htmlFor="quotaGb">
189
+ <input
190
+ id="quotaGb"
191
+ type="number"
192
+ min={1}
193
+ step={1}
194
+ className={INPUT_CLASS}
195
+ value={quotaGb}
196
+ onChange={(e) => setQuotaGb(Number(e.target.value))}
197
+ />
198
+ </FormField>
199
+
200
+ <label className="flex items-center gap-2 text-sm mb-4">
201
+ <input type="checkbox" checked={isResource} onChange={(e) => setIsResource(e.target.checked)} />
202
+ This is a resource mailbox (a bookable room or piece of equipment)
203
+ </label>
204
+
205
+ {isResource && (
206
+ <div className="border border-border rounded-sm p-4 mb-4 flex flex-col gap-4">
207
+ <FormField label="Resource type" htmlFor="resourceType">
208
+ <select
209
+ id="resourceType"
210
+ className={SELECT_CLASS}
211
+ value={resourceType}
212
+ onChange={(e) => setResourceType(e.target.value as "room" | "equipment")}
213
+ >
214
+ <option value="room">Room</option>
215
+ <option value="equipment">Equipment</option>
216
+ </select>
217
+ </FormField>
218
+
219
+ <FormField label="Capacity (optional)" htmlFor="resourceCapacity">
220
+ <input
221
+ id="resourceCapacity"
222
+ type="number"
223
+ min={0}
224
+ step={1}
225
+ className={INPUT_CLASS}
226
+ value={resourceCapacity}
227
+ onChange={(e) => setResourceCapacity(e.target.value)}
228
+ placeholder="Informational only"
229
+ />
230
+ </FormField>
231
+
232
+ <label className="flex items-center gap-2 text-sm">
233
+ <input
234
+ type="checkbox"
235
+ checked={autoAcceptBookings}
236
+ onChange={(e) => setAutoAcceptBookings(e.target.checked)}
237
+ />
238
+ Automatically accept booking requests
239
+ </label>
240
+
241
+ <label className="flex items-center gap-2 text-sm">
242
+ <input
243
+ type="checkbox"
244
+ checked={allowConflicts}
245
+ onChange={(e) => setAllowConflicts(e.target.checked)}
246
+ />
247
+ Allow conflicting bookings (skip conflict checking entirely)
248
+ </label>
249
+
250
+ <FormField label="Booking window, in days (optional)" htmlFor="bookingWindowDays">
251
+ <input
252
+ id="bookingWindowDays"
253
+ type="number"
254
+ min={0}
255
+ step={1}
256
+ className={INPUT_CLASS}
257
+ value={bookingWindowDays}
258
+ onChange={(e) => setBookingWindowDays(e.target.value)}
259
+ placeholder="No limit"
260
+ />
261
+ </FormField>
262
+
263
+ <FormField label="Maximum duration, in minutes (optional)" htmlFor="maxDurationMinutes">
264
+ <input
265
+ id="maxDurationMinutes"
266
+ type="number"
267
+ min={0}
268
+ step={1}
269
+ className={INPUT_CLASS}
270
+ value={maxDurationMinutes}
271
+ onChange={(e) => setMaxDurationMinutes(e.target.value)}
272
+ placeholder="No limit"
273
+ />
274
+ </FormField>
275
+ </div>
276
+ )}
277
+
278
+ <div className="flex gap-3 mt-2">
279
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
280
+ Create mailbox
281
+ </Button>
282
+ <a href="/admin">
283
+ <Button type="button" variant="secondary" className="!w-auto">
284
+ Cancel
285
+ </Button>
286
+ </a>
287
+ </div>
288
+ </form>
289
+ </div>
290
+ );
291
+ }