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

PHP 郵件開發完全指南:SMTP 協議、MIME 結構、附件處理、IMAP 收信與現代最佳實踐

     編輯:PHP 綜合知識

前言:為什麼不直接用 mail()?

PHP 內建 mail() 依賴本機 MTA (sendmail/postfix),在 Windows 無效、無法指定遠端 SMTP、不支援認證、MIME 組裝極其原始。**現代專案請直接使用成熟庫:PHPMailer、Symfony Mailer、Laravel Mail**。本文深入底層協議原理,幫助你理解庫的運作、除錯發送失敗、或在極受限環境手寫發送邏輯。

SMTP 協議核心流程

S: 220 smtp.example.com ESMTP Postfix
C: EHLO client.example.com
S: 250-SIZE 35840000
S: 250-STARTTLS
S: 250-AUTH LOGIN PLAIN XOAUTH2
S: 250 PIPELINING
C: STARTTLS
S: 220 Ready to start TLS
... TLS 握手 ...
C: EHLO client.example.com
S: 250 ... (同前)
C: AUTH LOGIN
S: 334 VXNlcm5hbWU6  (Base64 "Username:")
C: dXNlckBleGFtcGxlLmNvbQ==  (Base64 user@example.com)
S: 334 UGFzc3dvcmQ6  (Base64 "Password:")
C: bXlwYXNzd29yZA==  (Base64 mypassword)
S: 235 Authentication successful
C: MAIL FROM:<sender@example.com> SIZE=12345
S: 250 2.1.0 Ok
C: RCPT TO:<recipient@example.com>
S: 250 2.1.5 Ok
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: (完整 RFC 5322 訊息內容,含 Headers + 空行 + Body)
C: .
S: 250 2.0.0 Ok: queued as 1A2B3C
C: QUIT
S: 221 2.0.0 Bye

MIME 多部分結構詳解

郵件主體為樹狀結構,常見組合:

multipart/mixed (根)
  ├─ multipart/related (HTML + 內嵌圖片)
  │   ├─ multipart/alternative
  │   │   ├─ text/plain (純文字備選)
  │   │   └─ text/html (HTML 正文,img src="cid:xxx")
  │   ├─ image/png (cid:part1.xxx@domain)
  │   └─ image/jpeg (cid:part2.xxx@domain)
  ├─ application/pdf (attachment; filename="report.pdf")
  └─ application/zip (attachment; filename="data.zip")

關鍵點:

  • 每層 boundary 必須全域唯一,慣用 md5(uniqid(time(), true))
  • 內嵌圖片用 Content-ID: <unique@domain>,HTML 參照 cid:unique@domain
  • 附件需 Content-Disposition: attachment; filename="xxx"
  • 非 ASCII 標頭 (Subject, From 名稱) 必須 =?UTF-8?B?Base64編碼?= (RFC 2047)
  • Body 內容含非 ASCII 或二進制 → Content-Transfer-Encoding: base64 或 quoted-printable

手寫 SMTP 發送類核心實作 (教學用)

<?php
class SimpleSmtp
{
    private $socket;
    private $host;
    private $port;
    private $timeout = 30;

    public function __construct(string $host, int $port = 587) {
        $this->host = $host;
        $this->port = $port;
    }

    public function connect(): bool {
        $this->socket = fsockopen($this->host, $this->port, $errno, $errstr, $this->timeout);
        if (!$this->socket) throw new RuntimeException("Connect failed: $errstr ($errno)");
        stream_set_timeout($this->socket, $this->timeout);
        return $this->expect(220);
    }

    public function ehlo(string $hostname = 'localhost'): bool {
        $this->send("EHLO $hostname");
        return $this->expect(250, true); // 多行回應
    }

    public function startTls(): bool {
        $this->send("STARTTLS");
        if (!$this->expect(220)) return false;
        $crypto = stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
        return $crypto ? $this->ehlo() : false;
    }

    public function authLogin(string $user, string $pass): bool {
        $this->send("AUTH LOGIN");
        if (!$this->expect(334)) return false;
        $this->send(base64_encode($user));
        if (!$this->expect(334)) return false;
        $this->send(base64_encode($pass));
        return $this->expect(235);
    }

    public function mailFrom(string $email): bool {
        $this->send("MAIL FROM:<$email>");
        return $this->expect(250);
    }

