mail():依賴本地 MTA(sendmail/postfix),配置複雜、無 SMTP 驗證、進垃圾箱率高、不支援附件/HTMLclass SmtpMailer {
// 連接配置
private string $host;
private int $port = 25;
private int $timeout = 30;
private bool $auth = true;
private string $username;
private string $password;
private bool $ssl = false; // SSL/TLS 模式
private bool $tls = false; // STARTTLS 升級
// 運行時資源
private $socket;
private bool $connected = false;
// 日誌與調試
private bool $debug = false;
private string $logFile = '';
private string $lastError = '';
public function __construct(array $config = []) {
foreach ($config as $k => $v) {
if (property_exists($this, $k)) $this->$k = $v;
}
}
// 核心發送入口
public function send(
string|array $to,
string $from,
string $subject,
string $body,
bool $isHtml = true,
array $cc = [],
array $bcc = [],
array $attachments = []
): bool {
// 1. 建立連接
if (!$this->connect()) return false;
// 2. SMTP 會話流程
try {
$this->ehlo();
if ($this->tls) $this->startTls();
if ($this->auth) $this->authLogin();
$this->mailFrom($from);
$allRcpt = $this->rcptTo(array_merge((array)$to, $cc, $bcc));
$this->data();
$this->sendMessage($from, $to, $subject, $body, $isHtml, $cc, $bcc, $attachments);
$this->dataEnd();
$this->quit();
return true;
} catch (Exception $e) {
$this->lastError = $e->getMessage();
$this->log($e->getMessage());
return false;
} finally {
$this->close();
}
}
// ========== 底層協議實現 ==========
private function connect(): bool {
$prefix = $this->ssl ? 'ssl://' : '';
$this->socket = @fsockopen($prefix . $this->host, $this->port, $errno, $errstr, $this->timeout);
if (!$this->socket) {
throw new Exception("連接失敗: $errstr ($errno)");
}
stream_set_timeout($this->socket, $this->timeout);
return $this->readResponse(220); // 等待服務器就緒
}
private function sendCommand(string $cmd, string $args = ''): bool {
$line = $cmd . ($args ? ' ' . $args : '') . "\r\n";
$this->log(">> $line");
fwrite($this->socket, $line);
return $this->readResponse(250); // 通用成功碼
}
private function readResponse(int $expectCode = 0): bool {
$response = '';
while ($line = fgets($this->socket, 512)) {
$this->log("<< $line");
$response .= $line;
if (preg_match('/^\d{3} /', $line)) break; // 最後一行以代碼開頭
}
$code = (int)substr($response, 0, 3);
if ($expectCode && $code !== $expectCode) {
throw new Exception("SMTP 錯誤 [$code]: $response");
}
return $code >= 200 && $code < 400;
}
private function ehlo(): bool {
return $this->sendCommand('EHLO', gethostname());
}
private function startTls(): bool {
if (!$this->sendCommand('STARTTLS')) return false;
// 升級為加密流
$crypto = stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
if (!$crypto) throw new Exception('TLS 升級失敗');
return $this->ehlo(); // 重新 EHLO
}
private function authLogin(): bool {
if (!$this->sendCommand('AUTH LOGIN')) return false;
if (!$this->sendCommand(base64_encode($this->username))) return false;
return $this->sendCommand(base64_encode($this->password));
}
private function mailFrom(string $from): bool {
return $this->sendCommand('MAIL FROM', "<$from>");
}
private function rcptTo(array $recipients): bool {
foreach ($recipients as $rcpt) {
if (!$this->sendCommand('RCPT TO', "<$rcpt>")) return false;
}
return true;
}
private function data(): bool {
return $this->sendCommand('DATA', '', 354); // 354 進入數據模式
}
private function sendMessage(
string $from,
array|string $to,
string $subject,
string $body,
bool $isHtml,
array $cc,
array $bcc,
array $attachments
): void {
$headers = [];
$headers[] = "From: $from";
$headers[] = "To: " . implode(', ', (array)$to);
if ($cc) $headers[] = "Cc: " . implode(', ', $cc);
$headers[] = "Subject: $subject";
$headers[] = "Date: " . date('r');
$headers[] = "MIME-Version: 1.0";
$headers[] = "Message-ID: <" . microtime(true) . "@" . gethostname() . ">";
$headers[] = "X-Mailer: PHP SmtpMailer";
if ($attachments) {
$this->buildMultipartMessage($headers, $body, $isHtml, $attachments);
} else {
$headers[] = "Content-Type: " . ($isHtml ? 'text/html' : 'text/plain') . "; charset=UTF-8";
$headers[] = "Content-Transfer-Encoding: base64";
fwrite($this->socket, implode("\r\n", $headers) . "\r\n\r\n");
fwrite($this->socket, chunk_split(base64_encode($body)) . "\r\n");
}
}
private function buildMultipartMessage(
array &$headers,
string $body,
bool $isHtml,
array $attachments
): void {
$boundary = '----PHP_SMTP_' . md5(microtime(true));
$headers[] = "Content-Type: multipart/mixed; boundary=\"$boundary\"";
fwrite($this->socket, implode("\r\n", $headers) . "\r\n\r\n");
// 文本部分
fwrite($this->socket, "--$boundary\r\n");
fwrite($this->socket, "Content-Type: " . ($isHtml ? 'text/html' : 'text/plain') . "; charset=UTF-8\r\n");
fwrite($this->socket, "Content-Transfer-Encoding: base64\r\n\r\n");
fwrite($this->socket, chunk_split(base64_encode($body)) . "\r\n");
// 附件部分
foreach ($attachments as $att) {
[$path, $name, $mime] = $att + ['', basename($att[0]), 'application/octet-stream'];
$content = file_get_contents($path);
fwrite($this->socket, "--$boundary\r\n");
fwrite($this->socket, "Content-Type: $mime; name=\"$name\"\r\n");
fwrite($this->socket, "Content-Transfer-Encoding: base64\r\n");
fwrite($this->socket, "Content-Disposition: attachment; filename=\"$name\"\r\n\r\n");
fwrite($this->socket, chunk_split(base64_encode($content)) . "\r\n");
}
fwrite($this->socket, "--$boundary--\r\n");
}
private function dataEnd(): bool {
return $this->sendCommand('.', '', 250); // 單獨一行 . 結束
}
private function quit(): void {
@$this->sendCommand('QUIT');
}
private function close(): void {
if ($this->socket) {
fclose($this->socket);
$this->socket = null;
$this->connected = false;
}
}
// ========== 工具方法 ==========
private function log(string $msg): void {
if ($this->debug) echo $msg . "\n";
if ($this->logFile) {
error_log(date('Y-m-d H:i:s ') . $msg . "\n", 3, $this->logFile);
}
}
public function getLastError(): string {
return $this->lastError;
}
}
$mailer = new SmtpMailer([
'host' => 'smtp.qq.com',
'port' => 465,
'ssl' => true, // 隱式 SSL
'auth' => true,
'username' => 'your@qq.com',
'password' => '授權碼', // 非登錄密碼
'timeout' => 30,
'debug' => true,
'logFile' => __DIR__ . '/logs/smtp.log',
]);
$success = $mailer->send(
to: 'target@example.com',
from: 'your@qq.com',
subject: '測試郵件 ' . date('H:i:s'),
body: 'HTML 郵件測試
當前時間:' . date('Y-m-d H:i:s') . '
',
isHtml: true,
cc: ['cc1@example.com'],
attachments: [
[__DIR__ . '/report.pdf', 'monthly_report.pdf', 'application/pdf'],
[__DIR__ . '/logo.png', 'logo.png', 'image/png'],
]
);
if ($success) {
echo "發送成功";
} else {
echo "失敗: " . $mailer->getLastError();
}
| 現象 | 原因 | 解決 |
|---|---|---|
| 連接超時 | 端口被防火牆攔截、SSL/TLS 模式錯誤 | telnet 測試端口、嘗試 25/465/587、確認 ssl/tls 參數 |
| 認證失敗 | 密碼錯誤、未開啟 SMTP 服務、需授權碼 | 登錄郵箱網頁版開啟 SMTP、生成授權碼 |
| 進垃圾箱 | 缺少 SPF/DKIM/DMARC、IP 信譽低、內容觸發規則 | 配置域名解析、使用可信發信 IP、優化內容、預熱 IP |
| 中文亂碼 | 編碼聲明缺失 | 確保 Subject/Base64 編碼、Content-Type charset=UTF-8 |