PHP 內建 mail() 功能簡陋、無法支援 SMTP 認證、附件、SSL/TLS。本文提供一個功能完整的 SMTP 類別,支援:SMTP AUTH、SSL/TLS、HTML/純文字、多附件、抄送/密件副本、自定義 Headers、發送日誌、錯誤處理,可直接整合至專案使用。
$val) {
if (property_exists($this, $key)) $this->$key = $val;
}
}
// 主發送方法
public function send($to, $subject, $body, $options = []) {
$defaults = [
'cc' => '',
'bcc' => '',
'attachments' => [], // ['/path/file.pdf', ...] 或 [['path'=>'...', 'name'=>'...', 'mime'=>'...'], ...]
'isHtml' => true,
'charset' => 'UTF-8',
'headers' => [], // 額外 headers ['Reply-To' => '...', ...]
'priority' => 3, // 1=高, 3=正常, 5=低
];
$opts = array_merge($defaults, $options);
// 建立 MIME 訊息
$mime = $this->buildMimeMessage($to, $opts['cc'], $opts['bcc'], $subject, $body, $opts);
// 連線 SMTP
if (!$this->connect()) return false;
// SMTP 交談
try {
$this->sendCommand('HELO', $this->heloHost);
if ($this->auth) $this->authenticate();
$this->sendCommand('MAIL FROM:', "<{$this->fromEmail}>");
$recipients = array_merge(
(array)$to,
$opts['cc'] ? (array)$opts['cc'] : [],
$opts['bcc'] ? (array)$opts['bcc'] : []
);
foreach ($recipients as $rcpt) {
$this->sendCommand('RCPT TO:', "<{$rcpt}>");
}
$this->sendCommand('DATA');
$this->sendData($mime);
$this->sendCommand('QUIT');
$this->log("郵件發送成功: " . implode(', ', (array)$to));
return true;
} catch (Exception $e) {
$this->log("發送失敗: " . $e->getMessage());
return false;
} finally {
$this->disconnect();
}
}
// 建立 MIME 訊息 (支援附件、HTML/TEXT 替代)
private function buildMimeMessage($to, $cc, $bcc, $subject, $body, $opts) {
$boundary = '==MULTIPART_' . md5(uniqid(time())) . '==';
$altBoundary = '==ALTERNATIVE_' . md5(uniqid(time())) . '==';
$headers = [];
$headers[] = "MIME-Version: 1.0";
$headers[] = "Date: " . date('r');
$headers[] = "From: {$this->encodeHeader($this->fromName)} <{$this->fromEmail}>";
$headers[] = "To: " . $this->formatAddresses($to);
if ($cc) $headers[] = "Cc: " . $this->formatAddresses($cc);
if ($bcc) $headers[] = "Bcc: " . $this->formatAddresses($bcc);
$headers[] = "Subject: " . $this->encodeHeader($subject);
$headers[] = "Message-ID: <" . time() . "." . mt_rand() . "@{$this->heloHost}>";
$headers[] = "X-Mailer: PHP SMTP Class";
$headers[] = "X-Priority: {$opts['priority']}";
// 自定義 headers
foreach ($opts['headers'] as $k => $v) {
$headers[] = "$k: $v";
}
$headerStr = implode("\r\n", $headers) . "\r\n\r\n";
// 訊息主體
$message = '';
if (!empty($opts['attachments'])) {
// 混合類型:含附件
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: multipart/alternative; boundary=\"{$altBoundary}\"\r\n\r\n";
// 純文字版本
$message .= "--{$altBoundary}\r\n";
$message .= "Content-Type: text/plain; charset=\"{$opts['charset']}\"\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= base64_encode($opts['isHtml'] ? strip_tags($body) : $body) . "\r\n\r\n";
// HTML 版本
$message .= "--{$altBoundary}\r\n";
$message .= "Content-Type: text/html; charset=\"{$opts['charset']}\"\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= base64_encode($body) . "\r\n\r\n";
$message .= "--{$altBoundary}--\r\n\r\n";
// 附件
foreach ($opts['attachments'] as $att) {
$path = is_array($att) ? $att['path'] : $att;
$name = is_array($att) ? ($att['name'] ?? basename($path)) : basename($path);
$mime = is_array($att) ? ($att['mime'] ?? 'application/octet-stream') : 'application/octet-stream';
if (!file_exists($path)) continue;
$content = file_get_contents($path);
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: {$mime}; name=\"{$name}\"\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n";
$message .= "Content-Disposition: attachment; filename=\"{$name}\"\r\n\r\n";
$message .= chunk_split(base64_encode($content)) . "\r\n\r\n";
}
$message .= "--{$boundary}--\r\n";
} else {
// 無附件:簡單 alternative
$message .= "--{$altBoundary}\r\n";
$message .= "Content-Type: text/plain; charset=\"{$opts['charset']}\"\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= base64_encode($opts['isHtml'] ? strip_tags($body) : $body) . "\r\n\r\n";
if ($opts['isHtml']) {
$message .= "--{$altBoundary}\r\n";
$message .= "Content-Type: text/html; charset=\"{$opts['charset']}\"\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= base64_encode($body) . "\r\n\r\n";
}
$message .= "--{$altBoundary}--\r\n";
}
return $headerStr . $message;
}
// SMTP 連線
private function connect() {
$host = $this->secure === 'ssl' ? "ssl://{$this->host}" : $this->host;
$this->socket = @fsockopen($host, $this->port, $errno, $errstr, $this->timeout);
if (!$this->socket) {
throw new Exception("無法連線 SMTP: $errstr ($errno)");
}
stream_set_timeout($this->socket, $this->timeout);
$this->readResponse(); // 220
return true;
}
// SMTP 認證 (AUTH LOGIN)
private function authenticate() {
$this->sendCommand('AUTH LOGIN');
$this->sendCommand(base64_encode($this->username));
$this->sendCommand(base64_encode($this->password));
}
// 發送命令並檢查回應
private function sendCommand($cmd, $arg = '') {
$line = $arg ? "$cmd $arg" : $cmd;
fwrite($this->socket, $line . "\r\n");
$this->debug("> $line");
$response = $this->readResponse();
if ($response[0] >= '4') { // 4xx/5xx 錯誤
throw new Exception("SMTP 錯誤 [$cmd]: $response");
}
return $response;
}
// 發送 DATA 內容
private function sendData($data) {
fwrite($this->socket, $data . "\r\n.\r\n");
$this->debug("> [DATA] " . strlen($data) . " bytes");
$this->readResponse();
}
// 讀取 SMTP 回應
private function readResponse() {
$response = '';
while ($line = fgets($this->socket, 512)) {
$response .= $line;
$this->debug("< $line");
if (preg_match('/^\d{3} /', $line)) break;
}
return trim($response);
}
// 斷開連線
private function disconnect() {
if ($this->socket) {
@fclose($this->socket);
$this->socket = null;
}
}
// 格式化地址列表
private function formatAddresses($addresses) {
$arr = (array)$addresses;
return implode(', ', $arr);
}
// 編碼 Header (RFC 2047)
private function encodeHeader($str) {
if (preg_match('/[\x80-\xFF]/', $str)) {
return '=?UTF-8?B?' . base64_encode($str) . '?=';
}
return $str;
}
// 日誌記錄
private function log($msg) {
$line = date('Y-m-d H:i:s') . " $msg\n";
if ($this->debug) echo $line;
if ($this->logFile) {
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
}
}
// 除錯輸出
private function debug($msg) {
if ($this->debug) error_log("[SMTP] $msg");
}
}
?>
require 'SMTP.php';
$smtp = new SMTP([
'host' => 'smtp.gmail.com',
'port' => 465,
'secure' => 'ssl',
'auth' => true,
'username' => 'your@gmail.com',
'password' => 'app_password', // Gmail 應用程式密碼
'fromEmail' => 'your@gmail.com',
'fromName' => '我的系統',
'logFile' => __DIR__ . '/logs/mail.log',
'debug' => true,
]);
$success = $smtp->send(
'recipient@example.com',
'測試郵件主旨',
'HTML 內容
支援 中文 與圖片。
',
[
'cc' => 'cc@example.com',
'attachments' => [
['path' => '/path/to/report.pdf', 'name' => '月報.pdf'],
'/path/to/image.png', // 簡寫:只給路徑
],
'isHtml' => true,
'headers' => ['Reply-To' => 'reply@example.com'],
]
);
echo $success ? '發送成功' : '發送失敗';
// 修改類別加入 batchSend() 或手動控制連線
// 此處示範單發循環 (簡單但每封重新連線)
$recipients = ['a@x.com', 'b@x.com', 'c@x.com'];
foreach ($recipients as $to) {
$smtp->send($to, '批量通知', '內容...');
usleep(100000); // 避免觸發頻率限制
}
| 服務商 | SMTP Host | Port | Secure | 備註 |
|---|---|---|---|---|
| Gmail | smtp.gmail.com | 465 | ssl | 需開啟「應用程式密碼」 |
| Gmail | smtp.gmail.com | 587 | tls | STARTTLS |
| Outlook/Hotmail | smtp-mail.outlook.com | 587 | tls | |
| Office 365 | smtp.office365.com | 587 | tls | |
| 163 | smtp.163.com | 465/25 | ssl/無 | 需授權碼 |
| smtp.qq.com | 465/587 | ssl/tls | 需獨立密碼/授權碼 |
| 現象 | 原因 | 解決 |
|---|---|---|
| 連線逾時 | Port/SSL 錯誤、防火牆阻擋 | 確認 Port 465/587、telnet 測試連通性 |
| 認證失敗 | 密碼錯誤、未開啟 SMTP/應用程式密碼 | 重設授權碼、檢查帳號安全設定 |
| 進垃圾信箱 | SPF/DKIM/DMARC 未設定、內容觸發規則 | 設定域名 DNS 記錄、優化內容、使用專用 IP |
| 附件損壞/無法開啟 | 檔案路徑錯誤、base64 編碼問題 | 確認 file_exists、chunk_split(base64_encode()) |
| 中文主旨/附件名亂碼 | Header 未 RFC 2047 編碼 | 類別內建 encodeHeader() 自動處理 |
本 SMTP 類別實作完整 SMTP 協定流程,支援現代發信所需的所有核心功能。可直接複製使用,或作為理解 SMTP 運作原理的教學參考。生產環境高流量發信建議改用專業郵件 API 服務。