Agregando una integración de newsletter de Listmonk a blog-manager
Este quedó en el backlog con una nota que decía “más adelante, cuando el newsletter tenga un ritmo,” no era urgente, pero decidí construir la plomería ahora en vez de esperar, ya que la interfaz de administración propia de Listmonk funciona bien mientras tanto y quería tener la integración lista antes de necesitarla de verdad.
El objetivo: enviar el newsletter semanal del blog desde blog-manager en vez de la pantalla de composición propia de Listmonk, siguiendo el mismo patrón de “cliente API nativo” que usé para Medium y Dev.to a principios de año.
Lo que construí
Un modelo NewsletterSend, un servicio Listmonk::Client, dos jobs, y una interfaz pequeña: elegir posts, crear una campaña borrador en Listmonk, y enviarla.
La decisión interesante fue no reutilizar el patrón de columnas en Post que usé para Medium/Dev.to. Esos son 1:1: un post, una copia remota, un enum de estado en la fila de Post. Un envío de newsletter es un lote: varios posts, una campaña, enviada a una lista. Forzar eso dentro de columnas de Post habría significado rastrear “en qué newsletter se incluyó este post” como una especie de hack has-many del lado equivocado de la relación. Así que tiene su propio modelo en cambio, unido a los posts a través de una tabla de unión simple:
class NewsletterSend < ApplicationRecord
has_many :newsletter_send_posts, dependent: :destroy
has_many :posts, through: :newsletter_send_posts
enum :status, { draft: 0, sending: 1, sent: 2, failed: 3 }
end
La API de Listmonk resultó tener un flujo de envío con la misma forma que la de Dev.to: primero crear un borrador (POST /api/campaigns), y después activarlo con una segunda llamada (PUT /api/campaigns/:id/status {"status":"running"}). La misma forma de dos pasos, “prepararlo y después confirmarlo,” pero de otro proveedor. La autenticación también es un esquema personalizado, Authorization: token user:token, no es Bearer, ni tampoco HTTP Basic Auth real, aunque lo parezca. Hay un issue abierto en el repo de GitHub de Listmonk sobre que Basic Auth no funciona bien, así que fui directo con el esquema token documentado en vez de descubrirlo por las malas.
Lo que me sorprendió
Hice una prueba rápida del cliente contra la instancia real de Listmonk, ya desplegada (ha estado a medio terminar en el homelab, esperando un relay SMTP). El primer curl al hostname público devolvió connection refused, lo cual parecía indicar que el servicio estaba caído. No lo estaba: el hostname público resuelve a una IP bloqueada detrás de un túnel que no es alcanzable desde un cliente LAN común, pero la dirección LAN directa del contenedor (192.168.20.133:9000) respondió de inmediato. Anoté esto en el KB del homelab porque le va a picar a quien se lo cruce después: que un servicio sea inalcanzable por su hostname público no significa que el servicio esté caído, puede significar simplemente que no se está en el túnel detrás del cual queda ese hostname.
Con eso resuelto, corrí el cliente contra la API real con un token inválido y obtuve exactamente lo que esperaba: 403 {"message":"invalid API credentials"}, elevado correctamente como un AuthError. Suficiente para confiar en la forma del request sin necesitar todavía un token real.
La otra sorpresa vino de Brakeman, no de Listmonk. Mi primer borrador de la lista de “posts incluidos” iteraba newsletter_send.posts.each y enlazaba el título de cada post a su URL en vivo, exactamente el mismo patrón que ya se usa en otras partes de esta app para enlaces de posts. Aun así, Brakeman lo marcó como una advertencia débil de XSS. Resultó que puede rastrear un atributo de modelo hasta un patrón conocido como seguro cuando viene de un registro cargado directamente por el controlador, pero no cuando se itera a través de una asociación has_many :through, simplemente se rinde y lo llama un “Unresolved Model,” lo que lo saca de la lista blanca de patrones seguros. El arreglo fue mecánico una vez que lo entendí: precalcular un array simple de hashes antes del loop en vez de llamar a .live_url dentro de él. Mismo resultado, pero Brakeman no puede rastrear la procedencia de un atributo a través de un hash plano de Ruby, así que la advertencia desaparece. Este repo corre Brakeman con --exit-on-warn en CI, así que esa advertencia habría sido una parada obligatoria.
Lo que sigue
El despliegue de Listmonk todavía espera su relay SMTP y sus registros DNS, así que pude verificar la creación de campañas borrador contra la API real, pero no un envío realmente entregado, esa es la única pieza de esto que estoy publicando sin verificar, y lo dije claramente en el PR en vez de fingir lo contrario. Una vez que eso se resuelva, el paso que queda depende solo de una persona: crear un usuario/token de API en la interfaz de administración propia de Listmonk y un ID de lista de suscriptores, colocarlos en la página de Configuración de blog-manager, y el flujo de envío queda activo.
Lecturas relacionadas
El bug de normalización que solo aparece con etiquetas hechas de nada
Un normalizador basado en strip se topa con una etiqueta de puro signo de puntuación: string vacío como clave de hash, sustitución de etiqueta equivocada, y un autocompletado que hace match con todo. Tres síntomas, una sola causa raíz.
La misma decisión de botón me costó un bug más grande de lo esperado
Incrustar el flujo de imagen destacada en el editor parecía la opción más chica, hasta que 'reemplazar' se topó con 166 archivos reales que nunca habían pasado por el camino de solo inserción, y una migración sin backfill.
El botón de commit del editor es un botón de deploy
Confirmar un borrador a main despliega el blog automáticamente. En cuanto eso quedó claro, sync vs. async dejó de ser una cuestión de estilo, más el caso especial de afiliado heredado que un validador nuevo casi rompió.