首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 綜合知識

PHP Socket 實作 SMTP 郵件發送完整教學:不依賴 mail() 與 PHPMailer

     編輯:PHP 綜合知識

PHP 內建 mail() 功能簡陋、無 SMTP 驗證、難以除錯;PHPMailer 雖強大但體積較大。若需在無 Composer 環境、嵌入式裝置或極簡專案中發送郵件,直接使用 PHP Socket 擴展 實作 SMTP 協定 (RFC 5321) 是輕量且可控的選擇。本文提供完整的 MySendMail 類別,支援純文字/HTML、SSL/TLS (STARTTLS)、附件擴充基礎。

SMTP 協定流程圖解

Client                         Server
  |                              |
  |---- TCP Connect (port 25/465/587) --->|
  |<--- 220 smtp.example.com Ready ------|
  |---- HELO/EHLO hostname ------------->|
  |<--- 250 OK --------------------------|
  |---- AUTH LOGIN --------------------->|
  |<--- 334 VXNlcm5hbWU6 (base64) -------|
  |---- base64(username) --------------->|
  |<--- 334 UGFzc3dvcmQ6 ----------------|
  |---- base64(password) --------------->|
  |<--- 235 Authentication successful ---|
  |---- MAIL FROM:<sender@domain> ------->|
  |<--- 250 OK --------------------------|
  |---- RCPT TO:<rcpt@domain> ----------->|
  |<--- 250 OK --------------------------|
  |---- DATA ---------------------------->|
  |<--- 354 Start mail input ------------|
  |---- MIME Message (headers+body) ----->|
  |---- . (單獨一行) -------------------->|
  |<--- 250 Queued ----------------------|
  |---- QUIT ---------------------------->|
  |<--- 221 Bye -------------------------|
  |---- Close Socket --------------------|

完整 MySendMail 類別 (支援 STARTTLS)

<?php
/**
 * 輕量級 SMTP 發送類
 * 需求:PHP 開啟 sockets 擴展 (php.ini: extension=sockets)
 * 支援:純文字/HTML、STARTTLS (port 587)、SSL (port 465)
 */
class MySendMail
{
    private string $host = '';
    private int    $port = 587;
    private string $username = '';
    private string $password = '';
    private string $from = '';
    private string $to = '';
    private string $subject = '';
    private string $body = '';
    private bool   $isHtml = false;
    private bool   $useTls = true;       // STARTTLS
    private bool   $useSsl = false;      // SMTPS (port 465)
    private ?string $error = null;
    private $socket = null;

    // --- 設定器 ---
    public function setServer(string $host, int $port = 587, bool $tls = true, bool $ssl = false): self
    {
        $this->host = $host;
        $this->port = $port;
        $this->useTls = $tls;
        $this->useSsl = $ssl;
        return $this;
    }
    public function setAuth(string $user, string $pass): self { $this->username = $user; $this->password = $pass; return $this; }
    public function setFrom(string $email, string $name = ''): self { $this->from = $name ? "\"$name\" <$email>" : "<$email>"; return $this; }
    public function setTo(string $email, string $name = ''): self { $this->to = $name ? "\"$name\" <$email>" : "<$email>"; return $this; }
    public function setSubject(string $s): self { $this->subject = $s; return $this; }
    public function setBody(string $body, bool $isHtml = false): self { $this->body = $body; $this->isHtml = $isHtml; return $this; }
    public function getError(): ?string { return $this->error; }

    // --- 核心發送 ---
    public function send(): bool
    {
        try {
            $this->connect();
            $this->handshake();
            $this->auth();
            $this->mailTransaction();
            $this->close();
            return true;
        } catch (Throwable $e) {
            $this->error = $e->getMessage();
            $this->close();
            return false;
        }
    }

    private function connect(): void
    {
        $flags = $this->useSsl ? STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT : STREAM_CLIENT_CONNECT;
        $remote = ($this->useSsl ? 'ssl://' : 'tcp://') . $this->host . ':' . $this->port;

        $this->socket = @stream_socket_client($remote, $errno, $errstr, 30, $flags);
        if (!$this->socket) throw new RuntimeException("連線失敗:$errstr ($errno)");

        stream_set_timeout($this->socket, 10);
        $this->expect(220); // 歡迎訊息
    }

    private function handshake(): void
    {
        $this->command("EHLO " . gethostname());
        $this->expect(250);

        if ($this->useTls && !$this->useSsl) {
            $this->command("STARTTLS");
            $this->expect(220);
            // 升級為 TLS
            $crypto = stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
            if (!$crypto) throw new RuntimeException('STARTTLS 升級失敗');
            $this->command("EHLO " . gethostname()); // 重新 EHLO
            $this->expect(250);
        }
    }

