Compare commits

...
3 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.7 dc7dcfb5f3 Docs: neue Fixes in todo + TESTING nachpflegen
todo.md: drei letzte Bug/Feature-Einträge
- AutosaveDateInput (Kündigungsdatum-Fokus-Sprung)
- IMAP-Humanizer (Anhang + Sync)
- Bankkarte-Beschreibung in Vertrags-Ansicht + Select

TESTING.md: neuer Abschnitt "E-Mail-Client (IMAP/Anhänge)"
mit Testfällen für Sync-Fehler, Anhang-Download-Fehler und
Autosave-Datumsfelder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 15:42:18 +02:00
duffyduckandClaude Opus 4.7 70724e35dc Kündigungsdatum: Cursor bleibt beim Tippen im Feld
Bug: bei Eingabe der Jahreszahl im Kündigungsbestätigungs-Datum
sprang der Fokus raus und die Seite scrollte hoch. Ursache:
<input type=date> onChange feuert bei jedem Tastendruck. Sobald
"18.08.0002" ein gültiges Datum ergab, feuerte der Mutation-Aufruf
eine PUT-Request, die Server-Antwort re-renderte den Input und
der Cursor landete am Feldanfang – der Rest der Jahreszahl kam
im nächsten Feld an.

Fix: neue kleine AutosaveDateInput-Komponente – hält den Wert
während der Eingabe lokal, feuert onSave erst bei onBlur oder
Enter. Escape verwirft die Änderung. Kein Save bei unverändertem
Wert.

Angewendet auf beide betroffenen Stellen in ContractDetail
(Bestätigung erhalten am – Kündigungsschreiben + Options-Variante).

Andere Datumsfelder der App laufen entweder über react-hook-form
(batched, save auf Submit) oder halten den Wert lokal – die sind
vom Bug nicht betroffen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 15:32:25 +02:00
duffyduckandClaude Opus 4.7 f2703ed6b7 IMAP-Fehler: zentraler Humanizer + Auth-Fehler explizit
Der User sah beim Postfach-Sync stumpf "Command failed" – realer
Grund war ein abgelaufenes Postfach-Passwort. Der Bug betraf
Anhang-Download UND Sync und war der gleiche wie 2 Commits zuvor:
imapflow wirft `new Error('Command failed')` und legt Details in
`.responseText`/`.responseStatus` ab, wir haben sie nirgends
gelesen.