    public function rcptTo(string $email): bool {
        $this->send("RCPT TO:<$email>");
        return $this->expect(250);
    }

    public function data(string $rawMessage): bool {
        $this->send("DATA");
        if (!$this->expect(354)) return false;
        // 郵件內容每行以 CRLF 結尾,以單獨一行 "." 結束
        $this->send($rawMessage . "\r\n.");
        return $this->expect(250);
    }

    public function quit(): void {
        $this->send("QUIT");
        $this->expect(221);
        fclose($this->socket);
    }

    private function send(string $cmd): void {
        fwrite($this->socket, $cmd . "\r\n");
    }

    private function expect(int $code, bool $multiline = false): bool {
        $response = '';
        do {
            $line = fgets($this->socket, 512);
            if ($line === false) return false;
            $response .= $line;
        } while ($multiline && substr($line, 3, 1) !== ' ');
        return (int)substr($response, 0, 3) === $code;
    }
}

// 使用範例
$smtp = new SimpleSmtp('smtp.example.com', 587);
$smtp->connect();
$smtp->ehlo();
$smtp->startTls();
$smtp->authLogin('user', 'pass');
$smtp->mailFrom('sender@example.com');
$smtp->rcptTo('to@example.com');
$message = buildMimeMessage( /* ... */ );
$smtp->data($message);
$smtp->quit();
?>

MIME 訊息組裝工具函數

<?php
function buildMimeMessage(
    string $fromName, string $fromEmail,
    string $toName, string $toEmail,
    string $subject,
    string $textBody,
    string $htmlBody,
    array $attachments = [],      // ['path'=>..., 'name'=>..., 'mime'=>...]
    array $inlineImages = []      // ['cid'=>..., 'path'=>..., 'mime'=>...]
): string {
    $boundaryMixed = '==MIXED_' . md5(uniqid(time(), true));
    $boundaryRelated = '==RELATED_' . md5(uniqid(time(), true));
    $boundaryAlt = '==ALT_' . md5(uniqid(time(), true));

    $headers = [];
    $headers[] = "From: " . encodeHeader($fromName) . " <$fromEmail>";
    $headers[] = "To: " . encodeHeader($toName) . " <$toEmail>";
    $headers[] = "Subject: " . encodeHeader($subject);
    $headers[] = "MIME-Version: 1.0";
    $headers[] = "Content-Type: multipart/mixed; boundary=\"$boundaryMixed\"";
    $headers[] = "X-Mailer: PHP/" . PHP_VERSION;

    $body = "";
    $body .= "--$boundaryMixed\r\n";
    $body .= "Content-Type: multipart/related; boundary=\"$boundaryRelated\"\r\n\r\n";

    $body .= "--$boundaryRelated\r\n";
    $body .= "Content-Type: multipart/alternative; boundary=\"$boundaryAlt\"\r\n\r\n";

    $body .= "--$boundaryAlt\r\n";
    $body .= "Content-Type: text/plain; charset=UTF-8\r\n";
    $body .= "Content-Transfer-Encoding: base64\r\n\r\n";
    $body .= chunk_split(base64_encode($textBody)) . "\r\n";

    $body .= "--$boundaryAlt\r\n";
    $body .= "Content-Type: text/html; charset=UTF-8\r\n";
    $body .= "Content-Transfer-Encoding: base64\r\n\r\n";
    $body .= chunk_split(base64_encode($htmlBody)) . "\r\n";

    $body .= "--$boundaryAlt--\r\n";

    // 內嵌圖片
    foreach ($inlineImages as $img) {
        $data = chunk_split(base64_encode(file_get_contents($img['path'])));
        $body .= "--$boundaryRelated\r\n";
        $body .= "Content-Type: {$img['mime']}\r\n";
        $body .= "Content-Transfer-Encoding: base64\r\n";
        $body .= "Content-ID: <{$img['cid']}>\r\n";
        $body .= "Content-Disposition: inline; filename=\"{$img['name']}\"\r\n\r\n";
        $body .= "$data\r\n";
    }
    $body .= "--$boundaryRelated--\r\n";

    // 附件
    foreach ($attachments as $att) {
        $data = chunk_split(base64_encode(file_get_contents($att['path'])));
        $body .= "--$boundaryMixed\r\n";
        $body .= "Content-Type: {$att['mime']}; name=\"{$att['name']}\"\r\n";
        $body .= "Content-Transfer-Encoding: base64\r\n";
        $body .= "Content-Disposition: attachment; filename=\"{$att['name']}\"\r\n\r\n";
        $body .= "$data\r\n";
    }
    $body .= "--$boundaryMixed--\r\n";

    return implode("\r\n", $headers) . "\r\n\r\n" . $body;
}

