/** * Compose modal — rich text editor (TipTap) with template insert. * Used for new mail, reply, and forward. * Form validation: React Hook Form + Zod. */ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; import { TemplatePicker } from './TemplatePicker'; import { RichTextEditor } from './RichTextEditor'; import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail'; import { uploadAttachment, type UploadedAttachment, replaceSignatureVariables } from '@/api/mail'; import { useAuthStore } from '@/store/authStore'; import { FileText, Paperclip, X } from 'lucide-react'; export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft'; export interface ComposeModalProps { open: boolean; mode: ComposeMode; accountId: string; replyToMail: Mail | null; forwardMail: Mail | null; draftMail?: Mail | null; signatures: MailSignature[]; onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise; onSaveDraft?: (payload: MailDraftPayload) => Promise; onClose: () => void; } // ── Zod Schema ── const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function validateEmailList(val: string, ctx: z.RefinementCtx, field: string, required: boolean) { const emails = val.split(',').map((s) => s.trim()).filter(Boolean); if (required && emails.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'required', path: [field] }); return; } for (const email of emails) { if (!emailRegex.test(email)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'invalidEmail', path: [field] }); return; } } } const composeSchema = z.object({ to: z.string().default(''), cc: z.string().optional().default(''), bcc: z.string().optional().default(''), subject: z.string().min(1, 'required'), body: z.string().default(''), }).superRefine((data, ctx) => { validateEmailList(data.to, ctx, 'to', true); if (data.cc) validateEmailList(data.cc, ctx, 'cc', false); if (data.bcc) validateEmailList(data.bcc, ctx, 'bcc', false); }); type ComposeFormData = z.infer; export function ComposeModal({ open, mode, accountId, replyToMail, forwardMail, draftMail, signatures, onSend, onSaveDraft, onClose, }: ComposeModalProps) { const { t } = useTranslation(); const authUser = useAuthStore((s) => s.user); const authTenant = useAuthStore((s) => s.currentTenant); const [showCc, setShowCc] = useState(false); const [selectedSignatureId, setSelectedSignatureId] = useState(''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [attachments, setAttachments] = useState([]); const [uploadingFile, setUploadingFile] = useState(false); const [sending, setSending] = useState(false); const [savingDraft, setSavingDraft] = useState(false); const fileInputRef = useRef(null); const { register, handleSubmit, reset, setValue, watch, formState: { errors }, } = useForm({ resolver: zodResolver(composeSchema), defaultValues: { to: '', cc: '', bcc: '', subject: '', body: '' }, }); const bodyValue = watch('body'); useEffect(() => { if (!open) return; if (mode === 'reply' && replyToMail) { reset({ to: replyToMail.from_address, cc: '', bcc: '', subject: replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`, body: `\n\n---\n${replyToMail.body_text.slice(0, 200)}`, }); } else if (mode === 'forward' && forwardMail) { reset({ to: '', cc: '', bcc: '', subject: forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`, body: `\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`, }); } else if (mode === 'draft' && draftMail) { reset({ to: draftMail.to_addresses.join(', '), cc: draftMail.cc_addresses.join(', '), bcc: draftMail.bcc_addresses.join(', '), subject: draftMail.subject, body: draftMail.body_html || draftMail.body_text || '', }); setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0); } else { reset({ to: '', cc: '', bcc: '', subject: '', body: '' }); setAttachments([]); } }, [open, mode, replyToMail, forwardMail, draftMail, t, reset]); const insertSignature = useCallback((signatureId: string) => { setSelectedSignatureId(signatureId); const sig = signatures.find((s) => s.id === signatureId); if (sig) { const processedHtml = replaceSignatureVariables( sig.body_html, { name: authUser ? `${authUser.first_name} ${authUser.last_name}`.trim() : undefined, email: authUser?.email, role: authUser?.role, first_name: authUser?.first_name, last_name: authUser?.last_name }, { name: authTenant?.name }, ); setValue('body', `${bodyValue}

---
${processedHtml}`); } }, [signatures, authUser, authTenant, setValue, bodyValue]); const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => { setValue('body', templateBody); if (templateSubject) { setValue('subject', templateSubject); } setShowTemplatePicker(false); }, [setValue]); const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; setUploadingFile(true); try { for (let i = 0; i < files.length; i++) { const file = files[i]; if (file.size > 25 * 1024 * 1024) { alert(`${file.name} exceeds 25 MB limit`); continue; } const uploaded = await uploadAttachment(file); setAttachments((prev) => [...prev, uploaded]); } } catch (err) { console.error('Attachment upload failed:', err); alert('Failed to upload attachment'); } finally { setUploadingFile(false); if (fileInputRef.current) { fileInputRef.current.value = ''; } } }, []); const handleRemoveAttachment = useCallback((attId: string) => { setAttachments((prev) => prev.filter((a) => a.id !== attId)); }, []); const formatBytes = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const parseEmailList = (val: string): string[] => val ? val.split(',').map((s) => s.trim()).filter(Boolean) : []; const onSendValidated = useCallback(async (data: ComposeFormData) => { setSending(true); try { const toList = parseEmailList(data.to); const ccList = data.cc ? parseEmailList(data.cc) : undefined; const bccList = data.bcc ? parseEmailList(data.bcc) : undefined; if (mode === 'reply' && replyToMail) { const replyPayload: ReplyPayload = { account_id: accountId, body: data.body, is_html: true, to: toList, cc: ccList, signature_id: selectedSignatureId || null, }; await onSend(replyPayload, 'reply'); } else if (mode === 'forward' && forwardMail) { const fwdPayload: ForwardPayload = { account_id: accountId, to: toList, body: data.body, is_html: true, signature_id: selectedSignatureId || null, }; await onSend(fwdPayload, 'forward'); } else { const sendPayload: SendMailPayload = { account_id: accountId, to: toList, cc: ccList, bcc: bccList, subject: data.subject, body: data.body, is_html: true, signature_id: selectedSignatureId || null, attachments: attachments.map((a) => a.id), }; await onSend(sendPayload, 'new'); } setAttachments([]); onClose(); } finally { setSending(false); } }, [mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]); const handleSaveDraft = useCallback(async () => { if (!onSaveDraft) return; const data = watch(); setSavingDraft(true); try { const toList = parseEmailList(data.to); const ccList = data.cc ? parseEmailList(data.cc) : []; const bccList = data.bcc ? parseEmailList(data.bcc) : []; const payload: MailDraftPayload = { account_id: accountId, to: toList, cc: ccList, bcc: bccList, subject: data.subject, body_text: data.body.replace(/<[^>]*>/g, ''), body_html: data.body, }; await onSaveDraft(payload); } finally { setSavingDraft(false); } }, [onSaveDraft, watch, accountId]); const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : mode === 'draft' ? t('mail.editDraft') : t('mail.compose'); const errorMsg = (key: string | undefined) => { if (!key) return undefined; if (key === 'required') return t('validation.required'); if (key === 'invalidEmail') return t('validation.email'); return key; }; return (
{/* Template picker toggle (formatting toolbar is in RichTextEditor) */}
{showTemplatePicker && ( )} {/* Recipients */}
{!showCc ? ( ) : (
)}
{/* Editor */}
setValue('body', html)} placeholder={t('mail.body')} />
{/* Attachments */}
Max 25 MB per file
{attachments.length > 0 && (
    {attachments.map((att) => (
  • ))}
)}
{/* Signature */}