@nocobase/portal-template-default 1.0.15 → 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 (153) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.MD +4 -0
  3. package/dist/assets/{LineDraw-iG-ADes-.js → LineDraw-BBB5hwuJ.js} +1 -1
  4. package/dist/assets/{SymbolDraw-DXoSw1tM.js → SymbolDraw-CKS3uUb_.js} +1 -1
  5. package/dist/assets/{VisualMapping-C_ee4Eht.js → VisualMapping-BCwaW3TD.js} +1 -1
  6. package/dist/assets/auth-method-demo-BdCCvEEz.js +1 -0
  7. package/dist/assets/{auto-login-provider-5Uxza9Ms.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-CyqG1QME.js → cas-sign-in-button-BZCa4lR6.js} +1 -1
  14. package/dist/assets/{components-D-ptYjX_.js → components-_g-GjQeV.js} +3 -3
  15. package/dist/assets/{configuration-gate-C08mkkiX.js → configuration-gate-B4XVz7tS.js} +1 -1
  16. package/dist/assets/{createClipPathFromCoordSys-wj34-b1B.js → createClipPathFromCoordSys-z9Kmh-tu.js} +1 -1
  17. package/dist/assets/createSeriesDataSimply-CPqysF0K.js +1 -0
  18. package/dist/assets/{customGraphicKeyframeAnimation-DWrhrHL_.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-DSWsSruv.js → dingtalk-sign-in-button-DMPgE5kT.js} +1 -1
  27. package/dist/assets/{echarts-preview-DxHNfq1C.js → echarts-preview-R96Pbqmz.js} +1 -1
  28. package/dist/assets/{echarts-runtime-D8kXn6ME.js → echarts-runtime-Ce_z2eX_.js} +3 -3
  29. package/dist/assets/{echarts-runtime-advanced-QY8v95qK.js → echarts-runtime-advanced-Dr80KHJR.js} +1 -1
  30. package/dist/assets/{echarts-runtime-common-D-yqAK-r.js → echarts-runtime-common-DB95Yfc1.js} +1 -1
  31. package/dist/assets/{echarts-runtime-components-P4-i8jPU.js → echarts-runtime-components-DCJEaTJk.js} +1 -1
  32. package/dist/assets/{echarts-runtime-hierarchy-ByQZ-DbP.js → echarts-runtime-hierarchy-CezgZs9O.js} +1 -1
  33. package/dist/assets/{floating-BFVEyPEp.js → floating-O9WmiDXk.js} +2 -2
  34. package/dist/assets/index-BBKV6O5d.css +1 -0
  35. package/dist/assets/{index-Btk35Nx-.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-um5hdKdZ.js → index-DgB7uTMs.js} +2 -2
  39. package/dist/assets/{index-CwNxd66J.js → index-DroUAK80.js} +1 -1
  40. package/dist/assets/index-WKvygTBp.js +59 -0
  41. package/dist/assets/{labelHelper-DQqRy3a_.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-CQdRcR_p.js → oidc-sign-in-button-BIRonrpC.js} +1 -1
  44. package/dist/assets/{page-context-CnQ4GYM7.js → page-context-DAiW_dId.js} +3 -3
  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-DN_Vv6OL.js → sectorHelper-BuxrrJrA.js} +1 -1
  49. package/dist/assets/shortcut-BhKXxJLs.js +47 -0
  50. package/dist/assets/{sms-sign-in-form-DTX9GV2I.js → sms-sign-in-form-DKpxRDdZ.js} +1 -1
  51. package/dist/assets/{static.browser-jI1psK0d.js → static.browser-kzzpH0Xt.js} +8 -8
  52. package/dist/assets/switch-BdVxPIVU.js +1 -0
  53. package/dist/assets/{tool-cards-DFttLAfo.js → tool-cards-CpczIke-.js} +3 -3
  54. package/dist/assets/{use-authenticator-sign-in-CRGjzXju.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-C9j8wfCD.js → use-wecom-sign-in-dIqMtcN9.js} +1 -1
  57. package/dist/assets/wecom-sign-in-button-Dn_2zDab.js +1 -0
  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/components/auth/sign-up-form.tsx +287 -59
  70. package/src/components/auth/types.ts +23 -1
  71. package/src/extensions/nocobase-acl/README.md +28 -14
  72. package/src/extensions/nocobase-acl/demo/index.tsx +87 -15
  73. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +1 -0
  74. package/src/extensions/nocobase-mail/README.md +74 -0
  75. package/src/extensions/nocobase-mail/components/index.ts +79 -0
  76. package/src/extensions/nocobase-mail/components/mail-api.ts +423 -0
  77. package/src/extensions/nocobase-mail/components/mail-attachment-list.tsx +67 -0
  78. package/src/extensions/nocobase-mail/components/mail-compose-attachments.tsx +166 -0
  79. package/src/extensions/nocobase-mail/components/mail-compose.tsx +1071 -0
  80. package/src/extensions/nocobase-mail/components/mail-detail.tsx +653 -0
  81. package/src/extensions/nocobase-mail/components/mail-draft-autosave.ts +26 -0
  82. package/src/extensions/nocobase-mail/components/mail-empty.tsx +48 -0
  83. package/src/extensions/nocobase-mail/components/mail-filters.tsx +277 -0
  84. package/src/extensions/nocobase-mail/components/mail-inbox.tsx +544 -0
  85. package/src/extensions/nocobase-mail/components/mail-inline-images.ts +69 -0
  86. package/src/extensions/nocobase-mail/components/mail-label-badge.tsx +25 -0
  87. package/src/extensions/nocobase-mail/components/mail-label-manager.tsx +271 -0
  88. package/src/extensions/nocobase-mail/components/mail-labels-editor.tsx +267 -0
  89. package/src/extensions/nocobase-mail/components/mail-mass-tracking.tsx +271 -0
  90. package/src/extensions/nocobase-mail/components/mail-note-editor.tsx +99 -0
  91. package/src/extensions/nocobase-mail/components/mail-recipient-input.tsx +260 -0
  92. package/src/extensions/nocobase-mail/components/mail-reply-quote.ts +28 -0
  93. package/src/extensions/nocobase-mail/components/mail-rich-editor.tsx +287 -0
  94. package/src/extensions/nocobase-mail/components/mail-send-actions.tsx +238 -0
  95. package/src/extensions/nocobase-mail/components/mail-senders.ts +61 -0
  96. package/src/extensions/nocobase-mail/components/mail-settings-drawer.tsx +708 -0
  97. package/src/extensions/nocobase-mail/components/mail-showcase-layout.tsx +64 -0
  98. package/src/extensions/nocobase-mail/components/mail-signature-manager.tsx +258 -0
  99. package/src/extensions/nocobase-mail/components/mail-signatures.ts +25 -0
  100. package/src/extensions/nocobase-mail/components/mail-table.tsx +476 -0
  101. package/src/extensions/nocobase-mail/components/mail-template-manager.tsx +204 -0
  102. package/src/extensions/nocobase-mail/components/mail-toolbar.tsx +155 -0
  103. package/src/extensions/nocobase-mail/components/mail-unread.tsx +111 -0
  104. package/src/extensions/nocobase-mail/components/types.ts +297 -0
  105. package/src/extensions/nocobase-mail/components/use-mail-compose.tsx +262 -0
  106. package/src/extensions/nocobase-mail/components/use-mail-mass-messages.ts +54 -0
  107. package/src/extensions/nocobase-mail/components/use-mail-messages.ts +113 -0
  108. package/src/extensions/nocobase-mail/components/use-mail-signatures.ts +122 -0
  109. package/src/extensions/nocobase-mail/components/use-mail-templates.ts +54 -0
  110. package/src/extensions/nocobase-mail/extension.tsx +105 -0
  111. package/src/extensions/nocobase-mail/index.ts +1 -0
  112. package/src/extensions/nocobase-mail/mail-demo-pages.tsx +503 -0
  113. package/src/extensions/nocobase-mail/mail-pages.tsx +201 -0
  114. package/src/extensions/nocobase-mail/tests/mail-quote-html-regression.mjs +51 -0
  115. package/src/extensions/nocobase-mail/tests/mail-regression.mjs +649 -0
  116. package/src/extensions/nocobase-users-example/app-routes.tsx +121 -0
  117. package/src/extensions/nocobase-users-example/extension.tsx +2 -100
  118. package/src/extensions/nocobase-users-example/index.ts +1 -0
  119. package/src/lib/nocobase/acl/menu.ts +17 -3
  120. package/src/lib/nocobase/acl/types.ts +4 -0
  121. package/src/locales/en-US.ts +2 -1
  122. package/src/locales/zh-CN.ts +2 -1
  123. package/src/providers/runtime-config.ts +18 -0
  124. package/src/routes.tsx +10 -0
  125. package/src/vite-env.d.ts +3 -0
  126. package/vite.config.ts +8 -0
  127. package/dist/assets/auth-method-demo-DaOCuvsq.js +0 -1
  128. package/dist/assets/auto-login-provider-DyaPGK2D.js +0 -1
  129. package/dist/assets/auto-redirect-provider-CfwRvYOI.js +0 -1
  130. package/dist/assets/auto-redirect-provider-Cr3jmYts.js +0 -1
  131. package/dist/assets/auto-redirect-provider-QkJQRBbG.js +0 -1
  132. package/dist/assets/calendar-DjDRfD5t.js +0 -1
  133. package/dist/assets/card-DuQBOS6A.js +0 -1
  134. package/dist/assets/createSeriesDataSimply-D0A6SAVQ.js +0 -1
  135. package/dist/assets/demo-BYWHeKUe.js +0 -1
  136. package/dist/assets/demo-CPWoNtx8.js +0 -1
  137. package/dist/assets/demo-CyvJk5zV.js +0 -1
  138. package/dist/assets/demo-D4OiKplc.js +0 -1
  139. package/dist/assets/demo-DBueJg0m.js +0 -1
  140. package/dist/assets/demo-DkIhyiRK.js +0 -1
  141. package/dist/assets/demo-tpTjO0sC.js +0 -1
  142. package/dist/assets/index-BgCbGDMc.js +0 -43
  143. package/dist/assets/index-MB-jfQld.js +0 -686
  144. package/dist/assets/index-NH3aqqQz.css +0 -1
  145. package/dist/assets/index-gFtCXH3o.js +0 -58
  146. package/dist/assets/ldap-sign-in-form--6Gt3yGq.js +0 -1
  147. package/dist/assets/prompt-card-B2RS4nt2.js +0 -1
  148. package/dist/assets/prompt-output-DZHRM2ft.js +0 -1
  149. package/dist/assets/saml-sign-in-button-BHL5a0JV.js +0 -1
  150. package/dist/assets/shortcut-C5x5LQWU.js +0 -47
  151. package/dist/assets/switch-CRL8PmRI.js +0 -1
  152. package/dist/assets/use-dingtalk-sign-in-8WYzJ2jf.js +0 -1
  153. package/dist/assets/wecom-sign-in-button-mIS1zGSU.js +0 -1
