@rapidmx/web-client 0.6.0 → 0.8.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 (79) hide show
  1. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  2. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  3. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -152
  4. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  5. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  6. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  7. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  8. package/apps/shared/components/layout/AppShell.tsx +288 -288
  9. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  11. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  12. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  13. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  14. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  15. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  20. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  21. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  22. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
  24. package/apps/shared/components/mail/listPreferences.ts +228 -0
  25. package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
  26. package/apps/shared/styles/app.css +28 -5
  27. package/apps/www/index.tsx +1854 -1314
  28. package/apps/www/messages/[uid].tsx +5 -4
  29. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
  30. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  31. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
  32. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  33. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +9 -2
  34. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +20 -4
  35. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
  36. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  37. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
  38. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  39. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  40. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  41. package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
  42. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  43. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  45. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  46. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  47. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  48. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  49. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  50. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  51. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
  52. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  53. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  54. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  55. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
  56. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  57. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  58. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  59. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  60. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
  61. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  62. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  63. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  64. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  65. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  66. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  67. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  68. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
  69. package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
  70. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  71. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  72. package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
  73. package/dist/apps/shared/styles/app.css +28 -5
  74. package/dist/apps/www/index.js +506 -112
  75. package/dist/apps/www/messages/[uid].js +2 -3
  76. package/package.json +2 -2
  77. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  78. package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
  79. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -0,0 +1,263 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useRef, useState } from "react";
