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
| Property | Value |
|---|---|
| Identifier | listmonk |
| Display name | ListMonk |
| Connection mode | customFields (instance URL, API user, API token) |
| Editor | html |
| Channel kind | social (email campaign) |
| Comments / threads | No. supports_comments = false; only the first PostDetails is used |
| Pending / async publishing | No. supports_pending = false; returns PostStatus::Completed immediately after starting the campaign |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 100 |
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 images | Yes (inline_images = true) |
| API | Listmonk 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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
JWT_SECRET | No, 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_URL | No | the channel’s stored URL | Global 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_NETWORK | No | false | Global 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.
-
Sign in to Listmonk as an admin.
-
Create a user role with the permissions Rustiz needs. In Listmonk v4+ go to Admin → Users → User roles → New:
settings:get—authenticate()callsGET /api/settings.lists:get_all— thelisttool callsGET /api/lists. Alternatively, attach a List role with at least View access to the target list.templates:get— thetemplatestool callsGET /api/templates.campaigns:manage(orcampaigns:manage_all) — create the campaign.campaigns:send— start the campaign. This is a separate permission and is required even if the role hascampaigns:manage_all.
-
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. -
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.
-
Confirm the instance is reachable from the Rustiz server. HTTPS is strongly recommended; Basic auth sends the token on every request.
-
Verify the credentials:
curl -s -u 'rustiz_api:TOKEN' \ https://listmonk.example.com/api/settingsA 200 with a
data.app.site_namefield 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/listsandGET /api/templatesare paginated by Listmonk. Rustiz does not passpage/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 nosend_atscheduling 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=trueon 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:
| Key | Type | Validation regex | Notes |
|---|---|---|---|
url | text | `/^(https?://)(?:\S+(?::\S*)?@)?(?:(?:localhost) | (?:\d{1,3}(?:.\d{1,3}){3}) |
username | text | /^.+$/ | Listmonk API user name, not the admin login. |
password | password | /^.{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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
list | none | [{"id": <number>, "name": "..."}] | Calls GET /api/lists, reads data.results. Only the default first page. Requires lists:get_all or a list role. |
templates | none | [{"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. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
subject | string | Yes | minLength: 1; must be a non-empty string. Used as the campaign subject and, slugified, as the campaign name. |
preview | string | Yes | Must be a string; it may be empty. Injected into the hidden preheader block of the HTML email. |
list | string | Yes | List id as a string. Parsed to an integer at publish time; a non-numeric string becomes null. |
template | string | No | Template 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
subjectmust be a non-empty string.previewmust be a string (empty allowed).listmust be a string. There is no numeric check, so"abc"passes validation and becomes anulllist at publish time.template, if present and notnull, must be a string. A non-numeric string passes validation and is silently ignored at publish time.
Publish behavior
POST /api/campaignswith:name:slugify(subject)— Rustiz’s own slugifier maps&toand,|toor,<toless,>togreater, lowercases alphanumerics, and keeps Unicode letters/digits (unlike npmslugifystrict 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.
- Reads
data.uuidas the post id anddata.idas the campaign id. PUT /api/campaigns/{id}/statuswith{"status":"running"}.- Returns
post_id = uuidandrelease_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 = truemeans Rustiz’s HTML sanitizer keeps<img>tags in the content and allows theimgtag, so images must be public URLs in the HTML body. - The post
media/attachmentsarray is ignored. check_validityis 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 sendsend_atand does not leave the campaign as a draft. It creates the campaign and then sets status torunning. 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_URLis 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()setsaccess_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. listandtemplateids are strings in settings but integers on the wire. A non-numericlistpasses validation and produceslists: [null]. Listmonk will normally reject that campaign; validate the id before scheduling.- Tools show only the first page. Neither
list()nortemplates()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
releaseURLis 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; thesubjectvalidator prevents an empty subject, but a subject made only of punctuation can still produce an empty name. Listmonk may reject it. inline_imagesonly 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()orpost_analytics(); the flags arefalse. - 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
- Listmonk API introduction (Basic auth with API users, response envelope, error codes): https://listmonk.app/docs/apis/apis/
- Listmonk campaigns API (create, status transitions, preview): https://listmonk.app/docs/apis/campaigns/
- Listmonk user roles and permissions (including
campaigns:send): https://listmonk.app/docs/roles-and-permissions - Postiz provider settings: https://docs.postiz.com/public-api/providers/listmonk
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview