From bbf34d08d0ed48c782c3f75dc7a333770fed4468 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:47:20 -0500 Subject: [PATCH] correo --- app/Http/Controllers/WhatsappController.php | 5 + .../Livewire/Whatsapp/ShowCorreoConfig.php | 99 ++++++ app/Services/CorreoImapService.php | 284 ++++++++++++++++++ ...26_04_25_000001_add_correo_imap_config.php | 41 +++ resources/views/layouts/navigation.blade.php | 4 + .../whatsapp/show-correo-config.blade.php | 205 +++++++++++++ resources/views/whatsapp/correo.blade.php | 6 + routes/web.php | 1 + 8 files changed, 645 insertions(+) create mode 100644 app/Http/Livewire/Whatsapp/ShowCorreoConfig.php create mode 100644 app/Services/CorreoImapService.php create mode 100644 database/migrations/2026_04_25_000001_add_correo_imap_config.php create mode 100644 resources/views/livewire/whatsapp/show-correo-config.blade.php create mode 100644 resources/views/whatsapp/correo.blade.php diff --git a/app/Http/Controllers/WhatsappController.php b/app/Http/Controllers/WhatsappController.php index aaed28e..5e68a6a 100644 --- a/app/Http/Controllers/WhatsappController.php +++ b/app/Http/Controllers/WhatsappController.php @@ -35,4 +35,9 @@ class WhatsappController extends Controller { return view('whatsapp.logs'); } + + public function correo() + { + return view('whatsapp.correo'); + } } diff --git a/app/Http/Livewire/Whatsapp/ShowCorreoConfig.php b/app/Http/Livewire/Whatsapp/ShowCorreoConfig.php new file mode 100644 index 0000000..46ff34a --- /dev/null +++ b/app/Http/Livewire/Whatsapp/ShowCorreoConfig.php @@ -0,0 +1,99 @@ + 'required|string|max:255', + 'correo_imap_port' => 'required|integer|min:1|max:65535', + 'correo_imap_user' => 'required|email', + 'correo_imap_password' => 'required|string', + 'correo_imap_folder' => 'required|string|max:100', + ]; + + public function mount(): void + { + $keys = [ + 'correo_imap_host', 'correo_imap_port', 'correo_imap_ssl', + 'correo_imap_user', 'correo_imap_password', 'correo_imap_folder', + 'correo_imap_enabled', + ]; + foreach ($keys as $key) { + $this->{$key} = WhatsappSystemConfig::get($key, $this->{$key}); + } + } + + public function save(): void + { + $this->validate(); + + $keys = [ + 'correo_imap_host', 'correo_imap_port', 'correo_imap_ssl', + 'correo_imap_user', 'correo_imap_password', 'correo_imap_folder', + 'correo_imap_enabled', + ]; + foreach ($keys as $key) { + WhatsappSystemConfig::set($key, (string) $this->{$key}); + } + + $this->alert('success', 'Configuración guardada correctamente.'); + } + + public function probar(): void + { + $this->validate(); + + $this->testing = true; + $this->testStatus = ''; + $this->testError = ''; + $this->emails = []; + + try { + $service = new CorreoImapService( + host: $this->correo_imap_host, + port: (int) $this->correo_imap_port, + useSsl: $this->correo_imap_ssl === '1', + user: $this->correo_imap_user, + password: $this->correo_imap_password, + folder: $this->correo_imap_folder, + timeout: 20 + ); + + $this->emails = $service->fetchLatest(5); + $this->testStatus = 'ok'; + } catch (\Throwable $e) { + $this->testStatus = 'error'; + $this->testError = $e->getMessage(); + } finally { + $this->testing = false; + } + } + + public function render() + { + return view('livewire.whatsapp.show-correo-config'); + } +} diff --git a/app/Services/CorreoImapService.php b/app/Services/CorreoImapService.php new file mode 100644 index 0000000..fac1bca --- /dev/null +++ b/app/Services/CorreoImapService.php @@ -0,0 +1,284 @@ +connect(); + $this->login(); + $total = $this->selectFolder(); + + if ($total === 0) { + $this->logout(); + return []; + } + + // Tomamos los últimos $limit números de secuencia + $from = max(1, $total - $limit + 1); + $set = "{$from}:{$total}"; + + $emails = $this->fetchMessages($set); + + $this->logout(); + + // Devolver en orden descendente (más reciente primero) + return array_reverse($emails); + } + + 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 + { + // 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); + + // 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); + } +} diff --git a/database/migrations/2026_04_25_000001_add_correo_imap_config.php b/database/migrations/2026_04_25_000001_add_correo_imap_config.php new file mode 100644 index 0000000..2cd66f4 --- /dev/null +++ b/database/migrations/2026_04_25_000001_add_correo_imap_config.php @@ -0,0 +1,41 @@ + 'correo_imap_host', 'config_value' => '', 'description' => 'Servidor IMAP (ej: imap.gmail.com)'], + ['config_key' => 'correo_imap_port', 'config_value' => '993', 'description' => 'Puerto IMAP (993=SSL, 143=sin SSL)'], + ['config_key' => 'correo_imap_ssl', 'config_value' => '1', 'description' => 'Usar SSL/TLS (1=sí, 0=no)'], + ['config_key' => 'correo_imap_user', 'config_value' => '', 'description' => 'Usuario/email de la cuenta'], + ['config_key' => 'correo_imap_password', 'config_value' => '', 'description' => 'Contraseña o App Password'], + ['config_key' => 'correo_imap_folder', 'config_value' => 'INBOX', 'description' => 'Carpeta a leer'], + ['config_key' => 'correo_imap_enabled', 'config_value' => '0', 'description' => 'Habilitar lectura de correos (1/0)'], + ]; + + foreach ($keys as $key) { + DB::table('whatsapp_system_config')->insertOrIgnore([ + ...$key, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function down(): void + { + DB::table('whatsapp_system_config') + ->whereIn('config_key', [ + 'correo_imap_host', 'correo_imap_port', 'correo_imap_ssl', + 'correo_imap_user', 'correo_imap_password', 'correo_imap_folder', + 'correo_imap_enabled', + ])->delete(); + } +}; diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index a293d22..507524a 100755 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -283,6 +283,10 @@ Logs Webhook + + + Correo / IMAP + @endif diff --git a/resources/views/livewire/whatsapp/show-correo-config.blade.php b/resources/views/livewire/whatsapp/show-correo-config.blade.php new file mode 100644 index 0000000..c8250e2 --- /dev/null +++ b/resources/views/livewire/whatsapp/show-correo-config.blade.php @@ -0,0 +1,205 @@ +
+
+