@@ -0,0 +1,1071 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { ChevronDown, FileText, Loader2, PenLine, Quote, Trash2 } from "lucide-react";
3
+ import { toast } from "sonner";
4
+ import type {
5
+ MailAccount,
6
+ MailMessage,
7
+ MailRecipientOption,
8
+ MailSendPayload,
9
+ MailUploadedAttachment,
10
+ } from "./types";
11
+ import { mailApi } from "./mail-api";
12
+ import { applySignature } from "./mail-signatures";
13
+ import { useMailSignatures } from "./use-mail-signatures";
14
+ import { MailSignatureManager } from "./mail-signature-manager";
15
+ import { useMailTemplates } from "./use-mail-templates";
16
+ import { MailTemplateManager } from "./mail-template-manager";
17
+ import { MailSendActions } from "./mail-send-actions";
18
+ import { MailRecipientInput } from "./mail-recipient-input";
19
+ import { MailComposeAttachments } from "./mail-compose-attachments";
20
+ import { createDebouncedDraftSaver } from "./mail-draft-autosave";
21
+ import {
22
+ DEFAULT_MAIL_SENDER_KEY,
23
+ getMailSenderCandidates,
24
+ resolveMailSender,
25
+ } from "./mail-senders";
26
+ import { Button } from "@/components/ui/button";
27
+ import { Input } from "@/components/ui/input";
28
+ import { Label } from "@/components/ui/label";
29
+ import { MailRichEditor } from "./mail-rich-editor";
30
+ import {
31
+ Select,
32
+ SelectContent,
33
+ SelectItem,
34
+ SelectTrigger,
35
+ SelectValue,
36
+ } from "@/components/ui/select";
37
+ import {
38
+ DropdownMenu,
39
+ DropdownMenuContent,
40
+ DropdownMenuItem,
41
+ DropdownMenuLabel,
42
+ DropdownMenuRadioGroup,
43
+ DropdownMenuRadioItem,
44
+ DropdownMenuSeparator,
45
+ DropdownMenuTrigger,
46
+ } from "@/components/ui/dropdown-menu";
47
+ import {
48
+ Dialog,
49
+ DialogContent,
50
+ DialogHeader,
51
+ DialogTitle,
52
+ } from "@/components/ui/dialog";
53
+ import {
54
+ AlertDialog,
55
+ AlertDialogAction,
56
+ AlertDialogCancel,
57
+ AlertDialogContent,
58
+ AlertDialogDescription,
59
+ AlertDialogFooter,
60
+ AlertDialogHeader,
61
+ AlertDialogTitle,
62
+ } from "@/components/ui/alert-dialog";
63
+ import {
64
+ Sheet,
65
+ SheetContent,
66
+ SheetHeader,
67
+ SheetTitle,
68
+ } from "@/components/ui/sheet";
69
+ import { cn } from "@/lib/utils";
70
+ import { serializeReplyQuote, splitReplyQuote } from "./mail-reply-quote";
71
+
72
+ export interface ComposeInitialValues {
73
+ from?: string;
74
+ accountEmail?: string;
75
+ identityEmail?: string;
76
+ to?: string;
77
+ cc?: string;
78
+ subject?: string;
79
+ body?: string;
80
+ replyBody?: string;
81
+ replyTo?: string;
82
+ isDraft?: boolean;
83
+ id?: number;
84
+ attachments?: MailUploadedAttachment[];
85
+ reference?: ComposeReference;
86
+ }
87
+
88
+ export interface ComposeReference {
89
+ from: string;
90
+ date?: string;
91
+ subject?: string;
92
+ preview?: string;
93
+ html?: string;
94
+ }
95
+
96
+ export type ComposeMode = "new" | "reply" | "replyAll" | "forward" | "draft";
97
+
98
+ const parseList = (value: string) =>
99
+ value
100
+ .split(/[,;\n]/)
101
+ .map((item) => item.trim())
102
+ .filter(Boolean);
103
+
104
+ const uniqueRecipients = (value: string) => {
105
+ const seen = new Set<string>();
106
+ return parseList(value).filter((address) => {
107
+ const key = address.toLocaleLowerCase();
108
+ if (seen.has(key)) return false;
109
+ seen.add(key);
110
+ return true;
111
+ });
112
+ };
113
+
114
+ function MailComposeReference({
115
+ reference,
116
+ contentHtml,
117
+ onRemove,
118
+ }: {
119
+ reference: ComposeReference;
120
+ contentHtml?: string;
121
+ onRemove?: () => void;
122
+ }) {
123
+ const [expanded, setExpanded] = useState(true);
124
+
125
+ return (
126
+ <div className="overflow-hidden rounded-lg border bg-muted/25">
127
+ <div className="flex items-center">
128
+ <button
129
+ type="button"
130
+ className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left"
131
+ aria-expanded={expanded}
132
+ onClick={() => setExpanded((value) => !value)}
133
+ >
134
+ <Quote className="size-3.5 shrink-0 text-muted-foreground" />
135
+ <span className="min-w-0 flex-1">
136
+ <span className="block truncate text-xs font-medium">
137
+ Quoted message from {reference.from}
138
+ </span>
139
+ <span className="block truncate text-[11px] text-muted-foreground">
140
+ {[reference.subject, reference.date].filter(Boolean).join(" · ")}
141
+ </span>
142
+ </span>
143
+ <ChevronDown
144
+ className={cn(
145
+ "size-3.5 shrink-0 text-muted-foreground transition-transform",
146
+ expanded && "rotate-180"
147
+ )}
148
+ />
149
+ </button>
150
+ {onRemove && (
151
+ <Button
152
+ type="button"
153
+ variant="ghost"
154
+ size="icon-xs"
155
+ className="mr-2 shrink-0 text-muted-foreground hover:text-destructive"
156
+ title="Remove quoted message"
157
+ aria-label="Remove quoted message"
158
+ onClick={onRemove}
159
+ >
160
+ <Trash2 />
161
+ </Button>
162
+ )}
163
+ </div>
164
+ {expanded &&
165
+ (contentHtml ? (
166
+ <iframe
167
+ title="Quoted message content"
168
+ sandbox=""
169
+ srcDoc={contentHtml}
170
+ className="h-64 w-full border-0 border-t bg-white"
171
+ />
172
+ ) : reference.preview ? (
173
+ <div className="max-h-64 overflow-y-auto border-t px-4 py-3 text-sm leading-6 whitespace-pre-wrap text-muted-foreground">
174
+ {reference.preview}
175
+ </div>
176
+ ) : null)}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ export interface MailComposeFormProps {
182
+ accounts?: MailAccount[];
183
+ initial?: ComposeInitialValues;
184
+ mode?: ComposeMode;
185
+ onSent?: () => void;
186
+ onCancel?: () => void;
187
+ showCancel?: boolean;
188
+ allowScheduleSend?: boolean;
189
+ allowBulkSend?: boolean;
190
+ autoSaveDraft?: boolean;
191
+ bulkOnly?: boolean;
192
+ defaultBulkIntervalMs?: number;
193
+ recipientOptions?: MailRecipientOption[];
194
+ onAccountChange?: (account: MailAccount) => void;
195
+ className?: string;
196
+ }
197
+
198
+ export function MailComposeForm({
199
+ accounts: accountsProp,
200
+ initial,
201
+ mode = "new",
202
+ onSent,
203
+ onCancel,
204
+ showCancel = false,
205
+ allowScheduleSend = true,
206
+ allowBulkSend = false,
207
+ autoSaveDraft = true,
208
+ bulkOnly = false,
209
+ defaultBulkIntervalMs = 2_000,
210
+ recipientOptions,
211
+ onAccountChange,
212
+ className,
213
+ }: MailComposeFormProps) {
214
+ const [internalAccounts, setInternalAccounts] = useState<MailAccount[]>([]);
215
+ useEffect(() => {
216
+ if (accountsProp) return;
217
+ let active = true;
218
+ mailApi
219
+ .getAccounts()
220
+ .then((accounts) => active && setInternalAccounts(accounts))
221
+ .catch(() => active && setInternalAccounts([]));
222
+ return () => {
223
+ active = false;
224
+ };
225
+ }, [accountsProp]);
226
+ const accounts = accountsProp ?? internalAccounts;
227
+
228
+ const senderCandidates = useMemo(() => getMailSenderCandidates(accounts), [accounts]);
229
+ const defaultSenderKey = senderCandidates[0]?.key ?? "";
230
+
231
+ const [senderKey, setSenderKey] = useState("");
232
+ const [to, setTo] = useState(initial?.to ?? "");
233
+ const [cc, setCc] = useState(initial?.cc ?? "");
234
+ const [showCc, setShowCc] = useState(Boolean(initial?.cc));
235
+ const [subject, setSubject] = useState(initial?.subject ?? "");
236
+ const [body, setBody] = useState(initial?.body ?? "");
237
+ const [replyBody, setReplyBody] = useState(initial?.replyBody);
238
+ const [sendAction, setSendAction] = useState<
239
+ "send" | "schedule" | "bulk" | null
240
+ >(null);
241
+ const [savingDraft, setSavingDraft] = useState(false);
242
+ const [autoSavingDraft, setAutoSavingDraft] = useState(false);
243
+ const [lastAutoSavedAt, setLastAutoSavedAt] = useState<Date>();
244
+ const [autoSaveError, setAutoSaveError] = useState(false);
245
+ const [editRevision, setEditRevision] = useState(0);
246
+ const [draftId, setDraftId] = useState<number | undefined>(initial?.id);
247
+ const [attachments, setAttachments] = useState<MailUploadedAttachment[]>(
248
+ initial?.attachments ?? []
249
+ );
250
+ const [uploadingAttachments, setUploadingAttachments] = useState(false);
251
+ const [deleteDraftOpen, setDeleteDraftOpen] = useState(false);
252
+ const [recoverableDraft, setRecoverableDraft] = useState<MailMessage>();
253
+ const [recoveryChecking, setRecoveryChecking] = useState(false);
254
+ const checkedRecoveryKeys = useRef(new Set<string>());
255
+ const recoverySequence = useRef(0);
256
+ const recoveryPending = useRef(false);
257
+ const lastSavedSnapshot = useRef("");
258
+
259
+ const markEdited = useCallback(() => {
260
+ setEditRevision((revision) => revision + 1);
261
+ setAutoSaveError(false);
262
+ }, []);
263
+
264
+ const selectedSender = senderCandidates.find((candidate) => candidate.key === senderKey);
265
+ const activeAccount = accounts.find((account) => account.id === selectedSender?.accountId);
266
+ const handleAccountChange = useCallback(
267
+ (account: MailAccount) => {
268
+ setInternalAccounts((prev) =>
269
+ prev.map((item) => (item.id === account.id ? account : item))
270
+ );
271
+ onAccountChange?.(account);
272
+ },
273
+ [onAccountChange]
274
+ );
275
+ const { signatures, create, update, remove, setDefault } = useMailSignatures(
276
+ activeAccount,
277
+ handleAccountChange
278
+ );
279
+ const [signatureId, setSignatureId] = useState("");
280
+ const [managerOpen, setManagerOpen] = useState(false);
281
+ const appliedDefaultRef = useRef(false);
282
+
283
+ const {
284
+ templates,
285
+ create: createTemplate,
286
+ update: updateTemplate,
287
+ remove: removeTemplate,
288
+ } = useMailTemplates();
289
+ const [templateId, setTemplateId] = useState("");
290
+ const [templateManagerOpen, setTemplateManagerOpen] = useState(false);
291
+
292
+ useEffect(() => {
293
+ setTo(initial?.to ?? "");
294
+ setCc(initial?.cc ?? "");
295
+ setShowCc(Boolean(initial?.cc));
296
+ setSubject(initial?.subject ?? "");
297
+ setBody(initial?.body ?? "");
298
+ setReplyBody(initial?.replyBody);
299
+ setDraftId(initial?.id);
300
+ setAttachments(initial?.attachments ?? []);
301
+ setEditRevision(0);
302
+ setLastAutoSavedAt(undefined);
303
+ setAutoSaveError(false);
304
+ setRecoverableDraft(undefined);
305
+ checkedRecoveryKeys.current.clear();
306
+ lastSavedSnapshot.current = "";
307
+ }, [initial]);
308
+
309
+ useEffect(() => {
310
+ const preferred = resolveMailSender(senderCandidates, initial);
311
+ setSenderKey((prev) => {
312
+ if (preferred) return preferred.key;
313
+ return senderCandidates.some((candidate) => candidate.key === prev) ? prev : "";
314
+ });
315
+ }, [initial, senderCandidates]);
316
+
317
+ useEffect(() => {
318
+ if (!defaultSenderKey) return;
319
+ setSenderKey((prev) => {
320
+ if (senderCandidates.some((candidate) => candidate.key === prev)) return prev;
321
+ const saved = window.localStorage.getItem(DEFAULT_MAIL_SENDER_KEY);
322
+ return senderCandidates.some((candidate) => candidate.key === saved)
323
+ ? saved!
324
+ : defaultSenderKey;
325
+ });
326
+ }, [defaultSenderKey, senderCandidates]);
327
+
328
+ useEffect(() => {
329
+ if (senderKey) window.localStorage.setItem(DEFAULT_MAIL_SENDER_KEY, senderKey);
330
+ }, [senderKey]);
331
+
332
+ useEffect(() => {
333
+ if (mode === "draft") return;
334
+ appliedDefaultRef.current = false;
335
+ setSignatureId("");
336
+ setBody((prev) => applySignature(prev, undefined));
337
+ }, [activeAccount?.id, mode]);
338
+
339
+ useEffect(() => {
340
+ if (mode === "draft") return;
341
+ if (appliedDefaultRef.current) return;
342
+ if (!signatures.length) return;
343
+ appliedDefaultRef.current = true;
344
+ const fallback = signatures.find((signature) => signature.isDefault);
345
+ if (!fallback) return;
346
+ setSignatureId(String(fallback.id));
347
+ setBody((prev) => applySignature(prev, fallback.content));
348
+ }, [mode, signatures]);
349
+
350
+ const handleSignatureChange = useCallback(
351
+ (value: string | null) => {
352
+ const id = !value || value === "none" ? "" : value;
353
+ setSignatureId(id);
354
+ const signature = signatures.find((item) => String(item.id) === id);
355
+ setBody((prev) => applySignature(prev, signature?.content));
356
+ markEdited();
357
+ },
358
+ [signatures, markEdited]
359
+ );
360
+
361
+ const handleTemplateChange = useCallback(
362
+ (value: string | null) => {
363
+ const id = !value || value === "none" ? "" : value;
364
+ setTemplateId(id);
365
+ const template = templates.find((item) => String(item.id) === id);
366
+ if (!template) return;
367
+ if (template.subject) setSubject(template.subject);
368
+ const signature = signatures.find((item) => String(item.id) === signatureId);
369
+ setBody(applySignature(template.content, signature?.content));
370
+ markEdited();
371
+ },
372
+ [templates, signatures, signatureId, markEdited]
373
+ );
374
+
375
+ const recipients = useMemo(() => uniqueRecipients(to), [to]);
376
+ const hasValidRecipients = useMemo(
377
+ () =>
378
+ recipients.length > 0 &&
379
+ recipients.every((recipient) => /.+@.+\..+/.test(recipient)),
380
+ [recipients]
381
+ );
382
+
383
+ const buildPayload = useCallback(
384
+ (overrides: Partial<MailSendPayload> = {}): MailSendPayload => ({
385
+ id: draftId,
386
+ from: selectedSender?.identityEmail ?? "",
387
+ accountEmail: selectedSender?.accountEmail,
388
+ identityEmail: selectedSender?.identityEmail,
389
+ to: recipients,
390
+ cc: parseList(cc),
391
+ subject,
392
+ body: serializeReplyQuote(body, replyBody),
393
+ attachments,
394
+ replyTo: initial?.replyTo,
395
+ isDraft: Boolean(draftId || initial?.isDraft),
396
+ ...overrides,
397
+ }),
398
+ [draftId, initial, selectedSender, recipients, cc, subject, body, replyBody, attachments]
399
+ );
400
+
401
+ const draftSnapshot = useCallback((payload: MailSendPayload) => {
402
+ const content = { ...payload };
403
+ delete content.id;
404
+ return JSON.stringify(content);
405
+ }, []);
406
+
407
+ const saveDraftAutomatically = useCallback(
408
+ async ({ payload, snapshot }: { payload: MailSendPayload; snapshot: string }) => {
409
+ setAutoSavingDraft(true);
410
+ setAutoSaveError(false);
411
+ try {
412
+ const saved = await mailApi.saveDraft(payload);
413
+ setDraftId(saved.id);
414
+ lastSavedSnapshot.current = snapshot;
415
+ setLastAutoSavedAt(new Date());
416
+ } catch {
417
+ setAutoSaveError(true);
418
+ } finally {
419
+ setAutoSavingDraft(false);
420
+ }
421
+ },
422
+ []
423
+ );
424
+
425
+ const draftSaver = useMemo(
426
+ () => createDebouncedDraftSaver(saveDraftAutomatically),
427
+ [saveDraftAutomatically]
428
+ );
429
+
430
+ useEffect(() => () => draftSaver.cancel(), [draftSaver]);
431
+
432
+ useEffect(() => {
433
+ if (
434
+ mode !== "new" ||
435
+ initial?.isDraft ||
436
+ draftId ||
437
+ !selectedSender ||
438
+ !hasValidRecipients
439
+ ) {
440
+ recoveryPending.current = false;
441
+ setRecoveryChecking(false);
442
+ return;
443
+ }
444
+
445
+ const recoveryKey = [
446
+ selectedSender.accountEmail.toLocaleLowerCase(),
447
+ selectedSender.identityEmail.toLocaleLowerCase(),
448
+ ...recipients.map((recipient) => recipient.toLocaleLowerCase()).sort(),
449
+ ].join("|");
450
+ if (checkedRecoveryKeys.current.has(recoveryKey)) return;
451
+
452
+ const sequence = ++recoverySequence.current;
453
+ recoveryPending.current = true;
454
+ setRecoveryChecking(true);
455
+ const timer = setTimeout(() => {
456
+ mailApi
457
+ .findDraft({
458
+ accountEmail: selectedSender.accountEmail,
459
+ identityEmail: selectedSender.identityEmail,
460
+ to: recipients,
461
+ })
462
+ .then((draft) => {
463
+ if (sequence !== recoverySequence.current) return;
464
+ checkedRecoveryKeys.current.add(recoveryKey);
465
+ if (draft?.isDraft && !draft.mailId && !draft.rawId) {
466
+ setRecoverableDraft(draft);
467
+ }
468
+ })
469
+ .catch(() => undefined)
470
+ .finally(() => {
471
+ if (sequence !== recoverySequence.current) return;
472
+ recoveryPending.current = false;
473
+ setRecoveryChecking(false);
474
+ });
475
+ }, 500);
476
+
477
+ return () => {
478
+ clearTimeout(timer);
479
+ recoverySequence.current = sequence + 1;
480
+ recoveryPending.current = false;
481
+ };
482
+ }, [draftId, hasValidRecipients, initial?.isDraft, mode, recipients, selectedSender]);
483
+
484
+ useEffect(() => {
485
+ draftSaver.cancel();
486
+ if (
487
+ !autoSaveDraft ||
488
+ bulkOnly ||
489
+ !editRevision ||
490
+ !selectedSender ||
491
+ !hasValidRecipients ||
492
+ uploadingAttachments ||
493
+ sendAction !== null ||
494
+ savingDraft ||
495
+ autoSavingDraft ||
496
+ recoveryChecking ||
497
+ recoveryPending.current ||
498
+ recoverableDraft
499
+ ) {
500
+ return;
501
+ }
502
+
503
+ const payload = buildPayload({ isDraft: true });
504
+ const snapshot = draftSnapshot(payload);
505
+ if (snapshot === lastSavedSnapshot.current) return;
506
+ draftSaver.schedule({ payload, snapshot });
507
+ return () => draftSaver.cancel();
508
+ }, [
509
+ autoSaveDraft,
510
+ autoSavingDraft,
511
+ buildPayload,
512
+ bulkOnly,
513
+ draftSaver,
514
+ draftSnapshot,
515
+ editRevision,
516
+ hasValidRecipients,
517
+ recoverableDraft,
518
+ recoveryChecking,
519
+ savingDraft,
520
+ selectedSender,
521
+ sendAction,
522
+ uploadingAttachments,
523
+ ]);
524
+
525
+ const validateMessage = useCallback(() => {
526
+ if (!selectedSender) {
527
+ toast.error("Please select a sender account");
528
+ return false;
529
+ }
530
+ if (uploadingAttachments) {
531
+ toast.error("Finish, retry, or remove pending attachments before continuing");
532
+ return false;
533
+ }
534
+ if (!recipients.length) {
535
+ toast.error("Please add at least one recipient");
536
+ return false;
537
+ }
538
+ return true;
539
+ }, [selectedSender, recipients.length, uploadingAttachments]);
540
+
541
+ const handleSend = useCallback(async () => {
542
+ if (!validateMessage()) return;
543
+ draftSaver.cancel();
544
+ setSendAction("send");
545
+ try {
546
+ await mailApi.send(buildPayload());
547
+ toast.success("Message sent");
548
+ onSent?.();
549
+ } catch (error) {
550
+ toast.error(error instanceof Error ? error.message : "Failed to send");
551
+ } finally {
552
+ setSendAction(null);
553
+ }
554
+ }, [validateMessage, draftSaver, buildPayload, onSent]);
555
+
556
+ const handleScheduleSend = useCallback(
557
+ async (sendAt: Date) => {
558
+ if (!validateMessage()) return false;
559
+ draftSaver.cancel();
560
+ setSendAction("schedule");
561
+ try {
562
+ await mailApi.send(
563
+ buildPayload({ scheduleSendAt: sendAt.toISOString() })
564
+ );
565
+ toast.success("Message scheduled");
566
+ onSent?.();
567
+ return true;
568
+ } catch (error) {
569
+ toast.error(
570
+ error instanceof Error ? error.message : "Failed to schedule message"
571
+ );
572
+ return false;
573
+ } finally {
574
+ setSendAction(null);
575
+ }
576
+ },
577
+ [validateMessage, draftSaver, buildPayload, onSent]
578
+ );
579
+
580
+ const handleBulkSend = useCallback(
581
+ async (interval: number) => {
582
+ if (!validateMessage()) return false;
583
+ draftSaver.cancel();
584
+ if (recipients.length < 2) {
585
+ toast.error("Bulk send requires at least two recipients");
586
+ return false;
587
+ }
588
+ setSendAction("bulk");
589
+ try {
590
+ await mailApi.massSend(buildPayload(), { interval });
591
+ toast.success(`Bulk send started for ${recipients.length} recipients`);
592
+ onSent?.();
593
+ return true;
594
+ } catch (error) {
595
+ toast.error(
596
+ error instanceof Error ? error.message : "Failed to start bulk send"
597
+ );
598
+ return false;
599
+ } finally {
600
+ setSendAction(null);
601
+ }
602
+ },
603
+ [validateMessage, recipients.length, draftSaver, buildPayload, onSent]
604
+ );
605
+
606
+ const handleSaveDraft = useCallback(async () => {
607
+ setSavingDraft(true);
608
+ try {
609
+ const payload = buildPayload({ isDraft: true });
610
+ const saved = await mailApi.saveDraft(payload);
611
+ setDraftId(saved.id);
612
+ lastSavedSnapshot.current = draftSnapshot(payload);
613
+ setLastAutoSavedAt(new Date());
614
+ setAutoSaveError(false);
615
+ toast.success("Draft saved");
616
+ onSent?.();
617
+ } catch (error) {
618
+ toast.error(error instanceof Error ? error.message : "Failed to save draft");
619
+ } finally {
620
+ setSavingDraft(false);
621
+ }
622
+ }, [buildPayload, draftSnapshot, onSent]);
623
+
624
+ const handleDeleteDraft = useCallback(async () => {
625
+ if (!draftId) return;
626
+ try {
627
+ await mailApi.destroyMessages([draftId]);
628
+ toast.success("Draft deleted");
629
+ setDeleteDraftOpen(false);
630
+ onSent?.();
631
+ } catch (error) {
632
+ toast.error(error instanceof Error ? error.message : "Failed to delete draft");
633
+ }
634
+ }, [draftId, onSent]);
635
+
636
+ const useRecoveredDraft = useCallback(() => {
637
+ if (!recoverableDraft) return;
638
+ const recoveredAttachments = (recoverableDraft.attachments ?? []).flatMap(
639
+ (attachment) =>
640
+ attachment.path
641
+ ? [
642
+ {
643
+ originalname: attachment.originalname || attachment.filename,
644
+ filename: attachment.originalname || attachment.filename,
645
+ path: attachment.path,
646
+ size: attachment.size ?? 0,
647
+ encoding: attachment.encoding || "7bit",
648
+ mimetype:
649
+ attachment.mimetype || attachment.mimeType || "application/octet-stream",
650
+ mimeType:
651
+ attachment.mimeType || attachment.mimetype || "application/octet-stream",
652
+ },
653
+ ]
654
+ : []
655
+ );
656
+ setCc(recoverableDraft.cc || "");
657
+ setShowCc(Boolean(recoverableDraft.cc));
658
+ setSubject(recoverableDraft.subject || "");
659
+ const recoveredContent = splitReplyQuote(
660
+ recoverableDraft.bodyHtml || recoverableDraft.bodyText || ""
661
+ );
662
+ setBody(recoveredContent.body);
663
+ setReplyBody(recoveredContent.replyBody);
664
+ setAttachments(recoveredAttachments);
665
+ setDraftId(recoverableDraft.id);
666
+ setRecoverableDraft(undefined);
667
+ setEditRevision(0);
668
+ setLastAutoSavedAt(
669
+ recoverableDraft.updatedAt ? new Date(recoverableDraft.updatedAt) : new Date()
670
+ );
671
+ toast.success("Draft restored");
672
+ }, [recoverableDraft]);
673
+
674
+ const busy =
675
+ sendAction !== null || savingDraft || autoSavingDraft || uploadingAttachments;
676
+
677
+ return (
678
+ <div className={cn("flex flex-col gap-3", className)}>
679
+ <div className="grid grid-cols-[64px_1fr] items-center gap-2">
680
+ <Label className="text-xs text-muted-foreground">From</Label>
681
+ <Select
682
+ value={senderKey}
683
+ onValueChange={(value) => {
684
+ setSenderKey(value ?? "");
685
+ markEdited();
686
+ }}
687
+ >
688
+ <SelectTrigger className="h-9 w-full">
689
+ <SelectValue placeholder="Select sender">
690
+ {selectedSender?.label}
691
+ </SelectValue>
692
+ </SelectTrigger>
693
+ <SelectContent>
694
+ {senderCandidates.map((sender) => (
695
+ <SelectItem key={sender.key} value={sender.key}>
696
+ {sender.label}
697
+ </SelectItem>
698
+ ))}
699
+ </SelectContent>
700
+ </Select>
701
+ </div>
702
+
703
+ <div className="grid grid-cols-[64px_1fr] items-center gap-2">
704
+ <Label className="text-xs text-muted-foreground">To</Label>
705
+ <div className="flex items-center gap-2">
706
+ <MailRecipientInput
707
+ value={to}
708
+ onChange={(value) => {
709
+ setTo(value);
710
+ markEdited();
711
+ }}
712
+ options={recipientOptions}
713
+ placeholder="Add recipients…"
714
+ />
715
+ {!showCc && (
716
+ <Button variant="ghost" size="xs" onClick={() => setShowCc(true)}>
717
+ Cc
718
+ </Button>
719
+ )}
720
+ </div>
721
+ </div>
722
+
723
+ {showCc && (
724
+ <div className="grid grid-cols-[64px_1fr] items-center gap-2">
725
+ <Label className="text-xs text-muted-foreground">Cc</Label>
726
+ <MailRecipientInput
727
+ value={cc}
728
+ onChange={(value) => {
729
+ setCc(value);
730
+ markEdited();
731
+ }}
732
+ options={recipientOptions}
733
+ placeholder="Add Cc recipients…"
734
+ />
735
+ </div>
736
+ )}
737
+
738
+ <div className="grid grid-cols-[64px_1fr] items-center gap-2">
739
+ <Label className="text-xs text-muted-foreground">Subject</Label>
740
+ <Input
741
+ value={subject}
742
+ onChange={(e) => {
743
+ setSubject(e.target.value);
744
+ markEdited();
745
+ }}
746
+ placeholder="Subject"
747
+ className="h-9"
748
+ />
749
+ </div>
750
+
751
+ <MailRichEditor
752
+ value={body}
753
+ onChange={(value) => {
754
+ setBody(value);
755
+ markEdited();
756
+ }}
757
+ placeholder="Write your message…"
758
+ toolbarActions={
759
+ <>
760
+ <DropdownMenu>
761
+ <DropdownMenuTrigger
762
+ render={
763
+ <Button
764
+ variant="ghost"
765
+ size="icon-xs"
766
+ title="Template"
767
+ aria-label="Template"
768
+ />
769
+ }
770
+ >
771
+ <FileText />
772
+ </DropdownMenuTrigger>
773
+ <DropdownMenuContent align="end" className="min-w-52">
774
+ <DropdownMenuRadioGroup
775
+ value={templateId || "none"}
776
+ onValueChange={handleTemplateChange}
777
+ >
778
+ <DropdownMenuLabel>Message template</DropdownMenuLabel>
779
+ <DropdownMenuRadioItem value="none">No template</DropdownMenuRadioItem>
780
+ {templates.map((template) => (
781
+ <DropdownMenuRadioItem key={template.id} value={String(template.id)}>
782
+ {template.name}
783
+ </DropdownMenuRadioItem>
784
+ ))}
785
+ </DropdownMenuRadioGroup>
786
+ <DropdownMenuSeparator />
787
+ <DropdownMenuItem onClick={() => setTemplateManagerOpen(true)}>
788
+ Manage templates
789
+ </DropdownMenuItem>
790
+ </DropdownMenuContent>
791
+ </DropdownMenu>
792
+
793
+ <DropdownMenu>
794
+ <DropdownMenuTrigger
795
+ render={
796
+ <Button
797
+ variant="ghost"
798
+ size="icon-xs"
799
+ title="Signature"
800
+ aria-label="Signature"
801
+ />
802
+ }
803
+ >
804
+ <PenLine />
805
+ </DropdownMenuTrigger>
806
+ <DropdownMenuContent align="end" className="min-w-52">
807
+ <DropdownMenuRadioGroup
808
+ value={signatureId || "none"}
809
+ onValueChange={handleSignatureChange}
810
+ >
811
+ <DropdownMenuLabel>Signature</DropdownMenuLabel>
812
+ <DropdownMenuRadioItem value="none">No signature</DropdownMenuRadioItem>
813
+ {signatures.map((signature) => (
814
+ <DropdownMenuRadioItem key={signature.id} value={String(signature.id)}>
815
+ {signature.name}
816
+ </DropdownMenuRadioItem>
817
+ ))}
818
+ </DropdownMenuRadioGroup>
819
+ <DropdownMenuSeparator />
820
+ <DropdownMenuItem onClick={() => setManagerOpen(true)}>
821
+ Manage signatures
822
+ </DropdownMenuItem>
823
+ </DropdownMenuContent>
824
+ </DropdownMenu>
825
+ </>
826
+ }
827
+ />
828
+
829
+ {initial?.reference &&
830
+ mode !== "draft" &&
831
+ (mode === "forward" || Boolean(replyBody)) && (
832
+ <MailComposeReference
833
+ reference={initial.reference}
834
+ contentHtml={replyBody || initial.reference.html}
835
+ onRemove={
836
+ mode === "reply" || mode === "replyAll"
837
+ ? () => {
838
+ setReplyBody(undefined);
839
+ markEdited();
840
+ }
841
+ : undefined
842
+ }
843
+ />
844
+ )}
845
+
846
+ <div className="flex items-end justify-between gap-2">
847
+ <div className="min-w-0 flex-1">
848
+ <MailComposeAttachments
849
+ key={draftId ?? "new-draft"}
850
+ value={attachments}
851
+ onChange={setAttachments}
852
+ onBusyChange={setUploadingAttachments}
853
+ onDirty={markEdited}
854
+ disabled={sendAction !== null || savingDraft}
855
+ />
856
+ </div>
857
+
858
+ <div className="flex shrink-0 items-center gap-2">
859
+ {draftId && (
860
+ <Button
861
+ variant="ghost"
862
+ size="sm"
863
+ className="text-destructive hover:text-destructive"
864
+ onClick={() => setDeleteDraftOpen(true)}
865
+ disabled={busy}
866
+ >
867
+ <Trash2 /> Delete draft
868
+ </Button>
869
+ )}
870
+ {showCancel && (
871
+ <Button variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
872
+ Cancel
873
+ </Button>
874
+ )}
875
+ {!bulkOnly && (
876
+ <div className="flex items-center gap-2">
877
+ {autoSaveDraft && (
878
+ <span
879
+ className={cn(
880
+ "text-[11px] text-muted-foreground",
881
+ autoSaveError && "text-destructive"
882
+ )}
883
+ >
884
+ {autoSavingDraft
885
+ ? "Saving…"
886
+ : autoSaveError
887
+ ? "Auto-save failed"
888
+ : lastAutoSavedAt
889
+ ? `Saved ${lastAutoSavedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
890
+ : hasValidRecipients
891
+ ? "Auto-save on"
892
+ : "Add recipient to auto-save"}
893
+ </span>
894
+ )}
895
+ <Button variant="outline" size="sm" onClick={handleSaveDraft} disabled={busy}>
896
+ {savingDraft ? <Loader2 className="animate-spin" /> : null}
897
+ Save draft
898
+ </Button>
899
+ </div>
900
+ )}
901
+ <MailSendActions
902
+ disabled={busy}
903
+ loading={sendAction !== null}
904
+ recipientCount={recipients.length}
905
+ allowScheduleSend={!bulkOnly && allowScheduleSend}
906
+ allowBulkSend={!bulkOnly && allowBulkSend}
907
+ defaultBulkIntervalMs={defaultBulkIntervalMs}
908
+ primaryMode={bulkOnly ? "bulk" : "send"}
909
+ onSend={handleSend}
910
+ onScheduleSend={handleScheduleSend}
911
+ onBulkSend={handleBulkSend}
912
+ />
913
+ </div>
914
+ </div>
915
+
916
+ <MailSignatureManager
917
+ open={managerOpen}
918
+ onOpenChange={setManagerOpen}
919
+ signatures={signatures}
920
+ onCreate={create}
921
+ onUpdate={update}
922
+ onRemove={remove}
923
+ onSetDefault={setDefault}
924
+ />
925
+
926
+ <MailTemplateManager
927
+ open={templateManagerOpen}
928
+ onOpenChange={setTemplateManagerOpen}
929
+ templates={templates}
930
+ onCreate={createTemplate}
931
+ onUpdate={updateTemplate}
932
+ onRemove={removeTemplate}
933
+ />
934
+
935
+ <AlertDialog open={deleteDraftOpen} onOpenChange={setDeleteDraftOpen}>
936
+ <AlertDialogContent>
937
+ <AlertDialogHeader>
938
+ <AlertDialogTitle>Delete this draft?</AlertDialogTitle>
939
+ <AlertDialogDescription>
940
+ This permanently removes the draft and cannot be undone.
941
+ </AlertDialogDescription>
942
+ </AlertDialogHeader>
943
+ <AlertDialogFooter>
944
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
945
+ <AlertDialogAction
946
+ variant="destructive"
947
+ onClick={() => void handleDeleteDraft()}
948
+ >
949
+ Delete draft
950
+ </AlertDialogAction>
951
+ </AlertDialogFooter>
952
+ </AlertDialogContent>
953
+ </AlertDialog>
954
+
955
+ <AlertDialog
956
+ open={Boolean(recoverableDraft)}
957
+ onOpenChange={(open) => !open && setRecoverableDraft(undefined)}
958
+ >
959
+ <AlertDialogContent>
960
+ <AlertDialogHeader>
961
+ <AlertDialogTitle>Restore saved draft?</AlertDialogTitle>
962
+ <AlertDialogDescription>
963
+ A draft already exists for this sender and recipient. Restore its
964
+ subject, message, and attachments?
965
+ </AlertDialogDescription>
966
+ </AlertDialogHeader>
967
+ <AlertDialogFooter>
968
+ <AlertDialogCancel onClick={() => setRecoverableDraft(undefined)}>
969
+ Ignore
970
+ </AlertDialogCancel>
971
+ <AlertDialogAction onClick={useRecoveredDraft}>
972
+ Restore draft
973
+ </AlertDialogAction>
974
+ </AlertDialogFooter>
975
+ </AlertDialogContent>
976
+ </AlertDialog>
977
+ </div>
978
+ );
979
+ }
980
+
981
+ const composeTitle: Record<ComposeMode, string> = {
982
+ new: "New message",
983
+ reply: "Reply",
984
+ replyAll: "Reply all",
985
+ forward: "Forward",
986
+ draft: "Edit draft",
987
+ };
988
+
989
+ export type ComposeVariant = "drawer" | "dialog";
990
+
991
+ export interface MailComposeProps {
992
+ open: boolean;
993
+ onOpenChange: (open: boolean) => void;
994
+ accounts?: MailAccount[];
995
+ initial?: ComposeInitialValues;
996
+ mode?: ComposeMode;
997
+ onSent?: () => void;
998
+ variant?: ComposeVariant;
999
+ allowScheduleSend?: boolean;
1000
+ allowBulkSend?: boolean;
1001
+ autoSaveDraft?: boolean;
1002
+ defaultBulkIntervalMs?: number;
1003
+ recipientOptions?: MailRecipientOption[];
1004
+ onAccountChange?: (account: MailAccount) => void;
1005
+ }
1006
+
1007
+ export function MailCompose({
1008
+ open,
1009
+ onOpenChange,
1010
+ accounts,
1011
+ initial,
1012
+ mode = "new",
1013
+ onSent,
1014
+ variant = "drawer",
1015
+ allowScheduleSend = true,
1016
+ allowBulkSend = false,
1017
+ autoSaveDraft = true,
1018
+ defaultBulkIntervalMs = 2_000,
1019
+ recipientOptions,
1020
+ onAccountChange,
1021
+ }: MailComposeProps) {
1022
+ const [resetKey, setResetKey] = useState(0);
1023
+ const prevOpen = useRef(open);
1024
+ useEffect(() => {
1025
+ if (open && !prevOpen.current) setResetKey((key) => key + 1);
1026
+ prevOpen.current = open;
1027
+ }, [open]);
1028
+
1029
+ const form = open ? (
1030
+ <MailComposeForm
1031
+ key={resetKey}
1032
+ accounts={accounts}
1033
+ initial={initial}
1034
+ mode={mode}
1035
+ allowScheduleSend={allowScheduleSend}
1036
+ allowBulkSend={allowBulkSend}
1037
+ autoSaveDraft={autoSaveDraft}
1038
+ defaultBulkIntervalMs={defaultBulkIntervalMs}
1039
+ recipientOptions={recipientOptions}
1040
+ onAccountChange={onAccountChange}
1041
+ onSent={() => {
1042
+ onOpenChange(false);
1043
+ onSent?.();
1044
+ }}
1045
+ />
1046
+ ) : null;
1047
+
1048
+ if (variant === "dialog") {
1049
+ return (
1050
+ <Dialog open={open} onOpenChange={onOpenChange}>
1051
+ <DialogContent className="sm:max-w-2xl">
1052
+ <DialogHeader>
1053
+ <DialogTitle>{composeTitle[mode]}</DialogTitle>
1054
+ </DialogHeader>
1055
+ {form}
1056
+ </DialogContent>
1057
+ </Dialog>
1058
+ );
1059
+ }
1060
+
1061
+ return (
1062
+ <Sheet open={open} onOpenChange={onOpenChange}>
1063
+ <SheetContent side="right" className="w-full gap-0 p-0 data-[side=right]:sm:max-w-xl">
1064
+ <SheetHeader className="border-b border-border/60 px-6 py-4">
1065
+ <SheetTitle>{composeTitle[mode]}</SheetTitle>
1066
+ </SheetHeader>
1067
+ <div className="flex-1 overflow-y-auto px-6 py-4">{form}</div>
1068
+ </SheetContent>
1069
+ </Sheet>
1070
+ );
1071
+ }