Elixir y Liquid
Plantillas, filtros, campañas en segundo plano y bajas.
En esta página
Elixir es el módulo de envío masivo personalizado: subes un CSV o XLSX, escribes una plantilla Liquid y BloomX envía un correo por fila, con reintentos, cuotas, lista de supresión y baja de un clic. Hay dos modos: envío directo por lotes en primer plano y campañas en segundo plano persistentes.
Datos de entrada#
- Archivo de hasta 15 MB; hasta 100 000 filas y 500 columnas. CSV/TXT (quita BOM, detecta
,;tab o|, o respetasep=;; RFC 4180) y XLSX (solo la primera hoja, con protección anti zip-bomb; el.xlsantiguo se rechaza). - Cabeceras vacías pasan a
columna_Ny duplicadas anombre_2. Cada celda se recorta a 20 000 caracteres. - Los nombres de columna pueden llevar espacios y acentos; para símbolos usa
row["Precio (S/.)"]. - El editor es CodeMirror con autocompletado y *lint* de la plantilla; hay biblioteca de plantillas (máx. 200 por usuario) e historial de campañas.
Liquid: sintaxis y límites#
El motor Liquid es propio (no la librería estándar): compila una vez y renderiza por fila; los errores de sintaxis, etiqueta o filtro desconocido se detectan al compilar. Sin eval ni Function; solo se leen propiedades propias.
| Límite | Valor por defecto |
|---|---|
| Tamaño de plantilla | 500 000 caracteres |
| Profundidad / iteraciones / pasos | 24 / 100 000 / 2 000 000 |
Rango (a..b) | 10 000 elementos |
| Salida por correo | 5 MiB |
| Tiempo de render por fila | 2000 ms |
Hola {{ nombre | capitalize }},
{% if saldo > 0 %}
Tienes un saldo pendiente de S/ {{ saldo | round: 2 }} con vencimiento el {{ vence | date: "%d/%m/%Y" }}.
{% else %}
¡Gracias por estar al día!
{% endif %}
{% assign items = "A,B,C" | split: "," %}
{% for item in items limit: 2 %}
{{ forloop.index }}. Producto {{ item }}
{% endfor %}
Enviado el {{ current_date }}.
Si no deseas recibir más correos: {{ unsubscribe_url }}Etiquetas (tags)#
if/elsif/else,unless,for(conlimit,offset,reversed, rangos(1..N)yelse),case/when(coma uor),assign,capture,comment,raw,increment,decrement,cycle,echo,break,continue.- Control de espacios:
{{-,-}},{%-,-%}. Operadores:==,!=,>,<,>=,<=,contains,and,or. forloop:index,index0,rindex,rindex0,first,last,length,parentloop.- No soportados (error explícito):
include,render,liquid,tablerow,ifchangedy similares. - Diferencias con Liquid estándar: una celda vacía,
"false","nil"o"null"cuenta como falsa; las fechasYYYY-MM-DDyDD/MM/YYYYse tratan como fecha de calendario (sin zona horaria).
Filtros#
| Grupo | Filtros |
|---|---|
| Texto | upcase, downcase, capitalize, strip, lstrip, rstrip, strip_html, strip_newlines, newline_to_br, escape, escape_once, raw, url_encode, url_decode, base64_encode, base64_decode, base64_url_safe_encode, base64_url_safe_decode, truncate, truncatewords, replace, replace_first, replace_last, remove, remove_first, remove_last, prepend, append, default, json |
| Números | abs, ceil, floor, round, fixed, at_least, at_most, plus, minus, times, divided_by, modulo |
| Fecha | date (formatos strftime: %d %m %Y %H %M %S %B %b %A %a %e %j %I %l %p %Z %z %s %F %T %R %D…; con relleno %-d) |
| Listas | size, first, last, reverse, split, join, sort, sort_natural, uniq, compact, flatten, sum, min, max, concat, push, map, where, reject, slice |
fixed y push no son estándar de Liquid.
Variables#
- Las columnas de tu archivo.
current_date,current_day,current_month,current_year(las calcula el servidor, en español, con la zona de la campaña) ynow/today(para usar con| date).unsubscribe_url: enlace de baja firmado por destinatario.brand_name,brand_color,brand_logoaparecen como variables de sistema en el editor, pero el servidor no las calcula: solo llegan si el cliente las manda ensystemVars(máx. 50 claves).- Precedencia al fusionar: fecha <
systemVars< fila <unsubscribe_url. - Opciones de render:
autoescape(por defecto activo; usa| rawpara valores de confianza),strictVariables(por defecto activo) ytimezone(zona IANA, por defecto UTC).
Modos de envío#
Envío directo por lotes#
POST /api/elixir/send desde la interfaz, en lotes de 25 (el servidor acepta hasta ELIXIR_BATCH_MAX = 50 filas, tope 100) con presupuesto de 45 s. Devuelve results por fila (sent, skipped, error, unsubscribed) y pending, con retryAfterMs o paused: "quota". Límite de 1200 peticiones por hora por usuario y cuota en memoria ELIXIR_MAX_ROWS_PER_HOUR (5000 por defecto). Idempotente por (campaña, destinatario) con Idempotency-Key hacia Resend.
Campañas en segundo plano#
POST /api/elixir/campaigns -> crea el borrador (compila y valida la plantilla)
POST /api/elixir/campaigns/<id>/rows -> sube filas en trozos de hasta 500 ({ items: [{ index, row }] })
PATCH /api/elixir/campaigns/<id> {"action":"start"} (pause | resume | cancel | retry_errors)
GET /api/elixir/campaigns/<id>?rows=1 -> estado y filas
# El worker avanza con /api/cron/elixir (Authorization: Bearer <CRON_SECRET>) o POST /api/elixir/campaigns/<id>/tick (1 cada 10 s)- Hasta 20 000 filas por campaña (
ELIXIR_MAX_CAMPAIGN_ROWS; 413 al excederlo). Al subir, un correo inválido pasa askipped(invalid_email) y un duplicado askipped(duplicate). - Estados de campaña:
draft → running ⇄ paused, yrunning → done | cancelled | failed.startsolo desdedraft;resumedesdepaused,cancelledofailed;retry_errorsdevuelve las filaserrorapending. Borrar una campañarunningda 409. - Estados de fila:
pending → sending → sent | error | skipped | unsubscribed, ysent → bounced | complained(por eventos de Resend). - Worker (
/api/cron/elixir,Authorization: Bearer <CRON_SECRET>o la clave interna derivada): hasta 5 campañas en marcha, un solo worker por campaña (bloqueo de 90 s), lotes de 10 filas con 550 ms entre envíos, cuota persistente por usuario (filassentde la última hora,ELIXIR_MAX_ROWS_PER_HOUR). Filas atascadas ensendingmás de 5 min vuelven apending. - Reintentos: hasta 4 dentro de un envío (backoff exponencial con jitter, respeta
Retry-After) y, por fila, backoff30 s × 2^(n−1)con tope de 1 h y máximo 6 intentos (despuéserrorcon códigomax_attempts). Con 429/5xx/red la fila vuelve apendingy el lote se detiene. - Falla cerrado: si la lista de supresión no está disponible, no envía y reintenta a los 30 s. Si el render falla, la fila queda en
error: nunca se envía la plantilla cruda. El remitente debe ser el usuario o una cuenta vinculada. - Elixir no tiene cron programado en el repositorio: avanza por encadenamiento propio (hasta 400 saltos), por el sondeo de la interfaz o por un cron externo (Despliegue).
Supresión y bajas#
- Cada envío masivo añade
List-Unsubscribe(https ymailto:) yList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058) solo siNEXT_PUBLIC_APP_URLes https y hay secreto (UNSUBSCRIBE_SECREToNEXTAUTH_SECRET). Se añade un pie de baja salvo que la plantilla ya useunsubscribe_url. GET /api/webhooks/unsubscribe?t=<token>muestra una página de confirmación y no da de baja;POSTda de baja (un clic o formulario). Página bilingüe (?lang=), sin JavaScript; 30/min por IP.- Las bajas se guardan como eventos. Los rebotes permanentes y las quejas de Resend (webhook
resend-events) también suprimen; el retraso de entrega solo se anota. - Los destinatarios suprimidos se omiten en cada envío (
unsubscribed).ccybccadmiten Liquid (máx. 10 extras, filtrando suprimidos).
Límites conocidos#
- El worker siempre renderiza con
locale = espara las variables de fecha. - La cuota
ELIXIR_MAX_ROWS_PER_HOURdel modo directo es por instancia (memoria); la del worker es persistente. - Las plantillas y campañas persistidas no incluyen adjuntos.
- Sin
CRON_SECRETy sin cron propio, las campañas dependen de tener la interfaz abierta o del encadenamiento.