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