    private function auth(): void
    {
        $this->command("AUTH LOGIN");
        $this->expect(334);
        $this->command(base64_encode($this->username));
        $this->expect(334);
        $this->command(base64_encode($this->password));
        $this->expect(235);
    }

    private function mailTransaction(): void
    {
        $this->command("MAIL FROM:{$this->from}");
        $this->expect(250);
        $this->command("RCPT TO:{$this->to}");
        $this->expect(250);
        $this->command("DATA");
        $this->expect(354);

        $headers = "From: {$this->from}\r\n";
        $headers .= "To: {$this->to}\r\n";
        $headers .= "Subject: {$this->subject}\r\n";
        $headers .= "MIME-Version: 1.0\r\n";
        if ($this->isHtml) {
            $headers .= "Content-Type: text/html; charset=UTF-8\r\n";
        } else {
            $headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
        }
        $headers .= "\r\n";
        $message = $headers . $this->body . "\r\n.\r\n";

        fwrite($this->socket, $message);
        $this->expect(250);

        $this->command("QUIT");
        $this->expect(221);
    }

    private function command(string $cmd): void
    {
        fwrite($this->socket, $cmd . "\r\n");
        // 可選:echo "C: $cmd\n"; $resp = fgets($this->socket); echo "S: $resp";
    }

    private function expect(int $code): void
    {
        $line = fgets($this->socket, 512);
        if ($line === false) throw new RuntimeException('伺服器無回應 (timeout)');
        $replyCode = (int) substr($line, 0, 3);
        if ($replyCode !== $code) {
            throw new RuntimeException("SMTP 錯誤 [$replyCode]:$line");
        }
    }

    private function close(): void
    {
        if (is_resource($this->socket)) {
            @fclose($this->socket);
            $this->socket = null;
        }
    }
}

使用範例

<?php
require 'MySendMail.php';

$mail = new MySendMail();
$mail->setServer('smtp.gmail.com', 587, true, false)  // STARTTLS
     ->setAuth('your@gmail.com', 'app_password')      // Gmail 需應用程式密碼
     ->setFrom('your@gmail.com', '系統通知')
     ->setTo('target@example.com', '收件者')
     ->setSubject('測試主旨 ' . date('Y-m-d H:i:s'))
     ->setBody('<h1>HTML 郵件測試</h1><p>支援中文內容</p>', true);

if ($mail->send()) {
    echo "發送成功!";
} else {
    echo "發送失敗:" . $mail->getError();
}

進階擴充:附件支援 (MIME Multipart)

在 mailTransaction() 中將 Content-Type 改為 multipart/mixed; boundary="BOUNDARY",依序寫入:

--BOUNDARY
Content-Type: text/html; charset=UTF-8

HTML 內容
--BOUNDARY
Content-Type: application/pdf; name="report.pdf"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="report.pdf"

BASE64 編碼內容
--BOUNDARY--

建議改用 PHPMailer 或 Symfony Mime 處理複雜 MIME 結構,避免手工拼接錯誤。

常見問題排查

「extension=sockets 未啟用」
php.ini 取消註解 extension=sockets,重啟 PHP-FPM/Apache。
「連線逾時 / 110 Connection timed out」
防火牆封鎖 25/465/587 埠,或雲主機安全組未開放。改用 587 STARTTLS 或 465 SMTPS。
「535 Authentication failed」
帳密錯誤、未開啟 SMTP 服務、Gmail 需「應用程式密碼」而非登入密碼、OAuth2 未授權。
「550 Relay not permitted / SPF/DKIM/DMARC 失敗」
發件網域需設定 SPF/DKIM/DMARC 紀錄,且 From 必須為已驗證網域。
「SSL/TLS 憑證驗證失敗」
本地 CA 憑證過舊。stream_context_set_option($ctx, 'ssl', 'verify_peer', false) 僅供測試,生產請更新 ca-certificates。

效能與安全建議

  • 連線池:大量發送時,建立持久連線 (SMTP PIPELINING) 避免重複握手。
  • 非同步佇列:將郵件推入 Redis/RabbitMQ,由 Worker 背景發送,避免阻塞 HTTP 請求。
  • 速率限制:遵守郵件服務商每小時/每日配額,加入 Token Bucket 限流。
  • 日誌審計:記錄 Message-ID、收件者、狀態碼,便事後追蹤。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved