- El problema: Un pipeline de marketing atrapado en una herramienta low-code con un techo de 6.000 acciones/día, un tope de 200 contactos por corrida, y un dedup en memoria que reenvía cuando se cae. Y una restricción de fondo: no había forma de registrar apps ni pedir permisos nuevos a IT.
- La decisión: Sacar el envío de la herramienta low-code y ser dueña del camino completo — de un formulario a un inbox — tomando cada decisión de arquitectura en vez de repartirla entre SaaS donde nadie responde.
- Lo que puedes llevarte: cómo enviar como una casilla compartida sin acuñar una credencial nueva, cómo hacer que reenviar sea imposible en la base de datos (no improbable), cómo meter el header List-Unsubscribe que Graph se niega a aceptar como header, y cómo aislar la fuente de datos para que cambiarla no toque el motor. Con el código y los gotchas.
Este artículo es la tercera parte de un pipeline contado en tres. La primera, los formularios que alimentan Salesforce, es la entrada. La segunda, el conector de Salesforce sin acceso de admin, abre esa data en lenguaje natural. Esta es la salida: convertir esa data viva en correo que se dispara solo, de forma fiable y reproducible. No es un recorrido por lo que construí; es un mapa para que alguien con el mismo problema pueda trazar su propia solución.
El problema, sin adornos
> En pocas palabras: El pipeline viejo no fallaba por falta de features, sino por decisiones que no eran mías y que aun así eran mi problema. Cambiar eso empieza por identificar cuál es el muro real. El pipeline vivía en Power Automate sobre listas de SharePoint. La herramienta que construí para manejar esos flujos desde un agente (opens in new tab) me dio visibilidad total de su interior, y con eso, tres muros que ninguna optimización dentro de la herramienta podía tirar:
- Techo de 6.000 acciones/día. Cada envío es una acción; el límite es de la licencia, no del diseño. Un volumen de decenas de miles era imposible en cualquier arquitectura encima de esa herramienta.
- Tope de 200 contactos por corrida, del conector de la lista.
- Dedup en memoria. El único freno contra reenviar era un
filteren RAM: si el flujo se caía a media corrida, el freno no existía.
El diagnóstico que importó no fue "cómo optimizo esto", fue "cuál de estos muros es del diseño y cuál es de la herramienta". Los tres eran de la herramienta. La conclusión se sigue sola: el envío tiene que salir de ahí. Ese es el primer ejercicio de criterio, y define todo lo demás.
Enviar como una casilla compartida sin acuñar una credencial
> En pocas palabras: La receta estándar —registrar una app en Entra ID— necesita admin. La alternativa reproducible es reutilizar un cliente que Microsoft ya preautoriza, y montar sobre una delegación que ya existía. Sacar el envío a Microsoft Graph choca de inmediato con auth. La receta de manual es registrar una aplicación en Entra ID y darle Mail.Send. Eso necesita un permiso de admin que, en una firma cerrada, no vas a conseguir. La pregunta útil no es "cómo consigo admin", es "cuál es la credencial más poderosa que un usuario normal ya tiene, y qué tan poco necesito agregarle".
La respuesta: Microsoft ya preautoriza clientes first-party públicos para scopes de Graph vía device code, sin registrar nada. El truco —y el gotcha— es que la mayoría no están preautorizados en un tenant dado y devuelven AADSTS65002. Hay que encontrar el que sí. En la práctica, el cliente de Microsoft Graph CLI funciona donde Azure CLI y Office fallan.
La segunda mitad es la identidad. Enviar como una casilla compartida no es un permiso nuevo si alguien en la org ya tiene Send As sobre ella — que es justo como el pipeline viejo enviaba. Así que el login del módulo de envío lo hace esa persona, y su sesión es la que envía. No inventé un permiso; elegí el límite de integración correcto —un grant preautorizado + una delegación existente— y me quedé dentro de él.
> [!note] El patrón es transferible a cualquier proveedor cerrado: antes de pedir un permiso nuevo, busca el grant que la plataforma ya le concede a su propia herramienta oficial. Reusar una credencial sancionada casi siempre le gana a acuñar una nueva, porque la superficie de credencial más chica es ninguna superficie nueva. Es la misma disciplina del conector de Salesforce, aplicada al envío.
Enviar de verdad: /me/sendMail, Send As, y el header que Graph no te deja poner
> En pocas palabras: El envío en sí tiene un gotcha caro de descubrir: Graph rechaza List-Unsubscribe como header normal. Se mete como propiedad MAPI extendida. Ese detalle es la diferencia entre cumplir o no. Con token e identidad resueltos, enviar es un POST /me/sendMail con un override de from apuntando a la casilla compartida. Lo que no es obvio —y cuesta horas encontrar— es cómo poner el header List-Unsubscribe, que es lo que hace que Gmail y Outlook muestren el botón nativo de "cancelar suscripción". Graph rechaza ese header por la vía de internetMessageHeaders (solo admite headers con prefijo X-). La única forma es inyectarlo como una propiedad MAPI extendida, con el id String 0x1045.
Descubrir ese 0x1045 es exactamente el tipo de conocimiento que un artículo debería dejar escrito, porque no está en el "quickstart" de nadie. Sin él, el pipeline "cumple" a medias: hay link de baja en el cuerpo, pero no el header que los clientes de correo usan para el one-click.
Idempotencia por construcción: reenviar es imposible, no improbable
> En pocas palabras: El peor bug de un sender es mandar dos veces. Un chequeo en memoria es una promesa; una restricción en la base de datos es una garantía. Mueve la garantía al borde de datos. El dedup en memoria del pipeline viejo era el modo de fallo más peligroso: cuando se caía, reenviaba. La solución no es un chequeo más cuidadoso; es hacer el reenvío estructuralmente imposible. Un índice único parcial sobre el registro de envíos: para un mismo (campaign_id, email) con resultado sent, no puede existir una segunda fila.
Dos detalles que suben la robustez sin costo: los correos se guardan como citext, así KAREN@… y karen@… son el mismo contacto sin normalizar a mano; y el índice es parcial (where result='sent'), de modo que los intentos failed no bloquean un reintento legítimo. El criterio aquí es elegir dónde vive la verdad: no en la lógica de la app, que se cae, sino en el motor de datos, que no.
Reanudable sin cursor, y un claim atómico
> En pocas palabras: Si la consulta de "a quién le falta" ya excluye lo enviado, reiniciar continúa en vez de duplicar — no necesitas cursor. Y SKIP LOCKED evita que dos procesos tomen la misma campaña. Como el no-duplicado es una garantía de la DB, la reanudación sale gratis: la consulta de pendientes ya excluye lo enviado, lo suprimido y las bajas. Reiniciar el proceso continúa. No hay cursor que persistir ni corromper. Y las tres exclusiones son la misma consulta, así que "a quién le toca" y "a quién no" nunca se desincronizan.
El planificador que dispara campañas vencidas corre cada pocos minutos. Para que dos ticks nunca tomen la misma campaña, el claim es atómico:
FOR UPDATE SKIP LOCKED es el patrón canónico de cola sobre Postgres, y vale la pena conocerlo: convierte una tabla común en una cola de trabajos segura entre procesos, sin un broker aparte.
Cumplimiento como estructura, no como cortesía
> En pocas palabras: La baja no es un link decorativo al pie; es un token firmado, verificado en el borde, que escribe en la misma tabla que la consulta de pendientes ya consulta. El sistema no puede enviarle a quien salió. Cada correo lleva un pie con un token firmado con HMAC sobre (campaign_id, email). El link pega a una Edge Function que recomputa el HMAC, compara en tiempo constante, y si valida, registra la baja. La supresión y la baja no son un filtro que alguien recuerda aplicar; son parte de la definición de "a quién le toca este correo" (la consulta de arriba).
| El riesgo | La respuesta del diseño |
|---|---|
| Reenviar a alguien ya contactado | Índice único parcial: la segunda fila sent no existe |
| Enviarle a quien pidió salir | Baja/supresión excluidas en la consulta de pendientes, no en un filtro opcional |
| Un token de baja falsificado | HMAC firmado, comparado en tiempo constante; la Edge Function rechaza lo que no valida |
| Dos corridas que se traslapan | FOR UPDATE SKIP LOCKED: una sola toma la campaña |
| Rebasar el límite del proveedor | Pacing a 25/min bajo el techo de 30/min, con respeto de Retry-After |
El pacing merece una nota de criterio: 25/min está deliberadamente por debajo del techo de 30/min de Exchange, para dejar margen a los reintentos sin rozar el borde. No es un número mágico; es aritmética contra un límite documentado. Para 9.000 contactos son ~6 horas — trabajo de un proceso de fondo, no de una sesión interactiva.
La costura que hace todo esto sostenible
> En pocas palabras: El motor no sabe de dónde vienen los leads. Esa ignorancia es a propósito: cambiar la fuente de datos es escribir otro adaptador, no tocar el envío. La pieza que evita que este sistema se pudra es una costura pequeña y deliberada. El motor no conoce Salesforce ni CSV; conoce un solo contrato, NormalizedLead. Cada fuente es un adaptador que lo produce.
El salesforce-adapter lee del conector read-only y emite el mismo NormalizedLead, así la audiencia entra directo desde la fuente de verdad sin un export de por medio; un csv-adapter queda para cargas puntuales. El pipeline va de la forma al inbox sin humano en medio, y cambiar la fuente nunca toca el motor. Esa es la definición de una costura bien puesta: aislar "de dónde salen los datos" de "cómo se envían".
Un motor, varios disparadores
> En pocas palabras: El mismo motor que manda una campaña programada dispara recordatorios y newsletters por evento. Programado es solo el primer modo; la fuente de verdad manda. Como la fuente es Salesforce y el motor consume un contrato normalizado, el mismo mecanismo que manda una campaña por fecha dispara un correo por evento: un lead nuevo que un formulario inyecta encadena un recordatorio; una cohorte que cruza un umbral recibe una newsletter. Campaña, recordatorio y newsletter no son tres sistemas; son el mismo motor —idempotente, con pacing, con cumplimiento— con distinto disparador.
El outcome
> En pocas palabras: Qué cambió, concretamente, y qué le costó a la postura de seguridad de la org: nada.
- Del techo a sin techo. Los límites de 6.000 acciones/día y 200 contactos/corrida desaparecieron; el único que queda es el de Exchange (10.000/día, 30/min), que es del proveedor y se maneja con pacing, no un muro del diseño.
- De "ojalá no duplique" a "no puede duplicar". El dedup pasó de chequeo en memoria a índice único.
- De copiar-y-pegar a un solo camino, desde la misma fuente de verdad que alimentan los formularios.
- Sin costo de seguridad. Ninguna credencial nueva, ninguna app registrada, la identidad de envío es la que ya existía. La superficie de la org no creció.
La validación de punta a punta ya corrió: campañas reales programadas, disparadas solas a la hora exacta, con la baja funcionando y cero duplicados. No es un diseño en papel; es un pipeline vivo.
Las decisiones que lo sostienen
> En pocas palabras: Las elecciones sobre las que descansa el sistema, y el trabajo que sigue.
- Sacar el envío de la herramienta low-code en vez de optimizarla dentro, porque el muro era de la herramienta, no del diseño.
- Reutilizar un grant preautorizado + una delegación existente, en vez de registrar una app y pedir permisos.
- Estado en Postgres real: dedup, cursor y supresión como garantías del motor de datos, no trucos en memoria.
- Cumplimiento estructural: baja firmada por HMAC y supresión respetadas por la misma consulta que arma la audiencia.
- Una costura de adaptador que aísla la fuente del motor.
En el roadmap: tope diario automático para listas por encima del límite de Exchange; tracking de clicks por campaña de vuelta a Salesforce; procesador de rebotes que alimente la supresión solo; y un repo de referencia público y saneado con el esqueleto del motor (los detalles operativos quedan privados).
Referencias
> En pocas palabras: Las fuentes que hacen esta arquitectura reproducible, no solo creíble.
| Tema | Fuente |
|---|---|
Enviar correo con Graph (sendMail, override from) | Microsoft Graph — user: sendMail (opens in new tab) |
Propiedades MAPI extendidas (el List-Unsubscribe vía 0x1045) | Graph — extended properties overview (opens in new tab) |
Header List-Unsubscribe + one-click | RFC 8058 — One-Click Unsubscribe (opens in new tab) |
| Device code flow (auth sin app registrada) | Microsoft identity platform — device code (opens in new tab) |
| Índices parciales en Postgres (la idempotencia) | PostgreSQL — Partial Indexes (opens in new tab) |
SKIP LOCKED como cola de trabajos | PostgreSQL — SELECT FOR UPDATE / SKIP LOCKED (opens in new tab) |
| Edge Functions (verificación de la baja) | Supabase — Edge Functions (opens in new tab) |
| MCP que manejaba los flujos viejos (fase previa, público) | github.com/karenrebecag/PowerAutomate_MCP (opens in new tab) |
La lección real
> En pocas palabras: El criterio no es un momento de brillantez; es elegir la opción durable sobre la rápida, una y otra vez, y poder defender cada elección. La decisión más defendible de este proyecto no fue una idea ingeniosa, fue una postura sostenida: en cada bifurcación, elegir la opción que convierte una esperanza en una garantía. El dedup pudo ser un chequeo más cuidadoso; lo hice un índice único. El auth pudo ser un ticket a IT; lo hice la reutilización de un grant que ya existía. El cumplimiento pudo ser un link al pie; lo hice un token firmado que la misma consulta de audiencia respeta.
Ninguna de esas elecciones es más código que la alternativa fácil. Son mejor código, porque mueven la verdad del sistema a donde no se cae. Un pipeline de marketing no es difícil de armar; es difícil de poseer — y poseerlo significa poder nombrar cada una de sus garantías, mostrar el código que la sostiene, y explicar por qué le gana a lo que "debías" construir. Si alguien con mi mismo problema llega hasta aquí, tiene el mapa completo: el muro real, el grant que reutilizar, el header escondido, la restricción que hace imposible el duplicado, y la costura que deja crecer el sistema sin reescribirlo.