host = (string) $host; $this->port = (int) $port; $this->enc = (string) $enc; $this->user = (string) $user; $this->pass = (string) $pass; } /** * 发送邮件。 * @param string $to 收件人,多个用逗号分隔 * @param string $subject 主题 * @param string $bodyHtml 正文(HTML) * @param string $fromEmail 发件人邮箱 * @param string $fromName 发件人名称 * @return bool */ public function send($to, $subject, $bodyHtml, $fromEmail, $fromName) { if ($this->host === '') { $this->lastError = 'SMTP 主机未配置'; return false; } $port = $this->port ?: ($this->enc === 'ssl' ? 465 : 587); $timeout = 20; $scheme = $this->enc === 'ssl' ? 'ssl' : 'tcp'; $opts = []; if ($this->enc === 'ssl' || $this->enc === 'tls') { $opts['ssl'] = [ 'verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true, ]; } $ctx = $opts ? stream_context_create($opts) : null; $errno = 0; $errstr = ''; $sock = @stream_socket_client( $scheme . '://' . $this->host . ':' . $port, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx ); if (!$sock) { $this->lastError = "无法连接 SMTP 服务器({$this->host}:{$port}):$errstr ($errno)"; return false; } stream_set_timeout($sock, $timeout); // 读取服务商标语 220 if ($this->talk($sock, null, [220]) === false) { fclose($sock); return false; } $hostHeader = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost'; // EHLO if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) { fclose($sock); return false; } // STARTTLS 协商 if ($this->enc === 'tls') { if ($this->talk($sock, 'STARTTLS', [220]) === false) { fclose($sock); return false; } if (!@stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { $this->lastError = 'STARTTLS 加密协商失败'; fclose($sock); return false; } if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) { fclose($sock); return false; } } // AUTH LOGIN if ($this->talk($sock, 'AUTH LOGIN', [334]) === false) { fclose($sock); return false; } if ($this->talk($sock, base64_encode($this->user), [334]) === false) { fclose($sock); return false; } if ($this->talk($sock, base64_encode($this->pass), [235]) === false) { fclose($sock); return false; } // 信封 if ($this->talk($sock, 'MAIL FROM:<' . $fromEmail . '>', [250]) === false) { fclose($sock); return false; } $recipients = array_filter(array_map('trim', explode(',', $to))); foreach ($recipients as $rcpt) { $this->talk($sock, 'RCPT TO:<' . $rcpt . '>', [250, 251]); } // 信体 if ($this->talk($sock, 'DATA', [354]) === false) { fclose($sock); return false; } $data = $this->buildData($to, $subject, $bodyHtml, $fromEmail, $fromName); fwrite($sock, $data); if ($this->talk($sock, '.', [250]) === false) { fclose($sock); return false; } $this->talk($sock, 'QUIT', [221]); fclose($sock); return true; } private function buildData($to, $subject, $body, $fromEmail, $fromName) { $eol = "\r\n"; $headers = []; $headers[] = 'From: ' . $this->encodeHeader($fromName) . ' <' . $fromEmail . '>'; $headers[] = 'To: ' . $to; $headers[] = 'Subject: ' . $this->encodeHeader($subject); $headers[] = 'Date: ' . date('r'); $headers[] = 'MIME-Version: 1.0'; $headers[] = 'Content-Type: text/html; charset=UTF-8'; $headers[] = 'Content-Transfer-Encoding: base64'; $msg = implode($eol, $headers) . $eol . $eol; $msg .= chunk_split(base64_encode($body), 76, $eol); return $msg; } private function encodeHeader($s) { if (preg_match('/[^\x00-\x7F]/', (string) $s)) { return '=?UTF-8?B?' . base64_encode($s) . '?='; } return $s; } /** * 与服务器交互:发送命令并读取响应。 * @param resource $sock * @param string|null $cmd 为 null 时仅读取(用于读取初始标语) * @param array $expectCodes 期望的状态码数组 * @return string|false */ private function talk($sock, $cmd, $expectCodes) { if ($cmd !== null) { fwrite($sock, $cmd . "\r\n"); $this->log[] = 'C: ' . $cmd; } $resp = ''; while (true) { $line = @fgets($sock, 515); if ($line === false) { break; } $resp .= $line; // 末位第 4 个字符为空格表示响应结束(多行响应以 "- " 续接) if (isset($line[3]) && $line[3] === ' ') { break; } } $this->log[] = 'S: ' . trim($resp); $code = (int) substr($resp, 0, 3); if (!in_array($code, (array) $expectCodes, true)) { $this->lastError = '服务器返回错误:' . trim($resp); return false; } return $resp; } }