getPDO()->query( "SELECT host, port, encryption, username, password, from_email, from_name FROM v_smtp_active LIMIT 1" ); $row = $stmt->fetch(); return $row ?: [ 'host' => '', 'port' => 587, 'encryption' => 'tls', 'username' => '', 'password' => '', 'from_email' => '', 'from_name' => 'Post-ERG', ]; } /** * 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. */ 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'; $stmt = $db->getPDO()->prepare( "UPDATE smtp_settings SET host = :host, port = :port, encryption = :encryption, username = :username, password = :password, from_email = :from_email, from_name = :from_name, updated_at = CURRENT_TIMESTAMP WHERE id = 1" ); $stmt->execute([ ':host' => trim($merged['host']), ':port' => $port, ':encryption' => $encryption, ':username' => trim($merged['username']), ':password' => $merged['password'], // keep as-is ':from_email' => trim($merged['from_email']), ':from_name' => trim($merged['from_name']), ]); } /** * Check whether the SMTP relay is fully configured. */ public static function isConfigured(Database $db): bool { $s = self::getSettings($db); return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== ''; } // ----------------------------------------------------------------------- // Send helpers (transport wired later — stub implementation now) // ----------------------------------------------------------------------- /** * 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 * @param string $plain Plain-text alternative (optional) * @return bool True on successful delivery acceptance; false on failure */ public static function send( Database $db, string $to, string $subject, string $body, string $plain = '' ): bool { $s = self::getSettings($db); if ($s['from_email'] === '') { error_log('[SmtpRelay] send() aborted — no from_email configured'); return false; } // Build MIME message $boundary = 'posterg_' . bin2hex(random_bytes(8)); $date = date('r'); $fromHdr = $s['from_name'] !== '' ? "=?UTF-8?B?" . base64_encode($s['from_name']) . "?= <{$s['from_email']}>" : $s['from_email']; $subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?='; $hasPlain = ($plain !== ''); if ($hasPlain) { $msgBody = "--{$boundary}\r\n"; $msgBody .= "Content-Type: text/plain; charset=UTF-8\r\n"; $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n"; $msgBody .= chunk_split(base64_encode($plain)) . "\r\n"; $msgBody .= "--{$boundary}\r\n"; $msgBody .= "Content-Type: text/html; charset=UTF-8\r\n"; $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n"; $msgBody .= chunk_split(base64_encode($body)) . "\r\n"; $msgBody .= "--{$boundary}--"; $ctHdr = "multipart/alternative; boundary=\"{$boundary}\""; } else { $msgBody = chunk_split(base64_encode($body)); $ctHdr = 'text/html; charset=UTF-8'; } $rawMessage = "Date: {$date}\r\n" . "From: {$fromHdr}\r\n" . "To: {$to}\r\n" . "Subject: {$subjectHdr}\r\n" . "MIME-Version: 1.0\r\n" . "Content-Type: {$ctHdr}\r\n" . (!$hasPlain ? "Content-Transfer-Encoding: base64\r\n" : '') . "\r\n" . $msgBody; try { return self::smtpSend($s, $to, $rawMessage); } catch (\Throwable $e) { error_log('[SmtpRelay] ' . $e->getMessage()); return false; } } /** * 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) */ private static function smtpSend(array $s, string $to, string $rawMessage): bool { $host = $s['host']; $port = (int) $s['port']; $encryption = $s['encryption']; // 'tls' | 'ssl' | 'none' $timeout = 15; // For direct SSL (port 465) open with ssl:// wrapper $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; $errno = 0; $errstr = ''; $sock = @stream_socket_client( "{$connectHost}:{$port}", $errno, $errstr, $timeout ); if ($sock === false) { throw new \RuntimeException("SMTP connect failed ({$connectHost}:{$port}): {$errstr} [{$errno}]"); } stream_set_timeout($sock, $timeout); $read = function () use ($sock): string { $buf = ''; while ($line = fgets($sock, 512)) { $buf .= $line; // 4th char is ' ' when it's the last line of a multi-line reply if (isset($line[3]) && $line[3] === ' ') 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("SMTP unexpected response to '{$cmd}': {$resp}"); } return $resp; }; // Greeting $greeting = $read(); if (strncmp($greeting, '220', 3) !== 0) { throw new \RuntimeException("SMTP bad greeting: {$greeting}"); } $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'); } // 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; } /** * Queue (persist) an e-mail for deferred sending. * * Stub — will create a `mail_queue` table in a future migration. */ public static function queue( Database $db, string $to, string $subject, string $body, 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); } }