473 lines
17 KiB
PHP
473 lines
17 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Minimal pure-PHP IMAP4rev1 client.
|
|
*
|
|
* Supports:
|
|
* - SSL/TLS connections (imaps://, port 993)
|
|
* - LOGIN, SELECT, UID SEARCH, UID FETCH, LOGOUT
|
|
* - MIME message body extraction (text/plain preferred, HTML fallback)
|
|
* - Quoted-printable + base64 decoding
|
|
* - MIME-encoded header decoding (=?utf-8?B?...?=)
|
|
*
|
|
* NOT supported (deferred for MVP): STARTTLS, IDLE, threads, attachments
|
|
* download, server-side search, multiple folders.
|
|
*
|
|
* Errors throw RuntimeException with a friendly message.
|
|
*/
|
|
class ImapClient
|
|
{
|
|
private $sock = null;
|
|
private int $tag = 0;
|
|
private string $host;
|
|
private int $port;
|
|
private string $encryption; // 'ssl' | 'tls' | 'none'
|
|
private int $timeout;
|
|
|
|
public function __construct(string $host, int $port = 993, string $encryption = 'ssl', int $timeout = 15)
|
|
{
|
|
$this->host = $host;
|
|
$this->port = $port;
|
|
$this->encryption = $encryption;
|
|
$this->timeout = $timeout;
|
|
}
|
|
|
|
public function connect(): void
|
|
{
|
|
$scheme = $this->encryption === 'ssl' ? 'ssl' : 'tcp';
|
|
$url = $scheme . '://' . $this->host . ':' . $this->port;
|
|
$ctx = stream_context_create([
|
|
'ssl' => [
|
|
'verify_peer' => true,
|
|
'verify_peer_name' => true,
|
|
'allow_self_signed' => false,
|
|
],
|
|
]);
|
|
$errno = 0; $errstr = '';
|
|
$this->sock = @stream_socket_client($url, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, $ctx);
|
|
if (!$this->sock) {
|
|
throw new RuntimeException("Connect failed: $errstr ($errno)");
|
|
}
|
|
stream_set_timeout($this->sock, $this->timeout);
|
|
// Read greeting
|
|
$greet = $this->readLine();
|
|
if (strpos($greet, '* OK') !== 0 && strpos($greet, '* PREAUTH') !== 0) {
|
|
throw new RuntimeException('Bad server greeting: ' . trim($greet));
|
|
}
|
|
}
|
|
|
|
public function login(string $user, string $pass): void
|
|
{
|
|
// Quote string literals to be safe
|
|
$user = $this->quoteString($user);
|
|
$pass = $this->quoteString($pass);
|
|
$resp = $this->command("LOGIN $user $pass");
|
|
if (!$resp['ok']) {
|
|
throw new RuntimeException('LOGIN failed: ' . $resp['text']);
|
|
}
|
|
}
|
|
|
|
public function selectFolder(string $folder = 'INBOX'): array
|
|
{
|
|
$folder = $this->quoteString($folder);
|
|
$resp = $this->command("SELECT $folder");
|
|
if (!$resp['ok']) {
|
|
throw new RuntimeException('SELECT failed: ' . $resp['text']);
|
|
}
|
|
// Parse EXISTS count from untagged responses
|
|
$exists = 0;
|
|
foreach ($resp['untagged'] as $line) {
|
|
if (preg_match('/^\* (\d+) EXISTS/', $line, $m)) {
|
|
$exists = (int)$m[1];
|
|
}
|
|
}
|
|
return ['exists' => $exists];
|
|
}
|
|
|
|
/** Search returns UIDs of all messages matching criteria. */
|
|
public function uidSearch(string $criteria = 'ALL'): array
|
|
{
|
|
$resp = $this->command("UID SEARCH $criteria");
|
|
if (!$resp['ok']) {
|
|
throw new RuntimeException('SEARCH failed: ' . $resp['text']);
|
|
}
|
|
$uids = [];
|
|
foreach ($resp['untagged'] as $line) {
|
|
if (preg_match('/^\* SEARCH(.*)$/i', $line, $m)) {
|
|
preg_match_all('/\d+/', $m[1], $nums);
|
|
foreach ($nums[0] as $n) $uids[] = (int)$n;
|
|
}
|
|
}
|
|
return $uids;
|
|
}
|
|
|
|
/**
|
|
* Fetch envelope-like headers + spam-relevant fields for a list of UIDs.
|
|
* Returns [{uid, subject, from, date, seen, headers: <full-parsed-map>}, ...]
|
|
* The `headers` map contains lowercased keys ready for SpamDetector.
|
|
*/
|
|
public function fetchHeaders(array $uids): array
|
|
{
|
|
if (!$uids) return [];
|
|
sort($uids);
|
|
$set = $this->compactUidSet($uids);
|
|
// Pull more fields so SpamDetector has signals to work with
|
|
$fields = 'FROM SUBJECT DATE REPLY-TO MESSAGE-ID '
|
|
. 'X-SPAM-FLAG X-SPAM-STATUS X-SPAM-SCORE '
|
|
. 'AUTHENTICATION-RESULTS RECEIVED-SPF '
|
|
. 'LIST-UNSUBSCRIBE';
|
|
$resp = $this->command("UID FETCH $set (UID FLAGS BODY[HEADER.FIELDS ($fields)])");
|
|
if (!$resp['ok']) {
|
|
throw new RuntimeException('FETCH headers failed: ' . $resp['text']);
|
|
}
|
|
$messages = [];
|
|
foreach ($resp['fetches'] as $entry) {
|
|
$msg = [
|
|
'uid' => null,
|
|
'subject' => '',
|
|
'from' => '',
|
|
'date' => '',
|
|
'seen' => false,
|
|
'headers' => [],
|
|
];
|
|
if (preg_match('/UID (\d+)/', $entry['preamble'], $m)) $msg['uid'] = (int)$m[1];
|
|
if (preg_match('/FLAGS \(([^)]*)\)/', $entry['preamble'], $m)) {
|
|
$msg['seen'] = stripos($m[1], '\\Seen') !== false;
|
|
}
|
|
$headers = $this->parseHeaders($entry['body']);
|
|
// Decode MIME-encoded fields used by the UI
|
|
$msg['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
|
|
$msg['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
|
|
$msg['date'] = $headers['date'] ?? '';
|
|
// Pass the decoded versions back into the header map so SpamDetector
|
|
// sees the same strings the user sees
|
|
$headers['subject'] = $msg['subject'];
|
|
$headers['from'] = $msg['from'];
|
|
if (!empty($headers['reply-to'])) {
|
|
$headers['reply-to'] = $this->decodeMimeHeader($headers['reply-to']);
|
|
}
|
|
$msg['headers'] = $headers;
|
|
if ($msg['uid']) $messages[] = $msg;
|
|
}
|
|
return $messages;
|
|
}
|
|
|
|
/**
|
|
* Fetch full message body and parse it.
|
|
* Returns ['headers' => [...], 'text' => '...', 'html' => '...', 'attachments' => [...]]
|
|
*/
|
|
public function fetchMessage(int $uid): array
|
|
{
|
|
$resp = $this->command("UID FETCH $uid (UID BODY.PEEK[])");
|
|
if (!$resp['ok']) {
|
|
throw new RuntimeException('FETCH body failed: ' . $resp['text']);
|
|
}
|
|
if (!$resp['fetches']) {
|
|
throw new RuntimeException('Message not found');
|
|
}
|
|
$raw = $resp['fetches'][0]['body'];
|
|
return $this->parseMessage($raw);
|
|
}
|
|
|
|
/**
|
|
* Mark a list of UIDs as deleted (\Deleted flag) and expunge them.
|
|
* After expunge, those messages are gone from the server permanently.
|
|
* Returns the number of UIDs that were processed.
|
|
*/
|
|
public function deleteUids(array $uids): int
|
|
{
|
|
if (!$uids) return 0;
|
|
$set = $this->compactUidSet($uids);
|
|
$resp = $this->command("UID STORE $set +FLAGS (\\Deleted)");
|
|
if (!$resp['ok']) {
|
|
throw new RuntimeException('STORE \\Deleted failed: ' . $resp['text']);
|
|
}
|
|
$resp = $this->command('EXPUNGE');
|
|
if (!$resp['ok']) {
|
|
throw new RuntimeException('EXPUNGE failed: ' . $resp['text']);
|
|
}
|
|
return count($uids);
|
|
}
|
|
|
|
public function logout(): void
|
|
{
|
|
if ($this->sock) {
|
|
try { @$this->command('LOGOUT'); } catch (\Throwable $e) {}
|
|
@fclose($this->sock);
|
|
$this->sock = null;
|
|
}
|
|
}
|
|
|
|
public function __destruct()
|
|
{
|
|
$this->logout();
|
|
}
|
|
|
|
// ── Low-level protocol ─────────────────────────────────────────
|
|
|
|
private function nextTag(): string
|
|
{
|
|
$this->tag++;
|
|
return 'A' . str_pad((string)$this->tag, 4, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
private function writeLine(string $line): void
|
|
{
|
|
if (fwrite($this->sock, $line . "\r\n") === false) {
|
|
throw new RuntimeException('Socket write failed');
|
|
}
|
|
}
|
|
|
|
private function readLine(): string
|
|
{
|
|
$line = fgets($this->sock);
|
|
if ($line === false) {
|
|
throw new RuntimeException('Socket read failed (timeout?)');
|
|
}
|
|
return $line;
|
|
}
|
|
|
|
private function readBytes(int $n): string
|
|
{
|
|
$buf = '';
|
|
while (strlen($buf) < $n) {
|
|
$chunk = fread($this->sock, $n - strlen($buf));
|
|
if ($chunk === false || $chunk === '') {
|
|
throw new RuntimeException('Socket read truncated');
|
|
}
|
|
$buf .= $chunk;
|
|
}
|
|
return $buf;
|
|
}
|
|
|
|
/**
|
|
* Send a command and read all response lines until tagged completion.
|
|
* Handles literal continuations ({N}\r\n followed by N bytes).
|
|
* Returns ['ok' => bool, 'text' => string, 'untagged' => [...], 'fetches' => [...]]
|
|
*/
|
|
private function command(string $cmd): array
|
|
{
|
|
$tag = $this->nextTag();
|
|
$this->writeLine("$tag $cmd");
|
|
|
|
$untagged = [];
|
|
$fetches = []; // [{preamble, body}]
|
|
$okResult = null;
|
|
$okText = '';
|
|
|
|
while (true) {
|
|
$line = $this->readLine();
|
|
// Detect literal: {N}\r\n at end → read N more bytes, then continue
|
|
$literalBuf = '';
|
|
while (preg_match('/\{(\d+)\}\r?\n$/', $line, $m)) {
|
|
$bytes = (int)$m[1];
|
|
$literalBuf .= $this->readBytes($bytes);
|
|
$next = $this->readLine();
|
|
$line = rtrim($line, "\r\n") . "\x00LIT\x00" . $literalBuf . "\x00LITEND\x00" . $next;
|
|
$literalBuf = '';
|
|
}
|
|
|
|
// Tagged response check
|
|
if (strpos($line, $tag . ' ') === 0) {
|
|
$rest = substr($line, strlen($tag) + 1);
|
|
$okResult = (strpos($rest, 'OK') === 0);
|
|
$okText = trim($rest);
|
|
break;
|
|
}
|
|
|
|
// Untagged
|
|
if (strpos($line, '* ') === 0) {
|
|
$untagged[] = trim($line);
|
|
// Detect FETCH responses with literal body
|
|
if (preg_match('/^\* \d+ FETCH /', $line)) {
|
|
if (preg_match('/\x00LIT\x00(.*?)\x00LITEND\x00/s', $line, $m)) {
|
|
$body = $m[1];
|
|
$preamble = preg_replace('/\x00LIT\x00.*?\x00LITEND\x00.*$/s', '', $line);
|
|
$fetches[] = ['preamble' => $preamble, 'body' => $body];
|
|
} else {
|
|
// No literal, body might be inline (small messages)
|
|
$fetches[] = ['preamble' => $line, 'body' => ''];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ['ok' => $okResult, 'text' => $okText, 'untagged' => $untagged, 'fetches' => $fetches];
|
|
}
|
|
|
|
private function quoteString(string $s): string
|
|
{
|
|
// Simple quoting; passwords with quotes are escaped
|
|
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $s) . '"';
|
|
}
|
|
|
|
private function compactUidSet(array $uids): string
|
|
{
|
|
// For simplicity, just send a comma list (most servers accept this).
|
|
// Could be optimized to ranges (1:5,10,15:20).
|
|
return implode(',', $uids);
|
|
}
|
|
|
|
// ── MIME parsing ───────────────────────────────────────────────
|
|
|
|
private function parseHeaders(string $raw): array
|
|
{
|
|
$headers = [];
|
|
$lines = preg_split('/\r?\n/', $raw);
|
|
$current = null;
|
|
foreach ($lines as $line) {
|
|
if ($line === '') break;
|
|
if (preg_match('/^([!-9;-~]+):\s*(.*)$/', $line, $m)) {
|
|
$current = strtolower($m[1]);
|
|
$headers[$current] = $m[2];
|
|
} elseif ($current !== null && preg_match('/^[ \t]/', $line)) {
|
|
// Continuation
|
|
$headers[$current] .= ' ' . trim($line);
|
|
}
|
|
}
|
|
return $headers;
|
|
}
|
|
|
|
public function decodeMimeHeader(string $value): string
|
|
{
|
|
// Decode =?charset?B?...?= or =?charset?Q?...?=
|
|
return preg_replace_callback(
|
|
'/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/',
|
|
function ($m) {
|
|
$charset = strtoupper($m[1]);
|
|
$enc = strtoupper($m[2]);
|
|
$data = $m[3];
|
|
if ($enc === 'B') {
|
|
$decoded = base64_decode($data);
|
|
} else { // Q
|
|
$decoded = quoted_printable_decode(str_replace('_', ' ', $data));
|
|
}
|
|
if ($charset !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
|
}
|
|
return $decoded;
|
|
},
|
|
$value
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse a full RFC 822 message into headers + plain/html bodies.
|
|
* Returns ['headers' => [...], 'text' => string, 'html' => string]
|
|
*/
|
|
public function parseMessage(string $raw): array
|
|
{
|
|
// Split header / body at first blank line
|
|
$pos = strpos($raw, "\r\n\r\n");
|
|
if ($pos === false) $pos = strpos($raw, "\n\n");
|
|
$headerRaw = $pos !== false ? substr($raw, 0, $pos) : $raw;
|
|
$bodyRaw = $pos !== false ? substr($raw, $pos + ($raw[$pos] === "\r" ? 4 : 2)) : '';
|
|
|
|
$headers = $this->parseHeaders($headerRaw);
|
|
$headers['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
|
|
$headers['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
|
|
$headers['to'] = $this->decodeMimeHeader($headers['to'] ?? '');
|
|
|
|
$contentType = $headers['content-type'] ?? 'text/plain';
|
|
$encoding = strtolower($headers['content-transfer-encoding'] ?? '7bit');
|
|
|
|
$text = '';
|
|
$html = '';
|
|
|
|
if (stripos($contentType, 'multipart/') === 0) {
|
|
$boundary = $this->extractBoundary($contentType);
|
|
if ($boundary) {
|
|
[$text, $html] = $this->parseMultipart($bodyRaw, $boundary);
|
|
}
|
|
} else {
|
|
$decoded = $this->decodeBody($bodyRaw, $encoding);
|
|
$charset = $this->extractCharset($contentType);
|
|
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
|
}
|
|
if (stripos($contentType, 'text/html') !== false) {
|
|
$html = $decoded;
|
|
} else {
|
|
$text = $decoded;
|
|
}
|
|
}
|
|
|
|
return ['headers' => $headers, 'text' => $text, 'html' => $html];
|
|
}
|
|
|
|
private function parseMultipart(string $body, string $boundary): array
|
|
{
|
|
$parts = explode('--' . $boundary, $body);
|
|
// First chunk before first boundary, last chunk has -- closing — discard
|
|
array_shift($parts);
|
|
array_pop($parts);
|
|
|
|
$text = '';
|
|
$html = '';
|
|
|
|
foreach ($parts as $part) {
|
|
$part = ltrim($part, "\r\n");
|
|
$pos = strpos($part, "\r\n\r\n");
|
|
if ($pos === false) $pos = strpos($part, "\n\n");
|
|
if ($pos === false) continue;
|
|
$partHeaderRaw = substr($part, 0, $pos);
|
|
$partBody = substr($part, $pos + ($part[$pos] === "\r" ? 4 : 2));
|
|
$partHeaders = $this->parseHeaders($partHeaderRaw);
|
|
$ct = $partHeaders['content-type'] ?? 'text/plain';
|
|
$cte = strtolower($partHeaders['content-transfer-encoding'] ?? '7bit');
|
|
|
|
if (stripos($ct, 'multipart/') === 0) {
|
|
$innerBoundary = $this->extractBoundary($ct);
|
|
if ($innerBoundary) {
|
|
[$nestedText, $nestedHtml] = $this->parseMultipart($partBody, $innerBoundary);
|
|
if ($nestedText && !$text) $text = $nestedText;
|
|
if ($nestedHtml && !$html) $html = $nestedHtml;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$decoded = $this->decodeBody($partBody, $cte);
|
|
$charset = $this->extractCharset($ct);
|
|
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
|
}
|
|
|
|
if (stripos($ct, 'text/plain') !== false && !$text) {
|
|
$text = $decoded;
|
|
} elseif (stripos($ct, 'text/html') !== false && !$html) {
|
|
$html = $decoded;
|
|
}
|
|
}
|
|
|
|
return [$text, $html];
|
|
}
|
|
|
|
private function decodeBody(string $body, string $encoding): string
|
|
{
|
|
switch ($encoding) {
|
|
case 'base64':
|
|
return base64_decode(preg_replace('/\s+/', '', $body)) ?: '';
|
|
case 'quoted-printable':
|
|
return quoted_printable_decode($body);
|
|
default:
|
|
return $body;
|
|
}
|
|
}
|
|
|
|
private function extractBoundary(string $contentType): ?string
|
|
{
|
|
if (preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
|
|
return $m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function extractCharset(string $contentType): ?string
|
|
{
|
|
if (preg_match('/charset\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
|
|
return $m[1];
|
|
}
|
|
return null;
|
|
}
|
|
}
|