From a0d8c62082cc707c910570f91bf20e67094ea5b7 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Sat, 22 Aug 2026 00:03:48 +0200 Subject: [PATCH] Absenderpruefung bei PayPal-Mails entfernen --- app/paypal-inbox.php | 31 ++++++++++--------- docs/backoffice.md | 20 ++++++++++++ docs/deployment.md | 6 ++-- scripts/check-imap-support.php | 7 +++-- scripts/check-paypal-inbox-flow.php | 48 ++++++++++++++++------------- scripts/fetch-paypal-payments.php | 18 +++++------ 6 files changed, 78 insertions(+), 52 deletions(-) diff --git a/app/paypal-inbox.php b/app/paypal-inbox.php index de86126..685739b 100644 --- a/app/paypal-inbox.php +++ b/app/paypal-inbox.php @@ -153,14 +153,14 @@ function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?s } /** - * Stammt die Mail wirklich von PayPal? Geprueft wird die Domain der - * Absenderadresse - inklusive Landesvarianten (paypal.at, paypal.co.uk) und - * Versand-Subdomains (e.paypal.de), die PayPal tatsaechlich benutzt. + * Sieht der Absender nach PayPal aus? Erkennt Landesvarianten (paypal.at, + * paypal.co.uk) und Versand-Subdomains (e.paypal.de); der Vergleich haengt + * am Ende der Domain, "paypal.de.beispiel.com" ist also kein PayPal. * - * Der Vergleich haengt bewusst am Ende der Domain: "paypal.de.beispiel.com" - * ist NICHT PayPal, wuerde bei einer simplen Textsuche nach "@paypal.de" - * aber durchrutschen - und damit koennte jemand mit Kenntnis der - * Eingangsadresse Zahlungen erfinden. + * Nur zur Anzeige in der Diagnose (check-imap-support.php): die + * Verarbeitung prueft den Absender bewusst nicht, weil Zahlungsmails auch + * ueber manuelle Weiterleitungen ankommen. Massgeblich ist die + * Empfangsadresse. */ function paypal_from_is_paypal(string $fromHeader): bool { @@ -381,19 +381,22 @@ function paypal_preview(PDO $pdo, int $tenantId, array $parsed): array } /** - * Verarbeitet eine rohe PayPal-Mail vollstaendig: Tenant per Plus-Token, - * Absenderpruefung, Parsing, Abgleich. Fuer den IMAP-Cron und Tests. + * Verarbeitet eine rohe PayPal-Mail vollstaendig: Mandant per Plus-Token der + * Empfangsadresse, Parsing, Abgleich. Fuer den IMAP-Cron und Tests. + * + * Der Absender wird nicht ausgewertet - siehe Kommentar im Rumpf. * * @param string $recipient Empfaengeradresse mit Plus-Token - * @param string $fromHeader From-Header der Mail * @param string $rawBody (dekodierter) Mail-Body * @return array{status:string, tenant_id?:int} */ -function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, string $rawBody, bool $previewOnly = false): array +function paypal_process_raw(PDO $pdo, string $recipient, string $rawBody, bool $previewOnly = false): array { - if (!paypal_from_is_paypal($fromHeader)) { - return ['status' => 'not_from_paypal']; - } + // Der Absender wird bewusst nicht geprueft: Zahlungsmails erreichen die + // Kaffeeliste auch ueber manuelle Weiterleitungen, und dann steht dort + // die Adresse des Weiterleitenden statt PayPal. Massgeblich ist allein + // die Empfangsadresse - ihr Token bestimmt den Mandanten und ist damit + // zugleich das Geheimnis, das den Zugang schuetzt. $token = paypal_inbox_extract_token($recipient); if ($token === null) { return ['status' => 'no_token']; diff --git a/docs/backoffice.md b/docs/backoffice.md index 16c2264..f5b2dde 100644 --- a/docs/backoffice.md +++ b/docs/backoffice.md @@ -250,3 +250,23 @@ Mitglied hinter dieser Adresse fremdes Geld gutgeschrieben. Deshalb: - übrig bleibt nur eine Adresse, die **nahe am Namen des Zahlers** steht; ist keine in Reichweite, wird keine genommen und es bleibt beim Namensvergleich. + +### Keine Absenderprüfung + +Stand: 2026-08-21. Die Verarbeitung prüft **nicht**, ob die Mail von PayPal +kommt. Der Grund ist praktisch: Zahlungsmails erreichen die Kaffeeliste auch +über manuelle Weiterleitungen, und dann steht im Absender die Adresse des +Weiterleitenden. Eine Absenderprüfung hätte genau diese Mails abgelehnt +(`not_from_paypal`). + +Maßgeblich ist allein die **Empfangsadresse**: ihr Plus-Token bestimmt den +Mandanten (`paypal_inbox_extract_token()` → `paypal_inbox_resolve_tenant()`) +und ist damit zugleich das Geheimnis, das den Zugang schützt. Wer die +Adresse kennt, kann eine Mail im PayPal-Wortlaut schicken und eine +Gutschrift auslösen — die Adresse gehört deshalb nicht in Verteiler, +Signaturen oder Tickets. Als Bremsen bleiben: der Text muss als +PayPal-Zahlungsmail erkennbar sein, jeder Transaktionscode wird nur einmal +verbucht, und ohne eindeutige Zuordnung landet alles in der Warteschlange. + +`paypal_from_is_paypal()` gibt es weiterhin, aber nur noch als Anzeige in +`scripts/check-imap-support.php` („Absender sieht nach PayPal aus"). diff --git a/docs/deployment.md b/docs/deployment.md index b5ef748..2f40add 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -275,11 +275,11 @@ prüfen: ``` php scripts/fetch-paypal-payments.php --file=mail.html \ - --recipient=zahlungen+@kaffeeliste.de --from=service@paypal.de [--dry-run] + --recipient=zahlungen+@kaffeeliste.de [--dry-run] ``` -Die Verarbeitungskette selbst (Absenderprüfung, Token, Parser, Zuordnung, -Buchung, Dedup, Parken) deckt `scripts/check-paypal-inbox-flow.php` als +Die Verarbeitungskette selbst (Token, Parser, Zuordnung, Buchung, Dedup, +Parken) deckt `scripts/check-paypal-inbox-flow.php` als Regressionstest ab — alles außer der IMAP-Verbindung. ## Deploy-Ablauf für ein Update diff --git a/scripts/check-imap-support.php b/scripts/check-imap-support.php index 02d0dff..95f7f95 100644 --- a/scripts/check-imap-support.php +++ b/scripts/check-imap-support.php @@ -26,7 +26,8 @@ if (PHP_SAPI !== 'cli') { } require_once __DIR__ . '/../app/bootstrap.php'; -// paypal_from_is_paypal() fuer die Absenderpruefung in der Auflistung. +// paypal_from_is_paypal() - in der Auflistung nur als Information; die +// Verarbeitung selbst prueft den Absender nicht. require_once __DIR__ . '/../app/paypal-inbox.php'; function imap_check_line(string $label, string $value): void @@ -192,13 +193,13 @@ if (function_exists('imap_open')) { } $from = (string) ($overview->from ?? ''); printf( - "#%-3d %s Von: %s\n An: %s\n Betreff: %s\n Absender gilt als PayPal: %s\n", + "#%-3d %s Von: %s\n An: %s\n Betreff: %s\n Absender sieht nach PayPal aus: %s\n", $msgno, (int) ($overview->seen ?? 0) === 1 ? 'gelesen ' : 'UNGELESEN', $from, implode(' | ', $empfaenger) !== '' ? implode(' | ', $empfaenger) : '(keine Empfaengerzeile gefunden)', (string) ($overview->subject ?? ''), - paypal_from_is_paypal($from) ? 'ja' : 'NEIN - deshalb not_from_paypal' + paypal_from_is_paypal($from) ? 'ja' : 'nein (wird trotzdem verarbeitet)' ); } imap_close($mbox); diff --git a/scripts/check-paypal-inbox-flow.php b/scripts/check-paypal-inbox-flow.php index 2eea57a..2daa2ea 100644 --- a/scripts/check-paypal-inbox-flow.php +++ b/scripts/check-paypal-inbox-flow.php @@ -4,9 +4,10 @@ declare(strict_types=1); /** * Regressionstest fuer die Verarbeitung eingehender PayPal-Mails - alles - * ausser der IMAP-Verbindung selbst: Absenderpruefung, Mandant per - * Plus-Token, Parser, Zuordnung, Buchung, Dedup, das Parken bei - * abgeschalteter PayPal-Funktion und die schreibfreie Vorschau (--dry-run). + * ausser der IMAP-Verbindung selbst: Mandant per Plus-Token der + * Empfangsadresse, Parser, Zuordnung (Mailadresse, Name, Mitteilung), + * Buchung, Dedup, das Parken bei abgeschalteter PayPal-Funktion und die + * schreibfreie Vorschau (--dry-run). * * Der IMAP-Teil laesst sich nur gegen ein echtes Postfach pruefen (siehe * docs/deployment.md, Abschnitt PayPal-Postfach). @@ -109,26 +110,26 @@ try { $mailA = paypal_flow_mail("Erika Musterfrau {$namensSuffix}", '12,50', $codeA, 'Kaffeekasse', '12,15'); // Erst die Vorschau: sie darf nichts anlegen. - $vorschau = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailA, true); + $vorschau = paypal_process_raw($pdo, $empfaengerA, $mailA, true); paypal_flow_assert('Vorschau meldet die geplante Buchung', ($vorschau['status'] ?? '') === 'would_book', $failures, $passes); paypal_flow_assert('Vorschau nennt das Mitglied', str_contains((string) ($vorschau['participant'] ?? ''), 'Erika'), $failures, $passes); $zahlungenNachVorschau = (int) $pdo->query('SELECT COUNT(*) FROM paypal_payments')->fetchColumn(); - $ergebnisA = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailA); + $ergebnisA = paypal_process_raw($pdo, $empfaengerA, $mailA); paypal_flow_assert('eindeutiger Name wird automatisch gebucht', ($ergebnisA['status'] ?? '') === 'booked', $failures, $passes); paypal_flow_assert('Vorschau hat nichts gespeichert', $zahlungenNachVorschau + 1 === (int) $pdo->query('SELECT COUNT(*) FROM paypal_payments')->fetchColumn(), $failures, $passes); // Gebucht wird die "Summe" (nach Gebuehr), nicht der Sendebetrag. paypal_flow_assert('gebucht wird der Netto-Betrag (12,15 EUR)', paypal_flow_balance($pdo, $participantA) === 1215, $failures, $passes); // --- Dedup: dieselbe Mail ein zweites Mal - $ergebnisDoppelt = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailA); + $ergebnisDoppelt = paypal_process_raw($pdo, $empfaengerA, $mailA); paypal_flow_assert('dieselbe Transaktion wird nicht doppelt gebucht', ($ergebnisDoppelt['status'] ?? '') === 'duplicate', $failures, $passes); paypal_flow_assert('Guthaben bleibt nach der Dublette unveraendert', paypal_flow_balance($pdo, $participantA) === 1215, $failures, $passes); // --- Fall 2: unbekannter Zahler -> Warteschlange $codeB = 'TXNQUEUE' . strtoupper(bin2hex(random_bytes(4))); $mailB = paypal_flow_mail('Unbekannter Zahler', '5,00', $codeB, 'ohne Zuordnung'); - $ergebnisB = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailB); + $ergebnisB = paypal_process_raw($pdo, $empfaengerA, $mailB); paypal_flow_assert('unbekannter Zahler landet in der Warteschlange', ($ergebnisB['status'] ?? '') === 'unmatched', $failures, $passes); paypal_flow_assert('Warteschlange enthaelt die Zahlung', paypal_count_unmatched($pdo, $tenantA) === 1, $failures, $passes); @@ -138,7 +139,7 @@ try { $perMailAdresse = strtolower("karin-kassierer-{$namensSuffix}") . '-' . $suffix . 'm@test.local'; $codeM = 'TXNMAIL' . strtoupper(bin2hex(random_bytes(4))); $mailM = paypal_flow_mail('Voellig Anderer Name', '6,00', $codeM, 'per Adresse', null, $perMailAdresse); - $ergebnisM = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailM); + $ergebnisM = paypal_process_raw($pdo, $empfaengerA, $mailM); paypal_flow_assert('Zahlung wird ueber die Mailadresse zugeordnet', ($ergebnisM['status'] ?? '') === 'booked', $failures, $passes); paypal_flow_assert('Betrag landet beim Mitglied mit dieser Adresse', paypal_flow_balance($pdo, $perMailId) === 600, $failures, $passes); @@ -164,7 +165,7 @@ try { // Mail wird verworfen. $codeSelf = 'TXNSELF' . strtoupper(bin2hex(random_bytes(4))); $mailSelf = paypal_flow_mail('Noch Ein Zahler', '1,00', $codeSelf, 'eigene Adresse', null, $empfaengerA); - $ergebnisSelf = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailSelf); + $ergebnisSelf = paypal_process_raw($pdo, $empfaengerA, $mailSelf); paypal_flow_assert('eigene Empfaengeradresse wird nicht zur Zahleradresse', ($ergebnisSelf['status'] ?? '') === 'unmatched', $failures, $passes); $selfAdresse = $pdo->prepare('SELECT payer_email FROM paypal_payments WHERE transaction_code = ?'); $selfAdresse->execute([$codeSelf]); @@ -178,7 +179,7 @@ try { $codeE = 'TXNLERN' . strtoupper(bin2hex(random_bytes(4))); $zahlername = "Bernd B. Beispiel-Handel {$namensSuffix}"; $mailE = paypal_flow_mail($zahlername, '4,00', $codeE, 'erste Zahlung'); - $ergebnisE = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailE); + $ergebnisE = paypal_process_raw($pdo, $empfaengerA, $mailE); paypal_flow_assert('abweichender Zahlername landet zunaechst in der Warteschlange', ($ergebnisE['status'] ?? '') === 'unmatched', $failures, $passes); $zuordnung = paypal_assign_payment($pdo, $tenantA, (int) $ergebnisE['payment_id'], $abweichend, null); @@ -187,14 +188,14 @@ try { $codeF = 'TXNAUTO2' . strtoupper(bin2hex(random_bytes(4))); $mailF = paypal_flow_mail($zahlername, '4,00', $codeF, 'zweite Zahlung'); - $ergebnisF = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailF); + $ergebnisF = paypal_process_raw($pdo, $empfaengerA, $mailF); paypal_flow_assert('zweite Zahlung desselben Zahlers wird automatisch gebucht', ($ergebnisF['status'] ?? '') === 'booked', $failures, $passes); paypal_flow_assert('beide Zahlungen sind gutgeschrieben', paypal_flow_balance($pdo, $abweichend) === 800, $failures, $passes); // Ein bereits gepflegter PayPal-Name wird nicht ueberschrieben. $codeG = 'TXNKEEP' . strtoupper(bin2hex(random_bytes(4))); $mailG = paypal_flow_mail('Ganz Anderer Name', '2,00', $codeG, 'fremder Name'); - $ergebnisG = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailG); + $ergebnisG = paypal_process_raw($pdo, $empfaengerA, $mailG); $zuordnungG = paypal_assign_payment($pdo, $tenantA, (int) $ergebnisG['payment_id'], $abweichend, null); paypal_flow_assert('gepflegter PayPal-Name bleibt unveraendert', ($zuordnungG['learned_paypal_name'] ?? null) === null, $failures, $passes); $gespeichert = $pdo->prepare('SELECT paypal_name FROM participants WHERE id = ?'); @@ -210,10 +211,10 @@ try { $codeC = 'TXNPARK' . strtoupper(bin2hex(random_bytes(4))); $mailC = paypal_flow_mail("Max Mustermann {$namensSuffix}", '7,00', $codeC, 'trotzdem gezahlt'); - $vorschauC = paypal_process_raw($pdo, $empfaengerB, 'service@paypal.de', $mailC, true); + $vorschauC = paypal_process_raw($pdo, $empfaengerB, $mailC, true); paypal_flow_assert('Vorschau meldet das Parken', ($vorschauC['status'] ?? '') === 'would_park', $failures, $passes); - $ergebnisC = paypal_process_raw($pdo, $empfaengerB, 'service@paypal.de', $mailC); + $ergebnisC = paypal_process_raw($pdo, $empfaengerB, $mailC); paypal_flow_assert('abgeschaltetes PayPal parkt die Zahlung', ($ergebnisC['status'] ?? '') === 'parked', $failures, $passes); paypal_flow_assert('geparkte Zahlung wird nicht gebucht', paypal_flow_balance($pdo, $participantB) === 0, $failures, $passes); paypal_flow_assert('geparkte Zahlung steht in der Warteschlange', paypal_count_unmatched($pdo, $tenantB) === 1, $failures, $passes); @@ -227,15 +228,18 @@ try { $codeD = 'TXNLOCK' . strtoupper(bin2hex(random_bytes(4))); $mailD = paypal_flow_mail("Lisa Beispiel {$namensSuffix}", '3,00', $codeD, 'gesperrt'); - $ergebnisD = paypal_process_raw($pdo, "zahlungen+{$tokenC}@kaffeeliste.de", 'service@paypal.de', $mailD); + $ergebnisD = paypal_process_raw($pdo, "zahlungen+{$tokenC}@kaffeeliste.de", $mailD); paypal_flow_assert('Betreiber-Sperre parkt die Zahlung ebenfalls', ($ergebnisD['status'] ?? '') === 'parked', $failures, $passes); // --- Fall 5: alles, was gar nicht erst verarbeitet werden darf - $fremd = paypal_process_raw($pdo, $empfaengerA, 'no-reply@beispiel.de', $mailA); - paypal_flow_assert('Mail von fremdem Absender wird abgelehnt', ($fremd['status'] ?? '') === 'not_from_paypal', $failures, $passes); + // Der Absender wird bewusst nicht geprueft: eine von Hand weitergeleitete + // Mail traegt die Adresse des Weiterleitenden, soll aber ankommen. + $codeW = 'TXNFWD' . strtoupper(bin2hex(random_bytes(4))); + $mailW = paypal_flow_mail("Erika Musterfrau {$namensSuffix}", '3,50', $codeW, 'weitergeleitet'); + $weitergeleitet = paypal_process_raw($pdo, $empfaengerA, $mailW); + paypal_flow_assert('weitergeleitete Mail wird unabhaengig vom Absender verarbeitet', ($weitergeleitet['status'] ?? '') === 'booked', $failures, $passes); - // Absenderpruefung: PayPal verschickt aus mehreren Domains, eine - // Lookalike-Domain darf aber nie durchgehen. + // Die Absendererkennung dient nur noch der Diagnose-Anzeige. foreach ([ '"PayPal" ' => true, 'service@paypal.com' => true, @@ -255,13 +259,13 @@ try { ); } - $ohneToken = paypal_process_raw($pdo, 'zahlungen@kaffeeliste.de', 'service@paypal.de', $mailA); + $ohneToken = paypal_process_raw($pdo, 'zahlungen@kaffeeliste.de', $mailA); paypal_flow_assert('Adresse ohne Token wird abgelehnt', ($ohneToken['status'] ?? '') === 'no_token', $failures, $passes); - $falscherToken = paypal_process_raw($pdo, 'zahlungen+gibtesnicht@kaffeeliste.de', 'service@paypal.de', $mailA); + $falscherToken = paypal_process_raw($pdo, 'zahlungen+gibtesnicht@kaffeeliste.de', $mailA); paypal_flow_assert('unbekannter Token wird abgelehnt', ($falscherToken['status'] ?? '') === 'unknown_tenant', $failures, $passes); - $keineZahlung = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', 'Newsletter von PayPal'); + $keineZahlung = paypal_process_raw($pdo, $empfaengerA, 'Newsletter von PayPal'); paypal_flow_assert('Werbemail wird nicht als Zahlung erkannt', ($keineZahlung['status'] ?? '') === 'not_a_payment', $failures, $passes); } finally { foreach ($tenantIds as $tenantId) { diff --git a/scripts/fetch-paypal-payments.php b/scripts/fetch-paypal-payments.php index c5265ae..22602d2 100644 --- a/scripts/fetch-paypal-payments.php +++ b/scripts/fetch-paypal-payments.php @@ -8,10 +8,10 @@ declare(strict_types=1); * * Ablauf pro ungelesener Mail: * 1. Mandant ueber den Plus-Token der Empfaengeradresse bestimmen - * (zahlungen+@…). - * 2. Absender gegen paypal.de/.com pruefen. - * 3. Body parsen, Netto ermitteln, per Transaktionscode deduplizieren. - * 4. Eindeutiger Namens-Match -> automatisch als Einzahlung buchen, + * (zahlungen+@…). Der Absender wird bewusst nicht geprueft - + * Zahlungsmails kommen auch ueber manuelle Weiterleitungen an. + * 2. Body parsen, Netto ermitteln, per Transaktionscode deduplizieren. + * 3. Eindeutiger Namens-Match -> automatisch als Einzahlung buchen, * sonst in die Warteschlange legen. Ist PayPal fuer den Mandanten * abgeschaltet, wird die Zahlung nur geparkt (gespeichert, nicht * gebucht). @@ -31,7 +31,7 @@ declare(strict_types=1); * * Testmodus ohne IMAP (eine Rohmail aus einer Datei verarbeiten): * php scripts/fetch-paypal-payments.php --file=mail.html \ - * --recipient=zahlungen+abc123@kaffeeliste.de --from=service@paypal.de + * --recipient=zahlungen+abc123@kaffeeliste.de */ if (PHP_SAPI !== 'cli') { @@ -42,7 +42,7 @@ if (PHP_SAPI !== 'cli') { require_once __DIR__ . '/../app/paypal-inbox.php'; $pdo = app_db_pdo(); -$opts = getopt('', ['file:', 'recipient:', 'from:', 'dry-run']); +$opts = getopt('', ['file:', 'recipient:', 'dry-run']); // ---- Testmodus: eine Datei verarbeiten (ohne IMAP) -------------------------- if (isset($opts['file'])) { @@ -52,8 +52,7 @@ if (isset($opts['file'])) { exit(1); } $recipient = (string) ($opts['recipient'] ?? ''); - $from = (string) ($opts['from'] ?? 'service@paypal.de'); - $result = paypal_process_raw($pdo, $recipient, $from, $body, isset($opts['dry-run'])); + $result = paypal_process_raw($pdo, $recipient, $body, isset($opts['dry-run'])); echo 'Ergebnis: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . "\n"; exit(0); } @@ -151,12 +150,11 @@ for ($msgno = 1; $msgno <= $messageCount; $msgno++) { if (preg_match_all('/^(?:Delivered-To|X-Original-To|To|X-Forwarded-To):\s*(.+)$/im', $rawHeader, $all)) { $recipient = implode(' ', $all[1]); } - $from = (string) ($overview->from ?? ''); $body = paypal_imap_fetch_body($mbox, $msgno); // Im Probelauf wird nichts gespeichert und nichts gebucht - die Vorschau // sagt nur, was passieren wuerde. - $result = paypal_process_raw($pdo, $recipient, $from, $body, $dryRun); + $result = paypal_process_raw($pdo, $recipient, $body, $dryRun); switch ($result['status']) { case 'booked':