Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Listmonk (listmonk)

Listmonk is a self-hosted newsletter and mailing-list manager. Rustiz turns a post into an HTML email campaign, creates it, and immediately starts it. The instance URL and API credentials are supplied per channel, encrypted in custom_instance_details. There is no developer app, no OAuth and no app review.

  • Rust source: crates/rustiz-providers/src/listmonk.rs
  • Upstream port: listmonk.provider.ts
  • Editor: HTML
  • Connection mode: customFields

Overview

PropertyValue
Identifierlistmonk
Display nameListMonk
Connection modecustomFields (instance URL, API user, API token)
Editorhtml
Channel kindsocial (email campaign)
Comments / threadsNo. supports_comments = false; only the first PostDetails is used
Pending / async publishingNo. supports_pending = false; returns PostStatus::Completed immediately after starting the campaign
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job100
Character limit (max_length)100000000 characters (effectively unlimited; Listmonk has no small body cap). The Postiz docs table lists 300,000, but the Rust port’s max_length() returns 100000000.
Inline imagesYes (inline_images = true)
APIListmonk REST API (/api/...) with HTTP Basic auth

Server Environment Variables

required_env() returns no variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &[]
}
}

required_env_alternatives() is not overridden. The instance URL and credentials come from the channel, so rustiz providers --status reports listmonk as configured without server configuration.

VariableRequiredDefaultPurpose
JWT_SECRETNo, but should be set""Encrypts/decrypts the channel’s custom_instance_details (URL, username, password). It must be the same value at connect time and on every later call. If it changes, every Listmonk channel fails with Missing instance details. If it is unset consistently, an empty key is used (functional but insecure).
LISTMONK_BASE_URLNothe channel’s stored URLGlobal override for every Listmonk channel. When set, the stored instance URL is ignored and all calls go to this origin. Intended for tests/proxies; dangerous in a multi-instance deployment. The value is trimmed of a trailing slash.
ALLOW_PRIVATE_NETWORKNofalseGlobal SSRF switch. A Listmonk instance on localhost or a private IP is blocked unless this is true (or 1).

Why JWT_SECRET matters

connect_custom() in crates/rustiz-server/src/oauth.rs encrypts the entire custom-fields JSON with fixed_encryption(JWT_SECRET, ...). Listmonk::instance() decrypts it on every list/template/publish call. The provider reports a generic Missing instance details for a missing value, a wrong secret, or malformed JSON — it cannot tell them apart. Set a strong secret before connecting and never rotate it without reconnecting all Listmonk channels.

Step-by-Step Developer App Setup

There is no developer portal. Setup happens in the Listmonk admin UI.

  1. Sign in to Listmonk as an admin.

  2. Create a user role with the permissions Rustiz needs. In Listmonk v4+ go to Admin → Users → User roles → New:

    • settings:get — authenticate() calls GET /api/settings.
    • lists:get_all — the list tool calls GET /api/lists. Alternatively, attach a List role with at least View access to the target list.
    • templates:get — the templates tool calls GET /api/templates.
    • campaigns:manage (or campaigns:manage_all) — create the campaign.
    • campaigns:send — start the campaign. This is a separate permission and is required even if the role has campaigns:manage_all.
  3. Create a List role if the API user should be restricted to one or more lists. Assign at least View (and Manage, if the campaign should use it) for the target list. Without a list role, use lists:get_all / lists:manage_all.

  4. Create an API user: Admin → Users → Users → New, choose type API. Assign the user role and list role. Save and copy the generated access token — Listmonk shows it only once.

  5. Confirm the instance is reachable from the Rustiz server. HTTPS is strongly recommended; Basic auth sends the token on every request.

  6. Verify the credentials:

    curl -s -u 'rustiz_api:TOKEN' \
      https://listmonk.example.com/api/settings
    

    A 200 with a data.app.site_name field is success. A 401/403 means the token, username, or role permissions are wrong.

