Initial dev export (exclude uploads/runtime)
This commit is contained in:
226
core/services/Mailer.php
Normal file
226
core/services/Mailer.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Services;
|
||||
|
||||
class Mailer
|
||||
{
|
||||
public static function send(string $to, string $subject, string $html, array $settings): array
|
||||
{
|
||||
$host = (string)($settings['smtp_host'] ?? '');
|
||||
$port = (int)($settings['smtp_port'] ?? 587);
|
||||
$user = (string)($settings['smtp_user'] ?? '');
|
||||
$pass = (string)($settings['smtp_pass'] ?? '');
|
||||
$encryption = strtolower((string)($settings['smtp_encryption'] ?? 'tls'));
|
||||
$fromEmail = (string)($settings['smtp_from_email'] ?? '');
|
||||
$fromName = (string)($settings['smtp_from_name'] ?? '');
|
||||
|
||||
if ($fromEmail === '') {
|
||||
$fromEmail = $user !== '' ? $user : 'no-reply@localhost';
|
||||
}
|
||||
$fromHeader = $fromName !== '' ? "{$fromName} <{$fromEmail}>" : $fromEmail;
|
||||
|
||||
if ($host === '') {
|
||||
$headers = [
|
||||
'MIME-Version: 1.0',
|
||||
'Content-type: text/html; charset=utf-8',
|
||||
"From: {$fromHeader}",
|
||||
];
|
||||
$ok = mail($to, $subject, $html, implode("\r\n", $headers));
|
||||
return ['ok' => $ok, 'error' => $ok ? '' : 'mail() failed', 'debug' => 'transport=mail()'];
|
||||
}
|
||||
|
||||
$remote = $encryption === 'ssl' ? "ssl://{$host}:{$port}" : "{$host}:{$port}";
|
||||
$socket = stream_socket_client($remote, $errno, $errstr, 10);
|
||||
if (!$socket) {
|
||||
return ['ok' => false, 'error' => "SMTP connect failed: {$errstr}", 'debug' => "connect={$remote} errno={$errno} err={$errstr}"];
|
||||
}
|
||||
|
||||
$debug = [];
|
||||
|
||||
$read = function () use ($socket): string {
|
||||
$data = '';
|
||||
while (!feof($socket)) {
|
||||
$line = fgets($socket, 515);
|
||||
if ($line === false) {
|
||||
break;
|
||||
}
|
||||
$data .= $line;
|
||||
if (isset($line[3]) && $line[3] === ' ') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
};
|
||||
|
||||
$send = function (string $command) use ($socket, $read): string {
|
||||
fwrite($socket, $command . "\r\n");
|
||||
return $read();
|
||||
};
|
||||
|
||||
$resp = $read();
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (!self::isOkResponse($resp)) {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP greeting failed', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
|
||||
$resp = $send("EHLO localhost");
|
||||
$debug[] = 'C: EHLO localhost';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (!self::isOkResponse($resp)) {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP EHLO failed', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
$authCaps = self::parseAuthCapabilities($resp);
|
||||
|
||||
if ($encryption === 'tls') {
|
||||
$resp = $send("STARTTLS");
|
||||
$debug[] = 'C: STARTTLS';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (substr(trim($resp), 0, 3) !== '220') {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP STARTTLS failed', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP TLS negotiation failed', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
$resp = $send("EHLO localhost");
|
||||
$debug[] = 'C: EHLO localhost';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (!self::isOkResponse($resp)) {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP EHLO after TLS failed', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
$authCaps = self::parseAuthCapabilities($resp);
|
||||
}
|
||||
|
||||
if ($user !== '' && $pass !== '') {
|
||||
$authOk = false;
|
||||
$authErrors = [];
|
||||
|
||||
// Prefer advertised AUTH mechanisms when available.
|
||||
if (in_array('PLAIN', $authCaps, true)) {
|
||||
$authResp = $send("AUTH PLAIN " . base64_encode("\0{$user}\0{$pass}"));
|
||||
$debug[] = 'C: AUTH PLAIN [credentials]';
|
||||
$debug[] = 'S: ' . trim($authResp);
|
||||
if (substr(trim($authResp), 0, 3) === '235') {
|
||||
$authOk = true;
|
||||
} else {
|
||||
$authErrors[] = 'PLAIN rejected';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$authOk && (in_array('LOGIN', $authCaps, true) || !$authCaps)) {
|
||||
$resp = $send("AUTH LOGIN");
|
||||
$debug[] = 'C: AUTH LOGIN';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (substr(trim($resp), 0, 3) === '334') {
|
||||
$resp = $send(base64_encode($user));
|
||||
$debug[] = 'C: [username]';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (substr(trim($resp), 0, 3) === '334') {
|
||||
$resp = $send(base64_encode($pass));
|
||||
$debug[] = 'C: [password]';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (substr(trim($resp), 0, 3) === '235') {
|
||||
$authOk = true;
|
||||
} else {
|
||||
$authErrors[] = 'LOGIN password rejected';
|
||||
}
|
||||
} else {
|
||||
$authErrors[] = 'LOGIN username rejected';
|
||||
}
|
||||
} else {
|
||||
$authErrors[] = 'LOGIN command rejected';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$authOk) {
|
||||
fclose($socket);
|
||||
$err = $authErrors ? implode(', ', $authErrors) : 'No supported AUTH method';
|
||||
return ['ok' => false, 'error' => 'SMTP authentication failed: ' . $err, 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
}
|
||||
|
||||
$resp = $send("MAIL FROM:<{$fromEmail}>");
|
||||
$debug[] = "C: MAIL FROM:<{$fromEmail}>";
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (!self::isOkResponse($resp)) {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP MAIL FROM failed', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
$resp = $send("RCPT TO:<{$to}>");
|
||||
$debug[] = "C: RCPT TO:<{$to}>";
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (!self::isOkResponse($resp)) {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP RCPT TO failed', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
$resp = $send("DATA");
|
||||
$debug[] = 'C: DATA';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (substr(trim($resp), 0, 3) !== '354') {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP DATA failed', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
|
||||
$headers = [
|
||||
"From: {$fromHeader}",
|
||||
"To: {$to}",
|
||||
"Subject: {$subject}",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
];
|
||||
$message = implode("\r\n", $headers) . "\r\n\r\n" . $html . "\r\n.";
|
||||
fwrite($socket, $message . "\r\n");
|
||||
$resp = $read();
|
||||
$debug[] = 'C: [message body]';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
if (!self::isOkResponse($resp)) {
|
||||
fclose($socket);
|
||||
return ['ok' => false, 'error' => 'SMTP message rejected', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
$resp = $send("QUIT");
|
||||
$debug[] = 'C: QUIT';
|
||||
$debug[] = 'S: ' . trim($resp);
|
||||
fclose($socket);
|
||||
|
||||
return ['ok' => true, 'error' => '', 'debug' => implode("\n", $debug)];
|
||||
}
|
||||
|
||||
private static function isOkResponse(string $response): bool
|
||||
{
|
||||
$code = substr(trim($response), 0, 3);
|
||||
if ($code === '') {
|
||||
return false;
|
||||
}
|
||||
return $code[0] === '2' || $code[0] === '3';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private static function parseAuthCapabilities(string $ehloResponse): array
|
||||
{
|
||||
$caps = [];
|
||||
foreach (preg_split('/\r?\n/', $ehloResponse) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$line = preg_replace('/^\d{3}[ -]/', '', $line) ?? $line;
|
||||
if (stripos($line, 'AUTH ') === 0) {
|
||||
$parts = preg_split('/\s+/', substr($line, 5)) ?: [];
|
||||
foreach ($parts as $p) {
|
||||
$p = strtoupper(trim($p));
|
||||
if ($p !== '') {
|
||||
$caps[] = $p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($caps));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user