Files
opencrm/frontend/src/components/email/EmailDetail.tsx
T
duffyduck 2fee13d09e EmailDetail: ExternalLink-Icon beim "Zugeordnet zu"-Badge
Klick auf die Vertragsnummer öffnet weiterhin im selben Tab
(via React-Router Link). Neues Icon daneben öffnet den Vertrag in
einem neuen Browser-Tab – analog zum Pro-Tab-Link in CustomerDetail.
2026-06-03 18:20:10 +02:00

562 lines
21 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, useEffect, useMemo } from 'react';
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
import DOMPurify from 'dompurify';
import { CachedEmail, cachedEmailApi } from '../../services/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import Button from '../ui/Button';
import { Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import toast from 'react-hot-toast';
import SaveAttachmentModal from './SaveAttachmentModal';
import SaveEmailAsPdfModal from './SaveEmailAsPdfModal';
interface EmailDetailProps {
email: CachedEmail;
onReply: () => void;
onForward?: () => void; // Weiterleiten (öffnet ComposeModal im Forward-Modus)
onResend?: () => void; // Erneut an Empfänger senden (One-Click-Resend)
onAssignContract: () => void;
onDeleted?: () => void; // Callback nach Löschen
isSentFolder?: boolean;
isContractView?: boolean; // True wenn aus der Vertragsansicht aufgerufen
isTrashView?: boolean; // True wenn aus dem Papierkorb
onRestored?: () => void; // Callback nach Wiederherstellen
accountId?: number; // Für folder-counts Invalidierung
}
export default function EmailDetail({
email,
onReply,
onForward,
onResend,
onAssignContract,
onDeleted,
isSentFolder: _isSentFolder = false,
isContractView = false,
isTrashView = false,
onRestored,
accountId,
}: EmailDetailProps) {
const [showHtml, setShowHtml] = useState(true);
const [localStarred, setLocalStarred] = useState(email.isStarred);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] = useState(false);
const [saveAttachmentFilename, setSaveAttachmentFilename] = useState<string | null>(null);
const [showSaveAsPdfModal, setShowSaveAsPdfModal] = useState(false);
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
// Sync localStarred wenn sich die Email ändert
useEffect(() => {
setLocalStarred(email.isStarred);
}, [email.id, email.isStarred]);
// Email-Body sanitizen + alle <a>-Links auf neuen Tab umstellen.
// rel="noopener noreferrer" verhindert window.opener-Tab-Hijacking.
const safeHtmlBody = useMemo(() => {
if (!email.htmlBody) return '';
const sanitized = DOMPurify.sanitize(email.htmlBody, {
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
ADD_ATTR: ['target'],
});
const wrapper = document.createElement('div');
wrapper.innerHTML = sanitized;
wrapper.querySelectorAll('a').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
return wrapper.innerHTML;
}, [email.htmlBody]);
const toggleStarMutation = useMutation({
mutationFn: () => cachedEmailApi.toggleStar(email.id),
onMutate: () => {
// Optimistisches Update
setLocalStarred((prev) => !prev);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
queryClient.invalidateQueries({ queryKey: ['email', email.id] });
},
onError: () => {
// Bei Fehler zurücksetzen
setLocalStarred(email.isStarred);
},
});
const unassignMutation = useMutation({
mutationFn: () => cachedEmailApi.unassignFromContract(email.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
queryClient.invalidateQueries({ queryKey: ['email', email.id] });
if (email.contractId) {
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', email.contractId] });
}
toast.success('Vertragszuordnung aufgehoben');
},
onError: (error: Error) => {
console.error('Unassign error:', error);
toast.error(error.message || 'Fehler beim Aufheben der Zuordnung');
},
});
const deleteMutation = useMutation({
mutationFn: () => cachedEmailApi.delete(email.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
// Folder-Counts invalidieren
if (accountId) {
queryClient.invalidateQueries({ queryKey: ['folder-counts', accountId] });
}
if (email.contractId) {
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', email.contractId] });
}
toast.success('E-Mail in Papierkorb verschoben');
setShowDeleteConfirm(false);
onDeleted?.();
},
onError: (error: Error) => {
console.error('Delete error:', error);
toast.error(error.message || 'Fehler beim Löschen der E-Mail');
setShowDeleteConfirm(false);
},
});
const restoreMutation = useMutation({
mutationFn: () => cachedEmailApi.restore(email.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
// Folder-Counts invalidieren
if (accountId) {
queryClient.invalidateQueries({ queryKey: ['folder-counts', accountId] });
}
if (email.contractId) {
queryClient.invalidateQueries({ queryKey: ['contract-folder-counts', email.contractId] });
}
toast.success('E-Mail wiederhergestellt');
setShowRestoreConfirm(false);
onRestored?.();
},
onError: (error: Error) => {
console.error('Restore error:', error);
toast.error(error.message || 'Fehler beim Wiederherstellen der E-Mail');
setShowRestoreConfirm(false);
},
});
const permanentDeleteMutation = useMutation({
mutationFn: () => cachedEmailApi.permanentDelete(email.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['emails'] });
// Folder-Counts invalidieren (Trash count)
if (accountId) {
queryClient.invalidateQueries({ queryKey: ['folder-counts', accountId] });
}
toast.success('E-Mail endgültig gelöscht');
setShowPermanentDeleteConfirm(false);
onDeleted?.();
},
onError: (error: Error) => {
console.error('Permanent delete error:', error);
toast.error(error.message || 'Fehler beim endgültigen Löschen der E-Mail');
setShowPermanentDeleteConfirm(false);
},
});
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const parseAddresses = (jsonStr: string): string[] => {
try {
return JSON.parse(jsonStr);
} catch {
return [];
}
};
const parseAttachments = (jsonStr?: string): string[] => {
if (!jsonStr) return [];
try {
return JSON.parse(jsonStr);
} catch {
return [];
}
};
const toAddresses = parseAddresses(email.toAddresses);
const ccAddresses = email.ccAddresses ? parseAddresses(email.ccAddresses) : [];
const attachments = parseAttachments(email.attachmentNames);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-200 space-y-3">
{/* Subject & Actions */}
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold text-gray-900">
{email.subject || '(Kein Betreff)'}
</h2>
<div className="flex items-center gap-2 flex-shrink-0">
{isTrashView ? (
<>
{/* Papierkorb-Aktionen */}
<Button
variant="secondary"
size="sm"
onClick={() => setShowRestoreConfirm(true)}
title="Wiederherstellen"
>
<Undo2 className="w-4 h-4 mr-1" />
Wiederherstellen
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setShowPermanentDeleteConfirm(true)}
title="Endgültig löschen"
>
<Trash2 className="w-4 h-4 mr-1" />
Endgültig löschen
</Button>
</>
) : (
<>
{/* Normale Aktionen */}
<button
onClick={() => toggleStarMutation.mutate()}
className={`p-2 rounded-lg hover:bg-gray-100 ${
localStarred ? 'text-yellow-500' : 'text-gray-400'
}`}
title={localStarred ? 'Stern entfernen' : 'Als wichtig markieren'}
>
<Star className={`w-5 h-5 ${localStarred ? 'fill-current' : ''}`} />
</button>
<Button variant="secondary" size="sm" onClick={onReply}>
<Reply className="w-4 h-4 mr-1" />
Antworten
</Button>
{onForward && (
<Button
variant="secondary"
size="sm"
onClick={onForward}
title="Diese E-Mail als neue Nachricht weiterleiten (Empfänger kann beliebig eingegeben werden, Inhalt + Header werden zitiert)"
>
<Forward className="w-4 h-4 mr-1" />
Weiterleiten
</Button>
)}
{onResend && (
<Button
variant="ghost"
size="sm"
onClick={onResend}
title="Erneut an die ursprüngliche Empfänger-Adresse senden. Nützlich wenn die Stressfrei-Weiterleitungsadresse umgezogen ist die Mail kommt dann an den aktuell hinterlegten Forward-Empfänger."
>
<RotateCcw className="w-4 h-4 mr-1" />
Erneut senden
</Button>
)}
{/* E-Mail als PDF speichern */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowSaveAsPdfModal(true)}
title="E-Mail als PDF speichern"
>
<FileDown className="w-4 h-4" />
</Button>
{/* Löschen-Button nur für User mit emails:delete Permission */}
{hasPermission('emails:delete') && (
<Button
variant="danger"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
title="E-Mail löschen"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</>
)}
</div>
</div>
{/* From/To/Date */}
<div className="text-sm space-y-1">
<div className="flex items-center gap-2">
<span className="text-gray-500 w-12">Von:</span>
<span className="font-medium text-gray-900">
{email.fromName && `${email.fromName} `}
<span className="text-gray-600">&lt;{email.fromAddress}&gt;</span>
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-500 w-12">An:</span>
<span className="text-gray-700">{toAddresses.join(', ')}</span>
</div>
{ccAddresses.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-gray-500 w-12">CC:</span>
<span className="text-gray-700">{ccAddresses.join(', ')}</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-gray-500 w-12">Am:</span>
<span className="text-gray-700">{formatDate(email.receivedAt)}</span>
</div>
</div>
{/* Contract Assignment */}
<div className="flex items-center gap-2 pt-2">
{email.contract ? (
<div className="flex items-center gap-2 px-3 py-2 bg-green-50 border border-green-200 rounded-lg">
<Link2 className="w-4 h-4 text-green-600" />
<span className="text-sm text-green-800">
Zugeordnet zu:{' '}
<Link
to={`/contracts/${email.contract.id}`}
className="font-medium hover:underline"
>
{email.contract.contractNumber}
</Link>
</span>
<a
href={`/contracts/${email.contract.id}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-green-600 hover:text-green-800"
title="Vertrag in neuem Tab öffnen"
aria-label="Vertrag in neuem Tab öffnen"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
{!email.isAutoAssigned && (
<button
onClick={() => unassignMutation.mutate()}
className="ml-2 p-1 hover:bg-green-100 rounded"
title="Zuordnung aufheben"
>
<X className="w-4 h-4 text-green-600" />
</button>
)}
</div>
) : (
/* Zuordnen-Button: In Vertragsansicht ausblenden (E-Mails dort sind bereits zugeordnet) */
!isContractView && (
<Button variant="secondary" size="sm" onClick={onAssignContract}>
<Link2 className="w-4 h-4 mr-1" />
Vertrag zuordnen
</Button>
)
)}
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="pt-2">
<div className="flex items-center gap-2 mb-2">
<Paperclip className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500">
{attachments.length} Anhang{attachments.length > 1 ? 'e' : ''}
</span>
</div>
<div className="flex flex-wrap gap-2">
{attachments.map((name, i) => (
<div
key={i}
className="inline-flex items-center gap-1 px-3 py-2 bg-gray-100 rounded-lg text-sm text-gray-700"
>
<span className="max-w-[200px] truncate mr-1">{name}</span>
<a
href={cachedEmailApi.getAttachmentUrl(email.id, name, true)}
target="_blank"
rel="noopener noreferrer"
className="p-1 hover:bg-gray-200 rounded transition-colors"
title={`${name} öffnen`}
>
<ExternalLink className="w-4 h-4 text-gray-500" />
</a>
<a
href={cachedEmailApi.getAttachmentUrl(email.id, name)}
download={name}
className="p-1 hover:bg-gray-200 rounded transition-colors"
title={`${name} herunterladen`}
>
<Download className="w-4 h-4 text-gray-500" />
</a>
{/* Speichern-Button (nicht im Papierkorb) */}
{!isTrashView && (
<button
onClick={() => setSaveAttachmentFilename(name)}
className="p-1 hover:bg-blue-100 rounded transition-colors"
title={`${name} speichern unter...`}
>
<Save className="w-4 h-4 text-blue-500" />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Body Toggle */}
{email.htmlBody && email.textBody && (
<div className="px-4 py-2 border-b border-gray-200 flex items-center gap-2">
<button
onClick={() => setShowHtml(true)}
className={`px-3 py-1 text-sm rounded ${
showHtml ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
}`}
>
HTML
</button>
<button
onClick={() => setShowHtml(false)}
className={`px-3 py-1 text-sm rounded ${
!showHtml ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
}`}
>
Text
</button>
</div>
)}
{/* Email Body */}
<div className="flex-1 overflow-auto p-4">
{showHtml && email.htmlBody ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: safeHtmlBody }}
/>
) : (
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
{email.textBody || 'Kein Inhalt'}
</pre>
)}
</div>
{/* Lösch-Bestätigung Modal (in Papierkorb verschieben) */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
E-Mail löschen?
</h3>
<p className="text-gray-600 mb-4">
Die E-Mail wird in den Papierkorb verschoben.
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setShowDeleteConfirm(false)}
disabled={deleteMutation.isPending}
>
Abbrechen
</Button>
<Button
variant="danger"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Löschen...' : 'Löschen'}
</Button>
</div>
</div>
</div>
)}
{/* Wiederherstellen-Bestätigung Modal */}
{showRestoreConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
E-Mail wiederherstellen?
</h3>
<p className="text-gray-600 mb-4">
Die E-Mail wird wieder in den ursprünglichen Ordner ({email.folder === 'SENT' ? 'Gesendet' : 'Posteingang'}) verschoben.
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setShowRestoreConfirm(false)}
disabled={restoreMutation.isPending}
>
Abbrechen
</Button>
<Button
variant="primary"
onClick={() => restoreMutation.mutate()}
disabled={restoreMutation.isPending}
>
{restoreMutation.isPending ? 'Wird wiederhergestellt...' : 'Wiederherstellen'}
</Button>
</div>
</div>
</div>
)}
{/* Endgültig löschen-Bestätigung Modal */}
{showPermanentDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
E-Mail endgültig löschen?
</h3>
<p className="text-gray-600 mb-4">
Diese Aktion kann nicht rückgängig gemacht werden. Die E-Mail wird unwiderruflich gelöscht.
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setShowPermanentDeleteConfirm(false)}
disabled={permanentDeleteMutation.isPending}
>
Abbrechen
</Button>
<Button
variant="danger"
onClick={() => permanentDeleteMutation.mutate()}
disabled={permanentDeleteMutation.isPending}
>
{permanentDeleteMutation.isPending ? 'Wird gelöscht...' : 'Endgültig löschen'}
</Button>
</div>
</div>
</div>
)}
{/* Anhang speichern Modal */}
{saveAttachmentFilename && (
<SaveAttachmentModal
isOpen={true}
onClose={() => setSaveAttachmentFilename(null)}
emailId={email.id}
attachmentFilename={saveAttachmentFilename}
/>
)}
{/* E-Mail als PDF speichern Modal */}
{showSaveAsPdfModal && (
<SaveEmailAsPdfModal
isOpen={true}
onClose={() => setShowSaveAsPdfModal(false)}
emailId={email.id}
/>
)}
</div>
);
}