@nocobase/portal-template-default 1.0.16 → 1.0.17

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 (150) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.MD +4 -0
  3. package/dist/assets/{LineDraw-vPRTWiQ5.js → LineDraw-BBB5hwuJ.js} +1 -1
  4. package/dist/assets/{SymbolDraw-DOl_Tss3.js → SymbolDraw-CKS3uUb_.js} +1 -1
  5. package/dist/assets/{VisualMapping-j08W3KBm.js → VisualMapping-BCwaW3TD.js} +1 -1
  6. package/dist/assets/auth-method-demo-BdCCvEEz.js +1 -0
  7. package/dist/assets/{auto-login-provider-Bukw7zEe.js → auto-login-provider-DO5Il6Dp.js} +2 -2
  8. package/dist/assets/auto-login-provider-TQ08B6-i.js +1 -0
  9. package/dist/assets/auto-redirect-provider-ButXp0A3.js +1 -0
  10. package/dist/assets/auto-redirect-provider-DnGGP2Vn.js +1 -0
  11. package/dist/assets/auto-redirect-provider-ja-vFPaF.js +1 -0
  12. package/dist/assets/calendar-C7xnRgOT.js +1 -0
  13. package/dist/assets/{cas-sign-in-button-BQn3Mttx.js → cas-sign-in-button-BZCa4lR6.js} +1 -1
  14. package/dist/assets/{components-BT5mL9gW.js → components-_g-GjQeV.js} +3 -3
  15. package/dist/assets/{configuration-gate-BJoadoA6.js → configuration-gate-B4XVz7tS.js} +1 -1
  16. package/dist/assets/{createClipPathFromCoordSys-m8IbnJ1z.js → createClipPathFromCoordSys-z9Kmh-tu.js} +1 -1
  17. package/dist/assets/createSeriesDataSimply-CPqysF0K.js +1 -0
  18. package/dist/assets/{customGraphicKeyframeAnimation-CYLPurwK.js → customGraphicKeyframeAnimation-DMU5crAu.js} +1 -1
  19. package/dist/assets/demo-BPlae2bp.js +1 -0
  20. package/dist/assets/demo-Bj_Y0RrW.js +1 -0
  21. package/dist/assets/demo-CKPAQOib.js +1 -0
  22. package/dist/assets/demo-CXRTPvIx.js +1 -0
  23. package/dist/assets/demo-DJuVDu1n.js +1 -0
  24. package/dist/assets/demo-DUmWU7ik.js +1 -0
  25. package/dist/assets/demo-NhIzFRE1.js +1 -0
  26. package/dist/assets/{dingtalk-sign-in-button-DSluFaP9.js → dingtalk-sign-in-button-DMPgE5kT.js} +1 -1
  27. package/dist/assets/{echarts-preview-mRXoEciY.js → echarts-preview-R96Pbqmz.js} +1 -1
  28. package/dist/assets/{echarts-runtime-CvLr4Slh.js → echarts-runtime-Ce_z2eX_.js} +3 -3
  29. package/dist/assets/{echarts-runtime-advanced-BC1tvfQ0.js → echarts-runtime-advanced-Dr80KHJR.js} +1 -1
  30. package/dist/assets/{echarts-runtime-common-DhNaV5B8.js → echarts-runtime-common-DB95Yfc1.js} +1 -1
  31. package/dist/assets/{echarts-runtime-components-BiioS8t1.js → echarts-runtime-components-DCJEaTJk.js} +1 -1
  32. package/dist/assets/{echarts-runtime-hierarchy-CAWqYvFh.js → echarts-runtime-hierarchy-CezgZs9O.js} +1 -1
  33. package/dist/assets/{floating-RVbHvIiD.js → floating-O9WmiDXk.js} +2 -2
  34. package/dist/assets/index-BBKV6O5d.css +1 -0
  35. package/dist/assets/{index-Dw20Lzpp.js → index-CPzehPGe.js} +3 -3
  36. package/dist/assets/index-Cc6awvdg.js +1064 -0
  37. package/dist/assets/index-ChA0z9ke.js +38 -0
  38. package/dist/assets/{index-BysibBZu.js → index-DgB7uTMs.js} +2 -2
  39. package/dist/assets/{index-C-tdHirk.js → index-DroUAK80.js} +1 -1
  40. package/dist/assets/index-WKvygTBp.js +59 -0
  41. package/dist/assets/{labelHelper-DbITmOUW.js → labelHelper-Dk5jkiD-.js} +1 -1
  42. package/dist/assets/ldap-sign-in-form-CKN4l7qM.js +1 -0
  43. package/dist/assets/{oidc-sign-in-button-Cgpa8Qzj.js → oidc-sign-in-button-BIRonrpC.js} +1 -1
  44. package/dist/assets/{page-context-AU6RU0Qi.js → page-context-DAiW_dId.js} +4 -4
  45. package/dist/assets/prompt-card-rGZqpqkF.js +1 -0
  46. package/dist/assets/prompt-output-B4h3uLWf.js +1 -0
  47. package/dist/assets/saml-sign-in-button-B4ugaLIb.js +1 -0
  48. package/dist/assets/{sectorHelper-C6L782Ue.js → sectorHelper-BuxrrJrA.js} +1 -1
  49. package/dist/assets/shortcut-BhKXxJLs.js +47 -0
  50. package/dist/assets/{sms-sign-in-form-Bo2WLZuP.js → sms-sign-in-form-DKpxRDdZ.js} +1 -1
  51. package/dist/assets/{static.browser-CoPxsICt.js → static.browser-kzzpH0Xt.js} +1 -1
  52. package/dist/assets/switch-BdVxPIVU.js +1 -0
  53. package/dist/assets/{tool-cards-ipgJ_3Ec.js → tool-cards-CpczIke-.js} +3 -3
  54. package/dist/assets/{use-authenticator-sign-in-BzFeh7qe.js → use-authenticator-sign-in-CIj210ry.js} +1 -1
  55. package/dist/assets/use-dingtalk-sign-in-BW4kkXD3.js +1 -0
  56. package/dist/assets/{use-wecom-sign-in-PPIMLvGa.js → use-wecom-sign-in-dIqMtcN9.js} +1 -1
  57. package/dist/assets/{wecom-sign-in-button-rUq9RW8R.js → wecom-sign-in-button-Dn_2zDab.js} +1 -1
  58. package/dist/index.html +2 -2
  59. package/dist/index.raw.html +2 -2
  60. package/package.json +9 -1
  61. package/src/App.tsx +5 -2
  62. package/src/app/extension.ts +2 -0
  63. package/src/app/extensions.tsx +18 -6
  64. package/src/app/route-access-guard.tsx +22 -0
  65. package/src/app/route-runtime.ts +173 -0
  66. package/src/app/routes.tsx +2 -2
  67. package/src/components/app-shell/header.tsx +56 -1
  68. package/src/components/app-shell/sidebar.tsx +14 -2
  69. package/src/extensions/nocobase-acl/README.md +28 -14
  70. package/src/extensions/nocobase-acl/demo/index.tsx +87 -15
  71. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +1 -0
  72. package/src/extensions/nocobase-mail/README.md +74 -0
  73. package/src/extensions/nocobase-mail/components/index.ts +79 -0
  74. package/src/extensions/nocobase-mail/components/mail-api.ts +423 -0
  75. package/src/extensions/nocobase-mail/components/mail-attachment-list.tsx +67 -0
  76. package/src/extensions/nocobase-mail/components/mail-compose-attachments.tsx +166 -0
  77. package/src/extensions/nocobase-mail/components/mail-compose.tsx +1071 -0
  78. package/src/extensions/nocobase-mail/components/mail-detail.tsx +653 -0
  79. package/src/extensions/nocobase-mail/components/mail-draft-autosave.ts +26 -0
  80. package/src/extensions/nocobase-mail/components/mail-empty.tsx +48 -0
  81. package/src/extensions/nocobase-mail/components/mail-filters.tsx +277 -0
  82. package/src/extensions/nocobase-mail/components/mail-inbox.tsx +544 -0
  83. package/src/extensions/nocobase-mail/components/mail-inline-images.ts +69 -0
  84. package/src/extensions/nocobase-mail/components/mail-label-badge.tsx +25 -0
  85. package/src/extensions/nocobase-mail/components/mail-label-manager.tsx +271 -0
  86. package/src/extensions/nocobase-mail/components/mail-labels-editor.tsx +267 -0
  87. package/src/extensions/nocobase-mail/components/mail-mass-tracking.tsx +271 -0
  88. package/src/extensions/nocobase-mail/components/mail-note-editor.tsx +99 -0
  89. package/src/extensions/nocobase-mail/components/mail-recipient-input.tsx +260 -0
  90. package/src/extensions/nocobase-mail/components/mail-reply-quote.ts +28 -0
  91. package/src/extensions/nocobase-mail/components/mail-rich-editor.tsx +287 -0
  92. package/src/extensions/nocobase-mail/components/mail-send-actions.tsx +238 -0
  93. package/src/extensions/nocobase-mail/components/mail-senders.ts +61 -0
  94. package/src/extensions/nocobase-mail/components/mail-settings-drawer.tsx +708 -0
  95. package/src/extensions/nocobase-mail/components/mail-showcase-layout.tsx +64 -0
  96. package/src/extensions/nocobase-mail/components/mail-signature-manager.tsx +258 -0
  97. package/src/extensions/nocobase-mail/components/mail-signatures.ts +25 -0
  98. package/src/extensions/nocobase-mail/components/mail-table.tsx +476 -0
  99. package/src/extensions/nocobase-mail/components/mail-template-manager.tsx +204 -0
  100. package/src/extensions/nocobase-mail/components/mail-toolbar.tsx +155 -0
  101. package/src/extensions/nocobase-mail/components/mail-unread.tsx +111 -0
  102. package/src/extensions/nocobase-mail/components/types.ts +297 -0
  103. package/src/extensions/nocobase-mail/components/use-mail-compose.tsx +262 -0
  104. package/src/extensions/nocobase-mail/components/use-mail-mass-messages.ts +54 -0
  105. package/src/extensions/nocobase-mail/components/use-mail-messages.ts +113 -0
  106. package/src/extensions/nocobase-mail/components/use-mail-signatures.ts +122 -0
  107. package/src/extensions/nocobase-mail/components/use-mail-templates.ts +54 -0
  108. package/src/extensions/nocobase-mail/extension.tsx +105 -0
  109. package/src/extensions/nocobase-mail/index.ts +1 -0
  110. package/src/extensions/nocobase-mail/mail-demo-pages.tsx +503 -0
  111. package/src/extensions/nocobase-mail/mail-pages.tsx +201 -0
  112. package/src/extensions/nocobase-mail/tests/mail-quote-html-regression.mjs +51 -0
  113. package/src/extensions/nocobase-mail/tests/mail-regression.mjs +649 -0
  114. package/src/extensions/nocobase-users-example/app-routes.tsx +121 -0
  115. package/src/extensions/nocobase-users-example/extension.tsx +2 -100
  116. package/src/extensions/nocobase-users-example/index.ts +1 -0
  117. package/src/lib/nocobase/acl/menu.ts +17 -3
  118. package/src/lib/nocobase/acl/types.ts +4 -0
  119. package/src/locales/en-US.ts +2 -1
  120. package/src/locales/zh-CN.ts +2 -1
  121. package/src/providers/runtime-config.ts +18 -0
  122. package/src/routes.tsx +10 -0
  123. package/src/vite-env.d.ts +3 -0
  124. package/vite.config.ts +8 -0
  125. package/dist/assets/auth-method-demo-B2qcpY8z.js +0 -1
  126. package/dist/assets/auto-login-provider-BMtzvdxG.js +0 -1
  127. package/dist/assets/auto-redirect-provider-C6GNPg5r.js +0 -1
  128. package/dist/assets/auto-redirect-provider-D5c6kN2R.js +0 -1
  129. package/dist/assets/auto-redirect-provider-iTzGEDpU.js +0 -1
  130. package/dist/assets/calendar-Dv2RNnsr.js +0 -1
  131. package/dist/assets/card-C9Hpeq1c.js +0 -1
  132. package/dist/assets/createSeriesDataSimply-DKUV5b5e.js +0 -1
  133. package/dist/assets/demo-BWGOwqtJ.js +0 -1
  134. package/dist/assets/demo-BWbcvkya.js +0 -1
  135. package/dist/assets/demo-DIASSPgn.js +0 -1
  136. package/dist/assets/demo-DuZ6asUU.js +0 -1
  137. package/dist/assets/demo-Dxcy4bDE.js +0 -1
  138. package/dist/assets/demo-YpEC76uw.js +0 -1
  139. package/dist/assets/demo-wzkoR8T2.js +0 -1
  140. package/dist/assets/index-CBZbj45X.js +0 -58
  141. package/dist/assets/index-CgkdZ6Oc.js +0 -686
  142. package/dist/assets/index-NH3aqqQz.css +0 -1
  143. package/dist/assets/index-hSBzW-VA.js +0 -43
  144. package/dist/assets/ldap-sign-in-form-BOMNDTkK.js +0 -1
  145. package/dist/assets/prompt-card-FWugRgi-.js +0 -1
  146. package/dist/assets/prompt-output-DMTuTNab.js +0 -1
  147. package/dist/assets/saml-sign-in-button-qt8bMeoQ.js +0 -1
  148. package/dist/assets/shortcut-Bu7jT_-w.js +0 -47
  149. package/dist/assets/switch-DQkkIE7G.js +0 -1
  150. package/dist/assets/use-dingtalk-sign-in-mrq6K5Yx.js +0 -1
