@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,174 @@
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 { Attachment, Folder, Message, getMessage, listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
9
+ import MessageDetailPane from "./MessageDetailPane.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+
12
+ export interface ConversationThreadPaneProps {
13
+ conversation: ConversationSummary | null;
14
+ /** The mailbox's full folder list — a conversation can span folders (e.g. an Inbox message and the
15
+ * Sent Items copy of its reply), so each message's own recall eligibility is looked up individually
16
+ * against its own `folderUid` rather than assuming one folder for the whole thread. */
17
+ folders: Folder[];
18
+ }
19
+
20
+ /**
21
+ * The merged, Gmail-style reading pane for "By conversation" mode — every message in the conversation
22
+ * fetched and stacked oldest-to-newest, all but the most recent collapsed to a one-line summary
23
+ * (sender + subject) until clicked. Reuses `MessageDetailPane` for each *expanded* message's own
24
+ * header/attachments/body-iframe rendering rather than re-implementing it; a collapsed row is its own
25
+ * lightweight summary, since mounting a full `MessageDetailPane` (and its iframe) for every message in
26
+ * a long thread up front would be wasteful.
27
+ */
28
+ export default function ConversationThreadPane({ conversation, folders }: ConversationThreadPaneProps) {
29
+ const [messages, setMessages] = useState<Record<string, Message>>({});
30
+ const [attachmentsByUid, setAttachmentsByUid] = useState<Record<string, Attachment[]>>({});
31
+ const [expandedUids, setExpandedUids] = useState<Set<string>>(new Set());
32
+ const [loading, setLoading] = useState(false);
33
+ const [error, setError] = useState<string | null>(null);
34
+
35
+ useEffect(() => {
36
+ if (!conversation) {
37
+ setMessages({});
38
+ setExpandedUids(new Set());
39
+ return;
40
+ }
41
+ setLoading(true);
42
+ setError(null);
43
+ Promise.all(conversation.messageUids.map((uid) => getMessage(uid)))
44
+ .then((loaded) => {
45
+ setMessages(Object.fromEntries(loaded.map((m) => [m.uid, m])));
46
+ // `messageUids` always has at least one entry — a `ConversationSummary` only ever
47
+ // exists because it was grouped from real messages (see
48
+ // `BaseMessageRoute.conversations()`).
49
+ const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
50
+ setExpandedUids(new Set([latestUid]));
51
+ })
52
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this conversation."))
53
+ .finally(() => setLoading(false));
54
+ }, [conversation?.conversationId]);
55
+
56
+ // Lazily loads attachments and marks-as-read only for messages the reader has actually expanded —
57
+ // mirrors `mailDetailHooks.ts`'s `useMessageAttachments`/`useMarkMessageRead`, reimplemented here
58
+ // (rather than called in a loop, which the rules of hooks don't allow) since a thread can expand
59
+ // more than one message at once, unlike the single-selected-message case those hooks were built for.
60
+ useEffect(() => {
61
+ for (const uid of expandedUids) {
62
+ const message = messages[uid];
63
+ if (!message) continue;
64
+ if (message.hasAttachments && !attachmentsByUid[uid]) {
65
+ listAttachments(message.folderUid, uid)
66
+ .then((loaded) => setAttachmentsByUid((prev) => ({ ...prev, [uid]: loaded })))
67
+ .catch(() => {
68
+ // Best-effort, same as the mark-as-read catch below — `attachmentsByUid[uid] ??
69
+ // []` already renders no attachments while this stays unset, so there is nothing
70
+ // further to do on failure.
71
+ });
72
+ }
73
+ if (!message.flags.read) {
74
+ setMessageRead(message, true)
75
+ .then((updated) => setMessages((prev) => ({ ...prev, [uid]: updated })))
76
+ .catch(() => {
77
+ // Best-effort — matches `useMarkMessageRead`'s own doc comment.
78
+ });
79
+ }
80
+ }
81
+ }, [expandedUids, messages]);
82
+
83
+ function toggleExpanded(uid: string) {
84
+ setExpandedUids((prev) => {
85
+ const next = new Set(prev);
86
+ if (next.has(uid)) {
87
+ next.delete(uid);
88
+ } else {
89
+ next.add(uid);
90
+ }
91
+ return next;
92
+ });
93
+ }
94
+
95
+ if (!conversation) {
96
+ return <p className="p-8 text-sm text-text-muted">Select a conversation to read it.</p>;
97
+ }
98
+ if (loading) {
99
+ return <p className="p-8 text-sm text-text-muted">Loading&hellip;</p>;
100
+ }
101
+ if (error) {
102
+ return (
103
+ <div className="p-4 flex-1">
104
+ <Alert>{error}</Alert>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <div className="flex-1 min-w-0 flex flex-col overflow-y-auto">
111
+ <div className="border-b border-border p-4">
112
+ <h1 className="text-lg font-bold tracking-tight">{conversation.subject || "(no subject)"}</h1>
113
+ <p className="text-sm text-text-muted mt-1">
114
+ {conversation.messageCount} message{conversation.messageCount === 1 ? "" : "s"}
115
+ </p>
116
+ </div>
117
+ {conversation.messageUids.map((uid) => {
118
+ const message = messages[uid];
119
+ if (!message) {
120
+ return null;
121
+ }
122
+ const expanded = expandedUids.has(uid);
123
+ if (!expanded) {
124
+ return (
125
+ <button
126
+ key={uid}
127
+ type="button"
128
+ onClick={() => toggleExpanded(uid)}
129
+ className={[
130
+ "w-full text-left px-4 py-3 border-b border-border hover:bg-surface-alt",
131
+ message.flags.read ? "" : "font-semibold",
132
+ ].join(" ")}
133
+ >
134
+ <div className="flex items-center justify-between gap-2 text-sm">
135
+ <span className="truncate">{message.from.displayName || message.from.address}</span>
136
+ <span className="text-xs text-text-muted shrink-0">
137
+ {new Date(message.receivedDate).toLocaleString()}
138
+ </span>
139
+ </div>
140
+ <div className="text-xs text-text-muted truncate font-normal">{message.bodyPreview}</div>
141
+ </button>
142
+ );
143
+ }
144
+ return (
145
+ <div key={uid} className="border-b border-border">
146
+ <button
147
+ type="button"
148
+ onClick={() => toggleExpanded(uid)}
149
+ className="w-full text-left px-4 pt-2 text-xs text-text-muted hover:text-text"
150
+ >
151
+ Collapse
152
+ </button>
153
+ <MessageDetailPane
154
+ message={message}
155
+ attachments={attachmentsByUid[uid] ?? []}
156
+ isSentItems={folders.find((f) => f.uid === message.folderUid)?.type === "sent_items"}
157
+ onRecalled={(updated) => setMessages((prev) => ({ ...prev, [uid]: updated }))}
158
+ isOutbox={folders.find((f) => f.uid === message.folderUid)?.type === "outbox"}
159
+ draftsFolderUid={folders.find((f) => f.type === "drafts")?.uid}
160
+ // Unlike `apps/www/index.tsx`'s list-removal behavior, this patches the
161
+ // message in place (same as `onRecalled` above) rather than removing it from
162
+ // the conversation — `conversation.messageUids` is owned by the parent, not
163
+ // this component, so there's no membership list here to remove it from.
164
+ onScheduledSendCanceled={(updated) => setMessages((prev) => ({ ...prev, [uid]: updated }))}
165
+ isInbox={folders.find((f) => f.uid === message.folderUid)?.type === "inbox"}
166
+ onClassified={(updated) => setMessages((prev) => ({ ...prev, [uid]: updated }))}
167
+ onReceiptHandled={(updated) => setMessages((prev) => ({ ...prev, [uid]: updated }))}
168
+ />
169
+ </div>
170
+ );
171
+ })}
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,390 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ Attachment,
9
+ Message,
10
+ MessageClassification,
11
+ ReceiptType,
12
+ approveReceipt,
13
+ attachmentContentUrl,
14
+ cancelScheduledSend,
15
+ classifyMessage,
16
+ declineReceipt,
17
+ recallMessage,
18
+ } from "@rapidmx/react-shared/mail/mailApi.js";
19
+ import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
20
+ import { useCompose } from "./compose/ComposeContext.js";
21
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
22
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
23
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
24
+
25
+ export interface MessageDetailPaneProps {
26
+ message: Message | null;
27
+ attachments: Attachment[];
28
+ /** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
29
+ * on the desktop reading pane, which never navigates away (selecting a different message just swaps
30
+ * `message` in place). */
31
+ backHref?: string;
32
+ /** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
33
+ * Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
34
+ * computes this from its own already-loaded folder list rather than this component fetching folders
35
+ * itself. */
36
+ isSentItems?: boolean;
37
+ /** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
38
+ * the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
39
+ * `useMarkMessageRead`'s identical `onUpdated` callback. */
40
+ onRecalled?: (updated: Message) => void;
41
+ /** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
42
+ * from. Each caller computes this the same way it computes `isSentItems`. */
43
+ isOutbox?: boolean;
44
+ /** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
45
+ * scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
46
+ * on why clearing `scheduledSendTime` alone doesn't do that). */
47
+ draftsFolderUid?: string;
48
+ /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
49
+ * successfully canceling a scheduled send. */
50
+ onScheduledSendCanceled?: (updated: Message) => void;
51
+ /** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
52
+ * concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
53
+ * the "Move to Other"/"Move to Focused" control below only renders here, the same
54
+ * each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
55
+ isInbox?: boolean;
56
+ /** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
57
+ * successful classify — mirrors `onRecalled`'s identical shape. */
58
+ onClassified?: (updated: Message) => void;
59
+ /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
60
+ * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
61
+ * `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
62
+ * only ever `true` on a real delivered copy, so the banner below is self-gating. */
63
+ onReceiptHandled?: (updated: Message) => void;
64
+ }
65
+
66
+ function formatBytes(bytes: number): string {
67
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
68
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
69
+ return `${bytes} B`;
70
+ }
71
+
72
+ /**
73
+ * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
74
+ * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
75
+ * the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
76
+ * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
77
+ * site for how `message`/`attachments`/`isSentItems` are sourced.
78
+ */
79
+ export default function MessageDetailPane({
80
+ message,
81
+ attachments,
82
+ backHref,
83
+ isSentItems,
84
+ onRecalled,
85
+ isOutbox,
86
+ draftsFolderUid,
87
+ onScheduledSendCanceled,
88
+ isInbox,
89
+ onClassified,
90
+ onReceiptHandled,
91
+ }: MessageDetailPaneProps) {
92
+ const { openCompose } = useCompose();
93
+ const [confirming, setConfirming] = useState(false);
94
+ const [recalling, setRecalling] = useState(false);
95
+ const [canceling, setCanceling] = useState(false);
96
+ const [error, setError] = useState<string | null>(null);
97
+ // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
98
+ // pane rather than inside a confirmation modal — the two flows never need to share one message.
99
+ const [cancelError, setCancelError] = useState<string | null>(null);
100
+ const [classifying, setClassifying] = useState(false);
101
+ const [classifyError, setClassifyError] = useState<string | null>(null);
102
+ const [alwaysForSender, setAlwaysForSender] = useState(false);
103
+ // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
104
+ // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
105
+ // wouldn't distinguish which row's buttons should show a loading state.
106
+ const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
107
+ const [receiptError, setReceiptError] = useState<string | null>(null);
108
+
109
+ if (!message) {
110
+ return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
111
+ }
112
+
113
+ // Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
114
+ // once `message` is loaded (the early return above covers the only other state) — the non-null
115
+ // assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
116
+ function handleReply() {
117
+ openCompose({
118
+ mailboxUid: message!.mailboxUid,
119
+ to: message!.from.address,
120
+ subject: replySubject(message!.subject),
121
+ quotedHtml: buildReplyQuote(message!),
122
+ signatureContext: "reply_forward",
123
+ });
124
+ }
125
+
126
+ function handleReplyAll() {
127
+ const cc = message!.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
128
+ openCompose({
129
+ mailboxUid: message!.mailboxUid,
130
+ to: message!.from.address,
131
+ cc: cc.join(", "),
132
+ subject: replySubject(message!.subject),
133
+ quotedHtml: buildReplyQuote(message!),
134
+ signatureContext: "reply_forward",
135
+ });
136
+ }
137
+
138
+ function handleForward() {
139
+ openCompose({
140
+ mailboxUid: message!.mailboxUid,
141
+ subject: forwardSubject(message!.subject),
142
+ quotedHtml: buildForwardQuote(message!),
143
+ signatureContext: "reply_forward",
144
+ });
145
+ }
146
+
147
+ async function handleRecall() {
148
+ // Only ever invoked from the confirmation modal below, which itself only renders once `message`
149
+ // is loaded (the early return above covers the only other state) — the non-null assertion
150
+ // reflects that real invariant, matching this codebase's established pattern for the same class
151
+ // of always-true-in-practice guard.
152
+ setRecalling(true);
153
+ setError(null);
154
+ try {
155
+ const updated = await recallMessage(message!.uid);
156
+ setConfirming(false);
157
+ onRecalled?.(updated);
158
+ } catch (err) {
159
+ setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
160
+ } finally {
161
+ setRecalling(false);
162
+ }
163
+ }
164
+
165
+ // Only ever invoked from the "Cancel" button below, which itself only renders once `message` is
166
+ // loaded and `isOutbox`/`message.scheduledSendTime` are both truthy — `draftsFolderUid` is required
167
+ // by that same rendering guard (see the prop's own doc comment), so the non-null assertion reflects
168
+ // a real invariant, matching `handleRecall`'s identical pattern just above.
169
+ async function handleCancelScheduledSend() {
170
+ setCanceling(true);
171
+ setCancelError(null);
172
+ try {
173
+ const updated = await cancelScheduledSend(message!, draftsFolderUid!);
174
+ onScheduledSendCanceled?.(updated);
175
+ } catch (err) {
176
+ setCancelError(err instanceof ApiRequestError ? err.message : "Could not cancel this scheduled send.");
177
+ } finally {
178
+ setCanceling(false);
179
+ }
180
+ }
181
+
182
+ // Only ever invoked from the classification button below, which itself only renders once `message`
183
+ // is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
184
+ // `handleRecall`/`handleCancelScheduledSend` above.
185
+ async function handleClassify(classifyAs: MessageClassification) {
186
+ setClassifying(true);
187
+ setClassifyError(null);
188
+ try {
189
+ const updated = await classifyMessage(message!.uid, classifyAs, alwaysForSender);
190
+ onClassified?.(updated);
191
+ } catch (err) {
192
+ setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
193
+ } finally {
194
+ setClassifying(false);
195
+ }
196
+ }
197
+
198
+ // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
199
+ // is loaded — same real invariant as every other handler above.
200
+ async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
201
+ setReceiptBusy(type);
202
+ setReceiptError(null);
203
+ try {
204
+ const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message!.uid, type);
205
+ onReceiptHandled?.(updated);
206
+ } catch (err) {
207
+ setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
208
+ } finally {
209
+ setReceiptBusy(null);
210
+ }
211
+ }
212
+
213
+ return (
214
+ <div className="flex-1 min-w-0 flex flex-col">
215
+ <div className="border-b border-border p-4">
216
+ {backHref && (
217
+ <a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
218
+ &larr; Back to messages
219
+ </a>
220
+ )}
221
+ <div className="flex items-start justify-between gap-3">
222
+ <h1 className="text-lg font-bold tracking-tight">{message.subject || "(no subject)"}</h1>
223
+ {isSentItems &&
224
+ (message.recallRequestedAt ? (
225
+ <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
226
+ Recall requested
227
+ </span>
228
+ ) : (
229
+ <Button
230
+ type="button"
231
+ variant="secondary"
232
+ className="!w-auto shrink-0"
233
+ onClick={() => setConfirming(true)}
234
+ >
235
+ Recall this message
236
+ </Button>
237
+ ))}
238
+ {isOutbox && message.scheduledSendTime && (
239
+ <div className="flex items-center gap-2 shrink-0">
240
+ <span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
241
+ Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
242
+ </span>
243
+ <Button
244
+ type="button"
245
+ variant="secondary"
246
+ className="!w-auto"
247
+ loading={canceling}
248
+ disabled={canceling}
249
+ onClick={handleCancelScheduledSend}
250
+ >
251
+ Cancel
252
+ </Button>
253
+ </div>
254
+ )}
255
+ </div>
256
+ {cancelError && (
257
+ <div className="mt-2">
258
+ <Alert>{cancelError}</Alert>
259
+ </div>
260
+ )}
261
+ <p className="text-sm text-text-muted mt-1">
262
+ From {message.from.displayName || message.from.address} &middot;{" "}
263
+ {new Date(message.receivedDate).toLocaleString()}
264
+ </p>
265
+ <p className="text-sm text-text-muted">
266
+ To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
267
+ </p>
268
+ <div className="flex gap-2 mt-3">
269
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleReply}>
270
+ Reply
271
+ </Button>
272
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleReplyAll}>
273
+ Reply All
274
+ </Button>
275
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
276
+ Forward
277
+ </Button>
278
+ </div>
279
+ {isInbox && (
280
+ <div className="flex flex-wrap items-center gap-2 mt-2">
281
+ <Button
282
+ type="button"
283
+ variant="text"
284
+ loading={classifying}
285
+ disabled={classifying}
286
+ onClick={() =>
287
+ handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
288
+ }
289
+ >
290
+ {message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
291
+ </Button>
292
+ <label className="flex items-center gap-1.5 text-xs text-text-muted">
293
+ <input
294
+ type="checkbox"
295
+ checked={alwaysForSender}
296
+ onChange={(e) => setAlwaysForSender(e.target.checked)}
297
+ />
298
+ Always for this sender
299
+ </label>
300
+ </div>
301
+ )}
302
+ {classifyError && (
303
+ <div className="mt-2">
304
+ <Alert>{classifyError}</Alert>
305
+ </div>
306
+ )}
307
+ {(["delivery", "read"] as const)
308
+ .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
309
+ .map((type) => (
310
+ <div
311
+ key={type}
312
+ className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
313
+ >
314
+ <span>
315
+ {message.from.displayName || message.from.address} requested a {type} receipt for this
316
+ message.
317
+ </span>
318
+ <Button
319
+ type="button"
320
+ variant="secondary"
321
+ className="!w-auto"
322
+ loading={receiptBusy === type}
323
+ disabled={receiptBusy !== null}
324
+ onClick={() => handleReceipt(type, "approve")}
325
+ >
326
+ Send receipt
327
+ </Button>
328
+ <Button
329
+ type="button"
330
+ variant="text"
331
+ disabled={receiptBusy !== null}
332
+ onClick={() => handleReceipt(type, "decline")}
333
+ >
334
+ Decline
335
+ </Button>
336
+ </div>
337
+ ))}
338
+ {receiptError && (
339
+ <div className="mt-2">
340
+ <Alert>{receiptError}</Alert>
341
+ </div>
342
+ )}
343
+ {attachments.length > 0 && (
344
+ <ul className="flex flex-wrap gap-2 mt-3">
345
+ {attachments.map((attachment) => (
346
+ <li key={attachment.uid}>
347
+ <a
348
+ href={attachmentContentUrl(attachment.uid)}
349
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
350
+ >
351
+ {attachment.filename} ({formatBytes(attachment.sizeBytes)})
352
+ </a>
353
+ </li>
354
+ ))}
355
+ </ul>
356
+ )}
357
+ </div>
358
+ <iframe
359
+ key={message.uid}
360
+ title={message.subject || "Message content"}
361
+ src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
362
+ sandbox=""
363
+ className="flex-1 w-full border-0"
364
+ />
365
+
366
+ <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
367
+ <p className="text-sm text-text-muted mb-4">
368
+ This asks every original recipient's mail system to delete their copy, but only if it's
369
+ still unread there — there's no way to guarantee it, and no confirmation once it either
370
+ succeeds or fails. Recipients who already read the message will keep it.
371
+ </p>
372
+ {error && <Alert>{error}</Alert>}
373
+ <div className="flex gap-3">
374
+ <Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
375
+ Recall message
376
+ </Button>
377
+ <Button
378
+ type="button"
379
+ variant="secondary"
380
+ disabled={recalling}
381
+ onClick={() => setConfirming(false)}
382
+ className="!w-auto"
383
+ >
384
+ Cancel
385
+ </Button>
386
+ </div>
387
+ </Modal>
388
+ </div>
389
+ );
390
+ }