Products and permissions

  • No developer products or APIs to enable.
  • No OAuth scopes. Permissions are Listmonk user-role + list-role permissions.
  • The API user is not a normal login user: it has an automatically generated token, and the “password” in Rustiz is that token.
  • GET /api/lists and GET /api/templates are paginated by Listmonk. Rustiz does not pass page/per_page, so the tools return only the default first page.

Sandbox / testing mode

Listmonk has no hosted sandbox. Use a local or staging Listmonk instance and a test list containing only your own address.

  • A campaign is created and immediately set to running; it is not a draft. There is no send_at scheduling and no test-send step in Rustiz.
  • Never point a test at a production list with real subscribers.
  • If the instance is on a private address, set ALLOW_PRIVATE_NETWORK=true on Rustiz.

Production app review

There is no app review and no approval. Access is controlled entirely by the Listmonk API user’s role and list role. The same token works in production.

Connecting a Channel (CLI & MCP)

The credential payload has three keys:

KeyTypeValidation regexNotes
urltext`/^(https?://)(?:\S+(?::\S*)?@)?(?:(?:localhost)(?:\d{1,3}(?:.\d{1,3}){3})
usernametext/^.+$/Listmonk API user name, not the admin login.
passwordpassword/^.{3,}$/Listmonk API token. The field is labelled Password, but it is the token, not a password.

CLI

{
  "url": "https://listmonk.example.com",
  "username": "rustiz_api",
  "password": "listmonk-api-token"
}
rustiz channels connect listmonk --fields listmonk.json

printf '%s' '{"url":"https://listmonk.example.com","username":"rustiz_api","password":"listmonk-api-token"}' \
  | rustiz channels connect listmonk --fields -

Immediate result:

{ "connected": true, "pending": false, "name": "My Newsletter" }

The name is data.app.site_name from /api/settings, not the URL.

MCP

{
  "provider": "listmonk",
  "customFields": {
    "url": "https://listmonk.example.com",
    "username": "rustiz_api",
    "password": "listmonk-api-token"
  }
}

No follow-up step. Call integrationList for the channel id. Use --fields / customFields; --external-url / externalUrl is not the credential path for this provider.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
listnone[{"id": <number>, "name": "..."}]Calls GET /api/lists, reads data.results. Only the default first page. Requires lists:get_all or a list role.
templatesnone[{"id": 0, "name": "Default"}, {"id": <number>, "name": "..."}]Calls GET /api/templates. Prepends the synthetic Default entry with id 0; at publish time id 0 is filtered out and template_id is omitted, so Listmonk uses its default template.

MCP examples:

{ "integrationId": "int_...", "methodName": "list" }
{ "integrationId": "int_...", "methodName": "templates" }

Both functions have an empty input_schema / dataSchema. They ignore the stored access token and re-decrypt the instance details from the integration; the token argument exists only for the trait signature.

Post Settings & Media Validation

settings_schema()

CLI note: the simple rustiz posts create --channel <id> --content "..." form cannot pass provider settings. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

FieldTypeRequiredValidation / behavior
subjectstringYesminLength: 1; must be a non-empty string. Used as the campaign subject and, slugified, as the campaign name.
previewstringYesMust be a string; it may be empty. Injected into the hidden preheader block of the HTML email.
liststringYesList id as a string. Parsed to an integer at publish time; a non-numeric string becomes null.
templatestringNoTemplate id as a string. Parsed to an integer; 0, missing, null, or a non-numeric value means “use the default template” and template_id is omitted.

Example object form:

{
  "subject": "Weekly update",
  "preview": "Three things worth reading",
  "list": "3",
  "template": "2"
}

MCP settings array:

{
  "settings": [
    { "key": "subject", "value": "Weekly update" },
    { "key": "preview", "value": "Three things worth reading" },
    { "key": "list", "value": "3" },
    { "key": "template", "value": "2" }
  ]
}

The engine adds "__type": "listmonk" automatically if missing.

validate_settings() rules

  1. subject must be a non-empty string.
  2. preview must be a string (empty allowed).
  3. list must be a string. There is no numeric check, so "abc" passes validation and becomes a null list at publish time.
  4. template, if present and not null, must be a string. A non-numeric string passes validation and is silently ignored at publish time.

Publish behavior

  1. POST /api/campaigns with:
    • name: slugify(subject) — Rustiz’s own slugifier maps & to and, | to or, < to less, > to greater, lowercases alphanumerics, and keeps Unicode letters/digits (unlike npm slugify strict mode).
    • type: "regular";
    • content_type: "html";
    • subject: the raw subject;
    • lists: [<list id>], or [null] if the id could not be parsed;
    • body: a fixed HTML wrapper with a hidden preheader, 18 repetitions of the zero-width padding sequence, and <div class="content">message</div>;
    • template_id: included only when a non-zero template id was parsed.
  2. Reads data.uuid as the post id and data.id as the campaign id.
  3. PUT /api/campaigns/{id}/status with {"status":"running"}.
  4. Returns post_id = uuid and release_url = {instance}/api/campaigns/{id}/preview.

The preview URL requires Listmonk authentication; it is not a public newsletter URL.

Media requirements

  • Listmonk has no media upload endpoint in this provider. inline_images = true means Rustiz’s HTML sanitizer keeps <img> tags in the content and allows the img tag, so images must be public URLs in the HTML body.
  • The post media / attachments array is ignored.
  • check_validity is the trait default (Ok(None)); there are no provider-specific media checks.
  • Listmonk templates may rewrite or drop images depending on the selected template; the default template renders the campaign body.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • The campaign starts immediately. post() does not send send_at and does not leave the campaign as a draft. It creates the campaign and then sets status to running. Rustiz’s scheduled date only decides when the publish job calls Listmonk; once called, sending begins.
  • Non-idempotent create + start. If the create succeeds and the status call fails, the campaign remains a draft and the post errors. A retry creates a second campaign. Check Listmonk for a draft campaign before retrying a failed ListMonk post.
  • LISTMONK_BASE_URL is a global override. In a deployment with multiple Listmonk instances, setting it makes every channel talk to the same host. Prefer leaving it unset and storing the URL per channel.
  • The Basic-auth value is stored as both token and refresh token. authenticate() sets access_token = refresh_token = base64(username:password), with a 100-year expiry. post() ignores the passed token and decrypts the instance details again, so the stored token is mostly informational.
  • Credentials are encrypted, not hashed. The URL, username and token are recoverable with JWT_SECRET. Protect the secret like a database key.
  • list and template ids are strings in settings but integers on the wire. A non-numeric list passes validation and produces lists: [null]. Listmonk will normally reject that campaign; validate the id before scheduling.
  • Tools show only the first page. Neither list() nor templates() passes pagination parameters. A Listmonk instance with many lists or templates will not show all of them; the editor may need the id entered manually.
  • Preview URL needs auth. The returned releaseURL is an API preview endpoint, not a public archive link. Opening it in a browser without a Listmonk session returns 401/403.
  • Campaign name can be empty. slugify("") yields an empty string; the subject validator prevents an empty subject, but a subject made only of punctuation can still produce an empty name. Listmonk may reject it.
  • inline_images only affects sanitization. It does not upload images or rewrite relative paths. Use absolute public URLs in the HTML.
  • No analytics. Listmonk has campaign analytics, but this provider does not implement analytics() or post_analytics(); the flags are false.
  • No refresh. Credentials never expire. A 401/403 means the API user, token or permissions changed; reconnect.
  • Rate limiting is deployment-specific. Listmonk itself may return 429 behind a reverse proxy or rate-limit middleware; Rustiz’s generic HTTP layer retries 429/500 up to three times with the configured delay.

References