Correo¶
El correo en tequio sigue el patrón Mailable de Laravel: una clase encapsula "qué
correo es" (asunto, template, contexto, adjuntos) y la facade Mail lo envía, síncrono
o encolado.
# Ejemplo ILUSTRATIVO: el import y la API son reales; `WelcomeMailable` es de ejemplo
# (el Mailable real del demo es `DailyDigestMailable`).
from tequio.Core.Mail import Mail
Mail.send(WelcomeMailable(name="Calcifux"), to=["calcifux@example.com"])
Correo worker-side
tequio extrajo el correo de milpa porque vuelve al worker: muchos crons y jobs terminan mandando un correo (un resumen diario, una alerta, una notificación). Lo que NO viene es la capa web que lo disparaba (controladores HTTP, endpoints): aquí el correo nace de un cron, un job, un command de consola o un observer.
Anatomía: Mailable + MailContent¶
Un Mailable hereda de la ABC Mailable (tequio/Core/Mail/Mailable.py) e implementa
build(), que devuelve un MailContent (el WelcomeMailable de abajo es ilustrativo;
el Mailable real del demo es DailyDigestMailable):
from tequio.Core.Mail.Mailable import Mailable, MailContent
from tequio.Core.Translate import t, current_locale
class WelcomeMailable(Mailable):
def __init__(self, name: str):
# SOLO primitivos serializables (ver "Encolar" más abajo).
self._name = name
def build(self) -> MailContent:
return MailContent(
subject=t("emails.welcome.subject", {"name": self._name}),
template="mymodule/mail/welcome.html.j2",
context={"name": self._name, "locale": current_locale()},
)
build() arma el payload puro — no toca SMTP ni Jinja directamente.
Campos de MailContent¶
| Campo | Tipo | Para qué | Laravel |
|---|---|---|---|
subject |
str |
Asunto (ya traducido por ti). | ->subject() |
template |
str |
Ruta del template Jinja (compartido o modulo/...). |
->view() |
context |
dict |
Variables del template. | ->with() |
from_email / from_name |
str \| None |
Remitente; si None, usa el default de settings. |
->from() |
inline_assets |
dict[str, Path] |
CID → ruta (imagen embebida). En HTML: <img src="cid:logo">. |
$message->embed() |
attachments |
list[Path] |
Adjuntos por ruta (archivos en disco). | ->attach() |
data_attachments |
list[DataAttachment] |
Adjuntos por bytes en memoria. | ->attachData() |
cleanup_paths |
list[Path] |
Rutas a borrar tras enviar (opt-in). | File::delete() en finally |
Enviar: la facade Mail¶
Síncrono — Mail.send¶
Construye y manda en el acto por SMTP. No usa redis ni worker; bloquea hasta que SMTP responde. Ideal para local sin broker, tests, o cuando necesitas confirmar el envío.
Encolado — Mail.queue¶
Encola el envío en Celery (no bloquea). Parámetros:
queue: cola de Celery (ej."emails");None= cola por defecto.init_kwargs: los argumentos primitivos para reinstanciar el Mailable en el worker. Deben coincidir con el__init__.
mailable = WelcomeMailable(name="Calcifux")
Mail.queue(mailable, to=["calcifux@example.com"], queue="emails",
init_kwargs={"name": "Calcifux"})
Por convención, los correos van a la cola emails (ver La cola de correos),
que un worker consume con queue work --queue emails.
Si el broker está caído, Mail.queue lanza QueueUnavailableError (un mensaje claro,
no un 500 técnico). En un worker o un cron lo capturas y decides: reintentar, caer al
envío síncrono, o solo loguear que la cola no está disponible:
from tequio.Core.CeleryApp import QueueUnavailableError
try:
Mail.queue(mailable, to=to, init_kwargs={...})
except QueueUnavailableError as e:
logger.warning("cola de correo no disponible: {e}", e=e)
# ...reintentar más tarde, o caer a Mail.send(...) si el envío no puede esperar.
La cola de correos¶
Por convención, los correos van a la cola emails (= ->onQueue('emails') de Laravel):
al encolar pasas queue="emails", y el worker la consume con
Una cola dedicada deja los correos en su propio carril: un pico de envíos no atasca tus
jobs, y puedes escalar los workers de correo por separado. El generador make mailable ya
escribe este patrón en el stub (ver Consola), y el cron daily_digest
del demo encola así.
La cola emails no es mágica: es solo un nombre. Lo que la hace existir es que un worker
la consuma. Si arrancas queue work sin --queue, ese worker procesa la cola por
defecto (celery), no emails. Para consumir ambas en un mismo worker, lístalas:
Si encolas a
emailspero ningún worker la consume, los correos se quedan esperando en el broker (no se pierden, pero tampoco salen). En dev sin broker no necesitas nada de esto: usaMail.send(síncrono) o el driverlog, abajo.
El contrato del constructor: solo primitivos¶
Al encolar, el Mailable se reinstancia en el worker desde su dotted path +
init_kwargs, y build() corre allí (worker-side). Por eso el constructor solo
debe recibir primitivos serializables (str, int, listas de str, ids). Nada de sesiones
de BD ni clientes HTTP: no se serializan. Si necesitas más datos, pasa un id y recupéralo
en build().
Ventaja: si build() genera bytes (un PDF), esos bytes no viajan por la cola — se
generan en el worker.
Adjuntos¶
Por bytes (recomendado)¶
Sin tocar disco, sin cleanup:
from tequio.Core.Mail.Mailable import DataAttachment
content.data_attachments.append(
DataAttachment("reporte.pdf", pdf_bytes, "application/pdf")
)
Por archivo + cleanup opt-in¶
Si el PDF ya vive en disco como temporal, adjúntalo por ruta y declara su limpieza:
content.attachments.append(temp_path)
content.cleanup_paths.append(temp_path) # el Mailer lo borra tras enviar (finally)
El framework nunca borra un attachments por su cuenta: solo lo que declares en
cleanup_paths. Un asset persistente (un PDF fijo) va en attachments y NO en
cleanup_paths.
Logo inline por CID¶
content.inline_assets["logo"] = Path("app/Resources/Images/Emails/logo.png")
content.context["logo_cid"] = "logo"
En el template: <img src="cid:logo">. (El header SMTP usa <logo>; en el HTML va sin
ángulos.)
Plantillas (jinja)¶
El HTML de cada correo lo renderiza el TemplateEngine (tequio.Core.View) — el "Blade"
de tequio: un solo Environment de Jinja2 por proceso, autoescape ON (default seguro) y
StrictUndefined: una variable faltante en la plantilla revienta en lugar de
renderizar vacío en silencio (un correo a deudores debe fallar visiblemente en QA, no llegar
con campos en blanco).
Dónde viven y cómo se resuelven¶
El loader busca en este orden de prioridad:
| # | Dónde | Cómo se referencia |
|---|---|---|
| 1 | Modules/<X>/Resources/Views/ (por módulo, viaja con él) |
namespaced con el módulo en minúsculas: "demo/emails/digest.html.j2" (= demo::emails.digest de Laravel) |
| 2 | USER_VIEWS_DIR del .env (carpeta de TU proyecto) |
ruta relativa: puede pisar los layouts del framework |
| 3 | La raíz compartida del paquete (tequio/Resources/Views/) |
ruta relativa: "Emails/Trans/mastersigned.html.j2" |
En la raíz compartida ya vienen los layouts de correo listos para extender:
Emails/Trans/master.html.j2 (pelón) y Emails/Trans/mastersigned.html.j2 (con header de
logo + footer firmado), más sus parciales (Footer/, Styles/).
Crear tu plantilla¶
La del digest del demo es el ejemplo mínimo completo
(Modules/Demo/Resources/Views/emails/digest.html.j2):
{% extends "Emails/Trans/mastersigned.html.j2" %}
{% block content %}
<h2 style="margin:0 0 12px;">Resumen diario</h2>
<p>Hoy hay <strong>{{ total }}</strong> notas en total.</p>
{% endblock %}
El context que pasas en MailContent llega como variables ({{ total }}). Dos globals
ya están registrados en todos los templates sin importar nada: t() (i18n, ver
Monolingüe vs. i18n) y app_name (la marca del proyecto, desde
APP_NAME). Para cadenas con HTML legítimo usa | safe (≈ {!! !!}).
Si vienes de Laravel, el mapeo es 1:1:
| Blade (legacy) | Jinja (tequio) |
|---|---|
@extends('emails.trans.master') |
{% extends "Emails/Trans/master.html.j2" %} |
@yield('content') / @section |
{% block content %}{% endblock %} |
@include('emails.trans.footer.x') |
{% include "Emails/Trans/Footer/x.html.j2" %} |
{!! __('ns.key', $vars) !!} |
{{ t('ns.key', vars) \| safe }} |
{{ $data->x ?? '----' }} |
{{ data.x or '----' }} |
El logo del layout firmado se embebe por CID (ver Logo inline por CID): el
mastersignedpinta<img src="cid:{{ logo_cid }}">solo si el Mailable adjuntó el logo.
El motor y t() no son exclusivos del correo
El TemplateEngine (tequio.Core.View) es genérico: render() sirve para cualquier
salida con plantilla (un reporte, un fichero generado), no solo el HTML del Mailable. Y
t() (tequio.Core.Translate) es una función standalone: puedes traducir fuera de un
Mailable —en un job, un cron o un command— sin pasar por el correo.
Drivers (MAIL_DRIVER)¶
| Driver | Comportamiento |
|---|---|
smtp (default) |
Envío real por SMTP, según MAIL_* y MAIL_ENCRYPTION (""/tls/ssl). El default apunta a Mailpit (MAIL_HOST=localhost, MAIL_PORT=1025). |
log |
Loguea el correo completo (el MIME), no lo envía. Recomendado en dev sin SMTP. |
null / array |
No-op: lo descarta. |
El default es MAIL_DRIVER=smtp, apuntando a Mailpit (MAIL_HOST=localhost,
MAIL_PORT=1025): con docker compose up -d los correos llegan a Mailpit y los ves en
http://localhost:8025. Sin Mailpit (ni otro SMTP) ese envío falla, así que para dev sin
SMTP pon MAIL_DRIVER=log: el correo se vuelca al log sin abrir ninguna conexión
SMTP — arrancas un cron que manda correo y ves el MIME completo en logs/ sin levantar un
servidor.
Probar correos directo en el repo¶
¿Quieres ver el correo del demo sin levantar SMTP, redis ni Mailpit? El truco es
combinar tres cosas: SQLite (DATABASE_URL, la BD de juguete), el driver log
(MAIL_DRIVER=log, el MIME al log en vez de SMTP) y un broker a un puerto muerto
(BROKER_URL) para que Mail.queue falle limpio y el cron caiga al envío síncrono:
DATABASE_URL="sqlite:///probar_correo.db" \
MAIL_DRIVER=log \
BROKER_URL="redis://127.0.0.1:1/0" \
uv run python - <<'PY'
# Crea la tabla `notes` en la SQLite (vacía: el digest cuenta 0 y manda igual).
from tequio.Core.Database import Base, engine
import tequio.Models.Note # registra Note en la metadata
Base.metadata.create_all(engine)
# Dispara el cron a mano: Mail.queue("emails") falla (broker muerto) -> cae a Mail.send,
# y con MAIL_DRIVER=log el MIME COMPLETO se vuelca al log (subject, To, HTML, logo por CID).
from tequio.Modules.Demo.Crons.DailyDigestCron import daily_digest
daily_digest()
PY
En la salida verás la línea Mailer[log] | correo NO enviado (driver=log): seguida del
MIME completo del correo (cabeceras Subject/To, el cuerpo HTML y el logo embebido por
CID en base64). El BROKER_URL apunta a 127.0.0.1:1 (un puerto donde nadie escucha): así
Mail.queue lanza QueueUnavailableError y el cron toma su rama síncrona — exactamente el
fallback que documenta el cron del demo. Borra la BD con
rm probar_correo.db al terminar.
En el proyecto que genera
tequio new, el cron vive enapp.Modules.Demo.Crons.DailyDigestCrony el modelo enapp.Models.Note(notequio.*): ajusta los imports del snippet a tus paquetes. Aquí van con el prefijotequio.*porque corremos contra el repo del framework.
Monolingüe vs. i18n¶
| Caso | Patrón |
|---|---|
| i18n | subject con t(), template que extiende los layouts y usa t(). El locale es explícito (lo pasa quien dispara el correo) o cae al APP_FALLBACK_LOCALE. |
| Monolingüe | subject literal, template con texto fijo (sin t(), sin extends). |
Una app es monolingüe salvo que decidas traducir. tequio sí trae el i18n de los
correos (la dep i18nice[YAML] y el wrapper tequio.Core.Translate); lo que NO trae es
el i18n de la UI (eso vive en milpa, junto con la capa web).
Como tequio es worker-side, no hay request ni Accept-Language del cual sacar el
idioma: el locale lo eliges tú. Para un correo multilingüe, pasa el locale como un
primitivo más del Mailable (viaja en init_kwargs al encolar) y úsalo explícitamente en
build(); si no lo pasas, t() cae al APP_FALLBACK_LOCALE configurado.
# Ejemplo ILUSTRATIVO del patrón i18n (subject + cuerpo desde catálogo, con locale explícito).
class NoteCreatedMailable(Mailable):
def __init__(self, title: str, locale: str = "es") -> None:
self._title = title
self._locale = locale
def build(self) -> MailContent:
subject = t("demo/NoteCreated.subject", {"title": self._title}, self._locale)
return MailContent(
subject=subject,
template="demo/emails/note_created.html.j2",
# `locale` explícito en el contexto: gana sobre el current_locale() que inyecta el
# Mailer, así el template traduce en el idioma que decidiste aunque corra en el worker.
context={"title": self._title, "locale": self._locale},
)
Los correos del demo (tequio new --demo)¶
El módulo Demo trae un Mailable de referencia (en Demo/Mail/DailyDigestMailable.py) sobre un
layout firmado (Emails/Trans/mastersigned.html.j2: header + contenido + footer con
la firma del remitente + aviso de privacidad). El Mailable solo define subject, su
template (que {% extends %} el firmado) y su contexto:
| Mailable | Disparador | Demuestra |
|---|---|---|
DailyDigestMailable |
Cron demo.daily_digest (8:00 AM) |
Correo desde un cron sobre el layout firmado compartido; subject con el conteo; logo por CID. |
Recibe solo primitivos (total) — sin User ni dueño (tequio no tiene Auth; el digest
es un resumen ANÓNIMO con el conteo de notas; ver
Eventos y Observers). La firma del footer es del "Equipo
tequio". El subject es monolingüe (ES) con el conteo; el plumbing i18n de t() sigue
disponible (lo usan el footer firmado y el aviso de privacidad del layout).
El cron que manda correo¶
El demo trae un resumen diario (@cron_task en Demo/Crons/DailyDigestCron.py) que sí manda un
correo (es justo el caso que motivó traer el correo al worker): el scheduler lo dispara
a las 8:00, cuenta las notas y manda el digest.
@cron_task(name="demo.daily_digest", schedule=daily_at("08:00"), output="demo_digest")
def daily_digest() -> None:
"""Corre en el WORKER cada día a las 8:00 (lo despacha `schedule run`)."""
total = len(NoteRepository().all())
mailable = DailyDigestMailable(total=total)
try:
Mail.queue(mailable, to=["admin@example.com"], queue="emails")
except QueueUnavailableError:
Mail.send(mailable, to=["admin@example.com"]) # broker caído: envío síncrono
Transcripción simplificada
En el código real el destinatario es la constante _DIGEST_TO (= "admin@example.com",
el mismo valor) y, antes de enviar, el cron loguea el conteo con
logger.info("demo.daily_digest | {n} notas...", n=total). Aquí se omiten por brevedad;
el flujo es idéntico.
Worker-side lo idiomático es encolar (Mail.queue) a la cola emails; si el broker
no está, caemos al envío síncrono (Mail.send). Con el default smtp el correo va a
Mailpit (docker compose up -d) y lo ves en la UI; con el driver log se vuelca al log
sin SMTP (útil en dev sin Mailpit). Ver
Programación (cron).