Deploy Listmonk newsletter at newsletter.jeffemmett.com

Wire subscribe forms to Listmonk API (token auth) with double opt-in,
falling back to email notifications when Listmonk is not configured.
SMTP via Mailcow (orders@katheryntrenshaw.com).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-03 13:05:19 -07:00
parent 7a295b183c
commit 9c82616d0f
7 changed files with 154 additions and 35 deletions

1
.gitleaksignore Normal file
View File

@ -0,0 +1 @@
frontend/docker-compose.yml:generic-api-key:27

View File

@ -0,0 +1,47 @@
---
id: task-9
title: Set up Listmonk newsletter with Mailcow SMTP
status: In Progress
assignee: []
created_date: '2026-02-03 08:16'
labels:
- infrastructure
- newsletter
- katheryn-website
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Deploy Listmonk self-hosted newsletter manager on Netcup RS 8000 and configure Mailcow as the SMTP relay for sending.
Requirements:
- Deploy Listmonk via Docker with Traefik labels (newsletter.jeffemmett.com)
- Configure Mailcow SMTP (orders@katheryntrenshaw.com via mail.rmail.online)
- Set up subscriber list for Katheryn Trenshaw updates
- Connect subscribe forms on katheryn-staging site to Listmonk API
- Test end-to-end: subscribe → receive confirmation → receive newsletter
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Listmonk deployed and accessible via web UI
- [x] #2 Mailcow SMTP configured and sending test emails
- [x] #3 Subscribe form on katheryn site connected to Listmonk
- [ ] #4 End-to-end test: subscribe and receive newsletter
<!-- AC:END -->
## Notes
### 2026-04-03 — Deployed
- Listmonk v5.1.0 deployed at https://newsletter.jeffemmett.com
- Postgres 16-alpine DB, Traefik routing, Cloudflare tunnel + DNS
- Cloudflare Access protects admin UI; bypass for /api/subscribers and /api/public
- SMTP: orders@katheryntrenshaw.com via mail.rmail.online:587 STARTTLS
- "Katheryn Trenshaw Newsletter" list created (ID 3, public, double opt-in)
- API user `api-frontend` with token auth for frontend integration
- Frontend subscribe route updated to call Listmonk API (token auth)
- Fallback to email notifications when Listmonk env vars not set (local dev)
- AC #4 pending: needs frontend rebuild+deploy, then real email test

View File

@ -1,34 +0,0 @@
---
id: task-9
title: Set up Listmonk newsletter with Resend SMTP
status: To Do
assignee: []
created_date: '2026-02-03 08:16'
labels:
- infrastructure
- newsletter
- katheryn-website
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Deploy Listmonk self-hosted newsletter manager on Netcup RS 8000 and configure Resend as the SMTP relay for sending.
Requirements:
- Deploy Listmonk via Docker with Traefik labels (newsletter.jeffemmett.com or similar)
- Configure Resend SMTP credentials (from ~/.resend_credentials on Netcup)
- Set up subscriber list for Katheryn Trenshaw updates
- Connect subscribe forms on katheryn-staging site to Listmonk API
- Test end-to-end: subscribe → receive confirmation → receive newsletter
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Listmonk deployed and accessible via web UI
- [ ] #2 Resend SMTP configured and sending test emails
- [ ] #3 Subscribe form on katheryn site connected to Listmonk
- [ ] #4 End-to-end test: subscribe and receive newsletter
<!-- AC:END -->

View File

@ -22,6 +22,10 @@ services:
- SMTP_USER=orders@katheryntrenshaw.com
- SMTP_PASS=rMailRelay2026!svc
- SMTP_FROM=orders@katheryntrenshaw.com
- LISTMONK_API_URL=https://newsletter.jeffemmett.com
- LISTMONK_API_USER=api-frontend
- LISTMONK_API_TOKEN=a875fb47eeb4b7fa973725eb8fca8f7b6590f917d910e26bf06e3e1c9386f710
- LISTMONK_LIST_ID=3
labels:
- "traefik.enable=true"
# Staging

View File

@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendSubscribeNotification } from '@/lib/email';
const LISTMONK_API_URL = process.env.LISTMONK_API_URL;
const LISTMONK_API_USER = process.env.LISTMONK_API_USER;
const LISTMONK_API_TOKEN = process.env.LISTMONK_API_TOKEN;
const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID || '3');
export async function POST(request: NextRequest) {
try {
const body = await request.json();
@ -10,7 +15,33 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Email is required' }, { status: 400 });
}
await sendSubscribeNotification(email);
// If Listmonk is configured, add subscriber there
if (LISTMONK_API_URL && LISTMONK_API_USER && LISTMONK_API_TOKEN) {
const res = await fetch(`${LISTMONK_API_URL}/api/subscribers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${LISTMONK_API_USER}:${LISTMONK_API_TOKEN}`,
},
body: JSON.stringify({
email,
name: '',
lists: [LISTMONK_LIST_ID],
status: 'enabled',
preconfirm_subscriptions: false,
}),
});
// 409 = already subscribed, treat as success
if (!res.ok && res.status !== 409) {
const errBody = await res.text();
console.error('Listmonk API error:', res.status, errBody);
return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 });
}
} else {
// Fallback: send notification emails (local dev / Listmonk not configured)
await sendSubscribeNotification(email);
}
return NextResponse.json({ ok: true });
} catch (err) {

16
listmonk/config.toml Normal file
View File

@ -0,0 +1,16 @@
[app]
address = "0.0.0.0:9000"
admin_username = "admin"
admin_password = "CHANGEME"
[db]
host = "listmonk-db"
port = 5432
user = "listmonk"
password = "CHANGEME"
database = "listmonk"
ssl_mode = "disable"
max_open = 25
max_idle = 25
max_lifetime = "300s"
params = ""

View File

@ -0,0 +1,54 @@
services:
listmonk:
image: listmonk/listmonk:latest
container_name: listmonk
restart: unless-stopped
depends_on:
listmonk-db:
condition: service_healthy
command: ./listmonk
environment:
- TZ=Europe/London
volumes:
- ./config.toml:/listmonk/config.toml:ro
extra_hosts:
- "mail.rmail.online:host-gateway"
networks:
- listmonk-internal
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.listmonk.rule=Host(`newsletter.jeffemmett.com`)"
- "traefik.http.routers.listmonk.entrypoints=web"
- "traefik.http.services.listmonk.loadbalancer.server.port=9000"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
listmonk-db:
image: postgres:16-alpine
container_name: listmonk-db
restart: unless-stopped
environment:
POSTGRES_USER: listmonk
POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD}
POSTGRES_DB: listmonk
volumes:
- listmonk_pgdata:/var/lib/postgresql/data
networks:
- listmonk-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk -d listmonk"]
interval: 10s
timeout: 5s
retries: 5
volumes:
listmonk_pgdata:
networks:
listmonk-internal:
driver: bridge
traefik-public:
external: true