Files
opencrm/frontend/src/components/email/ComposeEmailModal.tsx
T
duffyduck f6df97226d feat(email): Weiterleiten + Erneut senden im Detail-Pane
Zwei Aktionen, die der existierende Reply-Pfad bisher nicht abdeckte:

1. Weiterleiten (Compose-Modal-Forward-Modus):
   - Neuer Button im EmailDetail, neben "Antworten"
   - ComposeEmailModal akzeptiert jetzt einen `forwardOf` prop und
     füllt das Formular im Forward-Stil vor:
     * To leer (User trägt selbst ein)
     * Subject mit "Fwd:"-Prefix
     * Body mit zitierten Headern (Von, An, Datum, Betreff) +
       Original-Text
   - Titel des Modals reagiert ("Antworten" / "Weiterleiten" /
     "Neue E-Mail")

2. Erneut senden (One-Click-Resend):
   - Neuer Button im EmailDetail; schickt die Mail nochmal an die
     ursprüngliche toAddresses (= die Stressfrei-Adresse selbst).
     Plesk routet dann gemäß der HEUTE hinterlegten Forwards –
     Use-Case: die Stressfrei-Forward-Adresse wurde nach Empfang
     umgestellt, der Empfang soll beim neuen Forward-Empfänger
     landen.
   - Confirm-Dialog erklärt den Vorgang und warnt explizit, dass
     Anhänge nicht erneut mit gesendet werden (Anhänge wären
     IMAP-Refetch, dafür "Weiterleiten" nutzen).
   - Toast-Feedback für Erfolg/Fehler.
   - Im TRASH-Folder wird der Resend-Button bewusst nicht
     eingeblendet (kein sinnvoller Use-Case dort).

