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 nodemailer = require('nodemailer');
const { syncApplication } = require('./google-sheets'); const { syncApplication } = require('./google-sheets');
const { createPayment, TICKET_LABELS, calculateAmount } = require('./mollie'); const { createPayment, TICKET_LABELS, calculateAmount } = require('./mollie');
const { addToListmonk } = require('./listmonk');
// Initialize PostgreSQL connection pool // Initialize PostgreSQL connection pool
const pool = new Pool({ const pool = new Pool({
@ -18,7 +19,7 @@ const smtp = nodemailer.createTransport({
port: parseInt(process.env.SMTP_PORT || '587'), port: parseInt(process.env.SMTP_PORT || '587'),
secure: false, secure: false,
auth: { auth: {
user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com', user: process.env.SMTP_USER || 'contact@valleyofthecommons.com',
pass: process.env.SMTP_PASS || '', pass: process.env.SMTP_PASS || '',
}, },
tls: { rejectUnauthorized: false }, tls: { rejectUnauthorized: false },
@ -295,12 +296,19 @@ module.exports = async function handler(req, res) {
// Sync to Google Sheets (fire-and-forget backup) // Sync to Google Sheets (fire-and-forget backup)
syncApplication(application); 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 // Send confirmation email to applicant
if (process.env.SMTP_PASS) { if (process.env.SMTP_PASS) {
try { try {
const confirmEmail = confirmationEmail(application); const confirmEmail = confirmationEmail(application);
const info = await smtp.sendMail({ 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, to: application.email,
subject: confirmEmail.subject, subject: confirmEmail.subject,
html: confirmEmail.html, html: confirmEmail.html,
@ -316,7 +324,7 @@ module.exports = async function handler(req, res) {
const adminEmail = adminNotificationEmail(application); const adminEmail = adminNotificationEmail(application);
const adminRecipients = (process.env.ADMIN_EMAILS || 'jeff@jeffemmett.com').split(','); const adminRecipients = (process.env.ADMIN_EMAILS || 'jeff@jeffemmett.com').split(',');
const info = await smtp.sendMail({ 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(', '), to: adminRecipients.join(', '),
subject: adminEmail.subject, subject: adminEmail.subject,
html: adminEmail.html, 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 { Pool } = require('pg');
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const { syncWaitlistSignup } = require('./google-sheets'); const { syncWaitlistSignup } = require('./google-sheets');
const { addToListmonk } = require('./listmonk');
// Initialize PostgreSQL connection pool // Initialize PostgreSQL connection pool
const pool = new Pool({ const pool = new Pool({
@ -17,38 +18,57 @@ const smtp = nodemailer.createTransport({
port: parseInt(process.env.SMTP_PORT || '587'), port: parseInt(process.env.SMTP_PORT || '587'),
secure: false, secure: false,
auth: { auth: {
user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com', user: process.env.SMTP_USER || 'contact@valleyofthecommons.com',
pass: process.env.SMTP_PASS || '', pass: process.env.SMTP_PASS || '',
}, },
tls: { rejectUnauthorized: false }, tls: { rejectUnauthorized: false },
}); });
const welcomeEmail = (signup) => ({ const welcomeEmail = (signup) => ({
subject: 'Welcome to Valley of the Commons', subject: 'Welcome to the Valley — A Village Built on Common Ground',
html: ` html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;"> <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: 24px;">Welcome to the Valley!</h1> <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>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> <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>
<ul>
<li>Application opening and deadlines</li> <table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<li>Event announcements and updates</li> <tr style="border-bottom: 1px solid #e8e8e0;">
<li>Ways to get involved</li> <td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 1</td>
</ul> <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 ? ` ${signup.involvement ? `
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0;"> <div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0; border-left: 3px solid #2d5016;">
<strong>Your interests:</strong> <strong style="color: #2d5016;">What you're bringing:</strong>
<p style="margin-bottom: 0;">${signup.involvement}</p> <p style="margin-bottom: 0; margin-top: 8px;">${signup.involvement}</p>
</div> </div>
` : ''} ` : ''}
<p> <p>We'll be in touch with application details, event updates, and ways to get involved as the village takes shape.</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 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 Apply Now
</a> </a>
</p> </p>
@ -149,12 +169,18 @@ module.exports = async function handler(req, res) {
// Sync to Google Sheets (fire-and-forget backup) // Sync to Google Sheets (fire-and-forget backup)
syncWaitlistSignup(signup); 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 // Send welcome email
if (process.env.SMTP_PASS) { if (process.env.SMTP_PASS) {
try { try {
const email = welcomeEmail(signup); const email = welcomeEmail(signup);
const info = await smtp.sendMail({ 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, to: signup.email,
subject: email.subject, subject: email.subject,
html: email.html, html: email.html,

View File

@ -7,6 +7,12 @@ services:
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID} - INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET} - INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
- INFISICAL_PROJECT_SLUG=valley-commons - 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: depends_on:
votc-db: votc-db:
condition: service_healthy condition: service_healthy
@ -16,6 +22,7 @@ services:
- "traefik.http.services.votc.loadbalancer.server.port=3000" - "traefik.http.services.votc.loadbalancer.server.port=3000"
networks: networks:
- traefik-public - traefik-public
- listmonk-internal
votc-db: votc-db:
image: postgres:16-alpine image: postgres:16-alpine
@ -42,3 +49,6 @@ volumes:
networks: networks:
traefik-public: traefik-public:
external: true external: true
listmonk-internal:
external:
name: listmonk_listmonk-internal