v1.0.0.1
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/site-front.php';
-
- final class Mailer
- {
- public static function send(string $to, string $subject, string $html, ?string $replyTo = null): bool
- {
- $to = trim($to);
- if ($to === '' || filter_var($to, FILTER_VALIDATE_EMAIL) === false) {
- error_log('StocketBase: mail send failed: invalid recipient address.');
-
- return false;
- }
-
- $settings = SiteFront::settings();
- if ($replyTo !== null && filter_var($replyTo, FILTER_VALIDATE_EMAIL) !== false) {
- $settings['mail_reply_to'] = $replyTo;
- }
- $transport = (string) ($settings['mail_transport'] ?? 'smtp');
-
- try {
- return match ($transport) {
- 'resend' => self::sendResend($settings, $to, $subject, $html),
- 'postmark' => self::sendPostmark($settings, $to, $subject, $html),
- default => self::sendSmtp($settings, $to, $subject, $html),
- };
- } catch (Throwable $e) {
- error_log('StocketBase: mail send failed: ' . $e->getMessage());
-
- return false;
- }
- }
-
- private static function fromHeaderParts(array $settings): array
- {
- $fromEmail = trim((string) ($settings['mail_from_email'] ?? ''));
- $fromName = trim((string) ($settings['mail_from_name'] ?? ''));
-
- if ($fromEmail === '' || filter_var($fromEmail, FILTER_VALIDATE_EMAIL) === false) {
- $fromEmail = 'no-reply@' . self::guessHost();
- }
-
- return [$fromEmail, $fromName];
- }
-
- private static function guessHost(): string
- {
- $url = (string) Config::get('APP_URL', '');
- $host = (string) parse_url($url, PHP_URL_HOST);
-
- return $host !== '' ? $host : 'localhost';
- }
-
- private static function sendSmtp(array $settings, string $to, string $subject, string $html): bool
- {
- $host = trim((string) ($settings['mail_smtp_host'] ?? ''));
- $port = (int) ($settings['mail_smtp_port'] ?? 587);
- $username = trim((string) ($settings['mail_smtp_username'] ?? ''));
- $password = (string) ($settings['mail_smtp_password'] ?? '');
- $encryption = (string) ($settings['mail_smtp_encryption'] ?? 'tls');
-
- if ($host === '') {
- error_log('StocketBase: mail send failed: SMTP host is not configured.');
-
- return false;
- }
-
- $remote = ($encryption === 'ssl' ? 'ssl://' : 'tcp://') . $host . ':' . $port;
- $socket = @stream_socket_client($remote, $errno, $errstr, 15, STREAM_CLIENT_CONNECT);
-
- if ($socket === false) {
- error_log('StocketBase: mail send failed: could not connect to SMTP host — ' . $errstr);
-
- return false;
- }
-
- stream_set_timeout($socket, 15);
-
- $read = static 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;
- };
-
- $write = static function (string $command) use ($socket): void {
- fwrite($socket, $command . "\r\n");
- };
-
- $expectCode = static function (string $response, array $codes): bool {
- return in_array((int) substr($response, 0, 3), $codes, true);
- };
-
- $localHost = self::guessHost();
-
- if (!$expectCode($read(), [220])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: SMTP server did not greet us properly.');
-
- return false;
- }
-
- $write('EHLO ' . $localHost);
- if (!$expectCode($read(), [250])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: EHLO rejected by SMTP server.');
-
- return false;
- }
-
- if ($encryption === 'tls') {
- $write('STARTTLS');
- if (!$expectCode($read(), [220])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: STARTTLS rejected by SMTP server.');
-
- return false;
- }
-
- if (!@stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
- fclose($socket);
- error_log('StocketBase: mail send failed: could not negotiate TLS with SMTP server.');
-
- return false;
- }
-
- $write('EHLO ' . $localHost);
- if (!$expectCode($read(), [250])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: EHLO after STARTTLS rejected by SMTP server.');
-
- return false;
- }
- }
-
- if ($username !== '') {
- $write('AUTH LOGIN');
- if (!$expectCode($read(), [334])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: AUTH LOGIN not accepted by SMTP server.');
-
- return false;
- }
-
- $write(base64_encode($username));
- if (!$expectCode($read(), [334])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: SMTP username rejected.');
-
- return false;
- }
-
- $write(base64_encode($password));
- if (!$expectCode($read(), [235])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: SMTP authentication failed.');
-
- return false;
- }
- }
-
- [$fromEmail, $fromName] = self::fromHeaderParts($settings);
- $replyTo = trim((string) ($settings['mail_reply_to'] ?? ''));
-
- $write('MAIL FROM:<' . $fromEmail . '>');
- if (!$expectCode($read(), [250])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: MAIL FROM rejected by SMTP server.');
-
- return false;
- }
-
- $write('RCPT TO:<' . $to . '>');
- if (!$expectCode($read(), [250, 251])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: RCPT TO rejected by SMTP server.');
-
- return false;
- }
-
- $write('DATA');
- if (!$expectCode($read(), [354])) {
- fclose($socket);
- error_log('StocketBase: mail send failed: DATA command rejected by SMTP server.');
-
- return false;
- }
-
- $boundary = 'sb_' . bin2hex(random_bytes(12));
- $headers = self::buildHeaders($fromEmail, $fromName, $to, $subject, $replyTo, $boundary);
- $payload = $headers . "\r\n\r\n" . self::dotStuff(self::multipartBody($boundary, $html)) . "\r\n.";
- fwrite($socket, $payload . "\r\n");
- $sendResponse = $read();
-
- $write('QUIT');
- fclose($socket);
-
- if (!$expectCode($sendResponse, [250])) {
- error_log('StocketBase: mail send failed: message rejected by SMTP server.');
-
- return false;
- }
-
- return true;
- }
-
- private static function dotStuff(string $body): string
- {
- $lines = preg_split("/\r\n|\r|\n/", $body);
- if ($lines === false) {
- return $body;
- }
-
- foreach ($lines as $index => $line) {
- if (isset($line[0]) && $line[0] === '.') {
- $lines[$index] = '.' . $line;
- }
- }
-
- return implode("\r\n", $lines);
- }
-
- private static function multipartBody(string $boundary, string $html): string
- {
- $text = (string) preg_replace('#<(br|/p|/tr|/h1|/h2|/h3|/div|/li)\b[^>]*>#i', "\n", $html);
- $text = (string) preg_replace('#</td>\s*<td[^>]*>#i', ': ', $text);
- $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
- $text = trim((string) preg_replace("/\n\s*\n\s*\n+/", "\n\n", (string) preg_replace("/[ \t]+/", ' ', $text))) . "\n";
-
- return '--' . $boundary . "\r\n"
- . "Content-Type: text/plain; charset=UTF-8\r\n"
- . "Content-Transfer-Encoding: base64\r\n\r\n"
- . chunk_split(base64_encode($text), 76, "\r\n")
- . '--' . $boundary . "\r\n"
- . "Content-Type: text/html; charset=UTF-8\r\n"
- . "Content-Transfer-Encoding: base64\r\n\r\n"
- . chunk_split(base64_encode($html), 76, "\r\n")
- . '--' . $boundary . '--';
- }
-
- private static function buildHeaders(string $fromEmail, string $fromName, string $to, string $subject, string $replyTo, string $boundary): string
- {
- $encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
- $fromHeader = $fromName !== '' ? '=?UTF-8?B?' . base64_encode($fromName) . '?= <' . $fromEmail . '>' : $fromEmail;
-
- $headers = [
- 'From: ' . $fromHeader,
- 'To: <' . $to . '>',
- 'Subject: ' . $encodedSubject,
- 'MIME-Version: 1.0',
- 'Content-Type: multipart/alternative; boundary="' . $boundary . '"',
- 'Date: ' . date('r'),
- 'Message-ID: <' . bin2hex(random_bytes(16)) . '@' . self::guessHost() . '>',
- ];
-
- if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL) !== false) {
- $headers[] = 'Reply-To: ' . $replyTo;
- }
-
- return implode("\r\n", $headers);
- }
-
- private static function sendResend(array $settings, string $to, string $subject, string $html): bool
- {
- $apiKey = trim((string) ($settings['mail_resend_api_key'] ?? ''));
- if ($apiKey === '') {
- error_log('StocketBase: mail send failed: Resend API key is not configured.');
-
- return false;
- }
-
- [$fromEmail, $fromName] = self::fromHeaderParts($settings);
- $replyTo = trim((string) ($settings['mail_reply_to'] ?? ''));
-
- $payload = [
- 'from' => $fromName !== '' ? $fromName . ' <' . $fromEmail . '>' : $fromEmail,
- 'to' => [$to],
- 'subject' => $subject,
- 'html' => $html,
- ];
- if ($replyTo !== '') {
- $payload['reply_to'] = $replyTo;
- }
-
- [$status, $body] = self::httpPostJson(
- 'https://api.resend.com/emails',
- ['Authorization: Bearer ' . $apiKey],
- $payload
- );
-
- if ($status < 200 || $status >= 300) {
- error_log('StocketBase: mail send failed: Resend responded with HTTP ' . $status . ' — ' . (string) json_encode($body));
-
- return false;
- }
-
- return true;
- }
-
- private static function sendPostmark(array $settings, string $to, string $subject, string $html): bool
- {
- $token = trim((string) ($settings['mail_postmark_server_token'] ?? ''));
- if ($token === '') {
- error_log('StocketBase: mail send failed: Postmark server token is not configured.');
-
- return false;
- }
-
- [$fromEmail, $fromName] = self::fromHeaderParts($settings);
- $replyTo = trim((string) ($settings['mail_reply_to'] ?? ''));
-
- $payload = [
- 'From' => $fromName !== '' ? $fromName . ' <' . $fromEmail . '>' : $fromEmail,
- 'To' => $to,
- 'Subject' => $subject,
- 'HtmlBody' => $html,
- 'MessageStream' => 'outbound',
- ];
- if ($replyTo !== '') {
- $payload['ReplyTo'] = $replyTo;
- }
-
- [$status, $body] = self::httpPostJson(
- 'https://api.postmarkapp.com/email',
- ['X-Postmark-Server-Token: ' . $token, 'Accept: application/json'],
- $payload
- );
-
- if ($status < 200 || $status >= 300) {
- error_log('StocketBase: mail send failed: Postmark responded with HTTP ' . $status . ' — ' . (string) json_encode($body));
-
- return false;
- }
-
- return true;
- }
-
- private static function httpPostJson(string $url, array $headers, array $payload): array
- {
- $body = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- $headers[] = 'Content-Type: application/json';
-
- if (function_exists('curl_init')) {
- $curl = curl_init($url);
- curl_setopt_array($curl, [
- CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => $body,
- CURLOPT_HTTPHEADER => $headers,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_CONNECTTIMEOUT => 10,
- CURLOPT_TIMEOUT => 20,
- ]);
- $response = curl_exec($curl);
- $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
- curl_close($curl);
-
- $decoded = is_string($response) ? json_decode($response, true) : null;
-
- return [$status, is_array($decoded) ? $decoded : []];
- }
-
- $context = stream_context_create([
- 'http' => [
- 'method' => 'POST',
- 'header' => implode("\r\n", $headers),
- 'content' => $body,
- 'timeout' => 20,
- 'ignore_errors' => true,
- ],
- ]);
-
- $result = @file_get_contents($url, false, $context);
- $status = 0;
- foreach ($http_response_header ?? [] as $line) {
- if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
- $status = (int) $match[1];
- }
- }
-
- $decoded = $result !== false ? json_decode($result, true) : null;
-
- return [$status, is_array($decoded) ? $decoded : []];
- }
- }
-