42 KiB
Raw Blame History

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

  1. Navigate to /app/listmonk
  2. Ensure "Management" tab is selected (default)
  3. 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"
  4. 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=true in .env, sync operations allowed
  • Disabled (red): LISTMONK_SYNC_ENABLED=false in .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:

  1. Click "Test Connection" button (top-right header)
  2. Loading spinner appears on button
  3. Backend tests Listmonk API connection:
    • GET /api/health endpoint
    • Verifies basic auth credentials
    • Checks API version compatibility
  4. 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)
  5. 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:

  1. Click "Sync Participants" button in "Sync Actions" card
  2. Loading spinner appears on button
  3. Backend fetches all campaign participants from database:
    • Query: SELECT DISTINCT email, name FROM Response WHERE verified = true
    • Filter: Only verified responses (email confirmed)
  4. 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)
  5. Result message appears:
    • Success: "Synced participants: 347 created, 23 updated"
    • Warning: "Synced participants: 347 created, 23 updated, 5 failed - check logs"
  6. 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:

  1. Click "Sync Locations" button in "Sync Actions" card
  2. Loading spinner appears on button
  3. 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
  4. 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)
  5. Result message appears:
    • Success: "Synced locations: 203 created, 45 updated"
  6. 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:

  1. Click "Sync Users" button in "Sync Actions" card
  2. Loading spinner appears on button
  3. Backend fetches all user accounts:
    • Query: SELECT * FROM User WHERE deletedAt IS NULL
    • Filter: Exclude soft-deleted users
  4. 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)
  5. Result message appears:
    • Success: "Synced users: 15 created, 3 updated"
  6. 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:

  1. Click "Sync All" button (primary blue, bottom-right of "Sync Actions" card)
  2. Loading spinner appears on button
  3. Backend syncs all three lists sequentially:
    • First: Sync participants
    • Second: Sync locations
    • Third: Sync users
  4. 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"
  5. 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:

  1. Scroll to "Advanced" section at bottom
  2. Click to expand "Advanced" collapse panel
  3. Click "Reinitialize Lists" button
  4. Confirmation popconfirm appears: "Reinitialize Lists. This will re-create any missing lists in Listmonk. Existing lists are preserved."
  5. Click "Reinitialize" to confirm (or click outside to cancel)
  6. Loading spinner appears on button
  7. Backend checks for existence of each list (Participants, Locations, Users)
  8. For each missing list:
    • Create new list with name and type (public/private)
    • Set list description
  9. Success message: "Lists reinitialized" (or error if creation fails)
  10. 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:

  1. Click "Listmonk Admin" button in tab switcher (top-right header)
  2. Active tab changes from "Management" to "Listmonk Admin"
  3. Page layout changes to fullbleed (removes padding for full-screen iframe)
  4. Loading spinner appears while iframe loads
  5. Backend generates auto-authentication token:
    • GET /api/listmonk/proxy-url
    • Response: { port: 9001, token: "auto-auth-token-xyz" }
  6. Iframe loads Listmonk URL with auth token:
    • URL: //localhost:9001/auth?token=auto-auth-token-xyz
    • Listmonk auto-authenticates user (no manual login required)
  7. 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:

  1. Click "Open Listmonk" button (top-right header, next to "Test Connection")
  2. New browser tab opens with Listmonk URL: //localhost:9001
  3. Listmonk login page appears (if not already logged in)
  4. Enter Listmonk admin credentials:
    • Username: Value of LISTMONK_WEB_ADMIN_USER env var
    • Password: Value of LISTMONK_WEB_ADMIN_PASSWORD env var
  5. 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 state
  • syncing (object): Sync button loading states (participants, locations, users, all, test, reinit)
  • iframeSrc (string | null): Listmonk iframe URL with auth token
  • iframeLoading (boolean): Iframe loading state
  • iframeError (string | null): Iframe load error message
  • iframeInitialized (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: iframeInitialized ref 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 of LISTMONK_SYNC_ENABLED env var
  • connected (boolean): Listmonk API reachable and credentials valid
  • initialized (boolean): Listmonk lists (Participants, Locations, Users) exist
  • lastSyncAt (ISO 8601 | null): Timestamp of last successful sync
  • lastError (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 objects
    • name (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 passed
  • message (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 completed
  • message (string): Result summary
  • results (object):
    • created (number): New subscribers added
    • updated (number): Existing subscribers updated
    • failed (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 completed
  • message (string): Result summary
  • results (object):
    • participants (object): Participant sync results
    • locations (object): Location sync results
    • users (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:

  1. Set loading state for specific button (participants, locations, or users)
  2. Send POST request to sync endpoint
  3. Show success message
  4. Show warning if some subscribers failed to sync
  5. Refresh status and stats to show updated counts
  6. Handle errors gracefully
  7. 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.current is false, so iframe loads
  • Subsequent calls: iframeInitialized.current is true, so function returns early (no redundant loads)
  • Ref persists: useRef value 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:

  1. Edit .env file:

    nano .env
    
  2. Add or update line:

    LISTMONK_SYNC_ENABLED=true
    
  3. Restart API container:

    docker compose restart api
    
  4. 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:

  1. Listmonk container down:

    • Service not running
    • Failed to start due to configuration error
  2. Wrong credentials:

    • LISTMONK_ADMIN_USER or LISTMONK_ADMIN_PASSWORD incorrect
    • API user not created in Listmonk database
  3. Network issue:

    • API container cannot reach Listmonk container
    • Docker network misconfigured

Solution:

  1. Start Listmonk:

    docker compose up -d listmonk
    
  2. Verify credentials:

    grep LISTMONK_ .env
    

    Check that LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD match Listmonk configuration.

  3. Test connection manually:

    curl -u admin:password http://localhost:9001/api/health
    

    Expected: {"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:

  1. Click "Advanced" to expand advanced section
  2. Click "Reinitialize Lists" button
  3. Confirm reinitialize
  4. Wait for success message: "Lists reinitialized"
  5. 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:

  1. Proxy URL endpoint failed:

    • API cannot generate auto-auth token
    • Redis down (tokens stored in Redis)
  2. X-Frame-Options blocking:

    • Listmonk sets X-Frame-Options: SAMEORIGIN
    • Browser blocks iframe from different origin
  3. CORS issue:

    • Listmonk does not allow iframe embedding from admin domain

Solution:

  1. Check Redis:

    docker compose ps redis
    docker compose exec redis redis-cli PING
    

    Expected: "PONG"

  2. Use "Open Listmonk" button instead:

    • Opens Listmonk in new tab (no iframe, no X-Frame-Options issue)
    • Manual login required (no auto-auth token)
  3. Configure Listmonk to allow iframes (developer fix):

    • Edit Listmonk nginx config
    • Remove or modify X-Frame-Options header
    • Restart Listmonk container