@@ -4,6 +4,7 @@ import { ActionError, defineAction } from 'astro:actions'
44import { checkContactRateLimit } from '@actions/utils/rateLimit'
55import { buildRequestFingerprint , createRateLimitIdentifier } from '@actions/utils/requestContext'
66import { getPrivacyPolicyVersion , getResendApiKey , isProd } from '@actions/utils/environment/environmentActions'
7+ import { ActionsFunctionError , throwActionError } from '@actions/utils/errors'
78import { createConsentRecord } from '@actions/gdpr/domain/consentStore'
89import type { ContactFormData , FileAttachment , EmailData } from '@actions/contact/@types'
910import { generateEmailContent , parseAttachments , validateInput } from './domain'
@@ -15,7 +16,6 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
1516 }
1617
1718 const resend = new Resend ( getResendApiKey ( ) )
18- // @TODO : Resend has a size limit of 40 MB
1919 const attachments = files . map ( file => ( { filename : file . filename , content : file . content } ) )
2020
2121 const result = await resend . emails . send ( {
@@ -27,97 +27,106 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
2727 } )
2828
2929 if ( ! result . data ) {
30- throw new ActionError ( { code : 'BAD_GATEWAY' , message : ' Failed to send email. Please try again later.' } )
30+ throw new ActionsFunctionError ( ' Failed to send email. Please try again later.', { status : 502 } )
3131 }
3232}
3333
3434export const contact = {
3535 submit : defineAction ( {
3636 accept : 'form' ,
3737 handler : async ( form : FormData , context ) : Promise < { success : true ; message : string } > => {
38- const { fingerprint } = buildRequestFingerprint ( {
39- route : '/_actions/contact/submit' ,
40- request : context . request ,
41- cookies : context . cookies ,
42- clientAddress : context . clientAddress ,
43- } )
44-
45- const rateLimitIdentifier = createRateLimitIdentifier ( 'contact' , fingerprint )
46- if ( ! checkContactRateLimit ( rateLimitIdentifier ) ) {
47- throw new ActionError ( {
48- code : 'TOO_MANY_REQUESTS' ,
49- message : 'Too many form submissions. Please try again later.' ,
38+ const route = '/_actions/contact/submit'
39+
40+ try {
41+ const { fingerprint } = buildRequestFingerprint ( {
42+ route,
43+ request : context . request ,
44+ cookies : context . cookies ,
45+ clientAddress : context . clientAddress ,
5046 } )
51- }
5247
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- }
48+ const rateLimitIdentifier = createRateLimitIdentifier ( 'contact' , fingerprint )
49+ if ( ! checkContactRateLimit ( rateLimitIdentifier ) ) {
50+ throw new ActionsFunctionError ( 'Too many form submissions. Please try again later.' , { status : 429 } )
51+ }
5952
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' )
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+ }
6559
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
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' )
7165
72- const files = await parseAttachments ( form )
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
7371
74- const validationErrors = validateInput ( formData )
75- if ( validationErrors . length > 0 ) {
76- throw new ActionError ( { code : 'BAD_REQUEST' , message : validationErrors [ 0 ] ?? 'Invalid form submission' } )
77- }
72+ const files = await parseAttachments ( form )
7873
79- const userAgent = context . request . headers . get ( 'user-agent' ) || 'unknown'
80- const ip =
81- context . clientAddress ||
82- context . request . headers . get ( 'x-forwarded-for' ) ?. split ( ',' ) [ 0 ] ?. trim ( ) ||
83- context . request . headers . get ( 'x-real-ip' ) ||
84- 'unknown'
85-
86- if ( formData . consent ) {
87- let subjectId = formData . DataSubjectId
88- if ( ! subjectId ) {
89- subjectId = uuidv4 ( )
90- } else if ( ! uuidValidate ( subjectId ) ) {
91- throw new ActionError ( { code : 'BAD_REQUEST' , message : 'Invalid DataSubjectId format' } )
74+ const validationErrors = validateInput ( formData )
75+ if ( validationErrors . length > 0 ) {
76+ throw new ActionsFunctionError ( validationErrors [ 0 ] ?? 'Invalid form submission' , { status : 400 } )
9277 }
9378
94- await createConsentRecord ( {
95- dataSubjectId : subjectId ,
96- email : formData . email . trim ( ) ,
97- purposes : [ 'contact' ] ,
98- source : 'contact_form' ,
99- userAgent,
100- ipAddress : ip !== 'unknown' ? ip : null ,
101- privacyPolicyVersion : getPrivacyPolicyVersion ( ) ,
102- consentText : null ,
103- verified : true ,
104- } )
105- }
79+ const userAgent = context . request . headers . get ( 'user-agent' ) || 'unknown'
80+ const ip =
81+ context . clientAddress ||
82+ context . request . headers . get ( 'x-forwarded-for' ) ?. split ( ',' ) [ 0 ] ?. trim ( ) ||
83+ context . request . headers . get ( 'x-real-ip' ) ||
84+ 'unknown'
85+
86+ if ( formData . consent ) {
87+ let subjectId = formData . DataSubjectId
88+ if ( ! subjectId ) {
89+ subjectId = uuidv4 ( )
90+ } else if ( ! uuidValidate ( subjectId ) ) {
91+ throw new ActionsFunctionError ( 'Invalid DataSubjectId format' , { status : 400 } )
92+ }
93+
94+ await createConsentRecord ( {
95+ dataSubjectId : subjectId ,
96+ email : formData . email . trim ( ) ,
97+ purposes : [ 'contact' ] ,
98+ source : 'contact_form' ,
99+ userAgent,
100+ ipAddress : ip !== 'unknown' ? ip : null ,
101+ privacyPolicyVersion : getPrivacyPolicyVersion ( ) ,
102+ consentText : null ,
103+ verified : true ,
104+ } )
105+ }
106106
107- const htmlContent = generateEmailContent ( formData , files )
108- await sendEmail (
109- {
110- from : 'contact@webstackbuilders.com' ,
111- to : 'info@webstackbuilders.com' ,
112- subject : `Contact Form: ${ formData . name } ` ,
113- html : htmlContent ,
114- } ,
115- files ,
116- )
117-
118- return {
119- success : true ,
120- message : 'Thank you for your message. We will get back to you soon!' ,
107+ const htmlContent = generateEmailContent ( formData , files )
108+ await sendEmail (
109+ {
110+ from : 'contact@webstackbuilders.com' ,
111+ to : 'info@webstackbuilders.com' ,
112+ subject : `Contact Form: ${ formData . name } ` ,
113+ html : htmlContent ,
114+ } ,
115+ files ,
116+ )
117+
118+ return {
119+ success : true ,
120+ message : 'Thank you for your message. We will get back to you soon!' ,
121+ }
122+ } catch ( error ) {
123+ if ( error instanceof ActionsFunctionError ) {
124+ throw error
125+ }
126+ if ( error instanceof ActionError ) {
127+ throw error
128+ }
129+ throwActionError ( error , { route, operation : 'submit' } )
121130 }
122131 } ,
123132 } ) ,
0 commit comments