11import { Resend } from 'resend'
22import { v4 as uuidv4 , validate as uuidValidate } from 'uuid'
33import { ActionError , defineAction } from 'astro:actions'
4+ import { z } from 'astro/zod'
45import { checkContactRateLimit } from '@actions/utils/rateLimit'
56import { buildRequestFingerprint , createRateLimitIdentifier } from '@actions/utils/requestContext'
67import { getPrivacyPolicyVersion , getResendApiKey , isProd } from '@actions/utils/environment/environmentActions'
78import { ActionsFunctionError , throwActionError } from '@actions/utils/errors'
89import { createConsentRecord } from '@actions/gdpr/domain/consentStore'
9- import type { ContactFormData , FileAttachment , EmailData } from '@actions/contact/@types'
10- import { generateEmailContent , parseAttachments , validateInput } from './domain'
11- import { parseBoolean , readString } from './utils'
10+ import type { FileAttachment , EmailData } from '@actions/contact/@types'
11+ import {
12+ contactTimelineValues ,
13+ generateEmailContent ,
14+ getFormDataFromInput ,
15+ parseAttachmentsFromInput ,
16+ validateInput
17+ } from './domain'
18+
19+ const trimString = ( value : unknown ) : unknown => ( typeof value === 'string' ? value . trim ( ) : value )
20+
21+ const emptyStringToUndefined = ( value : unknown ) : unknown => {
22+ if ( typeof value !== 'string' ) return value
23+ const trimmed = value . trim ( )
24+ return trimmed . length === 0 ? undefined : trimmed
25+ }
26+
27+ const optionalTrimmedString = ( maxLength ?: number ) => {
28+ const base = z . string ( )
29+ const limited = typeof maxLength === 'number' ? base . max ( maxLength ) : base
30+ return z . preprocess ( emptyStringToUndefined , limited . optional ( ) )
31+ }
32+
33+ const requiredTrimmedString = ( minLength = 1 , maxLength ?: number ) => {
34+ const base = z . preprocess ( trimString , z . string ( ) . min ( minLength ) )
35+ return typeof maxLength === 'number' ? base . pipe ( z . string ( ) . max ( maxLength ) ) : base
36+ }
37+
38+ const isFile = ( value : unknown ) : value is File => typeof File !== 'undefined' && value instanceof File
39+ const optionalFile = ( ) => z . custom < File > ( isFile ) . optional ( )
40+
41+ const contactFormInputSchema = z
42+ . object ( {
43+ /** Core fields used by the action. */
44+ name : requiredTrimmedString ( 2 , 100 ) ,
45+ email : z . preprocess ( trimString , z . string ( ) . email ( ) . max ( 254 ) ) ,
46+ message : requiredTrimmedString ( 10 , 2000 ) ,
47+ /** Extra fields submitted by the form UI. */
48+ company : optionalTrimmedString ( 100 ) ,
49+ phone : optionalTrimmedString ( 50 ) ,
50+ 'project_type' : optionalTrimmedString ( 50 ) ,
51+ budget : z . preprocess ( trimString , z . enum ( [ '5k-10k' , '10k-25k' , '25k-50k' , '50k+' ] ) ) ,
52+ timeline : z . preprocess ( emptyStringToUndefined , z . enum ( contactTimelineValues ) . optional ( ) ) ,
53+ /** Consent checkbox: value="true" when checked, otherwise missing. */
54+ consent : z . preprocess ( ( value ) => ( value === 'true' ? true : false ) , z . boolean ( ) ) . optional ( ) ,
55+ /** Optional hidden field supported by the action. */
56+ 'DataSubjectId' : z . preprocess ( emptyStringToUndefined , z . string ( ) . uuid ( ) . optional ( ) ) ,
57+ /** Backwards-compatible optional fields (older contact forms). */
58+ service : optionalTrimmedString ( 100 ) ,
59+ website : optionalTrimmedString ( 200 ) ,
60+ /** File uploads (not implemented in the UI yet). When implemented, we expect keys like file1..file5. */
61+ file1 : optionalFile ( ) ,
62+ file2 : optionalFile ( ) ,
63+ file3 : optionalFile ( ) ,
64+ file4 : optionalFile ( ) ,
65+ file5 : optionalFile ( ) ,
66+ } )
67+ . passthrough ( )
1268
1369async function sendEmail ( emailData : EmailData , files : FileAttachment [ ] ) : Promise < void > {
1470 if ( ! isProd ( ) ) {
@@ -34,7 +90,8 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
3490export const contact = {
3591 submit : defineAction ( {
3692 accept : 'form' ,
37- handler : async ( form : FormData , context ) : Promise < { success : true ; message : string } > => {
93+ input : contactFormInputSchema ,
94+ handler : async ( input , context ) : Promise < { success : true ; message : string } > => {
3895 const route = '/_actions/contact/submit'
3996
4097 try {
@@ -50,26 +107,8 @@ export const contact = {
50107 throw new ActionsFunctionError ( 'Too many form submissions. Please try again later.' , { status : 429 } )
51108 }
52109
53- const formData : ContactFormData = {
54- name : readString ( form , 'name' ) ,
55- email : readString ( form , 'email' ) ,
56- message : readString ( form , 'message' ) ,
57- consent : parseBoolean ( form . get ( 'consent' ) ) ,
58- }
59-
60- const phone = readString ( form , 'phone' )
61- const service = readString ( form , 'service' )
62- const budget = readString ( form , 'budget' )
63- const timeline = readString ( form , 'timeline' )
64- const website = readString ( form , 'website' )
65-
66- if ( phone ) formData . phone = phone
67- if ( service ) formData . service = service
68- if ( budget ) formData . budget = budget
69- if ( timeline ) formData . timeline = timeline
70- if ( website ) formData . website = website
71-
72- const files = await parseAttachments ( form )
110+ const formData = getFormDataFromInput ( input )
111+ const files = await parseAttachmentsFromInput ( input )
73112
74113 const validationErrors = validateInput ( formData )
75114 if ( validationErrors . length > 0 ) {
0 commit comments