prefix . 'sendmails_protokoll'; } /** * Eintrag schreiben. $mail: betreff, ref (X-SendMails-Ref) und atts (die wp_mail-Argumente, nur fuer * "Erneut senden" und nur mit eingeschalteter Option protokoll_inhalt, ohne Anhaenge). * * @return int ID des Eintrags (0 bei Fehler) */ public static function eintragen(string $status, string $quelle, $empfaenger, string $fehler = '', array $mail = []): int { global $wpdb; $liste = self::adressen($empfaenger); $erste = $liste[0] ?? ''; // Selbsttest: zaehlt mit, aber nicht als Empfaenger-Domain if (Testpostfach::ist($erste)) { $erste = ''; } $mitAdressen = (int) Einstellungen::wert('protokoll_tage') > 0; $inhalt = null; if ($mitAdressen && Einstellungen::wert('protokoll_inhalt') && isset($mail['atts']) && is_array($mail['atts'])) { $inhalt = wp_json_encode([ 'to' => $mail['atts']['to'] ?? '', 'subject' => (string) ($mail['atts']['subject'] ?? ''), 'message' => (string) ($mail['atts']['message'] ?? ''), 'headers' => $mail['atts']['headers'] ?? '', 'anhaenge' => count(array_filter((array) ($mail['atts']['attachments'] ?? []))), ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null; } $wpdb->insert(self::tabelle(), [ 'zeit' => gmdate('Y-m-d H:i:s'), 'quelle' => mb_substr($quelle, 0, 100), 'domain' => mb_substr(strtolower(substr((string) strrchr($erste, '@'), 1)), 0, 190), 'empfaenger' => max(1, count($liste)), 'status' => $status, 'fehler' => mb_substr(self::ohneAdressen($fehler), 0, 300), 'ref' => preg_match('/^[A-Za-z0-9;:_.-]{1,64}$/', (string) ($mail['ref'] ?? '')) ? (string) $mail['ref'] : '', 'an' => $mitAdressen ? mb_substr(implode(', ', $liste), 0, 500) : '', 'betreff' => $mitAdressen ? mb_substr(self::einzeilig((string) ($mail['betreff'] ?? '')), 0, 255) : '', 'inhalt' => $inhalt, ]); $id = (int) $wpdb->insert_id; if (wp_rand(1, 200) === 1) { self::aufraeumen(); } return $id; } private static function einzeilig(string $text): string { return trim((string) preg_replace('/\s+/u', ' ', $text)); } /** Fuer $fn die Herkunft $quelle verwenden (erneutes Senden einer Mail aus dem Protokoll). */ public static function mitQuelle(string $quelle, callable $fn) { self::$quelleVorgabe = $quelle; try { return $fn(); } finally { self::$quelleVorgabe = null; } } /** Mailadressen in Fehlertexten schwaerzen (PHPMailer nennt abgelehnte Empfaenger): a@b.de -> …@b.de, Test-Postfaecher unter ihrer Rolle */ public static function ohneAdressen(string $text): string { $text = Testpostfach::imText($text); return (string) preg_replace('/[^\s<>@,;:"()\[\]]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})/', '…@$1', $text); } /** * Herkunft eines wp_mail()-Aufrufs aus dem Aufrufstapel: Plugin-Ordner, * Theme oder "wordpress" (Kern, z. B. Passwort vergessen). */ public static function quelle(): string { if (self::$quelleVorgabe !== null) { return self::$quelleVorgabe; } $plugins = wp_normalize_path(WP_PLUGIN_DIR) . '/'; $mu = wp_normalize_path(WPMU_PLUGIN_DIR) . '/'; $themes = wp_normalize_path(get_theme_root()) . '/'; $eigen = wp_normalize_path(SENDMAILS_DIR); // Herkunft der Mail fuer die Statistik "Wer verschickt Mails?", nur Dateipfade, keine Argumente. foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 40) as $f) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace $datei = isset($f['file']) ? wp_normalize_path($f['file']) : ''; if ($datei === '' || str_starts_with($datei, $eigen)) { continue; } foreach (['plugin' => $plugins, 'mu' => $mu, 'theme' => $themes] as $art => $basis) { if (str_starts_with($datei, $basis)) { $ordner = strtok(substr($datei, strlen($basis)), '/'); $ordner = preg_replace('/\.php$/', '', (string) $ordner); return ($art === 'plugin' ? '' : $art . ':') . sanitize_key($ordner); } } } return 'wordpress'; } /** * Herkunft zu einer Kennung X-SendMails-Ref ("wp;;"), soweit sie von dieser Website stammt: * Kurzwerte aller hier bekannten Quellen (Protokoll, installierte Plugins, Themes, WordPress) vergleichen. * null: Kennung fehlt oder stammt von einer anderen Website bzw. Anwendung desselben Kontos. */ public static function refQuelle(string $ref): ?string { if (!preg_match('/^wp;([a-z0-9]{1,20});/', $ref, $m)) { return null; } if ($m[1] === 'test') { return 'test'; } static $karte = null; if ($karte === null) { global $wpdb; $quellen = (array) $wpdb->get_col($wpdb->prepare('SELECT DISTINCT quelle FROM %i', self::tabelle())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if (!function_exists('get_plugins')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach (array_keys(get_plugins()) as $datei) { $quellen[] = sanitize_key(strtok($datei, '/')); $quellen[] = sanitize_key(preg_replace('/\.php$/', '', basename($datei))); } foreach (array_keys(wp_get_themes()) as $theme) { $quellen[] = 'theme:' . sanitize_key($theme); } $quellen[] = 'wordpress'; $quellen[] = 'test'; $karte = []; foreach (array_unique($quellen) as $q) { $karte[Versand::refKurz((string) $q)] = (string) $q; } } return $karte[$m[1]] ?? null; } /** Lesbarer Name zu einer Quelle (Plugin-Name aus dem Kopf der Plugin-Datei). */ public static function name(string $quelle): string { static $namen = null; if ($quelle === 'wordpress') { return 'WordPress'; } if ($quelle === 'test') { return __('Test email from this plugin', 'purehost-send-mails'); } if (str_starts_with($quelle, 'theme:')) { $theme = wp_get_theme(substr($quelle, 6)); /* translators: %s: theme name */ return sprintf(__('Theme %s', 'purehost-send-mails'), $theme->exists() ? $theme->get('Name') : substr($quelle, 6)); } if ($namen === null) { if (!function_exists('get_plugins')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $namen = []; foreach (get_plugins() as $datei => $kopf) { $namen[sanitize_key(strtok($datei, '/'))] = $kopf['Name']; $namen[sanitize_key(preg_replace('/\.php$/', '', basename($datei)))] ??= $kopf['Name']; } } return $namen[preg_replace('/^mu:/', '', $quelle)] ?? $quelle; } /** Auswertung fuer die Statistik-Seite (letzte $tage Tage). */ public static function auswertung(int $tage): array { global $wpdb; $ab = gmdate('Y-m-d H:i:s', time() - $tage * DAY_IN_SECONDS); $t = self::tabelle(); $je = (array) $wpdb->get_results($wpdb->prepare( 'SELECT quelle, status, COUNT(*) AS mails, SUM(empfaenger) AS empfaenger FROM %i WHERE zeit >= %s GROUP BY quelle, status', $t, $ab ), ARRAY_A); $quellen = []; $summe = array_fill_keys(self::STATUS, 0); foreach ($je as $z) { $q = (string) $z['quelle']; $quellen[$q] ??= ['quelle' => $q, 'name' => self::name($q), 'mails' => 0, 'empfaenger' => 0] + array_fill_keys(self::STATUS, 0); $quellen[$q][$z['status']] = (int) $z['mails']; $quellen[$q]['mails'] += (int) $z['mails']; $quellen[$q]['empfaenger'] += (int) $z['empfaenger']; $summe[$z['status']] = ($summe[$z['status']] ?? 0) + (int) $z['mails']; } usort($quellen, static fn($a, $b) => $b['mails'] <=> $a['mails']); $domains = (array) $wpdb->get_results($wpdb->prepare( "SELECT domain, COUNT(*) AS mails, SUM(CASE WHEN status IN ('abgelehnt','aufgegeben') THEN 1 ELSE 0 END) AS fehler FROM %i WHERE zeit >= %s AND domain <> '' GROUP BY domain ORDER BY mails DESC LIMIT 10", $t, $ab ), ARRAY_A); $tage_reihe = (array) $wpdb->get_results($wpdb->prepare( 'SELECT substr(zeit, 1, 10) AS tag, status, COUNT(*) AS mails FROM %i WHERE zeit >= %s GROUP BY substr(zeit, 1, 10), status', $t, $ab ), ARRAY_A); $reihe = []; for ($i = $tage - 1; $i >= 0; $i--) { $reihe[gmdate('Y-m-d', time() - $i * DAY_IN_SECONDS)] = array_fill_keys(self::STATUS, 0); } foreach ($tage_reihe as $z) { if (isset($reihe[$z['tag']])) { $reihe[$z['tag']][$z['status']] = (int) $z['mails']; } } // Je UTC-Stunde zaehlen (hoechstens 24 x Tage Zeilen), Wochentag/Stunde dann in der Zeitzone der Website. $stunden = (array) $wpdb->get_results($wpdb->prepare( 'SELECT substr(zeit, 1, 13) AS stunde, COUNT(*) AS mails FROM %i WHERE zeit >= %s GROUP BY substr(zeit, 1, 13)', $t, $ab ), ARRAY_A); $heat = array_fill(0, 7, array_fill(0, 24, 0)); foreach ($stunden as $z) { $ts = strtotime($z['stunde'] . ':00:00 UTC'); $heat[(int) wp_date('N', $ts) - 1][(int) wp_date('G', $ts)] += (int) $z['mails']; } return ['quellen' => array_values($quellen), 'summe' => $summe, 'domains' => $domains, 'reihe' => $reihe, 'heatmap' => $heat, 'gesamt' => array_sum($summe)]; } /** Fuer den CSV-Export: Rohzeilen ohne Adressen. */ public static function zeilen(int $tage): array { global $wpdb; return (array) $wpdb->get_results($wpdb->prepare( 'SELECT zeit, quelle, domain, empfaenger, status, fehler FROM %i WHERE zeit >= %s ORDER BY id', self::tabelle(), gmdate('Y-m-d H:i:s', time() - $tage * DAY_IN_SECONDS) ), ARRAY_A); } /** * Statistik nach 90 Tagen loeschen; Empfaenger, Betreff und Zustelldetails (mit Adressen) nach der * eingestellten Zahl Tage leeren, den Inhalt nach 7 Tagen. Laeuft mit jedem Abgleich (alle 15 Minuten). */ public static function aufraeumen(): void { global $wpdb; $t = self::tabelle(); $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE zeit < %s', $t, gmdate('Y-m-d H:i:s', time() - self::AUFBEWAHRUNG_TAGE * DAY_IN_SECONDS))); $tage = max(0, (int) Einstellungen::wert('protokoll_tage')); $wpdb->query($wpdb->prepare( "UPDATE %i SET an = '', betreff = '', zustell_info = NULL, inhalt = NULL WHERE zeit < %s AND (an <> '' OR betreff <> '' OR zustell_info IS NOT NULL OR inhalt IS NOT NULL)", $t, gmdate('Y-m-d H:i:s', time() - $tage * DAY_IN_SECONDS) )); $inhaltTage = Einstellungen::wert('protokoll_inhalt') ? min($tage, self::INHALT_TAGE) : 0; $wpdb->query($wpdb->prepare( 'UPDATE %i SET inhalt = NULL WHERE zeit < %s AND inhalt IS NOT NULL', $t, gmdate('Y-m-d H:i:s', time() - $inhaltTage * DAY_IN_SECONDS) )); } /** * Zustellstatus fuer gesendete Mails der letzten 3 Tage ohne endgueltige Rueckmeldung bei send-mails.de * nachfragen (GET /v1/zustellstatus, je 100 Kennungen). Mit $ids nur diese Eintraege (Knopf in der Liste). * * @return int Zahl der Eintraege mit neuem Stand */ public static function zustellungAbgleichen(int $max = 500, array $ids = []): int { global $wpdb; if (Einstellungen::apiSchluessel() === '') { return 0; } $offen = (array) $wpdb->get_results($wpdb->prepare( "SELECT id, ref, empfaenger, an, zustellung FROM %i WHERE ref <> '' AND zustellung IN ('', 'teilweise', 'bounce_weich') AND status IN ('gesendet', 'nachgeholt') AND zeit >= %s ORDER BY id LIMIT %d", self::tabelle(), gmdate('Y-m-d H:i:s', time() - self::ZUSTELLUNG_TAGE * DAY_IN_SECONDS), $ids ? 5000 : $max ), ARRAY_A); if ($ids) { // Knopf in der Liste: nur die angezeigten Eintraege $ids = array_map('intval', $ids); $offen = array_slice(array_values(array_filter($offen, static fn($z) => in_array((int) $z['id'], $ids, true))), 0, $max); } $neu = 0; foreach (array_chunk($offen, 100) as $teil) { $r = Api::anfrage('/v1/zustellstatus', ['refs' => implode(',', array_unique(array_column($teil, 'ref')))]); if (!$r['ok']) { break; } $status = (array) ($r['daten']['status'] ?? []); foreach ($teil as $z) { $ereignisse = array_values(array_filter((array) ($status[$z['ref']] ?? []), 'is_array')); if (!$ereignisse) { continue; } $stand = self::zustellStand($ereignisse, (int) $z['empfaenger']); $info = array_map(static fn($e) => [ 'art' => (string) ($e['art'] ?? ''), // Adressen nur, solange das Protokoll Empfaenger speichert 'an' => (string) $z['an'] !== '' ? (string) ($e['empfaenger'] ?? '') : '', 'zeit' => (string) ($e['zeit'] ?? ''), 'diagnose' => mb_substr((string) ($e['diagnose'] ?? ''), 0, 300), 'diagnose_art' => (string) ($e['diagnose_art'] ?? ''), ], array_slice($ereignisse, 0, 50)); $wpdb->update(self::tabelle(), ['zustellung' => $stand, 'zustell_info' => wp_json_encode($info, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)], ['id' => (int) $z['id']]); if ($stand !== $z['zustellung']) { $neu++; } } } return $neu; } /** Gesamtstand einer Mail an $anzahl Empfaenger aus den Ereignissen von send-mails.de. */ public static function zustellStand(array $ereignisse, int $anzahl): string { $je = array_count_values(array_map(static fn($e) => (string) ($e['art'] ?? ''), $ereignisse)); $zugestellt = $je['zustellung'] ?? 0; return match (true) { !empty($je['beschwerde']) => 'beschwerde', !empty($je['bounce_hart']) => 'bounce_hart', !empty($je['abgelehnt']) => 'abgelehnt', $zugestellt >= max(1, $anzahl) => 'zugestellt', !empty($je['bounce_weich']) => 'bounce_weich', $zugestellt > 0 => 'teilweise', default => '', }; } /** * Eintraege fuer die Liste "E-Mail-Protokoll". Filter: suche (Empfaenger, Betreff), status * (zugestellt, offen, fehler, warteschlange), quelle. Ohne Inhalt. * * @return array{zeilen: array, gesamt: int} */ public static function liste(array $filter, int $seite = 1, int $proSeite = 50): array { global $wpdb; // Feste Abfrage, jeder Filter ueber Platzhalter (leer = nicht gefiltert) $suche = trim((string) ($filter['suche'] ?? '')); $muster = '%' . $wpdb->esc_like($suche) . '%'; $gruppe = (string) ($filter['status'] ?? ''); $gruppe = in_array($gruppe, ['zugestellt', 'offen', 'fehler', 'warteschlange'], true) ? $gruppe : ''; $quelle = (string) ($filter['quelle'] ?? ''); $gesamt = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE (%s = '' OR an LIKE %s OR betreff LIKE %s) AND (%s = '' OR quelle = %s) AND (%s = '' OR (%s = 'zugestellt' AND status IN ('gesendet','nachgeholt') AND zustellung = 'zugestellt') OR (%s = 'offen' AND status IN ('gesendet','nachgeholt') AND zustellung IN ('', 'teilweise')) OR (%s = 'fehler' AND (status IN ('abgelehnt','aufgegeben') OR zustellung IN ('bounce_hart','bounce_weich','beschwerde','abgelehnt'))) OR (%s = 'warteschlange' AND status IN ('warteschlange','angehalten')))", self::tabelle(), $suche, $muster, $muster, $quelle, $quelle, $gruppe, $gruppe, $gruppe, $gruppe, $gruppe )); $zeilen = (array) $wpdb->get_results($wpdb->prepare( "SELECT id, zeit, quelle, domain, empfaenger, status, fehler, ref, an, betreff, zustellung, zustell_info, (inhalt IS NOT NULL) AS hat_inhalt FROM %i WHERE (%s = '' OR an LIKE %s OR betreff LIKE %s) AND (%s = '' OR quelle = %s) AND (%s = '' OR (%s = 'zugestellt' AND status IN ('gesendet','nachgeholt') AND zustellung = 'zugestellt') OR (%s = 'offen' AND status IN ('gesendet','nachgeholt') AND zustellung IN ('', 'teilweise')) OR (%s = 'fehler' AND (status IN ('abgelehnt','aufgegeben') OR zustellung IN ('bounce_hart','bounce_weich','beschwerde','abgelehnt'))) OR (%s = 'warteschlange' AND status IN ('warteschlange','angehalten'))) ORDER BY id DESC LIMIT %d OFFSET %d", self::tabelle(), $suche, $muster, $muster, $quelle, $quelle, $gruppe, $gruppe, $gruppe, $gruppe, $gruppe, $proSeite, max(0, $seite - 1) * $proSeite ), ARRAY_A); return ['zeilen' => $zeilen, 'gesamt' => $gesamt]; } /** * Zahlen fuer den Wochenbericht (letzte $tage Tage), ohne Testmails und eigene Warnungen. * * @return array{gesamt: int, zugestellt: int, bounce_hart: int, bounce_weich: int, beschwerde: int, fehler: int, angehalten: int, quellen: array} */ public static function zahlen(int $tage): array { global $wpdb; $t = self::tabelle(); $ab = gmdate('Y-m-d H:i:s', time() - $tage * DAY_IN_SECONDS); $z = (array) $wpdb->get_row($wpdb->prepare( "SELECT COUNT(*) AS gesamt, SUM(CASE WHEN zustellung = 'zugestellt' THEN 1 ELSE 0 END) AS zugestellt, SUM(CASE WHEN zustellung = 'bounce_hart' THEN 1 ELSE 0 END) AS bounce_hart, SUM(CASE WHEN zustellung = 'bounce_weich' THEN 1 ELSE 0 END) AS bounce_weich, SUM(CASE WHEN zustellung = 'beschwerde' THEN 1 ELSE 0 END) AS beschwerde, SUM(CASE WHEN status IN ('abgelehnt', 'aufgegeben') THEN 1 ELSE 0 END) AS fehler, SUM(CASE WHEN status = 'angehalten' THEN 1 ELSE 0 END) AS angehalten FROM %i WHERE zeit >= %s AND quelle NOT IN ('test', 'sendmails')", $t, $ab ), ARRAY_A); $quellen = (array) $wpdb->get_results($wpdb->prepare( "SELECT quelle, COUNT(*) AS mails FROM %i WHERE zeit >= %s AND quelle NOT IN ('test', 'sendmails') GROUP BY quelle ORDER BY mails DESC LIMIT 10", $t, $ab ), ARRAY_A); return array_map('intval', array_intersect_key($z + array_fill_keys(['gesamt', 'zugestellt', 'bounce_hart', 'bounce_weich', 'beschwerde', 'fehler', 'angehalten'], 0), array_flip(['gesamt', 'zugestellt', 'bounce_hart', 'bounce_weich', 'beschwerde', 'fehler', 'angehalten']))) + ['quellen' => $quellen]; } /** Quellen der letzten 90 Tage fuer den Filter. */ public static function quellen(): array { global $wpdb; return (array) $wpdb->get_col($wpdb->prepare('SELECT DISTINCT quelle FROM %i ORDER BY quelle', self::tabelle())); } /** Gespeicherter Inhalt eines Eintrags (fuer "Erneut senden"), null wenn keiner. */ public static function inhalt(int $id): ?array { global $wpdb; $z = $wpdb->get_row($wpdb->prepare('SELECT quelle, inhalt FROM %i WHERE id = %d', self::tabelle(), $id), ARRAY_A); $daten = $z && $z['inhalt'] !== null ? json_decode((string) $z['inhalt'], true) : null; return is_array($daten) ? $daten + ['quelle' => (string) $z['quelle']] : null; } private static function adressen($empfaenger): array { $liste = is_array($empfaenger) ? $empfaenger : explode(',', (string) $empfaenger); $out = []; foreach ($liste as $a) { $a = trim((string) $a); if (preg_match('/<([^>]+)>/', $a, $m)) { $a = $m[1]; } if ($a !== '') { $out[] = $a; } } return $out; } }