diff --git a/.gitignore b/.gitignore index a938570..7e5496a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ emails.db *.egg-info/ build/ procesos/ +tools/previews/ diff --git a/plantilla/ejemplos.json b/plantilla/ejemplos.json new file mode 100644 index 0000000..4ef7afe --- /dev/null +++ b/plantilla/ejemplos.json @@ -0,0 +1,280 @@ +{ + "aceptado_con_pago": { + "plantilla": "aceptado", + "contexto": { + "logo_url": "", + "comprobante_tipo_corto": "factura", + "comprobante_tipo": "Factura electrónica", + "recepcion_fecha": "19/10/2026", + "recepcion_hora": "10:35", + "contacto_correo": "contacto@ejemplo.com", + "proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.", + "proveedor_ruc": "20537929520", + "asunto": "Aceptado · Factura F002-486 · ABR-261019-00042", + "preheader": "Su factura F002-486 pasó todas las validaciones. No necesita hacer nada más.", + "comprobante_numero": "F002-486", + "codigo_seguimiento": "ABR-261019-00042", + "fecha_estimada_pago": "18/11/2026", + "guias": [ + { + "guia": "T001-127773", + "importe_factura": "S/ 624.38", + "conforme": true + }, + { + "guia": "T001-127774", + "importe_factura": "S/ 311.20", + "conforme": true + }, + { + "guia": "T001-127775", + "importe_factura": "S/ 1,250.00", + "conforme": true + } + ] + } + }, + "aceptado_sin_pago": { + "plantilla": "aceptado", + "contexto": { + "logo_url": "", + "comprobante_tipo_corto": "factura", + "comprobante_tipo": "Factura electrónica", + "recepcion_fecha": "19/10/2026", + "recepcion_hora": "10:35", + "contacto_correo": "contacto@ejemplo.com", + "proveedor_razon_social": "SERVICIOS LOGÍSTICOS DEL SUR S.A.", + "proveedor_ruc": "20111111111", + "asunto": "Aceptado · Factura E001-2403 · ABR-261019-00043", + "preheader": "Su factura E001-2403 pasó todas las validaciones. No necesita hacer nada más.", + "comprobante_numero": "E001-2403", + "codigo_seguimiento": "ABR-261019-00043", + "fecha_estimada_pago": null, + "guias": [ + { + "guia": "T001-129352", + "importe_factura": "S/ 828.73", + "conforme": true + } + ] + } + }, + "aceptado_muchas_guias": { + "plantilla": "aceptado", + "contexto": { + "logo_url": "", + "comprobante_tipo_corto": "factura", + "comprobante_tipo": "Factura electrónica", + "recepcion_fecha": "19/10/2026", + "recepcion_hora": "10:35", + "contacto_correo": "contacto@ejemplo.com", + "proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.", + "proveedor_ruc": "20537929520", + "asunto": "Aceptado · Factura F004-5989 · ABR-261019-00044", + "preheader": "Su factura F004-5989 pasó todas las validaciones. No necesita hacer nada más.", + "comprobante_numero": "F004-5989", + "codigo_seguimiento": "ABR-261019-00044", + "fecha_estimada_pago": "30/11/2026", + "guias": [ + { + "guia": "T001-130000", + "importe_factura": "S/ 137.50", + "conforme": true + }, + { + "guia": "T001-130001", + "importe_factura": "S/ 275.00", + "conforme": true + }, + { + "guia": "T001-130002", + "importe_factura": "S/ 412.50", + "conforme": true + }, + { + "guia": "T001-130003", + "importe_factura": "S/ 550.00", + "conforme": true + }, + { + "guia": "T001-130004", + "importe_factura": "S/ 687.50", + "conforme": true + }, + { + "guia": "T001-130005", + "importe_factura": "S/ 825.00", + "conforme": true + }, + { + "guia": "T001-130006", + "importe_factura": "S/ 962.50", + "conforme": true + }, + { + "guia": "T001-130007", + "importe_factura": "S/ 1,100.00", + "conforme": true + }, + { + "guia": "T001-130008", + "importe_factura": "S/ 1,237.50", + "conforme": true + }, + { + "guia": "T001-130009", + "importe_factura": "S/ 1,375.00", + "conforme": true + }, + { + "guia": "T001-130010", + "importe_factura": "S/ 1,512.50", + "conforme": true + }, + { + "guia": "T001-130011", + "importe_factura": "S/ 1,650.00", + "conforme": true + } + ] + } + }, + "rechazado_dos_observaciones": { + "plantilla": "rechazado", + "contexto": { + "logo_url": "", + "comprobante_tipo_corto": "factura", + "comprobante_tipo": "Factura electrónica", + "recepcion_fecha": "19/10/2026", + "recepcion_hora": "10:35", + "contacto_correo": "contacto@ejemplo.com", + "proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.", + "proveedor_ruc": "20537929520", + "asunto": "Rechazado · Factura F002-486 · ABR-261019-00045", + "preheader": "Tiene 2 observaciones. Corríjalas y envíenos el comprobante nuevamente.", + "comprobante_numero": "F002-486", + "codigo_seguimiento": "ABR-261019-00045", + "guias": [ + { + "guia": "T001-127773", + "importe_factura": "S/ 624.38", + "conforme": false + }, + { + "guia": "T001-127774", + "importe_factura": "S/ 311.20", + "conforme": true + } + ], + "observaciones_texto": "2 observaciones", + "observaciones": [ + { + "numero": 1, + "titulo": "El importe no coincide con nuestro registro", + "filas": [ + { + "etiqueta": "Importe en su factura", + "valor": "S/ 624.38" + }, + { + "etiqueta": "Importe esperado", + "valor": "S/ 600.00" + } + ], + "como_corregir": "Revise el precio acordado para el servicio y emita el comprobante con el importe correcto." + }, + { + "numero": 2, + "titulo": "No encontramos la guía T001-128912", + "filas": [ + { + "etiqueta": "Guía", + "valor": "T001-128912" + } + ], + "como_corregir": "Verifique que la guía esté registrada y que la serie y el número estén bien escritos." + } + ], + "envio_correo": "comprobantes@ejemplo.com", + "tiene_guias": true + } + }, + "rechazado_una_observacion": { + "plantilla": "rechazado", + "contexto": { + "logo_url": "", + "comprobante_tipo_corto": "factura", + "comprobante_tipo": "Factura electrónica", + "recepcion_fecha": "19/10/2026", + "recepcion_hora": "10:35", + "contacto_correo": "contacto@ejemplo.com", + "proveedor_razon_social": "TRANSPORTES \"EL RÁPIDO\" E.I.R.L.", + "proveedor_ruc": "10060028686", + "asunto": "Rechazado · Factura F002-2450 · ABR-261019-00046", + "preheader": "Tiene 1 observación. Corríjalas y envíenos el comprobante nuevamente.", + "comprobante_numero": "F002-2450", + "codigo_seguimiento": "ABR-261019-00046", + "guias": [ + { + "guia": "T001-128912", + "importe_factura": "S/ 624.38", + "conforme": false + } + ], + "observaciones_texto": "1 observación", + "observaciones": [ + { + "numero": 1, + "titulo": "El importe no coincide con nuestro registro", + "filas": [ + { + "etiqueta": "Importe en su factura", + "valor": "S/ 624.38" + }, + { + "etiqueta": "Importe esperado", + "valor": "S/ 600.00" + } + ], + "como_corregir": "Revise el precio acordado para el servicio y emita el comprobante con el importe correcto." + } + ], + "envio_correo": "comprobantes@ejemplo.com", + "tiene_guias": true + } + }, + "rechazado_sin_comprobante": { + "plantilla": "rechazado", + "contexto": { + "logo_url": "", + "comprobante_tipo_corto": "comprobante", + "comprobante_tipo": "Factura electrónica", + "recepcion_fecha": "19/10/2026", + "recepcion_hora": "10:35", + "contacto_correo": "contacto@ejemplo.com", + "proveedor_razon_social": "proveedor@ejemplo.com", + "proveedor_ruc": null, + "asunto": "Rechazado · Comprobante no recibido · ABR-261019-00047", + "preheader": "Tiene 1 observación. Corríjalas y envíenos el comprobante nuevamente.", + "comprobante_numero": null, + "codigo_seguimiento": "ABR-261019-00047", + "guias": [], + "observaciones_texto": "1 observación", + "observaciones": [ + { + "numero": 1, + "titulo": "El correo no trae el XML ni el PDF del comprobante", + "filas": [ + { + "etiqueta": "Archivos recibidos", + "valor": "0" + } + ], + "como_corregir": "Adjunte el XML y el PDF del comprobante en el mismo correo." + } + ], + "envio_correo": "comprobantes@ejemplo.com", + "tiene_guias": false + } + } +} \ No newline at end of file diff --git a/plantilla/plantilla-aceptado.html b/plantilla/plantilla-aceptado.html new file mode 100644 index 0000000..9dfb36f --- /dev/null +++ b/plantilla/plantilla-aceptado.html @@ -0,0 +1,139 @@ + + + + + + + + + + +{{asunto}} + + + + +
{{preheader}} ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
+ + +
+ + + + + + + + + + + + + + +
+ + diff --git a/plantilla/plantilla-rechazado.html b/plantilla/plantilla-rechazado.html new file mode 100644 index 0000000..5604ebe --- /dev/null +++ b/plantilla/plantilla-rechazado.html @@ -0,0 +1,174 @@ + + + + + + + + + + +{{asunto}} + + + + +
{{preheader}} ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
+ + +
+ + + + + + +{{#tiene_guias}} + + +{{/tiene_guias}} + + + + + + + + +
+ + diff --git a/plantillas.py b/plantillas.py new file mode 100644 index 0000000..f3dd304 --- /dev/null +++ b/plantillas.py @@ -0,0 +1,123 @@ +""" +Renderizado de las plantillas HTML de correo (``plantilla/plantilla-*.html``). + +Las plantillas usan un subconjunto de Mustache: + + {{variable}} valor, con escape de HTML + {{#nombre}}...{{/nombre}} sección: se repite por cada elemento de una lista, + o se muestra una vez si el valor es verdadero + {{^nombre}}...{{/nombre}} sección invertida: se muestra si el valor es falso o vacío + +El renderizado es **estricto**: toda variable o sección debe existir en el +contexto (usa ``None``, ``False`` o ``[]`` para ocultar algo). Así un dato que +falta provoca un error al renderizar, en vez de un correo con huecos. + +No soporta ``{{{triple}}}``, parciales, comentarios ni nombres con punto; no se +usan en las plantillas. +""" + +import html +import os +import re + +CARPETA_PLANTILLAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla") + +_ETIQUETA = re.compile(r"\{\{\s*([#^/]?)\s*([A-Za-z_]\w*)\s*\}\}") + + +class PlantillaError(ValueError): + """La plantilla está mal formada o el contexto no tiene algún dato que necesita.""" + + +# ============================================================================ +def _analizar(texto: str) -> list: + """Convierte el texto en un árbol de nodos: ``("texto", s)``, ``("var", nombre)`` o ``("sec", nombre, hijos, invertida)``.""" + raiz: list = [] + pila: list[tuple[str, list, bool]] = [] # (nombre, hijos, invertida) de las secciones abiertas + actual = raiz + posicion = 0 + + for m in _ETIQUETA.finditer(texto): + if m.start() > posicion: + actual.append(("texto", texto[posicion:m.start()])) + posicion = m.end() + marca, nombre = m.group(1), m.group(2) + + if marca == "": + actual.append(("var", nombre)) + elif marca in ("#", "^"): + hijos: list = [] + actual.append(("sec", nombre, hijos, marca == "^")) + pila.append((nombre, actual, marca == "^")) + actual = hijos + else: # cierre + linea = texto.count("\n", 0, m.start()) + 1 + if not pila or pila[-1][0] != nombre: + raise PlantillaError(f"Línea {linea}: '{{{{/{nombre}}}}}' no corresponde a ninguna sección abierta") + actual = pila.pop()[1] + + if pila: + raise PlantillaError(f"La sección '{pila[-1][0]}' no se cierra") + if posicion < len(texto): + actual.append(("texto", texto[posicion:])) + return raiz + + +def _buscar(nombre: str, pila: list): + for marco in reversed(pila): + if isinstance(marco, dict) and nombre in marco: + return marco[nombre] + raise PlantillaError(f"Falta '{nombre}' en el contexto de la plantilla") + + +def _generar(nodos: list, pila: list) -> str: + salida: list[str] = [] + for nodo in nodos: + tipo = nodo[0] + if tipo == "texto": + salida.append(nodo[1]) + elif tipo == "var": + valor = _buscar(nodo[1], pila) + salida.append("" if valor is None else html.escape(str(valor), quote=True)) + else: + _, nombre, hijos, invertida = nodo + valor = _buscar(nombre, pila) + if invertida: + if not valor: + salida.append(_generar(hijos, pila)) + elif isinstance(valor, list): + for elemento in valor: + salida.append(_generar(hijos, pila + [elemento])) + elif valor: + salida.append(_generar(hijos, pila + [valor] if isinstance(valor, dict) else pila)) + return "".join(salida) + + +# ============================================================================ +def renderizar(texto: str, contexto: dict) -> str: + """Renderiza ``texto`` (plantilla Mustache) con ``contexto``. Lanza ``PlantillaError`` si falta algún dato.""" + return _generar(_analizar(texto), [contexto]) + + +def nombres_usados(texto: str) -> set[str]: + """Todos los nombres (variables y secciones) que usa la plantilla.""" + def recorrer(nodos): + for n in nodos: + if n[0] == "var": + yield n[1] + elif n[0] == "sec": + yield n[1] + yield from recorrer(n[2]) + return set(recorrer(_analizar(texto))) + + +def cargar_plantilla(nombre: str) -> str: + """Lee ``plantilla/plantilla-.html`` (por ejemplo ``aceptado`` o ``rechazado``).""" + ruta = os.path.join(CARPETA_PLANTILLAS, f"plantilla-{nombre}.html") + with open(ruta, encoding="utf-8") as f: + return f.read() + + +def renderizar_plantilla(nombre: str, contexto: dict) -> str: + """Carga la plantilla ``nombre`` y la renderiza con ``contexto``.""" + return renderizar(cargar_plantilla(nombre), contexto) diff --git a/tests/test_plantillas.py b/tests/test_plantillas.py new file mode 100644 index 0000000..48af5fd --- /dev/null +++ b/tests/test_plantillas.py @@ -0,0 +1,179 @@ +import json +import os + +import pytest + +import plantillas +from plantillas import PlantillaError, renderizar + +EJEMPLOS = os.path.join(plantillas.CARPETA_PLANTILLAS, "ejemplos.json") + + +def _ejemplos() -> dict: + with open(EJEMPLOS, encoding="utf-8") as f: + return json.load(f) + + +# ============================================================================ +# Motor de plantillas +# ============================================================================ +def test_variable_con_escape_de_html(): + assert renderizar("{{x}}", {"x": 'A & B "c" \'d\''}) == "A & B <i> "c" 'd'" + + +def test_variable_none_queda_vacia_y_numeros_se_convierten(): + assert renderizar("[{{a}}][{{b}}]", {"a": None, "b": 12.5}) == "[][12.5]" + + +def test_variable_faltante_lanza_error(): + with pytest.raises(PlantillaError, match="Falta 'x'"): + renderizar("{{x}}", {}) + + +def test_seccion_faltante_tambien_lanza_error(): + with pytest.raises(PlantillaError, match="Falta 'opcional'"): + renderizar("{{#opcional}}x{{/opcional}}", {}) + with pytest.raises(PlantillaError, match="Falta 'opcional'"): + renderizar("{{^opcional}}x{{/opcional}}", {}) + + +def test_seccion_de_lista_se_repite_y_ve_el_contexto_externo(): + texto = "{{titulo}}:{{#items}}[{{titulo}}-{{n}}]{{/items}}" + assert renderizar(texto, {"titulo": "T", "items": [{"n": 1}, {"n": 2}]}) == "T:[T-1][T-2]" + + +def test_lista_vacia_no_muestra_nada_y_la_invertida_si(): + texto = "{{#items}}x{{/items}}{{^items}}vacío{{/items}}" + assert renderizar(texto, {"items": []}) == "vacío" + assert renderizar(texto, {"items": [{}]}) == "x" + + +@pytest.mark.parametrize("valor, visible", [(True, True), (False, False), (None, False), ("", False), ("a", True), (0, False)]) +def test_seccion_booleana(valor, visible): + texto = "{{#f}}SI{{/f}}{{^f}}NO{{/f}}" + assert renderizar(texto, {"f": valor}) == ("SI" if visible else "NO") + + +def test_seccion_con_texto_muestra_una_vez_y_la_variable_sigue_disponible(): + """Caso de ``fecha_estimada_pago``: la sección usa un texto como condición y lo imprime dentro.""" + texto = "{{#pago}}Pago: {{pago}}{{/pago}}" + assert renderizar(texto, {"pago": "18/11/2026"}) == "Pago: 18/11/2026" + assert renderizar(texto, {"pago": None}) == "" + + +def test_secciones_con_el_mismo_nombre_seguidas_en_una_linea(): + """Caso de ``conforme``: ``{{#conforme}}..{{/conforme}}{{^conforme}}..{{/conforme}}``.""" + texto = "{{#guias}}<{{#ok}}Conforme{{/ok}}{{^ok}}No coincide{{/ok}}>{{/guias}}" + assert renderizar(texto, {"guias": [{"ok": True}, {"ok": False}]}) == "" + + +def test_secciones_anidadas(): + texto = "{{#obs}}{{t}}({{#filas}}{{k}}={{v}};{{/filas}}){{/obs}}" + contexto = {"obs": [{"t": "A", "filas": [{"k": "x", "v": 1}, {"k": "y", "v": 2}]}, {"t": "B", "filas": []}]} + assert renderizar(texto, contexto) == "A(x=1;y=2;)B()" + + +def test_seccion_sin_cerrar_o_cierre_sin_abrir(): + with pytest.raises(PlantillaError, match="no se cierra"): + renderizar("{{#a}}x", {"a": True}) + with pytest.raises(PlantillaError, match="no corresponde"): + renderizar("x{{/a}}", {"a": True}) + with pytest.raises(PlantillaError, match="no corresponde"): + renderizar("{{#a}}{{#b}}x{{/a}}{{/b}}", {"a": True, "b": True}) + + +def test_llaves_sueltas_de_css_no_se_confunden_con_etiquetas(): + assert renderizar("a{color:red;}{{x}}", {"x": "1"}) == "a{color:red;}1" + + +def test_nombres_usados(): + assert plantillas.nombres_usados("{{a}}{{#b}}{{c}}{{/b}}{{^d}}x{{/d}}") == {"a", "b", "c", "d"} + + +# ============================================================================ +# Plantillas reales con los ejemplos de plantilla/ejemplos.json +# ============================================================================ +ESCENARIOS = sorted(_ejemplos()) + + +def _claves(obj) -> set[str]: + if isinstance(obj, dict): + return set(obj) | {c for v in obj.values() for c in _claves(v)} + if isinstance(obj, list): + return {c for v in obj for c in _claves(v)} + return set() + + +@pytest.mark.parametrize("escenario", ESCENARIOS) +def test_todos_los_escenarios_se_renderizan_completos(escenario): + ejemplo = _ejemplos()[escenario] + html = plantillas.renderizar_plantilla(ejemplo["plantilla"], ejemplo["contexto"]) + + assert "{{" not in html and "}}" not in html # nada sin resolver + assert ejemplo["contexto"]["codigo_seguimiento"] in html + assert html.lstrip().startswith("") + + +@pytest.mark.parametrize("nombre", ["aceptado", "rechazado"]) +def test_los_ejemplos_cubren_las_variables_de_la_plantilla(nombre): + """Si la plantilla pide algo nuevo (o un ejemplo sobra), el desfase se nota aquí.""" + usados = plantillas.nombres_usados(plantillas.cargar_plantilla(nombre)) + ejemplos = [e["contexto"] for e in _ejemplos().values() if e["plantilla"] == nombre] + + assert ejemplos, f"No hay ejemplos para la plantilla {nombre}" + cubiertos = set().union(*(_claves(c) for c in ejemplos)) # el ejemplo sin guías no trae las claves de cada guía + assert usados <= cubiertos, f"Ningún ejemplo trae: {sorted(usados - cubiertos)}" + for contexto in ejemplos: + sobran = _claves(contexto) - usados + assert not sobran, f"Sobran en el ejemplo: {sorted(sobran)}" + + +def test_razon_social_con_caracteres_especiales_se_escapa(): + ejemplo = _ejemplos()["rechazado_una_observacion"] + html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"]) + + assert "<Lima>" in html and ""EL RÁPIDO"" in html + assert "" not in html + + +def test_aceptado_muestra_pago_estimado_solo_si_hay_fecha(): + ejemplos = _ejemplos() + con = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_con_pago"]["contexto"]) + sin = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_sin_pago"]["contexto"]) + + assert "Pago estimado" in con and "18/11/2026" in con + assert "Pago estimado" not in sin + + +def test_rechazado_numera_las_observaciones_y_marca_las_guias(): + html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"]) + + assert "OBSERVACIÓN 1" in html and "OBSERVACIÓN 2" in html + assert "OBSERVACIÓN 3" not in html + assert html.count("Conforme") == 1 and html.count("No coincide") == 1 + assert "Rechazado · 2 observaciones" in html + + +def test_enlaces_de_contacto_llevan_el_codigo_y_el_comprobante(): + contexto = _ejemplos()["rechazado_dos_observaciones"]["contexto"] + html = plantillas.renderizar_plantilla("rechazado", contexto) + + assert f"mailto:{contexto['contacto_correo']}?subject=Consulta%20F002-486%20ABR-261019-00045" in html + assert f'href="mailto:{contexto["envio_correo"]}"' in html + + +def test_rechazo_sin_comprobante_oculta_lo_que_no_existe(): + ejemplo = _ejemplos()["rechazado_sin_comprobante"] + html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"]) + + assert "Guías revisadas" not in html # sin tabla de guías vacía + assert "Comprobante" not in html # sin la fila "Comprobante —" + assert "RUC —" not in html and html.count("RUC") == 1 # solo el RUC del pie (empresa) + assert "No pudimos registrar su comprobante" in html + + +def test_rechazo_con_comprobante_si_muestra_guias_ruc_y_fila_comprobante(): + html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"]) + + assert "Guías revisadas" in html and "RUC 20537929520" in html and "Comprobante" in html + diff --git a/tools/previsualizar_plantillas.py b/tools/previsualizar_plantillas.py new file mode 100644 index 0000000..9e5c362 --- /dev/null +++ b/tools/previsualizar_plantillas.py @@ -0,0 +1,116 @@ +""" +Renderiza las plantillas de correo (plantilla/plantilla-*.html) con los datos de +ejemplo de plantilla/ejemplos.json, para revisarlas en el navegador o enviarse +un correo de prueba. + +Uso (desde cualquier directorio): + python tools/previsualizar_plantillas.py --listar + python tools/previsualizar_plantillas.py --abrir # todos los escenarios + python tools/previsualizar_plantillas.py --escenario aceptado_con_pago --abrir + python tools/previsualizar_plantillas.py --escenario rechazado_dos_observaciones --enviar yo@dominio.com otro@dominio.com + +Los HTML se guardan en tools/previews/ (ignorada por git). Para probar otros textos, +edita plantilla/ejemplos.json: el renderizado es estricto y avisa si falta algún dato. + +``--enviar`` manda el correo real con Microsoft Graph desde el buzón EMAIL del .env, +con el asunto prefijado con "[PRUEBA]". Exige indicar el escenario (o ``todos``). +En el correo enviado el logo queda vacío mientras ``logo_url`` no tenga una URL pública. +""" +import argparse +import json +import os +import sys +import webbrowser + +RAIZ = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +sys.path.insert(0, RAIZ) + +from dotenv import load_dotenv + +import plantillas + +RUTA_EJEMPLOS = os.path.join(plantillas.CARPETA_PLANTILLAS, "ejemplos.json") +SALIDA_POR_DEFECTO = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews") + +# Solo para la vista previa local: los clientes de correo no muestran imágenes data: +LOGO_PROVISIONAL = ( + "data:image/svg+xml;utf8," + "" + "" + "Aberio" + "" +) + + +def cargar_ejemplos() -> dict: + with open(RUTA_EJEMPLOS, encoding="utf-8") as f: + return json.load(f) + + +def seleccionar(ejemplos: dict, escenario: str | None) -> dict: + if escenario in (None, "todos"): + return ejemplos + if escenario not in ejemplos: + sys.exit(f"❌ Escenario '{escenario}' no existe. Disponibles: {', '.join(ejemplos)}") + return {escenario: ejemplos[escenario]} + + +def renderizar(ejemplo: dict, para_navegador: bool) -> str: + contexto = dict(ejemplo["contexto"]) + if para_navegador and not contexto.get("logo_url"): + contexto["logo_url"] = LOGO_PROVISIONAL + return plantillas.renderizar_plantilla(ejemplo["plantilla"], contexto) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Vista previa y envío de prueba de las plantillas de correo.") + parser.add_argument("--listar", action="store_true", help="Lista los escenarios disponibles y termina.") + parser.add_argument("--escenario", "-e", help="Nombre del escenario, o 'todos'. Por defecto, todos (obligatorio con --enviar).") + parser.add_argument("--salida", default=SALIDA_POR_DEFECTO, help="Carpeta donde se guardan los HTML.") + parser.add_argument("--abrir", action="store_true", help="Abre cada HTML en el navegador.") + parser.add_argument("--enviar", metavar="CORREO", nargs="+", help="Envía el escenario a estos correos como prueba (uno o varios).") + args = parser.parse_args() + + ejemplos = cargar_ejemplos() + if args.listar: + for nombre, e in ejemplos.items(): + print(f"{nombre:32} ({e['plantilla']}) {e['contexto']['asunto']}") + return + + if args.enviar and not args.escenario: + sys.exit("❌ Con --enviar indica el escenario: --escenario (o --escenario todos).") + + elegidos = seleccionar(ejemplos, args.escenario) + os.makedirs(args.salida, exist_ok=True) + + for nombre, ejemplo in elegidos.items(): + ruta = os.path.join(args.salida, f"{nombre}.html") + with open(ruta, "w", encoding="utf-8") as f: + f.write(renderizar(ejemplo, para_navegador=True)) + print(f"📄 {os.path.relpath(ruta)}") + if args.abrir: + webbrowser.open("file://" + os.path.abspath(ruta)) + + if args.enviar: + import outlook_utilities + + load_dotenv(dotenv_path=os.path.join(RAIZ, ".env")) + buzon = os.getenv("EMAIL", "") + if not buzon: + sys.exit("❌ EMAIL no configurado en .env") + + try: + token = outlook_utilities.get_access_token() + except Exception as ex: + sys.exit(f"❌ No se pudo obtener el token de Microsoft Graph, no se envió nada:\n {ex}") + if not token: + sys.exit("❌ No se pudo obtener el token de Microsoft Graph, no se envió nada") + + for nombre, ejemplo in elegidos.items(): + asunto = f"[PRUEBA] {ejemplo['contexto']['asunto']}" + ok = outlook_utilities.send_email(token, buzon, args.enviar, asunto, renderizar(ejemplo, para_navegador=False)) + print(f"{'✉️ Enviado' if ok else '❌ Falló'}: {asunto} → {', '.join(args.enviar)}") + + +if __name__ == "__main__": + main()