(string) ($atts['subject'] ?? ''), 'atts' => $atts]; if (Warteschlange::aufnehmen($atts, $grund, 'angehalten')) { Protokoll::eintragen('angehalten', $quelle, $an, $grund, $mail); return true; } Protokoll::eintragen('abgelehnt', $quelle, $an, $grund, $mail); return false; } // Zugangsdaten nicht lesbar (Salts geaendert): nie am Plugin vorbei ueber mail() senden. if (!Einstellungen::smtpBereit()) { return self::einreihenOderAblehnen($atts, __('SMTP login missing or unreadable.', 'purehost-send-mails'), $warteschlange); } // Bekannte Stoerung: nicht jede Mail 8 s auf die Verbindung warten lassen. if ($warteschlange && get_transient(self::STOERUNG)) { return self::einreihenOderAblehnen($atts, __('send-mails.de is currently not reachable.', 'purehost-send-mails'), true); } [$ok, $fehler] = self::senden($atts); $mail = ['betreff' => (string) ($atts['subject'] ?? ''), 'ref' => self::$letzteRef, 'atts' => $atts]; if ($ok) { Protokoll::eintragen('gesendet', $quelle, $an, '', $mail); return true; } $e = $fehler['empfaenger'] ?? null; // Teilergebnis (einige zugestellt oder dauerhaft abgelehnt): je voruebergehend abgelehntem // Empfaenger ein eigener Eintrag, nur an diese Adresse. Nie mehrere Adressen in einen To-Kopf // zusammenfassen, sonst sehen fruehere Bcc-Empfaenger einander. if ($e !== null && ($e['zugestellt'] || $e['dauerhaft'])) { if ($e['zugestellt']) { Protokoll::eintragen('gesendet', $quelle, $an, __('partially delivered', 'purehost-send-mails'), $mail); } $mail['ref'] = ''; foreach ($e['dauerhaft'] as $adresse) { Protokoll::eintragen('abgelehnt', $quelle, $adresse, $fehler['text'], $mail); } $eingereiht = 0; foreach ($e['vorueber'] as $adresse) { if ($warteschlange && Warteschlange::aufnehmen(self::nurAn($atts, [$adresse]), $fehler['text'])) { Protokoll::eintragen('warteschlange', $quelle, $adresse, $fehler['text'], $mail); $eingereiht++; } else { Protokoll::eintragen('abgelehnt', $quelle, $adresse, $fehler['text'], $mail); } } return $e['zugestellt'] || $eingereiht > 0; } // Niemand hat die Mail, niemand dauerhaft abgelehnt: die Mail unveraendert einreihen. if (!$fehler['dauerhaft']) { return self::einreihenOderAblehnen($atts, $fehler['text'], $warteschlange); } Protokoll::eintragen('abgelehnt', $quelle, $an, $fehler['text'], ['ref' => ''] + $mail); return false; } private static function einreihenOderAblehnen(array $atts, string $grund, bool $warteschlange): bool { $mail = ['betreff' => (string) ($atts['subject'] ?? ''), 'atts' => $atts]; if ($warteschlange && Warteschlange::aufnehmen($atts, $grund)) { Protokoll::eintragen('warteschlange', (string) $atts['quelle'], $atts['to'] ?? '', $grund, $mail); return true; } Protokoll::eintragen('abgelehnt', (string) $atts['quelle'], $atts['to'] ?? '', $grund, $mail); return false; } /** Dieselbe Mail nur an die genannte(n) Adresse(n), ohne Cc/Bcc-Kopfzeilen. Aufrufer geben je Eintrag eine Adresse. */ public static function nurAn(array $atts, array $adressen): array { $kopf = $atts['headers'] ?? []; $zeilen = is_array($kopf) ? $kopf : explode("\n", str_replace("\r\n", "\n", (string) $kopf)); $atts['headers'] = array_values(array_filter($zeilen, static fn($z) => !preg_match('/^\s*(cc|bcc)\s*:/i', (string) $z))); $atts['to'] = array_values($adressen); return $atts; } /** Host, der beim ersten Versuch voruebergehend abgelehnt hat (fuer den zweiten Versuch hinten). */ private static ?string $hintenAn = null; /** @return string[] */ private static function reihenfolge(): array { $hosts = Einstellungen::smtpHosts(); shuffle($hosts); if (self::$hintenAn !== null && count($hosts) > 1) { $hosts = array_merge(array_values(array_diff($hosts, [self::$hintenAn])), array_values(array_intersect($hosts, [self::$hintenAn]))); } return $hosts; } /** * Senden mit hoechstens einem zweiten Versuch: Lehnt eine Annahmestelle voruebergehend ab * (z. B. ihr Anteil am Kontingent oder Limit ist gerade aufgebraucht) und hat noch niemand die * Mail, geht sie sofort an die andere. Verbindungsfehler wechselt PHPMailer schon selbst. * * @return array{0: bool, 1: ?array} */ public static function senden(array $args): array { [$ok, $fehler] = self::sendenEinmal($args); if ($ok || $fehler === null || $fehler['dauerhaft'] || $fehler['verbindung'] || count(Einstellungen::smtpHosts()) < 2) { return [$ok, $fehler]; } $e = $fehler['empfaenger'] ?? null; if ($e !== null && ($e['zugestellt'] || $e['dauerhaft'])) { return [$ok, $fehler]; } global $phpmailer; $host = $phpmailer instanceof PHPMailer && $phpmailer->getSMTPInstance() instanceof Smtp ? $phpmailer->getSMTPInstance()->host : ''; if ($host === '') { return [$ok, $fehler]; } self::$hintenAn = $host; try { return self::sendenEinmal($args); } finally { self::$hintenAn = null; } } /** * Der eigentliche wp_mail()-Aufruf, ohne Warteschlange. Die wp_mail-Filter * anderer Plugins sind schon im aeusseren Aufruf gelaufen (bzw. beim Einreihen) * und werden hier ausgehaengt, damit sie je Mail nur einmal wirken. * * @return array{0: bool, 1: ?array} */ private static function sendenEinmal(array $args): array { global $wp_filter; self::$innen = true; self::$letzterFehler = null; self::$letzteRef = ''; self::$refQuelle = (string) ($args['quelle'] ?? 'test'); $filter = $wp_filter['wp_mail'] ?? null; unset($wp_filter['wp_mail']); // Stand der vorigen Mail verwerfen: scheitert diese schon vor dem Versand, darf fehlerMerken() // nicht die Empfaenger der vorigen sehen. global $phpmailer; if ($phpmailer instanceof PHPMailer && $phpmailer->getSMTPInstance() instanceof Smtp) { $phpmailer->getSMTPInstance()->zuruecksetzen(); } try { $ok = wp_mail( $args['to'] ?? '', (string) ($args['subject'] ?? ''), (string) ($args['message'] ?? ''), $args['headers'] ?? '', $args['attachments'] ?? [], $args['embeds'] ?? [] ); } finally { if ($filter !== null) { $wp_filter['wp_mail'] = $filter; } self::$innen = false; } if ($ok) { if (get_transient(self::STOERUNG)) { delete_transient(self::STOERUNG); } return [true, null]; } return [false, self::$letzterFehler ?? ['text' => __('Unknown error while sending.', 'purehost-send-mails'), 'code' => '', 'dauerhaft' => false, 'verbindung' => false, 'empfaenger' => null]]; } /** * Testmail ueber send-mails.de, auch vor dem Einschalten, immer ohne Warteschlange. * * @return array{0: bool, 1: ?array} */ public static function test(array $args): array { // Ersetzt ein anderes Plugin wp_mail() (z. B. Post SMTP), liefe die Testmail sonst ueber dessen Weg // und gaelte trotzdem als Erfolg. Dann direkt ueber die eigene Verbindung zu send-mails.de senden. if (self::fremdesMailPlugin() !== null) { $erg = self::direktSenden($args); } else { self::$erzwungen = true; try { $erg = self::senden($args + ['quelle' => 'test']); } finally { self::$erzwungen = false; } } // Im Protokoll mit Zustellstatus (Selbsttest: Zustellung und Bounce sichtbar) Protokoll::eintragen($erg[0] ? 'gesendet' : 'abgelehnt', 'test', $args['to'] ?? '', $erg[0] ? '' : (string) ($erg[1]['text'] ?? ''), ['betreff' => (string) ($args['subject'] ?? ''), 'ref' => $erg[0] ? self::$letzteRef : '']); return $erg; } /** * Testmail ohne wp_mail(): eigene PHPMailer-Verbindung mit Zugang, Absender und Fehlerauswertung des Plugins. * * @return array{0: bool, 1: ?array} */ private static function direktSenden(array $args): array { require_once ABSPATH . WPINC . '/PHPMailer/PHPMailer.php'; require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php'; require_once ABSPATH . WPINC . '/PHPMailer/Exception.php'; $pm = new PHPMailer(true); $pm->setSMTPInstance(new Smtp()); $pm->CharSet = 'UTF-8'; $text = ''; $code = ''; try { self::verbindungSetzen($pm, Einstellungen::smtpBenutzer(), Einstellungen::smtpPasswort()); $pm->setFrom((string) Einstellungen::wert('absender_email') ?: (string) get_option('admin_email'), (string) Einstellungen::wert('absender_name') ?: (string) get_bloginfo('name'), false); self::absenderSetzen($pm); foreach ((array) $args['to'] as $an) { $pm->addAddress((string) $an); } $pm->Subject = (string) ($args['subject'] ?? ''); $pm->Body = (string) ($args['message'] ?? ''); self::$letzteRef = 'wp;test;' . bin2hex(random_bytes(8)); $pm->addCustomHeader('X-SendMails-Ref', self::$letzteRef); $pm->send(); delete_transient(self::STOERUNG); return [true, null]; } catch (\PHPMailer\PHPMailer\Exception $e) { $text = $e->getMessage(); $code = (string) ($pm->getSMTPInstance()->getError()['smtp_code'] ?? ''); } finally { $pm->smtpClose(); } if ($code === '' && preg_match('/\b([45]\d\d)[ -]/', $text, $m)) { $code = $m[1]; } $smtp = $pm->getSMTPInstance(); $empfaenger = $smtp instanceof Smtp ? $smtp->auswertung() : null; // PHPMailer verwirft den Code nach QUIT; die eigene SMTP-Klasse hat ihn je Empfaenger if ($code === '' && $smtp instanceof Smtp && $smtp->abgelehnt) { $code = (string) reset($smtp->abgelehnt); } $verbindung = $code === '' && $empfaenger === null && self::istVerbindungsfehler($text); return [false, ['text' => $text, 'code' => $code, 'dauerhaft' => $empfaenger !== null ? (!$empfaenger['zugestellt'] && !$empfaenger['vorueber']) : self::dauerhaft($code, $text), 'verbindung' => $verbindung, 'empfaenger' => $empfaenger]]; } /** * Verbindung und Anmeldung pruefen, ohne eine Mail zu senden. * * @return array{0: bool, 1: string} */ public static function verbindungPruefen(string $benutzer, string $passwort): array { require_once ABSPATH . WPINC . '/PHPMailer/PHPMailer.php'; require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php'; require_once ABSPATH . WPINC . '/PHPMailer/Exception.php'; $pm = new PHPMailer(true); self::verbindungSetzen($pm, $benutzer, $passwort); try { $ok = $pm->smtpConnect(); $pm->smtpClose(); if ($ok) { delete_transient(self::STOERUNG); } return [$ok, $ok ? '' : __('Connection failed.', 'purehost-send-mails')]; } catch (\PHPMailer\PHPMailer\Exception $e) { return [false, $e->getMessage()]; } } /** Kurzwert der Herkunft fuer X-SendMails-Ref; nur diese Website kann ihn zurueckuebersetzen (Protokoll::refQuelle). */ public static function refKurz(string $quelle): string { return substr(hash_hmac('sha256', $quelle, wp_salt('nonce')), 0, 10); } public static function einrichten($pm): void { if (!$pm instanceof PHPMailer || !self::$innen || !(Einstellungen::versandAktiv() || (self::$erzwungen && Einstellungen::smtpBereit()))) { return; } self::verbindungSetzen($pm, Einstellungen::smtpBenutzer(), Einstellungen::smtpPasswort()); self::absenderSetzen($pm); Design::textteil($pm); // Herkunft nur als Kurzwert: Empfaenger sollen nicht sehen, welche Plugins laufen. $ref = 'wp;' . self::refKurz(self::$refQuelle) . ';' . bin2hex(random_bytes(8)); $ref = preg_replace('/[^A-Za-z0-9;:_.-]/', '', (string) apply_filters('sendmails_ref', $ref, self::$refQuelle)); if ($ref !== '') { $pm->addCustomHeader('X-SendMails-Ref', substr($ref, 0, 200)); } self::$letzteRef = substr($ref, 0, 64); } public static function verbindungSetzen(PHPMailer $pm, string $benutzer, string $passwort): void { $pm->isSMTP(); if (!$pm->getSMTPInstance() instanceof Smtp) { $pm->setSMTPInstance(new Smtp()); } // Mehrere Annahmestellen: zufaellige Reihenfolge verteilt die Last, PHPMailer nimmt bei // Verbindungsfehlern selbst die naechste. Nach einem 4xx steht die antwortende hinten. $pm->Host = implode(';', self::reihenfolge()); $pm->Port = Einstellungen::smtpPort(); $pm->SMTPAuth = true; $pm->AuthType = ''; $pm->Username = $benutzer; $pm->Password = $passwort; $verschluesselung = Einstellungen::smtpVerschluesselung(); $pm->SMTPSecure = $verschluesselung; $pm->SMTPAutoTLS = $verschluesselung !== ''; // Einstellungen anderer Plugins am geteilten $phpmailer nicht uebernehmen (verify_peer=false, Debug mit AUTH im Log). $pm->SMTPOptions = []; $pm->SMTPDebug = 0; $pm->Timeout = 8; $pm->SMTPKeepAlive = false; } /** * Absender auf eine bei send-mails.de verifizierte Domain bringen. send-mails.de * lehnt andere Absender ab (550); so geht die Mail trotzdem raus, und die * urspruengliche Adresse bleibt als Reply-To erhalten. */ public static function absenderSetzen(PHPMailer $pm): void { $ziel = (string) Einstellungen::wert('absender_email'); $name = (string) Einstellungen::wert('absender_name'); $von = strtolower((string) $pm->From); // Eigener Absender fuer diese Quelle (z. B. WooCommerce von shop@): gilt immer, mit eigener Antwortadresse $regel = self::regelFuer(self::$refQuelle); if ($regel !== null) { $altName = (string) $pm->FromName; $pm->setFrom($regel['email'], $regel['name'] !== '' ? $regel['name'] : ($name !== '' ? $name : self::anzeigeName($altName)), false); if ($regel['antwort'] !== '') { $pm->clearReplyTos(); $pm->addReplyTo($regel['antwort']); } elseif ($von !== '' && $von !== strtolower($regel['email']) && !self::istStandardAbsender($von) && !$pm->getReplyToAddresses()) { $pm->addReplyTo($von, $altName); } $pm->Sender = $pm->From; return; } if ($ziel !== '' && self::mussErsetzen($von, $ziel)) { $altName = (string) $pm->FromName; $pm->setFrom($ziel, $name !== '' ? $name : self::anzeigeName($altName), false); if ($von !== '' && $von !== strtolower($ziel) && !self::istStandardAbsender($von) && !$pm->getReplyToAddresses()) { $pm->addReplyTo($von, $altName); } } $pm->Sender = $pm->From; } /** @return array{email: string, name: string, antwort: string}|null Absender-Regel fuer diese Quelle */ public static function regelFuer(string $quelle): ?array { if ($quelle === '' || $quelle === 'test') { return null; } foreach ((array) Einstellungen::wert('absender_regeln') as $r) { if (is_array($r) && ($r['quelle'] ?? '') === $quelle && is_email((string) ($r['email'] ?? ''))) { return ['email' => (string) $r['email'], 'name' => (string) ($r['name'] ?? ''), 'antwort' => is_email((string) ($r['antwort'] ?? '')) ? (string) $r['antwort'] : '']; } } return null; } /** Name der Mail, ohne die WordPress-Vorgabe "WordPress": dann der Name der Website. */ private static function anzeigeName(string $name): string { return ($name === '' || $name === 'WordPress') ? wp_specialchars_decode((string) get_bloginfo('name'), ENT_QUOTES) : $name; } private static function mussErsetzen(string $von, string $ziel): bool { if ((bool) Einstellungen::wert('absender_erzwingen') || $von === '' || self::istStandardAbsender($von)) { return true; } $domain = substr((string) strrchr($von, '@'), 1); // Nie eine API-Abfrage im Versandweg: zuletzt bekannte Liste (Abgleich und Admin halten sie aktuell). $verifiziert = Api::bekannteDomains(); if ($verifiziert === null) { return $domain !== strtolower(substr((string) strrchr($ziel, '@'), 1)); } return !in_array($domain, $verifiziert, true); } /** * Anderes Mail-Plugin, das den Versand selbst uebernimmt (dann wirkt dieses Plugin nicht * oder nur scheinbar). Name fuer die Anzeige, null wenn keins gefunden. */ public static function fremdesMailPlugin(): ?string { if (function_exists('wp_mail')) { $datei = wp_normalize_path((string) (new \ReflectionFunction('wp_mail'))->getFileName()); if (!str_ends_with($datei, '/' . WPINC . '/pluggable.php')) { $ordner = str_starts_with($datei, wp_normalize_path(WP_PLUGIN_DIR) . '/') ? strtok(substr($datei, strlen(wp_normalize_path(WP_PLUGIN_DIR)) + 1), '/') : basename($datei); return Protokoll::name(sanitize_key((string) $ordner)); } } $bekannt = ['WPMailSMTP\\Core' => 'WP Mail SMTP', 'FluentMail\\App\\Services\\Mailer\\Manager' => 'FluentSMTP', 'PostmanWpMail' => 'Post SMTP', 'EasyWPSMTP\\Core' => 'Easy WP SMTP', 'SendinblueSmtp' => 'Brevo/Sendinblue', 'WP_SES\\Main' => 'WP Offload SES']; foreach ($bekannt as $klasse => $name) { if (class_exists($klasse, false)) { return $name; } } return null; } /** WordPress-Vorgabe wordpress@, die praktisch nie verifiziert ist. */ private static function istStandardAbsender(string $von): bool { return str_starts_with($von, 'wordpress@'); } public static function fehlerMerken($fehler): void { if (!$fehler instanceof \WP_Error) { return; } $code = ''; $empfaenger = null; global $phpmailer; if ($phpmailer instanceof PHPMailer && $phpmailer->Mailer === 'smtp') { $smtp = $phpmailer->getSMTPInstance(); $code = (string) ($smtp->getError()['smtp_code'] ?? ''); if ($smtp instanceof Smtp) { $empfaenger = $smtp->auswertung(); } // PHPMailer laesst die Verbindung nach einem Fehler offen und nimmt sie fuer die // naechste Mail wieder. Nach 4xx oder Zeitueberschreitung ist sie unbrauchbar. $phpmailer->smtpClose(); } if (!self::$innen) { return; } $text = $fehler->get_error_message(); if ($code === '' && preg_match('/\b([45]\d\d)[ -]/', $text, $m)) { $code = $m[1]; } $verbindung = $code === '' && $empfaenger === null && self::istVerbindungsfehler($text); if ($verbindung) { set_transient(self::STOERUNG, time(), self::STOERUNG_S); } $dauerhaftGesamt = $empfaenger !== null ? (!$empfaenger['zugestellt'] && !$empfaenger['vorueber']) : self::dauerhaft($code, $text); self::$letzterFehler = ['text' => $text, 'code' => $code, 'dauerhaft' => $dauerhaftGesamt, 'verbindung' => $verbindung, 'empfaenger' => $empfaenger]; } private static function istVerbindungsfehler(string $text): bool { return (bool) preg_match('/connect|timed out|timeout|Verbindung|getaddrinfo|network/i', $text); } /** * Dauerhaft ist nur, was send-mails.de mit 5xx ablehnt. Ausnahme 535 (Anmeldung * falsch): Das behebt der Admin, solange warten die Mails in der Warteschlange. * Verbindungsfehler und Zeitueberschreitungen gelten als voruebergehend. */ public static function dauerhaft(string $code, string $text): bool { if ($code !== '') { return $code[0] === '5' && $code !== '535'; } if (self::istVerbindungsfehler($text)) { return false; } // Anmeldefehler meldet PHPMailer ohne SMTP-Code (Text je nach Sprache "authenticate"/"Authentifizierung"). if (preg_match('/authenti/i', $text)) { return false; } // Ungueltige Adressen, fehlende Empfaenger u. ae. erkennt PHPMailer vor dem Versand. return true; } }