Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions src/lib/components/UKMPEmailForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { micromark } from 'micromark'
import LoadingSpinner from './LoadingSpinner.svelte'
import Link from '$lib/components/Link.svelte'
import Turnstile from '$lib/components/Turnstile.svelte'
import { turnstileSiteKey } from '$lib/turnstile'
import { slide } from 'svelte/transition'

interface Props {
Expand Down Expand Up @@ -44,6 +46,16 @@ ${userPostcode.toUpperCase()}`)
let submitStatus: 'idle' | 'success' | 'error' = $state('idle')
let errorMessage = $state('')
let confirmingSend = $state(false)
let honeypot = $state('')
let turnstileToken = $state('')

// Bumped after each submission to remount the widget: Turnstile tokens are
// single-use, so a resubmit with the same token would be rejected.
let turnstileNonce = $state(0)

// Without a configured site key (e.g. local development) there is no widget
// to wait for, and the server decides whether to accept the submission.
const canSubmit = $derived(!isSubmitting && (!turnstileSiteKey || turnstileToken !== ''))

let htmlPreview = $derived(micromark(message))

Expand Down Expand Up @@ -178,7 +190,9 @@ ${userPostcode.toUpperCase()}`)
senderPostcode: userPostcode,
recipient: mp.email,
subject: subject.trim(),
message: message.trim()
message: message.trim(),
nickname: honeypot,
turnstileToken
})
})

Expand All @@ -198,6 +212,10 @@ ${userPostcode.toUpperCase()}`)
console.error('Email submission error:', error)
} finally {
isSubmitting = false

// The token has now been spent (or rejected) either way — get a fresh one.
turnstileToken = ''
turnstileNonce += 1
}
}
</script>
Expand All @@ -214,6 +232,18 @@ ${userPostcode.toUpperCase()}`)
onsubmit?.(e)
}}
>
<div class="form-group honey">
<label for="mp-nickname">Nickname</label>
<input
type="text"
id="mp-nickname"
name="nickname"
tabindex="-1"
autocomplete="off"
bind:value={honeypot}
/>
</div>

<div class="form-group">
<label for="sender-email">Your email</label>
<input
Expand Down Expand Up @@ -331,6 +361,9 @@ ${userPostcode.toUpperCase()}`)
{#if confirmingSend}
<div class="confirm-box" in:slide={{ duration: 250 }}>
<p class="confirm-prompt">Send this email to <strong>{mp.name}</strong>?</p>
{#key turnstileNonce}
<Turnstile bind:token={turnstileToken} />
{/key}
<div class="confirm-actions">
<button
type="button"
Expand All @@ -343,7 +376,7 @@ ${userPostcode.toUpperCase()}`)
<button
type="button"
class="submit-button confirm-button"
disabled={isSubmitting}
disabled={!canSubmit}
onclick={handleSubmit}
>
{#if isSubmitting}
Expand Down Expand Up @@ -389,6 +422,17 @@ ${userPostcode.toUpperCase()}`)
padding-top: 1.5rem;
}

.honey {
display: none;
opacity: 0;
position: absolute;
left: -9999px;
height: 0;
width: 0;
overflow: hidden;
z-index: -1;
}

label {
font-weight: 500;
color: var(--text);
Expand Down
23 changes: 22 additions & 1 deletion src/routes/api/uk-send-mp-email/+server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AIRTABLE_API_KEY, AIRTABLE_WRITE_API_KEY } from '$env/static/private'
import type { AirtableListResponse, AirtableRecord } from '$lib/airtable'
import { validMPEmails } from '$lib/server/uk-postcode-to-mp.js'
import { checkNotSpam } from '$lib/server/turnstile-verify'
import { TURNSTILE_FIELD } from '$lib/turnstile'
import { json } from '@sveltejs/kit'
import { StatusCodes } from 'http-status-codes'
import type { RequestHandler } from './$types'
Expand Down Expand Up @@ -39,6 +41,8 @@ interface EmailRequest {
recipient: string
subject: string
message: string
nickname?: string
turnstileToken?: string
}

type UKSendMPEmailApiSuccessResponse = {
Expand All @@ -54,7 +58,7 @@ type UKSendMPEmailApiErrorResponse = {
export type UKSendMPEmailApiResponse =
UKSendMPEmailApiSuccessResponse | UKSendMPEmailApiErrorResponse

export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, url }) => {
if (!AIRTABLE_API_KEY || !AIRTABLE_WRITE_API_KEY) {
return json(
{
Expand Down Expand Up @@ -84,6 +88,23 @@ export const POST: RequestHandler = async ({ request }) => {
try {
const data = (await request.json()) as EmailRequest

// The endpoint is JSON-based rather than a form action, so the honeypot and
// Turnstile token are wrapped into a FormData shim for the shared spam check.
const spamCheckData = new FormData()
if (data.nickname) spamCheckData.set('nickname', data.nickname)
if (data.turnstileToken) spamCheckData.set(TURNSTILE_FIELD, data.turnstileToken)

const spam = await checkNotSpam(spamCheckData, url.hostname)
if (spam.drop) {
return json({ success: true, recordId: '' } satisfies UKSendMPEmailApiResponse)
}
if (spam.message) {
return json(
{ error: 'validation', message: spam.message } satisfies UKSendMPEmailApiResponse,
{ status: StatusCodes.FORBIDDEN }
)
}

// Basic validation
if (
!data.senderEmail ||
Expand Down
Loading