connect(); $this->login(); $total = $this->selectFolder(); if ($total === 0) { $this->logout(); return []; } // Descargar un lote suficiente para cubrir la ventana de tiempo. $fetchSize = min($total, max($limit * 4, 20)); $from = max(1, $total - $fetchSize + 1); $set = "{$from}:{$total}"; $emails = $this->fetchMessages($set); $this->logout(); // Más reciente primero $emails = array_reverse($emails); $cutoff = time() - ($minutesBack * 60); $result = []; foreach ($emails as $email) { $ts = $email['date'] ? @strtotime($email['date']) : false; // Correo más antiguo que la ventana → parar if ($ts !== false && $ts < $cutoff) { break; } $result[] = $email; if (count($result) >= $limit) { break; } } // ── Fallback: si la ventana no devolvió nada, mostrar los últimos $limit sin filtro if (empty($result)) { $result = array_slice($emails, 0, $limit); foreach ($result as &$e) { $e['fuera_de_ventana'] = true; } unset($e); } return $result; } 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 { // ── 1. MIME multipart: extraer SOLO la parte text/plain ────────── // Los emails de Bancolombia son multipart/alternative. // Detectamos el boundary en la primera línea --boundary if (preg_match('/^--([^\r\n]+)/m', $body, $bm)) { $plainPart = $this->extractMimePlainPart($body, $bm[1]); if ($plainPart !== null) { $body = $plainPart; } } // ── 2. Decodificar quoted-printable ────────────────────────────── if (preg_match('/=[0-9A-F]{2}/i', $body) || preg_match('/=\r?\n/m', $body)) { $body = quoted_printable_decode($body); } // ── 3. Eliminar cabeceras MIME residuales ──────────────────────── $body = preg_replace('/^(Content-[^\r\n]+\r?\n)+/mi', '', $body); // ── 4. Limpiar URLs de imágenes entre corchetes: "Logo [http://...]" $body = preg_replace('/\[https?:\/\/[^\]]*\]/i', '', $body); // ── 5. Si aún contiene HTML (fallback) quitar etiquetas ────────── if (preg_match('/<[a-z][\s>]/i', $body)) { $body = self::stripHtmlFull($body); } // ── 6. Normalizar espacios y líneas ────────────────────────────── $body = str_replace(["\r\n", "\r"], "\n", $body); $body = preg_replace('/\n{3,}/', "\n\n", $body); return trim($body); } /** * Extrae el contenido de la parte text/plain de un cuerpo MIME multipart. * Devuelve null si no se encuentra o el email no es multipart. */ private function extractMimePlainPart(string $body, string $boundary): ?string { $escaped = preg_quote(rtrim($boundary), '/'); // Separar por --boundary (con o sin espacios/CRLF alrededor) $parts = preg_split('/--' . $escaped . '(?:--)?[ \t]*\r?\n/', $body, -1, PREG_SPLIT_NO_EMPTY); foreach ($parts as $part) { // Ignorar partes sin cabeceras MIME if (stripos($part, 'Content-Type:') === false) { continue; } // ¿Es text/plain? if (!preg_match('/Content-Type:\s*text\/plain/i', $part)) { continue; } // Separar cabeceras del cuerpo (doble CRLF o LF) if (preg_match('/\r?\n\r?\n(.+)$/s', $part, $m)) { return $m[1]; } } return null; } /** * Elimina completamente bloques