WebOrbiton
v1.0.0.1

StocketBase

397 lines · 13.4 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/site-front.php';
  6. ​
  7. final class Mailer
  8. {
  9. public static function send(string $to, string $subject, string $html, ?string $replyTo = null): bool
  10. {
  11. $to = trim($to);
  12. if ($to === '' || filter_var($to, FILTER_VALIDATE_EMAIL) === false) {
  13. error_log('StocketBase: mail send failed: invalid recipient address.');
  14. ​
  15. return false;
  16. }
  17. ​
  18. $settings = SiteFront::settings();
  19. if ($replyTo !== null && filter_var($replyTo, FILTER_VALIDATE_EMAIL) !== false) {
  20. $settings['mail_reply_to'] = $replyTo;
  21. }
  22. $transport = (string) ($settings['mail_transport'] ?? 'smtp');
  23. ​
  24. try {
  25. return match ($transport) {
  26. 'resend' => self::sendResend($settings, $to, $subject, $html),
  27. 'postmark' => self::sendPostmark($settings, $to, $subject, $html),
  28. default => self::sendSmtp($settings, $to, $subject, $html),
  29. };
  30. } catch (Throwable $e) {
  31. error_log('StocketBase: mail send failed: ' . $e->getMessage());
  32. ​
  33. return false;
  34. }
  35. }
  36. ​
  37. private static function fromHeaderParts(array $settings): array
  38. {
  39. $fromEmail = trim((string) ($settings['mail_from_email'] ?? ''));
  40. $fromName = trim((string) ($settings['mail_from_name'] ?? ''));
  41. ​
  42. if ($fromEmail === '' || filter_var($fromEmail, FILTER_VALIDATE_EMAIL) === false) {
  43. $fromEmail = 'no-reply@' . self::guessHost();
  44. }
  45. ​
  46. return [$fromEmail, $fromName];
  47. }
  48. ​
  49. private static function guessHost(): string
  50. {
  51. $url = (string) Config::get('APP_URL', '');
  52. $host = (string) parse_url($url, PHP_URL_HOST);
  53. ​
  54. return $host !== '' ? $host : 'localhost';
  55. }
  56. ​
  57. private static function sendSmtp(array $settings, string $to, string $subject, string $html): bool
  58. {
  59. $host = trim((string) ($settings['mail_smtp_host'] ?? ''));
  60. $port = (int) ($settings['mail_smtp_port'] ?? 587);
  61. $username = trim((string) ($settings['mail_smtp_username'] ?? ''));
  62. $password = (string) ($settings['mail_smtp_password'] ?? '');
  63. $encryption = (string) ($settings['mail_smtp_encryption'] ?? 'tls');
  64. ​
  65. if ($host === '') {
  66. error_log('StocketBase: mail send failed: SMTP host is not configured.');
  67. ​
  68. return false;
  69. }
  70. ​
  71. $remote = ($encryption === 'ssl' ? 'ssl://' : 'tcp://') . $host . ':' . $port;
  72. $socket = @stream_socket_client($remote, $errno, $errstr, 15, STREAM_CLIENT_CONNECT);
  73. ​
  74. if ($socket === false) {
  75. error_log('StocketBase: mail send failed: could not connect to SMTP host — ' . $errstr);
  76. ​
  77. return false;
  78. }
  79. ​
  80. stream_set_timeout($socket, 15);
  81. ​
  82. $read = static function () use ($socket): string {
  83. $data = '';
  84. while (!feof($socket)) {
  85. $line = fgets($socket, 515);
  86. if ($line === false) {
  87. break;
  88. }
  89. $data .= $line;
  90. if (isset($line[3]) && $line[3] === ' ') {
  91. break;
  92. }
  93. }
  94. ​
  95. return $data;
  96. };
  97. ​
  98. $write = static function (string $command) use ($socket): void {
  99. fwrite($socket, $command . "\r\n");
  100. };
  101. ​
  102. $expectCode = static function (string $response, array $codes): bool {
  103. return in_array((int) substr($response, 0, 3), $codes, true);
  104. };
  105. ​
  106. $localHost = self::guessHost();
  107. ​
  108. if (!$expectCode($read(), [220])) {
  109. fclose($socket);
  110. error_log('StocketBase: mail send failed: SMTP server did not greet us properly.');
  111. ​
  112. return false;
  113. }
  114. ​
  115. $write('EHLO ' . $localHost);
  116. if (!$expectCode($read(), [250])) {
  117. fclose($socket);
  118. error_log('StocketBase: mail send failed: EHLO rejected by SMTP server.');
  119. ​
  120. return false;
  121. }
  122. ​
  123. if ($encryption === 'tls') {
  124. $write('STARTTLS');
  125. if (!$expectCode($read(), [220])) {
  126. fclose($socket);
  127. error_log('StocketBase: mail send failed: STARTTLS rejected by SMTP server.');
  128. ​
  129. return false;
  130. }
  131. ​
  132. if (!@stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
  133. fclose($socket);
  134. error_log('StocketBase: mail send failed: could not negotiate TLS with SMTP server.');
  135. ​
  136. return false;
  137. }
  138. ​
  139. $write('EHLO ' . $localHost);
  140. if (!$expectCode($read(), [250])) {
  141. fclose($socket);
  142. error_log('StocketBase: mail send failed: EHLO after STARTTLS rejected by SMTP server.');
  143. ​
  144. return false;
  145. }
  146. }
  147. ​
  148. if ($username !== '') {
  149. $write('AUTH LOGIN');
  150. if (!$expectCode($read(), [334])) {
  151. fclose($socket);
  152. error_log('StocketBase: mail send failed: AUTH LOGIN not accepted by SMTP server.');
  153. ​
  154. return false;
  155. }
  156. ​
  157. $write(base64_encode($username));
  158. if (!$expectCode($read(), [334])) {
  159. fclose($socket);
  160. error_log('StocketBase: mail send failed: SMTP username rejected.');
  161. ​
  162. return false;
  163. }
  164. ​
  165. $write(base64_encode($password));
  166. if (!$expectCode($read(), [235])) {
  167. fclose($socket);
  168. error_log('StocketBase: mail send failed: SMTP authentication failed.');
  169. ​
  170. return false;
  171. }
  172. }
  173. ​
  174. [$fromEmail, $fromName] = self::fromHeaderParts($settings);
  175. $replyTo = trim((string) ($settings['mail_reply_to'] ?? ''));
  176. ​
  177. $write('MAIL FROM:<' . $fromEmail . '>');
  178. if (!$expectCode($read(), [250])) {
  179. fclose($socket);
  180. error_log('StocketBase: mail send failed: MAIL FROM rejected by SMTP server.');
  181. ​
  182. return false;
  183. }
  184. ​
  185. $write('RCPT TO:<' . $to . '>');
  186. if (!$expectCode($read(), [250, 251])) {
  187. fclose($socket);
  188. error_log('StocketBase: mail send failed: RCPT TO rejected by SMTP server.');
  189. ​
  190. return false;
  191. }
  192. ​
  193. $write('DATA');
  194. if (!$expectCode($read(), [354])) {
  195. fclose($socket);
  196. error_log('StocketBase: mail send failed: DATA command rejected by SMTP server.');
  197. ​
  198. return false;
  199. }
  200. ​
  201. $boundary = 'sb_' . bin2hex(random_bytes(12));
  202. $headers = self::buildHeaders($fromEmail, $fromName, $to, $subject, $replyTo, $boundary);
  203. $payload = $headers . "\r\n\r\n" . self::dotStuff(self::multipartBody($boundary, $html)) . "\r\n.";
  204. fwrite($socket, $payload . "\r\n");
  205. $sendResponse = $read();
  206. ​
  207. $write('QUIT');
  208. fclose($socket);
  209. ​
  210. if (!$expectCode($sendResponse, [250])) {
  211. error_log('StocketBase: mail send failed: message rejected by SMTP server.');
  212. ​
  213. return false;
  214. }
  215. ​
  216. return true;
  217. }
  218. ​
  219. private static function dotStuff(string $body): string
  220. {
  221. $lines = preg_split("/\r\n|\r|\n/", $body);
  222. if ($lines === false) {
  223. return $body;
  224. }
  225. ​
  226. foreach ($lines as $index => $line) {
  227. if (isset($line[0]) && $line[0] === '.') {
  228. $lines[$index] = '.' . $line;
  229. }
  230. }
  231. ​
  232. return implode("\r\n", $lines);
  233. }
  234. ​
  235. private static function multipartBody(string $boundary, string $html): string
  236. {
  237. $text = (string) preg_replace('#<(br|/p|/tr|/h1|/h2|/h3|/div|/li)\b[^>]*>#i', "\n", $html);
  238. $text = (string) preg_replace('#</td>\s*<td[^>]*>#i', ': ', $text);
  239. $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
  240. $text = trim((string) preg_replace("/\n\s*\n\s*\n+/", "\n\n", (string) preg_replace("/[ \t]+/", ' ', $text))) . "\n";
  241. ​
  242. return '--' . $boundary . "\r\n"
  243. . "Content-Type: text/plain; charset=UTF-8\r\n"
  244. . "Content-Transfer-Encoding: base64\r\n\r\n"
  245. . chunk_split(base64_encode($text), 76, "\r\n")
  246. . '--' . $boundary . "\r\n"
  247. . "Content-Type: text/html; charset=UTF-8\r\n"
  248. . "Content-Transfer-Encoding: base64\r\n\r\n"
  249. . chunk_split(base64_encode($html), 76, "\r\n")
  250. . '--' . $boundary . '--';
  251. }
  252. ​
  253. private static function buildHeaders(string $fromEmail, string $fromName, string $to, string $subject, string $replyTo, string $boundary): string
  254. {
  255. $encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
  256. $fromHeader = $fromName !== '' ? '=?UTF-8?B?' . base64_encode($fromName) . '?= <' . $fromEmail . '>' : $fromEmail;
  257. ​
  258. $headers = [
  259. 'From: ' . $fromHeader,
  260. 'To: <' . $to . '>',
  261. 'Subject: ' . $encodedSubject,
  262. 'MIME-Version: 1.0',
  263. 'Content-Type: multipart/alternative; boundary="' . $boundary . '"',
  264. 'Date: ' . date('r'),
  265. 'Message-ID: <' . bin2hex(random_bytes(16)) . '@' . self::guessHost() . '>',
  266. ];
  267. ​
  268. if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL) !== false) {
  269. $headers[] = 'Reply-To: ' . $replyTo;
  270. }
  271. ​
  272. return implode("\r\n", $headers);
  273. }
  274. ​
  275. private static function sendResend(array $settings, string $to, string $subject, string $html): bool
  276. {
  277. $apiKey = trim((string) ($settings['mail_resend_api_key'] ?? ''));
  278. if ($apiKey === '') {
  279. error_log('StocketBase: mail send failed: Resend API key is not configured.');
  280. ​
  281. return false;
  282. }
  283. ​
  284. [$fromEmail, $fromName] = self::fromHeaderParts($settings);
  285. $replyTo = trim((string) ($settings['mail_reply_to'] ?? ''));
  286. ​
  287. $payload = [
  288. 'from' => $fromName !== '' ? $fromName . ' <' . $fromEmail . '>' : $fromEmail,
  289. 'to' => [$to],
  290. 'subject' => $subject,
  291. 'html' => $html,
  292. ];
  293. if ($replyTo !== '') {
  294. $payload['reply_to'] = $replyTo;
  295. }
  296. ​
  297. [$status, $body] = self::httpPostJson(
  298. 'https://api.resend.com/emails',
  299. ['Authorization: Bearer ' . $apiKey],
  300. $payload
  301. );
  302. ​
  303. if ($status < 200 || $status >= 300) {
  304. error_log('StocketBase: mail send failed: Resend responded with HTTP ' . $status . ' — ' . (string) json_encode($body));
  305. ​
  306. return false;
  307. }
  308. ​
  309. return true;
  310. }
  311. ​
  312. private static function sendPostmark(array $settings, string $to, string $subject, string $html): bool
  313. {
  314. $token = trim((string) ($settings['mail_postmark_server_token'] ?? ''));
  315. if ($token === '') {
  316. error_log('StocketBase: mail send failed: Postmark server token is not configured.');
  317. ​
  318. return false;
  319. }
  320. ​
  321. [$fromEmail, $fromName] = self::fromHeaderParts($settings);
  322. $replyTo = trim((string) ($settings['mail_reply_to'] ?? ''));
  323. ​
  324. $payload = [
  325. 'From' => $fromName !== '' ? $fromName . ' <' . $fromEmail . '>' : $fromEmail,
  326. 'To' => $to,
  327. 'Subject' => $subject,
  328. 'HtmlBody' => $html,
  329. 'MessageStream' => 'outbound',
  330. ];
  331. if ($replyTo !== '') {
  332. $payload['ReplyTo'] = $replyTo;
  333. }
  334. ​
  335. [$status, $body] = self::httpPostJson(
  336. 'https://api.postmarkapp.com/email',
  337. ['X-Postmark-Server-Token: ' . $token, 'Accept: application/json'],
  338. $payload
  339. );
  340. ​
  341. if ($status < 200 || $status >= 300) {
  342. error_log('StocketBase: mail send failed: Postmark responded with HTTP ' . $status . ' — ' . (string) json_encode($body));
  343. ​
  344. return false;
  345. }
  346. ​
  347. return true;
  348. }
  349. ​
  350. private static function httpPostJson(string $url, array $headers, array $payload): array
  351. {
  352. $body = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  353. $headers[] = 'Content-Type: application/json';
  354. ​
  355. if (function_exists('curl_init')) {
  356. $curl = curl_init($url);
  357. curl_setopt_array($curl, [
  358. CURLOPT_POST => true,
  359. CURLOPT_POSTFIELDS => $body,
  360. CURLOPT_HTTPHEADER => $headers,
  361. CURLOPT_RETURNTRANSFER => true,
  362. CURLOPT_CONNECTTIMEOUT => 10,
  363. CURLOPT_TIMEOUT => 20,
  364. ]);
  365. $response = curl_exec($curl);
  366. $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
  367. curl_close($curl);
  368. ​
  369. $decoded = is_string($response) ? json_decode($response, true) : null;
  370. ​
  371. return [$status, is_array($decoded) ? $decoded : []];
  372. }
  373. ​
  374. $context = stream_context_create([
  375. 'http' => [
  376. 'method' => 'POST',
  377. 'header' => implode("\r\n", $headers),
  378. 'content' => $body,
  379. 'timeout' => 20,
  380. 'ignore_errors' => true,
  381. ],
  382. ]);
  383. ​
  384. $result = @file_get_contents($url, false, $context);
  385. $status = 0;
  386. foreach ($http_response_header ?? [] as $line) {
  387. if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
  388. $status = (int) $match[1];
  389. }
  390. }
  391. ​
  392. $decoded = $result !== false ? json_decode($result, true) : null;
  393. ​
  394. return [$status, is_array($decoded) ? $decoded : []];
  395. }
  396. }
  397. ​