414 lines
14 KiB
JavaScript
414 lines
14 KiB
JavaScript
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
|
import { randomUUID } from 'crypto'
|
|
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../../../utils/newsletter.js'
|
|
import { encryptObject, decryptObject } from '../../../../../utils/encryption.js'
|
|
import nodemailer from 'nodemailer'
|
|
|
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
// filename is always a hardcoded constant (e.g., 'newsletter-posts.json'), never user input
|
|
const getDataPath = (filename) => {
|
|
const cwd = process.cwd()
|
|
if (cwd.endsWith('.output')) {
|
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
return path.join(cwd, '../server/data', filename)
|
|
}
|
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
return path.join(cwd, 'server/data', filename)
|
|
}
|
|
|
|
const NEWSLETTER_GROUPS_FILE = getDataPath('newsletter-groups.json')
|
|
const NEWSLETTER_POSTS_FILE = getDataPath('newsletter-posts.json')
|
|
|
|
async function readGroups() {
|
|
try {
|
|
const data = await fs.readFile(NEWSLETTER_GROUPS_FILE, 'utf-8')
|
|
return JSON.parse(data)
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return []
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function writeGroups(groups) {
|
|
await fs.writeFile(NEWSLETTER_GROUPS_FILE, JSON.stringify(groups, null, 2), 'utf-8')
|
|
}
|
|
|
|
async function readPosts() {
|
|
try {
|
|
const data = await fs.readFile(NEWSLETTER_POSTS_FILE, 'utf-8')
|
|
return JSON.parse(data)
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return []
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function writePosts(posts) {
|
|
await fs.writeFile(NEWSLETTER_POSTS_FILE, JSON.stringify(posts, null, 2), 'utf-8')
|
|
}
|
|
|
|
// Lädt Config für Logo und Clubname
|
|
async function loadConfig() {
|
|
try {
|
|
const configPath = getDataPath('config.json')
|
|
const data = await fs.readFile(configPath, 'utf-8')
|
|
return JSON.parse(data)
|
|
} catch {
|
|
return {
|
|
verein: { name: 'Harheimer Tischtennis-Club 1954 e.V.' }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lädt Logo als Base64
|
|
async function loadLogoAsBase64() {
|
|
try {
|
|
const logoPath = path.join(process.cwd(), 'public', 'images', 'logos', 'Harheimer TC.svg')
|
|
const logoData = await fs.readFile(logoPath, 'utf-8')
|
|
// SVG als Base64 kodieren
|
|
const base64Logo = Buffer.from(logoData).toString('base64')
|
|
return `data:image/svg+xml;base64,${base64Logo}`
|
|
} catch (error) {
|
|
console.error('Fehler beim Laden des Logos:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Erstellt Newsletter-HTML mit Header und Footer
|
|
async function createNewsletterHTML(post, group, unsubscribeToken = null, creatorName = null, creatorEmail = null) {
|
|
const config = await loadConfig()
|
|
const clubName = config.verein?.name || 'Harheimer Tischtennis-Club 1954 e.V.'
|
|
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
|
|
|
// Logo als Base64 laden
|
|
const logoDataUri = await loadLogoAsBase64()
|
|
|
|
let unsubscribeLink = ''
|
|
if (unsubscribeToken) {
|
|
const unsubscribeUrl = `${baseUrl}/newsletter/unsubscribe?token=${unsubscribeToken}`
|
|
unsubscribeLink = `
|
|
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 12px;">
|
|
<p>Sie erhalten diese E-Mail, weil Sie sich für unseren Newsletter angemeldet haben.</p>
|
|
<p style="margin-top: 10px;">
|
|
<a href="${unsubscribeUrl}" style="color: #dc2626; text-decoration: underline;">Newsletter abmelden</a>
|
|
</p>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
</head>
|
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f3f4f6;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 20px 0;">
|
|
<tr>
|
|
<td align="center">
|
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="background: linear-gradient(to right, #111827, #991b1b, #111827); padding: 30px;">
|
|
<table width="100%" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td width="50" valign="middle" style="padding-right: 15px;">
|
|
${logoDataUri ? `<img src="${logoDataUri}" alt="Harheimer TC Logo" style="width: 50px; height: 50px; display: block;" />` : ''}
|
|
</td>
|
|
<td valign="middle">
|
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: bold; font-family: 'Montserrat', Arial, sans-serif;">
|
|
Harheimer <span style="color: #fca5a5;">TC</span>
|
|
</h1>
|
|
<p style="margin: 5px 0 0 0; color: #e5e7eb; font-size: 14px;">
|
|
${clubName}
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Content -->
|
|
<tr>
|
|
<td style="padding: 30px;">
|
|
<h2 style="margin: 0 0 20px 0; color: #111827; font-size: 20px;">
|
|
${post.title}
|
|
</h2>
|
|
<div style="color: #374151; line-height: 1.6;">
|
|
${post.content}
|
|
</div>
|
|
${unsubscribeLink}
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="background-color: #f9fafb; padding: 20px; text-align: center; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb;">
|
|
<p style="margin: 0;">
|
|
${clubName}<br>
|
|
<a href="${baseUrl}" style="color: #dc2626; text-decoration: none;">${baseUrl}</a>
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
try {
|
|
// Authentifizierung prüfen
|
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
|
|
|
if (!token) {
|
|
throw createError({
|
|
statusCode: 401,
|
|
statusMessage: 'Nicht authentifiziert'
|
|
})
|
|
}
|
|
|
|
const user = await getUserFromToken(token)
|
|
if (!user || !hasAnyRole(user, 'admin', 'vorstand', 'newsletter')) {
|
|
throw createError({
|
|
statusCode: 403,
|
|
statusMessage: 'Keine Berechtigung'
|
|
})
|
|
}
|
|
|
|
const groupId = getRouterParam(event, 'id')
|
|
const body = await readBody(event)
|
|
const { title, content } = body
|
|
|
|
// Creator-Informationen für Absender
|
|
const creatorName = user.name || 'Harheimer TC'
|
|
const creatorEmail = user.email || process.env.SMTP_FROM || 'noreply@harheimertc.de'
|
|
|
|
if (!title || !content || (!content.trim() || content === '<p><br></p>')) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Titel und Inhalt sind erforderlich'
|
|
})
|
|
}
|
|
|
|
// Lade Gruppe
|
|
const groups = await readGroups()
|
|
const group = groups.find(g => g.id === groupId)
|
|
|
|
if (!group) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
statusMessage: 'Newsletter-Gruppe nicht gefunden'
|
|
})
|
|
}
|
|
|
|
// SMTP-Credentials prüfen
|
|
const smtpUser = process.env.SMTP_USER
|
|
const smtpPass = process.env.SMTP_PASS
|
|
|
|
if (!smtpUser || !smtpPass) {
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'SMTP-Credentials fehlen! Bitte setzen Sie SMTP_USER und SMTP_PASS in der .env Datei.'
|
|
})
|
|
}
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
|
port: process.env.SMTP_PORT || 587,
|
|
secure: false,
|
|
auth: {
|
|
user: smtpUser,
|
|
pass: smtpPass
|
|
}
|
|
})
|
|
|
|
// Empfänger bestimmen
|
|
let recipients = []
|
|
|
|
if (group.type === 'subscription') {
|
|
// Abonnenten-Newsletter
|
|
recipients = await getNewsletterSubscribers(!group.sendToExternal, group.id)
|
|
} else if (group.type === 'group') {
|
|
// Gruppen-Newsletter
|
|
recipients = await getRecipientsByGroup(group.targetGroup)
|
|
}
|
|
|
|
// Wenn keine Empfänger gefunden, Post trotzdem erstellen (aber nicht versenden)
|
|
if (recipients.length === 0) {
|
|
// Post ohne Versand erstellen
|
|
const posts = await readPosts()
|
|
const newPost = {
|
|
id: randomUUID(),
|
|
groupId,
|
|
title,
|
|
content,
|
|
createdAt: new Date().toISOString(),
|
|
createdBy: user.id,
|
|
sentAt: null,
|
|
sentTo: {
|
|
total: 0,
|
|
sent: 0,
|
|
failed: 0,
|
|
recipients: []
|
|
}
|
|
}
|
|
|
|
posts.push(newPost)
|
|
await writePosts(posts)
|
|
|
|
// Post-Count in Gruppe erhöhen
|
|
group.postCount = (group.postCount || 0) + 1
|
|
await writeGroups(groups)
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Post erfolgreich erstellt (keine Empfänger gefunden)',
|
|
post: {
|
|
id: newPost.id,
|
|
title: newPost.title,
|
|
groupId: newPost.groupId
|
|
},
|
|
stats: {
|
|
total: 0,
|
|
sent: 0,
|
|
failed: 0,
|
|
recipients: []
|
|
}
|
|
}
|
|
}
|
|
|
|
// Post erstellen
|
|
const posts = await readPosts()
|
|
const newPost = {
|
|
id: randomUUID(),
|
|
groupId,
|
|
title,
|
|
content,
|
|
createdAt: new Date().toISOString(),
|
|
createdBy: user.id,
|
|
sentAt: new Date().toISOString(),
|
|
sentTo: {
|
|
total: recipients.length,
|
|
sent: 0,
|
|
failed: 0,
|
|
failedEmails: []
|
|
}
|
|
}
|
|
|
|
// Newsletter versenden
|
|
let sentCount = 0
|
|
let failedCount = 0
|
|
const failedEmails = []
|
|
const errorDetails = []
|
|
|
|
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
console.log(`Versende Newsletter an ${recipients.length} Empfänger...`)
|
|
console.log('Empfänger:', recipients.map(r => r.email))
|
|
|
|
for (const recipient of recipients) {
|
|
try {
|
|
// Validiere E-Mail-Adresse
|
|
if (!recipient.email || !recipient.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
|
throw new Error(`Ungültige E-Mail-Adresse: ${recipient.email}`)
|
|
}
|
|
|
|
// Abmelde-Token generieren (nur für Abonnenten-Newsletter)
|
|
let unsubscribeToken = null
|
|
if (group.type === 'subscription') {
|
|
unsubscribeToken = await generateUnsubscribeToken(recipient.email)
|
|
}
|
|
|
|
const htmlContent = await createNewsletterHTML(newPost, group, unsubscribeToken, creatorName, creatorEmail)
|
|
|
|
const mailResult = await transporter.sendMail({
|
|
from: `"${creatorName}" <${creatorEmail}>`,
|
|
replyTo: creatorEmail,
|
|
to: recipient.email,
|
|
subject: title,
|
|
html: htmlContent
|
|
})
|
|
|
|
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
// recipient.email is validated and from trusted source (subscribers list)
|
|
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
console.log(`✅ Erfolgreich versendet an ${recipient.email}:`, mailResult.messageId)
|
|
sentCount++
|
|
} catch (error) {
|
|
const errorMsg = error.message || error.toString()
|
|
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
// recipient.email is validated and from trusted source (subscribers list)
|
|
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
console.error(`❌ Fehler beim Senden an ${recipient.email}:`, errorMsg)
|
|
failedCount++
|
|
failedEmails.push(recipient.email)
|
|
errorDetails.push({
|
|
email: recipient.email,
|
|
error: errorMsg
|
|
})
|
|
}
|
|
}
|
|
|
|
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
console.log(`Versand abgeschlossen: ${sentCount} erfolgreich, ${failedCount} fehlgeschlagen`)
|
|
|
|
// Post speichern mit Versand-Statistik und Empfängerliste
|
|
newPost.sentTo = {
|
|
total: recipients.length,
|
|
sent: sentCount,
|
|
failed: failedCount,
|
|
failedEmails: failedEmails.length > 0 ? failedEmails : undefined,
|
|
errorDetails: errorDetails.length > 0 ? errorDetails : undefined,
|
|
recipients: recipients.map(r => ({
|
|
email: r.email,
|
|
name: r.name || '',
|
|
sent: !failedEmails.includes(r.email)
|
|
}))
|
|
}
|
|
|
|
posts.push(newPost)
|
|
await writePosts(posts)
|
|
|
|
// Post-Count in Gruppe erhöhen
|
|
group.postCount = (group.postCount || 0) + 1
|
|
await writeGroups(groups)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Post erfolgreich erstellt und versendet`,
|
|
post: {
|
|
id: newPost.id,
|
|
title: newPost.title,
|
|
groupId: newPost.groupId
|
|
},
|
|
stats: {
|
|
total: recipients.length,
|
|
sent: sentCount,
|
|
failed: failedCount,
|
|
failedEmails: failedEmails.length > 0 ? failedEmails : undefined,
|
|
errorDetails: errorDetails.length > 0 ? errorDetails : undefined
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fehler beim Erstellen und Versenden des Posts:', error)
|
|
if (error.statusCode) {
|
|
throw error
|
|
}
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: error.message || 'Fehler beim Erstellen und Versenden des Posts'
|
|
})
|
|
}
|
|
})
|
|
|