Initial commit
This commit is contained in:
commit
42e1a76fd1
|
|
@ -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)
|
||||||
|
```
|
||||||
|
|
@ -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 <ID> 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í: <img src="cid:image123">
|
||||||
|
# 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/<uid>')
|
||||||
|
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/<uid>/<int:idx>')
|
||||||
|
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/<uid>', 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/<uid>', 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/<uid>', 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)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
flask>=3.0.0
|
||||||
|
imapclient>=3.0.0
|
||||||
|
|
@ -0,0 +1,652 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>PSP Mail</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600&family=Roboto:wght@300;400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"/>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
:root{
|
||||||
|
--bg:#f6f8fc;
|
||||||
|
--surface:#fff;
|
||||||
|
--accent:#1a73e8;
|
||||||
|
--accent-hover:#1557b0;
|
||||||
|
--accent-light:#e8f0fe;
|
||||||
|
--red:#d93025;
|
||||||
|
--yellow:#f4b400;
|
||||||
|
--green:#34a853;
|
||||||
|
--text:#202124;
|
||||||
|
--text2:#5f6368;
|
||||||
|
--text3:#444746;
|
||||||
|
--border:#e0e0e0;
|
||||||
|
--border2:#c4c7c5;
|
||||||
|
--hover:#f2f2f2;
|
||||||
|
--selected:#c2dbff;
|
||||||
|
--shadow-sm:0 1px 2px rgba(60,64,67,.3),0 1px 3px 1px rgba(60,64,67,.15);
|
||||||
|
--sidebar-w:256px;
|
||||||
|
--header-h:64px;
|
||||||
|
}
|
||||||
|
body{font-family:'Roboto',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column;overflow:hidden;font-size:14px}
|
||||||
|
|
||||||
|
/* HEADER */
|
||||||
|
.header{display:flex;align-items:center;padding:8px 16px;background:var(--surface);height:var(--header-h);gap:8px;z-index:30;flex-shrink:0;border-bottom:1px solid var(--border)}
|
||||||
|
.menu-btn,.icon-btn{width:40px;height:40px;border-radius:50%;border:none;background:none;cursor:pointer;color:var(--text2);font-size:16px;display:flex;align-items:center;justify-content:center;transition:.15s;flex-shrink:0}
|
||||||
|
.menu-btn{font-size:18px}
|
||||||
|
.menu-btn:hover,.icon-btn:hover{background:var(--hover)}
|
||||||
|
.logo{display:flex;align-items:center;gap:4px;width:188px;flex-shrink:0;font-family:'Google Sans',sans-serif;font-size:22px;text-decoration:none;user-select:none;cursor:default}
|
||||||
|
.logo .l-p{color:#4285F4}.logo .l-s{color:#EA4335}.logo .l-pp{color:#FBBC05}
|
||||||
|
.logo-txt{font-weight:400;color:#5f6368;letter-spacing:-.5px}
|
||||||
|
.search-wrap{flex:1;max-width:720px;margin:0 auto;background:#eaf1fb;border-radius:24px;display:flex;align-items:center;padding:0 6px 0 16px;height:46px;transition:.2s}
|
||||||
|
.search-wrap:focus-within{background:#fff;box-shadow:var(--shadow-sm)}
|
||||||
|
.search-wrap input{border:none;background:transparent;outline:none;width:100%;font-size:16px;color:var(--text);font-family:'Google Sans',sans-serif}
|
||||||
|
.search-wrap input::placeholder{color:var(--text2)}
|
||||||
|
.s-btn{background:none;border:none;cursor:pointer;width:38px;height:38px;border-radius:50%;color:var(--text2);font-size:15px;display:flex;align-items:center;justify-content:center;transition:.15s;flex-shrink:0}
|
||||||
|
.s-btn:hover{background:rgba(60,64,67,.1)}
|
||||||
|
.header-right{display:flex;align-items:center;gap:2px;margin-left:4px}
|
||||||
|
.avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#4285F4,#34A853);color:#fff;display:flex;align-items:center;justify-content:center;font-family:'Google Sans';font-weight:600;font-size:13px;cursor:pointer;margin-left:6px;flex-shrink:0}
|
||||||
|
|
||||||
|
/* LAYOUT */
|
||||||
|
.layout{display:flex;flex:1;overflow:hidden}
|
||||||
|
|
||||||
|
/* SIDEBAR */
|
||||||
|
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--bg);padding:8px 0;overflow-y:auto;overflow-x:hidden;transition:width .2s}
|
||||||
|
.compose-btn{display:flex;align-items:center;gap:12px;margin:4px 12px 12px;padding:17px 22px;background:var(--surface);border-radius:24px;box-shadow:0 1px 3px rgba(0,0,0,.2),0 1px 2px rgba(0,0,0,.1);cursor:pointer;font-family:'Google Sans';font-weight:500;font-size:14px;color:#444746;transition:.2s;border:none;width:calc(100% - 24px)}
|
||||||
|
.compose-btn:hover{box-shadow:0 2px 8px rgba(0,0,0,.25);background:#fafafa}
|
||||||
|
.compose-btn i{font-size:17px;color:var(--text2)}
|
||||||
|
.nav-item{display:flex;align-items:center;padding:0 16px 0 26px;height:32px;border-radius:0 16px 16px 0;margin-right:16px;cursor:pointer;font-size:14px;color:#444746;gap:14px;transition:background .1s;position:relative;user-select:none}
|
||||||
|
.nav-item:hover{background:#e8eaed}
|
||||||
|
.nav-item.active{background:#d3e3fd;font-weight:600;color:#041e49}
|
||||||
|
.nav-item .n-icon{font-size:17px;color:var(--text2);width:20px;text-align:center;flex-shrink:0}
|
||||||
|
.nav-item.active .n-icon{color:#041e49}
|
||||||
|
.nav-item .n-badge{margin-left:auto;font-size:12px;font-weight:600;color:#444746}
|
||||||
|
.nav-sep{height:1px;background:var(--border);margin:6px 0}
|
||||||
|
.nav-label{padding:8px 16px 4px 26px;font-size:11px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
|
||||||
|
/* MAIN */
|
||||||
|
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg)}
|
||||||
|
.toolbar{display:flex;align-items:center;padding:4px 8px;gap:2px;flex-shrink:0}
|
||||||
|
.t-btn{background:none;border:none;cursor:pointer;width:36px;height:36px;border-radius:50%;color:var(--text2);font-size:15px;display:flex;align-items:center;justify-content:center;transition:.15s;flex-shrink:0}
|
||||||
|
.t-btn:hover{background:var(--hover)}
|
||||||
|
.t-sep{width:1px;height:20px;background:var(--border);margin:0 4px;flex-shrink:0}
|
||||||
|
.t-label{font-size:13px;color:var(--text2);white-space:nowrap;padding:0 4px}
|
||||||
|
.t-spacer{flex:1}
|
||||||
|
|
||||||
|
/* MSG LIST BOX */
|
||||||
|
.list-box{flex:1;margin:0 16px 16px;overflow:hidden;border-radius:16px;background:var(--surface);box-shadow:0 1px 2px rgba(60,64,67,.15);display:flex;flex-direction:column}
|
||||||
|
.list-scroll{flex:1;overflow-y:auto}
|
||||||
|
|
||||||
|
/* MSG ROW */
|
||||||
|
.msg-row{display:flex;align-items:center;padding:0 12px;height:50px;cursor:pointer;gap:8px;transition:background .1s;border-bottom:1px solid #f0f0f0;flex-shrink:0}
|
||||||
|
.msg-row:last-child{border-bottom:none}
|
||||||
|
.msg-row:hover{background:#f5f5f5;box-shadow:inset 1px 0 0 #dadce0, inset -1px 0 0 #dadce0, 0 1px 3px 1px rgba(60,64,67,.12)}
|
||||||
|
.msg-row.unread{background:#fff}
|
||||||
|
.msg-row.read{background:#f2f6fc}
|
||||||
|
.msg-row.selected{background:#e8f0fe}
|
||||||
|
.msg-check{width:18px;height:18px;flex-shrink:0;display:flex;align-items:center;opacity:0;transition:opacity .15s}
|
||||||
|
.msg-row:hover .msg-check,.msg-row.selected .msg-check{opacity:1}
|
||||||
|
.msg-check input[type=checkbox]{cursor:pointer;width:16px;height:16px;accent-color:var(--accent)}
|
||||||
|
.msg-star{font-size:15px;cursor:pointer;color:transparent;flex-shrink:0;transition:.15s;-webkit-text-stroke:1.5px #bbb;line-height:1}
|
||||||
|
.msg-row:hover .msg-star{-webkit-text-stroke:1.5px #9aa0a6}
|
||||||
|
.msg-star.starred{color:var(--yellow);-webkit-text-stroke:1.5px var(--yellow)}
|
||||||
|
.msg-av{width:28px;height:28px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-family:'Google Sans';font-weight:500;font-size:12px;color:#fff}
|
||||||
|
.msg-from{width:155px;flex-shrink:0;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#444746}
|
||||||
|
.msg-row.unread .msg-from{font-weight:700;color:var(--text)}
|
||||||
|
.msg-body-col{flex:1;display:flex;align-items:baseline;gap:6px;overflow:hidden;min-width:0}
|
||||||
|
.msg-subject{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#444746;flex-shrink:0;max-width:50%}
|
||||||
|
.msg-row.unread .msg-subject{color:var(--text);font-weight:500}
|
||||||
|
.msg-snippet{font-size:13px;color:#9aa0a6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||||
|
.msg-snippet::before{content:" — ";color:#c5c5c5}
|
||||||
|
.msg-date{font-size:12px;color:var(--text2);flex-shrink:0;min-width:52px;text-align:right}
|
||||||
|
.msg-row.unread .msg-date{font-weight:600;color:var(--text)}
|
||||||
|
|
||||||
|
/* EMPTY / LOADING */
|
||||||
|
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:80px 20px;color:var(--text2);text-align:center;flex:1}
|
||||||
|
.empty-state i{font-size:60px;opacity:.18;color:var(--accent)}
|
||||||
|
.empty-state h3{font-family:'Google Sans';font-size:20px;color:var(--text2);font-weight:400}
|
||||||
|
.spinner{width:28px;height:28px;border:3px solid #e0e0e0;border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.loading-wrap{display:flex;align-items:center;justify-content:center;gap:14px;padding:60px;color:var(--text2);font-size:14px}
|
||||||
|
|
||||||
|
/* READ VIEW */
|
||||||
|
.read-panel{display:none;flex-direction:column;flex:1;overflow:hidden;background:var(--surface);border-radius:16px;margin:0 16px 16px;box-shadow:0 1px 2px rgba(60,64,67,.15)}
|
||||||
|
.read-panel.open{display:flex}
|
||||||
|
.read-toolbar{display:flex;align-items:center;padding:6px 12px;border-bottom:1px solid var(--border);gap:2px;flex-shrink:0}
|
||||||
|
.read-scroll{flex:1;overflow-y:auto;padding:0 24px 32px}
|
||||||
|
.read-subject-line{padding:20px 0 16px;font-family:'Google Sans';font-size:24px;font-weight:400;color:var(--text);line-height:1.3}
|
||||||
|
.msg-card{border:1px solid var(--border);border-radius:12px;margin-bottom:12px;overflow:hidden}
|
||||||
|
.card-head{display:flex;align-items:center;padding:14px 16px;gap:12px;cursor:pointer;background:var(--surface)}
|
||||||
|
.card-head:hover{background:#fafafa}
|
||||||
|
.card-sender-av{width:36px;height:36px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-family:'Google Sans';font-weight:500;font-size:15px;color:#fff}
|
||||||
|
.card-sender-info{flex:1;overflow:hidden}
|
||||||
|
.card-sender-name{font-weight:500;font-size:14px;color:var(--text)}
|
||||||
|
.card-sender-addr{font-size:12px;color:var(--text2);margin-top:2px}
|
||||||
|
.card-time{font-size:12px;color:var(--text2);flex-shrink:0;align-self:flex-start;margin-top:2px}
|
||||||
|
.card-acts{display:flex;gap:2px}
|
||||||
|
.card-body{padding:4px 16px 20px;font-size:14px;line-height:1.7;color:var(--text)}
|
||||||
|
.card-body iframe{width:100%;border:none;min-height:200px;display:block}
|
||||||
|
.card-body pre{white-space:pre-wrap;font-family:'Roboto',sans-serif;font-size:14px;line-height:1.6;color:var(--text)}
|
||||||
|
.card-atts{border-top:1px solid var(--border);padding:14px 16px;background:#fafafa}
|
||||||
|
.att-title{font-size:13px;color:var(--text2);font-weight:500;margin-bottom:10px}
|
||||||
|
.att-grid{display:flex;flex-wrap:wrap;gap:8px}
|
||||||
|
.att-chip{display:inline-flex;align-items:center;gap:10px;padding:8px 14px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:13px;transition:.15s;text-decoration:none;color:#444746;background:var(--surface)}
|
||||||
|
.att-chip:hover{background:var(--hover);border-color:var(--border2)}
|
||||||
|
.reply-row{display:flex;gap:10px;padding:16px 0 8px;flex-wrap:wrap}
|
||||||
|
.reply-btn{display:inline-flex;align-items:center;gap:8px;padding:8px 22px;border-radius:20px;font-size:14px;font-family:'Google Sans';cursor:pointer;transition:.15s;border:1px solid var(--border2);background:var(--surface);color:#444746}
|
||||||
|
.reply-btn:hover{background:var(--hover)}
|
||||||
|
|
||||||
|
/* BUTTONS */
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 20px;border-radius:4px;font-size:14px;font-family:'Google Sans';cursor:pointer;transition:.15s;border:1px solid var(--border)}
|
||||||
|
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent);border-radius:4px;box-shadow:0 1px 3px rgba(26,115,232,.3)}
|
||||||
|
.btn-primary:hover{background:var(--accent-hover);box-shadow:0 2px 6px rgba(26,115,232,.4)}
|
||||||
|
.btn-outline{background:var(--surface);color:var(--accent)}
|
||||||
|
.btn-outline:hover{background:var(--accent-light)}
|
||||||
|
|
||||||
|
/* COMPOSE */
|
||||||
|
.compose-modal{position:fixed;bottom:0;right:24px;width:560px;max-height:90vh;background:var(--surface);border-radius:8px 8px 0 0;box-shadow:0 8px 40px rgba(0,0,0,.3),0 2px 12px rgba(0,0,0,.2);display:flex;flex-direction:column;z-index:200;transform:translateY(100%);transition:transform .22s cubic-bezier(.4,0,.2,1)}
|
||||||
|
.compose-modal.open{transform:translateY(0)}
|
||||||
|
.compose-modal.minimized .compose-fields,.compose-modal.minimized .compose-body-wrap{display:none!important}
|
||||||
|
.compose-header{display:flex;align-items:center;padding:10px 16px;background:#404040;color:#fff;border-radius:8px 8px 0 0;cursor:pointer;user-select:none;gap:6px}
|
||||||
|
.compose-header h3{flex:1;font-size:14px;font-family:'Google Sans';font-weight:500}
|
||||||
|
.c-hbtn{background:none;border:none;color:rgba(255,255,255,.85);cursor:pointer;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;transition:.15s}
|
||||||
|
.c-hbtn:hover{background:rgba(255,255,255,.18)}
|
||||||
|
.compose-fields{display:flex;flex-direction:column}
|
||||||
|
.compose-field{display:flex;align-items:center;padding:6px 16px;border-bottom:1px solid var(--border);gap:8px}
|
||||||
|
.compose-field label{font-size:13px;color:var(--text2);min-width:44px;flex-shrink:0}
|
||||||
|
.compose-field input{flex:1;border:none;outline:none;font-size:14px;color:var(--text);font-family:'Roboto'}
|
||||||
|
.compose-body-wrap{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||||
|
.att-preview{display:flex;flex-wrap:wrap;gap:6px;padding:6px 16px 0}
|
||||||
|
.att-pill{display:flex;align-items:center;gap:6px;padding:4px 10px;background:var(--accent-light);border-radius:12px;font-size:12px;color:var(--accent)}
|
||||||
|
.att-pill button{background:none;border:none;color:var(--accent);cursor:pointer;font-size:11px;padding:0 2px}
|
||||||
|
.compose-body{flex:1;padding:12px 16px;border:none;outline:none;font-size:14px;resize:none;font-family:'Roboto';min-height:220px;color:var(--text);line-height:1.6}
|
||||||
|
.compose-footer{display:flex;align-items:center;padding:10px 16px;border-top:1px solid var(--border);gap:6px}
|
||||||
|
.c-foot-btn{background:none;border:none;cursor:pointer;width:34px;height:34px;border-radius:50%;color:var(--text2);font-size:16px;display:flex;align-items:center;justify-content:center;transition:.15s}
|
||||||
|
.c-foot-btn:hover{background:var(--hover)}
|
||||||
|
#att-input{display:none}
|
||||||
|
|
||||||
|
/* TOAST */
|
||||||
|
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(12px);background:#323232;color:#fff;padding:12px 20px;border-radius:6px;font-size:14px;opacity:0;transition:.3s cubic-bezier(.4,0,.2,1);z-index:400;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.3)}
|
||||||
|
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||||
|
|
||||||
|
/* SCROLLBAR */
|
||||||
|
::-webkit-scrollbar{width:7px}::-webkit-scrollbar-track{background:transparent}
|
||||||
|
::-webkit-scrollbar-thumb{background:#dadce0;border-radius:4px}::-webkit-scrollbar-thumb:hover{background:#bbb}
|
||||||
|
|
||||||
|
/* AVATAR PALETTE */
|
||||||
|
.av-0{background:#DB4437}.av-1{background:#F4B400}.av-2{background:#0F9D58}
|
||||||
|
.av-3{background:#4285F4}.av-4{background:#AB47BC}.av-5{background:#00ACC1}
|
||||||
|
.av-6{background:#FF7043}.av-7{background:#5C6BC0}.av-8{background:#26A69A}.av-9{background:#8D6E63}
|
||||||
|
|
||||||
|
@media(max-width:900px){
|
||||||
|
:root{--sidebar-w:72px}
|
||||||
|
.nav-item span:not(.n-icon):not(.n-badge){display:none}
|
||||||
|
.nav-item{padding:0;justify-content:center}
|
||||||
|
.compose-btn span{display:none}
|
||||||
|
.compose-btn{padding:18px;justify-content:center}
|
||||||
|
.logo-txt{display:none}
|
||||||
|
}
|
||||||
|
@media(max-width:600px){
|
||||||
|
.sidebar{display:none}
|
||||||
|
.compose-modal{width:100%;right:0}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<header class="header">
|
||||||
|
<button class="menu-btn" onclick="toggleSidebar()" title="Menú"><i class="fas fa-bars"></i></button>
|
||||||
|
<div class="logo">
|
||||||
|
<span class="l-p">P</span><span class="l-s">S</span><span class="l-pp">P</span>
|
||||||
|
<span class="logo-txt"> Mail</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-wrap">
|
||||||
|
<button class="s-btn" onclick="doSearch()" title="Buscar"><i class="fas fa-search"></i></button>
|
||||||
|
<input id="search-input" placeholder="Buscar en el correo" onkeydown="if(event.key==='Enter')doSearch()"/>
|
||||||
|
<button class="s-btn" onclick="clearSearch()" id="search-clear" style="display:none" title="Limpiar"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="icon-btn" onclick="refreshList()" id="refresh-btn" title="Actualizar"><i class="fas fa-sync-alt"></i></button>
|
||||||
|
<button class="icon-btn" title="Configuración"><i class="fas fa-cog"></i></button>
|
||||||
|
<button class="icon-btn" title="Aplicaciones"><i class="fas fa-th"></i></button>
|
||||||
|
<div class="avatar" title="javi@psp.es">J</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<nav class="sidebar" id="sidebar">
|
||||||
|
<button class="compose-btn" onclick="openCompose()">
|
||||||
|
<i class="fas fa-pen"></i><span>Redactar</span>
|
||||||
|
</button>
|
||||||
|
<div id="folder-list">
|
||||||
|
<div class="nav-item active" data-folder="INBOX" onclick="selectFolder(this,'INBOX')">
|
||||||
|
<span class="n-icon"><i class="fas fa-inbox"></i></span><span>Recibidos</span>
|
||||||
|
<span class="n-badge" id="badge-inbox"></span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-folder="Sent" onclick="selectFolder(this,'Sent')">
|
||||||
|
<span class="n-icon"><i class="fas fa-paper-plane"></i></span><span>Enviados</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-folder="Drafts" onclick="selectFolder(this,'Drafts')">
|
||||||
|
<span class="n-icon"><i class="fas fa-file-alt"></i></span><span>Borradores</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-sep"></div>
|
||||||
|
<div class="nav-item" data-folder="Trash" onclick="selectFolder(this,'Trash')">
|
||||||
|
<span class="n-icon"><i class="fas fa-trash-alt"></i></span><span>Papelera</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-folder="Junk" onclick="selectFolder(this,'Junk')">
|
||||||
|
<span class="n-icon"><i class="fas fa-shield-alt"></i></span><span>Spam</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-sep"></div>
|
||||||
|
<div class="nav-label">Etiquetas</div>
|
||||||
|
<div class="nav-item" onclick="filterStarred()">
|
||||||
|
<span class="n-icon"><i class="fas fa-star" style="color:#f4b400"></i></span><span>Destacados</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- LIST VIEW -->
|
||||||
|
<div id="list-view" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="t-btn" title="Seleccionar todo" onclick="toggleSelectAll()">
|
||||||
|
<i id="sel-icon" class="far fa-square"></i>
|
||||||
|
</button>
|
||||||
|
<button class="t-btn" title="Actualizar" onclick="refreshList()"><i class="fas fa-sync-alt"></i></button>
|
||||||
|
<div class="t-sep"></div>
|
||||||
|
<button class="t-btn" id="btn-delete" title="Eliminar" onclick="deleteSelected()" style="display:none"><i class="fas fa-trash-alt"></i></button>
|
||||||
|
<button class="t-btn" id="btn-mark-read" title="Marcar leído" onclick="markSelected('seen','add')" style="display:none"><i class="fas fa-envelope-open"></i></button>
|
||||||
|
<button class="t-btn" id="btn-mark-unread" title="Marcar no leído" onclick="markSelected('seen','remove')" style="display:none"><i class="fas fa-envelope"></i></button>
|
||||||
|
<div class="t-spacer"></div>
|
||||||
|
<span class="t-label" id="page-info"></span>
|
||||||
|
<button class="t-btn" title="Página anterior" onclick="prevPage()"><i class="fas fa-chevron-left"></i></button>
|
||||||
|
<button class="t-btn" title="Página siguiente" onclick="nextPage()"><i class="fas fa-chevron-right"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="list-box">
|
||||||
|
<div class="list-scroll" id="msg-list">
|
||||||
|
<div class="loading-wrap"><div class="spinner"></div><span>Cargando…</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- READ VIEW -->
|
||||||
|
<div id="read-view" class="read-panel">
|
||||||
|
<div class="read-toolbar">
|
||||||
|
<button class="t-btn" title="Volver" onclick="backToList()"><i class="fas fa-arrow-left"></i></button>
|
||||||
|
<div class="t-sep"></div>
|
||||||
|
<button class="t-btn" title="Archivar" onclick="archiveMsg()"><i class="fas fa-archive"></i></button>
|
||||||
|
<button class="t-btn" title="Eliminar" onclick="deleteCurrentMsg()"><i class="fas fa-trash-alt"></i></button>
|
||||||
|
<button class="t-btn" title="Spam"><i class="fas fa-ban"></i></button>
|
||||||
|
<div class="t-sep"></div>
|
||||||
|
<button class="t-btn" title="Marcar no leído" onclick="markCurrentMsg('seen','remove')"><i class="fas fa-envelope"></i></button>
|
||||||
|
<button class="t-btn" title="Posponer"><i class="fas fa-clock"></i></button>
|
||||||
|
<div class="t-spacer"></div>
|
||||||
|
<button class="t-btn" title="Más opciones"><i class="fas fa-ellipsis-v"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="read-content" class="read-scroll"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COMPOSE MODAL -->
|
||||||
|
<div class="compose-modal" id="compose-modal">
|
||||||
|
<div class="compose-header" onclick="toggleMinimize()">
|
||||||
|
<h3 id="compose-title">Nuevo mensaje</h3>
|
||||||
|
<button class="c-hbtn" onclick="event.stopPropagation();minimizeCompose()" title="Minimizar"><i class="fas fa-minus"></i></button>
|
||||||
|
<button class="c-hbtn" onclick="event.stopPropagation();maximizeCompose()" title="Maximizar"><i class="fas fa-expand-alt"></i></button>
|
||||||
|
<button class="c-hbtn" onclick="event.stopPropagation();closeCompose()" title="Cerrar"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="compose-fields">
|
||||||
|
<div class="compose-field">
|
||||||
|
<label>Para</label>
|
||||||
|
<input id="c-to" type="email" placeholder="Destinatarios"/>
|
||||||
|
</div>
|
||||||
|
<div class="compose-field">
|
||||||
|
<label>Asunto</label>
|
||||||
|
<input id="c-subject" placeholder="Asunto"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="compose-body-wrap">
|
||||||
|
<div class="att-preview" id="att-preview"></div>
|
||||||
|
<textarea class="compose-body" id="c-body" placeholder="Escribe tu mensaje aquí…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="compose-footer">
|
||||||
|
<button class="btn btn-primary" onclick="sendEmail()">Enviar</button>
|
||||||
|
<label class="c-foot-btn" title="Adjuntar archivo">
|
||||||
|
<i class="fas fa-paperclip"></i>
|
||||||
|
<input type="file" id="att-input" multiple onchange="addAttachments(this)"/>
|
||||||
|
</label>
|
||||||
|
<label class="c-foot-btn" title="Emoji"><i class="far fa-smile"></i></label>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="t-btn" onclick="closeCompose()" title="Descartar borrador"><i class="fas fa-trash-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOAST -->
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
let currentFolder='INBOX',currentPage=1,totalMessages=0;
|
||||||
|
const PER_PAGE=25;
|
||||||
|
let selectedUIDs=new Set(),currentMsg=null,attachFiles=[],isSearchMode=false,allMessages=[];
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded',()=>loadMessages());
|
||||||
|
|
||||||
|
async function api(url,opts={}){
|
||||||
|
try{const r=await fetch(url,opts);return await r.json();}
|
||||||
|
catch(e){return{error:e.message};}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar ───────────────────────────────────────────
|
||||||
|
function toggleSidebar(){
|
||||||
|
const s=document.getElementById('sidebar');
|
||||||
|
const open=s.style.width!=='0px'&&s.style.width!=='0';
|
||||||
|
s.style.width=open?'0':'';s.style.padding=open?'0':'';s.style.overflow=open?'hidden':'';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Folders ───────────────────────────────────────────
|
||||||
|
function selectFolder(el,folder){
|
||||||
|
currentFolder=folder;currentPage=1;isSearchMode=false;
|
||||||
|
document.querySelectorAll('.nav-item').forEach(x=>x.classList.remove('active'));
|
||||||
|
el.classList.add('active');backToList();loadMessages();
|
||||||
|
}
|
||||||
|
function filterStarred(){
|
||||||
|
const s=allMessages.filter(m=>document.getElementById('star-'+m.uid)?.classList.contains('starred'));
|
||||||
|
if(!s.length){toast('No hay mensajes destacados');return;}
|
||||||
|
renderMessages(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message List ──────────────────────────────────────
|
||||||
|
async function loadMessages(){
|
||||||
|
const list=document.getElementById('msg-list');
|
||||||
|
list.innerHTML='<div class="loading-wrap"><div class="spinner"></div><span>Cargando mensajes…</span></div>';
|
||||||
|
selectedUIDs.clear();updateToolbar();
|
||||||
|
const data=await api(`/api/messages?folder=${encodeURIComponent(currentFolder)}&page=${currentPage}&per_page=${PER_PAGE}`);
|
||||||
|
allMessages=data.messages||[];totalMessages=data.total||0;
|
||||||
|
const pi=document.getElementById('page-info');
|
||||||
|
if(totalMessages>0){
|
||||||
|
const f=(currentPage-1)*PER_PAGE+1,t=Math.min(currentPage*PER_PAGE,totalMessages);
|
||||||
|
pi.textContent=`${f}–${t} de ${totalMessages}`;
|
||||||
|
}else{pi.textContent='';}
|
||||||
|
renderMessages(allMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
function avClass(name){
|
||||||
|
let h=0;for(let i=0;i<name.length;i++)h=(h*31+name.charCodeAt(i))&0xFFFF;
|
||||||
|
return'av-'+(h%10);
|
||||||
|
}
|
||||||
|
function avLetter(name){return(name||'?').trim()[0].toUpperCase();}
|
||||||
|
|
||||||
|
function renderMessages(msgs){
|
||||||
|
const list=document.getElementById('msg-list');
|
||||||
|
if(!msgs||!msgs.length){
|
||||||
|
list.innerHTML='<div class="empty-state"><i class="fas fa-inbox"></i><h3>Bandeja vacía</h3><p>No hay mensajes aquí</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML=msgs.map(m=>{
|
||||||
|
const from=shortFrom(m.from);
|
||||||
|
return`<div class="msg-row ${m.seen?'read':'unread'}" id="row-${m.uid}" onclick="openMessage('${m.uid}')">
|
||||||
|
<div class="msg-check" onclick="event.stopPropagation()"><input type="checkbox" onchange="toggleSelect('${m.uid}',this)"/></div>
|
||||||
|
<span class="msg-star ${m.flagged?'starred':''}" id="star-${m.uid}" onclick="event.stopPropagation();toggleStar('${m.uid}')">★</span>
|
||||||
|
<div class="msg-av ${avClass(from)}">${avLetter(from)}</div>
|
||||||
|
<span class="msg-from">${escHtml(from)}</span>
|
||||||
|
<span class="msg-body-col">
|
||||||
|
<span class="msg-subject">${escHtml(m.subject)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="msg-date">${formatDate(m.date)}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortFrom(from){
|
||||||
|
if(!from)return'(desconocido)';
|
||||||
|
const m=from.match(/^"?([^"<]+)"?\s*</);if(m)return m[1].trim();
|
||||||
|
const a=from.match(/<([^>]+)>/);return a?a[1].split('@')[0]:from.split('@')[0];
|
||||||
|
}
|
||||||
|
function formatDate(ds){
|
||||||
|
if(!ds)return'';
|
||||||
|
try{
|
||||||
|
const d=new Date(ds),now=new Date();
|
||||||
|
if(d.toDateString()===now.toDateString())return d.toLocaleTimeString('es-ES',{hour:'2-digit',minute:'2-digit'});
|
||||||
|
if((now-d)/86400000<7)return d.toLocaleDateString('es-ES',{weekday:'short'});
|
||||||
|
if(d.getFullYear()===now.getFullYear())return d.toLocaleDateString('es-ES',{day:'numeric',month:'short'});
|
||||||
|
return d.toLocaleDateString('es-ES',{day:'numeric',month:'short',year:'2-digit'});
|
||||||
|
}catch{return'';}
|
||||||
|
}
|
||||||
|
function escHtml(s){
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pagination ────────────────────────────────────────
|
||||||
|
function prevPage(){if(currentPage>1){currentPage--;loadMessages();}}
|
||||||
|
function nextPage(){if(currentPage*PER_PAGE<totalMessages){currentPage++;loadMessages();}}
|
||||||
|
|
||||||
|
// ── Selection ─────────────────────────────────────────
|
||||||
|
function toggleSelect(uid,cb){
|
||||||
|
if(cb.checked)selectedUIDs.add(uid);else selectedUIDs.delete(uid);
|
||||||
|
document.getElementById('row-'+uid)?.classList.toggle('selected',cb.checked);
|
||||||
|
updateToolbar();
|
||||||
|
}
|
||||||
|
function toggleSelectAll(){
|
||||||
|
const cbs=document.querySelectorAll('.msg-check input');
|
||||||
|
const sel=selectedUIDs.size<cbs.length;
|
||||||
|
cbs.forEach(cb=>{
|
||||||
|
cb.checked=sel;
|
||||||
|
const uid=cb.closest('.msg-row').id.replace('row-','');
|
||||||
|
if(sel)selectedUIDs.add(uid);else selectedUIDs.delete(uid);
|
||||||
|
cb.closest('.msg-row').classList.toggle('selected',sel);
|
||||||
|
});
|
||||||
|
document.getElementById('sel-icon').className=sel?'fas fa-check-square':'far fa-square';
|
||||||
|
updateToolbar();
|
||||||
|
}
|
||||||
|
function updateToolbar(){
|
||||||
|
const has=selectedUIDs.size>0;
|
||||||
|
['btn-delete','btn-mark-read','btn-mark-unread'].forEach(id=>{
|
||||||
|
const el=document.getElementById(id);if(el)el.style.display=has?'':'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function deleteSelected(){
|
||||||
|
if(!selectedUIDs.size)return;
|
||||||
|
toast('Eliminando…');
|
||||||
|
for(const uid of selectedUIDs)await api(`/api/delete/${uid}?folder=${encodeURIComponent(currentFolder)}`,{method:'DELETE'});
|
||||||
|
selectedUIDs.clear();toast('Mensajes eliminados');loadMessages();
|
||||||
|
}
|
||||||
|
async function markSelected(flag,action){
|
||||||
|
for(const uid of selectedUIDs)await api(`/api/mark/${uid}?folder=${encodeURIComponent(currentFolder)}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({flag,action})});
|
||||||
|
toast('Actualizado');loadMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open Message ──────────────────────────────────────
|
||||||
|
async function openMessage(uid){
|
||||||
|
document.getElementById('list-view').style.display='none';
|
||||||
|
const rv=document.getElementById('read-view');rv.classList.add('open');
|
||||||
|
document.getElementById('read-content').innerHTML='<div class="loading-wrap"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
const data=await api(`/api/message/${uid}?folder=${encodeURIComponent(currentFolder)}`);
|
||||||
|
if(data.error){
|
||||||
|
document.getElementById('read-content').innerHTML=`<div class="loading-wrap" style="color:var(--red)"><i class="fas fa-exclamation-circle"></i> ${escHtml(data.error)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentMsg=data;
|
||||||
|
document.getElementById('row-'+uid)?.classList.replace('unread','read');
|
||||||
|
|
||||||
|
const sn=shortFrom(data.from);
|
||||||
|
const hasAtt=data.attachments&&data.attachments.length>0;
|
||||||
|
const attHtml=hasAtt?`
|
||||||
|
<div class="card-atts">
|
||||||
|
<div class="att-title"><i class="fas fa-paperclip" style="margin-right:6px"></i>${data.attachments.length} adjunto${data.attachments.length>1?'s':''}</div>
|
||||||
|
<div class="att-grid">
|
||||||
|
${data.attachments.map((a,i)=>{
|
||||||
|
const isImg=a.mime&&a.mime.startsWith('image/');
|
||||||
|
const thumb=isImg?`<img src="data:${a.mime};base64,${a.data}" style="width:56px;height:44px;object-fit:cover;border-radius:4px;border:1px solid var(--border)"/>`
|
||||||
|
:`<i class="fas fa-file" style="font-size:20px;color:var(--text2)"></i>`;
|
||||||
|
return`<a class="att-chip" href="/api/attachment/${uid}/${i}?folder=${encodeURIComponent(currentFolder)}" download="${escHtml(a.filename)}">${thumb}<span><div style="font-weight:500;font-size:12px">${escHtml(a.filename)}</div><div style="font-size:11px;color:var(--text2)">${fmtSize(a.size)}</div></span></a>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`:'';
|
||||||
|
|
||||||
|
const bodyHtml=data.body_html
|
||||||
|
?`<iframe sandbox="allow-same-origin" srcdoc="${escHtml(data.body_html)}" style="width:100%;border:none;min-height:200px;display:block" onload="resizeIframe(this)"></iframe>`
|
||||||
|
:`<pre>${escHtml(data.body_text||'')}</pre>`;
|
||||||
|
|
||||||
|
document.getElementById('read-content').innerHTML=`
|
||||||
|
<div class="read-subject-line">${escHtml(data.subject)}</div>
|
||||||
|
<div class="msg-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-sender-av ${avClass(sn)}">${avLetter(sn)}</div>
|
||||||
|
<div class="card-sender-info">
|
||||||
|
<div class="card-sender-name">${escHtml(sn)}</div>
|
||||||
|
<div class="card-sender-addr"><span style="color:var(--text2)">De:</span> ${escHtml(data.from)} <span style="color:var(--text2)">Para:</span> ${escHtml(data.to)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-time">${escHtml(data.date)}</div>
|
||||||
|
<div class="card-acts">
|
||||||
|
<button class="t-btn" title="Responder" onclick="replyMessage()"><i class="fas fa-reply"></i></button>
|
||||||
|
<button class="t-btn" title="Más"><i class="fas fa-ellipsis-v"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">${bodyHtml}</div>
|
||||||
|
${attHtml}
|
||||||
|
</div>
|
||||||
|
<div class="reply-row">
|
||||||
|
<button class="reply-btn" onclick="replyMessage()"><i class="fas fa-reply" style="color:var(--text2)"></i> Responder</button>
|
||||||
|
<button class="reply-btn" onclick="forwardMessage()"><i class="fas fa-share" style="color:var(--text2)"></i> Reenviar</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeIframe(iframe){
|
||||||
|
try{iframe.style.height=(iframe.contentWindow.document.body.scrollHeight+24)+'px';}catch(e){}
|
||||||
|
}
|
||||||
|
function fmtSize(bytes){
|
||||||
|
if(!bytes)return'0 B';
|
||||||
|
if(bytes<1024)return bytes+' B';
|
||||||
|
if(bytes<1048576)return(bytes/1024).toFixed(1)+' KB';
|
||||||
|
return(bytes/1048576).toFixed(1)+' MB';
|
||||||
|
}
|
||||||
|
function backToList(){
|
||||||
|
document.getElementById('list-view').style.display='flex';
|
||||||
|
document.getElementById('read-view').classList.remove('open');
|
||||||
|
currentMsg=null;
|
||||||
|
}
|
||||||
|
async function deleteCurrentMsg(){
|
||||||
|
if(!currentMsg)return;
|
||||||
|
await api(`/api/delete/${currentMsg.uid}?folder=${encodeURIComponent(currentFolder)}`,{method:'DELETE'});
|
||||||
|
toast('Conversación eliminada');backToList();loadMessages();
|
||||||
|
}
|
||||||
|
function archiveMsg(){toast('Archivado');backToList();loadMessages();}
|
||||||
|
async function markCurrentMsg(flag,action){
|
||||||
|
if(!currentMsg)return;
|
||||||
|
await api(`/api/mark/${currentMsg.uid}?folder=${encodeURIComponent(currentFolder)}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({flag,action})});
|
||||||
|
toast('Actualizado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reply / Forward ───────────────────────────────────
|
||||||
|
function replyMessage(){
|
||||||
|
if(!currentMsg)return;openCompose();
|
||||||
|
document.getElementById('c-to').value=currentMsg.from;
|
||||||
|
document.getElementById('c-subject').value='Re: '+currentMsg.subject.replace(/^Re:\s*/i,'');
|
||||||
|
document.getElementById('c-body').value='\n\nEl '+currentMsg.date+', '+currentMsg.from+' escribió:\n\n'+(currentMsg.body_text||'').split('\n').map(l=>'> '+l).join('\n');
|
||||||
|
document.getElementById('compose-title').textContent='Responder';
|
||||||
|
document.getElementById('c-to').focus();
|
||||||
|
}
|
||||||
|
function forwardMessage(){
|
||||||
|
if(!currentMsg)return;openCompose();
|
||||||
|
document.getElementById('c-subject').value='Fwd: '+currentMsg.subject.replace(/^Fwd:\s*/i,'');
|
||||||
|
document.getElementById('c-body').value='\n\n---------- Mensaje reenviado ----------\nDe: '+currentMsg.from+'\nFecha: '+currentMsg.date+'\nAsunto: '+currentMsg.subject+'\nPara: '+currentMsg.to+'\n\n'+(currentMsg.body_text||'');
|
||||||
|
document.getElementById('compose-title').textContent='Reenviar';
|
||||||
|
document.getElementById('c-to').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compose ───────────────────────────────────────────
|
||||||
|
function openCompose(){
|
||||||
|
const m=document.getElementById('compose-modal');
|
||||||
|
m.classList.add('open');m.classList.remove('minimized');
|
||||||
|
m.style.height='';m.style.maxWidth='';m.style.right='24px';m.style.left='';m.style.width='';m.style.borderRadius='';
|
||||||
|
document.getElementById('c-to').focus();
|
||||||
|
}
|
||||||
|
function closeCompose(){
|
||||||
|
document.getElementById('compose-modal').classList.remove('open','minimized');
|
||||||
|
['c-to','c-subject','c-body'].forEach(id=>document.getElementById(id).value='');
|
||||||
|
document.getElementById('compose-title').textContent='Nuevo mensaje';
|
||||||
|
attachFiles=[];document.getElementById('att-preview').innerHTML='';document.getElementById('att-input').value='';
|
||||||
|
}
|
||||||
|
let _minimized=false;
|
||||||
|
function minimizeCompose(){_minimized=!_minimized;document.getElementById('compose-modal').classList.toggle('minimized',_minimized);}
|
||||||
|
function toggleMinimize(){minimizeCompose();}
|
||||||
|
function maximizeCompose(){
|
||||||
|
const m=document.getElementById('compose-modal');
|
||||||
|
if(m.dataset.max==='1'){m.dataset.max='';m.style.maxWidth='';m.style.width='560px';m.style.height='';m.style.left='';m.style.right='24px';m.style.borderRadius='';}
|
||||||
|
else{m.dataset.max='1';m.style.maxWidth='100%';m.style.width='100%';m.style.height='100%';m.style.left='0';m.style.right='0';m.style.borderRadius='0';}
|
||||||
|
}
|
||||||
|
function addAttachments(input){Array.from(input.files).forEach(f=>attachFiles.push(f));renderAttPreviews();}
|
||||||
|
function renderAttPreviews(){
|
||||||
|
document.getElementById('att-preview').innerHTML=attachFiles.map((f,i)=>
|
||||||
|
`<div class="att-pill"><i class="fas fa-file" style="font-size:11px"></i>${escHtml(f.name)}<button onclick="removeAtt(${i})" title="Quitar"><i class="fas fa-times"></i></button></div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
function removeAtt(i){attachFiles.splice(i,1);renderAttPreviews();}
|
||||||
|
async function sendEmail(){
|
||||||
|
const to=document.getElementById('c-to').value.trim();
|
||||||
|
if(!to){toast('Indica el destinatario');return;}
|
||||||
|
const fd=new FormData();
|
||||||
|
fd.append('to',to);
|
||||||
|
fd.append('subject',document.getElementById('c-subject').value.trim()||'(Sin asunto)');
|
||||||
|
fd.append('body',document.getElementById('c-body').value);
|
||||||
|
fd.append('body_type','plain');
|
||||||
|
attachFiles.forEach(f=>fd.append('attachments',f));
|
||||||
|
const btn=document.querySelector('.compose-footer .btn-primary');
|
||||||
|
btn.innerHTML='<i class="fas fa-circle-notch fa-spin"></i> Enviando…';btn.disabled=true;
|
||||||
|
const data=await api('/api/send',{method:'POST',body:fd});
|
||||||
|
btn.innerHTML='Enviar';btn.disabled=false;
|
||||||
|
if(data.error){toast('Error: '+data.error);}else{toast('Mensaje enviado');closeCompose();}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search ─────────────────────────────────────────────
|
||||||
|
async function doSearch(){
|
||||||
|
const q=document.getElementById('search-input').value.trim();
|
||||||
|
document.getElementById('search-clear').style.display=q?'':'none';
|
||||||
|
if(!q){clearSearch();return;}
|
||||||
|
isSearchMode=true;backToList();
|
||||||
|
const list=document.getElementById('msg-list');
|
||||||
|
list.innerHTML='<div class="loading-wrap"><div class="spinner"></div><span>Buscando…</span></div>';
|
||||||
|
const data=await api(`/api/search?q=${encodeURIComponent(q)}&folder=${encodeURIComponent(currentFolder)}`);
|
||||||
|
allMessages=data.messages||[];
|
||||||
|
document.getElementById('page-info').textContent=allMessages.length?`${allMessages.length} resultado${allMessages.length!==1?'s':''}`:' Sin resultados';
|
||||||
|
renderMessages(allMessages);
|
||||||
|
}
|
||||||
|
function clearSearch(){
|
||||||
|
document.getElementById('search-input').value='';
|
||||||
|
document.getElementById('search-clear').style.display='none';
|
||||||
|
isSearchMode=false;loadMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Star ──────────────────────────────────────────────
|
||||||
|
async function toggleStar(uid){
|
||||||
|
const el=document.getElementById('star-'+uid);
|
||||||
|
const s=el.classList.toggle('starred');
|
||||||
|
await api(`/api/mark/${uid}?folder=${encodeURIComponent(currentFolder)}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({flag:'flagged',action:s?'add':'remove'})});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh ───────────────────────────────────────────
|
||||||
|
function refreshList(){
|
||||||
|
const i=document.querySelector('#refresh-btn i');
|
||||||
|
if(i){i.classList.add('fa-spin');setTimeout(()=>i.classList.remove('fa-spin'),700);}
|
||||||
|
if(isSearchMode)doSearch();else loadMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────
|
||||||
|
let _tt;
|
||||||
|
function toast(msg){
|
||||||
|
const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');
|
||||||
|
clearTimeout(_tt);_tt=setTimeout(()=>t.classList.remove('show'),3200);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue