Files
sirpremiumv2/app/Services/CorreoImapService.php
T
LizandroandClaude Sonnet 4.6 d88f67ab5b fix: corregir pipeline de validación de pagos
- GeminiVisionService: eliminar thinkingConfig (rompe modelos flash),
  reducir maxOutputTokens 8192→512, timeout 20→40s, default gemini-2.0-flash
- PublicChat: detectar correctamente errores de Gemini (__error/__parse_error)
  que antes pasaban el guard if(!$datosPago) por ser arrays truthy
- PagoValidadorService: tolerancia de hora 0→3 min, remitente >=2→>=1 palabra
- CorreoImapService: break→continue en correos fuera de ventana (IMAP no
  garantiza orden cronológico), fetchSize *4→*2 para evitar timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 05:00:23 +00:00

411 lines
15 KiB
PHP
Executable File

<?php
namespace App\Services;
/**
* CorreoImapService
*
* Lee correos vía protocolo IMAP puro usando sockets PHP.
* No requiere la extensión imap de PHP.
*/
class CorreoImapService
{
private $socket = null;
private int $tag = 1;
private string $lastError = '';
public function __construct(
private string $host,
private int $port,
private bool $useSsl,
private string $user,
private string $password,
private string $folder = 'INBOX',
private int $timeout = 15
) {}
// ──────────────────────────────────────────────
// API pública
// ──────────────────────────────────────────────
/**
* Conecta, autentica y devuelve:
* - Los correos de los últimos $minutesBack minutos, con máximo $limit emails.
* - Lo que ocurra primero: llegar al límite de tiempo o al límite de cantidad.
*
* Para tener margen suficiente se descarga un lote mayor ($fetchSize) y
* luego se filtra por fecha en PHP.
*
* Cada elemento: [seq, from, subject, date, body]
* El campo 'fuera_de_ventana' indica si el resultado vino del fallback (sin filtro de tiempo).
*/
public function fetchLatest(int $limit = 5, int $minutesBack = 5): array
{
$this->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<contenido>
// 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 <style>, <script>, <head> y luego
* convierte etiquetas estructurales en saltos de línea antes de strip_tags.
*/
private static function stripHtmlFull(string $html): string
{
// 1. Eliminar bloques cuyo contenido no es texto visible
$html = preg_replace('/<style[^>]*>.*?<\/style>/si', '', $html);
$html = preg_replace('/<script[^>]*>.*?<\/script>/si', '', $html);
$html = preg_replace('/<head[^>]*>.*?<\/head>/si', '', $html);
// 2. Convertir saltos estructurales a \n
$html = preg_replace('/<(br\s*\/?\s*|\/p|\/div|\/tr|\/li|\/td)>/i', "\n", $html);
// 3. Quitar todas las etiquetas restantes
$html = strip_tags($html);
// 4. Decodificar entidades HTML (&amp; &nbsp; etc.)
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// 5. Limpiar espacios por línea y líneas en blanco excesivas
$lines = array_map('trim', explode("\n", $html));
$lines = array_filter($lines, fn($l) => $l !== '');
return implode("\n", $lines);
}
}