+ + + + Lector de Correo — IMAP +

+
+ +
+ + {{-- ── Formulario de configuración ─────────────────── --}} +
+

+ + + + + Configuración del servidor +

+ +
+ + {{-- Host y puerto --}} +
+
+ + + @error('correo_imap_host')

{{ $message }}

@enderror +
+
+ + + @error('correo_imap_port')

{{ $message }}

@enderror +
+
+ + {{-- SSL --}} +
+
+ + +
+
+ + + @error('correo_imap_folder')

{{ $message }}

@enderror +
+
+ + {{-- Usuario --}} +
+ + + @error('correo_imap_user')

{{ $message }}

@enderror +
+ + {{-- Contraseña --}} +
+ + + @error('correo_imap_password')

{{ $message }}

@enderror +

+ Para Gmail usa una + App Password + (necesitas verificación en 2 pasos activa). +

+
+ + {{-- Habilitado --}} + + + {{-- Acciones --}} +
+ + +
+
+ + {{-- Ayuda rápida --}} +
+

Configuraciones comunes:

+
+ Gmailimap.gmail.com : 993 (SSL) + Outlook/Hotmailimap-mail.outlook.com : 993 (SSL) + Yahooimap.mail.yahoo.com : 993 (SSL) + cPanel / Hostingmail.tudominio.com : 993 (SSL) +
+
+
+ + {{-- ── Resultado de la prueba ───────────────────────── --}} +
+ {{-- Estado de la conexión --}} + @if($testStatus === 'error') +
+ + + +
+

Error de conexión

+

{{ $testError }}

+
+
+ @elseif($testStatus === 'ok') +
+ + + +
+

Conexión exitosa

+

+ Se encontraron {{ count($emails) }} correo(s) recientes. +

+
+
+ @else +
+ + + +

Configura los datos y pulsa Probar conexión

+

Se mostrarán los últimos 5 correos de la bandeja

+
+ @endif + + {{-- Lista de correos --}} + @if(count($emails) > 0) +
+ @foreach($emails as $i => $email) +
+ {{-- Cabecera del correo --}} + + + {{-- Cuerpo del correo --}} +
+ @if($email['body']) +
{{ $email['body'] }}
+ @else +

(Sin contenido de texto)

+ @endif +
+
+ @endforeach +
+ @endif +
+
+
diff --git a/resources/views/whatsapp/correo.blade.php b/resources/views/whatsapp/correo.blade.php new file mode 100644 index 0000000..717d052 --- /dev/null +++ b/resources/views/whatsapp/correo.blade.php @@ -0,0 +1,6 @@ + +
+ +
+ @include('layouts.footer') +
diff --git a/routes/web.php b/routes/web.php index d57644c..56304ea 100755 --- a/routes/web.php +++ b/routes/web.php @@ -81,6 +81,7 @@ Route::middleware(["auth", "solo_usuario_administrador"])->group(function () { Route::get('/programados', [WhatsappController::class, 'programados'])->name('programados'); Route::get('/configuracion', [WhatsappController::class, 'configuracion'])->name('configuracion'); Route::get('/logs', [WhatsappController::class, 'logs'])->name('logs'); + Route::get('/correo', [WhatsappController::class, 'correo'])->name('correo'); }); });