feat: mail attachment support — sync, display, download, upload

This commit is contained in:
Agent Zero
2026-07-15 18:43:38 +02:00
parent de74429f4e
commit 2108bdb9c2
8 changed files with 379 additions and 114 deletions
+18
View File
@@ -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> {
+98 -1
View File
@@ -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')}
+1 -1
View File
@@ -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"
+2
View File
@@ -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",
+2
View File
@@ -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",