6
+ import { HiOutlineFolder, HiOutlineFolderPlus } from "react-icons/hi2";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { Folder, createFolder } from "@rapidmx/react-shared/mail/mailApi.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 Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
12
+
13
+ /**
14
+ * The folder types a message can be moved *into* - Outbox is a transient send queue the server owns, and
15
+ * the non-mail folders (`calendar`/`contacts`/`tasks`/`notes`) aren't message folders at all, so neither is
16
+ * offered. Shared by the reading pane's Move to and select mode's bulk Move to, which is the whole point of
17
+ * this component: one destination list, one set of rules.
18
+ */
19
+ export const MOVE_TARGET_TYPES = new Set(["inbox", "drafts", "sent_items", "junk", "archive", "deleted_items", "user"]);
20
+
21
+ /** Above this many destinations the dialog offers a filter box - below it, one is just another thing to
22
+ * skip past on the way to a list that already fits on screen. */
23
+ export const FOLDER_FILTER_THRESHOLD = 8;
24
+
25
+ /** What `@rapidmx/restapi` accepts as a folder name, so a name it would refuse is refused here, next to the
26
+ * field, rather than as a 400 after a round trip. */
27
+ export const MAX_FOLDER_NAME_LENGTH = 255;
28
+
29
+ /**
30
+ * Why a name can't be used, or `null` when it can. Folders are a real tree (`Folder.parentFolderUid`), not
31
+ * paths, so a separator in a name would be a character in the name rather than a nesting instruction -
32
+ * which is exactly why it is refused: it would read as a hierarchy this app never created.
33
+ */
34
+ export function folderNameError(name: string, existing: Folder[]): string | null {
35
+ const trimmed = name.trim();
36
+ if (!trimmed) {
37
+ return "Enter a name for the new folder.";
38
+ }
39
+ if (trimmed.length > MAX_FOLDER_NAME_LENGTH) {
40
+ return `A folder name can be at most ${MAX_FOLDER_NAME_LENGTH} characters.`;
41
+ }
42
+ if (/[/\\]/.test(trimmed)) {
43
+ return "A folder name can't contain / or \\.";
44
+ }
45
+ const clash = existing.find((folder) => folder.name.toLowerCase() === trimmed.toLowerCase());
46
+ if (clash) {
47
+ return `This mailbox already has a folder called "${clash.name}". Pick it from the list instead.`;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ export interface MoveToFolderDialogProps {
53
+ open: boolean;
54
+ onClose: () => void;
55
+ /** The mailbox whose folders are offered, and the one a new folder is created in. */
56
+ mailboxUid: string;
57
+ /** Every folder of that mailbox - filtered to the movable types here, so callers hand over the list
58
+ * they already hold rather than pre-filtering it each time. */
59
+ folders: Folder[];
60
+ /** Where the message(s) are now: offered, but disabled, so the list doesn't change shape per folder. */
61
+ currentFolderUid?: string;
62
+ /** What is being moved, for the dialog's own title ("Move 3 messages to"). Defaults to one message. */
63
+ count?: number;
64
+ /**
65
+ * Performs the move, resolving once it has settled. A rejection is shown in this dialog, beside the
66
+ * destination that would retry it; the dialog closes on success. The bulk caller resolves either way
67
+ * and reports its own outcome in the selection bar, which is where a partly-applied bulk update has to
68
+ * be explained (see `runBulkAction()`).
69
+ */
70
+ onMove: (folderUid: string) => Promise<void>;
71
+ /** A folder this dialog created, so the caller's own folder list - and the sidebar - pick it up without
72
+ * a page load. Called before the move, so a failed move still leaves the folder that was created. */
73
+ onFolderCreated?: (folder: Folder) => void;
74
+ }
75
+
76
+ /**
77
+ * Outlook's "Move to" prompt: pick a destination folder for the open message or the selection, or create a
78
+ * folder and move into it in one step.
79
+ *
80
+ * A `Modal` rather than a `MenuButton` because creating a folder needs a text field, and a `role="menu"`
81
+ * has nowhere to put one (the same reason `NewLabelDialog` exists) - and because a destination list can be
82
+ * as long as the mailbox has folders, which wants a filter box rather than a scrolling popup.
83
+ *
84
+ * A new folder is created at the **top level of the mailbox**, typed `user`, never as a child of the folder
85
+ * being moved out of: this app's sidebar lists folders flat (`MailShell`'s own `FOLDER_ORDER`), so a
86
+ * subfolder would be created into a hierarchy nothing here renders.
87
+ */
88
+ export default function MoveToFolderDialog({
89
+ open,
90
+ onClose,
91
+ mailboxUid,
92
+ folders,
93
+ currentFolderUid,
94
+ count = 1,
95
+ onMove,
96
+ onFolderCreated,
97
+ }: MoveToFolderDialogProps) {
98
+ const [filter, setFilter] = useState("");
99
+ const [creating, setCreating] = useState(false);
100
+ const [newName, setNewName] = useState("");
101
+ const [busy, setBusy] = useState(false);
102
+ const [error, setError] = useState<string | null>(null);
103
+ const nameRef = useRef<HTMLInputElement | null>(null);
104
+
105
+ // Every opening starts from the same place, so a filter or a half-typed folder name from last time is
106
+ // never what the next move is made against.
107
+ useEffect(() => {
108
+ if (open) {
109
+ setFilter("");
110
+ setCreating(false);
111
+ setNewName("");
112
+ setBusy(false);
113
+ setError(null);
114
+ }
115
+ }, [open]);
116
+
117
+ // The field is only mounted once "New folder" is chosen, so focus moves to it when it appears.
118
+ useEffect(() => {
119
+ if (creating) {
120
+ nameRef.current?.focus();
121
+ }
122
+ }, [creating]);
123
+
124
+ const targets = folders.filter((folder) => MOVE_TARGET_TYPES.has(folder.type));
125
+ const shown = filter.trim()
126
+ ? targets.filter((folder) => folder.name.toLowerCase().includes(filter.trim().toLowerCase()))
127
+ : targets;
128
+ const offerFilter = targets.length > FOLDER_FILTER_THRESHOLD;
129
+ const noun = count === 1 ? "message" : `${count} messages`;
130
+
131
+ async function moveTo(folderUid: string) {
132
+ setBusy(true);
133
+ setError(null);
134
+ try {
135
+ await onMove(folderUid);
136
+ onClose();
137
+ } catch (err) {
138
+ setError(err instanceof ApiRequestError ? err.message : "Could not move to that folder.");
139
+ } finally {
140
+ setBusy(false);
141
+ }
142
+ }
143
+
144
+ async function handleCreate(e: FormEvent) {
145
+ e.preventDefault();
146
+ const invalid = folderNameError(newName, folders);
147
+ if (invalid) {
148
+ setError(invalid);
149
+ return;
150
+ }
151
+ setBusy(true);
152
+ setError(null);
153
+ let created: Folder;
154
+ try {
155
+ created = await createFolder({ mailboxUid, name: newName.trim(), type: "user" });
156
+ } catch (err) {
157
+ setError(err instanceof ApiRequestError ? err.message : "Could not create that folder.");
158
+ setBusy(false);
159
+ return;
160
+ }
161
+ // Handed over before the move: the folder exists from here on whether or not the move lands, and
162
+ // the reader would otherwise have to create it again to retry.
163
+ onFolderCreated?.(created);
164
+ setBusy(false);
165
+ await moveTo(created.uid);
166
+ }
167
+
168
+ return (
169
+ <Modal open={open} onClose={() => !busy && onClose()} title={`Move ${noun} to`}>
170
+ {offerFilter && !creating && (
171
+ <input
172
+ type="search"
173
+ value={filter}
174
+ onChange={(e) => setFilter(e.target.value)}
175
+ aria-label="Filter folders"
176
+ placeholder="Filter folders…"
177
+ className="w-full text-sm px-3 py-1.5 mb-2 rounded-md border border-border bg-surface"
178
+ />
179
+ )}
180
+ {!creating && (
181
+ <ul className="max-h-72 overflow-y-auto border border-border rounded-md divide-y divide-border">
182
+ {shown.map((folder) => {
183
+ const here = folder.uid === currentFolderUid;
184
+ return (
185
+ <li key={folder.uid}>
186
+ <button
187
+ type="button"
188
+ onClick={() => void moveTo(folder.uid)}
189
+ disabled={busy || here}
190
+ // Disabled rather than hidden, so the list doesn't change shape as the
191
+ // reader moves between folders.
192
+ title={here ? `Already in ${folder.name}` : undefined}
193
+ className="w-full text-left flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent"
194
+ >
195
+ <HiOutlineFolder size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
196
+ <span className="truncate">{folder.name}</span>
197
+ {here && <span className="ml-auto text-xs text-text-muted shrink-0">Already here</span>}
198
+ </button>
199
+ </li>
200
+ );
201
+ })}
202
+ {shown.length === 0 && (
203
+ <li className="px-3 py-2 text-sm text-text-muted">
204
+ {targets.length === 0 ? "This mailbox has no folders to move to yet." : "No folder matches that."}
205
+ </li>
206
+ )}
207
+ </ul>
208
+ )}
209
+ {creating ? (
210
+ <form onSubmit={handleCreate}>
211
+ <label className="block text-sm font-medium mb-1" htmlFor="move-to-new-folder">
212
+ New folder name
213
+ </label>
214
+ <input
215
+ id="move-to-new-folder"
216
+ ref={nameRef}
217
+ value={newName}
218
+ onChange={(e) => setNewName(e.target.value)}
219
+ maxLength={MAX_FOLDER_NAME_LENGTH}
220
+ className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface"
221
+ />
222
+ <p className="text-xs text-text-muted mt-1">
223
+ Created at the top level of this mailbox, and the {count === 1 ? "message is" : "messages are"} moved
224
+ into it.
225
+ </p>
226
+ {error && (
227
+ <div className="mt-3">
228
+ <Alert>{error}</Alert>
229
+ </div>
230
+ )}
231
+ <div className="flex justify-end gap-2 mt-4">
232
+ <Button type="button" variant="secondary" onClick={() => setCreating(false)} disabled={busy}>
233
+ Back
234
+ </Button>
235
+ <Button type="submit" disabled={busy}>
236
+ {busy ? "Creating…" : "Create and move"}
237
+ </Button>
238
+ </div>
239
+ </form>
240
+ ) : (
241
+ <>
242
+ <button
243
+ type="button"
244
+ onClick={() => {
245
+ setCreating(true);
246
+ setError(null);
247
+ }}
248
+ disabled={busy}
249
+ className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-dark hover:underline disabled:opacity-50 disabled:no-underline"
250
+ >
251
+ <HiOutlineFolderPlus size={16} aria-hidden="true" />
252
+ New folder…
253
+ </button>
254
+ {error && (
255
+ <div className="mt-3">
256
+ <Alert>{error}</Alert>
257
+ </div>
258
+ )}
259
+ </>
260
+ )}
261
+ </Modal>
262
+ );
263
+ }
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { PropsWithChildren, createContext, useContext, useMemo, useState } from "react";
6
+ import { DraftThreading } from "@rapidmx/react-shared/mail/mailApi.js";
6
7
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
7
8
  import ComposeWindow from "./ComposeWindow.js";
8
9
 
@@ -14,8 +15,13 @@ export interface ComposeSession {
14
15
  initialCc?: string;
15
16
  initialSubject?: string;
16
17
  /** Pre-built HTML (already includes its own quote-attribution wrapper — see `composeQuoting.ts`)
17
- * inserted below the resolved default signature. Absent for a fresh, non-reply/forward compose. */
18
+ * inserted below the resolved default signature. Absent for a fresh, non-reply/forward compose. Its presence
19
+ * is what makes the window open with the caret at the top of the body instead of in To. */
18
20
  initialQuotedHtml?: string;
21
+ /** See `OpenComposeInput.encrypt`'s own doc comment. */
22
+ initialEncrypt?: boolean;
23
+ /** See `OpenComposeInput.threading`'s own doc comment - handed to `createDraft()` by the window. */
24
+ threading?: DraftThreading;
19
25
  /** Which of a signature's two "default" flags to resolve against — `"new"` (the default) uses
20
26
  * `isDefaultForNewMessages`, `"reply_forward"` uses `isDefaultForReplyForward`. */
21
27
  signatureContext: "new" | "reply_forward";
@@ -46,6 +52,15 @@ export interface OpenComposeInput {
46
52
  * "Mailing lists" note), so the new compose window defaults its Sign toggle off rather than on.
47
53
  * The user can still turn it back on manually; this only changes the *default*. */
48
54
  suppressSigning?: boolean;
55
+ /** `true` for a reply to or forward of an encrypted message, whose quote may carry its decrypted content: the
56
+ * compose window starts with "Encrypt this message" requested, so it is never autosaved as a plaintext draft and
57
+ * can't be sent unencrypted without the user explicitly choosing to. */
58
+ encrypt?: boolean;
59
+ /** The thread this compose continues (`buildReplyThreading()` over the message being replied to or
60
+ * forwarded), recorded on the draft by `createDraft()`. Without it the message is relayed with no
61
+ * `In-Reply-To`/`References` at all and every mail system - the sender's own Sent Items included - files it
62
+ * as a new conversation rather than part of the thread. Absent for a fresh compose, which starts one. */
63
+ threading?: DraftThreading;
49
64
  }
50
65
 
51
66
  export interface ComposeContextValue {
@@ -71,7 +86,17 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
71
86
  const [sessions, setSessions] = useState<ComposeSession[]>([]);
72
87
  const isMobile = useIsMobile();
73
88
 
74
- function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning }: OpenComposeInput) {
89
+ function openCompose({
90
+ mailboxUid,
91
+ to,
92
+ cc,
93
+ subject,
94
+ quotedHtml,
95
+ signatureContext = "new",
96
+ suppressSigning,
97
+ encrypt,
98
+ threading,
99
+ }: OpenComposeInput) {
75
100
  setSessions((prev) => [
76
101
  ...prev,
77
102
  {
@@ -81,6 +106,8 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
81
106
  initialCc: cc,
82
107
  initialSubject: subject,
83
108
  initialQuotedHtml: quotedHtml,
109
+ initialEncrypt: encrypt,
110
+ threading,
84
111
  signatureContext,
85
112
  suppressSigning,
86
113
  minimized: false,