@@ -0,0 +1,544 @@
1
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { PenLine } from "lucide-react";
3
+ import { toast } from "sonner";
4
+ import type {
5
+ MailAccount,
6
+ MailColumnId,
7
+ MailLabel,
8
+ MailMessage,
9
+ MailNote,
10
+ MailScope,
11
+ } from "./types";
12
+ import { isLocalMailDraft, MailBoxType } from "./types";
13
+ import { mailApi } from "./mail-api";
14
+ import { useMailMessages } from "./use-mail-messages";
15
+ import { useMailCompose, buildComposeInitial } from "./use-mail-compose";
16
+ import type { ComposeMode } from "./mail-compose";
17
+ import { MailToolbar } from "./mail-toolbar";
18
+ import { MailTable } from "./mail-table";
19
+ import { MailDetail } from "./mail-detail";
20
+ import { Sheet, SheetContent } from "@/components/ui/sheet";
21
+ import { Button } from "@/components/ui/button";
22
+ import {
23
+ AlertDialog,
24
+ AlertDialogAction,
25
+ AlertDialogCancel,
26
+ AlertDialogContent,
27
+ AlertDialogDescription,
28
+ AlertDialogFooter,
29
+ AlertDialogHeader,
30
+ AlertDialogTitle,
31
+ } from "@/components/ui/alert-dialog";
32
+ import { cn } from "@/lib/utils";
33
+ import { useMailUnread } from "./mail-unread";
34
+
35
+ type MailReadState = Pick<
36
+ MailMessage,
37
+ | "mailId"
38
+ | "isRead"
39
+ | "relatedMessageIds"
40
+ | "relatedMessagesIsRead"
41
+ | "children"
42
+ | "childrenMessages"
43
+ >;
44
+
45
+ export function isMessageUnread(
46
+ message: Pick<MailMessage, "isRead" | "relatedMessagesIsRead">
47
+ ) {
48
+ return !message.isRead || message.relatedMessagesIsRead === false;
49
+ }
50
+
51
+ export function collectMessageMailIds(message: MailReadState) {
52
+ const conversation = [
53
+ message,
54
+ ...(message.children ?? []),
55
+ ...(message.childrenMessages ?? []),
56
+ ];
57
+
58
+ return Array.from(
59
+ new Set(
60
+ conversation.flatMap((item) => [
61
+ item.mailId,
62
+ ...(item.relatedMessageIds ?? []),
63
+ ])
64
+ )
65
+ ).filter((mailId): mailId is string => Boolean(mailId));
66
+ }
67
+
68
+ export function markMessageRead(message: MailMessage): MailMessage {
69
+ return { ...message, isRead: true, relatedMessagesIsRead: true };
70
+ }
71
+
72
+ export function markMessageUnread(message: MailMessage): MailMessage {
73
+ return { ...message, isRead: false, relatedMessagesIsRead: false };
74
+ }
75
+
76
+ export interface MailInboxProps {
77
+ scope?: MailScope;
78
+ boxType?: MailBoxType;
79
+ isRead?: boolean;
80
+ labelId?: number;
81
+ columns?: MailColumnId[];
82
+ filter?: Record<string, unknown>;
83
+ userId?: number;
84
+ pageSize?: number;
85
+ showToolbar?: boolean;
86
+ toolbarActions?: ReactNode;
87
+ className?: string;
88
+ }
89
+
90
+ export function MailInbox({
91
+ scope,
92
+ boxType,
93
+ isRead,
94
+ labelId,
95
+ columns,
96
+ filter,
97
+ userId,
98
+ pageSize = 15,
99
+ showToolbar = true,
100
+ toolbarActions,
101
+ className,
102
+ }: MailInboxProps) {
103
+ const [search, setSearch] = useState("");
104
+ const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
105
+ const [activeMessage, setActiveMessage] = useState<MailMessage | undefined>();
106
+ const [detailLoading, setDetailLoading] = useState(false);
107
+ const [detailOpen, setDetailOpen] = useState(false);
108
+ const [syncing, setSyncing] = useState(false);
109
+ const [accounts, setAccounts] = useState<MailAccount[]>([]);
110
+ const [permanentDeleteIds, setPermanentDeleteIds] = useState<number[]>([]);
111
+ const openMessageSequence = useRef(0);
112
+ const { refresh: refreshUnread } = useMailUnread();
113
+
114
+ const {
115
+ messages,
116
+ total,
117
+ loading,
118
+ page,
119
+ pageSize: effectivePageSize,
120
+ setPage,
121
+ refresh,
122
+ setMessages,
123
+ } = useMailMessages({ scope, boxType, isRead, labelId, search, filter, userId, pageSize });
124
+
125
+ const selectedMessages = messages.filter((message) => selectedIds.has(message.id));
126
+ const actionMode: "normal" | "trash" | "draft" | "providerDraft" | "scheduled" | "mixed" =
127
+ selectedMessages.length &&
128
+ selectedMessages.every((message) => message.boxType === MailBoxType.TRASH)
129
+ ? "trash"
130
+ : selectedMessages.length && selectedMessages.every(isLocalMailDraft)
131
+ ? "draft"
132
+ : selectedMessages.length &&
133
+ selectedMessages.every(
134
+ (message) => message.isDraft && !isLocalMailDraft(message)
135
+ )
136
+ ? "providerDraft"
137
+ : selectedMessages.length &&
138
+ selectedMessages.every(
139
+ (message) => message.boxType === MailBoxType.SCHEDULED
140
+ )
141
+ ? "scheduled"
142
+ : selectedMessages.some(
143
+ (message) =>
144
+ message.isDraft ||
145
+ message.boxType === MailBoxType.SCHEDULED ||
146
+ message.boxType === MailBoxType.TRASH
147
+ )
148
+ ? "mixed"
149
+ : "normal";
150
+
151
+ const { openCompose, composeDialog } = useMailCompose({
152
+ accounts,
153
+ onSent: refresh,
154
+ onAccountChange: (account) =>
155
+ setAccounts((prev) =>
156
+ prev.map((item) => (item.id === account.id ? account : item))
157
+ ),
158
+ });
159
+
160
+ useEffect(() => {
161
+ mailApi
162
+ .getAccounts()
163
+ .then(setAccounts)
164
+ .catch(() => setAccounts([]));
165
+ }, []);
166
+
167
+ const openMessage = useCallback(
168
+ async (message: MailMessage) => {
169
+ const sequence = ++openMessageSequence.current;
170
+ if (isLocalMailDraft(message)) {
171
+ try {
172
+ const detail = await mailApi.getMessage(message.id);
173
+ if (sequence !== openMessageSequence.current) return;
174
+ openCompose(buildComposeInitial(detail, "draft"), "draft");
175
+ } catch (error) {
176
+ if (sequence !== openMessageSequence.current) return;
177
+ toast.error(
178
+ error instanceof Error ? error.message : "Failed to load draft"
179
+ );
180
+ }
181
+ return;
182
+ }
183
+ setActiveMessage(message);
184
+ setDetailOpen(true);
185
+ setDetailLoading(true);
186
+ try {
187
+ const detail = await mailApi.getMessage(message.id);
188
+ if (sequence !== openMessageSequence.current) return;
189
+ setActiveMessage(detail);
190
+ if (isMessageUnread(message)) {
191
+ const mailIds = collectMessageMailIds(detail);
192
+ if (mailIds.length) {
193
+ try {
194
+ await mailApi.setRead(mailIds, true);
195
+ if (sequence !== openMessageSequence.current) return;
196
+ setActiveMessage(markMessageRead(detail));
197
+ setMessages((prev) =>
198
+ prev.map((m) =>
199
+ m.id === message.id ? markMessageRead(m) : m
200
+ )
201
+ );
202
+ refreshUnread();
203
+ } catch (error) {
204
+ toast.error(
205
+ error instanceof Error
206
+ ? error.message
207
+ : "Failed to mark message as read"
208
+ );
209
+ }
210
+ }
211
+ }
212
+ } catch (error) {
213
+ if (sequence !== openMessageSequence.current) return;
214
+ toast.error(error instanceof Error ? error.message : "Failed to load message");
215
+ } finally {
216
+ if (sequence === openMessageSequence.current) setDetailLoading(false);
217
+ }
218
+ },
219
+ [openCompose, setMessages, refreshUnread]
220
+ );
221
+
222
+ const toggleSelect = useCallback((id: number, checked: boolean) => {
223
+ setSelectedIds((prev) => {
224
+ const next = new Set(prev);
225
+ if (checked) next.add(id);
226
+ else next.delete(id);
227
+ return next;
228
+ });
229
+ }, []);
230
+
231
+ const clearSelectionAndRefresh = useCallback(() => {
232
+ setSelectedIds(new Set());
233
+ refresh();
234
+ }, [refresh]);
235
+
236
+ const handleSetRead = useCallback(
237
+ async (isRead: boolean) => {
238
+ const mailIds = messages
239
+ .filter((m) => selectedIds.has(m.id))
240
+ .map((m) => m.mailId);
241
+ if (!mailIds.length) return;
242
+ try {
243
+ await mailApi.setRead(mailIds, isRead);
244
+ setMessages((prev) =>
245
+ prev.map((m) => (selectedIds.has(m.id) ? { ...m, isRead } : m))
246
+ );
247
+ setSelectedIds(new Set());
248
+ toast.success(isRead ? "Marked as read" : "Marked as unread");
249
+ refreshUnread();
250
+ } catch (error) {
251
+ toast.error(error instanceof Error ? error.message : "Action failed");
252
+ }
253
+ },
254
+ [messages, selectedIds, setMessages, refreshUnread]
255
+ );
256
+
257
+ const handleDetailMarkUnread = useCallback(
258
+ async (message: MailMessage) => {
259
+ const mailIds = collectMessageMailIds(message);
260
+ if (!mailIds.length) return;
261
+ try {
262
+ await mailApi.setRead(mailIds, false);
263
+ setActiveMessage((prev) =>
264
+ prev?.id === message.id ? markMessageUnread(prev) : prev
265
+ );
266
+ setMessages((prev) =>
267
+ prev.map((item) =>
268
+ item.id === message.id ? markMessageUnread(item) : item
269
+ )
270
+ );
271
+ toast.success("Marked as unread");
272
+ refreshUnread();
273
+ } catch (error) {
274
+ toast.error(
275
+ error instanceof Error
276
+ ? error.message
277
+ : "Failed to mark message as unread"
278
+ );
279
+ }
280
+ },
281
+ [setMessages, refreshUnread]
282
+ );
283
+
284
+ const handleBulkTrash = useCallback(async () => {
285
+ const ids = Array.from(selectedIds);
286
+ if (!ids.length) return;
287
+ try {
288
+ await mailApi.trashMessages(ids, true);
289
+ toast.success("Moved to trash");
290
+ clearSelectionAndRefresh();
291
+ refreshUnread();
292
+ } catch (error) {
293
+ toast.error(error instanceof Error ? error.message : "Failed to move to trash");
294
+ }
295
+ }, [selectedIds, clearSelectionAndRefresh, refreshUnread]);
296
+
297
+ const handleBulkRestore = useCallback(async () => {
298
+ const ids = Array.from(selectedIds);
299
+ if (!ids.length) return;
300
+ try {
301
+ await mailApi.trashMessages(ids, false);
302
+ toast.success("Messages put back");
303
+ clearSelectionAndRefresh();
304
+ refreshUnread();
305
+ } catch (error) {
306
+ toast.error(error instanceof Error ? error.message : "Failed to put messages back");
307
+ }
308
+ }, [selectedIds, clearSelectionAndRefresh, refreshUnread]);
309
+
310
+ const handleSync = useCallback(async () => {
311
+ setSyncing(true);
312
+ try {
313
+ const emails = accounts.map((a) => a.email);
314
+ if (emails.length) await mailApi.sync(emails);
315
+ toast.success("Mailbox synced");
316
+ clearSelectionAndRefresh();
317
+ refreshUnread();
318
+ } catch (error) {
319
+ toast.error(error instanceof Error ? error.message : "Sync failed");
320
+ } finally {
321
+ setSyncing(false);
322
+ }
323
+ }, [accounts, clearSelectionAndRefresh, refreshUnread]);
324
+
325
+ const openReply = useCallback(
326
+ (message: MailMessage, mode: ComposeMode) => {
327
+ const accountEmails = accounts.flatMap((account) => [
328
+ account.email,
329
+ ...(account.identities?.map((identity) => identity.email) ?? []),
330
+ ]);
331
+ openCompose(buildComposeInitial(message, mode, accountEmails), mode);
332
+ },
333
+ [accounts, openCompose]
334
+ );
335
+
336
+ const handleDetailTrash = useCallback(
337
+ async (message: MailMessage) => {
338
+ try {
339
+ await mailApi.trashMessages([message.id], true);
340
+ toast.success("Moved to trash");
341
+ setDetailOpen(false);
342
+ setActiveMessage(undefined);
343
+ refresh();
344
+ refreshUnread();
345
+ } catch (error) {
346
+ toast.error(error instanceof Error ? error.message : "Failed to move to trash");
347
+ }
348
+ },
349
+ [refresh, refreshUnread]
350
+ );
351
+
352
+ const handleDetailRestore = useCallback(
353
+ async (message: MailMessage) => {
354
+ try {
355
+ await mailApi.trashMessages([message.id], false);
356
+ toast.success("Message put back");
357
+ setDetailOpen(false);
358
+ setActiveMessage(undefined);
359
+ refresh();
360
+ refreshUnread();
361
+ } catch (error) {
362
+ toast.error(error instanceof Error ? error.message : "Failed to put message back");
363
+ }
364
+ },
365
+ [refresh, refreshUnread]
366
+ );
367
+
368
+ const handlePermanentDelete = useCallback(async () => {
369
+ if (!permanentDeleteIds.length) return;
370
+ try {
371
+ await mailApi.destroyMessages(permanentDeleteIds);
372
+ toast.success("Messages permanently deleted");
373
+ if (activeMessage && permanentDeleteIds.includes(activeMessage.id)) {
374
+ setDetailOpen(false);
375
+ setActiveMessage(undefined);
376
+ }
377
+ setPermanentDeleteIds([]);
378
+ clearSelectionAndRefresh();
379
+ refreshUnread();
380
+ } catch (error) {
381
+ toast.error(
382
+ error instanceof Error ? error.message : "Failed to permanently delete messages"
383
+ );
384
+ }
385
+ }, [activeMessage, clearSelectionAndRefresh, permanentDeleteIds, refreshUnread]);
386
+
387
+ const handleCancelScheduled = useCallback(
388
+ async (message: MailMessage) => {
389
+ try {
390
+ await mailApi.cancelScheduled(message.id);
391
+ const draft = await mailApi.getMessage(message.id);
392
+ setDetailOpen(false);
393
+ setActiveMessage(undefined);
394
+ refresh();
395
+ openCompose(buildComposeInitial(draft, "draft"), "draft");
396
+ toast.success("Scheduled send canceled; message moved to drafts");
397
+ } catch (error) {
398
+ toast.error(
399
+ error instanceof Error ? error.message : "Failed to cancel scheduled send"
400
+ );
401
+ }
402
+ },
403
+ [openCompose, refresh]
404
+ );
405
+
406
+ const handleToggleTodo = useCallback(
407
+ async (message: MailMessage) => {
408
+ const isTodo = !message.isTodo;
409
+ try {
410
+ await mailApi.setTodo(message.id, isTodo);
411
+ setActiveMessage((prev) =>
412
+ prev && prev.id === message.id ? { ...prev, isTodo } : prev
413
+ );
414
+ setMessages((prev) =>
415
+ prev.map((m) => (m.id === message.id ? { ...m, isTodo } : m))
416
+ );
417
+ } catch (error) {
418
+ toast.error(error instanceof Error ? error.message : "Failed to update todo");
419
+ }
420
+ },
421
+ [setMessages]
422
+ );
423
+
424
+ const handleLabelsChange = useCallback(
425
+ (message: MailMessage, labels: MailLabel[]) => {
426
+ setActiveMessage((prev) =>
427
+ prev && prev.id === message.id ? { ...prev, labels } : prev
428
+ );
429
+ setMessages((prev) =>
430
+ prev.map((m) => (m.id === message.id ? { ...m, labels } : m))
431
+ );
432
+ },
433
+ [setMessages]
434
+ );
435
+
436
+ const handleNoteChange = useCallback(
437
+ (message: MailMessage, note: MailNote | undefined) => {
438
+ const noteArr = note ? [note] : [];
439
+ setActiveMessage((prev) =>
440
+ prev && prev.id === message.id ? { ...prev, note: noteArr } : prev
441
+ );
442
+ setMessages((prev) =>
443
+ prev.map((m) => (m.id === message.id ? { ...m, note: noteArr } : m))
444
+ );
445
+ },
446
+ [setMessages]
447
+ );
448
+
449
+ return (
450
+ <div className={cn("flex flex-col gap-3", className)}>
451
+ <MailTable
452
+ messages={messages}
453
+ loading={loading}
454
+ total={total}
455
+ page={page}
456
+ pageSize={effectivePageSize}
457
+ onPageChange={setPage}
458
+ selectedIds={selectedIds}
459
+ activeId={activeMessage?.id}
460
+ onOpen={openMessage}
461
+ onSelect={toggleSelect}
462
+ columns={columns}
463
+ emptyVariant={search ? "search" : "inbox"}
464
+ trailingAction={
465
+ showToolbar ? (
466
+ <Button size="sm" onClick={() => openCompose()}>
467
+ <PenLine />
468
+ Compose
469
+ </Button>
470
+ ) : undefined
471
+ }
472
+ toolbar={
473
+ showToolbar ? (
474
+ <MailToolbar
475
+ search={search}
476
+ onSearchChange={setSearch}
477
+ selectedCount={selectedIds.size}
478
+ syncing={syncing}
479
+ onSync={handleSync}
480
+ onMarkRead={() => handleSetRead(true)}
481
+ onMarkUnread={() => handleSetRead(false)}
482
+ actionMode={actionMode}
483
+ onRestore={handleBulkRestore}
484
+ onDeleteForever={() => setPermanentDeleteIds(Array.from(selectedIds))}
485
+ onTrash={handleBulkTrash}
486
+ onClearSelection={() => setSelectedIds(new Set())}
487
+ actions={toolbarActions}
488
+ />
489
+ ) : undefined
490
+ }
491
+ />
492
+
493
+ <Sheet open={detailOpen} onOpenChange={setDetailOpen}>
494
+ <SheetContent
495
+ side="right"
496
+ className="gap-0 p-0 data-[side=right]:sm:max-w-3xl"
497
+ >
498
+ <div className="flex-1 overflow-y-auto">
499
+ <MailDetail
500
+ message={activeMessage}
501
+ loading={detailLoading}
502
+ onReply={(m) => openReply(m, "reply")}
503
+ onReplyAll={(m) => openReply(m, "replyAll")}
504
+ onForward={(m) => openReply(m, "forward")}
505
+ onToggleTodo={handleToggleTodo}
506
+ onMarkUnread={handleDetailMarkUnread}
507
+ onTrash={handleDetailTrash}
508
+ onRestore={handleDetailRestore}
509
+ onDeleteForever={(message) => setPermanentDeleteIds([message.id])}
510
+ onCancelScheduled={handleCancelScheduled}
511
+ onLabelsChange={handleLabelsChange}
512
+ onNoteChange={handleNoteChange}
513
+ />
514
+ </div>
515
+ </SheetContent>
516
+ </Sheet>
517
+
518
+ {composeDialog}
519
+
520
+ <AlertDialog
521
+ open={permanentDeleteIds.length > 0}
522
+ onOpenChange={(open) => !open && setPermanentDeleteIds([])}
523
+ >
524
+ <AlertDialogContent>
525
+ <AlertDialogHeader>
526
+ <AlertDialogTitle>Permanently delete messages?</AlertDialogTitle>
527
+ <AlertDialogDescription>
528
+ This action deletes the selected messages from the mail provider and cannot be undone.
529
+ </AlertDialogDescription>
530
+ </AlertDialogHeader>
531
+ <AlertDialogFooter>
532
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
533
+ <AlertDialogAction
534
+ variant="destructive"
535
+ onClick={() => void handlePermanentDelete()}
536
+ >
537
+ Delete permanently
538
+ </AlertDialogAction>
539
+ </AlertDialogFooter>
540
+ </AlertDialogContent>
541
+ </AlertDialog>
542
+ </div>
543
+ );
544
+ }
@@ -0,0 +1,69 @@
1
+ import type { MailAttachment } from "./types";
2
+
3
+ export function normalizeContentId(value?: string) {
4
+ if (!value) return "";
5
+ let decoded = value;
6
+ try {
7
+ decoded = decodeURIComponent(value);
8
+ } catch {
9
+ // Keep malformed provider values usable instead of failing the whole body.
10
+ }
11
+ return decoded
12
+ .replace(/^cid:/i, "")
13
+ .replace(/[<>]/g, "")
14
+ .trim()
15
+ .toLocaleLowerCase();
16
+ }
17
+
18
+ function contentIdFromSource(source: string) {
19
+ if (/^cid:/i.test(source)) return normalizeContentId(source);
20
+
21
+ try {
22
+ const url = new URL(source.replace(/&amp;/gi, "&"), "https://mail.invalid");
23
+ if (!url.pathname.includes("mail:messageContentPreview")) return "";
24
+ return normalizeContentId(url.searchParams.get("contentId") ?? "");
25
+ } catch {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ const IMAGE_SOURCE_PATTERN = /\bsrc\s*=\s*(["'])([^"']+)\1/gi;
31
+
32
+ export function collectInlineContentIds(html: string) {
33
+ const ids: string[] = [];
34
+ const seen = new Set<string>();
35
+
36
+ for (const match of html.matchAll(IMAGE_SOURCE_PATTERN)) {
37
+ const id = contentIdFromSource(match[2]);
38
+ if (!id || seen.has(id)) continue;
39
+ seen.add(id);
40
+ ids.push(id);
41
+ }
42
+
43
+ return ids;
44
+ }
45
+
46
+ export function replaceInlineImageSources(
47
+ html: string,
48
+ sourceByContentId: ReadonlyMap<string, string>
49
+ ) {
50
+ return html.replace(
51
+ IMAGE_SOURCE_PATTERN,
52
+ (attribute, quote: string, source: string) => {
53
+ const contentId = contentIdFromSource(source);
54
+ const replacement = sourceByContentId.get(contentId);
55
+ return replacement ? `src=${quote}${replacement}${quote}` : attribute;
56
+ }
57
+ );
58
+ }
59
+
60
+ export function filterInlineAttachments(
61
+ attachments: MailAttachment[],
62
+ html: string
63
+ ) {
64
+ const inlineIds = new Set(collectInlineContentIds(html));
65
+ if (!inlineIds.size) return attachments;
66
+ return attachments.filter(
67
+ (attachment) => !inlineIds.has(normalizeContentId(attachment.contentId))
68
+ );
69
+ }
@@ -0,0 +1,25 @@
1
+ import type { MailLabel } from "./types";
2
+ import { LABEL_BADGE_CLASSES } from "./types";
3
+ import { Badge } from "@/components/ui/badge";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ export function MailLabelBadge({
7
+ label,
8
+ className,
9
+ }: {
10
+ label: MailLabel;
11
+ className?: string;
12
+ }) {
13
+ return (
14
+ <Badge
15
+ variant="secondary"
16
+ className={cn(
17
+ "h-5 rounded-md px-1.5 text-[10px] font-medium shadow-none",
18
+ LABEL_BADGE_CLASSES[label.color] ?? LABEL_BADGE_CLASSES.default,
19
+ className
20
+ )}
21
+ >
22
+ {label.label}
23
+ </Badge>
24
+ );
25
+ }