connect(); $this->login(); $total = $this->selectFolder(); if ($total === 0) { $this->logout(); return []; } // Tomamos los últimos $limit números de secuencia $from = max(1, $total - $limit + 1); $set = "{$from}:{$total}"; $emails = $this->fetchMessages($set); $this->logout(); // Devolver en orden descendente (más reciente primero) return array_reverse($emails); } public function getLastError(): string { return $this->lastError; } // ────────────────────────────────────────────── // Conexión y autenticación // ────────────────────────────────────────────── private function connect(): void { $address = $this->useSsl ? "ssl://{$this->host}:{$this->port}" : "tcp://{$this->host}:{$this->port}"; $context = stream_context_create([ 'ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false, ], ]); $this->socket = stream_socket_client( $address, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, $context ); if (! $this->socket) { throw new \RuntimeException("No se pudo conectar a {$address}: {$errstr} ({$errno})"); } stream_set_timeout($this->socket, $this->timeout); // Leer el banner de bienvenida del servidor $this->readLine(); } private function login(): void { $response = $this->command("LOGIN \"{$this->user}\" \"{$this->password}\""); if (! str_contains($response, 'OK')) { throw new \RuntimeException("Autenticación fallida. Respuesta: {$response}"); } } private function logout(): void { if ($this->socket) { $this->command('LOGOUT'); fclose($this->socket); $this->socket = null; } } // ────────────────────────────────────────────── // Operaciones IMAP // ────────────────────────────────────────────── /** * Selecciona la carpeta y retorna el número total de mensajes. */ private function selectFolder(): int { $response = $this->command("SELECT \"{$this->folder}\""); // Buscar "* N EXISTS" if (preg_match('/\*\s+(\d+)\s+EXISTS/i', $response, $m)) { return (int) $m[1]; } return 0; } /** * Descarga cabeceras + cuerpo de un rango de secuencia (ej: "1:5"). */ private function fetchMessages(string $set): array { // Pedimos FLAGS, ENVELOPE y BODY para extraer remitente, asunto y cuerpo $raw = $this->command("FETCH {$set} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] BODY.PEEK[TEXT])"); return $this->parseMessages($raw); } // ────────────────────────────────────────────── // Parser de respuesta FETCH // ────────────────────────────────────────────── private function parseMessages(string $raw): array { $emails = []; // Dividir por inicio de cada mensaje "* N FETCH" $blocks = preg_split('/\* \d+ FETCH /i', $raw, -1, PREG_SPLIT_NO_EMPTY); foreach ($blocks as $block) { $email = [ 'seq' => '', 'from' => '', 'subject' => '', 'date' => '', 'body' => '', ]; // FROM if (preg_match('/^From:\s*(.+)$/mi', $block, $m)) { $email['from'] = $this->decodeMimeHeader(trim($m[1])); } // SUBJECT if (preg_match('/^Subject:\s*(.+)$/mi', $block, $m)) { $email['subject'] = $this->decodeMimeHeader(trim($m[1])); } // DATE if (preg_match('/^Date:\s*(.+)$/mi', $block, $m)) { $email['date'] = trim($m[1]); } // BODY: tomamos el texto entre las dos secciones BODY // La respuesta tiene dos literales {N}\r\n $parts = preg_split('/\}\r?\n/', $block); if (count($parts) >= 3) { // La tercera sección suele ser el cuerpo de texto $rawBody = $parts[2]; // Limpiar hasta el fin del literal (antes del siguiente *) $rawBody = preg_replace('/\r?\n\)\s*\*.*$/s', '', $rawBody); $rawBody = preg_replace('/\r?\n\)\s*[A-Z0-9]+ OK.*/s', '', $rawBody); $email['body'] = $this->cleanBody(trim($rawBody)); } elseif (count($parts) === 2) { $rawBody = $parts[1]; $rawBody = preg_replace('/\r?\n\).*$/s', '', $rawBody); $email['body'] = $this->cleanBody(trim($rawBody)); } if ($email['from'] || $email['subject']) { $emails[] = $email; } } return $emails; } // ────────────────────────────────────────────── // Comunicación con el socket // ────────────────────────────────────────────── private function command(string $cmd): string { $tag = 'A' . str_pad((string) $this->tag++, 4, '0', STR_PAD_LEFT); fwrite($this->socket, "{$tag} {$cmd}\r\n"); $response = ''; while (! feof($this->socket)) { $line = fgets($this->socket, 8192); if ($line === false) { break; } $response .= $line; // La respuesta termina cuando llega la línea tagged if (str_starts_with($line, $tag)) { break; } } return $response; } private function readLine(): string { if (! $this->socket) { return ''; } return (string) fgets($this->socket, 1024); } // ────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────── private function decodeMimeHeader(string $value): string { if (function_exists('iconv_mime_decode')) { return iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8'); } // Fallback: decodificar manualmente =?charset?encoding?text?= return preg_replace_callback( '/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/u', function ($m) { $charset = $m[1]; $encoding = strtoupper($m[2]); $text = $m[3]; $decoded = $encoding === 'B' ? base64_decode($text) : quoted_printable_decode(str_replace('_', ' ', $text)); return mb_convert_encoding($decoded, 'UTF-8', $charset); }, $value ); } private function cleanBody(string $body): string { // Quitar quoted-printable si aplica if (str_contains($body, '=\r\n') || preg_match('/=[0-9A-F]{2}/i', $body)) { $body = quoted_printable_decode($body); } // Quitar líneas de separadores de partes MIME $body = preg_replace('/--[^\r\n]+\r?\n/m', '', $body); // Eliminar líneas de cabecera de parte MIME (Content-Type, etc.) $body = preg_replace('/^(Content-[^\r\n]+\r?\n)+/mi', '', $body); // Normalizar saltos de línea $body = str_replace(["\r\n", "\r"], "\n", $body); // Recortar líneas en blanco excesivas $body = preg_replace('/\n{3,}/', "\n\n", $body); return trim($body); } }