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>
This commit is contained in:
2026-05-16 14:46:44 +02:00
parent 185b38dc55
commit f6df97226d
4 changed files with 135 additions and 3 deletions
+15
View File
@@ -97,6 +97,21 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **↗ E-Mail-Postfach: Weiterleiten + Erneut senden**
- **Weiterleiten** (Compose-Modal-Erweiterung): neuer Button im
EmailDetail öffnet das ComposeEmailModal im Forward-Modus
To-Feld leer (User trägt den neuen Empfänger ein), Betreff mit
„Fwd:"-Prefix, Body mit zitierten Original-Headern (Von, An,
Datum, Betreff) + Original-Text.
- **Erneut senden** (One-Click): schickt die Mail noch einmal an
die ursprüngliche Empfänger-Adresse (= die Stressfrei-Adresse
selbst). Damit läuft sie durch die heute hinterlegten Forwards
und landet beim aktuell konfigurierten Kunden-Postfach Use-Case:
Stressfrei-Adresse wurde nach Empfang umgestellt, Original ist nur
in der alten Inbox. Confirm-Dialog mit Hinweis, dass Anhänge nicht
erneut mit gesendet werden (Weiterleiten dafür nutzen). Toast für
Erfolg/Fehler.
- [x] **🔍 E-Mail-Postfach: Suche + erweiterte Filter (Variante B)** - [x] **🔍 E-Mail-Postfach: Suche + erweiterte Filter (Variante B)**
- Suchleiste über der Email-Liste durchsucht parallel Subject, - Suchleiste über der Email-Liste durchsucht parallel Subject,
From-Address/Name und Body. From-Address/Name und Body.
@@ -11,6 +11,7 @@ interface ComposeEmailModalProps {
onClose: () => void; onClose: () => void;
account: MailboxAccount; account: MailboxAccount;
replyTo?: CachedEmail; replyTo?: CachedEmail;
forwardOf?: CachedEmail; // Weiterleiten: Body vorausgefüllt, To leer
onSuccess?: () => void; onSuccess?: () => void;
contractId?: number; // Optional: Vertrag dem die gesendete E-Mail zugeordnet wird contractId?: number; // Optional: Vertrag dem die gesendete E-Mail zugeordnet wird
} }
@@ -20,6 +21,7 @@ export default function ComposeEmailModal({
onClose, onClose,
account, account,
replyTo, replyTo,
forwardOf,
onSuccess, onSuccess,
contractId, contractId,
}: ComposeEmailModalProps) { }: ComposeEmailModalProps) {
@@ -47,6 +49,30 @@ export default function ComposeEmailModal({
? `\n\n--- Ursprüngliche Nachricht ---\nVon: ${replyTo.fromName || replyTo.fromAddress}\nAm: ${originalDate}\n\n${replyTo.textBody}` ? `\n\n--- Ursprüngliche Nachricht ---\nVon: ${replyTo.fromName || replyTo.fromAddress}\nAm: ${originalDate}\n\n${replyTo.textBody}`
: ''; : '';
setBody(quotedText); 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 { } else {
// Neue E-Mail: Felder leer // Neue E-Mail: Felder leer
setTo(''); setTo('');
@@ -57,7 +83,7 @@ export default function ComposeEmailModal({
setAttachments([]); setAttachments([]);
setError(null); setError(null);
} }
}, [isOpen, replyTo]); }, [isOpen, replyTo, forwardOf]);
// Maximale Dateigröße: 10 MB // Maximale Dateigröße: 10 MB
const MAX_FILE_SIZE = 10 * 1024 * 1024; const MAX_FILE_SIZE = 10 * 1024 * 1024;
@@ -194,7 +220,7 @@ export default function ComposeEmailModal({
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={handleClose} onClose={handleClose}
title={replyTo ? 'Antworten' : 'Neue E-Mail'} title={replyTo ? 'Antworten' : forwardOf ? 'Weiterleiten' : 'Neue E-Mail'}
size="lg" size="lg"
> >
<div className="space-y-4"> <div className="space-y-4">
@@ -26,6 +26,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const [showCompose, setShowCompose] = useState(false); const [showCompose, setShowCompose] = useState(false);
const [showAssign, setShowAssign] = useState(false); const [showAssign, setShowAssign] = useState(false);
const [replyToEmail, setReplyToEmail] = useState<CachedEmail | null>(null); const [replyToEmail, setReplyToEmail] = useState<CachedEmail | null>(null);
const [forwardEmail, setForwardEmail] = useState<CachedEmail | null>(null);
// Such- und Filterzustand. Alle Filter sind AND-verknüpft im Backend. // Such- und Filterzustand. Alle Filter sind AND-verknüpft im Backend.
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -193,14 +194,74 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const handleReply = () => { const handleReply = () => {
setReplyToEmail(emailDetail || null); setReplyToEmail(emailDetail || null);
setForwardEmail(null);
setShowCompose(true);
};
const handleForward = () => {
setForwardEmail(emailDetail || null);
setReplyToEmail(null);
setShowCompose(true); setShowCompose(true);
}; };
const handleNewEmail = () => { const handleNewEmail = () => {
setReplyToEmail(null); setReplyToEmail(null);
setForwardEmail(null);
setShowCompose(true); setShowCompose(true);
}; };
// "Erneut senden": die E-Mail an die ursprüngliche Empfänger-Adresse
// (= die Stressfrei-Adresse selbst) noch einmal schicken. Use-Case:
// wenn die Forwards der Stressfrei-Adresse zwischenzeitlich auf eine
// andere Kunden-E-Mail umgestellt wurden, kommt die alte Mail dort nicht
// an durch erneutes Senden ans Postfach läuft sie durch die jetzt
// aktuellen Forwards und landet beim neuen Empfänger.
const handleResend = async () => {
if (!emailDetail || !selectedAccount) return;
let toAddresses: string[] = [];
try {
const parsed = JSON.parse(emailDetail.toAddresses || '[]');
if (Array.isArray(parsed)) toAddresses = parsed;
} catch {
// Fallback: bekannte Mailbox-Adresse
if (selectedAccount.email) toAddresses = [selectedAccount.email];
}
if (toAddresses.length === 0 && selectedAccount.email) {
toAddresses = [selectedAccount.email];
}
const hasAttachments = emailDetail.hasAttachments;
const lines = [
`Diese E-Mail erneut an ${toAddresses.join(', ')} senden?`,
'',
'Die Mail wird via SMTP wieder ans Postfach zugestellt und nimmt den',
'Weg durch die AKTUELL hinterlegten Forwards damit landet sie bei',
'dem heute hinterlegten Empfänger (auch wenn er sich seit dem',
'Original-Empfang geändert hat).',
];
if (hasAttachments) {
lines.push('', '⚠ Hinweis: Anhänge werden NICHT erneut versendet. Wenn du Anhänge brauchst, nutze stattdessen "Weiterleiten".');
}
if (!confirm(lines.join('\n'))) return;
try {
const subj = emailDetail.subject || '(Kein Betreff)';
await stressfreiEmailApi.sendEmail(selectedAccount.id, {
to: toAddresses,
subject: subj,
text: emailDetail.textBody || undefined,
html: emailDetail.htmlBody || undefined,
});
toast.success('E-Mail wurde erneut an ' + toAddresses.join(', ') + ' gesendet.');
// Gesendet-Ordner-Counts aktualisieren
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
queryClient.invalidateQueries({ queryKey: ['emails'] });
} catch (err: any) {
toast.error(err?.response?.data?.error || err?.message || 'Fehler beim erneuten Senden');
}
};
const handleAssignContract = () => { const handleAssignContract = () => {
setShowAssign(true); setShowAssign(true);
}; };
@@ -551,6 +612,8 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
<EmailDetail <EmailDetail
email={emailDetail} email={emailDetail}
onReply={handleReply} onReply={handleReply}
onForward={handleForward}
onResend={selectedFolder !== 'TRASH' ? handleResend : undefined}
onAssignContract={handleAssignContract} onAssignContract={handleAssignContract}
onDeleted={() => { onDeleted={() => {
setSelectedEmail(null); setSelectedEmail(null);
@@ -582,9 +645,11 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
onClose={() => { onClose={() => {
setShowCompose(false); setShowCompose(false);
setReplyToEmail(null); setReplyToEmail(null);
setForwardEmail(null);
}} }}
account={selectedAccount} account={selectedAccount}
replyTo={replyToEmail || undefined} replyTo={replyToEmail || undefined}
forwardOf={forwardEmail || undefined}
onSuccess={() => { onSuccess={() => {
// Gesendete E-Mails aktualisieren // Gesendete E-Mails aktualisieren
queryClient.invalidateQueries({ queryKey: ['emails', 'customer', customerId, selectedAccountId, 'SENT'] }); queryClient.invalidateQueries({ queryKey: ['emails', 'customer', customerId, selectedAccountId, 'SENT'] });
+27 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react'; import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { CachedEmail, cachedEmailApi } from '../../services/api'; import { CachedEmail, cachedEmailApi } from '../../services/api';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -13,6 +13,8 @@ import SaveEmailAsPdfModal from './SaveEmailAsPdfModal';
interface EmailDetailProps { interface EmailDetailProps {
email: CachedEmail; email: CachedEmail;
onReply: () => void; onReply: () => void;
onForward?: () => void; // Weiterleiten (öffnet ComposeModal im Forward-Modus)
onResend?: () => void; // Erneut an Empfänger senden (One-Click-Resend)
onAssignContract: () => void; onAssignContract: () => void;
onDeleted?: () => void; // Callback nach Löschen onDeleted?: () => void; // Callback nach Löschen
isSentFolder?: boolean; isSentFolder?: boolean;
@@ -25,6 +27,8 @@ interface EmailDetailProps {
export default function EmailDetail({ export default function EmailDetail({
email, email,
onReply, onReply,
onForward,
onResend,
onAssignContract, onAssignContract,
onDeleted, onDeleted,
isSentFolder: _isSentFolder = false, isSentFolder: _isSentFolder = false,
@@ -222,6 +226,28 @@ export default function EmailDetail({
<Reply className="w-4 h-4 mr-1" /> <Reply className="w-4 h-4 mr-1" />
Antworten Antworten
</Button> </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 */} {/* E-Mail als PDF speichern */}
<Button <Button
variant="ghost" variant="ghost"