Backend braucht keine neuen Endpoints – beide Aktionen nutzen die
bestehenden `stressfreiEmailApi.sendEmail` + `cachedEmailApi.getById`
(letztere für den Body, der ohnehin schon im Detail-View geladen ist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:46:44 +02:00

380 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, FileText } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
import { useMutation } from '@tanstack/react-query';
interface ComposeEmailModalProps {
isOpen: boolean;
onClose: () => void;
account: MailboxAccount;
replyTo?: CachedEmail;
forwardOf?: CachedEmail; // Weiterleiten: Body vorausgefüllt, To leer
onSuccess?: () => void;
contractId?: number; // Optional: Vertrag dem die gesendete E-Mail zugeordnet wird
}
export default function ComposeEmailModal({
isOpen,
onClose,
account,
replyTo,
forwardOf,
onSuccess,
contractId,
}: ComposeEmailModalProps) {
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Formular bei Modal-Öffnung initialisieren
useEffect(() => {
if (isOpen) {
if (replyTo) {
// Antwort: Felder vorausfüllen
setTo(replyTo.fromAddress || '');
// Betreff: "Re:" nur hinzufügen wenn nicht schon vorhanden
const existingSubject = replyTo.subject || '';
const hasRePrefix = /^(Re|Aw|Fwd|Wg):\s*/i.test(existingSubject);
setSubject(hasRePrefix ? existingSubject : `Re: ${existingSubject}`);
// Ursprüngliche Nachricht zitieren
const originalDate = new Date(replyTo.receivedAt).toLocaleString('de-DE');
const quotedText = replyTo.textBody
? `\n\n--- Ursprüngliche Nachricht ---\nVon: ${replyTo.fromName || replyTo.fromAddress}\nAm: ${originalDate}\n\n${replyTo.textBody}`
: '';
setBody(quotedText);
} else if (forwardOf) {
// Weiterleiten: To leer (User trägt selbst ein), Betreff mit „Fwd:"
setTo('');
const existingSubject = forwardOf.subject || '';
const hasFwdPrefix = /^(Fwd|Wg):\s*/i.test(existingSubject);
setSubject(hasFwdPrefix ? existingSubject : `Fwd: ${existingSubject}`);
// Original-Header + Body zitieren (mehr Felder als bei Reply, damit
// der weitergeleitete Kontext erhalten bleibt)
const originalDate = new Date(forwardOf.receivedAt).toLocaleString('de-DE');
let toLine = '';
try {
const parsed = JSON.parse(forwardOf.toAddresses || '[]');
if (Array.isArray(parsed) && parsed.length > 0) {
toLine = `\nAn: ${parsed.join(', ')}`;
}
} catch {
// toAddresses war kein JSON ignorieren
}
const quotedText = forwardOf.textBody
? `\n\n--- Weitergeleitete Nachricht ---\nVon: ${
forwardOf.fromName || forwardOf.fromAddress
}${toLine}\nDatum: ${originalDate}\nBetreff: ${existingSubject}\n\n${forwardOf.textBody}`
: '';
setBody(quotedText);
} else {
// Neue E-Mail: Felder leer
setTo('');
setSubject('');
setBody('');
}
setCc('');
setAttachments([]);
setError(null);
}
}, [isOpen, replyTo, forwardOf]);
// Maximale Dateigröße: 10 MB
const MAX_FILE_SIZE = 10 * 1024 * 1024;
// Maximale Gesamtgröße aller Anhänge: 25 MB
const MAX_TOTAL_SIZE = 25 * 1024 * 1024;
// Datei zu Base64 konvertieren
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
// data:application/pdf;base64,JVBERi0... -> JVBERi0...
const result = reader.result as string;
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
});
};
// Dateien hinzufügen
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const newAttachments: EmailAttachment[] = [];
let currentTotalSize = attachments.reduce(
(sum, att) => sum + (att.content.length * 0.75), // Base64 ist ~33% größer
0
);
for (const file of Array.from(files)) {
// Einzelne Dateigröße prüfen
if (file.size > MAX_FILE_SIZE) {
setError(`Datei "${file.name}" ist zu groß (max. 10 MB)`);
continue;
}
// Gesamtgröße prüfen
if (currentTotalSize + file.size > MAX_TOTAL_SIZE) {
setError('Maximale Gesamtgröße der Anhänge erreicht (25 MB)');
break;
}
try {
const content = await fileToBase64(file);
newAttachments.push({
filename: file.name,
content,
contentType: file.type || 'application/octet-stream',
});
currentTotalSize += file.size;
} catch {
setError(`Fehler beim Lesen von "${file.name}"`);
}
}
if (newAttachments.length > 0) {
setAttachments((prev) => [...prev, ...newAttachments]);
}
// Input zurücksetzen damit gleiche Datei erneut gewählt werden kann
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Anhang entfernen
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
// Dateigröße formatieren
const formatFileSize = (base64Content: string): string => {
const bytes = base64Content.length * 0.75; // Base64 Dekodierung
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const sendMutation = useMutation({
mutationFn: () =>
stressfreiEmailApi.sendEmail(account.id, {
to: to.split(',').map((e) => e.trim()).filter(Boolean),
cc: cc ? cc.split(',').map((e) => e.trim()).filter(Boolean) : undefined,
subject,
text: body,
inReplyTo: replyTo?.messageId,
references: replyTo?.messageId ? [replyTo.messageId] : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
contractId,
}),
onSuccess: (result) => {
// Backend kann success=false zurückgeben auch bei HTTP 200
if (result && (result as any).success === false) {
const msg = (result as any).error || 'E-Mail-Versand fehlgeschlagen';
setError(msg);
toast.error(`SMTP-Fehler: ${msg}`, { duration: 8000 });
return;
}
toast.success('E-Mail versendet');
onSuccess?.();
handleClose();
},
onError: (err: any) => {
const msg =
err?.response?.data?.error ||
(err instanceof Error ? err.message : 'Fehler beim Senden');
setError(msg);
toast.error(`SMTP-Fehler: ${msg}`, { duration: 8000 });
},
});
const handleClose = () => {
// Formular wird beim nächsten Öffnen durch useEffect initialisiert
onClose();
};
const handleSend = () => {
if (!to.trim()) {
setError('Bitte Empfänger angeben');
return;
}
if (!subject.trim()) {
setError('Bitte Betreff angeben');
return;
}
setError(null);
sendMutation.mutate();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={replyTo ? 'Antworten' : forwardOf ? 'Weiterleiten' : 'Neue E-Mail'}
size="lg"
>
<div className="space-y-4">
{/* From */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Von
</label>
<div className="px-3 py-2 bg-gray-100 rounded-lg text-sm text-gray-700">
{account.email}
</div>
</div>
{/* To */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
An <span className="text-red-500">*</span>
</label>
<input
type="text"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="empfaenger@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-500">
Mehrere Empfänger mit Komma trennen
</p>
</div>
{/* CC */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CC
</label>
<input
type="text"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Subject */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Betreff <span className="text-red-500">*</span>
</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Betreff eingeben"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Body */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nachricht
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={10}
placeholder="Ihre Nachricht..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
{/* Anhänge */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Anhänge
</label>
{/* Versteckter File-Input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
multiple
className="hidden"
/>
{/* Anhang hinzufügen Button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Paperclip className="w-4 h-4 mr-2" />
Datei anhängen
</button>
{/* Anhang-Liste */}
{attachments.length > 0 && (
<div className="mt-2 space-y-2">
{attachments.map((att, index) => (
<div
key={index}
className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-lg"
>
<div className="flex items-center min-w-0">
<FileText className="w-4 h-4 text-gray-500 mr-2 flex-shrink-0" />
<span className="text-sm text-gray-700 truncate">
{att.filename}
</span>
<span className="ml-2 text-xs text-gray-500 flex-shrink-0">
({formatFileSize(att.content)})
</span>
</div>
<button
type="button"
onClick={() => removeAttachment(index)}
className="ml-2 p-1 text-gray-400 hover:text-red-500 transition-colors"
title="Anhang entfernen"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
<p className="mt-1 text-xs text-gray-500">
Max. 10 MB pro Datei, 25 MB gesamt
</p>
</div>
{/* Error */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={handleSend}
disabled={sendMutation.isPending}
>
<Send className="w-4 h-4 mr-2" />
{sendMutation.isPending ? 'Wird gesendet...' : 'Senden'}
</Button>
</div>
</div>
</Modal>
);
}