From 42e1a76fd10bc1dfe27ca427a459290b89b5debc Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 23 Feb 2026 20:23:29 +0100 Subject: [PATCH] Initial commit --- README.md | 60 ++++ app.py | 698 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + templates/index.html | 652 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1412 insertions(+) create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 templates/index.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..c155615 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# PSP Mail – Cliente de correo web + +AplicaciΓ³n web tipo Gmail para gestionar el correo de `javi@psp.es` en el servidor `10.10.0.101`. + +## CaracterΓ­sticas + +- πŸ“₯ **Bandeja de entrada** – Lista de mensajes con paginaciΓ³n (25 por pΓ‘gina) +- βœ‰οΈ **Redactar** – EnvΓ­o de correos con adjuntos, CC, soporte HTML +- πŸ“– **Leer mensajes** – VisualizaciΓ³n de texto y HTML con iframes seguros +- πŸ“Ž **Adjuntos** – Descarga directa de archivos adjuntos +- πŸ” **BΓΊsqueda** – Por asunto y remitente en cualquier carpeta +- πŸ—‚οΈ **Carpetas IMAP** – INBOX, Enviados, Borradores, Papelera, Spam +- ⭐ **Destacar** – Marcar mensajes con estrella +- βœ… **Acciones masivas** – Eliminar/marcar varios mensajes a la vez +- ↩️ **Responder / Reenviar** – Con historial original incluido + +## Requisitos + +```bash +pip install flask imapclient +``` + +## ConfiguraciΓ³n del servidor + +En `app.py` (lΓ­neas 14-19): + +```python +MAIL_SERVER = '10.10.0.101' +MAIL_USER = 'javi@psp.es' +MAIL_PASS = '1234' +SMTP_PORT = 25 +IMAP_PORT = 143 +POP_PORT = 110 +``` + +## Ejecutar + +```bash +cd mailapp +python app.py +``` + +Abre el navegador en: **http://localhost:5000** + +## Notas sobre SSL + +Si tu servidor usa SSL/TLS en IMAP (puerto 993) o SMTP (465/587), cambia en `app.py`: + +- IMAP con SSL: `imaplib.IMAP4_SSL(MAIL_SERVER, 993)` +- SMTP con TLS: aΓ±ade `server.starttls()` antes de `sendmail` + +## Estructura del proyecto + +``` +mailapp/ +β”œβ”€β”€ app.py # Backend Flask + lΓ³gica IMAP/SMTP +β”œβ”€β”€ requirements.txt +└── templates/ + └── index.html # Frontend (HTML/CSS/JS - todo en uno) +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..8ad4f50 --- /dev/null +++ b/app.py @@ -0,0 +1,698 @@ +# ════════════════════════════════════════════════════════════════════════ +# 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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b4a5928 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0.0 +imapclient>=3.0.0 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..a71a41b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,652 @@ + + + + + +PSP Mail + + + + + + + +
+ + + +
+ + + +
+ +
+ + + +
J
+
+
+ +
+ + + + + +
+ + +
+
+ + +
+ + + +
+ + + +
+
+
+
Cargando…
+
+
+
+ + +
+
+ +
+ + + +
+ + +
+ +
+
+
+ +
+
+ + +
+
+

Nuevo mensaje

+ + + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ + +
+ + + +