PHP 內建 mail() 依賴本機 MTA (sendmail/postfix),在 Windows 無效、無法指定遠端 SMTP、不支援認證、MIME 組裝極其原始。**現代專案請直接使用成熟庫:PHPMailer、Symfony Mailer、Laravel Mail**。本文深入底層協議原理,幫助你理解庫的運作、除錯發送失敗、或在極受限環境手寫發送邏輯。
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
郵件主體為樹狀結構,常見組合:
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@domainContent-Disposition: attachment; filename="xxx"=?UTF-8?B?Base64編碼?= (RFC 2047)Content-Transfer-Encoding: base64 或 quoted-printable<?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();
?>
<?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) . '?=';
}
?>
<?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 編碼 |
PHPMailer / Symfony Mailer / 框架整合郵件組件。郵件看似簡單,實則涉及協議、編碼、聲譽、合規多重複雜度。**「專業的事交給專業服務,業務邏輯專注於模板與佇列」** 是現代架構的核心原則。