agavesoft / mailflow
Laravel SDK for Smartmailto: identify contacts, track idempotent events and send transactional email.
Requires
- php: ^8.2
- illuminate/bus: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/encryption: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/mail: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- symfony/yaml: ^7.2|^8.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-17 20:23:15 UTC
README
agavesoft/smartmailto conecta una aplicacion Laravel (12 o 13) con Smartmailto: identifica personas (con o sin cuenta), registra eventos idempotentes, carga el historico y manda correos transaccionales.
Antes
agavesoft/mailflow(v1). La v2 cambia la API publica; ver Migrar desde v1.
Instalacion
composer require agavesoft/smartmailto
php artisan vendor:publish --tag=smartmailto-config # opcional
Version recomendada: "agavesoft/smartmailto": "^2.5" (tag estable), con un repositorio vcs a https://github.com/agavesoft/notificabot-lara-pkg-sdk mientras el paquete no este en Packagist.
^2.5@dev (develop como 2.5.x-dev) solo es para probar lo que aun no se publica.
SMARTMAILTO_API_URL=https://smartmailto.example.com SMARTMAILTO_API_TOKEN=mf_live_... # token del proyecto (se muestra una sola vez en el panel) SMARTMAILTO_ENABLED=true # false = el SDK no hace nada # SMARTMAILTO_QUEUE=true # default: encola tras el commit y reintenta # SMARTMAILTO_QUEUE_CONNECTION= # SMARTMAILTO_QUEUE_NAME= # SMARTMAILTO_WEBHOOK_SECRET=whsec_... # webhook de falla (F-008) # SMARTMAILTO_ATTACHMENTS_MAX_FILES=10 # SMARTMAILTO_ATTACHMENTS_MAX_BYTES=7340032 # SMARTMAILTO_PROVISION_TIMEOUT=120 # espera del aprovisionamiento y el catalogo (F-009) # SMARTMAILTO_PULL_ENABLED=false # pull: Smartmailto le pide datos a tu app (F-011) # SMARTMAILTO_PULL_SECRET=pullsec_... # SMARTMAILTO_PULL_RESOLVER=App\Smartmailto\MiPullResolver # SMARTMAILTO_PULL_HISTORY_MONTHS= # vacio = toda la historia de eventos # SMARTMAILTO_OUTBOX_ENABLED=false # outbox transaccional: eventos garantizados (F-010) # SMARTMAILTO_OUTBOX_CONNECTION= # conexion de BD de tus transacciones (vacio = la default) # SMARTMAILTO_ALERTS_MAIL_TO=soporte@agavesoft.com.mx # SMARTMAILTO_ALERTS_TEAMS_WEBHOOK_URL= # webhook de un flujo de Workflows de Power Automate
Por default cada llamada se encola despues del commit de la transaccion en curso: necesitas un worker de cola corriendo (php artisan queue:work). Con la cola sync la llamada se hace una sola vez al terminar el commit, sin reintentos; una falla dispara SmartmailtoDeliveryFailed. Para reintentos reales usa una cola de verdad (database, redis). Con SMARTMAILTO_QUEUE=false las llamadas son sincronas y lanzan SmartmailtoException si fallan.
Uso
use Agavesoft\Smartmailto\Facades\Smartmailto; use Agavesoft\Smartmailto\Identity; // Persona sin cuenta (compra como invitado): se identifica por correo. Smartmailto::track('orden_creada', Identity::guest($order->email), [ 'timbres' => $order->stamps, 'total' => $order->total, 'paquete' => $order->package_name, ], eventId: "ff:orden_creada:{$order->id}", object: ['order', $order->id], secrets: ['checkout_url' => $checkoutUrl]); // Persona con cuenta. Mandar su correo une su historial de invitado con la cuenta. // updatedAt (opcional, F-011): hora del cambio en tu app; si push y pull traen el mismo atributo, gana el mas reciente. Smartmailto::identify(Identity::user($user->id, $user->email), ['name' => $user->name], updatedAt: $user->updated_at); // Correo transaccional inmediato (idempotente). Smartmailto::send('recibo-compra', Identity::user($user->id, $user->email), [ 'total' => $order->total, ], idempotencyKey: "ff:recibo:{$order->id}");
Reglas del contrato
-
eventIdsale del hecho de negocio, nunca de un uuid nuevo:"{app}:{evento}:{id del objeto}". Asi un reintento (tuyo o del SDK) no duplica el evento: Smartmailto descarta el repetido. -
idempotencyKeyen cadasend: el mismo valor nunca manda dos correos. -
secretsson valores de un solo uso (ligas de activacion o de pago). Smartmailto los guarda cifrados, no los muestra en su panel ni en logs, y solo las plantillas los leen ({{secret:activation_url}}). Esas ligas no pasan por el seguimiento de clics. -
Datos fiscales y sensibles segun el canal (RFC, UUID/folio fiscal, contenido de CFDI, montos fiscales). La regla depende de si Smartmailto guarda el dato o solo lo entrega:
Canal Datos fiscales o sensibles Por que Propiedades de eventos ( track)Prohibidos Se guardan 13 meses, se ven en la linea de tiempo y alimentan workflows Atributos del contacto ( identify)Prohibidos Se guardan, se muestran y alimentan condiciones Datos de plantilla ( send→data)Prohibidos Se conservan para recrear el correo secrets(send/track)Permitidos Cifrados, se ven como [secreto]y se borran al estado final. Ahi va, por ejemplo, el folio fiscal que imprime la plantillaAdjuntos de envios transaccionales Permitidos Smartmailto no los conserva despues de la entrega, salvo que el proyecto retenga adjuntos con la copia del correo Nombre del archivo adjunto Permitido Puede llevar RFC o UUID: Smartmailto lo enmascara en logs y en "recrear correo" ( ***_****.xml #a1b2c3)Una variable marcada
sensitiveen el catalogo (F-009) no se puede usar en condiciones ni se muestra en claro. Manda solo lo que el correo necesita. -
object(['order', 100]) identifica el objeto de negocio; Smartmailto lo usa para llevar una secuencia por orden. -
Unir invitado y cuenta: cuando la persona reclama su orden o se registra, manda un evento o
identifyconIdentity::user($id, $correoDeLaOrden). Si tu app borra el correo de la orden al reclamarla, leelo antes.
Correos esenciales y emergencia
Para registro y recibo de compra (plantillas transaccionales en Smartmailto):
Smartmailto::send('recibo-compra', Identity::user($user->id, $user->email), $data, idempotencyKey: "ff:recibo:{$order->id}", sendBefore: now()->addMinutes(10));
Todo correo sale por Smartmailto; tu envio directo (SES) es solo emergencia y se reporta. Con el outbox (F-010, ver abajo) la emergencia ocurre solo en estos casos, y nunca por un fallo de red ni por health():
| Disparador | Cuando |
|---|---|
Evento SmartmailtoOutboxFailed con needsEmergencySend() |
La fila send recibio un rechazo definitivo (reason = "422", "404"...) o llego a su sendBefore sin acuse (reason = "expired"). Antes de disparar, el SDK pregunta a Smartmailto (GET /api/send/{key}): si el envio ya salio, esta en cola o se descarto a proposito (supresion, regla de repeticion), no hay emergencia |
Webhook de falla send_expired |
Smartmailto lo habia aceptado pero no lo mando antes de sendBefore (su cola se detuvo) |
En los dos casos tu app manda el correo por su canal directo, lo registra (FF: correos_salientes) y lo reporta:
El listener debe ir en cola (implements ShouldQueue): el evento se dispara una sola vez, y si un listener sincrono falla (SES caido, bug) el worker solo lo registra en el log y esa emergencia se pierde. En cola, la cola lo reintenta.
// app/Listeners/SmartmailtoEmergencia.php class SmartmailtoEmergencia implements ShouldQueue { public function handle(SmartmailtoOutboxFailed $event): void { if (! $event->needsEmergencySend()) { return; // track/identify/link: no hay correo que mandar; soporte revisa con el runbook } // Idempotente por la llave: si la cola reintenta el listener, el correo no sale dos veces. $salida = CorreoSaliente::firstWhere('idempotency_key', $event->key) ?? CorreoSaliente::mandarPorSes($event->key); // tu app sabe armar el correo de esa llave // Smartmailto lo registra como "enviado por emergencia desde el proyecto" y ya no lo manda aunque // le llegue despues. Si la fila esta en vuelo lanza OutboxRowInFlight: deja que la cola reintente. Smartmailto::reportExternalSend($event->key, sentAt: $salida->sent_at, reason: $event->reason); } }
- Con el outbox,
reportExternalSend()toma la identidad y la plantilla de la filasend(o pasalas:reportExternalSend($key, $identity, 'recibo')), cierra esa fila comosupersededy deja el reporte en el outbox con la misma garantia de entrega. Sin outbox lo encola como cualquier llamada (identidad y plantilla obligatorias). - Por que no hay duplicados: Smartmailto revisa
send_beforejusto antes de entregar al proveedor, y una llave reportada se acusa sin enviar (duplicate: true) en cualquier reintento posterior. - Riesgo residual aceptado: si Smartmailto entrego antes de
sendBefore, el acuse se perdio y Smartmailto no responde la consulta, sale una segunda copia; al llegar tu reporte queda comoduplicate_external(metrica, sin alerta critica). - Costo aceptado: con Smartmailto caido, el recibo sale por emergencia al vencer
sendBefore(10 min en los esenciales de FF). - Sin outbox sigue el contrato de v2.1: 410 →
SmartmailtoDeliveryFailed, y el webhooksend_expired; reporta igual conreportExternalSend(). Smartmailto::health()(ok,degraded,down) sirve para tableros; ya no es motivo para mandar directo.
Outbox: eventos garantizados (F-010)
Con el outbox, track, send, identify, link y reportExternalSend no encolan un job: escriben una fila en la tabla smartmailto_outbox dentro de la transaccion de tu app. Si la accion de negocio hace rollback no queda nada; si hace commit, la fila espera hasta que Smartmailto acusa recibo con su llave. Requiere un servidor con F-010.
php artisan vendor:publish --tag=smartmailto-migrations php artisan migrate
SMARTMAILTO_OUTBOX_ENABLED=true SMARTMAILTO_OUTBOX_CONNECTION= # la conexion de tus transacciones (vacio = la default) SMARTMAILTO_ALERTS_MAIL_TO=soporte@agavesoft.com.mx SMARTMAILTO_ALERTS_TEAMS_WEBHOOK_URL=https://... # webhook de un flujo de Workflows de Power Automate
// routes/console.php Schedule::command('smartmailto:outbox:work --max-time=55')->everyMinute()->withoutOverlapping()->onOneServer(); Schedule::command('smartmailto:outbox:prune')->daily(); // o como daemon (supervisor): php artisan smartmailto:outbox:work
DB::transaction(function () use ($order) { $order->markPaid(); // La fila queda en esta transaccion: sin commit no hay evento ni correo. Smartmailto::track('orden_pagada', Identity::user($order->user_id, $order->email), ['total' => $order->total], eventId: "ff:orden_pagada:{$order->id}", occurredAt: $order->paid_at); Smartmailto::send('recibo-compra', Identity::user($order->user_id, $order->email), $data, idempotencyKey: "ff:recibo:{$order->id}", sendBefore: now()->addMinutes(10)); // obligatorio con el outbox });
Que hace el worker (smartmailto:outbox:work, una pasada con --once):
| Situacion | Resultado |
|---|---|
Acuse con la llave (event_id, idempotency_key, link_id; identify con cualquier 2xx) |
acked |
| Red, 5xx, 429, 401/403 (token rotado), SDK sin configurar, respuesta sin acuse | Reintento: 30 s, 2 min, 10 min, 1 h y despues cada hora |
| 72 h sin acuse | failed (reason=gave_up) y SmartmailtoOutboxFailed; se reprocesa con smartmailto:outbox:retry |
send que llega a sendBefore sin acuse, o 410 |
expired + emergencia (despues de la consulta previa) |
| Rechazo definitivo (422, 404 de plantilla, 413) | failed + alerta inmediata agrupada por plantilla + SmartmailtoOutboxFailed |
link con 404 (el contacto aun no llega) o 409 contact_changed; reporte con 409 |
Reintento |
- Idempotencia: la misma llave pendiente no se duplica en la tabla y Smartmailto descarta los repetidos.
identifylleva una llave por llamada y mandaupdated_at(la hora en que lo llamaste) para que gane el mas reciente aunque lleguen fuera de orden. - Hora real: un
tracksinoccurredAtguarda la hora del commit; los workflows cuentan desde ahi aunque el evento llegue horas despues. - Aviso de vida: cada 5 min el worker hace
POST /api/outbox/heartbeat. Smartmailto alerta (con su propio canal) si el worker pasa 30 min sin avisar. - Cifrado: el payload de cada fila va cifrado con
APP_KEY. Rotar la llave con filas pendientes las deja sin poder leerse (se reintentan y alertan por antiguedad): vacia el outbox antes de rotarla. smartmailto:outbox:status(conteos, la mas vieja, alertas abiertas),smartmailto:outbox:retry {id*} --key= --failed(nunca unsend: al fallar ya paso a tu emergencia),smartmailto:outbox:prune(acked 7 dias; failed, expired y superseded 30 dias) ysmartmailto:outbox:prune --contact=correo|user_id(ARCO: borra las filas de esa persona).batch()(carga inicial) no usa el outbox: sigue por la cola; un lote se puede repetir sin riesgo.
Alertas (las emite tu app, agrupadas, nunca una por fila):
- Filas sin acuse: una alerta a los 15 min ("N pendientes, la mas vieja de hace X, ultimo error"), recordatorios a 1 h, 4 h, 12 h, 24 h y 48 h con conteos actualizados, un solo resumen a las 72 h de las filas que se rindieron y "recuperado" cuando ya no queda nada atrasado.
- Rechazos definitivos: alerta inmediata agrupada por plantilla; los siguientes de la misma plantilla se juntan en ventanas de 15 min.
- Canales: correo (
alerts.mail_to, con el mailer de tu app) y Teams (alerts.teams_webhook_url, tarjeta adaptable). Siempre quedan tambien en el log. Un escalon que no sale por ningun canal se reintenta en la siguiente pasada. - Sin datos personales: conteos, edades, llaves y codigos de error.
Adjuntos con el outbox (J12): hasta 1 MB por archivo van dentro del mensaje (cifrados en la fila). Uno mas grande va por URL firmada que Smartmailto descarga al aceptar el envio:
Attachment::fromDisk('s3', "cfdi/{$cfdi->uuid}.pdf", "{$cfdi->rfc}_{$cfdi->folio}.pdf"); // URL firmada nueva en cada intento (24 h) Attachment::fromUrl($urlHttps, 'reporte.pdf', $sha256); // URL que controlas tu
El SDK calcula el sha256 al llamar send() y Smartmailto lo verifica. El archivo debe seguir en el disco hasta el acuse. Tope por archivo: 15 MB (attachments.url_max_bytes). fromDisk/fromUrl tambien funcionan sin outbox.
Destinatarios externos, ligar contactos y consentimiento (F-010)
// Receptor de un CFDI: no se vuelve contacto (solo plantillas transaccionales). `origin` liga el envio // a quien lo emitio, para verlo en Envios. Smartmailto::send('cfdi-documentos', Identity::external($receptor->email), $data, idempotencyKey: "ff:cfdi-documentos:{$cfdi->id}:{$envio->id}", sendBefore: now()->addDay(), origin: Identity::user($user->id)); // La compra de un invitado era de una cuenta (timbres en otra cuenta): ligalos. Smartmailto::link(Identity::user($user->id), Identity::guest($order->email), 'timbres-en-otra-cuenta', linkId: "ff:link:order:{$order->id}"); // La persona volvio a aceptar correos (casilla explicita): reactiva a un contacto borrado por ARCO. Smartmailto::identify(Identity::user($user->id, $user->email), [], consent: true);
Identity::external()solo vale ensend()yreportExternalSend(); entrack,identifyolinklanzaInvalidArgumentException.link()nunca crea contactos: los dos deben existir (404 contact_not_found). Dos cuentas conuser_idno se ligan (409 both_have_user_id). Es idempotente porlinkId.
Adjuntos, copias y remitente (F-008)
Todo es opcional y con nombre: un send() sin estos parametros manda exactamente lo mismo que antes.
use Agavesoft\Smartmailto\Attachment; // CFDI por correo: PDF + XML, varios destinatarios, copia oculta y Reply-To de soporte. // Regla 4: el RFC en el NOMBRE del adjunto y el XML como adjunto estan permitidos (transaccional, no se // conservan); el folio fiscal va en `secrets`, nunca en `data`. Smartmailto::send('cfdi-por-correo', Identity::user($user->id, $user->email), [ 'folio' => $cfdi->folio, ], idempotencyKey: "ff:cfdi-correo:{$cfdi->id}:{$request->id}", secrets: ['folio_fiscal' => $cfdi->uuid], attachments: [ Attachment::fromPath(storage_path("cfdi/{$cfdi->uuid}.pdf"), "{$cfdi->rfc}_{$cfdi->folio}.pdf"), Attachment::fromData($cfdi->xml, "{$cfdi->rfc}_{$cfdi->folio}.xml"), // contenido crudo, no base64 ], to: ['contador@example.com'], // destinatarios ADICIONALES (el contacto siempre va) cc: ['administracion@example.com'], bcc: ['auditoria@facturafacilita.com'], replyTo: ['email' => 'soporte@facturafacilita.com', 'name' => 'Soporte'], ); // Recibo de compra con el PDF y otro remitente del mismo dominio. Smartmailto::send('recibo-compra', Identity::user($user->id, $user->email), $data, idempotencyKey: "ff:recibo:{$order->id}", sendBefore: now()->addMinutes(10), attachments: [Attachment::fromData($pdf, 'Comprobante.pdf')], from: ['email' => 'soporte@facturafacilita.com', 'name' => 'Factura Facilita'], ); // Restablecer contrasena: la liga de un solo uso viaja en secrets ({{secret:reset_url}}), no en data. Smartmailto::send('restablecer-contrasena', Identity::user($user->id, $user->email), [], idempotencyKey: "ff:reset:{$token->id}", secrets: ['reset_url' => $url]);
- Adjuntos:
Attachment::fromPath($ruta, $nombre?),Attachment::fromData($bytes, $nombre)oAttachment::fromUpload($request->file('x')); tambien se acepta directo unUploadedFileo una ruta. El SDK los codifica en base64. El tipo sale de la extension (pdf,xml,zip,png,jpg/jpeg,csv,txt) o delcontentTypeque pases si corresponde a ella. Nombre simple, max 150, sin rutas. - Limites: 10 archivos y 7 MB decodificados en total por envio. El SDK los valida antes de encolar (lanza
InvalidArgumentException); si el servidor tiene otros, ajustaSMARTMAILTO_ATTACHMENTS_MAX_FILES/SMARTMAILTO_ATTACHMENTS_MAX_BYTES. El servidor ademas revisa la firma del archivo (un.pdfdebe ser PDF). - Adjuntos y cola: el contenido se lee al llamar
send()y el job lleva el base64 (~1.37x el tamano de los archivos): no depende de que el archivo siga en disco cuando corre el worker. Consecuencias: (1) el payload del job puede pesar ~10 MB y queda sin cifrar en tu almacen de cola (jobsde la coladatabase, Redis); (2) SQS no sirve para envios con adjuntos (limite de 256 KB por mensaje): usadatabase/redisoSMARTMAILTO_QUEUE=falsepara esos envios.Smartmailto::fake()tambien guarda el base64. to,cc,bcc: max 10 por lista; solo con plantillaskind: transactional(si no: 422recipients_require_transactional). No crean contactos y la lista de supresion del proyecto les aplica.replyTo/from: un correo o['email' => ..., 'name' => ...].fromsolo del dominio del From del proyecto (422from_not_allowed).- Contrato completo:
docs/api-envio-y-aprovisionamiento.mddel servidor (notificabot-lara-mailflow).
Plantillas como codigo (aprovisionamiento, F-008 y F-009)
Un admin debe habilitar Aprovisionamiento por API en el proyecto (apagado: 403 provisioning_disabled). Todo es idempotente: lo que no cambio responde unchanged y no crea version.
v2.3 requiere un servidor con F-009 (
POST /api/provision): contra un servidor anteriorsmartmailto:provisionfalla con 404 y no cambia nada. Los metodosput*()sueltos siguen funcionando contra ambos.
resources/smartmailto/
├── variables/contact.yaml # F-009: catalogo de variables (o .yml / .json)
├── variables/eventos.yaml
├── partials/pie.html # {{> pie}}; frontmatter opcional: description
├── templates/recibo-compra.md # frontmatter: subject (requerido), kind, layout, display_name, description
└── workflows/checkout.yaml # o .json
--- subject: Tu recibo {{data:folio}} kind: transactional layout: default display_name: Recibo de compra --- <p>Hola {{contact:name}}</p> {{> pie}}
php artisan smartmailto:provision resources/smartmailto --dry-run # lee los archivos, no llama php artisan smartmailto:provision resources/smartmailto --validate # valida contra Smartmailto sin guardar (CI) php artisan smartmailto:provision resources/smartmailto # un solo paquete; lo nuevo queda en borrador php artisan smartmailto:provision resources/smartmailto --activate # ademas activa plantillas y workflows
- Todo o nada (F-009, RN-16): el comando arma un solo paquete (variables → bloques → plantillas → workflows → activaciones) y lo manda a
POST /api/provision. Si cualquier pieza falla, Smartmailto no cambia nada, lo que estaba activo sigue enviando con su version anterior, y el comando imprime todos los items fallidos y sale con codigo 1. Correrlo en cada deploy es seguro. Si no hubo respuesta (timeout,SMARTMAILTO_PROVISION_TIMEOUT, default 120 s), el paquete pudo aplicarse: vuelve a correrlo, porque es idempotente. - Borrador y activacion (RN-4): sin
--activate, lo nuevo quedadrafty un workflow cuya definicion cambia queda inactivo. Si estaba activo, deja de inscribir contactos y el comando lo advierte. Un workflow pausado por quejas solo se reactiva en el panel. Con--activatese activan en la misma operacion, con las mismas reglas que el panel: cero referencias a variables inexistentes, y ninguna referencia nueva a una obsoleta. Un workflow revisa tambien todas sus plantillas, que deben venir activas o activarse en el mismo paquete. - Avisos (
warnings): se imprimen (aviso template recibo: unknown_variable contact:nombre en body) y no hacen fallar. Codigos:unknown_variable,obsolete_variable,secret_in_subject,unknown_event,missing_group_operator,sensitive_keptyfiscal_key. --validatecorre el mismo paquete enPOST /api/validatesin guardar nada. Sale con codigo 1 si no es valido. Pide el token y el aprovisionamiento habilitado, igual que el deploy. Con--activatevalida tambien la activacion. Reemplaza cualquier copia local del parser de plantillas: la unica regla es la del servidor (RN-13).- El cuerpo va tal cual (metalenguaje de Smartmailto; no se convierte Markdown:
.mdsolo es el formato del archivo). Frontmatter planollave: valor. El nombre es el del archivo (^[a-z0-9][a-z0-9_-]*$). - Los bloques se mandan en orden alfabetico de archivo, y el servidor los aplica en ese orden: un bloque que incluye a otro debe nombrarse despues que el incluido.
- Sin
layouten el frontmatter el servidor conserva el actual; sinkind, una plantilla nueva nacemarketing.
Catalogo de variables (F-009)
Cada archivo de variables/ es una lista de variables. El catalogo dice que datos existen. Smartmailto valida contra el las plantillas y los workflows: una referencia a una variable que no existe se guarda con aviso, pero no se puede activar.
# variables/contact.yaml: atributos del contacto (los que mandas con identify) - scope: contact key: plan type: enum allowed_values: [free, pro] description: Plan contratado # obligatoria: es la documentacion que leen personas y agentes filterable: true # se puede buscar en Contactos (nunca si es sensible) - scope: contact key: telefono_movil type: string description: Movil del contacto sensitive: true # solo un admin lo ve; por API solo se enciende - scope: contact key: ciudad type: string description: Ciudad default: Mexico # respaldo si falta el dato y la plantilla no trae |default # variables/eventos.yaml: datos de evento ({{data:x}} y properties.x) y secretos ({{secret:x}}) - scope: event key: logo_url # sin `event`: comun (datos de send()) type: url description: Logo de la app - scope: event key: orden event: orden_creada # dato del evento orden_creada type: string description: Folio de la orden required: true # si el track no lo trae: 422 missing_event_data - scope: secret key: checkout_url event: orden_creada type: url description: Liga de pago de un solo uso
| Campo | Regla |
|---|---|
scope |
contact (atributo del contacto, persistente), event (dato por envio) o secret (dato de un solo uso; siempre sensible, nunca en el asunto) |
key |
Inmutable, ^[a-z][a-z0-9_]{0,63}$. Unica por seccion y evento. contact:email y contact:external_id son reservadas |
event |
Solo en event/secret: el evento al que pertenece. Sin event es comun (send()) |
type |
string, integer, decimal, boolean, date, datetime, url, email o enum (con allowed_values). No cambia mientras tenga usos (type_locked) |
allowed_values |
Se pueden agregar siempre; quitar uno contra el que compara una condicion: 409 allowed_value_in_use |
required |
Solo event/secret: se valida al recibir (422). En contact no existe (required_not_allowed): la obligatoriedad es de cada plantilla |
default |
Respaldo del catalogo. Orden: valor del contacto o evento → ` |
filterable |
Solo contact y no sensible (filterable_sensitive) |
sensitive |
Solo contact (un dato de evento sensible va como secret). Por API, SDK o provision solo se enciende: un sensitive: false sobre una sensible la conserva con el aviso sensitive_kept y no falla el deploy. Apagarla es exclusivo de un admin en el panel |
label |
Etiqueta visible, editable (default: la clave) |
Reglas que conviene saber al escribir plantillas:
- Una variable que la plantilla imprime sin
|defaultes obligatoria para esa plantilla. Si falta en un workflow, el envio queda endatos incompletosy se reintenta hasta 24 h; undata:/secret:faltante omite el paso de inmediato. Ensend()falla al momento con 422missing_variables. Dentro de{{#if contact:x}}no es obligatoria. - Sin datos fiscales (RFC, folio fiscal, regimen, CFDI) en
contactnievent: el folio fiscal va comosecret. Una clave que lo parece trae el avisofiscal_key. - Tope de 100 variables
contactactivas (catalog_full). Una variable en uso no se borra (409in_usecon la lista de usos): se marca obsoleta. Lo que ya la usa sigue enviando, pero nada nuevo se activa con ella. - YAML: escribe las fechas entre comillas (
default: '2026-01-01'). Sin comillas YAML las lee como fecha y el comando las rechaza antes de llamar. - El SDK solo revisa que los archivos se lean: lista valida,
scope,key,eventy que no haya repetidas. Lo demas lo valida el servidor, igual para el panel, la API, el SDK y MCP.
Desde codigo
Smartmailto::putVariable('contact', 'plan', ['type' => 'enum', 'allowed_values' => ['free', 'pro'], 'description' => 'Plan']); Smartmailto::putVariable('event', 'orden', ['type' => 'string', 'description' => 'Folio'], event: 'orden_creada'); Smartmailto::variables('event', 'orden_creada'); // catalogo (filtros opcionales) Smartmailto::variableUsages('contact', 'plan'); // donde se usa Smartmailto::obsoleteVariable('contact', 'plan'); Smartmailto::deleteVariable('contact', 'plan'); // false si no existia; 409 in_use si tiene usos Smartmailto::activateTemplate('recibo-compra'); Smartmailto::activateWorkflow('checkout'); Smartmailto::provisionPackage(['variables' => [...], 'templates' => [...]], activate: true); // todo o nada Smartmailto::validatePackage([...]); // { valid, errors, warnings, results }: revisa `valid` Smartmailto::schema(); // esquema para agentes (mismo que la ayuda del panel)
Tambien Smartmailto::putPartial($name, $body), Smartmailto::putTemplate($name, $subject, $body, layout:, kind:, displayName:, description:), Smartmailto::putWorkflow($name, $yamlOArreglo) y Smartmailto::templates() (con checksum = sha256 de json_encode([subject, body, layout, kind])). Una plantilla nueva queda draft y guardar un workflow nunca lo activa: usa activate*(). Las respuestas traen warnings. Son sincronos y lanzan SmartmailtoException si el servidor rechaza:
try { Smartmailto::provisionPackage($package, activate: true); } catch (SmartmailtoException $e) { $e->error(); // provision_failed | invalid_references | in_use | type_locked | ... $e->items(); // lo que fallo: [{ type, name, code, ref, location }] $e->usages(); // donde se usa la variable (in_use, type_locked, allowed_value_in_use) $e->warnings(); }
Webhook de falla (F-008)
Cuando un envio aceptado vence (send_before) o falla, Smartmailto avisa por el webhook de falla del proyecto (panel → engrane → Webhook de falla). El secreto whsec_... se muestra una vez:
SMARTMAILTO_WEBHOOK_SECRET=whsec_...
// routes/api.php (fuera de CSRF) Route::post('/smartmailto/webhook', SmartmailtoWebhookController::class)->middleware('smartmailto.webhook'); // o a mano: if (! Smartmailto::verifyWebhook($request)) { // usa smartmailto.webhook_secret; o verifyWebhook($request, $secret, 300) abort(401); } $event = $request->json()->all(); // version, event, delivery_id, send_id, idempotency_key, template, reason if ($event['event'] === 'send_expired') { // manda directo el correo de $event['idempotency_key'] (idempotente por delivery_id) }
- Verifica
X-Smartmailto-Signature(sha256=+ HMAC-SHA256 de"{timestamp}.{cuerpo crudo}") en tiempo constante y rechaza unX-Smartmailto-Timestampa mas de 5 minutos (en cualquier sentido) contra replay. Cada reintento llega con timestamp nuevo. - El middleware responde 401 si la firma no vale (Smartmailto no reintenta un 4xx) y 500 si falta
SMARTMAILTO_WEBHOOK_SECRET(error de configuracion de tu app: con 5xx Smartmailto reintenta ~10 h y el aviso no se pierde).smartmailto.webhook:600cambia la ventana. - Responde rapido (2xx) y procesa en cola.
Carga inicial
$batch = Smartmailto::batch(backfill: true); // no dispara correos Order::query()->paid()->unclaimed()->each(fn ($order) => $batch->track( 'orden_pagada', Identity::guest($order->email), ['timbres' => $order->stamps], eventId: "ff:orden_pagada:{$order->id}", object: ['order', $order->id], occurredAt: $order->paid_at, )); $batch->dispatch(); // se parte en lotes de 100
Volver a correr la carga es seguro: los eventId repetidos se descartan.
Con el pull (siguiente seccion) la carga inicial la hace Smartmailto: no hace falta escribir este comando.
Pull: Smartmailto le pide datos a tu app (F-011)
Con el modo push+pull del proyecto, Smartmailto llama a tu app para:
- la carga inicial, que lanza un admin desde el panel; nunca corre sola;
- la reconciliacion tras una caida;
- la resincronizacion nocturna, apagada por default;
- pedir un contacto cuando le falta un dato obligatorio para enviar.
Lo que llega por pull nunca dispara correos ni workflows: los eventos se guardan como historicos (source = pull) y si cuentan para condiciones y filtros.
1. Implementa el resolver. Lo que llega a Smartmailto se decide aqui:
use Agavesoft\Smartmailto\Contracts\PullResolver; use Agavesoft\Smartmailto\Identity; use Agavesoft\Smartmailto\Pull\{PullContact, PullCursor, PullEvent, PullPage}; use Carbon\CarbonInterface; class MiPullResolver implements PullResolver { public function contact(Identity $identity, ?CarbonInterface $eventsSince): ?PullContact { $user = $identity->userId !== null ? User::find($identity->userId) : User::firstWhere('email', $identity->email); return $user && $user->esContacto() ? $this->toContact($user, $eventsSince) : null; } public function contacts(?CarbonInterface $updatedSince, ?PullCursor $after, int $limit): PullPage { $users = User::query()->contactos() ->when($updatedSince, fn ($q) => $q->where('updated_at', '>=', $updatedSince)) // Estrictamente despues del cursor, en el mismo orden (updated_at, id): ->when($after, fn ($q) => $q->where(fn ($q) => $q->where('updated_at', '>', $after->updatedAt) ->orWhere(fn ($q) => $q->where('updated_at', $after->updatedAt)->where('id', '>', $after->key)))) ->orderBy('updated_at')->orderBy('id') ->limit($limit)->get(); return new PullPage($users->map(fn ($user) => $this->toContact($user, null))->all()); } private function toContact(User $user, ?CarbonInterface $eventsSince): PullContact { return new PullContact( Identity::user($user->id, $user->email), updatedAt: $user->updated_at, contactSince: $user->contacto_desde, // tu regla de "desde cuando es contacto" attributes: ['plan' => $user->plan, 'last_purchase_at' => $user->ultima_compra_at?->toIso8601String()], events: $user->ordenesPagadas($eventsSince)->map(fn ($order) => new PullEvent( "app:orden_pagada:{$order->id}", // EL MISMO eventId que mandas por track() 'orden_pagada', $order->paid_at, ['total' => $order->total], ['order', $order->id], ))->all(), unsubscribed: $user->sin_correos, key: $user->id, // desempate del cursor: la misma columna del orderBy ); } }
2. Configura. El panel del proyecto (engrane → Sincronizacion desde el proyecto) muestra el secreto pullsec_... una sola vez y pide la URL de tu app: https://tu-app/api/smartmailto/pull.
SMARTMAILTO_PULL_ENABLED=true SMARTMAILTO_PULL_SECRET=pullsec_... SMARTMAILTO_PULL_RESOLVER=App\Smartmailto\MiPullResolver # o enlaza Contracts\PullResolver en un provider
- La ruta
POST {prefix}/smartmailto/pull(prefijoapipor default, nombresmartmailto.pull) solo existe con el pull prendido y un resolver: si no, es 404. Se registra al arrancar la app: conphp artisan route:cacheregenera la cache al cambiar esto. - Middleware extra:
smartmailto.pull.route.middleware, por ejemplo un interruptor propio. Va fuera del grupoweb, sin CSRF. - Throttle propio:
smartmailto.pull.route.throttle, default120,1. Corre antes de la firma, asi que tambien cuenta las peticiones rechazadas. Smartmailto manda a lo mas 60/min de corridas y 60/min de un contacto. - Interruptores independientes: el pull no depende de
SMARTMAILTO_ENABLED, que es el interruptor del push. Con el push apagado, el catalogo remoto no se puede leer y la ruta responde 503, salvo que fijessmartmailto.pull.catalog.
3. Pruebalo sin red contra el contrato:
$pull = Smartmailto::fakePull(MiPullResolver::class, catalog: ['plan', 'last_purchase_at']); $pull->contact(Identity::user($user->id)); // el contacto tal como lo recibe Smartmailto (o null) $pull->assertPullContract(limit: 2); // recorre todas las paginas: orden, cursor estable, eventos completos
fakePull() prende el pull con un secreto de prueba y pasa por la ruta real (firma, filtro, historia, cursor). Con catalog no consulta a Smartmailto.
Reglas:
- Misma identidad y mismo
eventIdque en el push. Si el resolver armaevent_iddistintos a los de tutrack(), la carga duplica eventos y las condicionesevents.X.countcuentan doble. - Solo atributos del catalogo. Antes de responder, el SDK descarta las llaves que no son variables de contacto del catalogo del proyecto (F-009).
- El catalogo se lee de Smartmailto y se guarda en cache 5 min (
pull.catalog_ttl), o se fija consmartmailto.pull.catalog(lista de llaves). - Si no se puede leer, la ruta responde 503 y Smartmailto reintenta: nunca se mandan atributos sin filtrar.
- Las
propertiesde los eventos no se filtran.
- El catalogo se lee de Smartmailto y se guarda en cache 5 min (
- Condiciones por atributo, no por evento. El pull trae estado.
- Escribe las condiciones de tus workflows sobre atributos (
contact.attributes.last_purchase_at), no sobre "llego el evento X". - Las condiciones de compra deben usar
last_purchase_at: los eventos de mas de 13 meses ya no estan en Smartmailto, y alguien que compro hace 14 meses no es "nunca compro".
- Escribe las condiciones de tus workflows sobre atributos (
- Historia.
history_months(SMARTMAILTO_PULL_HISTORY_MONTHS) limita los eventos que se mandan; vacio = toda.- El SDK no topa la historia: Smartmailto descarta lo que este fuera de su retencion de eventos.
contact()recibeeventsSinceya calculado. Encontacts()el SDK filtra los eventos despues.
- Orden y cursor.
contacts()devuelve en orden(updatedAt, key)y estrictamente despues de$after.- El SDK arma el cursor desde el ultimo contacto y lo firma con el secreto del pull.
- Rotar el secreto invalida los cursores guardados: una corrida fallida tiene que empezar de cero.
- Si
updatedAtretrocede dentro de una pagina, la ruta responde 500 en vez de saltarse contactos. - Si tu columna guarda fracciones de segundo (
timestamp(6)), compara con$after->updatedAt->format('Y-m-d H:i:s.u'). El query builder formatea las fechas sin microsegundos y repetiria filas. contacts()no recibe el horizonte de historia. El SDK descarta los eventos viejos despues, pero si tienes mucha historia, limita tu consulta conconfig('smartmailto.pull.history_months').hasMore: truecon una pagina vacia responde 500: el cursor no tiene desde donde seguir.
- Quien es contacto lo decides tu.
contactSincees tu regla; sin ella, Smartmailto usa la fecha en que lo conocio. No devuelvas destinatarios externos, por ejemplo receptores de un CFDI. - Bajas y borrados.
unsubscribed: truellega como baja y el pull nunca la quita.PullPageaceptadeleted: [Identity, ...]: personas borradas en tu app, que Smartmailto deja inactivas sin borrarlas.- El borrado ARCO va siempre por
Smartmailto::forget(), y un contacto borrado por ARCO no se recrea por pull.
Contrato v1 (para implementarlo a mano en apps no Laravel). Smartmailto hace POST con cuerpo JSON:
// Un contacto { "version": 1, "op": "contact", "project_id": 12, "identity": { "user_id": "123" } | { "email": "a@b.mx" }, "include": ["attributes", "events"], "events_since": null } // Pagina (updated_since null = carga inicial; limit default 200, max 500) { "version": 1, "op": "contacts", "project_id": 12, "updated_since": "2026-10-07T03:00:00Z" | null, "cursor": "opaco" | null, "limit": 200, "include": ["attributes", "events"] }
| Header de la peticion | Valor |
|---|---|
User-Agent |
Smartmailto-Pull/1 |
X-Smartmailto-Timestamp |
segundos Unix |
X-Smartmailto-Request |
uuid nuevo en cada intento |
X-Smartmailto-Signature |
sha256= + HMAC-SHA256 hex de "{timestamp}.{cuerpo}" con el secreto del pull |
Respuesta 200 con X-Smartmailto-Timestamp y X-Smartmailto-Signature = sha256= + HMAC-SHA256 hex de "{timestamp}.{X-Smartmailto-Request}.{cuerpo}". Smartmailto descarta la pagina si la firma no coincide.
{ "version": 1,
"contacts": [ { "user_id": "123", "email": "a@b.mx",
"contact_since": "2024-03-01T10:00:00Z", "updated_at": "2026-10-07T21:10:00Z", "unsubscribed": false,
"attributes": { "last_purchase_at": "2026-09-30" },
"events": [ { "event_id": "ff:orden_pagada:991", "event": "orden_pagada", "occurred_at": "2026-09-30T10:00:00Z",
"properties": { "total": 100 }, "object": { "type": "order", "id": "991" } } ] } ],
"deleted": [ { "user_id": "77" } ],
"next_cursor": "opaco" | null }
- Contacto no encontrado:
contacts: [], no es error. - Rechazos definitivos:
401: firma invalida, timestamp a mas de 300 s oX-Smartmailto-Requestrepetido;404: pull apagado o sin resolver;422: version,op, identidad, fecha,limito cursor invalidos.
- Se reintentan:
503: sin secreto o sin catalogo;5xx: falla del resolver;429.
- La respuesta no debe pasar de 5 MB. El SDK recorta la pagina a
pull.max_response_bytesy el cursor sigue desde el ultimo contacto que cupo. Si un solo contacto no cabe, responde 500: bajahistory_months.
Fallas
- Se reintenta solo lo transitorio (red, 5xx, 429 respetando
Retry-After) con espera de 30 s, 2 min, 10 min y despues cada hora, hasta 24 h. - Un rechazo (422 por datos invalidos, 401 por token) no se reintenta.
- En ambos casos, al rendirse se dispara
Agavesoft\Smartmailto\Events\SmartmailtoDeliveryFailed(endpoint,key= event_id o idempotency key,status,error). Escuchalo para registrar o alertar. En un lote, cada item invalido dispara su propio evento. - Con el outbox (F-010) las reglas son las de su seccion: 72 h de reintentos, alertas agrupadas y
SmartmailtoOutboxFailed(outboxId,kind,key,reason,template,status,error) en vez deSmartmailtoDeliveryFailed.
Privacidad (consulta y borrado)
Smartmailto guarda correos y atributos cifrados con una llave por proyecto. Para atender a una persona:
Smartmailto::contact(Identity::user($user->id)); // correo, atributos, eventos y envios (o null) Smartmailto::renderedEmail($sendId); // el correo enviado, re-generado sin ligas de un solo uso Smartmailto::forget(Identity::guest('ana@example.com')); // borrado ARCO; true si existia
Cada consulta queda registrada en la bitacora de acceso del proyecto.
Pruebas en tu app
$fake = Smartmailto::fake(); // ... ejecuta tu codigo ... $fake->assertTracked('orden_pagada', fn ($body) => $body['object']['id'] === '100'); $fake->assertSent('recibo-compra'); $fake->assertNotTracked('orden_creada'); $fake->assertSent('cfdi-por-correo', fn ($body) => count($body['attachments']) === 2); $fake->assertProvisioned('templates', 'recibo-compra'); $fake->assertPackageProvisioned(fn (array $package, bool $activate) => $activate); // smartmailto:provision $fake->assertVariablePut('contact', 'plan'); $fake->assertActivated('workflow', 'checkout'); $fake->assertLinked(fn ($body) => $body['link_id'] === 'ff:link:order:555'); // F-010 $fake->assertExternalSendReported('ff:recibo:555'); // F-010: tu listener de emergencia
Con el fake las llamadas no pasan por el outbox, pero sus validaciones si (por ejemplo sendBefore obligatorio si outbox.enabled).
El fake no sale a la red. Sus respuestas se ajustan con provisionResponse, validateResponse, variablesResponse, usagesResponse, deleteResponse y schemaResponse.
Para probar tu resolver del pull, usa Smartmailto::fakePull() (ver Pull). Funciona junto con fake(): sin catalog, toma las variables de contacto de variablesResponse.
Contrato de eventos de Factura Facilita
Fuente de verdad: notificabot-conocimiento/features/2026-10-F003-sdk-ingesta-robusta/dimensiones/integracion.md.
| Evento | eventId | Identidad | object | properties | secrets |
|---|---|---|---|---|---|
orden_creada |
ff:orden_creada:{order_id} |
guest(email) | order | timbres, total, paquete | checkout_url |
orden_pagada |
ff:orden_pagada:{order_id} |
guest(email) o user(id, email) | order | invitado (bool), timbres, total, paid_at | activation_url, activation_expires_at (invitado) |
orden_reclamada |
ff:orden_reclamada:{order_id} |
user(id, correo de la orden) | order | via | — |
cuenta_creada |
ff:cuenta_creada:{user_id} |
user(id, email) | — | name | — |
sesion_iniciada |
ff:sesion_iniciada:{user_id}:{fecha} |
user(id) | — | — | — |
timbres_asignados |
ff:timbres_asignados:{package_account_id} |
user(id) | order? | disponibles, origen | — |
empresa_creada |
ff:empresa_creada:{company_id} |
user(id) | company | es_primera | — |
cfdi_timbrado |
ff:cfdi_timbrado:{cfdi_id} |
user(id) | cfdi | es_primero | — |
Migrar desde v1
v1 (agavesoft/mailflow) |
v2 (agavesoft/smartmailto) |
|---|---|
Mailflow::identify($userId, $attrs, async: true) |
Smartmailto::identify(Identity::user($userId, $email), $attrs) |
Mailflow::track($userId, $event, $props, async: true) |
Smartmailto::track($event, Identity::user($userId), $props, eventId: '...') |
Mailflow::send($userId, $slug, $data) |
Smartmailto::send($slug, Identity::user($userId, $email), $data, idempotencyKey: '...') |
MAILFLOW_API_URL, MAILFLOW_API_TOKEN |
SMARTMAILTO_API_URL, SMARTMAILTO_API_TOKEN |
| errores ignorados en silencio | reintentos reales + SmartmailtoDeliveryFailed |
Licencia
MIT.