Files
sirpremiumv2/app/Services/CorreoImapService.php
T
2026-04-25 14:55:11 -05:00

322 lines
11 KiB
PHP

<?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]
*/
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.
// 20 mensajes es razonable; si el buzón tiene pocos, se ajusta.
$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) {
// Parsear la fecha del correo
$ts = $email['date'] ? @strtotime($email['date']) : false;
// Si la fecha es válida y el correo es más antiguo que la ventana → parar
if ($ts !== false && $ts < $cutoff) {
break;
}
$result[] = $email;
// Límite de cantidad alcanzado → parar
if (count($result) >= $limit) {
break;
}
}
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<contenido>
$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);
// Si el cuerpo contiene HTML, convertir bloques estructurales a saltos
// de línea y luego quitar todas las etiquetas para obtener texto plano
if (preg_match('/<[a-z][\s>]/i', $body)) {
$body = preg_replace('/<(br\s*\/?|\/p|\/div|\/tr|\/li)>/i', "\n", $body);
$body = strip_tags($body);
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// 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);
}
}