feat: importar tickets PDF desde correo IMAP (Dovecot local, ssl sin verificar)

This commit is contained in:
Tatiana Villa Ema 2026-04-25 18:18:35 +02:00
parent 3d31d308e5
commit 4de7a3d8e0
3 changed files with 136 additions and 97 deletions

25
app.py
View File

@ -314,6 +314,31 @@ def api_regenerar():
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "mensaje": "Timeout al ejecutar el pipeline"}), 500
# -----------------------------------------------------------------------
# API: importar tickets desde correo
# -----------------------------------------------------------------------
@app.route("/api/importar-email", methods=["POST"])
@login_required
def api_importar_email():
usuario = session["usuario"]
try:
r = subprocess.run(
[sys.executable, str(BASE_DIR / "importar_tickets_email.py"), "--usuario", usuario],
cwd=str(BASE_DIR), capture_output=True, text=True, timeout=60
)
output = r.stdout + r.stderr
if r.returncode != 0:
return jsonify({"ok": False, "mensaje": "Error al importar email",
"detalle": output}), 500
# Extraer cuántos tickets se descargaron del output
import re as _re
m = _re.search(r"Total descargados: (\d+)", output)
n = int(m.group(1)) if m else 0
return jsonify({"ok": True, "nuevos": n,
"mensaje": f"{n} ticket(s) nuevos importados del correo"})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "mensaje": "Timeout al conectar con el correo"}), 500
# -----------------------------------------------------------------------
# Archivos estaticos de tickets (solo autenticados)
# -----------------------------------------------------------------------

View File

