76 KiB
💣 Minesweeper Multiplayer + Dashboard
Un proyecto completo de programación de servicios y procesos
Juego de buscaminas competitivo en red + Panel de control integral
Características • Arquitectura • Instalación • Uso • Mecánicas • Tecnologías
📸 Vista Previa
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🎮 MINESWEEPER MULTIPLAYER DASHBOARD │
├──────────────┬──────────────────────────────────────┬───────────────────────┤
│ │ │ ┌─────────────────┐ │
│ ACCIONES │ ÁREA DE RESULTADOS │ │ 💣 MINESWEEPER │ │
│ ────────── │ │ │ MULTIPLAYER │ │
│ │ ┌────────────────────────────┐ │ ├─────────────────┤ │
│ > Wallapop │ │ 📊 Monitor Sistema │ │ │ Ronda: 3 │ │
│ > Scraping │ │ 📈 CPU: 45% │ │ │ 💣 Bombas: 9 │ │
│ > API Tiempo │ │ 💾 RAM: 2.1GB │ │ │ ❤️ Vidas: 2 │ │
│ │ └────────────────────────────┘ │ ├─────────────────┤ │
│ APPS │ │ │ ┌───┬───┬───┐ │ │
│ ────────── │ Tabs: [Resultados][Navegador] │ │ │ ▢ │ ▢ │ ✓ │ │ │
│ > VS Code │ [Correos][Bloc][Tareas] │ │ ├───┼───┼───┤ │ │
│ > Camellos │ [Alarmas][Enlaces] │ │ │ 💥│ ▢ │ ▢ │ │ │
│ │ │ │ └───┴───┴───┘ │ │
│ BATCH │ ┌────────────────────────────┐ │ ├─────────────────┤ │
│ ────────── │ │ 📝 Panel de Notas │ │ │ [Iniciar Juego] │ │
│ > Backups │ │ │ │ │ [Zona Despejada]│ │
│ │ └────────────────────────────┘ │ └─────────────────┘ │
└──────────────┴──────────────────────────────────────┴───────────────────────┘
✨ Características
🎮 Juego Minesweeper Multijugador
| Característica | Descripción |
|---|---|
| 🔥 Competitivo | 2+ jugadores compiten en tiempo real |
| 💣 Colocación estratégica | Cada jugador coloca bombas para el rival |
| 🔄 Por turnos | Sistema de turnos para colocar y buscar |
| 📈 Dificultad progresiva | 5 rondas con grids y bombas crecientes |
| ❤️ Sistema de vidas | 3 vidas por partida, ¡no las pierdas! |
📊 Dashboard Integral
- 📡 Monitor del sistema en tiempo real (CPU, RAM, hilos)
- 🌤️ API del tiempo para Jávea (OpenWeather)
- 🛒 Análisis de Wallapop - Scraping de anuncios
- ⏰ Sistema de alarmas programables
- 📝 Bloc de notas integrado
- 🔗 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
┌─────────────────────────────────────────────────────────────────┐
│ ARQUITECTURA DEL SISTEMA │
└─────────────────────────────────────────────────────────────────┘
┌──────────────────────────┐
│ SERVIDOR TCP │
│ servidor.py │
│ ┌──────────────────┐ │
│ │ GameServer │ │
│ │ - Estado juego │ │
│ │ - Broadcast │ │
│ │ - Turnos │ │
│ └──────────────────┘ │
│ Puerto 3333 │
└────────────┬─────────────┘
│
┌────────────┴───────────────┐
│ Protocolo JSON │
│ sobre TCP/IP │
└────────────┬───────────────┘
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ CLIENTE 1 │ │ CLIENTE 2 │ │ CLIENTE N │
│ │ │ │ │ │
│ ┌────────────┐ │ │ ┌────────────┐ │ │ ┌────────────┐ │
│ │ app.py │ │ │ │ app.py │ │ │ │ cliente_ │ │
│ │ Dashboard │ │ │ │ Dashboard │ │ │ │ juego.py │ │
│ │ + Juego │ │ │ │ + Juego │ │ │ │ Standalone │ │
│ └────────────┘ │ │ └────────────┘ │ │ └────────────┘ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
📁 Estructura del Proyecto
Proyecto1AVApsp/
│
├── 📄 servidor.py # Servidor TCP del juego (371 líneas)
│ └── GameServer # Gestiona estado, turnos y broadcasts
│
├── 📄 app.py # Dashboard principal (2566 líneas)
│ ├── DashboardApp # Aplicación Tkinter completa
│ └── GameClient # Cliente TCP integrado
│
├── 📄 cliente_juego.py # Cliente standalone (220 líneas)
│ └── GameClient # Versión ligera para jugar
│
├── 📄 requirements.txt # Dependencias Python
└── 📄 README.md # Este archivo
🚀 Instalación
Requisitos Previos
- Python 3.8+
- pip (gestor de paquetes)
Paso 1: Clonar el Repositorio
git clone https://github.com/MarcosFerrandiz/Proyecto1AVApsp.git
cd Proyecto1AVApsp
Paso 2: Crear Entorno Virtual (Recomendado)
python -m venv .venv
# Linux/macOS
source .venv/bin/activate
# Windows
.venv\Scripts\activate
Paso 3: Instalar Dependencias
pip install -r requirements.txt
📦 Ver dependencias detalladas
| Paquete | Versión | Uso |
|---|---|---|
psutil |
≥5.9.0 | Monitor de recursos del sistema |
matplotlib |
≥3.5.0 | Gráficos en tiempo real |
pillow |
≥9.0.0 | Procesamiento de imágenes |
pygame |
≥2.1.0 | Reproducción de audio (opcional) |
requests |
≥2.32.0 | Peticiones HTTP (API, Scraping) |
beautifulsoup4 |
≥4.12.0 | Parsing HTML (Scraping) |
🎯 Uso
🖥️ Iniciar el Servidor
python servidor.py
El servidor escuchará en
0.0.0.0:3333
🎮 Opción A: Dashboard Completo
python app.py
Incluye el juego integrado + todas las funcionalidades del panel.
🎮 Opción B: Cliente Standalone
python cliente_juego.py
Cliente ligero solo para jugar al Minesweeper.
🔌 Conectar al Juego
- Introduce el Host del servidor (por defecto:
127.0.0.1) - Verifica el Puerto (por defecto:
3333) - Pulsa "Conectar"
- ¡Espera a otro jugador y pulsa "Iniciar Juego"!
💣 Mecánicas del Juego
🔄 Flujo de una Partida
┌─────────────────────────────────────────────────────────────────┐
│ FLUJO DEL JUEGO │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ LOBBY │────▶│ COLOCACIÓN │────▶│ BÚSQUEDA │ │
│ │ │ │ DE BOMBAS │ │ (PLAYING) │ │
│ └─────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │ │
│ │ ┌───────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
│ │ │ ¿BOMBA? │───▶│ EXPLOSIÓN│───▶│ ¿VIDAS=0? │ │
│ │ └────┬────┘ └──────────┘ └─────┬──────┘ │
│ │ │ NO │ │
│ │ ▼ SÍ ▼ │
│ │ ┌─────────┐ ┌──────────┐ │
│ │ │ SAFE │ │ GAME OVER│ │
│ │ │(casilla │ └──────────┘ │
│ │ │ segura) │ │
│ │ └────┬────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┿━━━━━━━━━━━◀────────────────────────┓ │
│ │ ┃ SIGUIENTE TURNO ┃ │
│ │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │
│ │ │
│ └───────────────────▶ (Nueva Partida) │
│ │
└─────────────────────────────────────────────────────────────────┘
📊 Progresión de Dificultad
| Ronda | Tamaño Grid | Bombas/Jugador | Total Bombas* |
|---|---|---|---|
| 1️⃣ | 3×3 | 3 | 6 |
| 2️⃣ | 5×5 | 5 | 10 |
| 3️⃣ | 8×8 | 9 | 18 |
| 4️⃣ | 11×11 | 12 | 24 |
| 5️⃣+ | 14×14 | 15 | 30 |
*Para 2 jugadores
🎯 Fases del Juego
1. 💣 Fase de Colocación (PLACING)
- Cada jugador coloca bombas por turnos
- Las bombas se muestran brevemente (1 segundo) a todos
- ¡Memoriza dónde pones TUS bombas y las del rival!
2. 🔍 Fase de Búsqueda (PLAYING)
- Excava casillas por turnos
- Casilla segura → Se marca en verde ✅
- Bomba → ¡EXPLOSIÓN! Pierdes 1 vida 💔
🏆 Condiciones de Victoria
| Condición | Resultado |
|---|---|
| Rival pierde todas las vidas | ¡GANASTE! 🎉 |
| Todas las casillas seguras reveladas | ¡Zona despejada! |
| Superas la ronda 5 | ¡Victoria total! 🏆 |
📡 Protocolo de Comunicación
Mensajes JSON Cliente → Servidor
// Iniciar partida
{ "type": "START_GAME" }
// Colocar bomba (fase PLACING)
{ "type": "PLACE_BOMB", "x": 2, "y": 1 }
// Excavar casilla (fase PLAYING)
{ "type": "CLICK_CELL", "x": 3, "y": 4 }
// Verificar zona despejada
{ "type": "CHECK_DUNGEON_CLEARED" }
Mensajes JSON Servidor → Clientes
// Nueva ronda
{
"type": "NEW_ROUND",
"round": 1,
"grid_size": 3,
"total_bombs_per_player": 3
}
// Notificación de turno
{
"type": "TURN_NOTIFY",
"active_player": "('127.0.0.1', 54321)",
"msg": "Turno de ... para poner bombas."
}
// Flash de bomba (visible 1 segundo)
{
"type": "BOMB_flash",
"x": 1, "y": 2,
"who": "('127.0.0.1', 54321)"
}
// Explosión
{
"type": "EXPLOSION",
"x": 1, "y": 2,
"who": "...",
"lives": 2
}
// Casilla segura
{ "type": "SAFE", "x": 0, "y": 0 }
// Game Over
{
"type": "GAME_OVER",
"loser": "...",
"msg": "💀 ¡... ha perdido todas sus vidas!"
}
🔧 Documentación Técnica Detallada
🆔 Sistema de Identificación de Jugadores
El servidor identifica a cada jugador mediante su dirección de socket, que es una tupla única (IP, Puerto):
┌─────────────────────────────────────────────────────────────────┐
│ IDENTIFICACIÓN ÚNICA DE JUGADORES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Cuando un cliente se conecta al servidor: │
│ │
│ Cliente 1 ──────► sock.accept() ──────► ('127.0.0.1', 49956) │
│ Cliente 2 ──────► sock.accept() ──────► ('127.0.0.1', 49968) │
│ │
│ Aunque ambos clientes estén en el MISMO ordenador (127.0.0.1) │
│ el PUERTO es diferente y único para cada conexión. │
│ │
│ Esta tupla (IP, Puerto) actúa como IDENTIFICADOR ÚNICO. │
│ │
└─────────────────────────────────────────────────────────────────┘
¿Cómo funciona en el mismo ordenador?
# servidor.py - Al aceptar conexión
conn, addr = s.accept() # addr = ('127.0.0.1', 49956)
# El sistema operativo asigna un puerto efímero ÚNICO
# a cada nueva conexión de socket del cliente
| Cliente | IP | Puerto (asignado por SO) | Identificador Completo |
|---|---|---|---|
| Dashboard 1 | 127.0.0.1 | 49956 | ('127.0.0.1', 49956) |
| Dashboard 2 | 127.0.0.1 | 49968 | ('127.0.0.1', 49968) |
| Cliente remoto | 192.168.1.50 | 52341 | ('192.168.1.50', 52341) |
💡 Clave: El puerto del cliente es asignado automáticamente por el sistema operativo y es siempre diferente para cada nueva conexión, incluso desde el mismo ordenador.
🔄 Gestión de Turnos
El servidor mantiene dos índices de turno para controlar quién juega:
# servidor.py - Variables de control de turnos
self.placing_turn_index = 0 # Índice en fase PLACING
self.playing_turn_index = 0 # Índice en fase PLAYING
self.client_list = [] # Lista ordenada de direcciones
Flujo de Verificación de Turno
┌─────────────────────────────────────────────────────────────────┐
│ VERIFICACIÓN DE TURNO │
├─────────────────────────────────────────────────────────────────┤
│ │
│ client_list = [('127.0.0.1', 49956), ('127.0.0.1', 49968)] │
│ ↑ ↑ │
│ índice 0 índice 1 │
│ │
│ Si placing_turn_index = 0: │
│ → Solo ('127.0.0.1', 49956) puede poner bombas │
│ │
│ Cuando recibe PLACE_BOMB de un cliente: │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ if str(addr) != str(current_turn_addr): │ │
│ │ return # No es su turno, ignorar │ │
│ └─────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Código de Verificación (servidor.py)
elif msg_type == 'PLACE_BOMB':
if self.state == STATE_PLACING:
# Obtener quién tiene el turno actual
current_turn_addr = self.client_list[self.placing_turn_index]
# Comparar con quién envió el mensaje
if str(addr) != str(current_turn_addr):
return # ¡No es tu turno! Ignorar mensaje
# Procesar la bomba...
👤 Cómo el Cliente Sabe si es su Turno
El cliente guarda su propia dirección al conectarse y la compara con los mensajes del servidor:
# app.py - Al conectarse
self.my_address = str(sock.getsockname()) # Ej: "('127.0.0.1', 49956)"
# Al recibir TURN_NOTIFY del servidor
def handle_message(self, msg):
if mtype == 'TURN_NOTIFY':
active_player = msg.get('active_player') # "('127.0.0.1', 49956)"
# Comparar con mi dirección
if active_player == self.my_address:
self._log_game("🎯 ¡ES TU TURNO!")
# Habilitar controles...
else:
self._log_game(f"⏳ Turno de {active_player}")
# Deshabilitar controles...
┌─────────────────────────────────────────────────────────────────┐
│ FLUJO DE NOTIFICACIÓN DE TURNO │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SERVIDOR CLIENTES │
│ ──────── ──────── │
│ │
│ broadcast({ Cliente 1 (49956): │
│ "type": "TURN_NOTIFY", ├─ my_address = 49956 │
│ "active_player": ├─ active_player = 49956 │
│ "('127.0.0.1', 49956)" └─ ✓ ¡ES MI TURNO! │
│ }) │
│ │ Cliente 2 (49968): │
│ └──────────────────────────► ├─ my_address = 49968 │
│ ├─ active_player = 49956 │
│ └─ ✗ No es mi turno │
│ │
└─────────────────────────────────────────────────────────────────┘
🔒 Sincronización con Threading Lock
El servidor usa un threading.Lock para evitar condiciones de carrera cuando múltiples clientes envían mensajes simultáneamente:
class GameServer:
def __init__(self):
self.lock = threading.Lock() # Mutex para sincronización
self.clients = {} # Diccionario compartido
self.state = STATE_LOBBY # Estado compartido
def process_message(self, client, addr, msg):
with self.lock: # Adquirir lock antes de modificar estado
if msg_type == 'PLACE_BOMB':
# Solo un hilo puede ejecutar esto a la vez
self.bombs.add((x, y))
self._broadcast_unlocked(...)
┌─────────────────────────────────────────────────────────────────┐
│ SINCRONIZACIÓN CON LOCK │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Hilo Cliente 1 ─────┐ │
│ ├──► with self.lock: ──► Ejecuta primero │
│ Hilo Cliente 2 ─────┘ │ │
│ ▼ │
│ (espera...) │
│ │ │
│ ▼ │
│ Hilo 2 ejecuta después │
│ │
│ Esto evita que dos jugadores modifiquen el estado │
│ del juego al mismo tiempo (condiciones de carrera). │
│ │
└─────────────────────────────────────────────────────────────────┘
📡 Arquitectura de Hilos
┌─────────────────────────────────────────────────────────────────┐
│ ARQUITECTURA DE HILOS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SERVIDOR (servidor.py) │
│ ══════════════════════ │
│ │
│ ┌─────────────────┐ │
│ │ Hilo Main │ ◄── Acepta conexiones (s.accept()) │
│ └────────┬────────┘ │
│ │ │
│ ├──► Hilo Cliente 1 ──► handle_client(sock1) │
│ ├──► Hilo Cliente 2 ──► handle_client(sock2) │
│ └──► Hilo Cliente N ──► handle_client(sockN) │
│ │
│ Cada cliente tiene su propio hilo daemon que: │
│ 1. Lee mensajes del socket (recv) │
│ 2. Procesa el mensaje (process_message) │
│ 3. Puede hacer broadcast a todos los clientes │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ CLIENTE (app.py) │
│ ════════════════ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Hilo Main │ │ Hilo Recv │ │
│ │ (Tkinter UI) │◄───│ (_recv_loop) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │ ▲ │
│ │ msg_queue.put() │ sock.recv() │
│ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ Cola de msgs │ ──────────┘ │
│ │ (thread-safe) │ │
│ └─────────────────┘ │
│ │
│ El hilo de recepción pone mensajes en una cola. │
│ El hilo principal (UI) los procesa con after(). │
│ │
└─────────────────────────────────────────────────────────────────┘
🎮 Resumen: Jugar en el Mismo Ordenador
| Paso | Qué Sucede |
|---|---|
| 1. Iniciar servidor | python servidor.py escucha en puerto 3333 |
| 2. Abrir Cliente 1 | Conecta → SO asigna puerto 49956 |
| 3. Abrir Cliente 2 | Conecta → SO asigna puerto 49968 |
| 4. Servidor registra | clients = {sock1: ('127.0.0.1', 49956), sock2: ('127.0.0.1', 49968)} |
| 5. Iniciar juego | client_list = [addr1, addr2] define orden de turnos |
| 6. Turno jugador 1 | Servidor envía TURN_NOTIFY con active_player = addr1 |
| 7. Cliente 1 compara | my_address == active_player → ¡Es mi turno! |
| 8. Cliente 2 compara | my_address != active_player → Esperar |
| 9. Jugador 1 actúa | Envía PLACE_BOMB o CLICK_CELL |
| 10. Servidor valida | addr == current_turn_addr → Válido, procesar |
| 11. Avanzar turno | placing_turn_index += 1 → Turno del siguiente |
🛠️ Tecnologías
|
Python 3 Lenguaje base |
Tkinter Interfaz gráfica |
TCP Sockets Comunicación en red |
|
Matplotlib Gráficos |
JSON Protocolo mensajes |
Requests APIs HTTP |
🧵 Conceptos de PSP Aplicados
| Concepto | Implementación |
|---|---|
| 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 (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).
# 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ónselect(mailbox)→ Seleccionar carpeta (INBOX, Sent, etc.)search(None, 'ALL')→ Buscar todos los correosfetch(id, '(BODY.PEEK[] FLAGS)')→ Obtener correo sin marcarlo como leídostore(id, '+FLAGS', '\\Seen')→ Marcar como leídoappend(folder, flags, date, msg)→ Guardar correo en carpeta
SMTP (Simple Mail Transfer Protocol)
Protocolo para enviar correos. Puerto: 25 (sin TLS).
# 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
# 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)
# 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[]?
# ❌ 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.
# 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)
# 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
# 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
# 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
# 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
# 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
- Gráfico de CPU en línea temporal
- Gráfico de memoria como área
- Contador de hilos del proceso
🌤️ API del Tiempo (Jávea)
- Temperatura actual y sensación térmica
- Humedad y velocidad del viento
- Descripción del clima
🛒 Análisis Wallapop
- Extracción de información de anuncios
- Headers personalizados para API
- Resultados formateados
⏰ Sistema de Alarmas
- Programación en minutos
- Notificaciones visuales
- Gestión de alarmas activas
🤝 Contribuir
- Fork del proyecto
- Crea tu Feature Branch (
git checkout -b feature/NuevaFuncion) - Commit tus cambios (
git commit -m 'Add: Nueva función') - Push a la rama (
git push origin feature/NuevaFuncion) - Abre un Pull Request
📝 Licencia
Este proyecto es de carácter educativo y fue desarrollado como parte del módulo de Programación de Servicios y Procesos.
Desarrollado con ❤️ por Marcos Ferrandiz
Proyecto 1º Evaluación - PSP (Programación de Servicios y Procesos)