Skip to content

Commit 20cf663

Browse files
committed
Add honeypot field to Newsletter
1 parent bf09dde commit 20cf663

8 files changed

Lines changed: 121 additions & 6 deletions

File tree

‎src/actions/newsletter/@types/index.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export type NewsletterFormData = {
22
email: string
33
firstName?: string
4+
website_url?: string
45
consentGiven?: boolean
56
DataSubjectId?: string
67
}
@@ -22,6 +23,7 @@ export interface PendingSubscription {
2223
export type NewsletterSubscribeInput = {
2324
email: string
2425
firstName?: string
26+
website_url?: string
2527
consentGiven?: boolean
2628
DataSubjectId?: string
2729
}

‎src/actions/newsletter/__tests__/action.spec.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,42 @@ beforeEach(() => {
138138
})
139139

140140
describe('newsletter.subscribe.handler', () => {
141+
it('silently drops submissions that fill the honeypot field', async () => {
142+
const { newsletter } = await import('../action')
143+
const { createConsentRecord } = await import('@actions/gdpr/entities/consent')
144+
const { createPendingSubscription } = await import('@actions/newsletter/domain')
145+
const { sendConfirmationEmail } = await import('@actions/newsletter/entities/email')
146+
147+
const context = {
148+
request: new Request('https://example.com/_actions/newsletter/subscribe', {
149+
method: 'POST',
150+
headers: { 'user-agent': 'ua-bot' },
151+
}),
152+
cookies: {} as unknown,
153+
clientAddress: '203.0.113.9',
154+
}
155+
156+
const response = await getMockedHandler<NewsletterSubscribeInput, NewsletterSubscribeOutput>(
157+
newsletter.subscribe
158+
)(
159+
{
160+
email: 'test@example.com',
161+
consentGiven: true,
162+
website_url: 'https://spam.example',
163+
},
164+
context
165+
)
166+
167+
expect(response).toEqual({
168+
success: true,
169+
message: 'Please check your email to confirm your subscription.',
170+
requiresConfirmation: true,
171+
})
172+
expect(createConsentRecord).not.toHaveBeenCalled()
173+
expect(createPendingSubscription).not.toHaveBeenCalled()
174+
expect(sendConfirmationEmail).not.toHaveBeenCalled()
175+
})
176+
141177
it('rejects when consent is missing', async () => {
142178
const { newsletter } = await import('../action')
143179

‎src/actions/newsletter/action.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
const subscribeSchema = z.object({
2323
email: z.string(),
2424
firstName: z.string().optional(),
25+
website_url: z.string().trim().max(200).optional(),
2526
consentGiven: z.boolean().optional(),
2627
DataSubjectId: z.string().optional(),
2728
})
@@ -94,6 +95,11 @@ export const newsletter = {
9495
context
9596
): Promise<{ success: true; message: string; requiresConfirmation: true }> => {
9697
const route = '/_actions/newsletter/subscribe'
98+
const successResponse = {
99+
success: true as const,
100+
message: 'Please check your email to confirm your subscription.',
101+
requiresConfirmation: true as const,
102+
}
97103
let stage: NewsletterSubscribeStage = 'buildRequestFingerprint'
98104
let fingerprint: string | undefined
99105
let consentFunctional = false
@@ -122,6 +128,10 @@ export const newsletter = {
122128
throw new ActionsFunctionError(`Try again in ${retryAfterSeconds}s`, { status: 429 })
123129
}
124130

131+
if (body.website_url) {
132+
return successResponse
133+
}
134+
125135
stage = 'validateEmail'
126136
const validatedEmail = validateEmail(body.email)
127137

@@ -175,11 +185,7 @@ export const newsletter = {
175185
stage = 'sendConfirmationEmail'
176186
await sendConfirmationEmail(validatedEmail, token, body.firstName)
177187

178-
return {
179-
success: true,
180-
message: 'Please check your email to confirm your subscription.',
181-
requiresConfirmation: true,
182-
}
188+
return successResponse
183189
} catch (error) {
184190
const errorContext = {
185191
route,

‎src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const defaultNewsletterProps: NewsletterProps = {
3838
variant: 'article',
3939
}
4040

41-
const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home']
41+
const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home', 'page']
4242

4343
const getElements = (root: NewsletterFormElement) => {
4444
const selectElement = <T extends Element>(selector: string): T => {
@@ -55,6 +55,7 @@ const getElements = (root: NewsletterFormElement) => {
5555
form: selectElement<HTMLFormElement>('#newsletter-form'),
5656
emailLabel: selectElement<HTMLLabelElement>('#newsletter-email-label'),
5757
emailInput: selectElement<HTMLInputElement>('#newsletter-email'),
58+
websiteUrlInput: selectElement<HTMLInputElement>('#newsletter-website_url'),
5859
consentCheckbox: selectElement<HTMLInputElement>('#newsletter-gdpr-consent'),
5960
submitButton: selectElement<HTMLButtonElement>('#newsletter-submit'),
6061
buttonText: selectElement<HTMLSpanElement>('#button-text'),
@@ -113,6 +114,7 @@ describe.each(newsletterVariants)('NewsletterFormElement web component (%s)', va
113114
expect(elements.description.id).toBe('newsletter-cta-' + variant + '-description')
114115
expect(elements.form.id).toBe('newsletter-form')
115116
expect(elements.emailInput.id).toBe('newsletter-email')
117+
expect(elements.websiteUrlInput.name).toBe('website_url')
116118
expect(elements.consentCheckbox.id).toBe('newsletter-gdpr-consent')
117119

118120
expect(elements.title.textContent).toContain(defaultNewsletterProps.title)
@@ -194,6 +196,27 @@ describe.each(newsletterVariants)('NewsletterFormElement web component (%s)', va
194196
})
195197
})
196198

199+
test('forwards the honeypot field when it is filled', async () => {
200+
newsletterSubscribeMock.mockResolvedValueOnce({
201+
data: { success: true, message: 'Please check your email to confirm your subscription.' },
202+
})
203+
204+
await renderNewsletter(async ({ elements }) => {
205+
elements.emailInput.value = 'test@example.com'
206+
elements.websiteUrlInput.value = 'https://spam.example'
207+
elements.consentCheckbox.checked = true
208+
209+
elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
210+
await flushPromises()
211+
212+
expect(newsletterSubscribeMock).toHaveBeenCalledWith({
213+
email: 'test@example.com',
214+
website_url: 'https://spam.example',
215+
consentGiven: true,
216+
})
217+
})
218+
})
219+
197220
test('handles API error responses gracefully', async () => {
198221
newsletterSubscribeMock.mockResolvedValueOnce({
199222
error: { message: 'Subscription failed' },

‎src/components/CallToAction/Newsletter/client/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ export class NewsletterFormElement extends LitElement {
227227
const email = this.emailInput.value.trim()
228228
const formData = this.form ? new FormData(this.form) : null
229229
const consentGiven = formData?.get('consent') === 'true'
230+
const websiteUrlRaw = formData?.get('website_url')
231+
const websiteUrl = typeof websiteUrlRaw === 'string' ? websiteUrlRaw.trim() : ''
230232

231233
const dataSubjectIdRaw = formData?.get('DataSubjectId')
232234
const dataSubjectId = typeof dataSubjectIdRaw === 'string' ? dataSubjectIdRaw.trim() : ''
@@ -263,6 +265,7 @@ export class NewsletterFormElement extends LitElement {
263265
try {
264266
result = await actions.newsletter.subscribe({
265267
email,
268+
...(websiteUrl ? { website_url: websiteUrl } : {}),
266269
consentGiven,
267270
...(DataSubjectId ? { DataSubjectId } : {}),
268271
})

‎src/components/CallToAction/Newsletter/layouts/article.astro‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ const {
4242
</p>
4343

4444
<form id="newsletter-form" class="space-y-4">
45+
<div
46+
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
47+
aria-hidden="true"
48+
>
49+
<label for="newsletter-website_url">Website</label>
50+
<input
51+
type="text"
52+
id="newsletter-website_url"
53+
name="website_url"
54+
tabindex="-1"
55+
autocomplete="off"
56+
inputmode="url"
57+
/>
58+
</div>
59+
4560
<div class="flex flex-col md:flex-row gap-3 pt-4">
4661
<div
4762
class="relative flex items-center gap-2 flex-1 after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent focus-within:after:-inset-1 focus-within:after:border-spotlight"

‎src/components/CallToAction/Newsletter/layouts/home.astro‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,21 @@ const {
127127
</div>
128128

129129
<form id="newsletter-form" class="space-y-4">
130+
<div
131+
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
132+
aria-hidden="true"
133+
>
134+
<label for="newsletter-website_url">Website</label>
135+
<input
136+
type="text"
137+
id="newsletter-website_url"
138+
name="website_url"
139+
tabindex="-1"
140+
autocomplete="off"
141+
inputmode="url"
142+
/>
143+
</div>
144+
130145
<div
131146
class="relative space-y-1 after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent focus-within:after:-inset-1 focus-within:after:border-spotlight"
132147
>

‎src/components/CallToAction/Newsletter/layouts/page.astro‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ const {
4848
</div>
4949

5050
<form id="newsletter-form" class="space-y-5">
51+
<div
52+
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
53+
aria-hidden="true"
54+
>
55+
<label for="newsletter-website_url">Website</label>
56+
<input
57+
type="text"
58+
id="newsletter-website_url"
59+
name="website_url"
60+
tabindex="-1"
61+
autocomplete="off"
62+
inputmode="url"
63+
/>
64+
</div>
65+
5166
<div class="space-y-1">
5267
<label
5368
id="newsletter-email-label"

0 commit comments

Comments
 (0)