Adding a Listmonk newsletter integration to blog-manager
This one sat in the backlog with a note saying “later, once the newsletter has a rhythm” - it wasn’t urgent, but I decided to build the plumbing now rather than wait, since Listmonk’s own admin UI works fine in the meantime and I wanted the integration ready before I actually need it.
The goal: send the weekly blog newsletter from blog-manager instead of Listmonk’s own compose screen, following the same “native API client” pattern I used for Medium and Dev.to earlier this year.
What I built
A NewsletterSend model, a Listmonk::Client service, two jobs, and a small UI - pick posts, create a Listmonk draft campaign, then send it.
The interesting decision was not reusing the Post-column pattern from Medium/Dev.to. Those are 1:1 - one post, one remote copy, one status enum on the Post row. A newsletter send is a batch: several posts, one campaign, sent to a list. Trying to force that into Post columns would have meant tracking “which newsletter was this post included in” as some kind of has-many hack on the wrong side of the relationship. So it’s its own model instead, joined to posts through a plain join table:
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
The Listmonk API itself turned out to use a send flow shaped just like Dev.to’s: create a draft first (POST /api/campaigns), then flip it live with a second call (PUT /api/campaigns/:id/status {"status":"running"}). Same two-step “stage it, then commit” shape, different vendor. Auth is a custom scheme too - Authorization: token user:token, not Bearer, not real HTTP Basic Auth, even though it looks like it should be. There’s an open GitHub issue on Listmonk’s repo about Basic Auth not working right, so I went with the documented token scheme from the start instead of finding that out the hard way.
What surprised me
I went to smoke-test the client against the real, already-deployed Listmonk instance (it’s been sitting half-finished in the homelab, waiting on an SMTP relay). First curl to the public hostname came back connection refused, which read like the service was down. It wasn’t - the public hostname resolves to a tunnel-gated IP that isn’t reachable from a plain LAN client, but the container’s direct LAN address (192.168.20.133:9000) answered immediately. Wrote that one down in the homelab KB since it’ll bite whoever hits it next: a service being unreachable by its public hostname doesn’t mean the service is down, it might just mean you’re not on the tunnel that hostname is gated behind.
With that sorted, I ran the client against the real API with a garbage token and got back exactly what I expected: 403 {"message":"invalid API credentials"}, correctly raised as an AuthError. Good enough to trust the request shape without needing a real token yet.
The other surprise was from Brakeman, not Listmonk. My first draft of the “posts included” list iterated newsletter_send.posts.each and linked each post’s title to its live URL - the exact same pattern already used elsewhere in this app for post links. Brakeman flagged it as a weak XSS warning anyway. Turns out it can trace a model attribute back to a known-safe pattern when it comes from a plain controller-loaded record, but not when it’s iterating through a has_many :through association - it just gives up and calls it an “Unresolved Model,” which drops it out of the safe-pattern allowlist. The fix was mechanical once I understood it: precompute a plain array of hashes before the loop instead of calling .live_url inside it. Same output, but Brakeman can’t trace attribute provenance through a plain Ruby hash, so the warning disappears. This repo runs Brakeman with --exit-on-warn in CI, so that one warning would have been a hard stop.
What’s next
The Listmonk deploy is still waiting on its SMTP relay and DNS records, so I could verify draft-campaign creation against the real API but not an actual delivered send - that’s the one piece of this I’m shipping unverified, and I said so plainly in the PR rather than pretend otherwise. Once that closes, the remaining step is just a person: create an API user/token in Listmonk’s own admin UI and a subscriber list ID, drop them into blog-manager’s Settings page, and the send flow is live.
Related reading
What happens when a job broadcasts to nobody
Closing the hero-image loop: insert-only frontmatter patching, a guard that caught real drift on its first run, and a Turbo broadcast with no listener.
A doc-drift fix that wasn't as boring as it sounded
Three audit items that each turned into something: a half-fixed claim, a quietly dead password reset, and a staging email that would have linked to production.
A 500 that was hiding inside a mounted engine's isolated routes
The jobs dashboard returned a 500 instead of a login page: bare route helpers resolve against the engine, not the app. One line, plus its dormant twin.
You might also find useful
Proton Pass
Privacy-focused password manager from the team behind Proton Mail.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn moreRackNerd VPS
Budget VPS hosting for lightweight always-on services.
As a RackNerd affiliate, I earn from qualifying purchases.
Learn moreAdGuard for iOS
System-wide ad and tracker blocking on iOS, no separate DNS server required.
As an AdGuard affiliate, I earn from qualifying purchases.
Learn more