@ -2,21 +2,21 @@
importar_tickets_email.py
-------------------------
Descarga los tickets PDF de Mercadona desde el correo (IMAP)
y los guarda en la carpeta tickets/ para ser procesados.
y los guarda en tickets/{usuario}/ para ser procesados.
Uso:
python importar_tickets_email.py
python importar_tickets_email.py --usuario tatiana
Configuracion en config.ini (se crea automaticamente la primera vez).
Para Gmail necesitas una "contrasena de aplicacion":
Cuenta Google -> Seguridad -> Verificacion en dos pasos ->
Contrasenas de aplicacion -> Otra -> copiar los 16 caracteres.
Configuracion en config.ini (montado como volumen en Docker).
Soporta SSL sin verificacion de certificado (util con servidores locales).
"""
import argparse
import imaplib
import email
import email.utils
import os
import ssl
import configparser
import sys
import subprocess
@ -24,26 +24,31 @@ from pathlib import Path
from datetime import datetime
from email.header import decode_header
# -----------------------------------------------------------------------
# Argumentos
# -----------------------------------------------------------------------
_parser = argparse.ArgumentParser()
_parser.add_argument("--usuario", default="default", help="Usuario al que asignar los tickets")
_args = _parser.parse_args()
# -----------------------------------------------------------------------
# Rutas
# -----------------------------------------------------------------------
BASE_DIR = Path(__file__).parent
TICKETS_DIR = BASE_DIR / "tickets"
BASE_DIR = Path(__file__).parent
CONFIG_FILE = BASE_DIR / "config.ini"
TICKETS_DIR.mkdir(exist_ok=True)
# -----------------------------------------------------------------------
# Configuracion
# -----------------------------------------------------------------------
DEFAULTS = {
"imap_host": "imap.gmail.com",
"imap_port": "993",
"correo": "",
"password": "",
"remitente": "noreply@mercadona.es",
"solo_nuevos": "true",
"ejecutar_pipeline": "false",
"imap_host": "localhost",
"imap_port": "993",
"correo": "",
"password": "",
"remitente": "noreply@mercadona.es",
"solo_nuevos": "true",
"ssl_verify": "true",
"ejecutar_pipeline": "true",
}
def leer_config():
@ -56,55 +61,6 @@ def leer_config():
cfg["email"].setdefault(k, v)
return cfg
def guardar_config(cfg):
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
cfg.write(f)
def pedir_config():
"""Solicita los datos de acceso si no estan configurados.
En Docker, usa variables de entorno EMAIL_CORREO / EMAIL_PASSWORD / EMAIL_IMAP_HOST.
"""
cfg = leer_config()
sec = cfg["email"]
# Leer desde variables de entorno (prioritarias sobre config.ini)
env_correo = os.environ.get("EMAIL_CORREO", "").strip()
env_pwd = os.environ.get("EMAIL_PASSWORD", "").strip()
env_host = os.environ.get("EMAIL_IMAP_HOST", "").strip()
if env_correo:
sec["correo"] = env_correo
if env_pwd:
sec["password"] = env_pwd
if env_host:
sec["imap_host"] = env_host
if not sec["correo"] or not sec["password"]:
# Modo interactivo: solo funciona fuera de Docker
if not sys.stdin.isatty():
print("ERROR: config.ini vacio y no hay variables de entorno EMAIL_CORREO / EMAIL_PASSWORD.")
print("Opciones:")
print(" 1. Crea config.ini en el host y montalo como volumen.")
print(" 2. Añade EMAIL_CORREO y EMAIL_PASSWORD al docker-compose.yml.")
sys.exit(1)
print("=== Configuracion inicial ===")
print("Necesito los datos de acceso al correo.")
print("Para Gmail usa una contrasena de aplicacion (no tu contrasena normal).")
print("https://myaccount.google.com/apppasswords\n")
correo = input("Correo electronico: ").strip()
pwd = input("Contrasena (o contrasena de aplicacion): ").strip()
host = input(f"Servidor IMAP [{sec['imap_host']}]: ").strip() or sec["imap_host"]
sec["correo"] = correo
sec["password"] = pwd
sec["imap_host"] = host
guardar_config(cfg)
print(f"\nConfiguracion guardada en {CONFIG_FILE}\n")
return cfg
# -----------------------------------------------------------------------
# Decodificacion de nombres de archivo
# -----------------------------------------------------------------------
@ -123,26 +79,43 @@ def decodificar_nombre(nombre_raw):
# -----------------------------------------------------------------------
# Descarga de tickets
# -----------------------------------------------------------------------
def descargar_tickets():
cfg = pedir_config()
def descargar_tickets(usuario: str) -> int:
cfg = leer_config()
sec = cfg["email"]
host = sec["imap_host"]
port = int(sec["imap_port"])
correo = sec["correo"]
password = sec["password"]
# Variables de entorno sobreescriben config.ini
host = os.environ.get("EMAIL_IMAP_HOST", sec["imap_host"]).strip()
port = int(os.environ.get("EMAIL_IMAP_PORT", sec["imap_port"]))
correo = os.environ.get("EMAIL_CORREO", sec["correo"]).strip()
password = os.environ.get("EMAIL_PASSWORD", sec["password"]).strip()
remitente = sec["remitente"]
solo_nuevos = sec["solo_nuevos"].lower() == "true"
solo_nuevos = sec["solo_nuevos"].lower() == "true"
ssl_verify = sec["ssl_verify"].lower() == "true"
ejecutar_pipeline = sec["ejecutar_pipeline"].lower() == "true"
print(f"Conectando a {host}:{port} como {correo}...")
if not correo or not password:
print("ERROR: correo o password no configurados en config.ini / variables de entorno.")
sys.exit(1)
tickets_dir = BASE_DIR / "tickets" / usuario
tickets_dir.mkdir(parents=True, exist_ok=True)
print(f"[email] Conectando a {host}:{port} como {correo} (ssl_verify={ssl_verify})...")
try:
conn = imaplib.IMAP4_SSL(host, port)
if ssl_verify:
conn = imaplib.IMAP4_SSL(host, port)
else:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
conn.login(correo, password)
except imaplib.IMAP4.error as e:
print(f"Error de autenticacion: {e}")
print("Comprueba el correo y la contrasena en config.ini")
print(f"[email] Error de autenticacion: {e}")
sys.exit(1)
except Exception as e:
print(f"[email] Error de conexion: {e}")
sys.exit(1)
conn.select("INBOX")
@ -155,21 +128,20 @@ def descargar_tickets():
ids = ids[0].split()
if not ids:
print("No hay tickets nuevos de Mercadona.")
print("[email] No hay tickets nuevos de Mercadona.")
conn.logout()
return 0
print(f"Encontrados {len(ids)} correo(s) de Mercadona.")
print(f"[email] Encontrados {len(ids)} correo(s) de Mercadona.")
descargados = 0
for uid in ids:
_, data = conn.fetch(uid, "(RFC822)")
msg = email.message_from_bytes(data[0][1])
# Fecha del correo para nombre de archivo
fecha_str = msg.get("Date", "")
try:
fecha_dt = email.utils.parsedate_to_datetime(fecha_str)
fecha_dt = email.utils.parsedate_to_datetime(fecha_str)
fecha_fmt = fecha_dt.strftime("%Y%m%d")
except Exception:
fecha_fmt = datetime.now().strftime("%Y%m%d")
@ -181,31 +153,34 @@ def descargar_tickets():
if not nombre_orig:
nombre_orig = f"ticket_{fecha_fmt}_{uid.decode()}.pdf"
# Evitar colisiones
destino = TICKETS_DIR / nombre_orig
destino = tickets_dir / nombre_orig
if destino.exists():
destino = TICKETS_DIR / f"{fecha_fmt}_{nombre_orig}"
destino = tickets_dir / f"{fecha_fmt}_{nombre_orig}"
destino.write_bytes(part.get_payload(decode=True))
print(f" Guardado: {destino.name}")
print(f"[email] Guardado: {destino.name}")
adjuntos_guardados += 1
descargados += 1
if adjuntos_guardados == 0:
print(f" Correo {uid.decode()}: sin PDF adjunto, ignorado.")
print(f"[email] Correo {uid.decode()}: sin PDF adjunto, ignorado.")
# Marcar como leido
conn.store(uid, "+FLAGS", "\\Seen")
conn.logout()
print(f"\nTotal descargados: {descargados} archivo(s) en {TICKETS_DIR}")
print(f"[email] Total descargados: {descargados} archivo(s) en {tickets_dir}")
# Ejecutar pipeline si esta configurado
if ejecutar_pipeline and descargados > 0:
print("\nEjecutando pipeline de procesado...")
subprocess.run([sys.executable, str(BASE_DIR / "autocompra7.py")], check=False)
subprocess.run([sys.executable, str(BASE_DIR / "generar_lista.py")], check=False)
print("Pipeline completado.")
print("[email] Ejecutando pipeline...")
subprocess.run(
[sys.executable, str(BASE_DIR / "autocompra7.py"), "--usuario", usuario],
cwd=str(BASE_DIR), check=False
)
subprocess.run(
[sys.executable, str(BASE_DIR / "generar_lista.py"), "--usuario", usuario],
cwd=str(BASE_DIR), check=False
)
print("[email] Pipeline completado.")
return descargados
@ -213,6 +188,5 @@ def descargar_tickets():
# Entry point
# -----------------------------------------------------------------------
if __name__ == "__main__":
descargados = descargar_tickets()
if descargados == 0:
sys.exit(0)
n = descargar_tickets(_args.usuario)
sys.exit(0 if n >= 0 else 1)

View File

@ -74,6 +74,13 @@
<div id="fotoProductos" style="margin-top:.5rem;"></div>
<div style="margin-top:1.25rem; border-top:1px solid var(--border); padding-top:1rem;">
<button class="btn btn-secondary btn-sm" onclick="importarEmail()" id="btnImportarEmail">
📧 Importar tickets del correo
</button>
<div id="importarEmailEstado" style="font-size:.8rem; color:var(--text-muted); margin-top:.4rem;"></div>
</div>
<div style="margin-top:1rem; border-top:1px solid var(--border); padding-top:1rem;">
<button class="btn btn-secondary btn-sm" onclick="regenerar()" id="btnRegenerar">
Regenerar predicciones
</button>
@ -355,6 +362,39 @@
estado.textContent = '❌ Error de conexion';
}
}
// -----------------------------------------------------------------------
// Importar tickets desde correo
// -----------------------------------------------------------------------
async function importarEmail() {
const btn = document.getElementById('btnImportarEmail');
const estado = document.getElementById('importarEmailEstado');
btn.disabled = true;
estado.style.color = 'var(--text-muted)';
estado.textContent = '⏳ Conectando con el correo...';
try {
const res = await fetch('/api/importar-email', { method: 'POST' });
const data = await res.json();
if (data.ok) {
if (data.nuevos > 0) {
estado.textContent = '✅ ' + data.mensaje + ' — regenerando predicciones...';
await fetch('/api/regenerar', { method: 'POST' });
setTimeout(() => location.reload(), 1200);
} else {
estado.textContent = '✅ No hay tickets nuevos en el correo.';
}
} else {
estado.style.color = '#f97316';
estado.textContent = '❌ ' + data.mensaje;
if (data.detalle) {
estado.innerHTML += '<pre style="font-size:.7rem;white-space:pre-wrap;margin-top:.3rem;color:#f97316;">' + data.detalle + '</pre>';
}
}
} catch(e) {
estado.textContent = '❌ Error de conexion';
} finally {
btn.disabled = false;
}
}
</script>
</body>
</html>