# ════════════════════════════════════════════════════════════════════════ # APLICACIÓN DE CORREO WEB - SERVIDOR FLASK + IMAP + SMTP # ════════════════════════════════════════════════════════════════════════ # IMPORTAR LIBRERÍAS NECESARIAS import os # Para operaciones del sistema import email # Para procesar mensajes de correo import smtplib # Para enviar correos import imaplib # Para recibir/gestionar correos import poplib # Para descargar correos (alternativa a IMAP) import base64 # Para codificar datos (adjuntos) import json # Para trabajar con JSON from email.mime.multipart import MIMEMultipart # Para correos con múltiples partes from email.mime.text import MIMEText # Para cuerpo de texto en correos from email.mime.base import MIMEBase # Para adjuntos from email import encoders # Para codificar adjuntos en base64 from email.header import decode_header # Para decodificar cabeceras especiales from datetime import datetime # Para fecha/hora from flask import Flask, render_template, request, jsonify, send_file, session # Framework web import io # Para manejar datos en memoria # ════════════════════════════════════════════════════════════════════════ # CREAR APLICACIÓN FLASK Y CONFIGURAR PARÁMETROS # ════════════════════════════════════════════════════════════════════════ app = Flask(__name__) # Crear la aplicación web app.secret_key = 'secret_mail_key_2024' # Clave para encriptar sesiones # ════════════════════════════════════════════════════════════════════════ # CONFIGURACIÓN DEL SERVIDOR DE CORREO (PSP) # ════════════════════════════════════════════════════════════════════════ MAIL_SERVER = '10.10.0.101' # Dirección IP del servidor de correo corporativo MAIL_USER = 'javi@psp.es' # Email del usuario MAIL_PASS = '1234' # Contraseña de acceso SMTP_PORT = 25 # Puerto para ENVIAR correos (SMTP) IMAP_PORT = 143 # Puerto para RECIBIR/GESTIONAR correos (IMAP) POP_PORT = 110 # Puerto alternativo para descargar (POP3) - no usado aquí # ════════════════════════════════════════════════════════════════════════ # FUNCIONES AUXILIARES (HELPERS) # ════════════════════════════════════════════════════════════════════════ def decode_str(s): """ Decodificar cabeceras de correo que pueden estar en diferentes codificaciones. Algunos correos tienen asuntos o remitentes en ISO-8859-1, UTF-8, etc. Esta función los convierte todos a texto normal. """ if s is None: return '' # Descomponer la cabecera codificada en partes parts = decode_header(s) result = [] # Procesar cada parte for part, enc in parts: if isinstance(part, bytes): # Si está en bytes, decodificar usando la codificación detectada result.append(part.decode(enc or 'utf-8', errors='replace')) else: # Si ya es texto, usarlo tal cual result.append(str(part)) return ' '.join(result) def get_imap(): """ CONECTAR CON EL SERVIDOR IMAP Abre una conexión segura con el servidor de correo para leer/gestionar mensajes. Retorna: (conexión, error) - Si hay error, conexión=None """ try: # Crear conexión IMAP al servidor M = imaplib.IMAP4(MAIL_SERVER, IMAP_PORT) # Autentificarse con usuario y contraseña M.login(MAIL_USER, MAIL_PASS) # Retornar conexión exitosa y sin error return M, None except Exception as e: # Si hay problema, retornar error return None, str(e) def parse_email_message(raw): """ EXTRAER INFORMACIÓN DEL CORREO COMPLETO Recibe el contenido RAW (sin procesar) del correo completo. Extrae: remitente, destinatario, asunto, cuerpo (texto/HTML), adjuntos, imágenes inline. Retorna: diccionario con toda la información organizada. """ # Convertir el contenido RAW en un objeto de mensaje procesable msg = email.message_from_bytes(raw) # Inicializar variables para el contenido body_text = '' # Texto plano del correo body_html = '' # Versión HTML del correo attachments = [] # Lista de archivos adjuntos def walk(part): """ FUNCIÓN INTERNA: Recorrer todas las partes del correo Los correos modernos pueden tener texto, HTML, imágenes, adjuntos, todo mezclado. Esta función recursiva procesa cada parte. """ nonlocal body_text, body_html # Poder modificar variables externas # Obtener el tipo de contenido (image/jpeg, text/plain, application/pdf, etc.) ct = part.get_content_type() # Obtener la disposición (attachment, inline, etc.) cd = str(part.get('Content-Disposition', '')) # Obtener el ID único si es una imagen inline (para mostrarla en el HTML) cid = part.get('Content-ID') if cid: cid = cid.strip('<>') # Limpiar formato a ID # ══════════════════════════════════════════════════════════════ # 1. PROCESAR ADJUNTOS E IMÁGENES INLINE # ══════════════════════════════════════════════════════════════ if 'attachment' in cd or part.get_filename() or (cid and ct.startswith('image/')): # Es un archivo adjunto o imagen fn = decode_str(part.get_filename() or 'file') # Nombre del archivo data = part.get_payload(decode=True) # Contenido binario del archivo # Guardar en la lista de adjuntos attachments.append({ 'filename': fn, # Nombre 'data': base64.b64encode(data).decode() if data else '', # Contenido en BASE64 'mime': ct, # Tipo (image/jpeg, etc) 'size': len(data) if data else 0, # Tamaño en bytes 'content_id': cid # ID para imágenes inline }) # ══════════════════════════════════════════════════════════════ # 2. PROCESAR CUERPO DE TEXTO PLANO # ══════════════════════════════════════════════════════════════ elif ct == 'text/plain' and not body_text: # Es texto plano y aún no hemos capturado uno payload = part.get_payload(decode=True) body_text = payload.decode(part.get_content_charset() or 'utf-8', errors='replace') # ══════════════════════════════════════════════════════════════ # 3. PROCESAR CUERPO HTML # ══════════════════════════════════════════════════════════════ elif ct == 'text/html' and not body_html: # Es HTML y aún no hemos capturado uno payload = part.get_payload(decode=True) body_html = payload.decode(part.get_content_charset() or 'utf-8', errors='replace') # ══════════════════════════════════════════════════════════════ # 4. RECURSIÓN: Si la parte contiene más partes, procesarlas # ══════════════════════════════════════════════════════════════ if part.is_multipart(): # Procesar cada sub-parte for p in part.get_payload(): walk(p) # ══════════════════════════════════════════════════════════════════ # EJECUTAR EL PROCESAMIENTO # ══════════════════════════════════════════════════════════════════ if msg.is_multipart(): # El correo tiene múltiples partes (lo más común) for part in msg.get_payload(): walk(part) else: # El correo es simple (solo texto o solo HTML) ct = msg.get_content_type() payload = msg.get_payload(decode=True) text = payload.decode(msg.get_content_charset() or 'utf-8', errors='replace') if payload else '' if ct == 'text/html': body_html = text else: body_text = text # ══════════════════════════════════════════════════════════════════ # REEMPLAZAR IMÁGENES INLINE (cid:xxx) POR DATA URLs # ══════════════════════════════════════════════════════════════════ # Los correos HTML puede referenciar imágenes así: # Necesitamos convertirlas a data URLs para que se vean en el navegador if body_html and attachments: for att in attachments: if att.get('content_id'): # Encontrar la imagen en los adjuntos cid = att['content_id'] # Crear URL de datos: data:image/jpeg;base64,XXXXX... dataurl = f"data:{att['mime']};base64,{att['data']}" # Reemplazar en el HTML (comillas dobles y simples) body_html = body_html.replace(f'src="cid:{cid}"', f'src="{dataurl}"') body_html = body_html.replace(f"src='cid:{cid}'", f"src='{dataurl}'") # ══════════════════════════════════════════════════════════════════ # RETORNAR TODA LA INFORMACIÓN ORGANIZADA # ══════════════════════════════════════════════════════════════════ return { 'from': decode_str(msg.get('From', '')), # Quién envió 'to': decode_str(msg.get('To', '')), # A quién fue 'subject': decode_str(msg.get('Subject', '(Sin asunto)')), # Asunto 'date': decode_str(msg.get('Date', '')), # Cuándo 'body_text': body_text, # Cuerpo texto plano 'body_html': body_html, # Cuerpo HTML (con imágenes procesadas) 'attachments': attachments, # Lista de archivos } # ════════════════════════════════════════════════════════════════════════ # RUTAS API (Endpoints que el navegador web llamará) # Cada @app.route es una URL que responde con JSON # ════════════════════════════════════════════════════════════════════════ # ────────────────────────────────────────────────────────────────────────── # 1. RUTA PRINCIPAL: Servir la página HTML # ────────────────────────────────────────────────────────────────────────── @app.route('/') def index(): """ GET / Cuando el usuario accede a http://localhost:5000/, servir el archivo HTML (interfaz gráfica). """ return render_template('index.html') # ────────────────────────────────────────────────────────────────────────── # 2. OBTENER LISTA DE CARPETAS (Inbox, Enviados, Papelera, etc) # ────────────────────────────────────────────────────────────────────────── @app.route('/api/folders') def get_folders(): """ GET /api/folders Conectar con IMAP y obtener la lista de carpetas del correo. Responde con: {"folders": ["INBOX", "Sent", "Drafts", "Trash", ...]} """ M, err = get_imap() # Conectar if err: return jsonify({'error': err}), 500 # Si hay error, retornar try: # Listar carpetas del servidor _, folders_raw = M.list() folders = [] # Procesar cada carpeta (parsear el formato IMAP) for f in folders_raw: parts = f.decode().split('"."') name = parts[-1].strip().strip('"') folders.append(name) M.logout() # Cerrar conexión return jsonify({'folders': folders}) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 3. OBTENER LISTA DE MENSAJES CON PAGINACIÓN # ────────────────────────────────────────────────────────────────────────── @app.route('/api/messages') def get_messages(): """ GET /api/messages?folder=INBOX&page=1&per_page=25 Obtener los mensajes de una carpeta, con paginación. Parámetros: - folder: nombre de la carpeta (INBOX, Sent, etc) - page: número de página (por defecto 1) - per_page: cuántos mensajes por página (por defecto 20) Retorna: { "messages": [ {"uid": "123", "from": "user@example.com", "subject": "Asunto", "date": "...", "seen": false}, ... ], "total": 150, "page": 1, "per_page": 25 } """ folder = request.args.get('folder', 'INBOX') # Carpeta solicitada page = int(request.args.get('page', 1)) # Número de página per_page = int(request.args.get('per_page', 20)) # Mensajes por página M, err = get_imap() if err: return jsonify({'error': err}), 500 try: # Seleccionar la carpeta M.select(folder) # Buscar TODOS los mensajes _, data = M.search(None, 'ALL') ids = data[0].split() # Obtener lista de IDs ids = list(reversed(ids)) # Invertir para mostrar más nuevos primero total = len(ids) # Total de mensajes en la carpeta # Calcular qué mensajes mostrar (paginación) start = (page - 1) * per_page end = start + per_page page_ids = ids[start:end] messages = [] # Procesar cada mensaje de esta página for uid in page_ids: # Obtener solo las cabeceras (más rápido que todo el mensaje) _, header_data = M.fetch(uid, '(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])') raw_header = header_data[0][1] msg = email.message_from_bytes(raw_header) # Verificar si el mensaje ya fue leído (flag \Seen) _, flags_data = M.fetch(uid, '(FLAGS)') flags = flags_data[0].decode() if flags_data[0] else '' seen = '\\Seen' in flags # Guardar información del mensaje messages.append({ 'uid': uid.decode(), 'from': decode_str(msg.get('From', '')), 'subject': decode_str(msg.get('Subject', '(Sin asunto)')), 'date': decode_str(msg.get('Date', '')), 'seen': seen, }) M.logout() return jsonify({ 'messages': messages, 'total': total, 'page': page, 'per_page': per_page }) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 4. OBTENER UN MENSAJE COMPLETO (con cuerpo, adjuntos, etc) # ────────────────────────────────────────────────────────────────────────── @app.route('/api/message/') def get_message(uid): """ GET /api/message/123?folder=INBOX Descargar un correo completo con su contenido, imágenes, adjuntos, etc. También marca el correo como leído (flag \Seen). Retorna el JSON del correo procesado por parse_email_message() """ folder = request.args.get('folder', 'INBOX') M, err = get_imap() if err: return jsonify({'error': err}), 500 try: M.select(folder) # Descargar el correo completo (RFC822) _, data = M.fetch(uid.encode(), '(RFC822)') raw = data[0][1] # Marcar el correo como leído M.store(uid.encode(), '+FLAGS', '\\Seen') M.logout() # Procesar el correo con la función parse_email_message() parsed = parse_email_message(raw) parsed['uid'] = uid # Agregar el UID return jsonify(parsed) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 5. DESCARGAR UN ADJUNTO ESPECÍFICO # ────────────────────────────────────────────────────────────────────────── @app.route('/api/attachment//') def get_attachment(uid, idx): """ GET /api/attachment/123/0?folder=INBOX Descargar un archivo adjunto específico. Parámetros: - uid: ID del correo - idx: índice del adjunto (0 = primer adjunto, 1 = segundo, etc) - folder: carpeta donde está el correo Retorna el archivo con su nombre y tipo MIME para descargar. """ folder = request.args.get('folder', 'INBOX') M, err = get_imap() if err: return jsonify({'error': err}), 500 try: M.select(folder) # Descargar el correo completo _, data = M.fetch(uid.encode(), '(RFC822)') raw = data[0][1] M.logout() # Procesar el correo parsed = parse_email_message(raw) att = parsed['attachments'][idx] # Obtener el adjunto por índice # Decodificar el contenido de base64 a bytes file_data = base64.b64decode(att['data']) # Enviar el archivo para descargar return send_file( io.BytesIO(file_data), download_name=att['filename'], # Nombre del archivo as_attachment=True, # Descargar (no mostrar en navegador) mimetype=att['mime'] # Tipo (image/jpeg, application/pdf, etc) ) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 6. ENVIAR UN CORREO (con o sin adjuntos) # ────────────────────────────────────────────────────────────────────────── @app.route('/api/send', methods=['POST']) def send_email(): """ POST /api/send Enviar un correo electrónico con opcionalmente: - cuerpo de texto plano - cuerpo HTML (formato enriquecido) - múltiples archivos adjuntos Parámetros (formulario): - to: dirección de correo destino - subject: asunto del correo - body: contenido del correo - body_type: 'plain' (texto) o 'html' (formato enriquecido) - attachments: lista de archivos (opcional) Usa SMTP para conectarse al servidor y enviar. """ data = request.form to_addr = data.get('to', '') subject = data.get('subject', '') body = data.get('body', '') body_type = data.get('body_type', 'plain') # Crear estructura MIME del correo msg = MIMEMultipart('alternative' if body_type == 'html' else 'mixed') msg['From'] = MAIL_USER msg['To'] = to_addr msg['Subject'] = subject # Agregar el cuerpo del correo (texto HTML o plano) if body_type == 'html': msg.attach(MIMEText(body, 'html', 'utf-8')) else: msg.attach(MIMEText(body, 'plain', 'utf-8')) # Procesar adjuntos si los hay files = request.files.getlist('attachments') for f in files: if f.filename: # Crear una parte MIME para el archivo part = MIMEBase('application', 'octet-stream') part.set_payload(f.read()) # Leer contenido del archivo encoders.encode_base64(part) # Codificar en base64 part.add_header('Content-Disposition', f'attachment; filename="{f.filename}"') msg.attach(part) # Enviar el correo vía SMTP try: with smtplib.SMTP(MAIL_SERVER, SMTP_PORT, timeout=10) as server: server.sendmail(MAIL_USER, [to_addr], msg.as_string()) return jsonify({'ok': True}) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 7. ELIMINAR UN MENSAJE # ────────────────────────────────────────────────────────────────────────── @app.route('/api/delete/', methods=['DELETE']) def delete_message(uid): """ DELETE /api/delete/123?folder=INBOX Marcar un correo como eliminado en IMAP. Parámetros: - uid: ID del correo a eliminar - folder: carpeta donde está el correo Usa dos comandos IMAP: 1. store(..., '+FLAGS', '\\Deleted') - marca con bandera \Deleted 2. expunge() - elimina permanentemente los marcados """ folder = request.args.get('folder', 'INBOX') M, err = get_imap() if err: return jsonify({'error': err}), 500 try: M.select(folder) M.store(uid.encode(), '+FLAGS', '\\Deleted') # Marcar como eliminado M.expunge() # Expulsar definitivamente M.logout() return jsonify({'ok': True}) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 8. MOVER UN MENSAJE A OTRA CARPETA # ────────────────────────────────────────────────────────────────────────── @app.route('/api/move/', methods=['POST']) def move_message(uid): """ POST /api/move/123 Copiar un correo a otra carpeta y eliminar del original. Parámetros JSON: - from: carpeta origen (ej. "INBOX") - to: carpeta destino (ej. "Trash") Proceso: 1. copy() - copiar el correo a la carpeta destino 2. store()+FLAGS - marcar \Deleted en la carpeta origen 3. expunge() - eliminar de la carpeta origen """ src = request.json.get('from', 'INBOX') dst = request.json.get('to', 'Trash') M, err = get_imap() if err: return jsonify({'error': err}), 500 try: M.select(src) M.copy(uid.encode(), dst) # Copiar a carpeta destino M.store(uid.encode(), '+FLAGS', '\\Deleted') # Marcar para eliminar M.expunge() # Eliminar del origen M.logout() return jsonify({'ok': True}) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 9. BUSCAR MENSAJES # ────────────────────────────────────────────────────────────────────────── @app.route('/api/search') def search_messages(): """ GET /api/search?q=palabra&folder=INBOX Buscar correos por asunto o remitente. Parámetros: - q: término de búsqueda - folder: carpeta donde buscar Usa el comando IMAP: - (OR SUBJECT "palabra" FROM "palabra") Retorna máximo 50 resultados. """ query = request.args.get('q', '') folder = request.args.get('folder', 'INBOX') M, err = get_imap() if err: return jsonify({'error': err}), 500 try: M.select(folder) # Criterio búsqueda: coincida en ASUNTO o REMITENTE criteria = f'(OR SUBJECT "{query}" FROM "{query}")' _, data = M.search(None, criteria) # Obtener máximo 50 resultados, más nuevos primero ids = list(reversed(data[0].split()))[:50] messages = [] # Procesar cada resultado encontrado for uid in ids: _, header_data = M.fetch(uid, '(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])') raw_header = header_data[0][1] msg = email.message_from_bytes(raw_header) _, flags_data = M.fetch(uid, '(FLAGS)') flags = flags_data[0].decode() if flags_data[0] else '' seen = '\\Seen' in flags messages.append({ 'uid': uid.decode(), 'from': decode_str(msg.get('From', '')), 'subject': decode_str(msg.get('Subject', '(Sin asunto)')), 'date': decode_str(msg.get('Date', '')), 'seen': seen, }) M.logout() return jsonify({'messages': messages, 'total': len(messages)}) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 10. MARCAR COMO LEÍDO O DESTACAR CON ESTRELLA # ────────────────────────────────────────────────────────────────────────── @app.route('/api/mark/', methods=['POST']) def mark_message(uid): """ POST /api/mark/123?folder=INBOX Marcar o desmarcar un correo como leído, o agregarlo/quitarlo de favoritos. Parámetros JSON: - flag: qué marcar ('seen' = leído, 'flagged' = estrella) - action: 'add' (marcar) o 'remove' (desmarcar) Usa comando IMAP store: - '+FLAGS' para agregar bandera - '-FLAGS' para quitar bandera Banderas IMAP: - \\Seen : el correo fue leído - \\Flagged : el correo tiene estrella/favorito """ folder = request.args.get('folder', 'INBOX') flag = request.json.get('flag', 'seen') # 'seen' o 'flagged' action = request.json.get('action', 'add') # 'add' o 'remove' M, err = get_imap() if err: return jsonify({'error': err}), 500 try: M.select(folder) # Convertir flag a bandera IMAP imap_flag = {'seen': '\\Seen', 'flagged': '\\Flagged'}.get(flag, '\\Seen') # Operación: agregar (+FLAGS) o quitar (-FLAGS) op = '+FLAGS' if action == 'add' else '-FLAGS' # Ejecutar en IMAP M.store(uid.encode(), op, imap_flag) M.logout() return jsonify({'ok': True}) except Exception as e: return jsonify({'error': str(e)}), 500 # ────────────────────────────────────────────────────────────────────────── # 11. CREAR UNA NUEVA CARPETA # ────────────────────────────────────────────────────────────────────────── @app.route('/api/create_folder', methods=['POST']) def create_folder(): """ POST /api/create_folder Crear una carpeta nueva en el servidor IMAP. Parámetro JSON: - name: nombre de la nueva carpeta Usa el comando IMAP create(). """ name = request.json.get('name', '') M, err = get_imap() if err: return jsonify({'error': err}), 500 try: M.create(name) # Crear la carpeta en el servidor M.logout() return jsonify({'ok': True}) except Exception as e: return jsonify({'error': str(e)}), 500 # ════════════════════════════════════════════════════════════════════════ # INICIO DEL SERVIDOR # ════════════════════════════════════════════════════════════════════════ if __name__ == '__main__': # Iniciar el servidor Flask # debug=True: recarga automática al cambiar código # host='0.0.0.0': aceptar conexiones desde cualquier dirección IP # port=5000: puerto donde escuchar app.run(debug=True, host='0.0.0.0', port=5000)