mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
fix: newsletter double opt-in bug fixes
- Fix rate limiter: await formLimiter.check() (was missing await) - Prevent duplicate confirmation emails: add context.skipNewsletterEmail flag - Service sets flag when creating/updating subscribers via API - Hook skips email sending when flag is present - Admin panel creations still trigger the hook - Fix unsubscribe links: use subscriber ID instead of token for welcome/unsubscribe emails - Token is nullified after confirmation, making old links invalid - ID-based lookups always work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
79577626e2
commit
411f1a040e
3 changed files with 39 additions and 8 deletions
|
|
@ -5,7 +5,7 @@ import { getPayload } from 'payload'
|
|||
import config from '@payload-config'
|
||||
import { createNewsletterService } from '@/lib/email/newsletter-service'
|
||||
import { getTenantFromRequest } from '@/lib/email/tenant-email-service'
|
||||
import { rateLimiters } from '@/lib/security/rate-limiter'
|
||||
import { formLimiter } from '@/lib/security/rate-limiter'
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/subscribe
|
||||
|
|
@ -28,7 +28,7 @@ export async function POST(request: Request): Promise<Response> {
|
|||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
|
||||
const rateLimitResult = rateLimiters.form.check(clientIp)
|
||||
const rateLimitResult = await formLimiter.check(clientIp)
|
||||
if (!rateLimitResult.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
|
@ -38,7 +38,7 @@ export async function POST(request: Request): Promise<Response> {
|
|||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(Math.ceil(rateLimitResult.retryAfter / 1000)),
|
||||
'Retry-After': String(rateLimitResult.retryAfter || 60),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,14 +13,25 @@ import {
|
|||
* Hook: Sendet automatisch Double Opt-In E-Mail bei neuen Newsletter-Anmeldungen
|
||||
*
|
||||
* Wird nur bei neuen Subscribern mit Status "pending" ausgeführt.
|
||||
* Vermeidet doppelten Versand durch Prüfung ob E-Mail bereits über API gesendet wurde.
|
||||
* Vermeidet doppelten Versand durch Prüfung ob E-Mail bereits über API/Service gesendet wurde.
|
||||
*
|
||||
* Der Hook wird NICHT ausgeführt wenn:
|
||||
* - Die Erstellung via overrideAccess erfolgt (Newsletter-Service nutzt dies)
|
||||
* - Ein Kontext-Flag gesetzt ist
|
||||
*/
|
||||
export const sendNewsletterConfirmation: CollectionAfterChangeHook<NewsletterSubscriber> = async ({
|
||||
doc,
|
||||
operation,
|
||||
req,
|
||||
previousDoc,
|
||||
context,
|
||||
}) => {
|
||||
// Skip wenn via Newsletter-Service erstellt (verwendet overrideAccess)
|
||||
// Der Service sendet die E-Mail selbst
|
||||
if (context?.skipNewsletterEmail) {
|
||||
return doc
|
||||
}
|
||||
|
||||
// Nur bei neuen Anmeldungen (create) oder wenn Status auf "pending" geändert wird
|
||||
const isNew = operation === 'create'
|
||||
const isResubscribe =
|
||||
|
|
@ -74,7 +85,8 @@ export const sendNewsletterConfirmation: CollectionAfterChangeHook<NewsletterSub
|
|||
firstName: doc.firstName || undefined,
|
||||
email: doc.email,
|
||||
confirmationUrl: `${baseUrl}/api/newsletter/confirm?token=${doc.confirmationToken}`,
|
||||
unsubscribeUrl: `${baseUrl}/api/newsletter/unsubscribe?token=${doc.confirmationToken}`,
|
||||
// Für Confirmation-E-Mail Token verwenden (noch gültig), aber ID als Fallback
|
||||
unsubscribeUrl: `${baseUrl}/api/newsletter/unsubscribe?token=${doc.confirmationToken || doc.id}`,
|
||||
tenantName: tenant.name,
|
||||
tenantWebsite,
|
||||
privacyPolicyUrl: tenantWebsite ? `${tenantWebsite}/datenschutz` : undefined,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export class NewsletterService {
|
|||
subscribedAt: new Date().toISOString(),
|
||||
},
|
||||
overrideAccess: true,
|
||||
context: { skipNewsletterEmail: true }, // Hook soll keine E-Mail senden
|
||||
})
|
||||
|
||||
// Double Opt-In E-Mail erneut senden
|
||||
|
|
@ -133,6 +134,7 @@ export class NewsletterService {
|
|||
source: data.source || subscriber.source,
|
||||
},
|
||||
overrideAccess: true,
|
||||
context: { skipNewsletterEmail: true }, // Hook soll keine E-Mail senden
|
||||
})
|
||||
|
||||
await this.sendConfirmationEmail(tenantId, {
|
||||
|
|
@ -164,6 +166,7 @@ export class NewsletterService {
|
|||
tenant: tenantId,
|
||||
},
|
||||
overrideAccess: true,
|
||||
context: { skipNewsletterEmail: true }, // Hook soll keine E-Mail senden, Service macht das
|
||||
})
|
||||
|
||||
// Double Opt-In E-Mail senden
|
||||
|
|
@ -361,13 +364,16 @@ export class NewsletterService {
|
|||
|
||||
/**
|
||||
* Willkommens-E-Mail nach erfolgreicher Bestätigung senden
|
||||
* Verwendet immer die Subscriber-ID für Unsubscribe-Links,
|
||||
* da der Token nach Bestätigung gelöscht wird.
|
||||
*/
|
||||
private async sendWelcomeEmail(
|
||||
tenantId: number,
|
||||
subscriber: NewsletterSubscriber,
|
||||
): Promise<void> {
|
||||
const tenant = await this.getTenant(tenantId)
|
||||
const templateData = this.buildTemplateData(tenant, subscriber)
|
||||
// Immer ID für Unsubscribe verwenden, da Token nach Bestätigung null ist
|
||||
const templateData = this.buildTemplateData(tenant, subscriber, { useIdForUnsubscribe: true })
|
||||
|
||||
await sendTenantEmail(this.payload, tenantId, {
|
||||
to: subscriber.email,
|
||||
|
|
@ -384,13 +390,15 @@ export class NewsletterService {
|
|||
|
||||
/**
|
||||
* Abmelde-Bestätigung senden
|
||||
* Verwendet die Subscriber-ID für Links, da kein Token mehr benötigt wird.
|
||||
*/
|
||||
private async sendUnsubscribeEmail(
|
||||
tenantId: number,
|
||||
subscriber: NewsletterSubscriber,
|
||||
): Promise<void> {
|
||||
const tenant = await this.getTenant(tenantId)
|
||||
const templateData = this.buildTemplateData(tenant, subscriber)
|
||||
// ID für Links verwenden
|
||||
const templateData = this.buildTemplateData(tenant, subscriber, { useIdForUnsubscribe: true })
|
||||
|
||||
await sendTenantEmail(this.payload, tenantId, {
|
||||
to: subscriber.email,
|
||||
|
|
@ -419,10 +427,15 @@ export class NewsletterService {
|
|||
|
||||
/**
|
||||
* Template-Daten zusammenstellen
|
||||
*
|
||||
* Für Confirmation-E-Mails wird der Token verwendet.
|
||||
* Für Willkommens- und andere E-Mails wird immer die ID verwendet,
|
||||
* da der Token nach Bestätigung gelöscht wird.
|
||||
*/
|
||||
private buildTemplateData(
|
||||
tenant: Tenant,
|
||||
subscriber: NewsletterSubscriber,
|
||||
options?: { useIdForUnsubscribe?: boolean },
|
||||
): NewsletterTemplateData {
|
||||
// Tenant-Website URL ermitteln
|
||||
const tenantWebsite = tenant.domains?.[0]?.domain
|
||||
|
|
@ -434,11 +447,17 @@ export class NewsletterService {
|
|||
? `${tenantWebsite}/datenschutz`
|
||||
: undefined
|
||||
|
||||
// Für Unsubscribe immer ID verwenden wenn kein Token vorhanden
|
||||
// oder wenn explizit angefordert (z.B. für Willkommens-E-Mail nach Bestätigung)
|
||||
const unsubscribeToken = options?.useIdForUnsubscribe || !subscriber.confirmationToken
|
||||
? String(subscriber.id)
|
||||
: subscriber.confirmationToken
|
||||
|
||||
return {
|
||||
firstName: subscriber.firstName || undefined,
|
||||
email: subscriber.email,
|
||||
confirmationUrl: `${this.baseUrl}/api/newsletter/confirm?token=${subscriber.confirmationToken}`,
|
||||
unsubscribeUrl: `${this.baseUrl}/api/newsletter/unsubscribe?token=${subscriber.confirmationToken || subscriber.id}`,
|
||||
unsubscribeUrl: `${this.baseUrl}/api/newsletter/unsubscribe?token=${unsubscribeToken}`,
|
||||
tenantName: tenant.name,
|
||||
tenantWebsite,
|
||||
privacyPolicyUrl,
|
||||
|
|
|
|||
Loading…
Reference in a new issue