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

PHP 實現 POP3 郵件收取完整實戰:從協議解析到生產級封裝

     編輯:PHP 相關知識

前言

POP3(Post Office Protocol v3,RFC 1939)是最經典的郵件接收協議。雖然現代應用多採用 IMAP,但 POP3 以其簡單、輕量、離線讀取模式,仍在郵件備份、遷移、自動化處理、IoT 設備通知等場景中發揮不可替代的作用。本文將基於原始 Socket 實現完整的 POP3 客戶端類,涵蓋協議流程、MIME 解析、附件處理、錯誤重試等生產級細節。

POP3 協議核心流程

+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

生產級 POP3 客戶端類實現

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 多部分解析與附件提取

原始郵件為 MIME 格式(RFC 2045/2046),包含:

  • multipart/alternative:純文本 + HTML 版本
  • multipart/mixed:正文 + 附件
  • multipart/related:HTML + 內嵌圖片

建議採用成熟庫而非手寫解析器:

  • php-mime-mail-parser:`composer require php-mime-mail-parser/php-mime-mail-parser`
  • laminas/laminas-mail:企業級組件,支持 POP3/IMAP/SMTP 統一抽象
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);
}

SSL/TLS 安全連接實現

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 流上的行緩衝行為

常見坑與最佳實踐

問題原因解決
連接超時防火牆攔截、端口錯誤、服務器不支持 SSLtelnet 測試連通性、確認端口(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 等成熟組件,站在巨人肩膀上構建業務邏輯。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved