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: }, ...] * 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; } }