From f0641cc04a6304da6a955df8a52663654f3deddb Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 30 Jan 2026 19:15:08 +0000 Subject: [PATCH] Add Listmonk integration for newsletter subscriber sync - New registrations with newsletter=yes are automatically added to WORLDPLAY list - Stores registration metadata (role, interests, location) as subscriber attributes - Handles existing subscribers by adding them to the list - Connected to Listmonk via internal Docker network Co-Authored-By: Claude Opus 4.5 --- docker-compose.yml | 9 +++++ server.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 2e73de5..02ae6ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,11 @@ services: - RESEND_API_KEY=${RESEND_API_KEY} - GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID} - GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS} + # Listmonk newsletter integration + - LISTMONK_URL=http://listmonk:9000 + - LISTMONK_USER=worldplay-api + - LISTMONK_PASS=worldplay-api-2026 + - LISTMONK_LIST_ID=20 volumes: - worldplay-data:/app/data labels: @@ -25,6 +30,7 @@ services: - "traefik.http.services.worldplay.loadbalancer.server.port=3000" networks: - traefik-public + - listmonk-internal healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/health"] interval: 30s @@ -39,3 +45,6 @@ volumes: networks: traefik-public: external: true + listmonk-internal: + external: true + name: listmonk_listmonk-internal diff --git a/server.js b/server.js index ad07455..e4c2080 100644 --- a/server.js +++ b/server.js @@ -12,6 +12,12 @@ const REGISTRATIONS_FILE = path.join(DATA_DIR, 'registrations.json'); // Initialize Resend for emails const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; +// Listmonk configuration for newsletter management +const LISTMONK_URL = process.env.LISTMONK_URL || 'http://listmonk:9000'; +const LISTMONK_USER = process.env.LISTMONK_USER || 'worldplay-api'; +const LISTMONK_PASS = process.env.LISTMONK_PASS || 'worldplay-api-2026'; +const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 20; // WORLDPLAY list + // Google Sheets configuration const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID; const GOOGLE_CREDENTIALS = process.env.GOOGLE_CREDENTIALS ? JSON.parse(process.env.GOOGLE_CREDENTIALS) : null; @@ -242,6 +248,84 @@ async function sendConfirmationEmail(registration) { } } +// Add subscriber to Listmonk for newsletter management +async function addToListmonk(registration) { + if (!registration.newsletter) { + console.log('Newsletter not opted in, skipping Listmonk...'); + return; + } + + try { + const auth = Buffer.from(`${LISTMONK_USER}:${LISTMONK_PASS}`).toString('base64'); + + // First, create or update the subscriber + const subscriberResponse = await fetch(`${LISTMONK_URL}/api/subscribers`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: registration.email, + name: `${registration.firstName} ${registration.lastName}`, + status: 'enabled', + lists: [LISTMONK_LIST_ID], + attribs: { + firstName: registration.firstName, + lastName: registration.lastName, + location: registration.location || '', + role: registration.role || '', + interests: registration.interests || [], + contribute: registration.contribute || [], + registrationId: registration.id, + registeredAt: registration.registeredAt + } + }), + }); + + if (subscriberResponse.ok) { + const data = await subscriberResponse.json(); + console.log(`Added to Listmonk WORLDPLAY list: ${registration.email} (ID: ${data.data?.id})`); + } else if (subscriberResponse.status === 409) { + // Subscriber already exists, add to list + console.log(`Subscriber exists in Listmonk, updating list membership: ${registration.email}`); + + // Get existing subscriber + const searchResponse = await fetch(`${LISTMONK_URL}/api/subscribers?query=email='${registration.email}'`, { + headers: { 'Authorization': `Basic ${auth}` }, + }); + + if (searchResponse.ok) { + const searchData = await searchResponse.json(); + if (searchData.data?.results?.length > 0) { + const subscriberId = searchData.data.results[0].id; + + // Add to WORLDPLAY list + await fetch(`${LISTMONK_URL}/api/subscribers/lists`, { + method: 'PUT', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ids: [subscriberId], + action: 'add', + target_list_ids: [LISTMONK_LIST_ID], + status: 'confirmed' + }), + }); + console.log(`Added existing subscriber to WORLDPLAY list: ${registration.email}`); + } + } + } else { + const errorText = await subscriberResponse.text(); + console.error(`Listmonk API error (${subscriberResponse.status}): ${errorText}`); + } + } catch (error) { + console.error('Error adding to Listmonk:', error.message); + } +} + // Registration endpoint app.post('/api/register', async (req, res) => { try { @@ -292,6 +376,9 @@ app.post('/api/register', async (req, res) => { // Send confirmation email (async, don't block response) sendConfirmationEmail(registration).catch(err => console.error('Email error:', err)); + // Add to Listmonk for newsletter management (async, don't block response) + addToListmonk(registration).catch(err => console.error('Listmonk error:', err)); + res.json({ success: true, message: 'Registration successful', @@ -425,5 +512,6 @@ ensureDataDir().then(() => { console.log(`Admin token: ${process.env.ADMIN_TOKEN || 'worldplay-admin-2026'}`); console.log(`Google Sheets: ${sheets ? 'enabled' : 'disabled'}`); console.log(`Email notifications: ${resend ? 'enabled' : 'disabled'}`); + console.log(`Listmonk newsletter sync: enabled (list ID: ${LISTMONK_LIST_ID})`); }); });