config = $config; } /** Connect, authenticate, send a single mail. Returns * ['ok' => bool, 'error' => string, 'message_id' => string, 'log' => string[]]. * * $options: * - html: optional HTML body. When set, the message goes * out as multipart/alternative with plain + html. * - inlineImages: array of ['cid' => 'logo', 'path' => '/abs/path', * 'mime' => 'image/svg+xml']. Wraps the alternative * in multipart/related so * resolves in the rendered HTML. */ public function send(string $to, string $fromName, string $fromAddr, string $body, array $extraHeaders = [], array $options = []): array { try { $this->connect(); $this->greet(); $this->authenticate(); $messageId = $this->transmit($to, $fromName, $fromAddr, $body, $extraHeaders, $options); $this->quit(); return ['ok' => true, 'error' => '', 'message_id' => $messageId, 'log' => $this->log]; } catch (\Throwable $e) { $this->closeSocket(); return ['ok' => false, 'error' => $e->getMessage(), 'message_id' => '', 'log' => $this->log]; } } private function connect(): void { $host = $this->config['host']; $port = (int)($this->config['port'] ?? 587); $enc = $this->config['encryption'] ?? 'starttls'; // Direct TLS ("smtps", usually port 465) opens on a TLS socket // straight away; STARTTLS opens plaintext then upgrades after EHLO. $scheme = ($enc === 'tls') ? 'tls://' : ''; $errno = 0; $errstr = ''; $this->sock = @stream_socket_client($scheme . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT); if (!$this->sock) throw new \RuntimeException("connect failed: $errstr ($errno)"); stream_set_timeout($this->sock, 15); $this->expect(220); } private function greet(): void { $me = gethostname() ?: 'localhost'; $this->send_line('EHLO ' . $me); $banner = $this->expect(250, true); if (($this->config['encryption'] ?? '') === 'starttls') { $this->send_line('STARTTLS'); $this->expect(220); if (!@stream_socket_enable_crypto($this->sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { throw new \RuntimeException('STARTTLS: crypto negotiation failed'); } // EHLO again after the upgrade (RFC 3207 requires it) $this->send_line('EHLO ' . $me); $this->expect(250, true); } } private function authenticate(): void { $user = $this->config['user'] ?? ''; $pass = $this->config['password'] ?? ''; if ($user === '') return; // unauthenticated relays are rare but valid $this->send_line('AUTH LOGIN'); $this->expect(334); $this->send_line(base64_encode($user)); $this->expect(334); $this->send_line(base64_encode($pass)); $this->expect(235); } private function transmit(string $to, string $fromName, string $fromAddr, string $body, array $extraHeaders, array $options = []): string { $this->send_line('MAIL FROM:<' . $fromAddr . '>'); $this->expect(250); $this->send_line('RCPT TO:<' . $to . '>'); $this->expect(250); $this->send_line('DATA'); $this->expect(354); // Build RFC 5322 message $host = parse_url('https://' . ($this->config['host'] ?? 'local'), PHP_URL_HOST) ?: 'local'; $messageId = bin2hex(random_bytes(12)) . '@' . $host; $date = date('r'); $subject = $extraHeaders['Subject'] ?? 'Support reply'; $html = $options['html'] ?? null; $inline = $options['inlineImages'] ?? []; // Common top-of-message headers $topHeaders = [ 'Date: ' . $date, 'From: ' . $this->encodeHeader($fromName) . ' <' . $fromAddr . '>', 'To: <' . $to . '>', 'Subject: ' . $this->encodeHeader($subject), 'Message-ID: <' . $messageId . '>', 'MIME-Version: 1.0', ]; foreach ($extraHeaders as $k => $v) { if ($k === 'Subject') continue; $topHeaders[] = $k . ': ' . $v; } if ($html === null) { // Plain-text only (original fast path) $topHeaders[] = 'Content-Type: text/plain; charset=utf-8'; $topHeaders[] = 'Content-Transfer-Encoding: 8bit'; $message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $this->dotStuff($body) . "\r\n."; $this->send_line($message); $this->expect(250); return $messageId; } // Multipart. text/plain + text/html alternative, optionally wrapped // in multipart/related for CID-addressable inline images so the // HTML version can reference them via . $altBoundary = 'alt_' . bin2hex(random_bytes(8)); $altParts = []; $altParts[] = "--{$altBoundary}\r\n" . "Content-Type: text/plain; charset=utf-8\r\n" . "Content-Transfer-Encoding: 8bit\r\n\r\n" . $this->dotStuff($body); $altParts[] = "--{$altBoundary}\r\n" . "Content-Type: text/html; charset=utf-8\r\n" . "Content-Transfer-Encoding: 8bit\r\n\r\n" . $this->dotStuff($html); $altBody = implode("\r\n", $altParts) . "\r\n--{$altBoundary}--"; if (!$inline) { $topHeaders[] = "Content-Type: multipart/alternative; boundary=\"{$altBoundary}\""; $message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $altBody . "\r\n."; $this->send_line($message); $this->expect(250); return $messageId; } $relBoundary = 'rel_' . bin2hex(random_bytes(8)); $relParts = []; // First related part: the alternative tree (plain + html) $relParts[] = "--{$relBoundary}\r\n" . "Content-Type: multipart/alternative; boundary=\"{$altBoundary}\"\r\n\r\n" . $altBody; // Remaining parts: inline images addressed by CID foreach ($inline as $img) { $cid = $img['cid'] ?? bin2hex(random_bytes(6)); $mime = $img['mime'] ?? 'application/octet-stream'; $path = $img['path'] ?? ''; $data = ($path && is_file($path)) ? (string)file_get_contents($path) : ''; if ($data === '') continue; // silently skip missing images $b64 = chunk_split(base64_encode($data), 76, "\r\n"); $name = basename($path); $relParts[] = "--{$relBoundary}\r\n" . "Content-Type: {$mime}; name=\"" . $this->encodeHeader($name) . "\"\r\n" . "Content-Transfer-Encoding: base64\r\n" . "Content-ID: <{$cid}>\r\n" . "Content-Disposition: inline; filename=\"" . $this->encodeHeader($name) . "\"\r\n\r\n" . $b64; } $relBody = implode("\r\n", $relParts) . "\r\n--{$relBoundary}--"; $topHeaders[] = "Content-Type: multipart/related; type=\"multipart/alternative\"; boundary=\"{$relBoundary}\""; $message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $relBody . "\r\n."; $this->send_line($message); $this->expect(250); return $messageId; } private function quit(): void { $this->send_line('QUIT'); // Don't hard-fail if the server misbehaves after our last command. @fgets($this->sock); $this->closeSocket(); } private function closeSocket(): void { if (is_resource($this->sock)) { @fclose($this->sock); } $this->sock = null; } /** Send a line (appends CRLF). */ private function send_line(string $line): void { $this->log[] = '> ' . $this->safeLogLine($line); fwrite($this->sock, $line . "\r\n"); } /** Read lines until a final reply (no '-' separator), assert the * expected 3-digit code. With $multi=true accepts same code across * continuation lines (e.g. EHLO's capability list). */ private function expect(int $code, bool $multi = false): string { $last = ''; while (!feof($this->sock)) { $line = fgets($this->sock, 1024); if ($line === false) throw new \RuntimeException('read failed'); $this->log[] = '< ' . rtrim($line); $last .= $line; if (strlen($line) < 4) throw new \RuntimeException('short reply'); $actual = (int)substr($line, 0, 3); if ($actual !== $code) throw new \RuntimeException("expected $code, got " . rtrim($line)); // '-' means continuation, ' ' or end means final if ($line[3] !== '-') break; if (!$multi) break; } return $last; } /** Encode a header value if it contains non-ASCII. Keeps the subject * readable for clients that support RFC 2047 encoded-word. */ private function encodeHeader(string $v): string { if (preg_match('/[\x80-\xff]/', $v)) { return '=?UTF-8?B?' . base64_encode($v) . '?='; } return $v; } /** RFC 5321 §4.5.2 dot-stuffing: a leading dot on a body line would * end the DATA transmission early. Double any such dot. */ private function dotStuff(string $body): string { // Normalise line endings to CRLF $body = preg_replace("/\r\n|\r|\n/", "\r\n", $body); return preg_replace('/^\./m', '..', $body); } /** Redact the LOGIN base64 so a saved log doesn't leak credentials. */ private function safeLogLine(string $line): string { if (preg_match('/^[A-Za-z0-9+\/=]+$/', $line) && strlen($line) > 8) return ''; return $line; } }