diff --git a/src/actions/newsletter/@types/index.d.ts b/src/actions/newsletter/@types/index.d.ts index 369408bb..969db425 100644 --- a/src/actions/newsletter/@types/index.d.ts +++ b/src/actions/newsletter/@types/index.d.ts @@ -1,6 +1,7 @@ export type NewsletterFormData = { email: string firstName?: string + website_url?: string consentGiven?: boolean DataSubjectId?: string } @@ -22,6 +23,7 @@ export interface PendingSubscription { export type NewsletterSubscribeInput = { email: string firstName?: string + website_url?: string consentGiven?: boolean DataSubjectId?: string } diff --git a/src/actions/newsletter/__tests__/action.spec.ts b/src/actions/newsletter/__tests__/action.spec.ts index bf57f7ca..5d7d764e 100644 --- a/src/actions/newsletter/__tests__/action.spec.ts +++ b/src/actions/newsletter/__tests__/action.spec.ts @@ -138,6 +138,42 @@ beforeEach(() => { }) describe('newsletter.subscribe.handler', () => { + it('silently drops submissions that fill the honeypot field', async () => { + const { newsletter } = await import('../action') + const { createConsentRecord } = await import('@actions/gdpr/entities/consent') + const { createPendingSubscription } = await import('@actions/newsletter/domain') + const { sendConfirmationEmail } = await import('@actions/newsletter/entities/email') + + const context = { + request: new Request('https://example.com/_actions/newsletter/subscribe', { + method: 'POST', + headers: { 'user-agent': 'ua-bot' }, + }), + cookies: {} as unknown, + clientAddress: '203.0.113.9', + } + + const response = await getMockedHandler( + newsletter.subscribe + )( + { + email: 'test@example.com', + consentGiven: true, + website_url: 'https://spam.example', + }, + context + ) + + expect(response).toEqual({ + success: true, + message: 'Please check your email to confirm your subscription.', + requiresConfirmation: true, + }) + expect(createConsentRecord).not.toHaveBeenCalled() + expect(createPendingSubscription).not.toHaveBeenCalled() + expect(sendConfirmationEmail).not.toHaveBeenCalled() + }) + it('rejects when consent is missing', async () => { const { newsletter } = await import('../action') diff --git a/src/actions/newsletter/action.ts b/src/actions/newsletter/action.ts index 7d674c34..89ef70d0 100644 --- a/src/actions/newsletter/action.ts +++ b/src/actions/newsletter/action.ts @@ -22,6 +22,7 @@ import { const subscribeSchema = z.object({ email: z.string(), firstName: z.string().optional(), + website_url: z.string().trim().max(200).optional(), consentGiven: z.boolean().optional(), DataSubjectId: z.string().optional(), }) @@ -94,6 +95,11 @@ export const newsletter = { context ): Promise<{ success: true; message: string; requiresConfirmation: true }> => { const route = '/_actions/newsletter/subscribe' + const successResponse = { + success: true as const, + message: 'Please check your email to confirm your subscription.', + requiresConfirmation: true as const, + } let stage: NewsletterSubscribeStage = 'buildRequestFingerprint' let fingerprint: string | undefined let consentFunctional = false @@ -122,6 +128,10 @@ export const newsletter = { throw new ActionsFunctionError(`Try again in ${retryAfterSeconds}s`, { status: 429 }) } + if (body.website_url) { + return successResponse + } + stage = 'validateEmail' const validatedEmail = validateEmail(body.email) @@ -175,11 +185,7 @@ export const newsletter = { stage = 'sendConfirmationEmail' await sendConfirmationEmail(validatedEmail, token, body.firstName) - return { - success: true, - message: 'Please check your email to confirm your subscription.', - requiresConfirmation: true, - } + return successResponse } catch (error) { const errorContext = { route, diff --git a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts index 5eab3952..8ce25127 100644 --- a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts +++ b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts @@ -38,7 +38,7 @@ const defaultNewsletterProps: NewsletterProps = { variant: 'article', } -const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home'] +const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home', 'page'] const getElements = (root: NewsletterFormElement) => { const selectElement = (selector: string): T => { @@ -55,6 +55,7 @@ const getElements = (root: NewsletterFormElement) => { form: selectElement('#newsletter-form'), emailLabel: selectElement('#newsletter-email-label'), emailInput: selectElement('#newsletter-email'), + websiteUrlInput: selectElement('#newsletter-website_url'), consentCheckbox: selectElement('#newsletter-gdpr-consent'), submitButton: selectElement('#newsletter-submit'), buttonText: selectElement('#button-text'), @@ -113,6 +114,7 @@ describe.each(newsletterVariants)('NewsletterFormElement web component (%s)', va expect(elements.description.id).toBe('newsletter-cta-' + variant + '-description') expect(elements.form.id).toBe('newsletter-form') expect(elements.emailInput.id).toBe('newsletter-email') + expect(elements.websiteUrlInput.name).toBe('website_url') expect(elements.consentCheckbox.id).toBe('newsletter-gdpr-consent') expect(elements.title.textContent).toContain(defaultNewsletterProps.title) @@ -194,6 +196,27 @@ describe.each(newsletterVariants)('NewsletterFormElement web component (%s)', va }) }) + test('forwards the honeypot field when it is filled', async () => { + newsletterSubscribeMock.mockResolvedValueOnce({ + data: { success: true, message: 'Please check your email to confirm your subscription.' }, + }) + + await renderNewsletter(async ({ elements }) => { + elements.emailInput.value = 'test@example.com' + elements.websiteUrlInput.value = 'https://spam.example' + elements.consentCheckbox.checked = true + + elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + await flushPromises() + + expect(newsletterSubscribeMock).toHaveBeenCalledWith({ + email: 'test@example.com', + 'website_url': 'https://spam.example', + consentGiven: true, + }) + }) + }) + test('handles API error responses gracefully', async () => { newsletterSubscribeMock.mockResolvedValueOnce({ error: { message: 'Subscription failed' }, diff --git a/src/components/CallToAction/Newsletter/client/index.ts b/src/components/CallToAction/Newsletter/client/index.ts index 62cdd088..c22e0425 100644 --- a/src/components/CallToAction/Newsletter/client/index.ts +++ b/src/components/CallToAction/Newsletter/client/index.ts @@ -227,6 +227,8 @@ export class NewsletterFormElement extends LitElement { const email = this.emailInput.value.trim() const formData = this.form ? new FormData(this.form) : null const consentGiven = formData?.get('consent') === 'true' + const websiteUrlRaw = formData?.get('website_url') + const websiteUrl = typeof websiteUrlRaw === 'string' ? websiteUrlRaw.trim() : '' const dataSubjectIdRaw = formData?.get('DataSubjectId') const dataSubjectId = typeof dataSubjectIdRaw === 'string' ? dataSubjectIdRaw.trim() : '' @@ -263,6 +265,7 @@ export class NewsletterFormElement extends LitElement { try { result = await actions.newsletter.subscribe({ email, + ...(websiteUrl ? { 'website_url': websiteUrl } : {}), consentGiven, ...(DataSubjectId ? { DataSubjectId } : {}), }) diff --git a/src/components/CallToAction/Newsletter/layouts/article.astro b/src/components/CallToAction/Newsletter/layouts/article.astro index cb536a47..6a1c0a00 100644 --- a/src/components/CallToAction/Newsletter/layouts/article.astro +++ b/src/components/CallToAction/Newsletter/layouts/article.astro @@ -42,6 +42,21 @@ const {

+ +
+ +
diff --git a/src/components/CallToAction/Newsletter/layouts/page.astro b/src/components/CallToAction/Newsletter/layouts/page.astro index 60eb5a75..ef1eecda 100644 --- a/src/components/CallToAction/Newsletter/layouts/page.astro +++ b/src/components/CallToAction/Newsletter/layouts/page.astro @@ -48,6 +48,21 @@ const {
+ +