Adicionando uma integração de newsletter com o Listmonk ao blog-manager
Esse item ficou no backlog com uma nota dizendo “depois, quando a newsletter tiver um ritmo”, não era urgente, mas decidi construir o encanamento agora em vez de esperar, já que a própria interface administrativa do Listmonk funciona bem enquanto isso e eu queria a integração pronta antes de realmente precisar dela.
O objetivo: enviar a newsletter semanal do blog a partir do blog-manager em vez da própria tela de composição do Listmonk, seguindo o mesmo padrão de “cliente de API nativo” que usei para Medium e Dev.to no começo do ano.
O que eu construí
Um model NewsletterSend, um serviço Listmonk::Client, dois jobs, e uma pequena interface, escolher posts, criar uma campanha rascunho no Listmonk, depois enviar.
A decisão interessante foi não reaproveitar o padrão de colunas em Post usado para Medium/Dev.to. Aqueles são 1:1, um post, uma cópia remota, um enum de status na linha de Post. Um envio de newsletter é um lote: vários posts, uma campanha, enviada para uma lista. Forçar isso em colunas de Post teria significado rastrear “em qual newsletter esse post foi incluído” como algum tipo de gambiarra has-many do lado errado do relacionamento. Então virou um model próprio, ligado aos posts por uma tabela de junção simples:
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
A própria API do Listmonk acabou usando um fluxo de envio com a mesma forma do Dev.to: criar um rascunho primeiro (POST /api/campaigns), depois deixá-lo ativo com uma segunda chamada (PUT /api/campaigns/:id/status {"status":"running"}). Mesma forma de duas etapas, “preparar e depois confirmar”, fornecedor diferente. A autenticação também é um esquema próprio, Authorization: token user:token, não Bearer, não HTTP Basic Auth de verdade, mesmo parecendo que deveria ser. Existe uma issue aberta no repositório do Listmonk sobre o Basic Auth não funcionar direito, então fui direto com o esquema token documentado em vez de descobrir isso do jeito difícil.
O que me surpreendeu
Fui fazer um smoke test do cliente contra a instância real do Listmonk, já em produção (estava parada meio inacabada no homelab, esperando um relay de SMTP). O primeiro curl para o hostname público voltou connection refused, o que parecia que o serviço estava fora do ar. Não estava, o hostname público resolve para um IP preso atrás de um túnel que não é alcançável de um cliente comum na rede local, mas o endereço direto do contêiner na rede local (192.168.20.133:9000) respondeu na hora. Anotei isso na base de conhecimento do homelab já que vai pegar quem quer que esbarre nisso depois: um serviço inalcançável pelo hostname público não significa que o serviço está fora do ar, pode só significar que você não está no túnel atrás do qual esse hostname está preso.
Com isso resolvido, rodei o cliente contra a API real com um token qualquer e recebi exatamente o que esperava: 403 {"message":"invalid API credentials"}, corretamente levantado como um AuthError. Bom o suficiente para confiar na forma da requisição sem precisar de um token de verdade ainda.
A outra surpresa veio do Brakeman, não do Listmonk. Meu primeiro rascunho da lista de “posts incluídos” iterava newsletter_send.posts.each e linkava o título de cada post para sua URL ao vivo, exatamente o mesmo padrão já usado em outros lugares desse app para links de post. O Brakeman marcou isso mesmo assim como um alerta fraco de XSS. Acontece que ele consegue rastrear um atributo de model até um padrão conhecido como seguro quando vem de um registro carregado direto por um controller, mas não quando está iterando por uma associação has_many :through, ele simplesmente desiste e chama isso de um “Unresolved Model,” o que tira o atributo da lista de padrões seguros. O fix foi mecânico assim que entendi: pré-computar um array simples de hashes antes do loop em vez de chamar .live_url dentro dele. Mesma saída, mas o Brakeman não consegue rastrear a proveniência de um atributo através de um hash Ruby simples, então o alerta desaparece. Esse repositório roda o Brakeman com --exit-on-warn no CI, então esse alerta sozinho teria travado tudo.
O que vem depois
O deploy do Listmonk ainda está esperando o próprio relay de SMTP e os registros de DNS, então consegui verificar a criação de campanha rascunho contra a API real, mas não um envio de verdade entregue, essa é a única parte disso que estou entregando sem verificar, e deixei isso claro no PR em vez de fingir o contrário. Quando isso se resolver, o passo restante é só uma pessoa: criar um usuário/token de API na própria interface administrativa do Listmonk e um ID de lista de assinantes, colocar os dois na página de configurações do blog-manager, e o fluxo de envio fica no ar.
Leitura relacionada
Uma correção de desvio de documentação que não foi tão chata quanto parecia
Três itens da auditoria que cada um virou outra coisa: uma alegação meio corrigida, uma recuperação de senha silenciosamente morta, e um e-mail de staging que apontaria para produção.
Um 500 escondido dentro das rotas isoladas de uma engine montada
O painel de jobs retornava um 500 em vez de uma página de login: helpers de rota sem qualificação resolvem contra a engine, não contra a aplicação. Uma linha, mais o gêmeo dormente dela.
Apagando código morto, e pegando um motivo errado para uma resposta certa
Uma issue de limpeza com uma justificativa errada, uma varredura de documentação que não era necessária, e a credencial órfã que uma revisão pegou.
Você também pode achar útil
Proton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisRackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba maisAdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba mais