mail_javi/app.py

699 lines
32 KiB
Python

# ════════════════════════════════════════════════════════════════════════
# 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)