547 lines
22 KiB
JavaScript
547 lines
22 KiB
JavaScript
const express = require('express');
|
||
const fs = require('fs').promises;
|
||
const path = require('path');
|
||
const { google } = require('googleapis');
|
||
const nodemailer = require('nodemailer');
|
||
const { Pool } = require('pg');
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3000;
|
||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||
const REGISTRATIONS_FILE = path.join(DATA_DIR, 'registrations.json');
|
||
|
||
// Initialize SMTP transport (Mailcow)
|
||
const smtp = process.env.SMTP_PASS ? nodemailer.createTransport({
|
||
host: process.env.SMTP_HOST || 'mail.rmail.online',
|
||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||
secure: false,
|
||
auth: {
|
||
user: process.env.SMTP_USER || 'noreply@jeffemmett.com',
|
||
pass: process.env.SMTP_PASS,
|
||
},
|
||
tls: { rejectUnauthorized: false },
|
||
}) : null;
|
||
|
||
// Listmonk PostgreSQL configuration for newsletter management
|
||
const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 20; // WORLDPLAY list
|
||
const listmonkPool = process.env.LISTMONK_DB_HOST ? new Pool({
|
||
host: process.env.LISTMONK_DB_HOST || 'listmonk-db',
|
||
port: parseInt(process.env.LISTMONK_DB_PORT) || 5432,
|
||
database: process.env.LISTMONK_DB_NAME || 'listmonk',
|
||
user: process.env.LISTMONK_DB_USER || 'listmonk',
|
||
password: process.env.LISTMONK_DB_PASS || 'listmonk_secure_2025',
|
||
}) : null;
|
||
|
||
// Google Sheets configuration
|
||
const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID;
|
||
function loadGoogleCredentials() {
|
||
// Prefer file-based credentials (cleaner for Docker)
|
||
const filePath = process.env.GOOGLE_SERVICE_ACCOUNT_FILE;
|
||
if (filePath) {
|
||
try {
|
||
return JSON.parse(require('fs').readFileSync(filePath, 'utf8'));
|
||
} catch (err) {
|
||
console.error('Failed to read Google credentials file:', err.message);
|
||
return null;
|
||
}
|
||
}
|
||
// Fall back to JSON string in env var
|
||
if (process.env.GOOGLE_CREDENTIALS) {
|
||
try { return JSON.parse(process.env.GOOGLE_CREDENTIALS); }
|
||
catch { console.error('Failed to parse GOOGLE_CREDENTIALS env var'); return null; }
|
||
}
|
||
return null;
|
||
}
|
||
const GOOGLE_CREDENTIALS = loadGoogleCredentials();
|
||
|
||
// Initialize Google Sheets API
|
||
let sheets = null;
|
||
if (GOOGLE_CREDENTIALS && GOOGLE_SHEET_ID) {
|
||
const auth = new google.auth.GoogleAuth({
|
||
credentials: GOOGLE_CREDENTIALS,
|
||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||
});
|
||
sheets = google.sheets({ version: 'v4', auth });
|
||
console.log('Google Sheets integration enabled');
|
||
}
|
||
|
||
// Middleware
|
||
app.use(express.json());
|
||
app.use(express.static('.'));
|
||
|
||
// Ensure data directory exists
|
||
async function ensureDataDir() {
|
||
try {
|
||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||
try {
|
||
await fs.access(REGISTRATIONS_FILE);
|
||
} catch {
|
||
await fs.writeFile(REGISTRATIONS_FILE, '[]');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating data directory:', error);
|
||
}
|
||
}
|
||
|
||
// Load registrations
|
||
async function loadRegistrations() {
|
||
try {
|
||
const data = await fs.readFile(REGISTRATIONS_FILE, 'utf8');
|
||
return JSON.parse(data);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Save registrations
|
||
async function saveRegistrations(registrations) {
|
||
await fs.writeFile(REGISTRATIONS_FILE, JSON.stringify(registrations, null, 2));
|
||
}
|
||
|
||
// Append registration to Google Sheet
|
||
async function appendToGoogleSheet(registration) {
|
||
if (!sheets || !GOOGLE_SHEET_ID) {
|
||
console.log('Google Sheets not configured, skipping...');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const values = [[
|
||
registration.registeredAt,
|
||
registration.firstName,
|
||
registration.lastName,
|
||
registration.email,
|
||
registration.location,
|
||
registration.role,
|
||
Array.isArray(registration.interests) ? registration.interests.join(', ') : registration.interests,
|
||
Array.isArray(registration.contribute) ? registration.contribute.join(', ') : registration.contribute,
|
||
registration.message,
|
||
registration.newsletter ? 'Yes' : 'No',
|
||
registration.id
|
||
]];
|
||
|
||
await sheets.spreadsheets.values.append({
|
||
spreadsheetId: GOOGLE_SHEET_ID,
|
||
range: 'Registrations!A:K',
|
||
valueInputOption: 'USER_ENTERED',
|
||
insertDataOption: 'INSERT_ROWS',
|
||
requestBody: { values },
|
||
});
|
||
|
||
console.log(`Added registration to Google Sheet: ${registration.email}`);
|
||
} catch (error) {
|
||
console.error('Error appending to Google Sheet:', error.message);
|
||
}
|
||
}
|
||
|
||
// Send confirmation email
|
||
async function sendConfirmationEmail(registration) {
|
||
if (!smtp) {
|
||
console.log('SMTP not configured, skipping email...');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const interestsText = Array.isArray(registration.interests) && registration.interests.length > 0
|
||
? registration.interests.map(i => {
|
||
const labels = {
|
||
'reality': '🎭 Playing with Reality',
|
||
'fiction': '✒️ Science Fictions',
|
||
'worlding': '🛠 Guerrilla Futuring',
|
||
'games': '🎲 Game Commons',
|
||
'infrastructure': '🌱 Infrastructures'
|
||
};
|
||
return labels[i] || i;
|
||
}).join(', ')
|
||
: 'Not specified';
|
||
|
||
const roleLabels = {
|
||
'writer': 'Sci-Fi Writer / Storyteller',
|
||
'gamemaker': 'Game Designer / Maker',
|
||
'artist': 'Artist / Performer',
|
||
'larper': 'LARPer / Roleplayer',
|
||
'economist': 'Weird Economist / Commons Activist',
|
||
'futurist': 'Futurist / Speculative Designer',
|
||
'academic': 'Academic / Researcher',
|
||
'tech': 'Technologist / Developer',
|
||
'curious': 'Curious Explorer',
|
||
'other': 'Other'
|
||
};
|
||
|
||
const contributeLabels = {
|
||
'session': 'Propose a session',
|
||
'workshop': 'Host a workshop',
|
||
'game': 'Bring a game to play or playtest',
|
||
'larp': 'Run a LARP or participatory format',
|
||
'project': 'Co-create a project or publication',
|
||
'other': 'Contribute in another way',
|
||
'attend': 'Attend only',
|
||
'unsure': 'Not sure yet'
|
||
};
|
||
|
||
const contributeText = Array.isArray(registration.contribute) && registration.contribute.length > 0
|
||
? registration.contribute.map(c => contributeLabels[c] || c).join(', ')
|
||
: 'Not specified';
|
||
|
||
await smtp.sendMail({
|
||
from: process.env.EMAIL_FROM || 'WORLDPLAY <noreply@jeffemmett.com>',
|
||
to: registration.email,
|
||
subject: '🎭 Welcome to WORLDPLAY – Registration Confirmed',
|
||
html: `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
</head>
|
||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #0a0a0f; color: #f0f0f5; padding: 40px 20px; margin: 0;">
|
||
<div style="max-width: 600px; margin: 0 auto;">
|
||
<div style="text-align: center; margin-bottom: 40px;">
|
||
<h1 style="font-family: monospace; font-size: 32px; background: linear-gradient(135deg, #9d4edd 0%, #ff006e 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin: 0;">WORLDPLAY</h1>
|
||
<p style="color: #a0a0b0; font-style: italic; margin-top: 8px;">: To be Defined</p>
|
||
</div>
|
||
|
||
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
|
||
<h2 style="color: #00d9ff; margin-top: 0; font-size: 20px;">Welcome, ${registration.firstName}! 🌟</h2>
|
||
|
||
<p style="color: #a0a0b0; line-height: 1.7;">
|
||
Thank you for registering your interest in <strong style="color: #f0f0f5;">WORLDPLAY</strong> – a pop-up physical hub for prefiguring and prehearsing postcapitalist futures through fiction, performance and play.
|
||
</p>
|
||
|
||
<p style="color: #a0a0b0; line-height: 1.7;">
|
||
We've received your registration and will be in touch with next steps as we finalize the programme.
|
||
</p>
|
||
</div>
|
||
|
||
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
|
||
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Your Registration Details</h3>
|
||
|
||
<table style="width: 100%; color: #a0a0b0; font-size: 14px;">
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Name:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.firstName} ${registration.lastName}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Email:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.email}</td>
|
||
</tr>
|
||
${registration.location ? `
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Location:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.location}</td>
|
||
</tr>
|
||
` : ''}
|
||
${registration.role ? `
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Role:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${roleLabels[registration.role] || registration.role}</td>
|
||
</tr>
|
||
` : ''}
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Interests:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${interestsText}</td>
|
||
</tr>
|
||
${(Array.isArray(registration.contribute) && registration.contribute.length > 0) || registration.contribute ? `
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Contribution:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${contributeText}</td>
|
||
</tr>
|
||
` : ''}
|
||
</table>
|
||
</div>
|
||
|
||
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
|
||
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Event Details</h3>
|
||
|
||
<p style="color: #a0a0b0; margin: 0;">
|
||
📅 <strong style="color: #f0f0f5;">June 7–13, 2026</strong><br>
|
||
📍 <strong style="color: #f0f0f5;">Commons Hub</strong>, Hirschwang an der Rax, Austria<br>
|
||
🏔️ Austrian Alps, ~1.5 hours from Vienna by train
|
||
</p>
|
||
</div>
|
||
|
||
<div style="text-align: center; padding: 24px 0; border-top: 1px solid rgba(157, 78, 221, 0.2);">
|
||
<p style="color: #a0a0b0; font-size: 14px; margin: 0;">
|
||
Questions? Reply to this email or visit <a href="https://worldplay.art" style="color: #00d9ff;">worldplay.art</a>
|
||
</p>
|
||
<p style="color: #9d4edd; font-size: 12px; margin-top: 16px; font-style: italic;">
|
||
Reality is a design space ✨
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`,
|
||
});
|
||
|
||
console.log(`Confirmation email sent to: ${registration.email}`);
|
||
} catch (error) {
|
||
console.error('Error sending confirmation email:', error.message);
|
||
}
|
||
}
|
||
|
||
// Add subscriber to Listmonk for newsletter management (direct DB access)
|
||
async function addToListmonk(registration) {
|
||
if (!registration.newsletter) {
|
||
console.log('Newsletter not opted in, skipping Listmonk...');
|
||
return;
|
||
}
|
||
|
||
if (!listmonkPool) {
|
||
console.log('Listmonk database not configured, skipping...');
|
||
return;
|
||
}
|
||
|
||
const client = await listmonkPool.connect();
|
||
try {
|
||
const fullName = `${registration.firstName} ${registration.lastName}`;
|
||
const attribs = {
|
||
worldplay: {
|
||
firstName: registration.firstName,
|
||
lastName: registration.lastName,
|
||
location: registration.location || '',
|
||
role: registration.role || '',
|
||
interests: registration.interests || [],
|
||
contribute: registration.contribute || [],
|
||
registrationId: registration.id,
|
||
registeredAt: registration.registeredAt
|
||
}
|
||
};
|
||
|
||
// Check if subscriber exists
|
||
const existingResult = await client.query(
|
||
'SELECT id, attribs FROM subscribers WHERE email = $1',
|
||
[registration.email]
|
||
);
|
||
|
||
let subscriberId;
|
||
|
||
if (existingResult.rows.length > 0) {
|
||
// Subscriber exists - update attributes and get ID
|
||
subscriberId = existingResult.rows[0].id;
|
||
const existingAttribs = existingResult.rows[0].attribs || {};
|
||
const mergedAttribs = { ...existingAttribs, ...attribs };
|
||
|
||
await client.query(
|
||
'UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3',
|
||
[fullName, JSON.stringify(mergedAttribs), subscriberId]
|
||
);
|
||
console.log(`Updated existing Listmonk subscriber: ${registration.email} (ID: ${subscriberId})`);
|
||
} else {
|
||
// Create new subscriber
|
||
const insertResult = await client.query(
|
||
`INSERT INTO subscribers (uuid, email, name, status, attribs, created_at, updated_at)
|
||
VALUES (gen_random_uuid(), $1, $2, 'enabled', $3, NOW(), NOW())
|
||
RETURNING id`,
|
||
[registration.email, fullName, JSON.stringify(attribs)]
|
||
);
|
||
subscriberId = insertResult.rows[0].id;
|
||
console.log(`Created new Listmonk subscriber: ${registration.email} (ID: ${subscriberId})`);
|
||
}
|
||
|
||
// Add to WORLDPLAY list if not already a member
|
||
await client.query(
|
||
`INSERT INTO subscriber_lists (subscriber_id, list_id, status, created_at, updated_at)
|
||
VALUES ($1, $2, 'confirmed', NOW(), NOW())
|
||
ON CONFLICT (subscriber_id, list_id) DO UPDATE SET status = 'confirmed', updated_at = NOW()`,
|
||
[subscriberId, LISTMONK_LIST_ID]
|
||
);
|
||
console.log(`Added to Listmonk WORLDPLAY list: ${registration.email}`);
|
||
|
||
} catch (error) {
|
||
console.error('Error adding to Listmonk:', error.message);
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
|
||
// Registration endpoint
|
||
app.post('/api/register', async (req, res) => {
|
||
try {
|
||
const { firstName, lastName, email, location, role, otherRole, interests, contribute, message, newsletter } = req.body;
|
||
|
||
// Validation
|
||
if (!firstName || !lastName || !email) {
|
||
return res.status(400).json({ error: 'First name, last name, and email are required' });
|
||
}
|
||
|
||
if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||
return res.status(400).json({ error: 'Please provide a valid email address' });
|
||
}
|
||
|
||
// Load existing registrations
|
||
const registrations = await loadRegistrations();
|
||
|
||
// Check for duplicate email
|
||
if (registrations.some(r => r.email.toLowerCase() === email.toLowerCase())) {
|
||
return res.status(400).json({ error: 'This email is already registered' });
|
||
}
|
||
|
||
// Create new registration
|
||
const registration = {
|
||
id: Date.now().toString(36) + Math.random().toString(36).substr(2),
|
||
firstName: firstName.trim(),
|
||
lastName: lastName.trim(),
|
||
email: email.toLowerCase().trim(),
|
||
location: location?.trim() || '',
|
||
role: role === 'other' && otherRole ? `Other: ${otherRole.trim()}` : (role || ''),
|
||
interests: interests || [],
|
||
contribute: contribute || [],
|
||
message: message?.trim() || '',
|
||
newsletter: newsletter === 'yes',
|
||
registeredAt: new Date().toISOString(),
|
||
ipAddress: req.ip || req.connection.remoteAddress
|
||
};
|
||
|
||
// Save registration locally
|
||
registrations.push(registration);
|
||
await saveRegistrations(registrations);
|
||
|
||
console.log(`New registration: ${registration.firstName} ${registration.lastName} <${registration.email}>`);
|
||
|
||
// Add to Google Sheet (async, don't block response)
|
||
appendToGoogleSheet(registration).catch(err => console.error('Sheet error:', err));
|
||
|
||
// 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',
|
||
id: registration.id
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Registration error:', error);
|
||
res.status(500).json({ error: 'An error occurred. Please try again.' });
|
||
}
|
||
});
|
||
|
||
// Admin endpoint to view registrations (protected by simple token)
|
||
app.get('/api/registrations', async (req, res) => {
|
||
const token = req.headers['x-admin-token'] || req.query.token;
|
||
const adminToken = process.env.ADMIN_TOKEN || 'worldplay-admin-2026';
|
||
|
||
if (token !== adminToken) {
|
||
return res.status(401).json({ error: 'Unauthorized' });
|
||
}
|
||
|
||
try {
|
||
const registrations = await loadRegistrations();
|
||
res.json({
|
||
count: registrations.length,
|
||
registrations: registrations.map(r => ({
|
||
...r,
|
||
ipAddress: undefined // Don't expose IP in admin view
|
||
}))
|
||
});
|
||
} catch (error) {
|
||
res.status(500).json({ error: 'Failed to load registrations' });
|
||
}
|
||
});
|
||
|
||
// Export registrations as CSV
|
||
app.get('/api/registrations/export', async (req, res) => {
|
||
const token = req.headers['x-admin-token'] || req.query.token;
|
||
const adminToken = process.env.ADMIN_TOKEN || 'worldplay-admin-2026';
|
||
|
||
if (token !== adminToken) {
|
||
return res.status(401).json({ error: 'Unauthorized' });
|
||
}
|
||
|
||
try {
|
||
const registrations = await loadRegistrations();
|
||
|
||
const headers = ['ID', 'First Name', 'Last Name', 'Email', 'Location', 'Role', 'Interests', 'Contribute', 'Message', 'Newsletter', 'Registered At'];
|
||
const rows = registrations.map(r => [
|
||
r.id,
|
||
r.firstName,
|
||
r.lastName,
|
||
r.email,
|
||
r.location,
|
||
r.role,
|
||
Array.isArray(r.interests) ? r.interests.join('; ') : r.interests,
|
||
Array.isArray(r.contribute) ? r.contribute.join('; ') : r.contribute,
|
||
r.message.replace(/"/g, '""'),
|
||
r.newsletter ? 'Yes' : 'No',
|
||
r.registeredAt
|
||
]);
|
||
|
||
const csv = [
|
||
headers.join(','),
|
||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||
].join('\n');
|
||
|
||
res.setHeader('Content-Type', 'text/csv');
|
||
res.setHeader('Content-Disposition', 'attachment; filename=worldplay-registrations.csv');
|
||
res.send(csv);
|
||
} catch (error) {
|
||
res.status(500).json({ error: 'Failed to export registrations' });
|
||
}
|
||
});
|
||
|
||
// Financial transparency page
|
||
app.get('/financial-transparency', (req, res) => {
|
||
res.sendFile(path.join(__dirname, 'financial-transparency.html'));
|
||
});
|
||
|
||
// Health check
|
||
app.get('/health', (req, res) => {
|
||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||
});
|
||
|
||
// Stats endpoint
|
||
app.get('/api/stats', async (req, res) => {
|
||
try {
|
||
const registrations = await loadRegistrations();
|
||
|
||
const stats = {
|
||
totalRegistrations: registrations.length,
|
||
byRole: {},
|
||
byInterest: {},
|
||
byContribute: {}
|
||
};
|
||
|
||
registrations.forEach(r => {
|
||
// Count by role
|
||
if (r.role) {
|
||
stats.byRole[r.role] = (stats.byRole[r.role] || 0) + 1;
|
||
}
|
||
|
||
// Count by interest
|
||
if (Array.isArray(r.interests)) {
|
||
r.interests.forEach(interest => {
|
||
stats.byInterest[interest] = (stats.byInterest[interest] || 0) + 1;
|
||
});
|
||
}
|
||
|
||
// Count by contribute
|
||
if (Array.isArray(r.contribute)) {
|
||
r.contribute.forEach(contrib => {
|
||
stats.byContribute[contrib] = (stats.byContribute[contrib] || 0) + 1;
|
||
});
|
||
} else if (r.contribute) {
|
||
stats.byContribute[r.contribute] = (stats.byContribute[r.contribute] || 0) + 1;
|
||
}
|
||
});
|
||
|
||
res.json(stats);
|
||
} catch (error) {
|
||
res.status(500).json({ error: 'Failed to calculate stats' });
|
||
}
|
||
});
|
||
|
||
// Start server
|
||
ensureDataDir().then(() => {
|
||
app.listen(PORT, '0.0.0.0', () => {
|
||
console.log(`WORLDPLAY server running on port ${PORT}`);
|
||
console.log(`Admin token: ${process.env.ADMIN_TOKEN || 'worldplay-admin-2026'}`);
|
||
console.log(`Google Sheets: ${sheets ? 'enabled' : 'disabled'}`);
|
||
console.log(`Email notifications: ${smtp ? 'enabled (Mailcow SMTP)' : 'disabled (no SMTP_PASS)'}`);
|
||
console.log(`Listmonk newsletter sync: ${listmonkPool ? 'enabled' : 'disabled'} (list ID: ${LISTMONK_LIST_ID})`);
|
||
});
|
||
});
|