Skip to content
Development

Adding a Listmonk newsletter integration to blog-manager

By Victor Da Luz
railsrubynewsletterdev-logblog-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

Development

The editor commit button is a deploy button

Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.

Read