2026-07-01 20:43:49 +02:00
|
|
|
/**
|
2026-07-16 17:14:14 +02:00
|
|
|
* Compose modal — rich text editor (TipTap) with template insert.
|
2026-07-01 20:43:49 +02:00
|
|
|
* Used for new mail, reply, and forward.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
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';
|
2026-07-16 17:14:14 +02:00
|
|
|
import { RichTextEditor } from './RichTextEditor';
|
2026-07-15 19:44:41 +02:00
|
|
|
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
|
2026-07-16 21:55:38 +02:00
|
|
|
import { uploadAttachment, type UploadedAttachment, replaceSignatureVariables } from '@/api/mail';
|
|
|
|
|
import { useAuthStore } from '@/store/authStore';
|
2026-07-23 03:21:05 +02:00
|
|
|
import { FileText, Paperclip, X } from 'lucide-react';
|
2026-07-01 20:43:49 +02:00
|
|
|
|
2026-07-15 19:44:41 +02:00
|
|
|
export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft';
|
2026-07-01 20:43:49 +02:00
|
|
|
|
|
|
|
|
export interface ComposeModalProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
mode: ComposeMode;
|
|
|
|
|
accountId: string;
|
|
|
|
|
replyToMail: Mail | null;
|
|
|
|
|
forwardMail: Mail | null;
|
2026-07-15 19:44:41 +02:00
|
|
|
draftMail?: Mail | null;
|
2026-07-01 20:43:49 +02:00
|
|
|
signatures: MailSignature[];
|
|
|
|
|
onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise<void>;
|
2026-07-15 19:44:41 +02:00
|
|
|
onSaveDraft?: (payload: MailDraftPayload) => Promise<void>;
|
2026-07-01 20:43:49 +02:00
|
|
|
onClose: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ComposeModal({
|
|
|
|
|
open,
|
|
|
|
|
mode,
|
|
|
|
|
accountId,
|
|
|
|
|
replyToMail,
|
|
|
|
|
forwardMail,
|
2026-07-15 19:44:41 +02:00
|
|
|
draftMail,
|
2026-07-01 20:43:49 +02:00
|
|
|
signatures,
|
|
|
|
|
onSend,
|
2026-07-15 19:44:41 +02:00
|
|
|
onSaveDraft,
|
2026-07-01 20:43:49 +02:00
|
|
|
onClose,
|
|
|
|
|
}: ComposeModalProps) {
|
|
|
|
|
const { t } = useTranslation();
|
2026-07-16 21:55:38 +02:00
|
|
|
const authUser = useAuthStore((s) => s.user);
|
|
|
|
|
const authTenant = useAuthStore((s) => s.currentTenant);
|
2026-07-01 20:43:49 +02:00
|
|
|
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);
|
2026-07-15 19:44:41 +02:00
|
|
|
const [savingDraft, setSavingDraft] = useState(false);
|
2026-07-01 20:43:49 +02:00
|
|
|
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
|
|
|
|
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
2026-07-15 18:43:38 +02:00
|
|
|
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
|
|
|
|
|
const [uploadingFile, setUploadingFile] = useState(false);
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
2026-07-01 20:43:49 +02:00
|
|
|
|
|
|
|
|
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)}`);
|
|
|
|
|
} 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)}`);
|
2026-07-15 19:44:41 +02:00
|
|
|
} 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 || '');
|
|
|
|
|
setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0);
|
2026-07-01 20:43:49 +02:00
|
|
|
} else {
|
|
|
|
|
setTo('');
|
|
|
|
|
setCc('');
|
|
|
|
|
setBcc('');
|
|
|
|
|
setSubject('');
|
|
|
|
|
setBody('');
|
2026-07-15 18:43:38 +02:00
|
|
|
setAttachments([]);
|
2026-07-01 20:43:49 +02:00
|
|
|
}
|
2026-07-15 19:44:41 +02:00
|
|
|
}, [open, mode, replyToMail, forwardMail, draftMail, t]);
|
2026-07-01 20:43:49 +02:00
|
|
|
|
|
|
|
|
const insertSignature = useCallback((signatureId: string) => {
|
|
|
|
|
setSelectedSignatureId(signatureId);
|
|
|
|
|
const sig = signatures.find((s) => s.id === signatureId);
|
2026-07-16 17:14:14 +02:00
|
|
|
if (sig) {
|
2026-07-16 21:55:38 +02:00
|
|
|
const processedHtml = replaceSignatureVariables(
|
|
|
|
|
sig.body_html,
|
2026-07-19 17:41:39 +02:00
|
|
|
{ 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 },
|
2026-07-16 21:55:38 +02:00
|
|
|
{ name: authTenant?.name },
|
|
|
|
|
);
|
|
|
|
|
setBody((prev) => `${prev}<br/><br/>---<br/>${processedHtml}`);
|
2026-07-01 20:43:49 +02:00
|
|
|
}
|
2026-07-16 21:55:38 +02:00
|
|
|
}, [signatures, authUser, authTenant]);
|
2026-07-01 20:43:49 +02:00
|
|
|
|
|
|
|
|
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
|
2026-07-16 17:14:14 +02:00
|
|
|
setBody(templateBody);
|
2026-07-01 20:43:49 +02:00
|
|
|
if (templateSubject) {
|
|
|
|
|
setSubject(templateSubject);
|
|
|
|
|
}
|
|
|
|
|
setShowTemplatePicker(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-15 18:43:38 +02:00
|
|
|
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
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`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-15 19:44:41 +02:00
|
|
|
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]);
|
|
|
|
|
|
2026-07-01 20:43:49 +02:00
|
|
|
const handleSend = useCallback(async () => {
|
|
|
|
|
if (!to.trim()) return;
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
if (mode === 'reply' && replyToMail) {
|
|
|
|
|
const replyPayload: ReplyPayload = {
|
|
|
|
|
account_id: accountId,
|
|
|
|
|
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,
|
|
|
|
|
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,
|
|
|
|
|
body,
|
|
|
|
|
is_html: true,
|
|
|
|
|
signature_id: selectedSignatureId || null,
|
2026-07-15 18:43:38 +02:00
|
|
|
attachments: attachments.map((a) => a.id),
|
2026-07-01 20:43:49 +02:00
|
|
|
};
|
|
|
|
|
await onSend(sendPayload, 'new');
|
|
|
|
|
}
|
2026-07-15 18:43:38 +02:00
|
|
|
setAttachments([]);
|
2026-07-01 20:43:49 +02:00
|
|
|
onClose();
|
|
|
|
|
} finally {
|
|
|
|
|
setSending(false);
|
|
|
|
|
}
|
2026-07-15 18:43:38 +02:00
|
|
|
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
|
2026-07-01 20:43:49 +02:00
|
|
|
|
2026-07-15 19:44:41 +02:00
|
|
|
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : mode === 'draft' ? t('mail.editDraft') : t('mail.compose');
|
2026-07-01 20:43:49 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-07-15 17:53:56 +02:00
|
|
|
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
|
2026-07-01 20:43:49 +02:00
|
|
|
<div className="space-y-4" data-testid="compose-modal">
|
2026-07-16 17:14:14 +02:00
|
|
|
{/* Template picker toggle (formatting toolbar is in RichTextEditor) */}
|
|
|
|
|
<div className="flex items-center gap-2" data-testid="compose-toolbar">
|
2026-07-01 20:43:49 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() => setShowTemplatePicker(!showTemplatePicker)}
|
2026-07-16 17:14:14 +02:00
|
|
|
className="px-3 py-1.5 rounded hover:bg-secondary-100 text-sm"
|
2026-07-01 20:43:49 +02:00
|
|
|
aria-label={t('mail.insertTemplate')}
|
|
|
|
|
title={t('mail.insertTemplate')}
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="template-picker-toggle"
|
|
|
|
|
>
|
|
|
|
|
{t('mail.template')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{showTemplatePicker && (
|
|
|
|
|
<TemplatePicker onSelect={handleTemplateSelect} />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Recipients */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Input
|
|
|
|
|
label={t('mail.to')}
|
|
|
|
|
value={to}
|
|
|
|
|
onChange={(e) => setTo(e.target.value)}
|
|
|
|
|
placeholder="recipient@example.com"
|
|
|
|
|
required
|
|
|
|
|
data-testid="compose-to"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{!showCc ? (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowCc(true)}
|
|
|
|
|
className="text-sm text-primary-600 hover:text-primary-700"
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
{t('mail.showCcBcc')}
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
2026-07-15 17:53:56 +02:00
|
|
|
<div className="w-full grid grid-cols-1 sm:grid-cols-2 gap-2">
|
2026-07-01 20:43:49 +02:00
|
|
|
<Input
|
|
|
|
|
label={t('mail.cc')}
|
|
|
|
|
value={cc}
|
|
|
|
|
onChange={(e) => setCc(e.target.value)}
|
|
|
|
|
placeholder="cc@example.com"
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
label={t('mail.bcc')}
|
|
|
|
|
value={bcc}
|
|
|
|
|
onChange={(e) => setBcc(e.target.value)}
|
|
|
|
|
placeholder="bcc@example.com"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Input
|
|
|
|
|
label={t('mail.subject')}
|
|
|
|
|
value={subject}
|
|
|
|
|
onChange={(e) => setSubject(e.target.value)}
|
|
|
|
|
placeholder={t('mail.subjectPlaceholder')}
|
|
|
|
|
data-testid="compose-subject"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Editor */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
|
2026-07-16 17:14:14 +02:00
|
|
|
<RichTextEditor
|
|
|
|
|
content={body}
|
|
|
|
|
onChange={setBody}
|
|
|
|
|
placeholder={t('mail.body')}
|
2026-07-01 20:43:49 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-07-15 18:43:38 +02:00
|
|
|
{/* Attachments */}
|
|
|
|
|
<div data-testid="compose-attachments">
|
|
|
|
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.attachments')}</label>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
multiple
|
|
|
|
|
onChange={handleFileSelect}
|
|
|
|
|
className="hidden"
|
|
|
|
|
data-testid="compose-file-input"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
isLoading={uploadingFile}
|
|
|
|
|
icon={
|
2026-07-23 03:21:05 +02:00
|
|
|
<Paperclip className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
2026-07-15 18:43:38 +02:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{t('mail.addAttachment')}
|
|
|
|
|
</Button>
|
|
|
|
|
<span className="text-xs text-secondary-400">Max 25 MB per file</span>
|
|
|
|
|
</div>
|
|
|
|
|
{attachments.length > 0 && (
|
|
|
|
|
<ul className="mt-2 space-y-1">
|
|
|
|
|
{attachments.map((att) => (
|
|
|
|
|
<li key={att.id} className="flex items-center gap-3 p-2 rounded-md bg-secondary-50">
|
2026-07-23 03:21:05 +02:00
|
|
|
<FileText className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
2026-07-15 18:43:38 +02:00
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<p className="text-sm text-secondary-800 truncate">{att.filename}</p>
|
|
|
|
|
<p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleRemoveAttachment(att.id)}
|
|
|
|
|
className="p-1 rounded hover:bg-secondary-200 text-secondary-500"
|
|
|
|
|
aria-label={t('common.remove')}
|
|
|
|
|
>
|
2026-07-23 03:21:05 +02:00
|
|
|
<X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
2026-07-15 18:43:38 +02:00
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-07-01 20:43:49 +02:00
|
|
|
{/* Signature */}
|
|
|
|
|
<Select
|
|
|
|
|
label={t('mail.signature')}
|
|
|
|
|
value={selectedSignatureId}
|
|
|
|
|
onChange={(e) => insertSignature(e.target.value)}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: '', label: t('mail.noSignature') },
|
|
|
|
|
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
2026-07-15 17:53:56 +02:00
|
|
|
<div className="flex justify-end gap-2 pt-2 border-t border-secondary-200 sticky bottom-0 bg-white py-3">
|
2026-07-01 20:43:49 +02:00
|
|
|
<Button variant="secondary" onClick={onClose} type="button">
|
|
|
|
|
{t('common.cancel')}
|
|
|
|
|
</Button>
|
2026-07-15 19:44:41 +02:00
|
|
|
{onSaveDraft && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
onClick={handleSaveDraft}
|
|
|
|
|
isLoading={savingDraft}
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="compose-save-draft"
|
|
|
|
|
>
|
|
|
|
|
{t('mail.saveDraft')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-07-01 20:43:49 +02:00
|
|
|
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
|
|
|
|
|
{t('mail.send')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
);
|
|
|
|
|
}
|