227 lines
8.5 KiB
PHP
227 lines
8.5 KiB
PHP
<?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));
|
|
}
|
|
}
|