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 * 2, 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 → saltar (no parar, pueden haber más nuevos) if ($ts !== false && $ts < $cutoff) { continue; } $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"). * * Usamos BODY.PEEK[1] en lugar de BODY.PEEK[TEXT]: * - Para emails multipart/alternative (como Bancolombia), BODY[1] devuelve * ÚNICAMENTE la parte text/plain, sin boundaries ni HTML. * - Para emails simples (solo texto), BODY[1] también devuelve el cuerpo. */ private function fetchMessages(string $set): array { $raw = $this->command("FETCH {$set} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] BODY.PEEK[1])"); 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: la respuesta IMAP usa literales {N}\r\n // Con dos secciones pedidas habrá al menos 3 partes al dividir por "}\r\n". // La tercera parte es BODY[1] (texto plano directo). $parts = preg_split('/\}\r?\n/', $block); $rawBody = ''; if (count($parts) >= 3) { $rawBody = $parts[2]; // Quitar el cierre del literal IMAP (\r\n) y la línea "OK" $rawBody = preg_replace('/\r?\n\s*\)\s*(\r?\n[A-Z0-9]+ OK.*)?$/s', '', $rawBody); } elseif (count($parts) === 2) { $rawBody = $parts[1]; $rawBody = preg_replace('/\r?\n\s*\).*$/s', '', $rawBody); } if ($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 { // ── 0. Base64 — detectar antes que QP ──────────────────────────── // BODY[1] puede llegar codificado en base64 (Content-Transfer-Encoding: base64) // si el servidor no decodifica automáticamente. $noWs = preg_replace('/[\r\n\t ]/', '', $body); if (strlen($noWs) > 60 && preg_match('/^[A-Za-z0-9+\/]+=*$/', $noWs)) { $decoded = base64_decode($noWs, true); if ($decoded !== false && preg_match('//u', $decoded)) { $body = $decoded; } } // ── 1. Quoted-printable ────────────────────────────────────────── if (preg_match('/=[0-9A-F]{2}/i', $body) || preg_match('/=\r?\n/m', $body)) { $body = quoted_printable_decode($body); } // ── 2. Si aún hay boundaries MIME residuales, extraer text/plain ─ if (preg_match('/^--([^\r\n]+)/m', $body, $bm)) { $plain = $this->extractMimePlainPart($body, $bm[1]); if ($plain !== null) { // Decodificar QP en la parte extraída también if (preg_match('/=[0-9A-F]{2}/i', $plain) || preg_match('/=\r?\n/m', $plain)) { $plain = quoted_printable_decode($plain); } $body = $plain; } } // ── 3. Eliminar cabeceras MIME residuales de parte ─────────────── // (Content-Type:, Content-Transfer-Encoding:, etc.) $body = preg_replace('/^(Content-[^\r\n]+\r?\n)+/mi', '', $body); // ── 4. Limpiar URLs entre corchetes: "Logo [http://...]" ───────── $body = preg_replace('/\[https?:\/\/[^\]]*\]/i', '', $body); // ── 5. Fallback HTML: si queda HTML, quitar etiquetas ──────────── if (preg_match('/<[a-z][\s>]/i', $body)) { $body = self::stripHtmlFull($body); } // ── 6. Normalizar saltos de línea y espacios ───────────────────── $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