Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7dcfb5f3 | ||
|
|
70724e35dc | ||
|
|
f2703ed6b7 |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user