POP3(Post Office Protocol v3,RFC 1939)是最經典的郵件接收協議。雖然現代應用多採用 IMAP,但 POP3 以其簡單、輕量、離線讀取模式,仍在郵件備份、遷移、自動化處理、IoT 設備通知等場景中發揮不可替代的作用。本文將基於原始 Socket 實現完整的 POP3 客戶端類,涵蓋協議流程、MIME 解析、附件處理、錯誤重試等生產級細節。
+OK POP3 server ready
USER username
+OK User name accepted
PASS password
+OK Mailbox locked and ready
STAT
+OK 3 34567
LIST
+OK 3 messages
1 1200
2 2345
3 12345
RETR 1
+OK 1200 octets
...message data...
.
QUIT
+OK Sayonara
1, 'size'=>1200, 'uidl'=>'...'], ...]
public array $headers = [];
public array $body = [];
public string $error = '';
public function __construct(string $host, int $port = 110, int $timeout = 30, bool $debug = false) {
$this->host = $host;
$this->port = $port;
$this->timeout = $timeout;
$this->debug = $debug;
}
public function connect(): bool {
$this->socket = @fsockopen($this->host, $this->port, $errno, $errstr, $this->timeout);
if (!$this->socket) {
$this->error = "連接失敗 [$errno]: $errstr";
return false;
}
stream_set_timeout($this->socket, $this->timeout);
return $this->readResponse() !== false;
}
public function login(string $user, string $pass): bool {
return $this->sendCommand("USER $user") && $this->sendCommand("PASS $pass");
}
public function stat(): bool {
if (!$this->sendCommand('STAT')) return false;
// +OK 3 34567
if (preg_match('/^\+OK\s+(\d+)\s+(\d+)/', $this->lastResponse, $m)) {
$this->messageCount = (int)$m[1];
$this->totalSize = (int)$m[2];
return true;
}
$this->error = 'STAT 響應解析失敗: ' . $this->lastResponse;
return false;
}
public function listMessages(): bool {
if (!$this->sendCommand('LIST')) return false;
$this->messages = [];
while (($line = $this->readLine()) !== false) {
if ($line === '.') break;
if (preg_match('/^(\d+)\s+(\d+)$/', trim($line), $m)) {
$this->messages[] = ['num' => (int)$m[1], 'size' => (int)$m[2]];
}
}
return true;
}
public function retrieve(int $msgNum): bool {
if (!$this->sendCommand("RETR $msgNum")) return false;
$this->headers = [];
$this->body = [];
$inHeader = true;
while (($line = $this->readLine()) !== false) {
if ($line === '.') break;
if ($inHeader) {
if ($line === '') { $inHeader = false; continue; }
$this->headers[] = $line;
} else {
$this->body[] = $line;
}
}
return true;
}
public function delete(int $msgNum): bool {
return $this->sendCommand("DELE $msgNum");
}
public function quit(): bool {
$ok = $this->sendCommand('QUIT');
$this->close();
return $ok;
}
public function close(): void {
if ($this->socket) {
@fclose($this->socket);
$this->socket = null;
}
}
private function sendCommand(string $cmd): bool {
if ($this->debug) error_log("POP3 >>> $cmd");
if (fwrite($this->socket, "$cmd\r\n") === false) {
$this->error = '發送命令失敗';
return false;
}
return $this->readResponse() !== false;
}
private function readResponse(): ?string {
$this->lastResponse = $this->readLine();
if ($this->lastResponse === false) {
$this->error = '讀取響應超時或連接斷開';
return null;
}
if ($this->debug) error_log("POP3 <<< {$this->lastResponse}");
return $this->lastResponse;
}
private function readLine(): ?string {
$line = @fgets($this->socket, 8192);
if ($line === false || $line === '') return false;
return rtrim($line, "\r\n");
}
// MIME 簡易解析:提取 Subject、From、Content-Type、Boundary 等
public function parseHeaders(): array {
$parsed = [];
$currentKey = '';
foreach ($this->headers as $line) {
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $m)) {
$currentKey = strtolower($m[1]);
$parsed[$currentKey][] = $m[2];
} elseif ($currentKey && preg_match('/^\s+(.*)$/', $line, $m)) {
$parsed[$currentKey][count($parsed[$currentKey])-1] .= ' ' . $m[1];
}
}
return $parsed;
}
}
connect()) die("連接失敗: {$client->error}");
if (!$client->login('user@example.com', 'password')) die("登錄失敗: {$client->error}");
if (!$client->stat()) die("STAT 失敗: {$client->error}");
echo "郵件數: {$client->messageCount}, 總大小: {$client->totalSize} bytes\n";
if (!$client->listMessages()) die("LIST 失敗: {$client->error}");
foreach ($client->messages as $msg) {
echo "郵件 #{$msg['num']} 大小: {$msg['size']} bytes\n";
}
// 讀取最新一封
$latest = end($client->messages);
if ($latest && $client->retrieve($latest['num'])) {
$headers = $client->parseHeaders();
echo "主題: " . ($headers['subject'][0] ?? '(無)') . "\n";
echo "發件人: " . ($headers['from'][0] ?? '(無)') . "\n";
echo "正文預覽: " . implode("\n", array_slice($client->body, 0, 5)) . "\n";
}
$client->quit();
原始郵件為 MIME 格式(RFC 2045/2046),包含:
multipart/alternative:純文本 + HTML 版本multipart/mixed:正文 + 附件multipart/related:HTML + 內嵌圖片建議採用成熟庫而非手寫解析器:
use PhpMimeMailParser\Parser;
$parser = new Parser();
$parser->setText($rawMessage); // $rawMessage 來自 RETR 獲取的完整原文
$subject = $parser->getHeader('subject');
$from = $parser->getHeader('from');
$text = $parser->getMessageBody('text');
$html = $parser->getMessageBody('html');
$attachments = $parser->getAttachments();
foreach ($attachments as $att) {
$filename = $att->getFilename();
$content = $att->getContent();
file_put_contents("/storage/attachments/$filename", $content);
}
fsockopen 不支持 SSL,必須使用 stream_socket_client:
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'cafile' => '/etc/ssl/certs/ca-certificates.crt',
]
]);
$socket = stream_socket_client(
"ssl://pop.example.com:995",
$errno, $errstr, 30,
STREAM_CLIENT_CONNECT, $ctx
);
if (!$socket) throw new Exception("SSL 連接失敗: $errstr");
stream_set_timeout($socket, 30);
// 後續收發同 fsockopen,但需注意 fgets 在 SSL 流上的行緩衝行為
| 問題 | 原因 | 解決 |
|---|---|---|
| 連接超時 | 防火牆攔截、端口錯誤、服務器不支持 SSL | telnet 測試連通性、確認端口(110/995)、抓包分析 |
| 認證失敗 | 密碼錯誤、賬戶鎖定、需應用專用密碼、IP 白名單 | 啟用調試模式查看交互、檢查郵箱服務商文檔 |
| 中文亂碼 | 郵件編碼(GB2312/UTF-8/Base64/Quoted-Printable)未正確解碼 | 使用 MIME 解析庫自動處理、手動 iconv/mb_convert_encoding |
| 大郵件截斷 | fgets 默認 8192 字節、PHP 內存限制 | 增大緩衝區、分塊讀取、設置 memory_limit |
| 刪除未生效 | POP3 DELE 標記刪除,需 QUIT 確認提交;RSET 可撤銷 | 確保正常 QUIT、異常時不發送 QUIT 保留郵件 |
POP3 雖老,但其「下載並刪除/保留」的簡單語義在自動化郵件處理、備份歸檔、IoT 通知等場景極具價值。掌握 Socket 層協議交互、MIME 解析、SSL 安全連接、異常重試等核心技能,能讓你在無 IMAP 權限、無圖形界面的服務器環境中靈活處理郵件數據。建議生產項目直接採用 laminas/laminas-mail 或 php-mime-mail-parser 等成熟組件,站在巨人肩膀上構建業務邏輯。