diff --git a/frontend/src/__tests__/mail/ComposeModal.validation.test.tsx b/frontend/src/__tests__/mail/ComposeModal.validation.test.tsx new file mode 100644 index 0000000..74f1675 --- /dev/null +++ b/frontend/src/__tests__/mail/ComposeModal.validation.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@/api/mail', () => ({ + decodeMimeHeader: (s: string) => s, + fetchTemplates: vi.fn().mockResolvedValue([]), + substituteTemplate: vi.fn().mockResolvedValue({ subject: '', body: '' }), + uploadAttachment: vi.fn(), + replaceSignatureVariables: (s: string) => s, +})); + +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }), +})); + +vi.mock('@/store/authStore', () => ({ + useAuthStore: () => ({ user: null, currentTenant: null }), +})); + +import { ComposeModal } from '@/components/mail/ComposeModal'; + +function renderModal(props: Partial> = {}) { + const defaultProps = { + open: true, + mode: 'new' as const, + accountId: 'acc1', + replyToMail: null, + forwardMail: null, + signatures: [], + onSend: vi.fn().mockResolvedValue(undefined), + onClose: vi.fn(), + }; + return render(); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ComposeModal validation (RHF + Zod)', () => { + it('shows validation error when "to" is empty on submit', async () => { + renderModal(); + fireEvent.click(screen.getByTestId('compose-send')); + await waitFor(() => { + const toInput = screen.getByTestId('compose-to'); + const errorEl = toInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red'); + expect(errorEl).toBeTruthy(); + }); + }); + + it('shows validation error for invalid email in "to" field', async () => { + renderModal(); + fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'not-an-email' } }); + fireEvent.click(screen.getByTestId('compose-send')); + await waitFor(() => { + const toInput = screen.getByTestId('compose-to'); + const errorEl = toInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red'); + expect(errorEl).toBeTruthy(); + }); + }); + + it('shows validation error when subject is empty', async () => { + renderModal(); + fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'valid@example.com' } }); + fireEvent.click(screen.getByTestId('compose-send')); + await waitFor(() => { + const subjectInput = screen.getByTestId('compose-subject'); + const errorEl = subjectInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red'); + expect(errorEl).toBeTruthy(); + }); + }); + + it('calls onSend when form is valid', async () => { + const onSend = vi.fn().mockResolvedValue(undefined); + renderModal({ onSend }); + fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'valid@example.com' } }); + fireEvent.change(screen.getByTestId('compose-subject'), { target: { value: 'Test Subject' } }); + fireEvent.click(screen.getByTestId('compose-send')); + await waitFor(() => { + expect(onSend).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/components/mail/ComposeModal.tsx b/frontend/src/components/mail/ComposeModal.tsx index 48419a1..4a5d1ca 100644 --- a/frontend/src/components/mail/ComposeModal.tsx +++ b/frontend/src/components/mail/ComposeModal.tsx @@ -1,10 +1,14 @@ /** * 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'; @@ -31,6 +35,38 @@ export interface ComposeModalProps { 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, @@ -46,46 +82,61 @@ export function ComposeModal({ const { t } = useTranslation(); const authUser = useAuthStore((s) => s.user); const authTenant = useAuthStore((s) => s.currentTenant); - const [to, setTo] = useState(''); - const [cc, setCc] = useState(''); - const [bcc, setBcc] = useState(''); - const [subject, setSubject] = useState(''); - const [body, setBody] = useState(''); const [showCc, setShowCc] = useState(false); - const [sending, setSending] = useState(false); - const [savingDraft, setSavingDraft] = 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) { - setTo(replyToMail.from_address); - setSubject(replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`); - setBody(`\n\n---\n${replyToMail.body_text.slice(0, 200)}`); + 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) { - setTo(''); - setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`); - setBody(`\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)}`); + 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) { - setTo(draftMail.to_addresses.join(', ')); - setCc(draftMail.cc_addresses.join(', ')); - setBcc(draftMail.bcc_addresses.join(', ')); - setSubject(draftMail.subject); - setBody(draftMail.body_html || draftMail.body_text || ''); + 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 { - setTo(''); - setCc(''); - setBcc(''); - setSubject(''); - setBody(''); + reset({ to: '', cc: '', bcc: '', subject: '', body: '' }); setAttachments([]); } - }, [open, mode, replyToMail, forwardMail, draftMail, t]); + }, [open, mode, replyToMail, forwardMail, draftMail, t, reset]); const insertSignature = useCallback((signatureId: string) => { setSelectedSignatureId(signatureId); @@ -96,17 +147,17 @@ export function ComposeModal({ { 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 }, ); - setBody((prev) => `${prev}

---
${processedHtml}`); + setValue('body', `${bodyValue}

---
${processedHtml}`); } - }, [signatures, authUser, authTenant]); + }, [signatures, authUser, authTenant, setValue, bodyValue]); const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => { - setBody(templateBody); + setValue('body', templateBody); if (templateSubject) { - setSubject(templateSubject); + setValue('subject', templateSubject); } setShowTemplatePicker(false); - }, []); + }, [setValue]); const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { const files = e.target.files; @@ -143,40 +194,20 @@ export function ComposeModal({ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; - const handleSaveDraft = useCallback(async () => { - if (!onSaveDraft) return; - setSavingDraft(true); - try { - const toList = to.split(',').map((s) => s.trim()).filter(Boolean); - const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : []; - const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : []; - const payload: MailDraftPayload = { - account_id: accountId, - to: toList, - cc: ccList, - bcc: bccList, - subject, - body_text: body.replace(/<[^>]*>/g, ''), - body_html: body, - }; - await onSaveDraft(payload); - } finally { - setSavingDraft(false); - } - }, [to, cc, bcc, subject, body, accountId, onSaveDraft]); + const parseEmailList = (val: string): string[] => + val ? val.split(',').map((s) => s.trim()).filter(Boolean) : []; - const handleSend = useCallback(async () => { - if (!to.trim()) return; + const onSendValidated = useCallback(async (data: ComposeFormData) => { setSending(true); try { - const toList = to.split(',').map((s) => s.trim()).filter(Boolean); - const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : undefined; - const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : undefined; + 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, + body: data.body, is_html: true, to: toList, cc: ccList, @@ -187,7 +218,7 @@ export function ComposeModal({ const fwdPayload: ForwardPayload = { account_id: accountId, to: toList, - body, + body: data.body, is_html: true, signature_id: selectedSignatureId || null, }; @@ -198,8 +229,8 @@ export function ComposeModal({ to: toList, cc: ccList, bcc: bccList, - subject, - body, + subject: data.subject, + body: data.body, is_html: true, signature_id: selectedSignatureId || null, attachments: attachments.map((a) => a.id), @@ -211,13 +242,43 @@ export function ComposeModal({ } finally { setSending(false); } - }, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]); + }, [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) */}
setSubject(e.target.value)} + {...register('subject')} + error={errorMsg(errors.subject?.message)} placeholder={t('mail.subjectPlaceholder')} data-testid="compose-subject" /> @@ -285,8 +346,8 @@ export function ComposeModal({
setValue('body', html)} placeholder={t('mail.body')} />
@@ -367,11 +428,11 @@ export function ComposeModal({ {t('mail.saveDraft')} )} -
- +
); -} \ No newline at end of file +}