mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
smtp: typed probe errors with per-field UI highlighting on save
This commit is contained in:
@@ -1,13 +1,30 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SMTP Relay — credentials stored in the DB, sending via PHP's built-in mail
|
||||
* wrappers (SMTP transport layer is wired later).
|
||||
* Structured exception for SMTP probe failures.
|
||||
* Carries a `field` hint so the UI can highlight the relevant input.
|
||||
*
|
||||
* field values: 'smtp_host', 'smtp_port', 'smtp_encryption',
|
||||
* 'smtp_username', 'smtp_password', or null (unknown)
|
||||
*/
|
||||
class SmtpProbeException extends \RuntimeException {
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?string $field = null
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP Relay — credentials stored in the DB, sending via a native PHP socket
|
||||
* client (no external dependencies).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. CRUD on the singleton smtp_settings row.
|
||||
* 2. Build MIME messages.
|
||||
* 3. Send via `mail()` now; swap transport later (e.g. PHPMailer / Symfony Mailer).
|
||||
* 3. Send via native SMTP socket (STARTTLS / SSL / plain).
|
||||
* 4. Probe credentials without sending any message (for validation on save).
|
||||
*/
|
||||
class SmtpRelay {
|
||||
|
||||
@@ -41,16 +58,13 @@ class SmtpRelay {
|
||||
/**
|
||||
* Upsert SMTP settings.
|
||||
*
|
||||
* @param array $data Associative array with keys: host, port, encryption,
|
||||
* username, password, from_email, from_name.
|
||||
* Keys not present are left unchanged.
|
||||
* @param array $data Keys: host, port, encryption, username, password,
|
||||
* from_email, from_name. Missing keys are left unchanged.
|
||||
*/
|
||||
public static function updateSettings(Database $db, array $data): void {
|
||||
// Read existing so we can merge partial updates
|
||||
$current = self::getSettings($db);
|
||||
$merged = array_merge($current, $data);
|
||||
|
||||
// Sanitize
|
||||
$port = max(1, min(65535, (int)$merged['port']));
|
||||
$encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true)
|
||||
? $merged['encryption'] : 'tls';
|
||||
@@ -73,7 +87,7 @@ class SmtpRelay {
|
||||
':port' => $port,
|
||||
':encryption' => $encryption,
|
||||
':username' => trim($merged['username']),
|
||||
':password' => $merged['password'], // keep as-is
|
||||
':password' => $merged['password'],
|
||||
':from_email' => trim($merged['from_email']),
|
||||
':from_name' => trim($merged['from_name']),
|
||||
]);
|
||||
@@ -88,16 +102,220 @@ class SmtpRelay {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send helpers (transport wired later — stub implementation now)
|
||||
// Credential probe (validates without sending any message)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Test SMTP credentials without sending any message.
|
||||
*
|
||||
* Connects, does EHLO + optional STARTTLS, authenticates, then QUITs.
|
||||
* No MAIL FROM / RCPT TO / DATA — nothing lands in any mailbox.
|
||||
*
|
||||
* Returns ['ok' => bool, 'error' => string, 'field' => ?string].
|
||||
* `field` is the HTML input id to highlight on failure:
|
||||
* 'smtp_host' | 'smtp_port' | 'smtp_encryption' | 'smtp_username' | 'smtp_password' | null
|
||||
*
|
||||
* @return array{ok:bool, error:string, field:?string}
|
||||
*/
|
||||
public static function test(Database $db): array
|
||||
{
|
||||
$s = self::getSettings($db);
|
||||
if ($s['host'] === '') {
|
||||
return ['ok' => false, 'error' => 'Hôte SMTP non configuré.', 'field' => 'smtp_host'];
|
||||
}
|
||||
|
||||
try {
|
||||
self::smtpProbe($s);
|
||||
return ['ok' => true, 'error' => '', 'field' => null];
|
||||
} catch (SmtpProbeException $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage(), 'field' => $e->field];
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage(), 'field' => null];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level SMTP probe: connect → EHLO → STARTTLS → AUTH → QUIT.
|
||||
* Throws SmtpProbeException with a `field` hint on every failure point.
|
||||
*/
|
||||
private static function smtpProbe(array $s): void
|
||||
{
|
||||
$host = $s['host'];
|
||||
$port = (int) $s['port'];
|
||||
$encryption = $s['encryption'];
|
||||
$timeout = 10;
|
||||
|
||||
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
|
||||
|
||||
$errno = 0; $errstr = '';
|
||||
$ctx = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true,
|
||||
],
|
||||
]);
|
||||
$sock = @stream_socket_client(
|
||||
"{$connectHost}:{$port}", $errno, $errstr, $timeout,
|
||||
STREAM_CLIENT_CONNECT, $ctx
|
||||
);
|
||||
if ($sock === false) {
|
||||
$isNameFail = (
|
||||
stripos($errstr, 'name or service') !== false ||
|
||||
stripos($errstr, 'resolve') !== false ||
|
||||
stripos($errstr, 'getaddrinfo') !== false ||
|
||||
stripos($errstr, 'nodename') !== false
|
||||
);
|
||||
$isRefused = ($errno === 111 || stripos($errstr, 'refused') !== false);
|
||||
$isTimeout = ($errno === 110 || stripos($errstr, 'timed') !== false || $errstr === '');
|
||||
|
||||
if ($isNameFail) {
|
||||
throw new SmtpProbeException(
|
||||
"Hôte introuvable « {$host} » — vérifiez l'adresse du serveur SMTP.",
|
||||
'smtp_host'
|
||||
);
|
||||
}
|
||||
if ($isRefused) {
|
||||
throw new SmtpProbeException(
|
||||
"Connexion refusée sur le port {$port} — vérifiez le port et le mode de chiffrement.",
|
||||
'smtp_port'
|
||||
);
|
||||
}
|
||||
throw new SmtpProbeException(
|
||||
$isTimeout
|
||||
? "Délai dépassé en tentant de joindre {$host}:{$port} — hôte ou port incorrect ?"
|
||||
: "Connexion impossible à {$host}:{$port} — {$errstr} [{$errno}]",
|
||||
$isTimeout ? 'smtp_host' : null
|
||||
);
|
||||
}
|
||||
stream_set_timeout($sock, $timeout);
|
||||
|
||||
$read = function () use ($sock): string {
|
||||
$buf = '';
|
||||
while (($line = fgets($sock, 512)) !== false) {
|
||||
$buf .= $line;
|
||||
if (isset($line[3]) && $line[3] === ' ') break;
|
||||
$meta = stream_get_meta_data($sock);
|
||||
if ($meta['timed_out']) break;
|
||||
}
|
||||
return $buf;
|
||||
};
|
||||
$send = function (string $cmd) use ($sock, $read): string {
|
||||
fwrite($sock, $cmd . "\r\n");
|
||||
return $read();
|
||||
};
|
||||
|
||||
// Greeting
|
||||
$greeting = $read();
|
||||
if (strncmp($greeting, '220', 3) !== 0) {
|
||||
$meta = stream_get_meta_data($sock);
|
||||
if ($meta['timed_out']) {
|
||||
throw new SmtpProbeException(
|
||||
"Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?",
|
||||
'smtp_port'
|
||||
);
|
||||
}
|
||||
throw new SmtpProbeException(
|
||||
"Réponse inattendue du serveur : " . json_encode(trim($greeting)),
|
||||
'smtp_host'
|
||||
);
|
||||
}
|
||||
|
||||
$parseEhlo = function (string $resp): array {
|
||||
$caps = [];
|
||||
foreach (explode("\n", $resp) as $line) {
|
||||
$line = rtrim($line);
|
||||
if (strlen($line) > 4) $caps[] = strtoupper(substr($line, 4));
|
||||
}
|
||||
return $caps;
|
||||
};
|
||||
|
||||
$ehloResp = $send('EHLO ' . gethostname());
|
||||
$caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : [];
|
||||
if (empty($caps)) {
|
||||
$send('HELO ' . gethostname());
|
||||
}
|
||||
|
||||
// STARTTLS upgrade
|
||||
if ($encryption === 'tls') {
|
||||
$stResp = $send('STARTTLS');
|
||||
if (strncmp($stResp, '220', 3) !== 0) {
|
||||
throw new SmtpProbeException(
|
||||
"Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou \"Aucun\".",
|
||||
'smtp_encryption'
|
||||
);
|
||||
}
|
||||
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new SmtpProbeException(
|
||||
"Échec de la négociation TLS — essayez SSL (port 465) ou vérifiez le port.",
|
||||
'smtp_encryption'
|
||||
);
|
||||
}
|
||||
$ehloResp = $send('EHLO ' . gethostname());
|
||||
$caps = $parseEhlo($ehloResp);
|
||||
}
|
||||
|
||||
// AUTH
|
||||
if ($s['username'] !== '') {
|
||||
$authLine = '';
|
||||
foreach ($caps as $cap) {
|
||||
if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; }
|
||||
}
|
||||
$mechanisms = preg_split('/[\s=]+/', $authLine);
|
||||
|
||||
try {
|
||||
if (in_array('PLAIN', $mechanisms, true)) {
|
||||
$token = base64_encode("\0{$s['username']}\0{$s['password']}");
|
||||
$resp = $send("AUTH PLAIN {$token}");
|
||||
if (strncmp($resp, '235', 3) !== 0) {
|
||||
$code = substr(trim($resp), 0, 3);
|
||||
$field = ($code === '535') ? 'smtp_password' : 'smtp_username';
|
||||
throw new SmtpProbeException(
|
||||
"Authentification refusée : " . trim($resp),
|
||||
$field
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// AUTH LOGIN challenge/response
|
||||
$r1 = $send('AUTH LOGIN');
|
||||
if (strncmp($r1, '334', 3) !== 0) {
|
||||
throw new SmtpProbeException(
|
||||
"Le serveur n'accepte pas AUTH LOGIN : " . trim($r1),
|
||||
'smtp_username'
|
||||
);
|
||||
}
|
||||
$r2 = $send(base64_encode($s['username']));
|
||||
if (strncmp($r2, '334', 3) !== 0) {
|
||||
throw new SmtpProbeException(
|
||||
"Nom d'utilisateur refusé : " . trim($r2),
|
||||
'smtp_username'
|
||||
);
|
||||
}
|
||||
$r3 = $send(base64_encode($s['password']));
|
||||
if (strncmp($r3, '235', 3) !== 0) {
|
||||
throw new SmtpProbeException(
|
||||
"Mot de passe refusé : " . trim($r3),
|
||||
'smtp_password'
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (SmtpProbeException $e) {
|
||||
@fclose($sock);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$send('QUIT');
|
||||
fclose($sock);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send an e-mail using the stored SMTP credentials.
|
||||
*
|
||||
* Uses a native PHP socket SMTP client — no external dependencies.
|
||||
* Supports STARTTLS (port 587 / encryption=tls) and direct SSL (port 465 /
|
||||
* encryption=ssl). Falls back to plain if encryption=none.
|
||||
*
|
||||
* @param string $to Recipient e-mail address
|
||||
* @param string $subject Subject line
|
||||
* @param string $body HTML body
|
||||
@@ -117,7 +335,6 @@ class SmtpRelay {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build MIME message
|
||||
$boundary = 'xamxam_' . bin2hex(random_bytes(8));
|
||||
$date = date('r');
|
||||
$fromHdr = ($s['from_name'] ?? '') !== ''
|
||||
@@ -162,20 +379,15 @@ class SmtpRelay {
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level native SMTP socket client.
|
||||
*
|
||||
* @param array $s SMTP settings row
|
||||
* @param string $to Envelope recipient
|
||||
* @param string $rawMessage Full RFC 2822 message (headers + body)
|
||||
* Low-level native SMTP socket client (full send path).
|
||||
*/
|
||||
private static function smtpSend(array $s, string $to, string $rawMessage): bool
|
||||
{
|
||||
$host = $s['host'];
|
||||
$port = (int) $s['port'];
|
||||
$encryption = $s['encryption']; // 'tls' | 'ssl' | 'none'
|
||||
$encryption = $s['encryption'];
|
||||
$timeout = 15;
|
||||
|
||||
// For direct SSL (port 465) open with ssl:// wrapper
|
||||
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
|
||||
|
||||
$errno = 0; $errstr = '';
|
||||
@@ -199,7 +411,6 @@ class SmtpRelay {
|
||||
$buf = '';
|
||||
while (($line = fgets($sock, 512)) !== false) {
|
||||
$buf .= $line;
|
||||
// 4th char is ' ' when it's the last line of a multi-line reply
|
||||
if (isset($line[3]) && $line[3] === ' ') break;
|
||||
$meta = stream_get_meta_data($sock);
|
||||
if ($meta['timed_out']) break;
|
||||
@@ -218,167 +429,13 @@ class SmtpRelay {
|
||||
return $resp;
|
||||
};
|
||||
|
||||
// Greeting
|
||||
$greeting = $read();
|
||||
if (strncmp($greeting, '220', 3) !== 0) {
|
||||
$meta = stream_get_meta_data($sock);
|
||||
$detail = $meta['timed_out'] ? '(socket timed out — no data received)' : '(received: ' . json_encode($greeting) . ')';
|
||||
$detail = $meta['timed_out'] ? '(timed out)' : '(received: ' . json_encode($greeting) . ')';
|
||||
throw new \RuntimeException("SMTP bad greeting {$detail}");
|
||||
}
|
||||
|
||||
$parseEhlo = function (string $resp): array {
|
||||
// Returns list of capability tokens, e.g. ['STARTTLS','AUTH PLAIN LOGIN',...]
|
||||
$caps = [];
|
||||
foreach (explode("\n", $resp) as $line) {
|
||||
$line = rtrim($line);
|
||||
if (strlen($line) > 4) {
|
||||
$caps[] = strtoupper(substr($line, 4));
|
||||
}
|
||||
}
|
||||
return $caps;
|
||||
};
|
||||
|
||||
// EHLO
|
||||
$ehloResp = $send('EHLO ' . gethostname());
|
||||
$caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : [];
|
||||
if (empty($caps)) {
|
||||
$send('HELO ' . gethostname());
|
||||
}
|
||||
|
||||
// STARTTLS upgrade
|
||||
if ($encryption === 'tls') {
|
||||
$expect('STARTTLS', '220');
|
||||
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed (check server cert / CA bundle)');
|
||||
}
|
||||
// Re-EHLO after TLS — refresh capabilities
|
||||
$ehloResp = $send('EHLO ' . gethostname());
|
||||
$caps = $parseEhlo($ehloResp);
|
||||
}
|
||||
|
||||
// AUTH — pick mechanism from server capabilities
|
||||
if ($s['username'] !== '') {
|
||||
// Find the AUTH line, e.g. "AUTH PLAIN LOGIN" or "AUTH=PLAIN LOGIN"
|
||||
$authLine = '';
|
||||
foreach ($caps as $cap) {
|
||||
if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; }
|
||||
}
|
||||
$mechanisms = preg_split('/[\s=]+/', $authLine);
|
||||
// Prefer PLAIN, fall back to LOGIN
|
||||
if (in_array('PLAIN', $mechanisms, true)) {
|
||||
// AUTH PLAIN: single base64(\0user\0pass)
|
||||
$token = base64_encode("\0{$s['username']}\0{$s['password']}");
|
||||
$expect("AUTH PLAIN {$token}", '235');
|
||||
} else {
|
||||
// AUTH LOGIN: challenge/response
|
||||
$expect('AUTH LOGIN', '334');
|
||||
$expect(base64_encode($s['username']), '334');
|
||||
$expect(base64_encode($s['password']), '235');
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope
|
||||
$expect("MAIL FROM:<{$s['from_email']}>", '250');
|
||||
$expect("RCPT TO:<{$to}>", '250');
|
||||
$expect('DATA', '354');
|
||||
|
||||
// Message — dot-stuff lines starting with '.'
|
||||
$stuffed = preg_replace('/^\./m', '..', $rawMessage);
|
||||
$resp = $send($stuffed . "\r\n.");
|
||||
if (strncmp($resp, '250', 3) !== 0) {
|
||||
throw new \RuntimeException("SMTP DATA rejected: {$resp}");
|
||||
}
|
||||
|
||||
$send('QUIT');
|
||||
fclose($sock);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the stored SMTP credentials without sending any message.
|
||||
*
|
||||
* Opens a socket, performs EHLO, upgrades to TLS if configured, authenticates,
|
||||
* then immediately sends QUIT. No MAIL FROM / RCPT TO / DATA is issued,
|
||||
* so nothing lands in any mailbox.
|
||||
*
|
||||
* @return array{ok:bool, error:string}
|
||||
*/
|
||||
public static function test(Database $db): array
|
||||
{
|
||||
$s = self::getSettings($db);
|
||||
if ($s['host'] === '') {
|
||||
return ['ok' => false, 'error' => 'Hôte SMTP non configuré.'];
|
||||
}
|
||||
|
||||
try {
|
||||
self::smtpProbe($s);
|
||||
return ['ok' => true, 'error' => ''];
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like smtpSend() but stops after AUTH — no envelope, no message.
|
||||
*/
|
||||
private static function smtpProbe(array $s): void
|
||||
{
|
||||
$host = $s['host'];
|
||||
$port = (int) $s['port'];
|
||||
$encryption = $s['encryption'];
|
||||
$timeout = 10;
|
||||
|
||||
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
|
||||
|
||||
$errno = 0; $errstr = '';
|
||||
$ctx = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true,
|
||||
],
|
||||
]);
|
||||
$sock = @stream_socket_client(
|
||||
"{$connectHost}:{$port}", $errno, $errstr, $timeout,
|
||||
STREAM_CLIENT_CONNECT, $ctx
|
||||
);
|
||||
if ($sock === false) {
|
||||
throw new \RuntimeException("Connexion impossible à {$host}:{$port} — {$errstr} [{$errno}]");
|
||||
}
|
||||
stream_set_timeout($sock, $timeout);
|
||||
|
||||
$read = function () use ($sock, $timeout): string {
|
||||
$buf = '';
|
||||
while (($line = fgets($sock, 512)) !== false) {
|
||||
$buf .= $line;
|
||||
if (isset($line[3]) && $line[3] === ' ') break;
|
||||
$meta = stream_get_meta_data($sock);
|
||||
if ($meta['timed_out']) break;
|
||||
}
|
||||
return $buf;
|
||||
};
|
||||
$send = function (string $cmd) use ($sock, $read): string {
|
||||
fwrite($sock, $cmd . "\r\n");
|
||||
return $read();
|
||||
};
|
||||
$expect = function (string $cmd, string $code) use ($send): string {
|
||||
$resp = $send($cmd);
|
||||
if (strncmp($resp, $code, strlen($code)) !== 0) {
|
||||
throw new \RuntimeException("Réponse SMTP inattendue pour '{$cmd}' : " . trim($resp));
|
||||
}
|
||||
return $resp;
|
||||
};
|
||||
|
||||
// Greeting
|
||||
$greeting = $read();
|
||||
if (strncmp($greeting, '220', 3) !== 0) {
|
||||
$meta = stream_get_meta_data($sock);
|
||||
$detail = $meta['timed_out']
|
||||
? '(délai dépassé — aucune donnée reçue)'
|
||||
: '(reçu : ' . json_encode(trim($greeting)) . ')';
|
||||
throw new \RuntimeException("Salutation SMTP invalide {$detail}");
|
||||
}
|
||||
|
||||
$parseEhlo = function (string $resp): array {
|
||||
$caps = [];
|
||||
foreach (explode("\n", $resp) as $line) {
|
||||
@@ -397,7 +454,7 @@ class SmtpRelay {
|
||||
if ($encryption === 'tls') {
|
||||
$expect('STARTTLS', '220');
|
||||
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new \RuntimeException('Échec de la négociation TLS (vérifiez le certificat du serveur)');
|
||||
throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed');
|
||||
}
|
||||
$ehloResp = $send('EHLO ' . gethostname());
|
||||
$caps = $parseEhlo($ehloResp);
|
||||
@@ -419,13 +476,27 @@ class SmtpRelay {
|
||||
}
|
||||
}
|
||||
|
||||
$expect("MAIL FROM:<{$s['from_email']}>", '250');
|
||||
$expect("RCPT TO:<{$to}>", '250');
|
||||
$expect('DATA', '354');
|
||||
|
||||
$stuffed = preg_replace('/^\./m', '..', $rawMessage);
|
||||
$resp = $send($stuffed . "\r\n.");
|
||||
if (strncmp($resp, '250', 3) !== 0) {
|
||||
throw new \RuntimeException("SMTP DATA rejected: {$resp}");
|
||||
}
|
||||
|
||||
$send('QUIT');
|
||||
fclose($sock);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Queue stub
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Queue (persist) an e-mail for deferred sending.
|
||||
*
|
||||
* Stub — will create a `mail_queue` table in a future migration.
|
||||
*/
|
||||
public static function queue(
|
||||
@@ -436,19 +507,14 @@ class SmtpRelay {
|
||||
string $plain = ''
|
||||
): void {
|
||||
// TODO: INSERT INTO mail_queue …
|
||||
// Placeholder so callers exist now and wire up later.
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Strip HTML tags to produce a rough plain-text fallback.
|
||||
*/
|
||||
private static function htmlToPlain(string $html): string {
|
||||
$text = strip_tags($html);
|
||||
// Collapse multiple whitespace lines
|
||||
$text = preg_replace('/\n{3,}/', "\n\n", $text);
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user