feat: add Listmonk newsletter integration for VotC

Add direct PostgreSQL Listmonk integration to sync waitlist signups
and applications to the Valley of the Commons list (ID 24).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-01 11:23:17 -08:00
parent 59b37fb018
commit 42062a2467
4 changed files with 142 additions and 20 deletions

View File

@ -5,6 +5,7 @@ const { Pool } = require('pg');
const nodemailer = require('nodemailer');
const { syncApplication } = require('./google-sheets');
const { createPayment, TICKET_LABELS, calculateAmount } = require('./mollie');
const { addToListmonk } = require('./listmonk');
// Initialize PostgreSQL connection pool
const pool = new Pool({
@ -18,7 +19,7 @@ const smtp = nodemailer.createTransport({
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com',
user: process.env.SMTP_USER || 'contact@valleyofthecommons.com',
pass: process.env.SMTP_PASS || '',
},
tls: { rejectUnauthorized: false },
@ -295,12 +296,19 @@ module.exports = async function handler(req, res) {
// Sync to Google Sheets (fire-and-forget backup)
syncApplication(application);
// Add to Listmonk newsletter
addToListmonk(application.email, `${application.first_name} ${application.last_name}`, {
source: 'application',
weeks: weeksSelected,
contributionAmount: data.contribution_amount,
}).catch(err => console.error('[Listmonk] Application sync failed:', err.message));
// Send confirmation email to applicant
if (process.env.SMTP_PASS) {
try {
const confirmEmail = confirmationEmail(application);
const info = await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <newsletter@valleyofthecommons.com>',
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
to: application.email,
subject: confirmEmail.subject,
html: confirmEmail.html,
@ -316,7 +324,7 @@ module.exports = async function handler(req, res) {
const adminEmail = adminNotificationEmail(application);
const adminRecipients = (process.env.ADMIN_EMAILS || 'jeff@jeffemmett.com').split(',');
const info = await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <newsletter@valleyofthecommons.com>',
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
to: adminRecipients.join(', '),
subject: adminEmail.subject,
html: adminEmail.html,

78
api/listmonk.js Normal file
View File

@ -0,0 +1,78 @@
// Listmonk newsletter integration via direct PostgreSQL access
const { Pool } = require('pg');
const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 24; // Valley of the Commons 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 || '',
}) : null;
async function addToListmonk(email, name, attribs = {}) {
if (!listmonkPool) {
console.log('[Listmonk] Database not configured, skipping');
return false;
}
const client = await listmonkPool.connect();
try {
const mergeAttribs = {
votc: {
...attribs,
registeredAt: new Date().toISOString(),
}
};
// Check if subscriber exists
const existing = await client.query(
'SELECT id, attribs FROM subscribers WHERE email = $1',
[email]
);
let subscriberId;
if (existing.rows.length > 0) {
subscriberId = existing.rows[0].id;
const existingAttribs = existing.rows[0].attribs || {};
const merged = { ...existingAttribs, ...mergeAttribs };
await client.query(
'UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3',
[name, JSON.stringify(merged), subscriberId]
);
console.log(`[Listmonk] Updated existing subscriber: ${email} (ID: ${subscriberId})`);
} else {
const result = 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`,
[email, name, JSON.stringify(mergeAttribs)]
);
subscriberId = result.rows[0].id;
console.log(`[Listmonk] Created new subscriber: ${email} (ID: ${subscriberId})`);
}
// Add to VotC list
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(`[Listmonk] Added to VotC list: ${email}`);
return true;
} catch (error) {
console.error('[Listmonk] Error:', error.message);
return false;
} finally {
client.release();
}
}
function isConfigured() {
return !!listmonkPool;
}
module.exports = { addToListmonk, isConfigured };

View File

@ -4,6 +4,7 @@
const { Pool } = require('pg');
const nodemailer = require('nodemailer');
const { syncWaitlistSignup } = require('./google-sheets');
const { addToListmonk } = require('./listmonk');
// Initialize PostgreSQL connection pool
const pool = new Pool({
@ -17,38 +18,57 @@ const smtp = nodemailer.createTransport({
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com',
user: process.env.SMTP_USER || 'contact@valleyofthecommons.com',
pass: process.env.SMTP_PASS || '',
},
tls: { rejectUnauthorized: false },
});
const welcomeEmail = (signup) => ({
subject: 'Welcome to Valley of the Commons',
subject: 'Welcome to the Valley — A Village Built on Common Ground',
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2d5016; margin-bottom: 24px;">Welcome to the Valley!</h1>
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2d5016; margin-bottom: 8px;">Welcome to the Valley!</h1>
<p style="font-size: 15px; color: #5a7a3a; margin-top: 0; margin-bottom: 28px; font-style: italic;">A village built on common ground</p>
<p>Dear ${signup.name},</p>
<p>Thank you for your interest in <strong>Valley of the Commons</strong> - a four-week pop-up village in the Austrian Alps (August 24 - September 20, 2026).</p>
<p>Thank you for stepping toward something different. <strong>Valley of the Commons</strong> is a four-week pop-up village in Austria's Höllental Valley (August 24 September 20, 2026) a living commons shared in work and study, in making and care, in governance and everyday life.</p>
<p>You've been added to our community list. We'll keep you updated on:</p>
<ul>
<li>Application opening and deadlines</li>
<li>Event announcements and updates</li>
<li>Ways to get involved</li>
</ul>
<p>For four weeks, we'll come together to lay the foundations for life beyond extractive systems. Each week explores a different dimension of what a commons-based society can look like:</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 1px solid #e8e8e0;">
<td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 1</td>
<td style="padding: 10px 12px;">Return of the Commons</td>
</tr>
<tr style="border-bottom: 1px solid #e8e8e0; background: #fafaf5;">
<td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 2</td>
<td style="padding: 10px 12px;">Cosmo-local Production & Open Value Accounting</td>
</tr>
<tr style="border-bottom: 1px solid #e8e8e0;">
<td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 3</td>
<td style="padding: 10px 12px;">Future Living</td>
</tr>
<tr style="background: #fafaf5;">
<td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 4</td>
<td style="padding: 10px 12px;">Governance & Funding</td>
</tr>
</table>
<p>Mornings are structured learning paths. Afternoons host workshops, field visits, and working groups. And in between shared meals, hikes into the Alps, river swimming, mushroom foraging, fire circles, and the kind of conversations that only happen when people live and build together.</p>
${signup.involvement ? `
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0;">
<strong>Your interests:</strong>
<p style="margin-bottom: 0;">${signup.involvement}</p>
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0; border-left: 3px solid #2d5016;">
<strong style="color: #2d5016;">What you're bringing:</strong>
<p style="margin-bottom: 0; margin-top: 8px;">${signup.involvement}</p>
</div>
` : ''}
<p>
<a href="https://valleyofthecommons.com/apply.html" style="display: inline-block; background: #2d5016; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
<p>We'll be in touch with application details, event updates, and ways to get involved as the village takes shape.</p>
<p style="text-align: center; margin: 28px 0;">
<a href="https://valleyofthecommons.com/apply.html" style="display: inline-block; background: #2d5016; color: white; padding: 14px 32px; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
Apply Now
</a>
</p>
@ -149,12 +169,18 @@ module.exports = async function handler(req, res) {
// Sync to Google Sheets (fire-and-forget backup)
syncWaitlistSignup(signup);
// Add to Listmonk newsletter
addToListmonk(signup.email, signup.name, {
involvement: signup.involvement,
source: 'waitlist',
}).catch(err => console.error('[Listmonk] Waitlist sync failed:', err.message));
// Send welcome email
if (process.env.SMTP_PASS) {
try {
const email = welcomeEmail(signup);
const info = await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <newsletter@valleyofthecommons.com>',
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
to: signup.email,
subject: email.subject,
html: email.html,

View File

@ -7,6 +7,12 @@ services:
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
- INFISICAL_PROJECT_SLUG=valley-commons
- LISTMONK_DB_HOST=listmonk-db
- LISTMONK_DB_PORT=5432
- LISTMONK_DB_NAME=listmonk
- LISTMONK_DB_USER=listmonk
- LISTMONK_DB_PASS=${LISTMONK_DB_PASS:-listmonk_secure_2025}
- LISTMONK_LIST_ID=24
depends_on:
votc-db:
condition: service_healthy
@ -16,6 +22,7 @@ services:
- "traefik.http.services.votc.loadbalancer.server.port=3000"
networks:
- traefik-public
- listmonk-internal
votc-db:
image: postgres:16-alpine
@ -42,3 +49,6 @@ volumes:
networks:
traefik-public:
external: true
listmonk-internal:
external:
name: listmonk_listmonk-internal