- 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>
411 lines
15 KiB
PHP
Executable File
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 (& 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);
|
|
}
|
|
}
|