feat: mail attachment support — sync, display, download, upload
This commit is contained in:
@@ -39,9 +39,11 @@ export interface MailAttachment {
|
||||
mail_id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
size: number;
|
||||
content_id?: string | null;
|
||||
is_inline: boolean;
|
||||
dms_file_id?: string | null;
|
||||
}
|
||||
|
||||
export interface Mail {
|
||||
@@ -461,6 +463,22 @@ export function downloadAttachment(mailId: string, attachmentId: string): Promis
|
||||
}).then((res) => res.data);
|
||||
}
|
||||
|
||||
export interface UploadedAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function uploadAttachment(file: File): Promise<UploadedAttachment> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return apiClient.post<UploadedAttachment>('/mail/upload-attachment', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then((res) => res.data);
|
||||
}
|
||||
|
||||
// ─── Templates ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function createTemplate(payload: CreateTemplatePayload): Promise<MailTemplate> {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { TemplatePicker } from './TemplatePicker';
|
||||
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail';
|
||||
import { uploadAttachment, type UploadedAttachment } from '@/api/mail';
|
||||
|
||||
export type ComposeMode = 'new' | 'reply' | 'forward';
|
||||
|
||||
@@ -46,6 +47,9 @@ export function ComposeModal({
|
||||
const [sending, setSending] = useState(false);
|
||||
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -63,6 +67,7 @@ export function ComposeModal({
|
||||
setBcc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setAttachments([]);
|
||||
}
|
||||
}, [open, mode, replyToMail, forwardMail, t]);
|
||||
|
||||
@@ -102,6 +107,41 @@ export function ComposeModal({
|
||||
setShowTemplatePicker(false);
|
||||
}, []);
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!to.trim()) return;
|
||||
setSending(true);
|
||||
@@ -139,14 +179,16 @@ export function ComposeModal({
|
||||
body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
attachments: attachments.map((a) => a.id),
|
||||
};
|
||||
await onSend(sendPayload, 'new');
|
||||
}
|
||||
setAttachments([]);
|
||||
onClose();
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, onSend, onClose]);
|
||||
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
|
||||
|
||||
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose');
|
||||
|
||||
@@ -266,6 +308,61 @@ export function ComposeModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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={
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{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">
|
||||
<svg className="w-5 h-5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<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')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signature */}
|
||||
<Select
|
||||
label={t('mail.signature')}
|
||||
|
||||
@@ -200,7 +200,7 @@ export function MailDetail({
|
||||
</svg>
|
||||
<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)}</p>
|
||||
<p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"remove": "Entfernen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"create": "Erstellen",
|
||||
@@ -427,6 +428,7 @@
|
||||
"subjectPlaceholder": "Betrereff eingeben...",
|
||||
"body": "Text",
|
||||
"attachments": "Anhänge",
|
||||
"addAttachment": "Anhang hinzufügen",
|
||||
"download": "Herunterladen",
|
||||
"compose": "Verfassen",
|
||||
"send": "Senden",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"remove": "Remove",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
@@ -427,6 +428,7 @@
|
||||
"subjectPlaceholder": "Enter subject...",
|
||||
"body": "Body",
|
||||
"attachments": "Attachments",
|
||||
"addAttachment": "Add Attachment",
|
||||
"download": "Download",
|
||||
"compose": "Compose",
|
||||
"send": "Send",
|
||||
|
||||
Reference in New Issue
Block a user