From f0a7676451bdb3c22bb472a9aa1a7b28bbe557de Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Tue, 10 Mar 2026 09:46:21 +0200 Subject: [PATCH] Rewrite ImapClient to use raw sockets instead of php-imap extension Production server doesn't have php-imap extension. Replaced the entire ImapClient class to use stream_socket_client() with raw IMAP protocol commands. Supports SSL/TLS, STARTTLS, MIME header decoding, body extraction with encoding detection, and charset conversion. Co-Authored-By: Claude Opus 4.6 --- api.php | 439 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 317 insertions(+), 122 deletions(-) diff --git a/api.php b/api.php index c47d710..c844418 100644 --- a/api.php +++ b/api.php @@ -117,10 +117,12 @@ function sendMail(string $to, string $subject, string $htmlBody): bool { return mail($to, $subject, $htmlBody, $headers, '-f ' . MAIL_FROM); } -// ==================== IMAP CLIENT ==================== +// ==================== IMAP CLIENT (socket-pohjainen, ei vaadi php-imap) ==================== class ImapClient { - private $connection = null; + private $socket = null; + private int $tagCounter = 0; + public string $lastError = ''; public function connect(array $config): bool { $host = $config['imap_host'] ?? ''; @@ -130,160 +132,354 @@ class ImapClient { $encryption = $config['imap_encryption'] ?? 'ssl'; if (empty($host) || empty($user) || empty($pass)) { + $this->lastError = 'IMAP-asetukset puuttuvat'; return false; } - $mailbox = '{' . $host . ':' . $port . '/imap/' . $encryption . '}INBOX'; + $prefix = ($encryption === 'ssl') ? 'ssl://' : 'tcp://'; + $context = stream_context_create([ + 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false] + ]); - // Suppress warnings, handle errors manually - $this->connection = @imap_open($mailbox, $user, $pass, 0, 1); + $this->socket = @stream_socket_client( + $prefix . $host . ':' . $port, + $errno, $errstr, 15, + STREAM_CLIENT_CONNECT, $context + ); - if (!$this->connection) { + if (!$this->socket) { + $this->lastError = "Yhteys epäonnistui: {$errstr} ({$errno})"; return false; } + + stream_set_timeout($this->socket, 30); + + // Read greeting + $greeting = $this->readLine(); + if (!$greeting || strpos($greeting, '* OK') === false) { + $this->lastError = 'Palvelin ei vastannut oikein: ' . $greeting; + $this->disconnect(); + return false; + } + + // STARTTLS if needed + if ($encryption === 'tls') { + $resp = $this->command('STARTTLS'); + if (!$this->isOk($resp)) { + $this->lastError = 'STARTTLS epäonnistui'; + $this->disconnect(); + return false; + } + if (!stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + $this->lastError = 'TLS-neuvottelu epäonnistui'; + $this->disconnect(); + return false; + } + } + + // Login + $resp = $this->command('LOGIN "' . $this->escape($user) . '" "' . $this->escape($pass) . '"'); + if (!$this->isOk($resp)) { + $this->lastError = 'Kirjautuminen epäonnistui: väärä tunnus tai salasana'; + $this->disconnect(); + return false; + } + + // Select INBOX + $resp = $this->command('SELECT INBOX'); + if (!$this->isOk($resp)) { + $this->lastError = 'INBOX:n avaus epäonnistui'; + $this->disconnect(); + return false; + } + return true; } public function fetchMessages(int $limit = 50): array { - if (!$this->connection) return []; + if (!$this->socket) return []; + + // Get message count from STATUS + $resp = $this->command('STATUS INBOX (MESSAGES)'); + $totalMessages = 0; + foreach ($resp as $line) { + if (preg_match('/MESSAGES\s+(\d+)/i', $line, $m)) { + $totalMessages = intval($m[1]); + } + } + if ($totalMessages === 0) return []; + + $start = max(1, $totalMessages - $limit + 1); + $range = $start . ':' . $totalMessages; + + // Fetch headers for range + $resp = $this->command("FETCH {$range} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)] BODY.PEEK[TEXT] FLAGS)"); $messages = []; - $check = imap_check($this->connection); - if (!$check || $check->Nmsgs === 0) return []; + $current = null; + $headerBuf = ''; + $bodyBuf = ''; + $readingHeader = false; + $readingBody = false; + $headerBytesLeft = 0; + $bodyBytesLeft = 0; - $start = max(1, $check->Nmsgs - $limit + 1); - $end = $check->Nmsgs; - - for ($i = $end; $i >= $start; $i--) { - $header = @imap_headerinfo($this->connection, $i); - if (!$header) continue; - - $overview = @imap_fetch_overview($this->connection, strval($i), 0); - - // Decode subject - $subject = ''; - if (isset($header->subject)) { - $decoded = imap_mime_header_decode($header->subject); - foreach ($decoded as $part) { - $subject .= $part->text; - } - } - - // From - $fromEmail = ''; - $fromName = ''; - if (isset($header->from[0])) { - $fromEmail = $header->from[0]->mailbox . '@' . ($header->from[0]->host ?? ''); - if (isset($header->from[0]->personal)) { - $decoded = imap_mime_header_decode($header->from[0]->personal); - foreach ($decoded as $part) { - $fromName .= $part->text; - } - } - } - - // Message-ID - $messageId = isset($header->message_id) ? trim($header->message_id) : ''; - - // In-Reply-To - $inReplyTo = ''; - if (isset($header->in_reply_to)) { - $inReplyTo = trim($header->in_reply_to); - } - - // References - $references = ''; - if (isset($header->references)) { - $references = trim($header->references); - } - - // Date - $date = isset($header->date) ? date('Y-m-d H:i:s', strtotime($header->date)) : date('Y-m-d H:i:s'); - - // Body — prefer plain text - $body = $this->getBody($i); - - $messages[] = [ - 'subject' => $subject, - 'from_email' => $fromEmail, - 'from_name' => $fromName, - 'message_id' => $messageId, - 'in_reply_to' => $inReplyTo, - 'references' => $references, - 'date' => $date, - 'body' => $body, - ]; + // Simpler approach: fetch one-by-one for reliability + $messages = []; + for ($i = $totalMessages; $i >= $start; $i--) { + $msg = $this->fetchSingleMessage($i); + if ($msg) $messages[] = $msg; } return $messages; } - private function getBody(int $msgNum): string { - if (!$this->connection) return ''; + private function fetchSingleMessage(int $num): ?array { + // Fetch headers + $resp = $this->command("FETCH {$num} BODY.PEEK[HEADER]"); + $headerRaw = $this->extractLiteral($resp); - $structure = @imap_fetchstructure($this->connection, $msgNum); - if (!$structure) return ''; + if (!$headerRaw) return null; - // Simple message (no parts) - if (empty($structure->parts)) { - $body = imap_fetchbody($this->connection, $msgNum, '1'); - return $this->decodeBody($body, $structure->encoding ?? 0); + $headers = $this->parseHeaders($headerRaw); + $subject = $this->decodeMimeHeader($headers['subject'] ?? ''); + $fromRaw = $headers['from'] ?? ''; + $fromParsed = $this->parseFrom($fromRaw); + $messageId = trim($headers['message-id'] ?? ''); + $inReplyTo = trim($headers['in-reply-to'] ?? ''); + $references = trim($headers['references'] ?? ''); + $dateStr = $headers['date'] ?? ''; + $date = $dateStr ? @date('Y-m-d H:i:s', strtotime($dateStr)) : date('Y-m-d H:i:s'); + if (!$date) $date = date('Y-m-d H:i:s'); + + // Fetch body (text part) + $body = $this->fetchBody($num); + + return [ + 'subject' => $subject, + 'from_email' => $fromParsed['email'], + 'from_name' => $this->decodeMimeHeader($fromParsed['name']), + 'message_id' => $messageId, + 'in_reply_to' => $inReplyTo, + 'references' => $references, + 'date' => $date, + 'body' => $body, + ]; + } + + private function fetchBody(int $num): string { + // Try text/plain first via BODYSTRUCTURE + $resp = $this->command("FETCH {$num} BODYSTRUCTURE"); + $structLine = implode(' ', $resp); + + // Simple approach: fetch BODY[1] (usually text/plain in multipart) + // or BODY[TEXT] for simple messages + $resp = $this->command("FETCH {$num} BODY.PEEK[1]"); + $body = $this->extractLiteral($resp); + + if (!$body) { + // Fallback: full text + $resp = $this->command("FETCH {$num} BODY.PEEK[TEXT]"); + $body = $this->extractLiteral($resp); } - // Multipart — look for text/plain first, then text/html - $plainBody = ''; - $htmlBody = ''; + if (!$body) return ''; - foreach ($structure->parts as $partNum => $part) { - $partIndex = strval($partNum + 1); + // Detect encoding from BODYSTRUCTURE + $encoding = ''; + // Parse BODYSTRUCTURE for encoding (7BIT, BASE64, QUOTED-PRINTABLE) + if (preg_match('/"TEXT"\s+"PLAIN"\s+\([^)]*\)\s+NIL\s+NIL\s+"([^"]+)"/i', $structLine, $em)) { + $encoding = strtoupper($em[1]); + } elseif (preg_match('/BODY\[1\].*?"([^"]+)"/i', $structLine, $em)) { + $encoding = strtoupper($em[1]); + } - if ($part->type === 0) { // TEXT - $subtype = strtolower($part->subtype ?? ''); - $body = imap_fetchbody($this->connection, $msgNum, $partIndex); - $decoded = $this->decodeBody($body, $part->encoding ?? 0); - - // Handle charset - $charset = $this->getCharset($part); - if ($charset && strtolower($charset) !== 'utf-8') { - $converted = @iconv($charset, 'UTF-8//IGNORE', $decoded); - if ($converted !== false) $decoded = $converted; - } - - if ($subtype === 'plain') { - $plainBody = $decoded; - } elseif ($subtype === 'html') { - $htmlBody = $decoded; + // Try to detect encoding from body content if not found + if (!$encoding) { + // Check if it looks like base64 + if (preg_match('/^[A-Za-z0-9+\/=\s]+$/', trim($body)) && strlen(trim($body)) > 50) { + $decoded = @base64_decode($body, true); + if ($decoded !== false && strlen($decoded) > 0) { + // Verify it produces readable text + if (preg_match('/[\x20-\x7E\xC0-\xFF]/', $decoded)) { + $body = $decoded; + } } } + } else { + if ($encoding === 'BASE64') { + $body = base64_decode($body); + } elseif ($encoding === 'QUOTED-PRINTABLE') { + $body = quoted_printable_decode($body); + } } - if ($plainBody) return trim($plainBody); - if ($htmlBody) return trim(strip_tags($htmlBody)); - return ''; - } - - private function decodeBody(string $body, int $encoding): string { - switch ($encoding) { - case 3: return base64_decode($body); // BASE64 - case 4: return quoted_printable_decode($body); // QUOTED-PRINTABLE - default: return $body; + // Strip HTML if it looks like HTML + if (preg_match('/parameters)) return ''; - foreach ($part->parameters as $param) { - if (strtolower($param->attribute) === 'charset') { - return $param->value; + // Try charset conversion + if (preg_match('/charset[="\s]+([^\s;"]+)/i', $structLine, $cm)) { + $charset = strtolower(trim($cm[1], '"')); + if ($charset && $charset !== 'utf-8') { + $converted = @iconv($charset, 'UTF-8//IGNORE', $body); + if ($converted !== false) $body = $converted; } } - return ''; + + return trim($body); + } + + private function parseHeaders(string $raw): array { + $headers = []; + $lines = explode("\n", str_replace("\r\n", "\n", $raw)); + $lastKey = ''; + foreach ($lines as $line) { + if ($line === '' || $line === "\r") continue; + // Continuation line (starts with space/tab) + if (preg_match('/^[\s\t]+(.+)/', $line, $m)) { + if ($lastKey && isset($headers[$lastKey])) { + $headers[$lastKey] .= ' ' . trim($m[1]); + } + continue; + } + if (preg_match('/^([A-Za-z\-]+):\s*(.*)$/', $line, $m)) { + $key = strtolower($m[1]); + $headers[$key] = trim($m[2]); + $lastKey = $key; + } + } + return $headers; + } + + private function parseFrom(string $from): array { + $from = trim($from); + if (preg_match('/^"?([^"<]*)"?\s*<([^>]+)>/', $from, $m)) { + return ['name' => trim($m[1], ' "'), 'email' => trim($m[2])]; + } + if (preg_match('/^([^\s@]+@[^\s@]+)/', $from, $m)) { + return ['name' => '', 'email' => $m[1]]; + } + return ['name' => '', 'email' => $from]; + } + + private function decodeMimeHeader(string $str): string { + if (strpos($str, '=?') === false) return trim($str); + $decoded = ''; + $parts = preg_split('/(=\?[^\?]+\?[BbQq]\?[^\?]*\?=)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE); + foreach ($parts as $part) { + if (preg_match('/^=\?([^\?]+)\?([BbQq])\?([^\?]*)\?=$/', $part, $m)) { + $charset = $m[1]; + $encoding = strtoupper($m[2]); + $text = $m[3]; + if ($encoding === 'B') { + $text = base64_decode($text); + } elseif ($encoding === 'Q') { + $text = quoted_printable_decode(str_replace('_', ' ', $text)); + } + if (strtolower($charset) !== 'utf-8') { + $converted = @iconv($charset, 'UTF-8//IGNORE', $text); + if ($converted !== false) $text = $converted; + } + $decoded .= $text; + } else { + // Remove whitespace between encoded words + if (trim($part) === '') continue; + $decoded .= $part; + } + } + return trim($decoded); + } + + private function command(string $cmd): array { + $tag = 'A' . (++$this->tagCounter); + $this->writeLine("{$tag} {$cmd}"); + + $response = []; + while (true) { + $line = $this->readLine(); + if ($line === false || $line === null) break; + $response[] = $line; + + // Check for literal {N} — read N bytes + if (preg_match('/\{(\d+)\}$/', $line, $m)) { + $bytes = intval($m[1]); + $data = $this->readBytes($bytes); + $response[] = $data; + // Read the closing line after literal + $closingLine = $this->readLine(); + if ($closingLine !== false && $closingLine !== null) { + $response[] = $closingLine; + } + continue; + } + + // Tagged response = done + if (strpos($line, $tag . ' ') === 0) break; + } + return $response; + } + + private function extractLiteral(array $resp): string { + $result = ''; + for ($i = 0; $i < count($resp); $i++) { + if (preg_match('/\{(\d+)\}$/', $resp[$i], $m)) { + // Next element should be the literal data + if (isset($resp[$i + 1])) { + $result .= $resp[$i + 1]; + } + } + } + return $result; + } + + private function isOk(array $resp): bool { + foreach ($resp as $line) { + if (preg_match('/^A\d+\s+OK/i', $line)) return true; + if (preg_match('/^A\d+\s+(NO|BAD)/i', $line)) return false; + } + return false; + } + + private function escape(string $str): string { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $str); + } + + private function writeLine(string $line): void { + if (!$this->socket) return; + fwrite($this->socket, $line . "\r\n"); + } + + private function readLine(): ?string { + if (!$this->socket) return null; + $line = fgets($this->socket, 8192); + if ($line === false) return null; + return rtrim($line, "\r\n"); + } + + private function readBytes(int $n): string { + if (!$this->socket) return ''; + $data = ''; + $remaining = $n; + while ($remaining > 0) { + $chunk = fread($this->socket, min($remaining, 8192)); + if ($chunk === false || $chunk === '') break; + $data .= $chunk; + $remaining -= strlen($chunk); + } + return $data; } public function disconnect(): void { - if ($this->connection) { - @imap_close($this->connection); - $this->connection = null; + if ($this->socket) { + try { + $this->command('LOGOUT'); + } catch (\Throwable $e) {} + @fclose($this->socket); + $this->socket = null; } } } @@ -1282,9 +1478,8 @@ switch ($action) { $imap = new ImapClient(); if (!$imap->connect($config)) { - $errors = imap_errors(); http_response_code(500); - echo json_encode(['error' => 'IMAP-yhteys epäonnistui' . ($errors ? ': ' . implode(', ', $errors) : '')]); + echo json_encode(['error' => 'IMAP-yhteys epäonnistui: ' . $imap->lastError]); break; }