Fix: humanizeImapError() als zentraler Helper in imapService:
- Extrahiert responseText/responseStatus aus imapflow-Errors
- Erkennt Auth-Fehler ("authentication failed", "invalid
  credentials", NO+auth) und liefert klare Meldung mit
  Handlungsanweisung: "Passwort stimmt nicht mehr, bitte
  Zugangsdaten synchronisieren".
- Deckt zusätzlich Netzwerk/TLS/Timeout/UID-Stale ab.

Angewendet auf:
- fetchAttachmentInner (Anhang-Fetch)
- downloadAttachment-Controller (Response an UI)
- syncEmailsForAccount (Sync-Ergebnis + Toast)

Damit sieht der User im Toast künftig statt "Command failed" die
tatsächliche Ursache – gleicher Mechanismus für Sync und
Anhang-Download.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 15:28:43 +02:00
7 changed files with 226 additions and 51 deletions
@@ -5,7 +5,7 @@ import * as cachedEmailService from '../services/cachedEmail.service.js';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import * as invoiceService from '../services/invoice.service.js';
import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '../services/smtpService.js';
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { fetchAttachment, appendToSent, ImapCredentials, humanizeImapError } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
import { decrypt } from '../utils/encryption.js';
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate, assertSafePdf } from '../utils/sanitize.js';
@@ -797,33 +797,9 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
res.send(attachment.content);
} catch (error) {
console.error('downloadAttachment error:', error);
// imapflow versteckt die eigentliche Server-Antwort in
// `.responseText`/`.responseStatus` und setzt `.message` immer nur auf
// `'Command failed'`. Wenn wir nur die `.message` ausgeben, sieht der
// Endnutzer im UI nichts Verwertbares.
const anyErr = error as { message?: string; responseText?: string; responseStatus?: string };
const rawMsg = anyErr?.responseText
? `${anyErr.responseStatus ? anyErr.responseStatus + ' ' : ''}${anyErr.responseText}`
: error instanceof Error ? error.message : 'Unbekannter Fehler';
const lower = rawMsg.toLowerCase();
let friendly = rawMsg;
if (lower.includes('socket disconnected') && lower.includes('tls')) {
friendly =
'IMAP-Server hat die TLS-Verbindung abgelehnt. Mögliche Ursache: selbstsigniertes Zertifikat. Bitte in den E-Mail-Provider-Einstellungen "Selbstsignierte Zertifikate erlauben" aktivieren.';
} else if (lower.includes('econnrefused')) {
friendly = 'IMAP-Server ist nicht erreichbar (Verbindung verweigert). Bitte Server/Port prüfen.';
} else if (lower.includes('etimedout')) {
friendly = 'Zeitüberschreitung beim Verbinden zum IMAP-Server. Bitte später erneut versuchen.';
} else if (lower.includes('authentication') || lower.includes('auth')) {
friendly = 'IMAP-Authentifizierung fehlgeschlagen. Bitte Zugangsdaten prüfen.';
} else if (lower.includes('no longer exist') || lower.includes('no such message') || lower.includes('unknown')) {
friendly = 'Nachricht existiert im IMAP-Postfach nicht mehr (wahrscheinlich verschoben oder gelöscht). Bitte E-Mail-Liste neu synchronisieren.';
}
res.status(500).json({
success: false,
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
error: `Fehler beim Herunterladen des Anhangs: ${humanizeImapError(error)}`,
} as ApiResponse);
}
}
+6 -3
View File
@@ -4,7 +4,7 @@
import { CachedEmail, Prisma, EmailFolder } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { decrypt } from '../utils/encryption.js';
import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete } from './imapService.js';
import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete, humanizeImapError } from './imapService.js';
import { getImapSmtpSettings } from './emailProvider/emailProviderService.js';
// ==================== TYPES ====================
@@ -155,12 +155,15 @@ export async function syncEmailsForAccount(
};
} catch (error) {
console.error('syncEmailsForAccount error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
// humanizeImapError zieht die eigentliche Server-Antwort aus dem
// imapflow-Error und erkennt insbesondere Auth-Fehler ("Passwort
// stimmt nicht mehr") mit klarer Handlungsanweisung vorher sah
// der User im Sync-Toast nur "Command failed".
return {
success: false,
newEmails: 0,
totalEmails: 0,
error: errorMessage,
error: humanizeImapError(error),
};
}
}
+69 -8
View File
@@ -7,6 +7,69 @@ import { simpleParser, ParsedMail, AddressObject } from 'mailparser';
// Verschlüsselungstyp
export type MailEncryption = 'SSL' | 'STARTTLS' | 'NONE';
/**
* Zieht eine menschenlesbare Fehlermeldung aus einem imapflow-Error.
* `imapflow` wirft bei jedem IMAP-NO/BAD-Response immer nur
* `new Error('Command failed')` und legt den echten Grund in
* `err.responseText` / `err.responseStatus` ab. Ohne diese Extraktion
* sähe der User im UI nur "Command failed" ohne Kontext (das war der
* ursprüngliche Bug 2026-07-11: Passwort war falsch, gemeldet wurde
* nur "Command failed").
*
* Zusätzlich mappen wir häufige Fehler-Klassen auf klare Meldungen
* mit Handlungsanweisung gilt sowohl für Anhang-Download als auch
* für Postfach-Sync, damit der User weiß was zu tun ist.
*/
export function humanizeImapError(error: unknown): string {
const anyErr = error as {
message?: string;
responseText?: string;
responseStatus?: string;
authenticationFailed?: boolean;
code?: string;
};
const rawText = anyErr?.responseText || anyErr?.message || 'Unbekannter Fehler';
const statusTag = anyErr?.responseStatus ? `${anyErr.responseStatus} ` : '';
const combined = `${statusTag}${rawText}`;
const lower = combined.toLowerCase();
// 1) Auth-Fehler explizit erkennen häufigste Ursache in der Praxis.
// Passwort im CRM stimmt nicht mehr mit dem am Provider überein
// (Passwort geändert / abgelaufen / Postfach neu angelegt).
if (
anyErr?.authenticationFailed
|| lower.includes('authentication failed')
|| lower.includes('auth failed')
|| lower.includes('invalid credentials')
|| lower.includes('login failed')
|| lower.includes('logindisabled')
|| (anyErr?.responseStatus === 'NO' && lower.includes('auth'))
) {
return 'IMAP-Login abgelehnt. Das gespeicherte Postfach-Passwort stimmt nicht mehr. Bitte im Postfach "Zugangsdaten synchronisieren" ausführen oder das Passwort neu setzen.';
}
// 2) UID-Stale-Fehler beim Fetch: E-Mail existiert IMAP-seitig nicht mehr.
if (lower.includes('no longer exist') || lower.includes('no such message')) {
return 'Nachricht existiert im IMAP-Postfach nicht mehr (wahrscheinlich verschoben oder gelöscht). Bitte E-Mail-Liste neu synchronisieren.';
}
// 3) Netzwerk-/TLS-Fehler
if (lower.includes('socket disconnected') && lower.includes('tls')) {
return 'IMAP-Server hat die TLS-Verbindung abgelehnt. Mögliche Ursache: selbstsigniertes Zertifikat. Bitte in den E-Mail-Provider-Einstellungen "Selbstsignierte Zertifikate erlauben" aktivieren.';
}
if (lower.includes('econnrefused')) {
return 'IMAP-Server ist nicht erreichbar (Verbindung verweigert). Bitte Server/Port prüfen.';
}
if (lower.includes('etimedout')) {
return 'Zeitüberschreitung beim Verbinden zum IMAP-Server. Bitte später erneut versuchen.';
}
// 4) Fallback: den vom Server gelieferten Text nehmen der ist meist
// schon aussagekräftig ("BAD Session expired." etc.). Wenn wir hier
// gar nichts hätten, käme jetzt zumindest der Status-Tag durch.
return combined;
}
export interface ImapCredentials {
host: string;
port: number;
@@ -531,15 +594,13 @@ async function fetchAttachmentInner(
}
} catch (fetchErr) {
// imapflow wirft bei IMAP-NO/BAD immer nur `new Error('Command failed')`,
// die eigentliche Server-Antwort steht in `.responseText`. Ohne die
// Anreicherung sähe der User oben im UI nur "Command failed" ohne
// Kontext (Ursache 2026-07-11).
const anyErr = fetchErr as { message?: string; responseText?: string; responseStatus?: string; response?: unknown };
const detail = anyErr?.responseText || anyErr?.message || 'unbekannter Fehler';
const statusTag = anyErr?.responseStatus ? `${anyErr.responseStatus} ` : '';
console.error(`[fetchAttachment] fetch(UID ${uid}) failed [${statusTag}${detail}]:`, fetchErr);
// die eigentliche Server-Antwort steht in `.responseText`. Zentraler
// Helper `humanizeImapError` sorgt dafür, dass Anhang-Download UND
// Sync die gleiche klare Meldung liefern (2026-07-11: Ursache war
// ein abgelaufenes Postfach-Passwort, kam als "Command failed" durch).
console.error(`[fetchAttachment] fetch(UID ${uid}) failed:`, fetchErr);
throw new Error(
`Nachricht mit UID ${uid} konnte nicht geladen werden: ${statusTag}${detail}. Möglicherweise wurde sie im IMAP-Postfach verschoben oder gelöscht.`,
`Nachricht mit UID ${uid}: ${humanizeImapError(fetchErr)}`,
);
}
+34
View File
@@ -162,6 +162,40 @@ ob der Eintrag erstellt wurde.
---
## 📎 E-Mail-Client (IMAP/Anhänge)
### 1. Postfach-Sync
- [ ] Kunde-Detail → Tab "E-Mail-Postfach" → Sync-Button → "E-Mails synchronisiert"-Toast
- [ ] Falsches Postfach-Passwort im Backend hinterlegen → Sync-Button → Toast zeigt
**klare Meldung** "IMAP-Login abgelehnt. Das gespeicherte Postfach-Passwort stimmt
nicht mehr…" **NICHT** "Command failed"
- [ ] Postfach-Passwort im Modal neu setzen ("Passwort synchronisieren") → nächster
Sync klappt
### 2. Anhang-Download
- [ ] E-Mail mit PDF-Anhang öffnen → "Anzeigen" klickt → PDF öffnet inline im neuen Tab
- [ ] Bei falschem Postfach-Passwort: klare Fehlermeldung wie bei Sync (nicht
"Command failed")
- [ ] Anhang einer server-seitig gelöschten E-Mail → Meldung "Nachricht existiert im
IMAP-Postfach nicht mehr … bitte E-Mail-Liste neu synchronisieren"
### 3. Autosave-Datumsfelder (Vertrag)
- [ ] Vertrag mit Status Gekündigt/etc. öffnen → Kündigungsbestätigung hochladen →
"Bestätigung erhalten am"-Feld erscheint
- [ ] Datum manuell eintippen (Tag → Monat → Jahr, Ziffer für Ziffer) → **Cursor bleibt
im Feld**, Seite scrollt NICHT, Fokus springt NICHT raus
- [ ] Nach Verlassen des Felds (Tab / Klick woanders) → Speichern feuert einmal, Datum
ist gespeichert
- [ ] Enter im Feld → speichert und verlässt Feld
- [ ] Escape im Feld → verwirft Änderung, alter Wert wieder da
- [ ] Gleicher Test für "Bestätigung erhalten am"-Feld unter Kündigungsbestätigung
Optionen
---
## Wie benutzen?
1. Diese Datei öffnen
+31
View File
@@ -97,6 +97,37 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🐞 Kündigungsdatum: Cursor sprang beim Tippen aus dem Feld**
- `<input type=date>` feuerte `onChange` bei jedem Tastendruck; sobald
z.B. `18.08.0002` ein gültiges Datum ergab, feuerte die PUT-Mutation,
Server-Antwort re-renderte den Input, Cursor sprang raus, Seite
scrollte, weiteres Tippen landete im nächsten Feld.
- Fix: neue `AutosaveDateInput`-Komponente hält den Wert während der
Eingabe lokal, feuert `onSave` erst bei Blur/Enter. Escape verwirft.
Angewendet auf beide Kündigungsbestätigungs-Datumsfelder in
ContractDetail. Andere App-Datumsfelder laufen über react-hook-form
(batched Submit) und sind nicht betroffen.
- [x] **🐞 IMAP-Fehler: „Command failed" ohne Kontext**
- `imapflow` wirft bei jedem IMAP-NO/BAD-Response nur
`new Error('Command failed')` und legt Details in `.responseText`/
`.responseStatus` ab. Wir haben sie nirgends gelesen → User sah
im Sync-Toast und im Anhang-Download nur "Command failed" ohne
Ursache. Realer Fall: abgelaufenes Postfach-Passwort.
- Fix: zentraler `humanizeImapError()`-Helper in `imapService.ts`.
Extrahiert responseText/responseStatus, erkennt Auth-Fehler,
UID-Stale, TLS-/Netzwerk-Fehler und gibt jeweils eine klare
Meldung mit Handlungsanweisung zurück. Angewendet auf
`fetchAttachmentInner`, `downloadAttachment`-Controller und
`syncEmailsForAccount`.
- [x] **🆕 Bankkarte-Beschreibung in Vertragsansicht + Auswahl**
- Nach dem Anlegen des `description`-Feldes (Bankkarten-Modal) wird
die Beschreibung jetzt auch in der Vertragsansicht (Bankkarten-Card)
und im ContractForm-Select angezeigt. Im Select mit
Whitespace-Flatten und 60-Zeichen-Cap, damit lange Notizen den
Select nicht sprengen.
- [x] **🔒 Pentest R104.1 Salutation-Endpunkte: 404 statt 500**
- INFO-Finding: `GET/PUT /api/customers/:id/salutation-preference`
warfen bei nicht-existierendem Customer 500 statt 404.
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from 'react';
/**
* Datums-Input, der beim Tippen KEIN onSave feuert, sondern erst
* onBlur oder bei Enter/Escape. Grund:
*
* Ein `<input type="date">` feuert `onChange` bei jedem Tastendruck,
* sobald das Feld ein syntaktisch gültiges Datum ergibt. Beim Tippen
* einer Jahreszahl (z.B. "2026") wird das Feld schon bei "0002"
* gültig, ein direkt daran gehängter `mutate()`-Aufruf feuert eine
* PUT-Request, die Server-Antwort re-rendert den Input und der Cursor
* springt aus dem Feld die Seite scrollt hoch, weiteres Tippen
* landet im nächsten Feld.
*
* Deshalb: lokaler State während der Eingabe, Save erst wenn der
* User fertig ist (Blur / Enter). Escape verwirft die lokale Änderung.
*/
interface AutosaveDateInputProps {
/** Aktueller Wert vom Server als ISO-Date-String (`YYYY-MM-DD` oder leer). */
value: string;
/** Wird aufgerufen, wenn der User das Feld verlässt oder Enter drückt und
* der lokale Wert vom Server-Wert abweicht. `null` bedeutet "leer". */
onSave: (value: string | null) => void;
className?: string;
disabled?: boolean;
}
export default function AutosaveDateInput({
value,
onSave,
className = 'block w-full max-w-[180px] px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500',
disabled,
}: AutosaveDateInputProps) {
const [local, setLocal] = useState(value);
// Ref hält den letzten "committeten" Wert, damit wir nach dem Speichern
// bei einem Prop-Update den lokalen State nur updaten wenn der User
// nicht gerade tippt.
const isFocused = useRef(false);
// Wenn der Server-Wert sich ändert und wir nicht gerade fokussiert
// sind, den lokalen State nachziehen. Sonst würde ein externes Update
// während der Eingabe das getippte überschreiben.
useEffect(() => {
if (!isFocused.current) {
setLocal(value);
}
}, [value]);
const commit = () => {
const trimmed = local.trim();
// Nur speichern, wenn sich WIRKLICH was geändert hat.
if (trimmed === (value || '')) return;
onSave(trimmed === '' ? null : trimmed);
};
return (
<input
type="date"
value={local}
onChange={(e) => setLocal(e.target.value)}
onFocus={() => { isFocused.current = true; }}
onBlur={() => {
isFocused.current = false;
commit();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setLocal(value);
(e.currentTarget as HTMLInputElement).blur();
}
}}
disabled={disabled}
className={className}
/>
);
}
@@ -18,6 +18,7 @@ import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, Exter
import JpgToPdfModal from '../../components/ui/JpgToPdfModal';
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import AutosaveDateInput from '../../components/ui/AutosaveDateInput';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
@@ -2325,14 +2326,9 @@ export default function ContractDetail() {
<div className="mt-2">
<label className="text-xs text-gray-500 block mb-1">Bestätigung erhalten am</label>
<div className="flex items-center gap-2">
<input
type="date"
<AutosaveDateInput
value={c.cancellationConfirmationDate ? c.cancellationConfirmationDate.split('T')[0] : ''}
onChange={(e) => {
const value = e.target.value || null;
updateCancellationDateMutation.mutate(value);
}}
className="block w-full max-w-[180px] px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
onSave={(value) => updateCancellationDateMutation.mutate(value)}
/>
{c.cancellationConfirmationDate && (
<button
@@ -2463,14 +2459,9 @@ export default function ContractDetail() {
<div className="mt-2">
<label className="text-xs text-gray-500 block mb-1">Bestätigung erhalten am</label>
<div className="flex items-center gap-2">
<input
type="date"
<AutosaveDateInput
value={c.cancellationConfirmationOptionsDate ? c.cancellationConfirmationOptionsDate.split('T')[0] : ''}
onChange={(e) => {
const value = e.target.value || null;
updateCancellationOptionsDateMutation.mutate(value);
}}
className="block w-full max-w-[180px] px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
onSave={(value) => updateCancellationOptionsDateMutation.mutate(value)}
/>
{c.cancellationConfirmationOptionsDate && (
<button