feat: add confirmation emails and Listmonk newsletter sync
- Add nodemailer SMTP integration for payment confirmation emails via newsletter@cryptocommonsgather.ing - Add Listmonk PostgreSQL integration for newsletter subscriber sync - Update webhook to send confirmation email after Stripe payment - Add SMTP and Listmonk env vars to docker-compose Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7a16b4c884
commit
823102b908
|
|
@ -1,6 +1,8 @@
|
||||||
import { type NextRequest, NextResponse } from "next/server"
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
import Stripe from "stripe"
|
import Stripe from "stripe"
|
||||||
import { updatePaymentStatus } from "@/lib/google-sheets"
|
import { updatePaymentStatus } from "@/lib/google-sheets"
|
||||||
|
import { sendPaymentConfirmation } from "@/lib/email"
|
||||||
|
import { addToListmonk } from "@/lib/listmonk"
|
||||||
|
|
||||||
// Lazy initialization to avoid build-time errors
|
// Lazy initialization to avoid build-time errors
|
||||||
let stripe: Stripe | null = null
|
let stripe: Stripe | null = null
|
||||||
|
|
@ -61,7 +63,30 @@ export async function POST(request: NextRequest) {
|
||||||
console.error(`[Webhook] Failed to update Google Sheet for ${metadata.name}`)
|
console.error(`[Webhook] Failed to update Google Sheet for ${metadata.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Send confirmation email
|
// Send payment confirmation email
|
||||||
|
if (customerEmail) {
|
||||||
|
await sendPaymentConfirmation({
|
||||||
|
name: metadata.name || "",
|
||||||
|
email: customerEmail,
|
||||||
|
amountPaid: session.amount_total
|
||||||
|
? `€${(session.amount_total / 100).toFixed(2)}`
|
||||||
|
: "",
|
||||||
|
paymentMethod: session.payment_method_types?.[0] || "card",
|
||||||
|
contributions: metadata.contributions || "",
|
||||||
|
dietary: metadata.dietary || "",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to Listmonk newsletter
|
||||||
|
addToListmonk({
|
||||||
|
email: customerEmail,
|
||||||
|
name: metadata.name || "",
|
||||||
|
attribs: {
|
||||||
|
contact: metadata.contact,
|
||||||
|
contributions: metadata.contributions,
|
||||||
|
expectations: metadata.expectations,
|
||||||
|
},
|
||||||
|
}).catch((err) => console.error("[Webhook] Listmonk sync failed:", err))
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,35 @@ services:
|
||||||
- GOOGLE_SERVICE_ACCOUNT_KEY=${GOOGLE_SERVICE_ACCOUNT_KEY}
|
- GOOGLE_SERVICE_ACCOUNT_KEY=${GOOGLE_SERVICE_ACCOUNT_KEY}
|
||||||
- GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID}
|
- GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID}
|
||||||
- GOOGLE_SHEET_NAME=${GOOGLE_SHEET_NAME:-Registrations}
|
- GOOGLE_SHEET_NAME=${GOOGLE_SHEET_NAME:-Registrations}
|
||||||
|
- SMTP_HOST=${SMTP_HOST:-mail.rmail.online}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
- SMTP_USER=${SMTP_USER:-newsletter@cryptocommonsgather.ing}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- EMAIL_FROM=${EMAIL_FROM:-Crypto Commons Gathering <newsletter@cryptocommonsgather.ing>}
|
||||||
|
- LISTMONK_DB_HOST=${LISTMONK_DB_HOST:-listmonk-db}
|
||||||
|
- LISTMONK_DB_PORT=${LISTMONK_DB_PORT:-5432}
|
||||||
|
- LISTMONK_DB_NAME=${LISTMONK_DB_NAME:-listmonk}
|
||||||
|
- LISTMONK_DB_USER=${LISTMONK_DB_USER:-listmonk}
|
||||||
|
- LISTMONK_DB_PASS=${LISTMONK_DB_PASS}
|
||||||
|
- LISTMONK_LIST_ID=${LISTMONK_LIST_ID:-22}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.ccg.rule=Host(`cryptocommonsgather.ing`) || Host(`www.cryptocommonsgather.ing`)"
|
- "traefik.http.routers.ccg.rule=Host(`cryptocommonsgather.ing`) || Host(`www.cryptocommonsgather.ing`)"
|
||||||
- "traefik.http.routers.ccg.entrypoints=web,websecure"
|
- "traefik.http.routers.ccg.entrypoints=web,websecure"
|
||||||
- "traefik.http.services.ccg.loadbalancer.server.port=3000"
|
- "traefik.http.services.ccg.loadbalancer.server.port=3000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- traefik-public
|
||||||
|
- listmonk-internal
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
|
listmonk-internal:
|
||||||
|
external:
|
||||||
|
name: listmonk_listmonk-internal
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import nodemailer from "nodemailer"
|
||||||
|
|
||||||
|
// Lazy-initialized SMTP transport (Mailcow)
|
||||||
|
let transporter: nodemailer.Transporter | null = null
|
||||||
|
|
||||||
|
function getTransporter() {
|
||||||
|
if (!transporter && process.env.SMTP_PASS) {
|
||||||
|
transporter = 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 || "newsletter@cryptocommonsgather.ing",
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return transporter
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_FROM =
|
||||||
|
process.env.EMAIL_FROM ||
|
||||||
|
"Crypto Commons Gathering <newsletter@cryptocommonsgather.ing>"
|
||||||
|
|
||||||
|
interface PaymentConfirmationData {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
amountPaid: string
|
||||||
|
paymentMethod: string
|
||||||
|
contributions: string
|
||||||
|
dietary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPaymentConfirmation(
|
||||||
|
data: PaymentConfirmationData
|
||||||
|
): Promise<boolean> {
|
||||||
|
const transport = getTransporter()
|
||||||
|
if (!transport) {
|
||||||
|
console.log("[Email] SMTP not configured, skipping confirmation email")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<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: #f59e0b; margin-bottom: 8px;">You're In!</h1>
|
||||||
|
<p style="font-size: 15px; color: #92400e; margin-top: 0; margin-bottom: 28px; font-style: italic;">Crypto Commons Gathering 2026</p>
|
||||||
|
|
||||||
|
<p>Dear ${data.name},</p>
|
||||||
|
|
||||||
|
<p>Your payment of <strong>${data.amountPaid}</strong> has been confirmed. You are now registered for <strong>Crypto Commons Gathering 2026</strong> in Austria's Höllental Valley, August 16–22, 2026.</p>
|
||||||
|
|
||||||
|
<div style="background: #fffbeb; padding: 20px; border-radius: 8px; margin: 24px 0; border-left: 3px solid #f59e0b;">
|
||||||
|
<h3 style="margin-top: 0; color: #92400e;">Registration Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0;"><strong>Amount:</strong></td>
|
||||||
|
<td style="padding: 4px 0;">${data.amountPaid}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0;"><strong>Payment:</strong></td>
|
||||||
|
<td style="padding: 4px 0;">${data.paymentMethod}</td>
|
||||||
|
</tr>
|
||||||
|
${data.dietary ? `<tr><td style="padding: 4px 0;"><strong>Dietary:</strong></td><td style="padding: 4px 0;">${data.dietary}</td></tr>` : ""}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${data.contributions ? `
|
||||||
|
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0;">
|
||||||
|
<strong style="color: #92400e;">What you're bringing:</strong>
|
||||||
|
<p style="margin-bottom: 0; margin-top: 8px;">${data.contributions}</p>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
|
<h3 style="color: #92400e;">What's Next?</h3>
|
||||||
|
<ul style="line-height: 1.8;">
|
||||||
|
<li>Join the <a href="https://t.me/+n5V_wDVKWrk1ZTBh" style="color: #f59e0b;">CCG26 Telegram group</a> to connect with other participants</li>
|
||||||
|
<li>Start preparing your session proposals</li>
|
||||||
|
<li>Watch for pre-event communications with logistics details</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 32px;">
|
||||||
|
See you in the valley,<br>
|
||||||
|
<strong>The Crypto Commons Gathering Team</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd; margin: 32px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">
|
||||||
|
You received this email because you registered at cryptocommonsgather.ing.<br>
|
||||||
|
<a href="https://cryptocommonsgather.ing">cryptocommonsgather.ing</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transport.sendMail({
|
||||||
|
from: EMAIL_FROM,
|
||||||
|
to: data.email,
|
||||||
|
subject: "Registration Confirmed - Crypto Commons Gathering 2026",
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
console.log(
|
||||||
|
`[Email] Payment confirmation sent to ${data.email} (${info.messageId})`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Email] Failed to send payment confirmation:", error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import pg from "pg"
|
||||||
|
|
||||||
|
const { Pool } = pg
|
||||||
|
|
||||||
|
// Lazy-initialized Listmonk PostgreSQL connection
|
||||||
|
let pool: pg.Pool | null = null
|
||||||
|
|
||||||
|
function getPool() {
|
||||||
|
if (!pool && process.env.LISTMONK_DB_HOST) {
|
||||||
|
pool = new Pool({
|
||||||
|
host: process.env.LISTMONK_DB_HOST,
|
||||||
|
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 || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID || "22") // Crypto Commons list
|
||||||
|
|
||||||
|
interface SubscriberData {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
attribs?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addToListmonk(data: SubscriberData): Promise<boolean> {
|
||||||
|
const db = getPool()
|
||||||
|
if (!db) {
|
||||||
|
console.log("[Listmonk] Database not configured, skipping")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await db.connect()
|
||||||
|
try {
|
||||||
|
const attribs = {
|
||||||
|
ccg: {
|
||||||
|
...data.attribs,
|
||||||
|
registeredAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if subscriber exists
|
||||||
|
const existing = await client.query(
|
||||||
|
"SELECT id, attribs FROM subscribers WHERE email = $1",
|
||||||
|
[data.email]
|
||||||
|
)
|
||||||
|
|
||||||
|
let subscriberId: number
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
subscriberId = existing.rows[0].id
|
||||||
|
const mergedAttribs = { ...existing.rows[0].attribs, ...attribs }
|
||||||
|
await client.query(
|
||||||
|
"UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3",
|
||||||
|
[data.name, JSON.stringify(mergedAttribs), subscriberId]
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
`[Listmonk] Updated existing subscriber: ${data.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`,
|
||||||
|
[data.email, data.name, JSON.stringify(attribs)]
|
||||||
|
)
|
||||||
|
subscriberId = result.rows[0].id
|
||||||
|
console.log(
|
||||||
|
`[Listmonk] Created new subscriber: ${data.email} (ID: ${subscriberId})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to Crypto Commons 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 list ${LISTMONK_LIST_ID}: ${data.email}`)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Listmonk] Error:", error)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,6 @@
|
||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
"@vercel/analytics": "latest",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -45,6 +44,8 @@
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
|
|
@ -65,6 +66,8 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"@types/nodemailer": "^6.4.0",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue