diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0817d35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv/ +ENV/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Configuración de correo (contiene credenciales) +.mail_config.json + +# Backups +*.backup* +*.bak + +# OS +.DS_Store +Thumbs.db diff --git a/ARQUITECTURA_CORREO.md b/ARQUITECTURA_CORREO.md new file mode 100644 index 0000000..a01f44a --- /dev/null +++ b/ARQUITECTURA_CORREO.md @@ -0,0 +1,246 @@ +# Arquitectura del Cliente de Correo - Proyecto1AVApsp + +## Estructura de la aplicación + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Proyecto1AVApsp │ +│ Panel de Laboratorio │ +└──────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ + │ Tareas │ │ Correos │ │ Juegos │ + └─────────┘ └────┬────┘ └─────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ + ┌────▼────────┐ ┌───────▼───────┐ + │ IMAP │ │ SMTP │ + │ Cliente │ │ Cliente │ + └─────┬───────┘ └───────┬───────┘ + │ │ + │ ┌──────────────┐ │ + └─────────► Servidor ◄─────────┘ + │ Webmin │ + │ 10.10.0.101 │ + └──────────────┘ +``` + +## Componentes principales + +### 1. Interfaz de Usuario (app.py:631-753) +- Panel de configuración del servidor +- Lista de correos (bandeja de entrada) +- Visor de correos +- Ventana de composición de correos + +### 2. Gestión IMAP (app.py:1380-1540) +**Funciones:** +- `_connect_mail_server()`: Conecta al servidor IMAP +- `_disconnect_mail_server()`: Desconecta del servidor +- `_refresh_mail_list()`: Carga la lista de correos +- `_on_mail_select()`: Maneja la selección de correos +- `_display_mail()`: Muestra el contenido de un correo + +**Protocolo:** IMAP (Puerto 143) + +### 3. Gestión SMTP (app.py:1585-1620) +**Funciones:** +- `_open_compose_window()`: Abre ventana de redacción +- `_send_mail()`: Envía correos usando SMTP + +**Protocolo:** SMTP (Puerto 25) + +## Flujo de datos + +### Lectura de correos (IMAP) +``` +Usuario → Clic "Conectar" + ↓ +_connect_mail_server() + ↓ +imaplib.IMAP4(host, 143) + ↓ +imap.login(usuario, contraseña) + ↓ +imap.select('INBOX') + ↓ +_refresh_mail_list() + ↓ +imap.search(None, 'ALL') + ↓ +imap.fetch(mail_id, 'RFC822') + ↓ +email.message_from_bytes() + ↓ +Mostrar en Listbox + ↓ +Usuario selecciona correo + ↓ +_display_mail() + ↓ +Parsear contenido (texto/HTML) + ↓ +Mostrar en ScrolledText +``` + +### Envío de correos (SMTP) +``` +Usuario → Clic "Nuevo correo" + ↓ +_open_compose_window() + ↓ +Usuario completa campos (Para, Asunto, Mensaje) + ↓ +Usuario → Clic "Enviar" + ↓ +_send_mail() + ↓ +MIMEMultipart() + ↓ +MIMEText(body, 'plain') + ↓ +smtplib.SMTP(host, 25) + ↓ +server.send_message(msg) + ↓ +Confirmación al usuario +``` + +## Protocolos de comunicación + +### IMAP (Internet Message Access Protocol) +- **Puerto**: 143 (sin cifrar) / 993 (cifrado) +- **Uso**: Leer correos del servidor +- **Ventajas**: + - Los correos permanecen en el servidor + - Acceso desde múltiples dispositivos + - Sincronización de carpetas + +### SMTP (Simple Mail Transfer Protocol) +- **Puerto**: 25 (sin cifrar) / 587 (TLS) / 465 (SSL) +- **Uso**: Enviar correos +- **Autenticación**: Opcional según configuración del servidor + +## Configuración del servidor Webmin + +### Servicios necesarios: +``` +┌─────────────────────────────────────────┐ +│ Webmin (http://10.10.0.101:20000) │ +├─────────────────────────────────────────┤ +│ Servicios de correo: │ +│ • Postfix (SMTP) → Puerto 25 │ +│ • Dovecot (IMAP) → Puerto 143 │ +│ • Dovecot (POP3) → Puerto 110 │ +└─────────────────────────────────────────┘ +``` + +### Pasos en Webmin: +1. **Servers → Dovecot IMAP/POP3 Server** + - Habilitar servicio IMAP en puerto 143 + - Configurar usuarios y contraseñas + +2. **Servers → Postfix Mail Server** + - Habilitar servicio SMTP en puerto 25 + - Configurar dominio y relay + +3. **System → Users and Groups** + - Crear usuarios del sistema para correo + - Asignar contraseñas + +## Seguridad + +### Advertencias actuales: +⚠️ **La implementación actual usa conexiones sin cifrar** + +### Recomendaciones: +1. Usar IMAPS (puerto 993) en lugar de IMAP (143) +2. Usar SMTPS (puerto 465/587) en lugar de SMTP (25) +3. Implementar SSL/TLS en las conexiones +4. No usar contraseñas en texto plano en el código +5. Usar autenticación del servidor SMTP + +### Mejora de seguridad (código): +```python +# IMAP con SSL +import imaplib +imap = imaplib.IMAP4_SSL('10.10.0.101', 993) + +# SMTP con TLS +import smtplib +smtp = smtplib.SMTP('10.10.0.101', 587) +smtp.starttls() +smtp.login(username, password) +``` + +## Estructura de archivos + +``` +Proyecto1AVApsp/ +├── app.py # Aplicación principal con cliente de correo +├── CORREO_README.md # Documentación de usuario +├── test_mail_server.py # Script de prueba de conectividad +├── requirements.txt # Dependencias (smtplib/imaplib incluidos en Python) +└── README.md # Documentación general del proyecto +``` + +## Variables de estado + +```python +# Variables del cliente de correo (en DashboardApp) +self.mail_connected = False # Estado de conexión +self.imap_connection = None # Objeto de conexión IMAP +self.current_mailbox = 'INBOX' # Bandeja actual +self.mail_list = [] # Lista de correos cargados + +# Widgets de UI +self.mail_imap_host # Entry: servidor IMAP +self.mail_imap_port # Entry: puerto IMAP +self.mail_smtp_host # Entry: servidor SMTP +self.mail_smtp_port # Entry: puerto SMTP +self.mail_username # Entry: usuario +self.mail_password # Entry: contraseña +self.mail_listbox # Listbox: lista de correos +self.mail_body_text # ScrolledText: cuerpo del correo +``` + +## Testing + +### Script de prueba de conectividad: +```bash +python3 test_mail_server.py +``` + +Este script verifica: +- Conexión a Webmin (puerto 20000) +- Disponibilidad de SMTP (puerto 25) +- Disponibilidad de IMAP (puerto 143) +- Disponibilidad de POP3 (puerto 110) + +## Próximas mejoras + +1. **Seguridad** + - [ ] Implementar SSL/TLS + - [ ] Autenticación segura + - [ ] Gestión de certificados + +2. **Funcionalidad** + - [ ] Soporte para adjuntos + - [ ] Vista HTML mejorada + - [ ] Múltiples carpetas + - [ ] Búsqueda de correos + - [ ] Responder/Reenviar + +3. **UI/UX** + - [ ] Indicador de correos no leídos + - [ ] Filtros y ordenamiento + - [ ] Marcadores/etiquetas + - [ ] Vista previa de adjuntos + +4. **Performance** + - [ ] Carga asíncrona de correos + - [ ] Cache de correos + - [ ] Paginación diff --git a/CORREO_README.md b/CORREO_README.md new file mode 100644 index 0000000..a3d9898 --- /dev/null +++ b/CORREO_README.md @@ -0,0 +1,155 @@ +# Cliente de Correo Electrónico - Proyecto1AVApsp + +## Descripción + +Se ha implementado un cliente de correo electrónico completo en la pestaña "Correos" de la aplicación. Este cliente permite conectarse a tu servidor Webmin para leer y enviar correos electrónicos. + +## Características + +- **Conexión IMAP**: Lee correos desde tu servidor (Puerto 143) +- **Conexión SMTP**: Envía correos nuevos (Puerto 25) +- **Interfaz tipo Outlook**: Panel dividido con lista de correos y visor +- **Redacción de correos**: Ventana dedicada para componer nuevos mensajes + +## Configuración del Servidor Webmin + +### Datos de conexión predeterminados: + +- **Servidor IMAP**: `10.10.0.101` +- **Puerto IMAP**: `143` +- **Servidor SMTP**: `10.10.0.101` +- **Puerto SMTP**: `25` + +### Puertos configurados en Webmin: + +- SMTP (envío de correo): 25 +- IMAP (consulta de correo): 143 +- POP (descarga de correo): 110 (no usado en esta implementación) +- Interfaz web Webmin: http://10.10.0.101:20000 + +## Cómo usar el cliente de correo + +### 1. Ejecutar la aplicación + +```bash +python3 app.py +``` + +### 2. Ir a la pestaña "Correos" + +La pestaña está ubicada en el panel central de la aplicación. + +### 3. Configurar la conexión + +En el panel "Configuración del servidor": + +- **Servidor IMAP**: Ya está configurado como `10.10.0.101` +- **Puerto IMAP**: Ya está configurado como `143` +- **Servidor SMTP**: Ya está configurado como `10.10.0.101` +- **Puerto SMTP**: Ya está configurado como `25` +- **Usuario**: Introduce tu nombre de usuario del servidor de correo +- **Contraseña**: Introduce tu contraseña + +### 4. Conectar al servidor + +Haz clic en el botón **"Conectar"**. Si la conexión es exitosa: +- El estado cambiará a "Conectado" en verde +- Se cargarán automáticamente los correos de la bandeja de entrada + +### 5. Leer correos + +- Los correos aparecen en la lista de la izquierda +- Haz clic en un correo para ver su contenido completo +- Se muestran: + - Remitente (De) + - Asunto + - Fecha + - Cuerpo del mensaje + +### 6. Actualizar la lista de correos + +Haz clic en el botón **"Actualizar"** para recargar la lista de correos desde el servidor. + +### 7. Enviar un nuevo correo + +1. Haz clic en el botón **"Nuevo correo"** +2. Se abrirá una ventana de composición +3. Completa los campos: + - **Para**: Dirección del destinatario + - **Asunto**: Título del correo + - **Mensaje**: Contenido del correo +4. Haz clic en **"Enviar"** + +### 8. Desconectar + +Cuando termines, haz clic en **"Desconectar"** para cerrar la conexión con el servidor. + +## Notas técnicas + +### Bibliotecas utilizadas + +El cliente utiliza las bibliotecas estándar de Python: +- `imaplib`: Para leer correos (protocolo IMAP) +- `smtplib`: Para enviar correos (protocolo SMTP) +- `email`: Para parsear y crear mensajes de correo + +### Limitaciones actuales + +1. **Seguridad**: + - La conexión IMAP es sin cifrado (puerto 143 en lugar de 993 IMAPS) + - La conexión SMTP es sin cifrado (puerto 25 en lugar de 465/587 SMTPS) + - Para producción, se recomienda usar conexiones cifradas + +2. **Funcionalidades**: + - Solo se muestran los últimos 50 correos + - No hay soporte para adjuntos aún + - Solo se muestra la bandeja de entrada (INBOX) + - Los correos HTML se muestran como texto crudo + +3. **Autenticación SMTP**: + - El código actual no utiliza autenticación para SMTP + - Si tu servidor Webmin requiere autenticación, necesitarás descomentar estas líneas en el código: + ```python + # server.starttls() + # server.login(username, password) + ``` + +## Solución de problemas + +### Error: "No se pudo conectar al servidor" + +1. Verifica que el servidor Webmin esté en ejecución +2. Comprueba que puedes hacer ping a `10.10.0.101` +3. Verifica que los puertos 143 y 25 estén abiertos: + ```bash + telnet 10.10.0.101 143 + telnet 10.10.0.101 25 + ``` + +### Error: "Login failed" + +- Verifica que el usuario y contraseña sean correctos +- Comprueba que la cuenta de correo esté configurada en Webmin + +### No se cargan los correos + +- Haz clic en "Actualizar" para recargar +- Verifica la configuración de la bandeja INBOX en el servidor +- Revisa el panel de notas para ver mensajes de error + +## Mejoras futuras + +- [ ] Soporte para conexiones cifradas (IMAPS/SMTPS) +- [ ] Gestión de adjuntos +- [ ] Múltiples carpetas/bandejas +- [ ] Búsqueda de correos +- [ ] Marcado como leído/no leído +- [ ] Eliminación de correos +- [ ] Responder y reenviar correos +- [ ] Vista HTML mejorada +- [ ] Configuración persistente + +## Contacto + +Para más información sobre la configuración del servidor Webmin, consulta: +- Interfaz web Webmin: http://10.10.0.101:20000 diff --git a/README.md b/README.md index ac7738d..2af63dc 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,18 @@ - 🔗 **Gestor de enlaces** rápidos - 🎲 **Minijuego de camellos** con animaciones +### 📧 **Cliente de Correo Electrónico Completo** +| Característica | Descripción | +|----------------|-------------| +| 📬 **IMAP** | Lectura de correos desde servidor Webmin (Puerto 143) | +| 📤 **SMTP** | Envío de correos con autenticación (Puerto 25) | +| 💾 **Auto-guardado** | Credenciales guardadas con Base64 | +| 👥 **Múltiples destinatarios** | Envío a varios correos simultáneamente | +| 📎 **Adjuntos** | Soporte para imágenes, PDFs, docs, Excel, ZIP | +| 🖼️ **Imágenes inline** | Visualización de imágenes dentro del correo | +| 🗂️ **Carpetas** | Bandeja de entrada y enviados sincronizados | +| 🔵 **Indicadores** | Correos leídos/no leídos con marcadores visuales | + --- ## 🏗️ Arquitectura @@ -634,12 +646,714 @@ class GameServer: |----------|----------------| | **Procesos** | Lanzamiento de aplicaciones externas (VS Code, Firefox) | | **Threads** | Servidor multihilo, cliente con hilos de recepción | -| **Sockets TCP** | Comunicación cliente-servidor en red | -| **Servicios** | API OpenWeather, scraping de Wallapop | +| **Sockets TCP** | Comunicación cliente-servidor en red (Juego y Correo) | +| **Servicios** | API OpenWeather, scraping de Wallapop, IMAP/SMTP | | **Sincronización** | Locks para acceso concurrente a estado compartido | --- +## 📧 CLIENTE DE CORREO ELECTRÓNICO: ARQUITECTURA Y FUNCIONAMIENTO + +### 🏗️ Arquitectura del Sistema de Correo + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ARQUITECTURA DEL CLIENTE DE CORREO │ +└─────────────────────────────────────────────────────────────────────┘ + + ┌───────────────────────────┐ + │ SERVIDOR WEBMIN │ + │ 10.10.0.101 │ + └─────────────┬─────────────┘ + │ + ┌─────────────────────┴─────────────────────┐ + │ │ + ┌───────▼────────┐ ┌────────▼────────┐ + │ PUERTO 143 │ │ PUERTO 25 │ + │ IMAP │ │ SMTP │ + │ (Lectura) │ │ (Envío) │ + └───────┬────────┘ └────────┬────────┘ + │ │ + └─────────────────┬───────────────────────┘ + │ + ┌─────────▼──────────┐ + │ app.py │ + │ DashboardApp │ + │ │ + │ Tab "Correos" │ + └────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ +┌───────▼────────┐ ┌─────────▼────────┐ ┌────────▼────────┐ +│ _connect_ │ │ _refresh_ │ │ _send_mail_ │ +│ mail_server() │ │ mail_list() │ │ with_attach() │ +│ │ │ │ │ │ +│ IMAP Login │ │ IMAP FETCH │ │ SMTP Send │ +└────────────────┘ └──────────────────┘ └─────────────────┘ +``` + +### 📡 Protocolos Utilizados + +#### **IMAP (Internet Message Access Protocol)** +Protocolo para **leer correos** del servidor. Puerto: **143** (sin TLS). + +```python +# Conexión IMAP (app.py líneas 1578-1582) +import imaplib +self.imap_connection = imaplib.IMAP4(host, port_num) # Puerto 143 +self.imap_connection.login(username, password) +``` + +**Comandos IMAP utilizados:** +- `login(user, pass)` → Autenticación +- `select(mailbox)` → Seleccionar carpeta (INBOX, Sent, etc.) +- `search(None, 'ALL')` → Buscar todos los correos +- `fetch(id, '(BODY.PEEK[] FLAGS)')` → Obtener correo sin marcarlo como leído +- `store(id, '+FLAGS', '\\Seen')` → Marcar como leído +- `append(folder, flags, date, msg)` → Guardar correo en carpeta + +#### **SMTP (Simple Mail Transfer Protocol)** +Protocolo para **enviar correos**. Puerto: **25** (sin TLS). + +```python +# Conexión SMTP (app.py líneas 2949-2955) +import smtplib +with smtplib.SMTP(smtp_host, smtp_port_num, timeout=15) as server: + server.send_message(msg, to_addrs=recipients) +``` + +--- + +### 🔐 Sistema de Credenciales + +#### **Guardado Automático con Base64** + +```python +# GUARDAR (app.py líneas 1540-1557) +import base64 +config = { + 'imap_host': '10.10.0.101', + 'imap_port': '143', + 'smtp_host': '10.10.0.101', + 'smtp_port': '25', + 'username': 'marcos@psp.es', + 'password': base64.b64encode(password.encode()).decode() # Codificar +} +json.dump(config, open('.mail_config.json', 'w'), indent=2) +``` + +```python +# CARGAR (app.py líneas 1512-1520) +config = json.load(open('.mail_config.json', 'r')) +password = base64.b64decode(config['password']).decode() # Decodificar +``` + +**Flujo de guardado:** +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ FLUJO DE GUARDADO DE CREDENCIALES │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Usuario ingresa credenciales │ +│ └─► Usuario: marcos@psp.es │ +│ └─► Password: 1234 │ +│ │ +│ 2. Marca checkbox "💾 Recordar credenciales" │ +│ └─► mail_remember_var.get() = True │ +│ │ +│ 3. Al conectar exitosamente, se llama _save_mail_credentials() │ +│ │ +│ 4. Base64 encoding: │ +│ Password "1234" → Bytes b'1234' │ +│ → Base64 b'MTIzNA==' │ +│ → String "MTIzNA==" │ +│ │ +│ 5. Se guarda en .mail_config.json: │ +│ { │ +│ "username": "marcos@psp.es", │ +│ "password": "MTIzNA==" │ +│ } │ +│ │ +│ 6. Al reiniciar app.py: │ +│ └─► _load_mail_credentials() lee el archivo │ +│ └─► Decodifica Base64: "MTIzNA==" → "1234" │ +│ └─► Precarga los campos automáticamente │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**⚠️ IMPORTANTE:** Base64 NO es encriptación, solo ofuscación. El archivo `.mail_config.json` está protegido en `.gitignore` para no subirlo a Git. + +--- + +### 📬 Lectura de Correos (IMAP) + +#### **Función Principal: `_refresh_mail_list()` (líneas 1635-1801)** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ FLUJO DE LECTURA DE CORREOS │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. SELECT 'INBOX' │ +│ └─► imap_connection.select('INBOX') │ +│ │ +│ 2. SEARCH ALL │ +│ └─► status, messages = imap_connection.search(None, 'ALL') │ +│ └─► mail_ids = messages[0].split() # [b'1', b'2', b'3', ...] │ +│ │ +│ 3. FETCH con BODY.PEEK[] (no marca como leído) │ +│ for mail_id in mail_ids: │ +│ status, msg_data = imap_connection.fetch( │ +│ mail_id, │ +│ '(BODY.PEEK[] FLAGS)' # ← PEEK es clave │ +│ ) │ +│ │ +│ 4. EXTRAER FLAGS (\Seen) │ +│ └─► Busca en respuesta IMAP: "FLAGS (\\Seen ...)" │ +│ └─► is_seen = True si contiene "\\Seen" │ +│ │ +│ 5. PARSEAR EMAIL │ +│ └─► msg = email.message_from_bytes(msg_data) │ +│ └─► from_addr = decode_header(msg['From'])[0][0] │ +│ └─► subject = decode_header(msg['Subject'])[0][0] │ +│ │ +│ 6. GUARDAR EN LISTA LOCAL │ +│ self.mail_list.append({ │ +│ 'id': mail_id, │ +│ 'from': from_addr, │ +│ 'subject': subject, │ +│ 'date': date_str, │ +│ 'msg': msg, # Objeto email completo │ +│ 'is_seen': is_seen │ +│ }) │ +│ │ +│ 7. MOSTRAR EN LISTBOX │ +│ if is_seen: │ +│ display = f' {from_addr} - {subject}' # Sin emoji │ +│ itemconfig(idx, fg='#888888') # Gris │ +│ else: │ +│ display = f'🔵 {from_addr} - {subject}' # Con emoji │ +│ itemconfig(idx, fg='#000000') # Negro │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### **¿Por qué BODY.PEEK[]?** + +```python +# ❌ MAL: RFC822 marca el correo como leído automáticamente +fetch(mail_id, 'RFC822') + +# ✅ BIEN: BODY.PEEK[] lee sin cambiar el flag \Seen +fetch(mail_id, '(BODY.PEEK[] FLAGS)') +``` + +--- + +### 📤 Envío de Correos (SMTP) + +#### **Función: `_send_mail_with_attachments()` (líneas 2837-2977)** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ FLUJO DE ENVÍO DE CORREO │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. VALIDAR DESTINATARIOS │ +│ to_addr_raw = "marcos@psp.es, user2@example.com" │ +│ └─► Split por comas/punto y coma │ +│ └─► recipients = ['marcos@psp.es', 'user2@example.com'] │ +│ └─► Validar regex: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,} │ +│ │ +│ 2. CREAR MENSAJE MIME MULTIPART │ +│ from email.mime.multipart import MIMEMultipart │ +│ msg = MIMEMultipart() │ +│ msg['From'] = 'marcos@psp.es' │ +│ msg['To'] = 'marcos@psp.es, user2@example.com' │ +│ msg['Subject'] = 'Asunto del correo' │ +│ │ +│ 3. ADJUNTAR CUERPO │ +│ from email.mime.text import MIMEText │ +│ msg.attach(MIMEText(body, 'plain', 'utf-8')) │ +│ │ +│ 4. ADJUNTAR ARCHIVOS │ +│ for file_path in attachments: │ +│ if file_ext == '.png': │ +│ part = MIMEImage(file_data, name=filename) │ +│ elif file_ext == '.pdf': │ +│ part = MIMEApplication(file_data, _subtype='pdf') │ +│ # ... otros tipos │ +│ msg.attach(part) │ +│ │ +│ 5. CONECTAR SMTP Y ENVIAR │ +│ with smtplib.SMTP('10.10.0.101', 25) as server: │ +│ server.send_message(msg, to_addrs=recipients) │ +│ │ +│ 6. GUARDAR EN CARPETA SENT (IMAP) │ +│ └─► _save_to_sent_folder(msg) # Ver siguiente sección │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### **Tipos MIME Soportados** + +| Extensión | Tipo MIME | Clase Python | +|-----------|-----------|--------------| +| `.png`, `.jpg` | `image/*` | `MIMEImage` | +| `.pdf` | `application/pdf` | `MIMEApplication` | +| `.doc`, `.docx` | `application/msword` | `MIMEApplication` | +| `.xls`, `.xlsx` | `application/vnd.ms-excel` | `MIMEApplication` | +| `.zip`, `.rar` | `application/zip` | `MIMEApplication` | +| `.txt` | `text/plain` | `MIMEText` | +| Otros | `application/octet-stream` | `MIMEBase` | + +--- + +### 💾 Guardado en Servidor IMAP + +#### **Función: `_save_to_sent_folder()` (líneas 2979-3033)** + +Después de enviar un correo por SMTP, se guarda una copia en la carpeta "Sent" del servidor IMAP para que aparezca en Webmin y otros clientes. + +```python +# Intentar múltiples nombres de carpeta +sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items'] + +for folder in sent_folders: + try: + # Intentar seleccionar la carpeta + status, _ = self.imap_connection.select(folder) + if status == 'OK': + # Guardar correo con APPEND + self.imap_connection.append( + folder, + '\\Seen', # Marcar como leído + imaplib.Time2Internaldate(time.time()), + msg.as_bytes() + ) + break + except: + continue + +# Si no existe, crearla +if not folder_found: + self.imap_connection.create('Sent') + self.imap_connection.append('Sent', '\\Seen', date, msg_bytes) +``` + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ GUARDADO EN CARPETA SENT DEL SERVIDOR │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Usuario envía correo con SMTP │ +│ └─► Correo enviado a marcos@psp.es │ +│ │ +│ 2. _save_to_sent_folder() se ejecuta automáticamente │ +│ │ +│ 3. Intenta conectar a carpeta "Sent": │ +│ ├─► Intenta: SELECT 'Sent' ❌ Falla │ +│ ├─► Intenta: SELECT 'INBOX.Sent' ✅ OK │ +│ └─► Carpeta encontrada │ +│ │ +│ 4. Guarda el correo con APPEND: │ +│ APPEND "INBOX.Sent" (\Seen) "19-Feb-2026 19:30:00" {bytes} │ +│ │ +│ 5. Resultado: │ +│ ┌─────────────────────────────────────┐ │ +│ │ SERVIDOR WEBMIN │ │ +│ │ ├─ INBOX (3 correos) │ │ +│ │ └─ Sent (1 correo NUEVO) ← AQUÍ │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ 6. Al abrir Webmin: │ +│ └─► El correo aparece en "Sent" │ +│ └─► Otros clientes (Thunderbird, etc.) también lo ven │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 👥 Envío a Múltiples Destinatarios + +#### **Validación y Parsing (líneas 2703-2740)** + +```python +# Entrada del usuario +to_addr_raw = "marcos@psp.es, user2@example.com; user3@test.org" + +# 1. Split por comas O punto y coma +import re +recipients = re.split(r'[;,]\s*', to_addr_raw) +# → ['marcos@psp.es', 'user2@example.com', 'user3@test.org'] + +# 2. Limpiar espacios +recipients = [r.strip() for r in recipients if r.strip()] + +# 3. Validar formato con regex +email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' +invalid_emails = [email for email in recipients if not re.match(email_pattern, email)] + +if invalid_emails: + messagebox.showwarning('⚠️ Advertencia', + f'Los siguientes emails no son válidos:\n{", ".join(invalid_emails)}') + return + +# 4. Confirmar si son múltiples +if len(recipients) > 1: + confirm = messagebox.askyesno('📧 Múltiples destinatarios', + f'¿Enviar correo a {len(recipients)} destinatarios?\n\n' + + '\n'.join(f' • {email}' for email in recipients)) + if not confirm: + return + +# 5. Enviar a todos +self._send_mail_with_attachments(recipients, subject, body, attachments) +``` + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ENVÍO A MÚLTIPLES DESTINATARIOS │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Usuario escribe en campo "Para:": │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ marcos@psp.es, user2@test.com, user3@example.org │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ Al hacer clic en "📤 ENVIAR": │ +│ │ +│ 1. Parse: Split por ',' o ';' │ +│ ['marcos@psp.es', 'user2@test.com', 'user3@example.org'] │ +│ │ +│ 2. Validación regex de cada email │ +│ ✅ marcos@psp.es → Válido │ +│ ✅ user2@test.com → Válido │ +│ ✅ user3@example.org → Válido │ +│ │ +│ 3. Diálogo de confirmación: │ +│ ┌───────────────────────────────────┐ │ +│ │ ¿Enviar a 3 destinatarios? │ │ +│ │ │ │ +│ │ • marcos@psp.es │ │ +│ │ • user2@test.com │ │ +│ │ • user3@example.org │ │ +│ │ │ │ +│ │ [Sí] [No] │ │ +│ └───────────────────────────────────┘ │ +│ │ +│ 4. SMTP envía a todos: │ +│ msg['To'] = 'marcos@psp.es, user2@test.com, user3@example.org' │ +│ server.send_message(msg, to_addrs=[...]) │ +│ │ +│ 5. Mensaje de éxito: │ +│ ✅ "Correo enviado correctamente a 3 destinatarios" │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 📎 Manejo de Adjuntos + +#### **Adjuntar Archivos** + +```python +# Usuario hace clic en "📎 Adjuntar archivo" +file_paths = filedialog.askopenfilenames( + title='Seleccionar archivos', + filetypes=[ + ('Imágenes', '*.png *.jpg *.jpeg *.gif'), + ('PDFs', '*.pdf'), + ('Documentos', '*.doc *.docx *.xls *.xlsx'), + ('Todos', '*.*') + ] +) + +# Se guardan en lista +attachments.append(file_path) + +# Al enviar, se procesan: +for file_path in attachments: + file_name = os.path.basename(file_path) # "documento.pdf" + file_ext = os.path.splitext(file_path)[1] # ".pdf" + + with open(file_path, 'rb') as f: + file_data = f.read() + + if file_ext == '.pdf': + part = MIMEApplication(file_data, _subtype='pdf') + part.add_header('Content-Disposition', 'attachment', filename=file_name) + msg.attach(part) +``` + +#### **Imágenes Inline con Ctrl+V** + +```python +# Usuario copia una imagen y presiona Ctrl+V +def on_paste(event): + try: + # Obtener imagen del portapapeles + img = ImageGrab.grabclipboard() + + if img: + # Guardar temporalmente + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png') + img.save(temp_file.name) + + # Mostrar miniatura en interfaz + thumbnail = img.resize((150, 150)) + photo = ImageTk.PhotoImage(thumbnail) + label = tk.Label(frame, image=photo) + label.image = photo # Mantener referencia + label.pack() + + # Agregar a lista de adjuntos + inline_images_data.append({'data': img_bytes}) + except: + pass +``` + +--- + +### 🖼️ Visualización de Correos + +#### **Función: `_display_mail()` (líneas 2055-2337)** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ VISUALIZACIÓN DE CORREO │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Usuario hace clic en un correo de la lista │ +│ └─► _on_mail_select() → _display_mail(mail_info) │ +│ │ +│ 2. Marcar como leído en servidor (si es INBOX y no leído) │ +│ if not mail_info['is_seen']: │ +│ imap_connection.store(mail_id, '+FLAGS', '\\Seen') │ +│ # Actualizar visualmente: quitar 🔵, poner gris │ +│ │ +│ 3. Actualizar encabezados │ +│ mail_from_label.config(text='De: marcos@psp.es') │ +│ mail_subject_label.config(text='Asunto: Test') │ +│ mail_date_label.config(text='Fecha: 19/02/2026') │ +│ │ +│ 4. Procesar contenido multipart │ +│ if msg.is_multipart(): │ +│ for part in msg.walk(): │ +│ if content_type == 'text/plain': │ +│ body = part.get_payload(decode=True).decode() │ +│ elif part.get_filename(): # Adjunto │ +│ attachments.append({...}) │ +│ │ +│ 5. Mostrar cuerpo en Text widget │ +│ mail_body_text.delete('1.0', 'end') │ +│ mail_body_text.insert('1.0', body) │ +│ │ +│ 6. Mostrar imágenes inline (si PIL está disponible) │ +│ for att in images: │ +│ img = Image.open(BytesIO(att['data'])) │ +│ img.thumbnail((500, 500)) # Redimensionar │ +│ photo = ImageTk.PhotoImage(img) │ +│ mail_body_text.image_create('end', image=photo) │ +│ │ +│ 7. Mostrar otros adjuntos (PDFs, docs, etc.) │ +│ for att in other_attachments: │ +│ # Frame con icono, nombre, tamaño y botón "💾 Guardar" │ +│ icon = '📄' if PDF else '📝' if Word else '📎' │ +│ Button(text='💾 Guardar', command=save_file) │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 🔵 Sistema de Indicadores Visuales + +```python +# Al cargar correos (líneas 1775-1790) +for mail in mail_list: + if is_seen: + # Correo leído + display_text = f' {from_addr[:27]} - {subject[:37]}' + self.mail_listbox.insert('end', display_text) + idx = self.mail_listbox.size() - 1 + self.mail_listbox.itemconfig(idx, + fg='#888888', # Gris + selectforeground='#666666' + ) + else: + # Correo NO leído + display_text = f'🔵 {from_addr[:27]} - {subject[:37]}' + self.mail_listbox.insert('end', display_text) + idx = self.mail_listbox.size() - 1 + self.mail_listbox.itemconfig(idx, + fg='#000000', # Negro + selectforeground='#1a73e8' + ) + +# Contador de no leídos +self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False)) +self.unread_label.config(text=f'Correos sin leer: {self.unread_count}') +``` + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ APARIENCIA VISUAL EN LISTBOX │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 📬 Bandeja de entrada Correos sin leer: 2 │ │ +│ ├───────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ 🔵 marcos@psp.es - Test para grabación ← NO LEÍDO (negro) │ │ +│ │ 🔵 user@example.com - Propuesta proyecto ← NO LEÍDO (negro) │ │ +│ │ admin@server.com - Notificación ← LEÍDO (gris) │ │ +│ │ webmaster@test.org - Informe ← LEÍDO (gris) │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ Al hacer clic en el primero (🔵 NO LEÍDO): │ +│ 1. Se marca como leído en el servidor (STORE +FLAGS \Seen) │ +│ 2. Se actualiza la visualización: │ +│ ├─ Quita el emoji 🔵 │ +│ ├─ Cambia color a gris │ +│ └─ Decrementa contador: "Correos sin leer: 1" │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 🛡️ Manejo de Errores y Log + +```python +# Función de log (líneas 4304-4315) +def _log(self, text: str) -> None: + # Verificar si estamos en hilo principal + if threading.current_thread() is not threading.main_thread(): + self.after(0, lambda t=text: self._log(t)) + return + + # Verificar si el widget notes existe + if not hasattr(self, 'notes') or self.notes is None: + print(f'[LOG] {text}') # Consola durante inicialización + return + + # Log normal en interfaz + timestamp = datetime.datetime.now().strftime('%H:%M:%S') + self.notes.insert('end', f'[{timestamp}] {text}\n') + self.notes.see('end') +``` + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ EJEMPLO DE LOG EN INTERFAZ │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 📝 Panel de Notas │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ [19:28:43] Conectando a 10.10.0.101:143... │ │ +│ │ [19:28:44] Conexión IMAP establecida │ │ +│ │ [19:28:44] Carpetas IMAP disponibles: ['INBOX', 'Sent'] │ │ +│ │ [19:28:44] Credenciales guardadas correctamente │ │ +│ │ [19:28:45] Cargando 8 correos... │ │ +│ │ [19:28:46] 8 correos cargados (2 sin leer) │ │ +│ │ [19:29:10] === CORREO SELECCIONADO #0: Test grabación === │ │ +│ │ [19:29:10] Correo marcado como leído en el servidor │ │ +│ │ [19:29:10] >>> Actualizando encabezados │ │ +│ │ [19:29:10] Texto plano encontrado: 245 caracteres │ │ +│ │ [19:29:10] Adjunto detectado: imagen.png (image/png) │ │ +│ │ [19:29:10] Imagen inline mostrada: imagen.png │ │ +│ │ [19:29:10] >>> _display_mail COMPLETADO OK │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 📊 Resumen de Funciones Clave + +| Función | Líneas | Responsabilidad | +|---------|--------|-----------------| +| `_build_tab_correos()` | 632-850 | Construir toda la interfaz del tab Correos | +| `_load_mail_credentials()` | 1483-1525 | Cargar credenciales de `.mail_config.json` | +| `_save_mail_credentials()` | 1527-1559 | Guardar credenciales con Base64 | +| `_connect_mail_server()` | 1561-1613 | Conectar a IMAP, listar carpetas | +| `_refresh_mail_list()` | 1635-1801 | Cargar correos de INBOX con FETCH | +| `_show_inbox()` | 1803-1811 | Cambiar a bandeja de entrada | +| `_show_sent()` | 1828-1880 | Cambiar a carpeta de enviados | +| `_on_mail_select()` | 1982-2053 | Manejar clic en correo, marcar como leído | +| `_display_mail()` | 2055-2337 | Mostrar contenido, imágenes y adjuntos | +| `_open_compose_window()` | 2353-2788 | Abrir ventana de redacción | +| `_send_mail_with_attachments()` | 2837-2977 | Enviar correo por SMTP con adjuntos | +| `_save_to_sent_folder()` | 2979-3033 | Guardar copia en servidor IMAP | + +--- + +### 🔄 Flujo Completo de Usuario + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ FLUJO COMPLETO: LEER Y ENVIAR CORREO │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. INICIAR APLICACIÓN │ +│ └─► python3 app.py │ +│ └─► _load_mail_credentials() precarga usuario y contraseña │ +│ │ +│ 2. IR A TAB "CORREOS" │ +│ └─► _build_tab_correos() ya construyó la interfaz │ +│ │ +│ 3. CONECTAR │ +│ └─► Clic en "🔗 Conectar" │ +│ └─► _connect_mail_server() │ +│ ├─ IMAP4('10.10.0.101', 143) │ +│ ├─ login('marcos@psp.es', '1234') │ +│ └─ list() → Muestra carpetas disponibles │ +│ └─► _save_mail_credentials() si "Recordar" está marcado │ +│ └─► _refresh_mail_list() carga correos automáticamente │ +│ │ +│ 4. LEER CORREO │ +│ └─► Clic en correo de la lista │ +│ └─► _on_mail_select() │ +│ ├─ store(id, '+FLAGS', '\\Seen') si no leído │ +│ └─ _display_mail(mail_info) │ +│ ├─ Actualiza encabezados │ +│ ├─ Muestra cuerpo │ +│ ├─ Muestra imágenes inline │ +│ └─ Muestra botones para guardar adjuntos │ +│ │ +│ 5. ENVIAR NUEVO CORREO │ +│ └─► Clic en "✉️ Nuevo correo" │ +│ └─► _open_compose_window() │ +│ ├─ Ventana emergente con campos │ +│ ├─ Botón "📎 Adjuntar archivo" │ +│ ├─ Soporte Ctrl+V para imágenes │ +│ └─ Botón "📤 ENVIAR CORREO" │ +│ ├─ Validar destinatarios (regex) │ +│ ├─ Confirmar si múltiples │ +│ ├─ _send_mail_with_attachments() │ +│ │ ├─ Crear MIMEMultipart │ +│ │ ├─ Adjuntar archivos │ +│ │ └─ SMTP send_message() │ +│ └─ _save_to_sent_folder() │ +│ └─ IMAP APPEND a 'Sent' │ +│ │ +│ 6. VERIFICAR EN WEBMIN │ +│ └─► Abrir http://10.10.0.101:20000 │ +│ └─► Carpeta "Sent" → Correo aparece ahí │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + ## 🎨 Características del Dashboard ### 📊 Monitor del Sistema diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md new file mode 100644 index 0000000..9397fd0 --- /dev/null +++ b/TESTING_GUIDE.md @@ -0,0 +1,286 @@ +# Guía de Pruebas - Cliente de Correo Electrónico +## Proyecto1AVApsp - Sesión del 16-19 de Febrero 2026 + +Esta guía te ayudará a probar todas las funcionalidades recién implementadas en el cliente de correo. + +--- + +## 🔧 CORRECCIÓN APLICADA (19 Feb 2026) + +**Problema**: Error `AttributeError: '_tkinter.tkapp' object has no attribute 'notes'` al iniciar la aplicación +**Causa**: La función `_load_mail_credentials()` intentaba escribir en el log antes de que el widget `notes` se hubiera creado +**Solución**: Modificada la función `_log()` (app.py:4304) para verificar si el widget existe antes de usarlo +**Estado**: ✅ **CORREGIDO** - La aplicación ahora arranca sin errores + +--- + +## ✅ FUNCIONALIDADES IMPLEMENTADAS EN ESTA SESIÓN + +### 1. **Guardado automático de credenciales** +### 2. **Envío a múltiples destinatarios** +### 3. **Guardado de correos enviados en el servidor IMAP** +### 4. **Eliminación del botón "Actualizar"** +### 5. **Corrección visual de indicadores de correos no leídos** + +--- + +## 🧪 PLAN DE PRUEBAS + +### PRUEBA 1: Guardado de Credenciales + +**Objetivo**: Verificar que las credenciales se guarden y carguen automáticamente + +**Pasos**: +1. Ejecuta la aplicación: `python3 app.py` +2. Ve a la pestaña "Correos" +3. Observa si los campos están precargados con tus credenciales +4. Si es la primera vez, introduce: + - Usuario: `marcos@psp.es` + - Contraseña: tu contraseña +5. Marca la casilla "💾 Recordar credenciales" (debe estar marcada por defecto) +6. Haz clic en "🔌 Conectar" +7. Verifica que aparezca el mensaje "Credenciales guardadas correctamente" en el log +8. **CIERRA completamente la aplicación** +9. Vuelve a ejecutar: `python3 app.py` +10. Ve a la pestaña "Correos" + +**Resultado esperado**: +- ✅ Los campos de usuario, contraseña y servidores deben estar precargados +- ✅ El archivo `.mail_config.json` existe en el directorio del proyecto +- ✅ Puedes conectarte sin volver a escribir las credenciales + +**Para probar el borrado de credenciales**: +1. Conecta al servidor +2. Desconecta +3. Desmarca "💾 Recordar credenciales" +4. Vuelve a conectar +5. Cierra la aplicación +6. Al abrir de nuevo, los campos deben estar vacíos + +--- + +### PRUEBA 2: Múltiples Destinatarios + +**Objetivo**: Verificar que se pueden enviar correos a varios destinatarios simultáneamente + +**Pasos**: +1. Conecta al servidor de correo +2. Haz clic en "✉️ Nuevo correo" +3. En el campo "Para:", introduce **múltiples correos separados por comas**: + ``` + marcos@psp.es, destinatario2@ejemplo.com, destinatario3@ejemplo.com + ``` +4. También puedes usar **punto y coma**: + ``` + marcos@psp.es; destinatario2@ejemplo.com + ``` +5. Introduce un asunto: "Prueba múltiples destinatarios" +6. Escribe un mensaje: "Este es un correo de prueba" +7. Haz clic en "📤 ENVIAR CORREO" + +**Resultado esperado**: +- ✅ Aparece un diálogo de confirmación mostrando los 3 destinatarios +- ✅ El mensaje dice: "¿Enviar correo a 3 destinatarios?" +- ✅ Se listan todos los correos con un • al inicio +- ✅ Al confirmar, el correo se envía correctamente +- ✅ El mensaje de éxito indica "enviado a 3 destinatarios" + +**Prueba con email inválido**: +1. Intenta enviar a: `marcos@psp.es, correo-invalido, otro@ejemplo.com` +2. Haz clic en "📤 ENVIAR CORREO" + +**Resultado esperado**: +- ✅ Aparece advertencia: "Los siguientes emails no son válidos: correo-invalido" +- ✅ NO se envía el correo hasta que corrijas el formato + +--- + +### PRUEBA 3: Guardado en Carpeta "Enviados" del Servidor + +**Objetivo**: Verificar que los correos enviados se guardan en el servidor IMAP y son visibles en Webmin + +**Pasos**: +1. Conecta al servidor de correo en la aplicación +2. Envía un correo de prueba: + - **Para**: marcos@psp.es + - **Asunto**: "Prueba guardado en servidor" + - **Mensaje**: "Verificando que este correo se guarda en IMAP" +3. Observa el log en la parte inferior de la aplicación +4. Busca mensajes como: + ``` + Correo guardado en carpeta: Sent + ``` +5. Haz clic en el botón "📧 Enviados" en la aplicación +6. **Verifica que el correo aparece en la lista** +7. Ahora abre tu navegador y ve a: `http://10.10.0.101:20000` +8. Inicia sesión en Webmin +9. Ve a "Correo" → "Usermin" → "Read Email" o accede directamente a tu cliente de correo Webmin +10. Busca la carpeta "Sent" o "Enviados" + +**Resultado esperado**: +- ✅ El correo aparece en la pestaña "📧 Enviados" de la aplicación +- ✅ El correo también aparece en Webmin en la carpeta de enviados +- ✅ Si abres el correo en Webmin, debe tener el contenido correcto +- ✅ Los adjuntos (si los había) también están presentes + +**Si falla**: +- Revisa el log de la aplicación para ver qué carpeta se intentó usar +- Las carpetas probadas automáticamente son: + - `Sent` + - `INBOX.Sent` + - `Enviados` + - `INBOX.Enviados` + - `Sent Items` +- Si tu servidor usa otro nombre, aparecerá un mensaje de error en el log + +--- + +### PRUEBA 4: Interfaz sin Botón "Actualizar" + +**Objetivo**: Verificar que la interfaz se actualiza automáticamente sin necesidad del botón + +**Pasos**: +1. Conecta al servidor +2. Observa la barra de herramientas (arriba de la lista de correos) +3. Verifica que solo hay **un botón**: "✉️ Nuevo correo" +4. El botón "🔄 Actualizar" ya **NO debe existir** +5. Haz clic en "📧 Enviados" +6. Observa que la lista se actualiza automáticamente +7. Haz clic en "📬 Entrada" +8. Nuevamente, la lista se actualiza sin necesidad de botón + +**Resultado esperado**: +- ✅ No existe el botón "🔄 Actualizar" +- ✅ La lista se actualiza automáticamente al cambiar de carpeta +- ✅ La interfaz se ve más limpia + +--- + +### PRUEBA 5: Indicadores Visuales de Correos No Leídos + +**Objetivo**: Verificar que los correos no leídos se distinguen visualmente sin errores + +**Pasos**: +1. Conecta al servidor +2. Ve a "📬 Entrada" +3. Observa la lista de correos +4. Identifica correos no leídos y leídos + +**Resultado esperado**: +- ✅ **Correos NO leídos**: Tienen el emoji 🔵 al inicio y texto en **negro** +- ✅ **Correos leídos**: **NO** tienen emoji y el texto está en **gris** +- ✅ NO aparece el error: "unknown option '-font'" +- ✅ La lista se carga sin errores en el log + +**Apariencia visual**: +``` +🔵 [01/02/25] De: remitente@ejemplo.com | Asunto: Correo nuevo ← No leído + [31/01/25] De: otro@ejemplo.com | Asunto: Correo antiguo ← Leído (gris) +``` + +--- + +## 🔍 PRUEBAS ADICIONALES RECOMENDADAS + +### Prueba de Adjuntos con Múltiples Destinatarios +1. Envía un correo a 2-3 destinatarios +2. Incluye 1-2 archivos adjuntos (imágenes, PDFs) +3. Verifica que todos los destinatarios reciben los adjuntos + +### Prueba de Imágenes Pegadas (Ctrl+V) +1. Copia una imagen del portapapeles +2. En la ventana de composición, presiona Ctrl+V +3. Verifica que la imagen se muestra en la vista previa +4. Envía el correo +5. Verifica en "Enviados" que el correo tiene el adjunto + +### Prueba de Longitud de Lista de Destinatarios +1. Intenta enviar a 10+ destinatarios +2. Verifica que el diálogo de confirmación muestra todos +3. Confirma que se envía correctamente + +--- + +## 🐛 QUÉ HACER SI ENCUENTRAS ERRORES + +### Error: "No se puede guardar en carpeta Sent" +**Solución**: +1. Revisa el log para ver qué carpetas se intentaron +2. Conéctate a Webmin y verifica el nombre exacto de tu carpeta de enviados +3. Si tiene un nombre diferente, avísame para ajustar el código + +### Error: "Los siguientes emails no son válidos" +**Causa**: Formato incorrecto de email +**Solución**: Verifica que todos los emails tengan el formato: `usuario@dominio.ext` + +### Error: "unknown option '-font'" +**Causa**: Este error ya fue corregido +**Solución**: Si aún aparece, verifica que estás usando la versión más reciente de `app.py` + +### Credenciales no se cargan automáticamente +**Solución**: +1. Verifica que el archivo `.mail_config.json` existe +2. Ejecuta: `cat .mail_config.json` para ver su contenido +3. Verifica que la casilla "💾 Recordar credenciales" esté marcada al conectar + +--- + +## 📊 REGISTRO DE PRUEBAS + +Usa esta tabla para registrar tus pruebas: + +| Prueba | Estado | Observaciones | +|--------|--------|---------------| +| Guardado de credenciales | ⬜ Pendiente / ✅ OK / ❌ Error | | +| Múltiples destinatarios | ⬜ Pendiente / ✅ OK / ❌ Error | | +| Guardado en servidor IMAP | ⬜ Pendiente / ✅ OK / ❌ Error | | +| Sin botón "Actualizar" | ⬜ Pendiente / ✅ OK / ❌ Error | | +| Indicadores visuales | ⬜ Pendiente / ✅ OK / ❌ Error | | + +--- + +## 📝 NOTAS IMPORTANTES + +### Seguridad de Credenciales +- Las contraseñas se guardan con codificación Base64 (NO es encriptación real) +- Cualquiera con acceso al archivo `.mail_config.json` puede decodificar la contraseña +- El archivo está excluido de Git para no subirlo accidentalmente +- Para mayor seguridad en producción, considera usar `cryptography.fernet` + +### Limitaciones Conocidas +- El servidor IMAP/SMTP debe estar accesible en `10.10.0.101` +- No se usa TLS/SSL (puerto 25 y 143 son sin cifrado) +- Los correos HTML se muestran como texto plano +- No hay soporte para CC/BCC (se puede agregar si lo necesitas) + +--- + +## ✨ PRÓXIMAS MEJORAS SUGERIDAS + +Si todo funciona correctamente, estas son funcionalidades que se pueden agregar: + +1. **Campos CC y BCC** para copias de correos +2. **Responder y Reenviar** correos existentes +3. **Búsqueda de correos** por remitente, asunto o contenido +4. **Firma automática** al final de cada correo +5. **Plantillas de correo** predefinidas +6. **Encriptación real** de contraseñas con Fernet +7. **Soporte TLS/SSL** para conexiones seguras +8. **Carpetas personalizadas** (Borradores, Papelera, etc.) +9. **Exportar correos** a PDF o HTML +10. **Notificaciones de escritorio** para correos nuevos + +--- + +## 🚀 EJECUTAR PRUEBAS + +Para empezar a probar, simplemente ejecuta: + +```bash +cd /home/marcos/Documentos/Proyecto1AVApsp +python3 app.py +``` + +Y sigue las pruebas en orden del 1 al 5. + +**¡Buena suerte con las pruebas!** Si encuentras algún problema, avísame con el mensaje de error del log. diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc deleted file mode 100644 index ee377de..0000000 Binary files a/__pycache__/app.cpython-313.pyc and /dev/null differ diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc deleted file mode 100644 index f7063db..0000000 Binary files a/__pycache__/app.cpython-314.pyc and /dev/null differ diff --git a/app.py b/app.py index 6b493e7..08bf6b1 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ import sys import tempfile import threading import time +import traceback import webbrowser from dataclasses import dataclass from typing import Any, Callable @@ -629,14 +630,237 @@ class DashboardApp(tk.Tk): tk.Button(frame, text='Abrir', command=lambda: self._open_web(self.url_entry.get())).pack(side='left', padx=4) def _build_tab_correos(self) -> None: - tk.Label( - self.tab_correos, - text='Área futura para gestionar correos.' - '\nDe momento, utiliza la pestaña "Bloc de notas" para escribir.', - bg='white', - font=('Arial', 12), - justify='left' - ).pack(pady=20) + """Construye la interfaz completa del cliente de correo - Diseño mejorado""" + self.tab_correos.configure(bg='#f0f2f5') + + # Variables de estado del correo + self.mail_connected = False + self.imap_connection = None + self.current_mailbox = 'INBOX' + self.mail_list = [] + self.mail_attachments = [] + self.unread_count = 0 + self.unread_label = None + self.mail_filter_unread = False # Filtro para mostrar solo no leídos + + # Panel de conexión con diseño moderno + conn_frame = tk.LabelFrame(self.tab_correos, text='⚙️ Configuración del servidor', + bg='#ffffff', font=('Arial', 12, 'bold'), fg='#1a73e8', + padx=15, pady=12, relief='solid', bd=1) + conn_frame.pack(fill='x', padx=15, pady=12) + + # Grid para campos de configuración + config_grid = tk.Frame(conn_frame, bg='#ffffff') + config_grid.pack(fill='x', pady=5) + + # Fila 1: IMAP + tk.Label(config_grid, text='📥 IMAP:', bg='#ffffff', font=('Arial', 10, 'bold'), + fg='#5f6368', width=12, anchor='e').grid(row=0, column=0, padx=8, pady=6, sticky='e') + self.mail_imap_host = tk.Entry(config_grid, width=18, font=('Arial', 10), + relief='solid', bd=1, bg='#f8f9fa') + self.mail_imap_host.insert(0, '10.10.0.101') + self.mail_imap_host.grid(row=0, column=1, padx=5, pady=6, sticky='w') + + tk.Label(config_grid, text='Puerto:', bg='#ffffff', font=('Arial', 10), + fg='#5f6368').grid(row=0, column=2, padx=5, pady=6, sticky='e') + self.mail_imap_port = tk.Entry(config_grid, width=8, font=('Arial', 10), + relief='solid', bd=1, bg='#f8f9fa') + self.mail_imap_port.insert(0, '143') + self.mail_imap_port.grid(row=0, column=3, padx=5, pady=6, sticky='w') + + # Fila 2: SMTP + tk.Label(config_grid, text='📤 SMTP:', bg='#ffffff', font=('Arial', 10, 'bold'), + fg='#5f6368', width=12, anchor='e').grid(row=1, column=0, padx=8, pady=6, sticky='e') + self.mail_smtp_host = tk.Entry(config_grid, width=18, font=('Arial', 10), + relief='solid', bd=1, bg='#f8f9fa') + self.mail_smtp_host.insert(0, '10.10.0.101') + self.mail_smtp_host.grid(row=1, column=1, padx=5, pady=6, sticky='w') + + tk.Label(config_grid, text='Puerto:', bg='#ffffff', font=('Arial', 10), + fg='#5f6368').grid(row=1, column=2, padx=5, pady=6, sticky='e') + self.mail_smtp_port = tk.Entry(config_grid, width=8, font=('Arial', 10), + relief='solid', bd=1, bg='#f8f9fa') + self.mail_smtp_port.insert(0, '25') + self.mail_smtp_port.grid(row=1, column=3, padx=5, pady=6, sticky='w') + + # Fila 3: Credenciales + tk.Label(config_grid, text='👤 Usuario:', bg='#ffffff', font=('Arial', 10, 'bold'), + fg='#5f6368', width=12, anchor='e').grid(row=2, column=0, padx=8, pady=6, sticky='e') + self.mail_username = tk.Entry(config_grid, width=35, font=('Arial', 10), + relief='solid', bd=1, bg='#f8f9fa') + self.mail_username.grid(row=2, column=1, columnspan=3, padx=5, pady=6, sticky='ew') + + tk.Label(config_grid, text='🔒 Contraseña:', bg='#ffffff', font=('Arial', 10, 'bold'), + fg='#5f6368', width=12, anchor='e').grid(row=3, column=0, padx=8, pady=6, sticky='e') + self.mail_password = tk.Entry(config_grid, width=35, font=('Arial', 10), + show='•', relief='solid', bd=1, bg='#f8f9fa') + self.mail_password.grid(row=3, column=1, columnspan=3, padx=5, pady=6, sticky='ew') + + # Checkbox para recordar credenciales + remember_frame = tk.Frame(config_grid, bg='#ffffff') + remember_frame.grid(row=4, column=1, columnspan=3, padx=5, pady=8, sticky='w') + self.mail_remember_var = tk.BooleanVar(value=True) + tk.Checkbutton(remember_frame, text='💾 Recordar credenciales', variable=self.mail_remember_var, + bg='#ffffff', font=('Arial', 9), fg='#5f6368', + selectcolor='#ffffff', activebackground='#ffffff', + cursor='hand2').pack(side='left') + + # Cargar credenciales guardadas + self._load_mail_credentials() + + # Botones de conexión con diseño mejorado + btn_row = tk.Frame(conn_frame, bg='#ffffff') + btn_row.pack(fill='x', pady=12) + + self.btn_mail_connect = tk.Button(btn_row, text='🔗 Conectar', command=self._connect_mail_server, + bg='#1a73e8', fg='white', relief='flat', + font=('Arial', 11, 'bold'), padx=20, pady=8, + cursor='hand2', activebackground='#1557b0') + self.btn_mail_connect.pack(side='left', padx=8) + + self.btn_mail_disconnect = tk.Button(btn_row, text='⚠️ Desconectar', command=self._disconnect_mail_server, + bg='#dc3545', fg='white', relief='flat', + font=('Arial', 11, 'bold'), padx=20, pady=8, + state='disabled', cursor='hand2', activebackground='#bd2130') + self.btn_mail_disconnect.pack(side='left', padx=8) + + # Estado con diseño más llamativo + status_frame = tk.Frame(btn_row, bg='#fef3cd', relief='solid', bd=1, padx=12, pady=6) + status_frame.pack(side='left', padx=15) + self.mail_status_label = tk.Label(status_frame, text='⚫ Desconectado', bg='#fef3cd', + fg='#856404', font=('Arial', 10, 'bold')) + self.mail_status_label.pack() + + # Panel principal dividido con mejor diseño + main_panel = tk.PanedWindow(self.tab_correos, orient='horizontal', bg='#f0f2f5', + sashwidth=8, sashrelief='raised', bd=0) + main_panel.pack(fill='both', expand=True, padx=15, pady=(0, 15)) + + # ========== Panel izquierdo: Lista de correos ========== + left_panel = tk.Frame(main_panel, bg='#ffffff', relief='solid', bd=1) + main_panel.add(left_panel, minsize=350) + + # Header de la lista con gradiente visual + toolbar = tk.Frame(left_panel, bg='#1a73e8', height=50) + toolbar.pack(fill='x') + + toolbar_content = tk.Frame(toolbar, bg='#1a73e8') + toolbar_content.pack(fill='both', expand=True, padx=12, pady=10) + + # Label del buzón actual + self.mail_folder_label = tk.Label(toolbar_content, text='📬 Bandeja de entrada', bg='#1a73e8', + fg='white', font=('Arial', 13, 'bold')) + self.mail_folder_label.pack(side='left') + + # Botones de carpetas + btn_folders = tk.Frame(toolbar_content, bg='#1a73e8') + btn_folders.pack(side='left', padx=15) + + tk.Button(btn_folders, text='📬 Entrada', command=self._show_inbox, + bg='#5f9cf4', fg='white', relief='flat', font=('Arial', 9, 'bold'), + padx=10, pady=5, cursor='hand2', activebackground='#4285f4').pack(side='left', padx=2) + + tk.Button(btn_folders, text='📤 Enviados', command=self._show_sent, + bg='#5f9cf4', fg='white', relief='flat', font=('Arial', 9, 'bold'), + padx=10, pady=5, cursor='hand2', activebackground='#4285f4').pack(side='left', padx=2) + + # Botón de filtro "Sin leer" + self.btn_filter_unread = tk.Button(btn_folders, text='🔵 Sin leer', command=self._toggle_filter_unread, + bg='#7c8691', fg='white', relief='flat', font=('Arial', 9, 'bold'), + padx=10, pady=5, cursor='hand2', activebackground='#5f6368') + self.btn_filter_unread.pack(side='left', padx=2) + + # Botones de acción con estilo + btn_actions = tk.Frame(toolbar_content, bg='#1a73e8') + btn_actions.pack(side='right') + + tk.Button(btn_actions, text='✉️ Nuevo correo', command=self._open_compose_window, + bg='#34a853', fg='white', relief='flat', font=('Arial', 10, 'bold'), + padx=15, pady=6, cursor='hand2', activebackground='#2d8e47').pack(side='right', padx=3) + + # Lista de correos mejorada + list_frame = tk.Frame(left_panel, bg='#ffffff') + list_frame.pack(fill='both', expand=True, padx=2, pady=2) + + scrollbar = tk.Scrollbar(list_frame, width=14) + scrollbar.pack(side='right', fill='y') + + self.mail_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set, + font=('Segoe UI', 10), height=20, + selectbackground='#e8f0fe', selectforeground='#1a73e8', + bg='#ffffff', fg='#202124', relief='flat', + activestyle='none', highlightthickness=0) + self.mail_listbox.pack(side='left', fill='both', expand=True) + scrollbar.config(command=self.mail_listbox.yview) + self.mail_listbox.bind('<>', self._on_mail_select) + + # ========== Panel derecho: Visor de correo ========== + right_panel = tk.Frame(main_panel, bg='#ffffff', relief='solid', bd=1) + main_panel.add(right_panel, minsize=500) + + # Header del correo con diseño atractivo + mail_header = tk.Frame(right_panel, bg='#f8f9fa', relief='solid', bd=1) + mail_header.pack(fill='x', padx=0, pady=0) + + headers_content = tk.Frame(mail_header, bg='#f8f9fa') + headers_content.pack(fill='x', padx=20, pady=15) + + # Asunto destacado + subject_frame = tk.Frame(headers_content, bg='#f8f9fa') + subject_frame.pack(fill='x', pady=(0, 10)) + self.mail_subject_label = tk.Label(subject_frame, text='', bg='#f8f9fa', + font=('Arial', 14, 'bold'), anchor='w', + fg='#202124', wraplength=600) + self.mail_subject_label.pack(fill='x') + + # Info del remitente + from_frame = tk.Frame(headers_content, bg='#f8f9fa') + from_frame.pack(fill='x', pady=3) + tk.Label(from_frame, text='De:', bg='#f8f9fa', font=('Arial', 10, 'bold'), + fg='#5f6368', width=10, anchor='w').pack(side='left') + self.mail_from_label = tk.Label(from_frame, text='', bg='#f8f9fa', + font=('Arial', 10), anchor='w', fg='#1a73e8') + self.mail_from_label.pack(side='left', fill='x', expand=True) + + # Fecha + date_frame = tk.Frame(headers_content, bg='#f8f9fa') + date_frame.pack(fill='x', pady=3) + tk.Label(date_frame, text='Fecha:', bg='#f8f9fa', font=('Arial', 10, 'bold'), + fg='#5f6368', width=10, anchor='w').pack(side='left') + self.mail_date_label = tk.Label(date_frame, text='', bg='#f8f9fa', + font=('Arial', 10), anchor='w', fg='#5f6368') + self.mail_date_label.pack(side='left', fill='x', expand=True) + + # Separador elegante + tk.Frame(right_panel, height=2, bg='#dadce0').pack(fill='x') + + # Frame para cuerpo y adjuntos + content_frame = tk.Frame(right_panel, bg='#ffffff') + content_frame.pack(fill='both', expand=True) + + # Cuerpo del correo con mejor tipografía - usando Text para soportar imágenes inline + text_scroll_frame = tk.Frame(content_frame, bg='#ffffff') + text_scroll_frame.pack(fill='both', expand=True) + + mail_body_scrollbar = tk.Scrollbar(text_scroll_frame) + mail_body_scrollbar.pack(side='right', fill='y') + + self.mail_body_text = tk.Text(text_scroll_frame, wrap='word', + font=('Segoe UI', 11), + bg='#ffffff', fg='#202124', + relief='flat', padx=15, pady=15, + spacing1=3, spacing3=5, + yscrollcommand=mail_body_scrollbar.set) + self.mail_body_text.pack(side='left', fill='both', expand=True) + mail_body_scrollbar.config(command=self.mail_body_text.yview) + self.mail_body_text.config(state='disabled') + + # Lista para almacenar referencias de imágenes inline (evitar garbage collection) + self.mail_inline_images = [] + + # Frame para mostrar adjuntos (imágenes) + self.mail_attachments_frame = tk.Frame(content_frame, bg='#f8f9fa') + # No se empaqueta hasta que haya adjuntos def _build_tab_bloc_notas(self) -> None: tk.Label(self.tab_bloc, text='Bloc de notas', bg='white', font=('Arial', 12, 'bold')).pack(pady=6) @@ -1117,7 +1341,8 @@ class DashboardApp(tk.Tk): for idx in range(3): status.columnconfigure(idx, weight=1) - tk.Label(status, text='Correos sin leer', font=('Arial', 11, 'bold'), bg='#f1f1f1').grid(row=0, column=0, padx=16, pady=6, sticky='w') + self.unread_label = tk.Label(status, text='Correos sin leer: 0', font=('Arial', 11, 'bold'), bg='#f1f1f1') + self.unread_label.grid(row=0, column=0, padx=16, pady=6, sticky='w') self.traffic_label = tk.Label( status, @@ -1254,6 +1479,1568 @@ class DashboardApp(tk.Tk): fh.write(self.editor.get('1.0', 'end')) self._log(f'Guardado en {path}') + # -------------------- Funciones de correo electrónico -------------------- + def _load_mail_credentials(self) -> None: + """Carga las credenciales guardadas del archivo de configuración""" + try: + import base64 + config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json') + + if not os.path.exists(config_file): + return + + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + + # Cargar configuración del servidor + self.mail_imap_host.delete(0, 'end') + self.mail_imap_host.insert(0, config.get('imap_host', '10.10.0.101')) + + self.mail_imap_port.delete(0, 'end') + self.mail_imap_port.insert(0, config.get('imap_port', '143')) + + self.mail_smtp_host.delete(0, 'end') + self.mail_smtp_host.insert(0, config.get('smtp_host', '10.10.0.101')) + + self.mail_smtp_port.delete(0, 'end') + self.mail_smtp_port.insert(0, config.get('smtp_port', '25')) + + # Cargar credenciales (contraseña codificada en base64 para ofuscación básica) + self.mail_username.delete(0, 'end') + self.mail_username.insert(0, config.get('username', '')) + + if 'password' in config and config['password']: + try: + # Decodificar contraseña + password_encoded = config['password'] + password = base64.b64decode(password_encoded).decode('utf-8') + self.mail_password.delete(0, 'end') + self.mail_password.insert(0, password) + except Exception: + pass + + self._log('Credenciales de correo cargadas') + + except Exception as e: + self._log(f'No se pudieron cargar credenciales: {e}') + + def _save_mail_credentials(self) -> None: + """Guarda las credenciales en un archivo de configuración""" + if not self.mail_remember_var.get(): + # Si no está marcado "recordar", eliminar el archivo de configuración + try: + config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json') + if os.path.exists(config_file): + os.remove(config_file) + self._log('Credenciales eliminadas') + except Exception: + pass + return + + try: + import base64 + + config = { + 'imap_host': self.mail_imap_host.get().strip(), + 'imap_port': self.mail_imap_port.get().strip(), + 'smtp_host': self.mail_smtp_host.get().strip(), + 'smtp_port': self.mail_smtp_port.get().strip(), + 'username': self.mail_username.get().strip(), + 'password': base64.b64encode(self.mail_password.get().encode('utf-8')).decode('utf-8') + } + + config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json') + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2) + + self._log('Credenciales guardadas correctamente') + + except Exception as e: + self._log(f'Error al guardar credenciales: {e}') + + def _connect_mail_server(self) -> None: + """Conecta al servidor IMAP para leer correos""" + import imaplib + + host = self.mail_imap_host.get().strip() + port = self.mail_imap_port.get().strip() + username = self.mail_username.get().strip() + password = self.mail_password.get() + + if not host or not port or not username or not password: + messagebox.showerror('Error', 'Todos los campos son obligatorios') + return + + try: + port_num = int(port) + self._log(f'Conectando a {host}:{port_num}...') + + # Conectar a IMAP + self.imap_connection = imaplib.IMAP4(host, port_num) + self.imap_connection.login(username, password) + self.imap_connection.select('INBOX') + + self.mail_connected = True + self.mail_status_label.config(text='🟢 Conectado', fg='#137333', bg='#ceead6') + self.btn_mail_connect.config(state='disabled') + self.btn_mail_disconnect.config(state='normal') + + self._log('Conexión establecida correctamente') + + # Listar carpetas IMAP disponibles para debugging + try: + status, folders = self.imap_connection.list() + if status == 'OK': + self._log('=== Carpetas IMAP disponibles en el servidor ===') + for folder in folders: + folder_str = folder.decode() if isinstance(folder, bytes) else str(folder) + self._log(f' - {folder_str}') + self._log('=' * 50) + except Exception as e: + self._log(f'No se pudieron listar carpetas: {e}') + + messagebox.showinfo('✅ Éxito', 'Conectado al servidor de correo') + + # Guardar credenciales si está marcado "recordar" + self._save_mail_credentials() + + # Cargar lista de correos + self._refresh_mail_list() + + except Exception as exc: + self._log(f'Error al conectar: {exc}') + messagebox.showerror('❌ Error de conexión', f'No se pudo conectar al servidor:\n{exc}') + self.imap_connection = None + + def _disconnect_mail_server(self) -> None: + """Desconecta del servidor IMAP""" + if self.imap_connection: + try: + self.imap_connection.close() + self.imap_connection.logout() + except Exception: + pass + self.imap_connection = None + + self.mail_connected = False + self.mail_status_label.config(text='⚫ Desconectado', fg='#856404', bg='#fef3cd') + self.btn_mail_connect.config(state='normal') + self.btn_mail_disconnect.config(state='disabled') + self.mail_listbox.delete(0, 'end') + self.mail_list = [] + self._clear_mail_display() + self._log('Desconectado del servidor de correo') + + def _refresh_mail_list(self) -> None: + """Actualiza la lista de correos desde el servidor""" + if not self.mail_connected or not self.imap_connection: + messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor') + return + + try: + import email + from email.header import decode_header + + self.mail_listbox.delete(0, 'end') + self.mail_list = [] + + # Buscar todos los correos en el buzón actual + self.imap_connection.select(self.current_mailbox) + status, messages = self.imap_connection.search(None, 'ALL') + + if status != 'OK': + messagebox.showerror('Error', 'No se pudieron obtener los correos') + return + + mail_ids = messages[0].split() + + # Ordenar del más reciente al más antiguo + mail_ids = list(reversed(mail_ids)) + + # Limitar a los últimos 50 correos + mail_ids = mail_ids[:50] + + self._log(f'Cargando {len(mail_ids)} correos...') + + for mail_id in mail_ids: + # Obtenemos el correo con BODY.PEEK[] en lugar de RFC822 para NO marcarlo como leído + status, msg_data = self.imap_connection.fetch(mail_id, '(BODY.PEEK[] FLAGS)') + + if status != 'OK': + continue + + # Extraer flags correctamente desde la respuesta IMAP + is_seen = False + flags_debug = "" + try: + # La respuesta IMAP tiene formato: [(b'1 (FLAGS (\\Seen) BODY[] {size}', b'email_data'), b')'] + # Buscamos el flag \Seen en toda la respuesta + for item in msg_data: + if isinstance(item, tuple): + for part in item: + if isinstance(part, bytes): + part_str = part.decode('utf-8', errors='ignore') + # Buscar específicamente la sección FLAGS entre paréntesis + if 'FLAGS (' in part_str: + flags_debug = part_str + # Extraer solo la parte de FLAGS (...) + import re + flags_match = re.search(r'FLAGS \(([^)]+)\)', part_str) + if flags_match: + flags_content = flags_match.group(1) + # Verificar si contiene \Seen + if '\\Seen' in flags_content: + is_seen = True + break + elif isinstance(item, bytes): + item_str = item.decode('utf-8', errors='ignore') + if 'FLAGS (' in item_str: + flags_debug = item_str + import re + flags_match = re.search(r'FLAGS \(([^)]+)\)', item_str) + if flags_match: + flags_content = flags_match.group(1) + if '\\Seen' in flags_content: + is_seen = True + break + if is_seen: + break + + # Log de debug para ver qué flags se detectaron + if flags_debug: + self._log(f'DEBUG FLAGS correo {mail_id.decode()}: {flags_debug[:200]} -> is_seen={is_seen}') + + except Exception as e: + # En caso de error, asumimos NO leído para evitar marcar incorrectamente + self._log(f'DEBUG: Error parseando flags del correo {mail_id}: {e}') + is_seen = False + + # Parsear el correo - buscar el RFC822 en la respuesta + msg = None + try: + # La respuesta tiene formato: [(b'1 (FLAGS (...) RFC822 {size}', email_bytes), b')'] + # Buscamos la parte que contiene los bytes del email + for item in msg_data: + if isinstance(item, tuple): + # item[0] es la cabecera, item[1] son los bytes del email + if len(item) >= 2 and isinstance(item[1], bytes): + msg = email.message_from_bytes(item[1]) + break + + if msg is None: + # Intento alternativo: a veces viene en msg_data[0][1] + if len(msg_data) > 0 and isinstance(msg_data[0], tuple) and len(msg_data[0]) > 1: + msg = email.message_from_bytes(msg_data[0][1]) + except Exception as e: + self._log(f'Error parseando correo {mail_id}: {e}') + continue + + if msg is None: + self._log(f'No se pudo parsear el correo {mail_id}') + continue + + # Extraer asunto + subject = msg.get('Subject', 'Sin asunto') + if subject: + decoded_parts = decode_header(subject) + subject_parts = [] + for part, encoding in decoded_parts: + if isinstance(part, bytes): + subject_parts.append(part.decode(encoding or 'utf-8', errors='ignore')) + else: + subject_parts.append(part) + subject = ''.join(subject_parts) + + # Extraer remitente + from_addr = msg.get('From', 'Desconocido') + + # Extraer fecha + date_str = msg.get('Date', '') + + # Guardar información del correo + self.mail_list.append({ + 'id': mail_id.decode(), + 'subject': subject, + 'from': from_addr, + 'date': date_str, + 'msg': msg, + 'is_seen': is_seen + }) + + # Aplicar filtro si está activo + if self.mail_filter_unread and is_seen: + # Si el filtro está activo y el correo está leído, no lo mostramos + continue + + # Mostrar en la lista con indicador visual + if is_seen: + # Correo leído: texto normal en gris + display_text = f' {from_addr[:27]} - {subject[:37]}' + else: + # Correo NO leído: texto en negrita con indicador + display_text = f'🔵 {from_addr[:27]} - {subject[:37]}' + + self.mail_listbox.insert('end', display_text) + + # Aplicar color según estado + idx = self.mail_listbox.size() - 1 + if is_seen: + self.mail_listbox.itemconfig(idx, fg='#888888', selectforeground='#666666') + else: + self.mail_listbox.itemconfig(idx, fg='#000000', selectforeground='#1a73e8') + + # Contar correos sin leer + self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False)) + if self.unread_label: + self.unread_label.config(text=f'Correos sin leer: {self.unread_count}') + + self._log(f'{len(mail_ids)} correos cargados ({self.unread_count} sin leer)') + + except Exception as exc: + self._log(f'Error al cargar correos: {exc}') + messagebox.showerror('Error', f'Error al cargar correos:\n{exc}') + + def _show_inbox(self) -> None: + """Muestra la bandeja de entrada""" + if not self.mail_connected or not self.imap_connection: + messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor') + return + + self.current_mailbox = 'INBOX' + self.mail_folder_label.config(text='📬 Bandeja de entrada') + self._refresh_mail_list() + + def _toggle_filter_unread(self) -> None: + """Activa/desactiva el filtro de correos sin leer""" + self.mail_filter_unread = not self.mail_filter_unread + + # Cambiar el aspecto del botón según el estado + if self.mail_filter_unread: + self.btn_filter_unread.config(bg='#ea4335', activebackground='#c5221f') # Rojo activo + self._log('Filtro activado: mostrando solo correos sin leer') + else: + self.btn_filter_unread.config(bg='#7c8691', activebackground='#5f6368') # Gris inactivo + self._log('Filtro desactivado: mostrando todos los correos') + + # Recargar la lista con el nuevo filtro + self._refresh_mail_list() + + def _show_sent(self) -> None: + """Muestra la carpeta de correos enviados""" + if not self.mail_connected or not self.imap_connection: + messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor') + return + + try: + import email + from email.header import decode_header + + # Intentar acceder a la carpeta de enviados con diferentes nombres posibles + sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items'] + mailbox_found = False + + for folder in sent_folders: + try: + status, _ = self.imap_connection.select(folder) + if status == 'OK': + self.current_mailbox = folder + mailbox_found = True + self._log(f'Accediendo a carpeta: {folder}') + break + except Exception: + continue + + if not mailbox_found: + messagebox.showwarning('Advertencia', 'No se encontró la carpeta de enviados en el servidor') + return + + self.mail_folder_label.config(text='📤 Correos enviados') + + # Limpiar la lista actual + self.mail_listbox.delete(0, 'end') + self.mail_list = [] + + # Buscar todos los correos + status, messages = self.imap_connection.search(None, 'ALL') + + if status != 'OK': + messagebox.showerror('Error', 'No se pudieron obtener los correos enviados') + return + + mail_ids = messages[0].split() + + # Ordenar del más reciente al más antiguo + mail_ids = list(reversed(mail_ids)) + + # Limitar a los últimos 50 correos + mail_ids = mail_ids[:50] + + self._log(f'Cargando {len(mail_ids)} correos enviados...') + + for mail_id in mail_ids: + # Obtenemos el correo con BODY.PEEK[] en lugar de RFC822 + status, msg_data = self.imap_connection.fetch(mail_id, '(BODY.PEEK[] FLAGS)') + + if status != 'OK': + continue + + # Extraer flags correctamente desde la respuesta IMAP + is_seen = False + try: + for item in msg_data: + if isinstance(item, tuple): + for part in item: + if isinstance(part, bytes): + part_str = part.decode('utf-8', errors='ignore') + # Buscar específicamente la sección FLAGS entre paréntesis + if 'FLAGS (' in part_str: + import re + flags_match = re.search(r'FLAGS \(([^)]+)\)', part_str) + if flags_match: + flags_content = flags_match.group(1) + if '\\Seen' in flags_content: + is_seen = True + break + elif isinstance(item, bytes): + item_str = item.decode('utf-8', errors='ignore') + if 'FLAGS (' in item_str: + import re + flags_match = re.search(r'FLAGS \(([^)]+)\)', item_str) + if flags_match: + flags_content = flags_match.group(1) + if '\\Seen' in flags_content: + is_seen = True + break + if is_seen: + break + except Exception as e: + self._log(f'DEBUG: Error parseando flags del correo enviado {mail_id}: {e}') + is_seen = False + + # Parsear el correo - buscar el RFC822 en la respuesta + msg = None + try: + for item in msg_data: + if isinstance(item, tuple): + if len(item) >= 2 and isinstance(item[1], bytes): + msg = email.message_from_bytes(item[1]) + break + + if msg is None: + if len(msg_data) > 0 and isinstance(msg_data[0], tuple) and len(msg_data[0]) > 1: + msg = email.message_from_bytes(msg_data[0][1]) + except Exception as e: + self._log(f'Error parseando correo enviado {mail_id}: {e}') + continue + + if msg is None: + self._log(f'No se pudo parsear el correo enviado {mail_id}') + continue + + # Extraer asunto + subject = msg.get('Subject', 'Sin asunto') + if subject: + decoded_parts = decode_header(subject) + subject_parts = [] + for part, encoding in decoded_parts: + if isinstance(part, bytes): + subject_parts.append(part.decode(encoding or 'utf-8', errors='ignore')) + else: + subject_parts.append(part) + subject = ''.join(subject_parts) + + # Extraer destinatario (To en lugar de From) + to_addr = msg.get('To', 'Desconocido') + + # Extraer fecha + date_str = msg.get('Date', '') + + # Guardar información del correo + self.mail_list.append({ + 'id': mail_id.decode(), + 'subject': subject, + 'to': to_addr, + 'from': msg.get('From', 'Desconocido'), + 'date': date_str, + 'msg': msg, + 'is_seen': is_seen + }) + + # Mostrar en la lista - Para enviados mostramos "Para: destinatario" + display_text = f'Para: {to_addr[:25]} - {subject[:35]}' + self.mail_listbox.insert('end', display_text) + + # Para enviados NO actualizamos el contador (solo cuenta los de INBOX) + # El contador mantiene el valor de la bandeja de entrada + + self._log(f'{len(mail_ids)} correos enviados cargados') + + except Exception as exc: + self._log(f'Error al cargar correos enviados: {exc}') + messagebox.showerror('Error', f'Error al cargar correos enviados:\n{exc}') + + def _on_mail_select(self, event) -> None: + """Maneja la selección de un correo en la lista""" + try: + print('\n' + '='*80) + print('DEBUG: _on_mail_select() EJECUTADO') + print('='*80) + self._log('DEBUG: _on_mail_select() EJECUTADO') + + selection = self.mail_listbox.curselection() + print(f'DEBUG: selection = {selection}') + self._log(f'DEBUG: selection = {selection}') + + if not selection: + print('DEBUG: No hay selección, retornando') + self._log('DEBUG: No hay selección, retornando') + return + + index = selection[0] + print(f'DEBUG: index = {index}, len(mail_list) = {len(self.mail_list)}') + self._log(f'DEBUG: index = {index}, len(mail_list) = {len(self.mail_list)}') + + if index >= len(self.mail_list): + print('DEBUG: Índice fuera de rango') + self._log('DEBUG: Índice fuera de rango') + return + + mail_info = self.mail_list[index] + print(f'=== CORREO SELECCIONADO #{index}: {mail_info["subject"]} ===') + self._log(f'=== CORREO SELECCIONADO #{index}: {mail_info["subject"]} ===') + + # Marcar como leído en el servidor si estamos en INBOX + if self.mail_connected and self.imap_connection and self.current_mailbox == 'INBOX': + try: + mail_id = mail_info['id'] + # Solo marcar si aún no está leído + if not mail_info.get('is_seen', False): + # Marcar el correo como leído (añadir flag \Seen) + self.imap_connection.store(mail_id, '+FLAGS', '\\Seen') + # Actualizar el flag local + mail_info['is_seen'] = True + + # Actualizar visualmente el item en la lista + from_addr = mail_info['from'] + subject = mail_info['subject'] + display_text = f' {from_addr[:27]} - {subject[:37]}' + self.mail_listbox.delete(index) + self.mail_listbox.insert(index, display_text) + self.mail_listbox.itemconfig(index, fg='#888888', selectforeground='#666666') + self.mail_listbox.selection_set(index) + + # Recalcular contador de sin leer + self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False)) + if self.unread_label: + self.unread_label.config(text=f'Correos sin leer: {self.unread_count}') + + print(f'DEBUG: Correo marcado como leído') + self._log(f'Correo marcado como leído en el servidor') + except Exception as mark_error: + print(f'DEBUG: Error al marcar como leído: {mark_error}') + self._log(f'Error al marcar correo como leído: {mark_error}') + + print('DEBUG: Llamando a _display_mail()...') + self._log('DEBUG: Llamando a _display_mail()...') + self._display_mail(mail_info) + print('DEBUG: _display_mail() completado') + self._log('DEBUG: _display_mail() completado') + except Exception as e: + error_msg = f'ERROR CRÍTICO en _on_mail_select: {e}' + print(error_msg) + print(f'Traceback: {traceback.format_exc()}') + self._log(error_msg) + messagebox.showerror('Error', f'Error al seleccionar correo:\n{e}') + + def _display_mail(self, mail_info: dict) -> None: + """Muestra el contenido de un correo seleccionado con soporte para imágenes""" + try: + self._log(f'>>> Iniciando _display_mail para: {mail_info["subject"]}') + print(f'>>> Iniciando _display_mail para: {mail_info["subject"]}') + + import email + from email.header import decode_header + import io + + # Intentar importar PIL/Pillow (opcional) + try: + from PIL import Image, ImageTk + PIL_AVAILABLE = True + print('>>> PIL disponible - se mostrarán miniaturas de imágenes') + except ImportError: + PIL_AVAILABLE = False + print('>>> PIL NO disponible - se mostrarán iconos en lugar de imágenes') + self._log('ADVERTENCIA: PIL/Pillow no está instalado. Las imágenes se mostrarán como iconos.') + + msg = mail_info['msg'] + + # Actualizar encabezados + self._log('>>> Actualizando encabezados') + print('>>> Actualizando encabezados') + self.mail_from_label.config(text=mail_info['from']) + self.mail_subject_label.config(text=mail_info['subject']) + self.mail_date_label.config(text=mail_info['date']) + self._log(f'>>> Encabezados actualizados: From={mail_info["from"][:30]}') + print(f'>>> Encabezados actualizados') + + # Extraer cuerpo del correo y adjuntos + body = '' + attachments = [] # Todos los adjuntos (imágenes, PDFs, etc.) + + self._log(f'>>> Procesando correo: {mail_info["subject"]}') + print(f'DEBUG: Procesando correo: {mail_info["subject"]}') # Debug extra + + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + content_disposition = str(part.get('Content-Disposition')) + + self._log(f' Part: {content_type}, disposition: {content_disposition[:50]}') + + # Saltar contenedores multipart + if content_type.startswith('multipart/'): + self._log(f' -> Saltando contenedor multipart') + continue + + # Procesar texto + if content_type == 'text/plain' and 'attachment' not in content_disposition: + try: + payload = part.get_payload(decode=True) + if payload: + body = payload.decode('utf-8', errors='ignore') + self._log(f'Texto plano encontrado: {len(body)} caracteres') + except Exception as e: + self._log(f'Error al decodificar texto plano: {e}') + pass + + elif content_type == 'text/html' and not body and 'attachment' not in content_disposition: + try: + payload = part.get_payload(decode=True) + if payload: + body = payload.decode('utf-8', errors='ignore') + self._log(f'HTML encontrado: {len(body)} caracteres') + except Exception as e: + self._log(f'Error al decodificar HTML: {e}') + pass + + # Procesar archivos adjuntos (imágenes, PDFs, documentos, etc.) + else: + # Detectar adjuntos: si tiene filename o si es attachment explícito + filename = part.get_filename() + is_attachment = 'attachment' in content_disposition.lower() + + # También considerar imágenes y PDFs como adjuntos potenciales + if filename or is_attachment or content_type.startswith('image/') or content_type.startswith('application/'): + try: + file_data = part.get_payload(decode=True) + if file_data: + if not filename: + # Generar nombre según el tipo + if content_type.startswith('image/'): + ext = content_type.split('/')[-1] + filename = f'imagen_{len(attachments) + 1}.{ext}' + elif content_type == 'application/pdf': + filename = f'documento_{len(attachments) + 1}.pdf' + elif 'word' in content_type: + filename = f'documento_{len(attachments) + 1}.docx' + elif 'excel' in content_type or 'spreadsheet' in content_type: + filename = f'hoja_{len(attachments) + 1}.xlsx' + else: + filename = f'archivo_{len(attachments) + 1}' + + attachments.append({ + 'data': file_data, + 'filename': filename, + 'content_type': content_type, + 'is_image': content_type.startswith('image/') + }) + self._log(f'Adjunto detectado: {filename} ({content_type})') + except Exception as exc: + self._log(f'Error al procesar adjunto: {exc}') + else: + try: + payload = msg.get_payload(decode=True) + if payload: + body = payload.decode('utf-8', errors='ignore') + else: + body = str(msg.get_payload()) + self._log(f'Correo simple: {len(body)} caracteres') + except Exception as e: + body = str(msg.get_payload()) + self._log(f'Error al decodificar correo simple: {e}') + + # Mostrar el cuerpo + self._log(f'Mostrando cuerpo: {len(body)} caracteres') + print(f'>>> Mostrando cuerpo: {len(body)} caracteres') + + # Limpiar imágenes inline previas + self.mail_inline_images = [] + + self.mail_body_text.config(state='normal') + self.mail_body_text.delete('1.0', 'end') + + # Insertar texto del cuerpo + if body: + self.mail_body_text.insert('1.0', body) + else: + self.mail_body_text.insert('1.0', '[Sin contenido de texto]') + + # Separar imágenes de otros adjuntos + images_to_show = [] + other_attachments = [] + + for att in attachments: + if att['is_image']: + images_to_show.append(att) + else: + other_attachments.append(att) + + # Mostrar imágenes inline en el cuerpo del correo + if images_to_show and PIL_AVAILABLE: + self.mail_body_text.insert('end', '\n\n' + '─' * 60 + '\n') + self.mail_body_text.insert('end', '📷 Imágenes adjuntas:\n\n') + + for idx, att_info in enumerate(images_to_show): + try: + # Cargar imagen + image = Image.open(io.BytesIO(att_info['data'])) + + # Redimensionar para mostrar inline (max 500px de ancho) + max_width = 500 + if image.width > max_width: + ratio = max_width / image.width + new_height = int(image.height * ratio) + image = image.resize((max_width, new_height), Image.Resampling.LANCZOS) + + photo = ImageTk.PhotoImage(image) + self.mail_inline_images.append(photo) # Guardar referencia + + # Insertar nombre del archivo + self.mail_body_text.insert('end', f"📎 {att_info['filename']}\n") + + # Insertar imagen + self.mail_body_text.image_create('end', image=photo) + self.mail_body_text.insert('end', '\n\n') + + print(f'Imagen inline insertada: {att_info["filename"]}') + self._log(f'Imagen inline mostrada: {att_info["filename"]}') + except Exception as e: + print(f'Error al mostrar imagen inline: {e}') + self.mail_body_text.insert('end', f"[Error al cargar {att_info['filename']}]\n\n") + + self.mail_body_text.config(state='disabled') + + # Limpiar frame de adjuntos previo + for widget in self.mail_attachments_frame.winfo_children(): + widget.destroy() + + # Mostrar sección de adjuntos solo para archivos NO-imagen (PDFs, docs, etc.) + self._log(f'Total de adjuntos: {len(attachments)} ({len(images_to_show)} imágenes, {len(other_attachments)} otros)') + if other_attachments: + # Solo mostrar esta sección si hay archivos que no son imágenes + self.mail_attachments_frame.pack(fill='x', padx=5, pady=10, before=self.mail_body_text.master) + + tk.Label(self.mail_attachments_frame, text='📎 Otros archivos adjuntos:', + bg='#f8f9fa', font=('Arial', 10, 'bold'), fg='#5f6368', + anchor='w').pack(fill='x', padx=10, pady=(10, 5)) + + # Frame con scroll para los adjuntos + attachments_container = tk.Frame(self.mail_attachments_frame, bg='#f8f9fa') + attachments_container.pack(fill='x', padx=10, pady=5) + + for idx, att_info in enumerate(other_attachments): + try: + # Frame para cada adjunto + att_frame = tk.Frame(attachments_container, bg='#ffffff', + relief='solid', bd=1, padx=10, pady=10) + att_frame.pack(side='left', padx=5, pady=5) + + # Mostrar icono según tipo de archivo + icon = '📄' if att_info['content_type'] == 'application/pdf' else '📎' + if 'word' in att_info['content_type'] or att_info['filename'].endswith(('.doc', '.docx')): + icon = '📝' + elif 'excel' in att_info['content_type'] or att_info['filename'].endswith(('.xls', '.xlsx')): + icon = '📊' + elif att_info['filename'].endswith(('.zip', '.rar', '.7z')): + icon = '📦' + + tk.Label(att_frame, text=icon, bg='#ffffff', + font=('Arial', 48)).pack(pady=20) + + # Nombre del archivo + tk.Label(att_frame, text=att_info['filename'], bg='#ffffff', + fg='#5f6368', font=('Arial', 9), wraplength=200).pack(pady=(5, 0)) + + # Tamaño del archivo + file_size = len(att_info['data']) + size_str = f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB" + tk.Label(att_frame, text=size_str, bg='#ffffff', + fg='#888', font=('Arial', 8)).pack() + + # Botón para guardar el archivo + def save_file(data=att_info['data'], name=att_info['filename'], is_img=att_info['is_image']): + # Determinar extensión por defecto + if is_img: + default_ext = '.jpg' + filetypes = [('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp'), + ('Todos los archivos', '*.*')] + elif name.endswith('.pdf'): + default_ext = '.pdf' + filetypes = [('PDF', '*.pdf'), ('Todos los archivos', '*.*')] + else: + default_ext = os.path.splitext(name)[1] or '.dat' + filetypes = [('Todos los archivos', '*.*')] + + save_path = filedialog.asksaveasfilename( + defaultextension=default_ext, + initialfile=name, + filetypes=filetypes + ) + if save_path: + try: + with open(save_path, 'wb') as f: + f.write(data) + messagebox.showinfo('✅ Éxito', f'Archivo guardado en:\n{save_path}') + except Exception as e: + messagebox.showerror('❌ Error', f'No se pudo guardar el archivo:\n{e}') + + tk.Button(att_frame, text='💾 Guardar', command=save_file, + bg='#4285f4', fg='white', relief='flat', + font=('Arial', 9, 'bold'), cursor='hand2', + padx=10, pady=3).pack(pady=(5, 0)) + + except Exception as exc: + self._log(f'Error al mostrar adjunto: {exc}') + else: + # Ocultar frame si no hay adjuntos + self.mail_attachments_frame.pack_forget() + + # Debugging final: verificar estado de widgets + print('\n>>> DEBUG FINAL DE WIDGETS:') + print(f' mail_from_label.text = {self.mail_from_label.cget("text")}') + print(f' mail_subject_label.text = {self.mail_subject_label.cget("text")}') + print(f' mail_date_label.text = {self.mail_date_label.cget("text")}') + print(f' mail_body_text visible = {self.mail_body_text.winfo_viewable()}') + print(f' mail_body_text width = {self.mail_body_text.winfo_width()}') + print(f' mail_body_text height = {self.mail_body_text.winfo_height()}') + body_content = self.mail_body_text.get('1.0', 'end') + print(f' mail_body_text contenido (primeros 100 chars) = {body_content[:100]}') + print(f' Adjuntos mostrados: {len(attachments)}') + self._log(f'>>> _display_mail COMPLETADO OK - Body: {len(body)} chars, Adjuntos: {len(attachments)}') + print(f'>>> _display_mail COMPLETADO OK') + + except Exception as e: + error_msg = f'ERROR CRÍTICO en _display_mail: {e}' + print(error_msg) + print(f'Traceback: {traceback.format_exc()}') + self._log(error_msg) + messagebox.showerror('Error', f'Error al mostrar correo:\n{e}\n\nVer terminal para más detalles') + + def _clear_mail_display(self) -> None: + """Limpia la visualización del correo""" + self.mail_from_label.config(text='') + self.mail_subject_label.config(text='') + self.mail_date_label.config(text='') + self.mail_body_text.config(state='normal') + self.mail_body_text.delete('1.0', 'end') + self.mail_body_text.config(state='disabled') + + # Limpiar adjuntos + for widget in self.mail_attachments_frame.winfo_children(): + widget.destroy() + self.mail_attachments_frame.pack_forget() + + def _open_compose_window(self) -> None: + """Abre una ventana mejorada para redactar un nuevo correo con soporte para imágenes""" + if not self.mail_connected: + messagebox.showwarning('⚠️ Advertencia', 'Primero debes conectarte al servidor') + return + + # Ventana más grande y moderna (casi fullscreen) + compose_window = tk.Toplevel(self) + compose_window.title('✉️ Redactar nuevo correo') + + # Obtener dimensiones de la pantalla y hacer la ventana casi fullscreen + screen_width = compose_window.winfo_screenwidth() + screen_height = compose_window.winfo_screenheight() + window_width = int(screen_width * 0.85) # 85% del ancho de pantalla + window_height = int(screen_height * 0.85) # 85% del alto de pantalla + + # Centrar la ventana + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + + compose_window.geometry(f'{window_width}x{window_height}+{x}+{y}') + compose_window.minsize(900, 650) + compose_window.configure(bg='#f0f2f5') + compose_window.transient(self) + compose_window.grab_set() + + # Lista para almacenar rutas de archivos adjuntos + attachments = [] + attachment_labels = [] + + # ========== HEADER ========== + header = tk.Frame(compose_window, bg='#1a73e8', height=60) + header.pack(fill='x') + header.pack_propagate(False) + + tk.Label(header, text='✍️ Nuevo mensaje', bg='#1a73e8', fg='white', + font=('Arial', 16, 'bold')).pack(side='left', padx=25, pady=15) + + # ========== CAMPOS DEL CORREO ========== + fields_container = tk.Frame(compose_window, bg='#ffffff', relief='solid', bd=1) + fields_container.pack(fill='x', padx=20, pady=(20, 10)) + + fields_frame = tk.Frame(fields_container, bg='#ffffff', padx=25, pady=20) + fields_frame.pack(fill='x') + + # Para (destinatarios múltiples) + to_frame = tk.Frame(fields_frame, bg='#ffffff') + to_frame.pack(fill='x', pady=8) + tk.Label(to_frame, text='Para:', bg='#ffffff', font=('Arial', 11, 'bold'), + fg='#5f6368', width=10, anchor='w').pack(side='left', padx=(0, 10)) + + # Frame para entry y label de ayuda + to_input_frame = tk.Frame(to_frame, bg='#ffffff') + to_input_frame.pack(side='left', fill='x', expand=True) + + to_entry = tk.Entry(to_input_frame, font=('Arial', 11), relief='solid', bd=1, + bg='#f8f9fa', fg='#202124') + to_entry.pack(fill='x') + + # Label de ayuda para múltiples destinatarios + tk.Label(to_input_frame, text='💡 Separa múltiples destinatarios con comas o punto y coma', + bg='#ffffff', fg='#5f6368', font=('Arial', 8, 'italic'), + anchor='w').pack(fill='x', pady=(2, 0)) + + to_entry.focus() + + # Asunto + subject_frame = tk.Frame(fields_frame, bg='#ffffff') + subject_frame.pack(fill='x', pady=8) + tk.Label(subject_frame, text='Asunto:', bg='#ffffff', font=('Arial', 11, 'bold'), + fg='#5f6368', width=10, anchor='w').pack(side='left', padx=(0, 10)) + subject_entry = tk.Entry(subject_frame, font=('Arial', 11), relief='solid', bd=1, + bg='#f8f9fa', fg='#202124') + subject_entry.pack(side='left', fill='x', expand=True) + + # Separador + tk.Frame(fields_frame, height=1, bg='#dadce0').pack(fill='x', pady=10) + + # ========== BARRA DE HERRAMIENTAS ========== + toolbar = tk.Frame(fields_container, bg='#f8f9fa', relief='solid', bd=1) + toolbar.pack(fill='x', padx=0, pady=0) + + toolbar_content = tk.Frame(toolbar, bg='#f8f9fa') + toolbar_content.pack(fill='x', padx=15, pady=10) + + tk.Label(toolbar_content, text='📎 Adjuntar:', bg='#f8f9fa', + font=('Arial', 10, 'bold'), fg='#5f6368').pack(side='left', padx=(0, 10)) + + def get_file_icon(file_path: str) -> str: + """Devuelve el emoji apropiado según el tipo de archivo""" + ext = os.path.splitext(file_path)[1].lower() + if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']: + return '🖼️' + elif ext == '.pdf': + return '📄' + elif ext in ['.doc', '.docx']: + return '📝' + elif ext in ['.xls', '.xlsx']: + return '📊' + elif ext in ['.zip', '.rar', '.7z']: + return '📦' + elif ext in ['.txt', '.log']: + return '📃' + else: + return '📎' + + def attach_file(file_type: str = 'all'): + """Adjunta un archivo al correo""" + if file_type == 'image': + title = 'Seleccionar imagen' + filetypes = [ + ('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp *.webp'), + ('Todos los archivos', '*.*') + ] + elif file_type == 'pdf': + title = 'Seleccionar PDF' + filetypes = [ + ('Documentos PDF', '*.pdf'), + ('Todos los archivos', '*.*') + ] + else: + title = 'Seleccionar archivo' + filetypes = [ + ('Todos los archivos', '*.*'), + ('Documentos PDF', '*.pdf'), + ('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp'), + ('Documentos Word', '*.doc *.docx'), + ('Hojas de cálculo', '*.xls *.xlsx'), + ('Archivos comprimidos', '*.zip *.rar *.7z'), + ('Archivos de texto', '*.txt *.log') + ] + + file_path = filedialog.askopenfilename(title=title, filetypes=filetypes) + + if file_path: + attachments.append(file_path) + file_name = os.path.basename(file_path) + file_icon = get_file_icon(file_path) + + # Crear label para mostrar el archivo adjunto + att_frame = tk.Frame(attachments_list, bg='#e8f0fe', relief='solid', bd=1) + att_frame.pack(fill='x', padx=5, pady=3) + + tk.Label(att_frame, text=f'{file_icon} {file_name}', bg='#e8f0fe', + fg='#1a73e8', font=('Arial', 9), anchor='w').pack(side='left', + padx=10, pady=5, fill='x', expand=True) + + def remove_attachment(frame=att_frame, path=file_path): + attachments.remove(path) + frame.destroy() + + tk.Button(att_frame, text='✕', command=remove_attachment, + bg='#dc3545', fg='white', relief='flat', + font=('Arial', 9, 'bold'), cursor='hand2', + padx=8, pady=2).pack(side='right', padx=5, pady=2) + + attachment_labels.append(att_frame) + self._log(f'Archivo adjunto: {file_name}') + + # Botones de adjuntar + tk.Button(toolbar_content, text='🖼️ Imagen', command=lambda: attach_file('image'), + bg='#4285f4', fg='white', relief='flat', font=('Arial', 10, 'bold'), + padx=15, pady=6, cursor='hand2', activebackground='#3367d6').pack(side='left', padx=3) + + tk.Button(toolbar_content, text='📄 PDF', command=lambda: attach_file('pdf'), + bg='#ea4335', fg='white', relief='flat', font=('Arial', 10, 'bold'), + padx=15, pady=6, cursor='hand2', activebackground='#c5362d').pack(side='left', padx=3) + + tk.Button(toolbar_content, text='📎 Otro archivo', command=lambda: attach_file('all'), + bg='#fbbc04', fg='#202124', relief='flat', font=('Arial', 10, 'bold'), + padx=15, pady=6, cursor='hand2', activebackground='#f9ab00').pack(side='left', padx=3) + + tk.Label(toolbar_content, text='💡 Puedes adjuntar imágenes, PDFs y otros documentos', + bg='#f8f9fa', fg='#5f6368', font=('Arial', 9)).pack(side='left', padx=15) + + # ========== CONTENEDOR PRINCIPAL SCROLLABLE ========== + # Frame contenedor que se expandirá + main_content_frame = tk.Frame(compose_window, bg='#f0f2f5') + main_content_frame.pack(fill='both', expand=True, padx=20, pady=(0, 10)) + + # Canvas con scrollbar para todo el contenido + canvas = tk.Canvas(main_content_frame, bg='#f0f2f5', highlightthickness=0) + scrollbar = tk.Scrollbar(main_content_frame, orient='vertical', command=canvas.yview) + scrollable_frame = tk.Frame(canvas, bg='#f0f2f5') + + scrollable_frame.bind( + '', + lambda e: canvas.configure(scrollregion=canvas.bbox('all')) + ) + + canvas.create_window((0, 0), window=scrollable_frame, anchor='nw') + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side='left', fill='both', expand=True) + scrollbar.pack(side='right', fill='y') + + # ========== LISTA DE ADJUNTOS ========== + attachments_frame = tk.Frame(scrollable_frame, bg='#ffffff', relief='solid', bd=1) + attachments_frame.pack(fill='x', pady=(0, 10)) + + att_header = tk.Frame(attachments_frame, bg='#f8f9fa') + att_header.pack(fill='x') + tk.Label(att_header, text='📋 Archivos adjuntos', bg='#f8f9fa', + font=('Arial', 10, 'bold'), fg='#5f6368').pack(side='left', + padx=15, pady=8) + + # Scrollable frame para adjuntos + att_canvas_frame = tk.Frame(attachments_frame, bg='#ffffff', height=100) + att_canvas_frame.pack(fill='both', expand=True) + att_canvas_frame.pack_propagate(False) + + attachments_list = tk.Frame(att_canvas_frame, bg='#ffffff') + attachments_list.pack(fill='both', expand=True, padx=10, pady=10) + + # ========== CUERPO DEL MENSAJE ========== + body_container = tk.Frame(scrollable_frame, bg='#ffffff', relief='solid', bd=1) + body_container.pack(fill='both', expand=True, pady=(0, 10)) + + body_header = tk.Frame(body_container, bg='#f8f9fa') + body_header.pack(fill='x') + tk.Label(body_header, text='✏️ Mensaje (Ctrl+V para pegar imágenes)', bg='#f8f9fa', + font=('Arial', 11, 'bold'), fg='#5f6368').pack(anchor='w', + padx=20, pady=10) + + # Usar Text en lugar de ScrolledText para soportar imágenes inline + text_frame = tk.Frame(body_container, bg='#ffffff') + text_frame.pack(fill='both', expand=True, padx=5, pady=(0, 5)) + + body_text_scroll = tk.Scrollbar(text_frame) + body_text_scroll.pack(side='right', fill='y') + + body_text = tk.Text(text_frame, wrap='word', + font=('Segoe UI', 11), bg='#ffffff', + fg='#202124', relief='flat', padx=20, pady=15, + spacing1=3, spacing3=5, height=15, + yscrollcommand=body_text_scroll.set) + body_text.pack(side='left', fill='both', expand=True) + body_text_scroll.config(command=body_text.yview) + + # Lista para guardar referencias a PhotoImage (evitar garbage collection) + inline_images = [] + # Lista para guardar las imágenes como datos (para enviar) + inline_images_data = [] + + def paste_image(event=None): + """Pega una imagen desde el portapapeles al cuerpo del correo""" + try: + from PIL import ImageGrab, ImageTk, Image + import io + + # Intentar obtener imagen del portapapeles + img = ImageGrab.grabclipboard() + + if img is not None and isinstance(img, Image.Image): + # Guardar imagen original para envío + img_buffer = io.BytesIO() + img.save(img_buffer, format='PNG') + img_data = img_buffer.getvalue() + + # Generar nombre único para la imagen + img_name = f'imagen_pegada_{len(inline_images_data) + 1}.png' + inline_images_data.append({ + 'data': img_data, + 'name': img_name, + 'pil_image': img.copy() + }) + + # Redimensionar para mostrar (max 600px de ancho) + max_width = 600 + if img.width > max_width: + ratio = max_width / img.width + new_height = int(img.height * ratio) + img = img.resize((max_width, new_height), Image.Resampling.LANCZOS) + + # Convertir a PhotoImage + photo = ImageTk.PhotoImage(img) + inline_images.append(photo) # Guardar referencia + + # Insertar en el Text widget + body_text.image_create('insert', image=photo) + body_text.insert('insert', '\n') # Nueva línea después de la imagen + + self._log(f'Imagen pegada en el correo: {img_name}') + print(f'Imagen pegada y guardada para envío: {img_name}') + return 'break' # Prevenir el comportamiento por defecto + elif isinstance(img, list): + # Si es una lista de archivos (copiar archivos desde explorador) + for file_path in img: + if os.path.isfile(file_path): + ext = os.path.splitext(file_path)[1].lower() + if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']: + # Cargar imagen desde archivo + img_file = Image.open(file_path) + + # Guardar para envío + img_buffer = io.BytesIO() + img_file.save(img_buffer, format='PNG') + img_data = img_buffer.getvalue() + + img_name = f'imagen_pegada_{len(inline_images_data) + 1}.png' + inline_images_data.append({ + 'data': img_data, + 'name': img_name, + 'pil_image': img_file.copy() + }) + + # Redimensionar si es muy grande + max_width = 600 + if img_file.width > max_width: + ratio = max_width / img_file.width + new_height = int(img_file.height * ratio) + img_file = img_file.resize((max_width, new_height), Image.Resampling.LANCZOS) + + photo = ImageTk.PhotoImage(img_file) + inline_images.append(photo) + + body_text.image_create('insert', image=photo) + body_text.insert('insert', '\n') + + self._log(f'Imagen pegada: {os.path.basename(file_path)}') + return 'break' + except Exception as e: + print(f'Error al pegar imagen: {e}') + # Si falla, permitir pegado normal de texto + pass + + return None # Permitir comportamiento por defecto para texto + + # Vincular Ctrl+V + body_text.bind('', paste_image) + body_text.bind('', paste_image) + + # ========== BOTONES DE ACCIÓN (FIJOS EN LA PARTE INFERIOR) ========== + btn_frame = tk.Frame(compose_window, bg='#ffffff', relief='solid', bd=2) + btn_frame.pack(side='bottom', fill='x', padx=0, pady=0) + + # Contenedor interno con padding + btn_container = tk.Frame(btn_frame, bg='#ffffff') + btn_container.pack(fill='x', padx=20, pady=15) + + def send_mail(): + to_addr_raw = to_entry.get().strip() + subject = subject_entry.get().strip() + body = body_text.get('1.0', 'end').strip() + + if not to_addr_raw: + messagebox.showwarning('⚠️ Advertencia', 'Debes especificar al menos un destinatario') + to_entry.focus() + return + + # Separar múltiples destinatarios (por coma o punto y coma) + import re + recipients = re.split(r'[;,]\s*', to_addr_raw) + recipients = [r.strip() for r in recipients if r.strip()] + + # Validar que haya al menos un destinatario + if not recipients: + messagebox.showwarning('⚠️ Advertencia', 'Debes especificar al menos un destinatario válido') + to_entry.focus() + return + + # Validar formato de emails + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + invalid_emails = [email for email in recipients if not re.match(email_pattern, email)] + + if invalid_emails: + messagebox.showwarning('⚠️ Advertencia', + f'Los siguientes emails no son válidos:\n{", ".join(invalid_emails)}') + to_entry.focus() + return + + if not subject: + messagebox.showwarning('⚠️ Advertencia', 'Debes especificar un asunto') + subject_entry.focus() + return + + if not body: + messagebox.showwarning('⚠️ Advertencia', 'El mensaje no puede estar vacío') + body_text.focus() + return + + # Mostrar confirmación si hay múltiples destinatarios + if len(recipients) > 1: + confirm = messagebox.askyesno('📧 Múltiples destinatarios', + f'¿Enviar correo a {len(recipients)} destinatarios?\n\n' + + '\n'.join(f' • {email}' for email in recipients)) + if not confirm: + return + + # Combinar adjuntos de archivos con imágenes pegadas + all_attachments = attachments.copy() # Archivos adjuntados con botones + + # Guardar imágenes pegadas como archivos temporales + temp_files = [] + for img_data in inline_images_data: + try: + # Crear archivo temporal + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png', prefix='pasted_img_') + temp_file.write(img_data['data']) + temp_file.close() + temp_files.append(temp_file.name) + all_attachments.append(temp_file.name) + print(f"Imagen pegada guardada temporalmente: {temp_file.name}") + except Exception as e: + print(f"Error al guardar imagen temporal: {e}") + + # Enviar con todos los adjuntos a múltiples destinatarios + self._send_mail_with_attachments(recipients, subject, body, all_attachments, compose_window) + + # Limpiar archivos temporales después de enviar + for temp_file in temp_files: + try: + if os.path.exists(temp_file): + os.unlink(temp_file) + print(f"Archivo temporal eliminado: {temp_file}") + except Exception as e: + print(f"Error al eliminar temporal: {e}") + + # Botón Enviar (destacado y más grande) + send_btn = tk.Button(btn_container, text='📤 ENVIAR CORREO', command=send_mail, + bg='#34a853', fg='white', relief='raised', bd=2, + font=('Arial', 13, 'bold'), padx=40, pady=15, + cursor='hand2', activebackground='#2d8e47') + send_btn.pack(side='left', padx=(0, 15)) + + # Botón Cancelar + cancel_btn = tk.Button(btn_container, text='❌ CANCELAR', command=compose_window.destroy, + bg='#dc3545', fg='white', relief='raised', bd=2, + font=('Arial', 12, 'bold'), padx=35, pady=13, + cursor='hand2', activebackground='#b02a37') + cancel_btn.pack(side='left', padx=5) + + # Información adicional + info_label = tk.Label(btn_container, text='💡 Tip: Puedes adjuntar varias imágenes a tu correo', + bg='#ffffff', fg='#5f6368', font=('Arial', 10)) + info_label.pack(side='right', padx=10) + + def _send_mail(self, to_addr: str, subject: str, body: str, window: tk.Toplevel) -> None: + """Envía un correo electrónico usando SMTP""" + import smtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + smtp_host = self.mail_smtp_host.get().strip() + smtp_port = self.mail_smtp_port.get().strip() + username = self.mail_username.get().strip() + password = self.mail_password.get() + + try: + smtp_port_num = int(smtp_port) + + self._log(f'Enviando correo a {to_addr}...') + + # Crear el mensaje + msg = MIMEMultipart() + msg['From'] = username + msg['To'] = to_addr + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain', 'utf-8')) + + # Conectar y enviar + with smtplib.SMTP(smtp_host, smtp_port_num, timeout=10) as server: + # Nota: Si el servidor requiere autenticación TLS, descomentar: + # server.starttls() + # server.login(username, password) + + server.send_message(msg) + + self._log('Correo enviado correctamente') + + # Guardar copia en carpeta de enviados del servidor IMAP + self._save_to_sent_folder(msg) + + # Si estamos viendo la carpeta de enviados, actualizar la lista + if self.current_mailbox != 'INBOX': + self._show_sent() + + messagebox.showinfo('✅ Éxito', 'Correo enviado correctamente') + window.destroy() + + except Exception as exc: + self._log(f'Error al enviar correo: {exc}') + messagebox.showerror('❌ Error', f'No se pudo enviar el correo:\n{exc}') + + def _send_mail_with_attachments(self, to_addrs, subject: str, body: str, + attachments: list, window: tk.Toplevel) -> None: + """Envía un correo electrónico con adjuntos a uno o múltiples destinatarios usando SMTP + + Args: + to_addrs: String con un email o lista de emails + subject: Asunto del correo + body: Cuerpo del mensaje + attachments: Lista de rutas de archivos adjuntos + window: Ventana de composición a cerrar tras enviar + """ + import smtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + from email.mime.image import MIMEImage + from email.mime.application import MIMEApplication + from email.mime.base import MIMEBase + from email import encoders + + smtp_host = self.mail_smtp_host.get().strip() + smtp_port = self.mail_smtp_port.get().strip() + username = self.mail_username.get().strip() + password = self.mail_password.get() + + # Convertir a lista si es un string + if isinstance(to_addrs, str): + recipients = [to_addrs] + else: + recipients = to_addrs + + try: + smtp_port_num = int(smtp_port) + + recipients_str = ', '.join(recipients) + self._log(f'Enviando correo con {len(attachments)} adjunto(s) a {len(recipients)} destinatario(s)...') + + # Crear el mensaje multipart + msg = MIMEMultipart() + msg['From'] = username + msg['To'] = recipients_str # Todos los destinatarios separados por comas + msg['Subject'] = subject + + # Adjuntar el cuerpo del mensaje + msg.attach(MIMEText(body, 'plain', 'utf-8')) + + # Adjuntar los archivos + for file_path in attachments: + try: + file_name = os.path.basename(file_path) + file_ext = os.path.splitext(file_path)[1].lower() + + with open(file_path, 'rb') as file: + file_data = file.read() + + # Determinar el tipo de archivo y usar el MIME apropiado + if file_ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']: + # Imágenes + image = MIMEImage(file_data, name=file_name) + image.add_header('Content-Disposition', 'attachment', filename=file_name) + msg.attach(image) + self._log(f'Imagen adjuntada: {file_name}') + + elif file_ext == '.pdf': + # PDFs + pdf = MIMEApplication(file_data, _subtype='pdf') + pdf.add_header('Content-Disposition', 'attachment', filename=file_name) + msg.attach(pdf) + self._log(f'PDF adjuntado: {file_name}') + + elif file_ext in ['.doc', '.docx']: + # Documentos Word + part = MIMEApplication(file_data, _subtype='msword' if file_ext == '.doc' else 'vnd.openxmlformats-officedocument.wordprocessingml.document') + part.add_header('Content-Disposition', 'attachment', filename=file_name) + msg.attach(part) + self._log(f'Documento Word adjuntado: {file_name}') + + elif file_ext in ['.xls', '.xlsx']: + # Hojas de cálculo Excel + part = MIMEApplication(file_data, _subtype='vnd.ms-excel' if file_ext == '.xls' else 'vnd.openxmlformats-officedocument.spreadsheetml.sheet') + part.add_header('Content-Disposition', 'attachment', filename=file_name) + msg.attach(part) + self._log(f'Hoja de cálculo adjuntada: {file_name}') + + elif file_ext in ['.zip', '.rar', '.7z']: + # Archivos comprimidos + part = MIMEApplication(file_data, _subtype='zip') + part.add_header('Content-Disposition', 'attachment', filename=file_name) + msg.attach(part) + self._log(f'Archivo comprimido adjuntado: {file_name}') + + elif file_ext in ['.txt', '.log']: + # Archivos de texto + part = MIMEText(file_data.decode('utf-8', errors='ignore'), 'plain', 'utf-8') + part.add_header('Content-Disposition', 'attachment', filename=file_name) + msg.attach(part) + self._log(f'Archivo de texto adjuntado: {file_name}') + + else: + # Para otros tipos de archivos (genérico) + part = MIMEBase('application', 'octet-stream') + part.set_payload(file_data) + encoders.encode_base64(part) + part.add_header('Content-Disposition', f'attachment; filename= {file_name}') + msg.attach(part) + self._log(f'Archivo adjuntado: {file_name}') + + except Exception as exc: + self._log(f'Error al adjuntar {file_name}: {exc}') + messagebox.showwarning('⚠️ Advertencia', + f'No se pudo adjuntar {file_name}:\n{exc}') + + # Conectar y enviar a todos los destinatarios + with smtplib.SMTP(smtp_host, smtp_port_num, timeout=15) as server: + # Nota: Si el servidor requiere autenticación TLS, descomentar: + # server.starttls() + # server.login(username, password) + + # send_message maneja automáticamente múltiples destinatarios + server.send_message(msg, to_addrs=recipients) + + # Mensaje de éxito + if len(recipients) == 1: + success_msg = f'Correo enviado correctamente con {len(attachments)} adjunto(s)' + else: + success_msg = f'Correo enviado correctamente a {len(recipients)} destinatarios con {len(attachments)} adjunto(s)' + + self._log(success_msg) + + # Guardar copia en carpeta de enviados del servidor IMAP + self._save_to_sent_folder(msg) + + # Si estamos viendo la carpeta de enviados, actualizar la lista + if self.current_mailbox != 'INBOX': + self._show_sent() + + messagebox.showinfo('✅ Éxito', success_msg) + window.destroy() + + except Exception as exc: + self._log(f'Error al enviar correo: {exc}') + messagebox.showerror('❌ Error', f'No se pudo enviar el correo:\n{exc}') + + def _save_to_sent_folder(self, msg) -> None: + """Guarda una copia del correo enviado en la carpeta Sent del servidor IMAP""" + if not self.mail_connected or not self.imap_connection: + self._log('No se puede guardar en carpeta Sent: no hay conexión IMAP') + return + + try: + import imaplib + import time + + self._log('Intentando guardar correo en carpeta de enviados del servidor...') + + # Convertir el mensaje a bytes (formato RFC822) + msg_bytes = msg.as_bytes() + + # Intentar diferentes nombres de carpeta de enviados + sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items'] + saved = False + + for folder in sent_folders: + try: + self._log(f'Intentando guardar en carpeta: {folder}') + # Intentar agregar el mensaje a la carpeta + # Usar el flag \Seen para marcarlo como leído + date = imaplib.Time2Internaldate(time.time()) + result = self.imap_connection.append(folder, '\\Seen', date, msg_bytes) + + if result[0] == 'OK': + self._log(f'✓ Correo guardado exitosamente en carpeta: {folder}') + self._log(f'✓ El correo debería aparecer en Webmin en la carpeta {folder}') + saved = True + break + else: + self._log(f'Respuesta del servidor: {result}') + except Exception as e: + # Si esta carpeta no existe, intentar con la siguiente + self._log(f'Carpeta {folder} no disponible: {e}') + continue + + if not saved: + # Si no se pudo guardar en ninguna carpeta, intentar crear "Sent" + try: + self._log('Ninguna carpeta de enviados encontrada. Intentando crear "Sent"...') + self.imap_connection.create('Sent') + self._log('Carpeta "Sent" creada exitosamente') + date = imaplib.Time2Internaldate(time.time()) + result = self.imap_connection.append('Sent', '\\Seen', date, msg_bytes) + if result[0] == 'OK': + self._log('✓ Carpeta "Sent" creada y correo guardado') + self._log('✓ El correo debería aparecer en Webmin en la carpeta Sent') + saved = True + else: + self._log(f'Error al guardar en carpeta creada: {result}') + except Exception as e: + self._log(f'✗ No se pudo crear carpeta Sent ni guardar correo: {e}') + + if not saved: + self._log('⚠ ADVERTENCIA: El correo se envió pero NO se guardó en el servidor') + self._log('⚠ No aparecerá en Webmin ni en otros clientes de correo') + + except Exception as exc: + self._log(f'✗ Error crítico al guardar en carpeta de enviados: {exc}') + import traceback + self._log(f'Traceback: {traceback.format_exc()}') + def _open_simple_scraping_popup(self) -> None: if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE): messagebox.showerror('Scraping', 'Instala requests y beautifulsoup4 para usar esta función.') @@ -2518,6 +4305,11 @@ class DashboardApp(tk.Tk): if threading.current_thread() is not threading.main_thread(): self.after(0, lambda t=text: self._log(t)) return + # Verificar si el widget notes existe antes de intentar usarlo + if not hasattr(self, 'notes') or self.notes is None: + # Si no existe, simplemente imprimir en consola durante inicialización + print(f'[LOG] {text}') + return timestamp = datetime.datetime.now().strftime('%H:%M:%S') self.notes.insert('end', f'[{timestamp}] {text}\n') self.notes.see('end') diff --git a/requirements.txt b/requirements.txt index fd62718..e7dc95f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pillow>=9.0.0 # pygame optional for direct mp3 playback; we use system player as fallback pygame>=2.1.0 requests>=2.32.0 -beautifulsoup4>=4.12.0 \ No newline at end of file +beautifulsoup4>=4.12.0 +# Email libraries are part of Python standard library (smtplib, imaplib, email) \ No newline at end of file diff --git a/test_mail_server.py b/test_mail_server.py new file mode 100755 index 0000000..92d338d --- /dev/null +++ b/test_mail_server.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Script de prueba para verificar la conexión con el servidor de correo Webmin +""" +import socket + +def test_connection(host, port, service_name): + """Prueba la conexión a un puerto específico""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + result = sock.connect_ex((host, port)) + sock.close() + + if result == 0: + print(f"✓ {service_name} (puerto {port}): CONECTADO") + return True + else: + print(f"✗ {service_name} (puerto {port}): NO DISPONIBLE") + return False + except Exception as e: + print(f"✗ {service_name} (puerto {port}): ERROR - {e}") + return False + +if __name__ == '__main__': + print("Probando conexión con servidor Webmin...") + print("=" * 50) + + host = '10.10.0.101' + + # Probar puertos + results = [] + results.append(test_connection(host, 20000, 'Webmin Web Interface')) + results.append(test_connection(host, 25, 'SMTP')) + results.append(test_connection(host, 143, 'IMAP')) + results.append(test_connection(host, 110, 'POP3')) + + print("=" * 50) + if all(results[1:]): # Ignorar webmin interface para el resultado + print("✓ Todos los servicios de correo están disponibles") + else: + print("⚠ Algunos servicios de correo no están disponibles") + print("\nSugerencias:") + print("1. Verifica que el servidor Webmin esté en ejecución") + print("2. Comprueba la configuración del firewall") + print("3. Verifica que los servicios de correo estén habilitados en Webmin") diff --git a/test_startup.py b/test_startup.py new file mode 100755 index 0000000..c77479f --- /dev/null +++ b/test_startup.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Script de prueba para verificar que la aplicación arranca correctamente +sin errores relacionados con el widget notes. +""" + +import sys +import subprocess +import time + +def test_startup(): + """Prueba que la aplicación arranca sin errores críticos""" + print("🧪 Probando inicio de aplicación...") + print("=" * 60) + + # Ejecutar la aplicación por 3 segundos y capturar salida + try: + process = subprocess.Popen( + ['python3', 'app.py'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # Esperar 3 segundos para ver si hay errores iniciales + time.sleep(3) + + # Intentar terminar el proceso + process.terminate() + + # Esperar a que termine y capturar salida + try: + stdout, stderr = process.communicate(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + + print("\n📤 Salida estándar:") + print("-" * 60) + if stdout: + print(stdout) + else: + print("(Sin salida)") + + print("\n⚠️ Errores/Advertencias:") + print("-" * 60) + if stderr: + # Filtrar errores críticos + lines = stderr.split('\n') + critical_errors = [line for line in lines if 'AttributeError' in line or 'Traceback' in line or 'Error' in line] + + if critical_errors: + print("❌ ERRORES CRÍTICOS ENCONTRADOS:") + for line in critical_errors: + print(f" {line}") + return False + else: + print("(Solo advertencias menores, no errores críticos)") + else: + print("(Sin errores)") + + print("\n" + "=" * 60) + print("✅ La aplicación arrancó correctamente") + print("✅ No se detectaron errores de AttributeError con 'notes'") + print("✅ Puedes ejecutar: python3 app.py") + return True + + except Exception as e: + print(f"\n❌ Error durante la prueba: {e}") + return False + +if __name__ == '__main__': + success = test_startup() + sys.exit(0 if success else 1)