function encodeHeader(string $text): string {
    return '=?UTF-8?B?' . base64_encode($text) . '?=';
}
?>

接收郵件:IMAP 操作要點

<?php
$mbox = imap_open('{imap.example.com:993/imap/ssl}INBOX', 'user', 'pass')
    or die('IMAP connect failed: ' . imap_last_error());

// 搜尋未讀
$unread = imap_search($mbox, 'UNSEEN');
if ($unread) {
    foreach ($unread as $msgno) {
        $header = imap_headerinfo($mbox, $msgno);
        $structure = imap_fetchstructure($mbox, $msgno);
        $body = getMimeBody($mbox, $msgno, $structure);
        // 處理附件...
        imap_delete($mbox, $msgno); // 標記刪除
    }
    imap_expunge($mbox); // 真正刪除
}
imap_close($mbox);

function getMimeBody($mbox, $msgno, $structure, $partno = '') {
    if (empty($structure->parts)) { // 單一部分
        $data = imap_fetchbody($mbox, $msgno, $partno ?: 1);
        return decodePart($data, $structure->encoding);
    }
    // multipart 遞迴
    foreach ($structure->parts as $i => $part) {
        $subPartno = $partno ? "$partno." . ($i+1) : ($i+1);
        if ($part->ifdisposition && $part->disposition === 'ATTACHMENT') {
            // 處理附件儲存...
            continue;
        }
        $result = getMimeBody($mbox, $msgno, $part, $subPartno);
        if ($result !== null) return $result; // 找到 text/plain 或 text/html
    }
    return null;
}

function decodePart(string $data, int $encoding): string {
    return match ($encoding) {
        0, 1 => $data,           // 7bit, 8bit
        2 => imap_binary($data), // BINARY
        3 => imap_base64($data), // BASE64
        4 => imap_qprint($data), // QUOTED-PRINTABLE
        default => $data,
    };
}
?>

常見發送失敗排查清單

現象可能原因排查步驟
連線逾時防火牆/安全組阻擋 25/465/587、SMTP 服務未啟動telnet smtp.host 587、nc -zv smtp.host 587、檢查雲廠商出站規則
530 Authentication required未啟用 AUTH、加密方式錯誤 (SSL vs STARTTLS)嘗試 465 (SMTPS) 或 587 (STARTTLS)、確認帳密、檢查「不安全應用」存取權
550 Relay not permitted發件域未驗證、SPF/DKIM/DMARC 缺失、IP 信譽差設定 SPF/DKIM/DMARC、使用專業郵件服務 (SendGrid, Mailgun, Amazon SES)
進垃圾箱內容觸發規則、IP 黑名單、缺少 List-Unsubscribe、圖文比失衡測試 mail-tester.com、純文字備選、避免敏感詞、配置反向解析
附件損壞/亂碼Base64 編碼錯誤、未正確設定 Content-Transfer-Encoding、檔名編碼檢查原始郵件來源、使用 chunk_split(base64_encode())、檔名 RFC 2231 編碼

現代最佳實踐總結

  1. 棄用 mail(),改用 PHPMailer / Symfony Mailer / 框架整合郵件組件。
  2. 使用專業交易郵件服務 (SendGrid, Mailgun, Postmark, Amazon SES, 阿里雲郵件推送) 而非自建 SMTP,解決 IP 信譽、退信處理、發送量擴展。
  3. 模板引擎分離內容:Twig/Blade 渲染 HTML/Text,注入 CSP nonce、內嵌關鍵 CSS。
  4. 非同步佇列發送:Web 請求只推送 Job 到 Redis/RabbitMQ,Worker 背景發送、重試、記錄日誌。
  5. 監控與告警:追蹤送達率、退信率、開啟率、點擊率;異常即時通知。
  6. 安全:DKIM 簽名、SPF/DMARC 對齊、TLS 1.2+、定期輪換 API Key、敏感資訊不入郵件內文。
郵件看似簡單,實則涉及協議、編碼、聲譽、合規多重複雜度。**「專業的事交給專業服務,業務邏輯專注於模板與佇列」** 是現代架構的核心原則。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved