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:
@@ -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'] });
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user