42 KiB
ListmonkPage
Overview
The ListmonkPage provides administrative management of the Listmonk newsletter integration, offering a dual-view interface with management controls (sync, status monitoring) on one tab and an embedded Listmonk admin interface on another. It enables synchronization of campaign participants, map locations, and users to Listmonk subscriber lists, monitors connection status, displays list statistics with subscriber counts, and provides advanced operations like reinitialization and connection testing. The embedded admin tab loads the full Listmonk web UI via iframe with auto-authentication, allowing direct management of campaigns, subscribers, and templates without leaving the admin interface.
Route: /app/listmonk
Component: admin/src/pages/ListmonkPage.tsx (395 lines)
Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended)
Layout: AppLayout
Backend Module: api/src/modules/listmonk/
Screenshot
[Screenshot: ListmonkPage with "Newsletter / Listmonk" title. Right side has tab switcher (Management selected, Listmonk Admin grayed), "Test Connection" button, and "Open Listmonk" button (opens in new tab). Below are two cards side-by-side: "Status" card shows Sync Enabled (green checkmark), Connection (green Connected), Lists Initialized (green Yes), Last Sync (2 minutes ago), Last Error (None). "Sync Actions" card has 4 buttons in 2×2 grid: "Sync Participants", "Sync Locations", "Sync Users", "Sync All" (primary blue). Below is "List Statistics" card with table showing List Name column (Participants, Locations, Users) and Subscribers column (347, 203, 15). At bottom is collapsed "Advanced" section. When "Listmonk Admin" tab selected, full Listmonk UI loads in iframe with dark theme, showing campaigns list, subscribers count, and send email button.]
Features
- Dual-view interface — Tab switcher between Management and Listmonk Admin
- Status monitoring — Real-time sync status, connection state, initialization status
- Selective synchronization — Sync participants, locations, or users individually
- Bulk synchronization — Sync all lists at once
- Connection testing — Test Listmonk API connectivity before syncing
- List statistics — Subscriber counts for each list (Participants, Locations, Users)
- Advanced operations — Reinitialize lists if corrupted or missing
- Embedded Listmonk admin — Full Listmonk UI loaded in iframe with auto-authentication
- External Listmonk access — Open Listmonk in new tab (direct access on port 9001)
- Error reporting — Display last sync error with timestamp
- Last sync tracking — Relative time since last successful sync
- Sync failure counts — Track failed subscriber additions (shown in warnings)
- Fullbleed iframe — Listmonk Admin tab removes padding for full-screen experience
User Workflow
Checking Sync Status
- Navigate to
/app/listmonk - Ensure "Management" tab is selected (default)
- Observe "Status" card (left column):
- Sync Enabled: Badge shows Enabled (green) or Disabled (red)
- Connection: Badge shows Connected (green), Disconnected (orange), or N/A (gray)
- Lists Initialized: Badge shows Yes (green) or No (gray)
- Last Sync: Relative time (e.g., "2 minutes ago") or "Never"
- Last Error: Error message or "None"
- Check "List Statistics" table:
- Participants: Subscriber count for campaign participants
- Locations: Subscriber count for map locations
- Users: Subscriber count for user accounts
Sync Enabled States:
- Enabled (green):
LISTMONK_SYNC_ENABLED=truein .env, sync operations allowed - Disabled (red):
LISTMONK_SYNC_ENABLED=falsein .env, sync operations blocked
Connection States:
- Connected (green): Listmonk API reachable, credentials valid
- Disconnected (orange): Listmonk API unreachable or credentials invalid (only shown if sync enabled)
- N/A (gray): Sync disabled, connection not tested
Lists Initialized:
- Yes (green): Listmonk lists (Participants, Locations, Users) exist and ready
- No (gray): Lists not yet created (click "Reinitialize Lists" to create)
Testing Listmonk Connection
When to Test:
- Before first sync (verify credentials)
- After updating Listmonk URL/credentials
- Troubleshooting sync failures
Steps:
- Click "Test Connection" button (top-right header)
- Loading spinner appears on button
- Backend tests Listmonk API connection:
- GET
/api/healthendpoint - Verifies basic auth credentials
- Checks API version compatibility
- GET
- Result message appears:
- Success: "Connection successful" (green toast)
- Warning: "Connection partially successful - check configuration" (orange toast)
- Error: "Connection failed - check Listmonk URL and credentials" (red toast)
- Status card refreshes to show updated connection state
Success Criteria:
- Listmonk API responds to /api/health endpoint
- Credentials (username/password) authenticate successfully
- API version is compatible (v2.0+)
Syncing Participants to Listmonk
What is "Participants"?
Campaign participants who submitted responses via the response wall. Synced to Listmonk "Participants" list for newsletter targeting.
Steps:
- Click "Sync Participants" button in "Sync Actions" card
- Loading spinner appears on button
- Backend fetches all campaign participants from database:
- Query:
SELECT DISTINCT email, name FROM Response WHERE verified = true - Filter: Only verified responses (email confirmed)
- Query:
- For each participant:
- Check if subscriber exists in Listmonk "Participants" list
- If not exists, create new subscriber with name and email
- If exists, update subscriber attributes (last campaign, response count)
- Result message appears:
- Success: "Synced participants: 347 created, 23 updated"
- Warning: "Synced participants: 347 created, 23 updated, 5 failed - check logs"
- Status card and list statistics update to show new counts
Sync Logic:
// Fetch participants from database
const participants = await prisma.response.findMany({
where: { verified: true },
distinct: ['email'],
select: { email: true, name: true, campaignId: true },
});
// For each participant
for (const participant of participants) {
try {
// Check if subscriber exists
const existingSubscriber = await listmonkClient.getSubscriberByEmail(participant.email);
if (existingSubscriber) {
// Update existing subscriber
await listmonkClient.updateSubscriber(existingSubscriber.id, {
name: participant.name,
attribs: { lastCampaign: participant.campaignId },
});
updated++;
} else {
// Create new subscriber
await listmonkClient.createSubscriber({
email: participant.email,
name: participant.name,
lists: [participantsListId],
attribs: { source: 'campaign_response' },
});
created++;
}
} catch (error) {
failed++;
}
}
Syncing Locations to Listmonk
What is "Locations"?
Map locations (residential addresses, campaign offices, etc.). Synced to Listmonk "Locations" list for geographic targeting.
Steps:
- Click "Sync Locations" button in "Sync Actions" card
- Loading spinner appears on button
- Backend fetches all locations with valid email addresses:
- Query:
SELECT * FROM Location WHERE email IS NOT NULL AND deletedAt IS NULL - Filter: Only locations with email, not soft-deleted
- Query:
- For each location:
- Check if subscriber exists in Listmonk "Locations" list
- If not exists, create new subscriber with address details
- If exists, update subscriber attributes (address, postal code, cut)
- Result message appears:
- Success: "Synced locations: 203 created, 45 updated"
- Status card and list statistics update
Subscriber Attributes:
{
email: location.email,
name: location.name || location.address, // Fallback to address if no name
lists: [locationsListId],
attribs: {
address: location.address,
postalCode: location.postalCode,
city: location.city,
cutId: location.cutId,
province: location.province,
},
}
Syncing Users to Listmonk
What is "Users"?
User accounts (admins, volunteers, etc.). Synced to Listmonk "Users" list for internal communications.
Steps:
- Click "Sync Users" button in "Sync Actions" card
- Loading spinner appears on button
- Backend fetches all user accounts:
- Query:
SELECT * FROM User WHERE deletedAt IS NULL - Filter: Exclude soft-deleted users
- Query:
- For each user:
- Check if subscriber exists in Listmonk "Users" list
- If not exists, create new subscriber with role info
- If exists, update subscriber attributes (role, last login)
- Result message appears:
- Success: "Synced users: 15 created, 3 updated"
- Status card and list statistics update
Subscriber Attributes:
{
email: user.email,
name: user.name,
lists: [usersListId],
attribs: {
role: user.role,
lastLogin: user.lastLogin,
},
}
Syncing All Lists at Once
When to Use:
- Initial setup (populate all lists)
- After bulk data import (NAR import, CSV import)
- Regular maintenance (weekly/monthly sync)
Steps:
- Click "Sync All" button (primary blue, bottom-right of "Sync Actions" card)
- Loading spinner appears on button
- Backend syncs all three lists sequentially:
- First: Sync participants
- Second: Sync locations
- Third: Sync users
- Result message shows aggregated counts:
- Success: "Synced all lists: 347 participants, 203 locations, 15 users"
- Warning: "Synced all lists: 565 total, 8 failed - check logs"
- Status card and list statistics update to show all new counts
Performance:
- Sequential execution: Lists synced one at a time (not parallel)
- Duration: Typically 10-30 seconds for 500+ subscribers
- Idempotent: Safe to run multiple times (creates or updates, no duplicates)
Reinitializing Listmonk Lists
When to Reinitialize:
- Lists accidentally deleted in Listmonk
- Fresh Listmonk installation
- Corrupted list data
Steps:
- Scroll to "Advanced" section at bottom
- Click to expand "Advanced" collapse panel
- Click "Reinitialize Lists" button
- Confirmation popconfirm appears: "Reinitialize Lists. This will re-create any missing lists in Listmonk. Existing lists are preserved."
- Click "Reinitialize" to confirm (or click outside to cancel)
- Loading spinner appears on button
- Backend checks for existence of each list (Participants, Locations, Users)
- For each missing list:
- Create new list with name and type (public/private)
- Set list description
- Success message: "Lists reinitialized" (or error if creation fails)
- Status card updates to show "Lists Initialized: Yes"
Important: Reinitialization only creates missing lists. Existing lists are NOT deleted or modified. Existing subscribers remain intact.
Accessing Embedded Listmonk Admin
What is "Listmonk Admin" Tab?
Full Listmonk web UI embedded in iframe, allowing direct management without leaving admin interface.
Steps:
- Click "Listmonk Admin" button in tab switcher (top-right header)
- Active tab changes from "Management" to "Listmonk Admin"
- Page layout changes to fullbleed (removes padding for full-screen iframe)
- Loading spinner appears while iframe loads
- Backend generates auto-authentication token:
- GET
/api/listmonk/proxy-url - Response:
{ port: 9001, token: "auto-auth-token-xyz" }
- GET
- Iframe loads Listmonk URL with auth token:
- URL:
//localhost:9001/auth?token=auto-auth-token-xyz - Listmonk auto-authenticates user (no manual login required)
- URL:
- Full Listmonk UI appears in iframe:
- Dashboard (campaign stats, subscriber counts)
- Campaigns (create/send newsletters)
- Subscribers (view/edit/import)
- Lists (manage subscriber lists)
- Templates (email templates with WYSIWYG editor)
Use Cases:
- Create newsletter campaigns
- View/edit subscribers directly
- Import subscribers from CSV
- Design email templates
- View campaign analytics
Limitations:
- Iframe may have slight performance overhead vs. direct access
- Some Listmonk features may require full-screen (use "Open Listmonk" button instead)
Opening Listmonk in New Tab
When to Use:
- Full-screen Listmonk access (no iframe constraints)
- Better performance (no iframe overhead)
- Working with large subscriber lists (better scrolling)
Steps:
- Click "Open Listmonk" button (top-right header, next to "Test Connection")
- New browser tab opens with Listmonk URL:
//localhost:9001 - Listmonk login page appears (if not already logged in)
- Enter Listmonk admin credentials:
- Username: Value of
LISTMONK_WEB_ADMIN_USERenv var - Password: Value of
LISTMONK_WEB_ADMIN_PASSWORDenv var
- Username: Value of
- Click "Login" to access full Listmonk interface
Note: This opens Listmonk directly on port 9001. User must manually authenticate (no auto-auth token). Use this for full-featured access without iframe restrictions.
Component Breakdown
Ant Design Components Used
- Typography.Text — Labels, descriptions
- Row / Col — Grid layout for status and sync action cards
- Card — Container for Status, Sync Actions, List Statistics
- Descriptions — Key-value pairs in Status card
- Descriptions.Item — Individual status fields
- Badge — Status indicators (Enabled/Disabled, Connected/Disconnected, Yes/No)
- Space — Button grouping
- Button — Sync buttons, Test Connection, Open Listmonk
- Radio.Group — Tab switcher (Management / Listmonk Admin)
- Radio.Button — Individual tab buttons
- Table — List statistics table
- Collapse — Advanced section (collapsible)
- Popconfirm — Reinitialize confirmation dialog
- Alert — Iframe error alert (if load fails)
- Spin — Loading indicators (initial load, iframe load, button actions)
- App.useApp — Access to message and modal contexts
- message — Toast notifications for success/error feedback
Dual-View Tab Switcher
<Radio.Group
value={activeTab}
onChange={(e) => {
const tab = e.target.value as 'management' | 'admin';
setActiveTab(tab);
if (tab === 'admin') loadIframe(); // Lazy-load iframe
}}
optionType="button"
buttonStyle="solid"
size="small"
>
<Radio.Button value="management">
<SettingOutlined /> Management
</Radio.Button>
<Radio.Button value="admin">
<DesktopOutlined /> Listmonk Admin
</Radio.Button>
</Radio.Group>
Tab Switcher Features:
- Button style: Solid background for selected tab (more prominent than default)
- Icons: Visual indicators (Settings for Management, Desktop for Admin)
- Lazy loading: Iframe only loads when Admin tab selected (performance optimization)
- Size small: Compact header controls
Status Card
<Card title="Status" size="small">
<Descriptions column={1} size="small">
<Descriptions.Item label="Sync Enabled">
<Badge
status={status?.enabled ? 'success' : 'error'}
text={status?.enabled ? 'Enabled' : 'Disabled'}
/>
</Descriptions.Item>
<Descriptions.Item label="Connection">
<Badge
status={status?.connected ? 'success' : status?.enabled ? 'warning' : 'default'}
text={status?.connected ? 'Connected' : status?.enabled ? 'Disconnected' : 'N/A'}
/>
</Descriptions.Item>
<Descriptions.Item label="Lists Initialized">
<Badge
status={status?.initialized ? 'success' : 'default'}
text={status?.initialized ? 'Yes' : 'No'}
/>
</Descriptions.Item>
<Descriptions.Item label="Last Sync">
{status?.lastSyncAt ? dayjs(status.lastSyncAt).fromNow() : 'Never'}
</Descriptions.Item>
<Descriptions.Item label="Last Error">
{status?.lastError || 'None'}
</Descriptions.Item>
</Descriptions>
</Card>
Status Badge Colors:
- Success (green dot): Enabled, Connected, Initialized=Yes
- Error (red dot): Disabled
- Warning (orange dot): Enabled but Disconnected
- Default (gray dot): N/A (sync disabled), Initialized=No
Sync Actions Card
<Card title="Sync Actions" size="small">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
<Button
block
icon={<SyncOutlined />}
loading={syncing.participants}
onClick={() => handleSync('participants')}
disabled={!status?.enabled}
>
Sync Participants
</Button>
</Col>
<Col xs={24} sm={12}>
<Button
block
icon={<SyncOutlined />}
loading={syncing.locations}
onClick={() => handleSync('locations')}
disabled={!status?.enabled}
>
Sync Locations
</Button>
</Col>
<Col xs={24} sm={12}>
<Button
block
icon={<SyncOutlined />}
loading={syncing.users}
onClick={() => handleSync('users')}
disabled={!status?.enabled}
>
Sync Users
</Button>
</Col>
<Col xs={24} sm={12}>
<Button
block
type="primary"
icon={<SyncOutlined />}
loading={syncing.all}
onClick={handleSyncAll}
disabled={!status?.enabled}
>
Sync All
</Button>
</Col>
</Row>
</Space>
</Card>
Sync Actions Features:
- Block buttons: Full-width buttons for easy clicking
- 2×2 grid: Responsive layout (stacked on mobile, side-by-side on desktop)
- Individual loading states: Each button has its own loading spinner
- Disabled state: Buttons disabled if sync not enabled in .env
- Primary styling: "Sync All" button uses primary blue (most common action)
List Statistics Table
<Table
dataSource={stats?.lists || []}
rowKey="name"
size="small"
loading={loading}
pagination={false}
columns={[
{ title: 'List Name', dataIndex: 'name', key: 'name' },
{
title: 'Subscribers',
dataIndex: 'subscriberCount',
key: 'subscriberCount',
width: 120,
align: 'right' as const,
},
]}
locale={{
emptyText: status?.initialized
? 'No lists found'
: 'Lists not initialized — run a sync or reinitialize',
}}
/>
Table Features:
- Small size: Compact rows for dashboard-style display
- No pagination: Only 3 lists (Participants, Locations, Users), always fit on one page
- Right-aligned numbers: Subscriber counts right-aligned for easier comparison
- Custom empty text: Different message if lists not initialized vs. genuinely empty
Embedded Listmonk Iframe
{activeTab === 'admin' && (
<div>
{iframeLoading && (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
)}
{iframeError && (
<Alert
type="error"
message={iframeError}
showIcon
action={
<Button size="small" onClick={loadIframe}>
Retry
</Button>
}
style={{ marginBottom: 16 }}
/>
)}
{iframeSrc && !iframeLoading && (
<iframe
src={iframeSrc}
style={{
width: '100%',
height: 'calc(100vh - 64px)', // Full viewport height minus header
border: 'none',
display: 'block',
}}
title="Listmonk Admin"
/>
)}
</div>
)}
Iframe Features:
- Full viewport height:
calc(100vh - 64px)fills available space - No border: Seamless integration with admin interface
- Loading state: Large spinner while iframe loads
- Error handling: Alert with retry button if iframe fails to load
- Auto-authentication: Token in URL query string (
?token=xyz) logs user in automatically
State Management
Local State (No Zustand Store)
const [status, setStatus] = useState<ListmonkStatus | null>(null);
const [stats, setStats] = useState<ListmonkStats | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState<Record<string, boolean>>({});
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const [iframeLoading, setIframeLoading] = useState(false);
const [iframeError, setIframeError] = useState<string | null>(null);
const iframeInitialized = useRef(false);
const [activeTab, setActiveTab] = useState<'management' | 'admin'>('management');
State Variables:
status(object | null): Sync status (enabled, connected, initialized, lastSyncAt, lastError)stats(object | null): List statistics (lists array with name and subscriberCount)loading(boolean): Initial page load statesyncing(object): Sync button loading states (participants, locations, users, all, test, reinit)iframeSrc(string | null): Listmonk iframe URL with auth tokeniframeLoading(boolean): Iframe loading stateiframeError(string | null): Iframe load error messageiframeInitialized(ref): Prevents redundant iframe loads (only load once)activeTab(string): Currently active tab ('management' or 'admin')
No Global State:
This page does NOT use Zustand stores. Listmonk data is fetched directly from the API and stored in local state. This is appropriate because:
- Listmonk data is admin-only
- Data changes infrequently (manual sync operations)
- No need to share state between pages
- Simpler architecture without store overhead
Lazy Iframe Loading
Iframe only loads when Admin tab is selected:
const loadIframe = useCallback(async () => {
if (iframeInitialized.current && iframeSrc) return; // Already loaded, skip
setIframeLoading(true);
setIframeError(null);
try {
const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
const { port, token } = res.data;
const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;
setIframeSrc(url);
iframeInitialized.current = true; // Mark as initialized
} catch {
setIframeError('Failed to load Listmonk admin — ensure the proxy is running');
} finally {
setIframeLoading(false);
}
}, [iframeSrc]);
// Load iframe when Admin tab selected
onChange={(e) => {
const tab = e.target.value as 'management' | 'admin';
setActiveTab(tab);
if (tab === 'admin') loadIframe();
}}
Why Lazy Load?
- Performance: Iframe not loaded until needed (saves memory + network)
- User experience: Most users stay on Management tab (no iframe overhead)
- One-time load:
iframeInitializedref prevents redundant loads
Fullbleed Layout for Iframe
When Admin tab is active, page header sets fullBleed: true:
useEffect(() => {
setPageHeader({
title: 'Newsletter / Listmonk',
actions: headerActions,
fullBleed: activeTab === 'admin', // Remove padding for full-screen iframe
});
return () => setPageHeader(null);
}, [setPageHeader, headerActions, activeTab]);
Result:
- Management tab: Normal padding (comfortable reading)
- Admin tab: No padding (iframe fills entire content area)
API Integration
Endpoints Used
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
| GET | /api/listmonk |
Get sync status | Required |
| GET | /api/listmonk/stats |
Get list statistics | Required |
| POST | /api/listmonk/test-connection |
Test Listmonk API connection | Required |
| POST | /api/listmonk/sync/participants |
Sync participants list | Required |
| POST | /api/listmonk/sync/locations |
Sync locations list | Required |
| POST | /api/listmonk/sync/users |
Sync users list | Required |
| POST | /api/listmonk/sync/all |
Sync all lists | Required |
| POST | /api/listmonk/reinitialize |
Reinitialize lists | Required |
| GET | /api/listmonk/proxy-url |
Get iframe URL with auth token | Required |
Load Sync Status
Request:
const { data } = await api.get<ListmonkStatus>('/listmonk');
Response (200 OK):
{
"enabled": true,
"connected": true,
"initialized": true,
"lastSyncAt": "2026-02-11T10:30:00.000Z",
"lastError": null
}
Response Fields:
enabled(boolean): Value ofLISTMONK_SYNC_ENABLEDenv varconnected(boolean): Listmonk API reachable and credentials validinitialized(boolean): Listmonk lists (Participants, Locations, Users) existlastSyncAt(ISO 8601 | null): Timestamp of last successful synclastError(string | null): Last error message (or null if no errors)
Load List Statistics
Request:
const { data } = await api.get<ListmonkStats>('/listmonk/stats');
Response (200 OK):
{
"lists": [
{
"name": "Participants",
"subscriberCount": 347
},
{
"name": "Locations",
"subscriberCount": 203
},
{
"name": "Users",
"subscriberCount": 15
}
]
}
Response Fields:
lists(array): Array of list objectsname(string): List name (Participants, Locations, or Users)subscriberCount(number): Number of subscribers in list
Backend Calculation:
const lists = await listmonkClient.getLists();
const stats = await Promise.all(
lists.map(async (list) => {
const count = await listmonkClient.getSubscriberCount(list.id);
return { name: list.name, subscriberCount: count };
})
);
return { lists: stats };
Test Listmonk Connection
Request:
const { data } = await api.post<{ success: boolean; message: string }>('/listmonk/test-connection');
Response (200 OK) - Success:
{
"success": true,
"message": "Connection successful - Listmonk v2.3.0"
}
Response (200 OK) - Failure:
{
"success": false,
"message": "Connection failed: Authentication error"
}
Response Fields:
success(boolean): Whether connection test passedmessage(string): Result message (success details or error reason)
Backend Test:
try {
// Test Listmonk API health endpoint
const health = await listmonkClient.getHealth();
if (health.version) {
return { success: true, message: `Connection successful - Listmonk v${health.version}` };
} else {
return { success: false, message: 'Connection failed: Invalid response' };
}
} catch (error) {
return { success: false, message: `Connection failed: ${error.message}` };
}
Sync Participants/Locations/Users
Request:
const type = 'participants'; // or 'locations' or 'users'
const { data } = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);
Response (200 OK):
{
"success": true,
"message": "Synced participants: 347 created, 23 updated",
"results": {
"created": 347,
"updated": 23,
"failed": 5
}
}
Response Fields:
success(boolean): Whether sync operation completedmessage(string): Result summaryresults(object):created(number): New subscribers addedupdated(number): Existing subscribers updatedfailed(number): Subscribers that failed to sync (API errors, validation errors)
Backend Workflow:
// 1. Fetch data from database
const participants = await prisma.response.findMany({
where: { verified: true },
distinct: ['email'],
});
// 2. Sync to Listmonk
const results = { created: 0, updated: 0, failed: 0 };
for (const participant of participants) {
try {
const existing = await listmonkClient.getSubscriberByEmail(participant.email);
if (existing) {
await listmonkClient.updateSubscriber(existing.id, { /* ... */ });
results.updated++;
} else {
await listmonkClient.createSubscriber({ /* ... */ });
results.created++;
}
} catch (error) {
results.failed++;
}
}
// 3. Update last sync timestamp
await prisma.listmonkStatus.update({
where: { id: 'singleton' },
data: { lastSyncAt: new Date() },
});
return { success: true, message: `Synced participants: ${results.created} created, ${results.updated} updated`, results };
Sync All Lists
Request:
const { data } = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');
Response (200 OK):
{
"success": true,
"message": "Synced all lists: 347 participants, 203 locations, 15 users",
"results": {
"participants": {
"created": 347,
"updated": 23,
"failed": 5
},
"locations": {
"created": 203,
"updated": 12,
"failed": 2
},
"users": {
"created": 15,
"updated": 3,
"failed": 1
}
}
}
Response Fields:
success(boolean): Whether all syncs completedmessage(string): Result summaryresults(object):participants(object): Participant sync resultslocations(object): Location sync resultsusers(object): User sync results
Reinitialize Lists
Request:
await api.post('/listmonk/reinitialize');
Response (200 OK):
{
"message": "Lists reinitialized"
}
Backend Workflow:
// 1. Check for existence of each list
const lists = await listmonkClient.getLists();
const participantsList = lists.find(l => l.name === 'Participants');
const locationsList = lists.find(l => l.name === 'Locations');
const usersList = lists.find(l => l.name === 'Users');
// 2. Create missing lists
if (!participantsList) {
await listmonkClient.createList({
name: 'Participants',
type: 'public',
description: 'Campaign participants from response wall',
});
}
if (!locationsList) {
await listmonkClient.createList({
name: 'Locations',
type: 'public',
description: 'Map locations with email addresses',
});
}
if (!usersList) {
await listmonkClient.createList({
name: 'Users',
type: 'private',
description: 'User accounts (admins, volunteers)',
});
}
// 3. Update initialization status
await prisma.listmonkStatus.update({
where: { id: 'singleton' },
data: { initialized: true },
});
return { message: 'Lists reinitialized' };
Get Iframe Proxy URL
Request:
const { data } = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
Response (200 OK):
{
"port": 9001,
"token": "auto-auth-token-abc123xyz"
}
Response Fields:
port(number): Listmonk service port (typically 9001)token(string): Auto-authentication token (valid for 5 minutes)
Backend Workflow:
// 1. Generate auto-authentication token
const token = crypto.randomBytes(32).toString('hex');
// 2. Store token in Redis with 5-minute expiry
await redis.set(`listmonk-auth:${token}`, 'admin', 'EX', 300);
// 3. Return port and token
return {
port: process.env.LISTMONK_PORT || 9001,
token,
};
Listmonk Auto-Authentication:
Listmonk checks Redis for token when /auth?token=xyz is accessed:
// Listmonk auth handler
router.get('/auth', async (req, res) => {
const { token } = req.query;
// Verify token in Redis
const userId = await redis.get(`listmonk-auth:${token}`);
if (userId) {
// Auto-login user
req.session.userId = userId;
res.redirect('/admin');
} else {
res.status(401).send('Invalid or expired token');
}
});
Code Examples
Complete Sync Flow
const handleSync = async (type: 'participants' | 'locations' | 'users') => {
setSyncing(s => ({ ...s, [type]: true })); // Set loading state for specific button
try {
const res = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);
// Show success or warning message
if (res.data.success) {
message.success(res.data.message);
// Show warning if some failed
if (res.data.results && res.data.results.failed > 0) {
message.warning(`${res.data.results.failed} failed — check logs for details`);
}
} else {
message.error(res.data.message);
}
// Refresh status and stats
await Promise.all([fetchStatus(), fetchStats()]);
} catch {
message.error(`Failed to sync ${type}`);
} finally {
setSyncing(s => ({ ...s, [type]: false })); // Clear loading state
}
};
Key Steps:
- Set loading state for specific button (participants, locations, or users)
- Send POST request to sync endpoint
- Show success message
- Show warning if some subscribers failed to sync
- Refresh status and stats to show updated counts
- Handle errors gracefully
- Always clear loading state in finally block
Sync All Flow
const handleSyncAll = async () => {
setSyncing(s => ({ ...s, all: true }));
try {
const res = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');
if (res.data.success) {
message.success(res.data.message);
// Show warning if any failed
if (res.data.results) {
const { participants, locations, users } = res.data.results;
const totalFailed = participants.failed + locations.failed + users.failed;
if (totalFailed > 0) {
message.warning(`${totalFailed} total failures — check logs for details`);
}
}
} else {
message.error(res.data.message);
}
await Promise.all([fetchStatus(), fetchStats()]);
} catch {
message.error('Failed to sync all');
} finally {
setSyncing(s => ({ ...s, all: false }));
}
};
Aggregate Failure Count:
Sums failed count from all three lists (participants + locations + users) to show total failures.
Lazy Iframe Loading
const iframeInitialized = useRef(false);
const loadIframe = useCallback(async () => {
if (iframeInitialized.current && iframeSrc) return; // Already loaded
setIframeLoading(true);
setIframeError(null);
try {
const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
const { port, token } = res.data;
const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;
setIframeSrc(url);
iframeInitialized.current = true;
} catch {
setIframeError('Failed to load Listmonk admin — ensure the proxy is running');
} finally {
setIframeLoading(false);
}
}, [iframeSrc]);
Lazy Loading Logic:
- First call:
iframeInitialized.currentis false, so iframe loads - Subsequent calls:
iframeInitialized.currentis true, so function returns early (no redundant loads) - Ref persists:
useRefvalue persists across re-renders (unlike state)
Performance Considerations
Lazy Iframe Loading
Iframe only loads when Admin tab is selected:
if (tab === 'admin') loadIframe();
Performance Impact:
- Without lazy loading: Iframe loads on page mount (even if user never switches to Admin tab)
- With lazy loading: Iframe only loads if needed
- Result: Faster initial page load, reduced memory usage
Parallel Status and Stats Fetching
Status and stats fetched in parallel:
const fetchAll = useCallback(async () => {
setLoading(true);
await Promise.all([fetchStatus(), fetchStats()]);
setLoading(false);
}, [fetchStatus, fetchStats]);
Performance Impact:
- Sequential: 200ms (status) + 200ms (stats) = 400ms total
- Parallel: max(200ms, 200ms) = 200ms total
- Result: 2× faster initial page load
Conditional Iframe Rendering
Iframe not rendered until tab is selected:
{activeTab === 'admin' && (
<iframe src={iframeSrc} />
)}
Performance Impact:
- Always rendered: Iframe exists in DOM even when hidden (consumes memory)
- Conditional: Iframe only exists in DOM when visible (no memory overhead)
Responsive Design
Mobile Sync Actions Layout
Sync action buttons adapt to mobile viewports:
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}> {/* Full width mobile, half width desktop */}
<Button block>Sync Participants</Button>
</Col>
<Col xs={24} sm={12}>
<Button block>Sync Locations</Button>
</Col>
<Col xs={24} sm={12}>
<Button block>Sync Users</Button>
</Col>
<Col xs={24} sm={12}>
<Button block>Sync All</Button>
</Col>
</Row>
Responsive Grid:
- Mobile (xs, <576px): Stacked buttons (full width)
- Desktop (sm+, ≥576px): 2×2 grid (half width each)
Iframe Height
Iframe fills available viewport height:
<iframe
src={iframeSrc}
style={{
height: 'calc(100vh - 64px)', // Full viewport height minus header
}}
/>
Calculation:
100vh: Full viewport height-64px: Subtract header height (64px)- Result: Iframe fills entire content area below header
Accessibility
Keyboard Navigation
All interactive elements are keyboard-accessible:
Tab Switcher:
- Tab: Focus on tab switcher
- Arrow Keys: Navigate between Management and Admin tabs
- Enter/Space: Activate selected tab
Buttons:
- Tab: Move between buttons (Test Connection → Sync Participants → Sync Locations...)
- Enter/Space: Activate focused button
Iframe:
- Tab: Focus moves into iframe (Listmonk UI is keyboard-accessible)
- Shift+Tab: Focus moves out of iframe back to page controls
Screen Reader Support
All elements have proper ARIA labels:
Status Badges:
<Badge
status={status?.connected ? 'success' : 'warning'}
text={status?.connected ? 'Connected' : 'Disconnected'}
aria-label={`Listmonk connection status: ${status?.connected ? 'connected' : 'disconnected'}`}
/>
Sync Buttons:
<Button
icon={<SyncOutlined />}
onClick={() => handleSync('participants')}
aria-label="Sync participants to Listmonk Participants list"
>
Sync Participants
</Button>
Color Contrast
All color-coded elements meet WCAG AA standards:
Status Badges:
- Success (green dot):
#52c41a= visible on all backgrounds - Warning (orange dot):
#faad14= visible on all backgrounds - Error (red dot):
#ff4d4f= visible on all backgrounds - Default (gray dot):
#d9d9d9= visible on all backgrounds
Troubleshooting
Sync Disabled (Buttons Grayed Out)
Problem: All sync buttons are grayed out (disabled state).
Diagnosis:
Check .env file:
grep LISTMONK_SYNC_ENABLED .env
Expected: LISTMONK_SYNC_ENABLED=true
Actual: LISTMONK_SYNC_ENABLED=false or missing
Solution:
-
Edit
.envfile:nano .env -
Add or update line:
LISTMONK_SYNC_ENABLED=true -
Restart API container:
docker compose restart api -
Refresh page to see enabled buttons
Connection Test Fails
Problem: Click "Test Connection", get error: "Connection failed - check Listmonk URL and credentials".
Diagnosis:
Check Listmonk container:
docker compose ps listmonk
Expected: STATUS = Up
Check Listmonk logs:
docker compose logs listmonk
Common errors:
ERROR: Database connection failed
ERROR: Authentication failed for user "api"
Possible Causes:
-
Listmonk container down:
- Service not running
- Failed to start due to configuration error
-
Wrong credentials:
LISTMONK_ADMIN_USERorLISTMONK_ADMIN_PASSWORDincorrect- API user not created in Listmonk database
-
Network issue:
- API container cannot reach Listmonk container
- Docker network misconfigured
Solution:
-
Start Listmonk:
docker compose up -d listmonk -
Verify credentials:
grep LISTMONK_ .envCheck that
LISTMONK_ADMIN_USERandLISTMONK_ADMIN_PASSWORDmatch Listmonk configuration. -
Test connection manually:
curl -u admin:password http://localhost:9001/api/healthExpected:
{"version":"2.3.0"}
Lists Not Initialized
Problem: Status shows "Lists Initialized: No".
Diagnosis:
Check Listmonk lists:
docker compose exec listmonk listmonk --dump-all-lists
Expected: Participants, Locations, Users lists present
Actual: No lists found
Solution:
- Click "Advanced" to expand advanced section
- Click "Reinitialize Lists" button
- Confirm reinitialize
- Wait for success message: "Lists reinitialized"
- Refresh page to see "Lists Initialized: Yes"
Iframe Not Loading
Problem: Click "Listmonk Admin" tab, but only see loading spinner or error message.
Diagnosis:
Check iframe error message in Alert:
Failed to load Listmonk admin — ensure the proxy is running
Check browser console for errors:
Refused to display 'http://localhost:9001' in a frame because it set 'X-Frame-Options' to 'SAMEORIGIN'
Possible Causes:
-
Proxy URL endpoint failed:
- API cannot generate auto-auth token
- Redis down (tokens stored in Redis)
-
X-Frame-Options blocking:
- Listmonk sets
X-Frame-Options: SAMEORIGIN - Browser blocks iframe from different origin
- Listmonk sets
-
CORS issue:
- Listmonk does not allow iframe embedding from admin domain
Solution:
-
Check Redis:
docker compose ps redis docker compose exec redis redis-cli PINGExpected: "PONG"
-
Use "Open Listmonk" button instead:
- Opens Listmonk in new tab (no iframe, no X-Frame-Options issue)
- Manual login required (no auto-auth token)
-
Configure Listmonk to allow iframes (developer fix):
- Edit Listmonk nginx config
- Remove or modify
X-Frame-Optionsheader - Restart Listmonk container
Related Documentation
- Listmonk Backend Module — Backend Listmonk service
- Listmonk Sync Service — Sync orchestration
- Listmonk Client — API client
- Listmonk API Reference — Listmonk endpoints
- Newsletter Feature — Newsletter system overview
- ResponsesPage — Campaign responses (synced to Listmonk)
- LocationsPage — Map locations (synced to Listmonk)
- UsersPage — User accounts (synced to Listmonk)
- Listmonk Documentation — Official Listmonk docs
- Troubleshooting: Listmonk Issues — Listmonk troubleshooting