@@ -26,6 +26,61 @@ const confirmSchema = z.object({
2626 token : z . string ( ) . min ( 1 ) ,
2727} )
2828
29+ type NewsletterSubscribeStage =
30+ | 'buildRequestFingerprint'
31+ | 'checkRateLimit'
32+ | 'validateEmail'
33+ | 'validateConsent'
34+ | 'resolveDataSubjectId'
35+ | 'createConsentRecord'
36+ | 'createPendingSubscription'
37+ | 'sendConfirmationEmail'
38+
39+ const getEmailDomain = ( email : string ) : string | undefined => {
40+ const normalizedEmail = email . trim ( ) . toLowerCase ( )
41+ const atIndex = normalizedEmail . lastIndexOf ( '@' )
42+
43+ if ( atIndex === - 1 || atIndex === normalizedEmail . length - 1 ) {
44+ return undefined
45+ }
46+
47+ return normalizedEmail . slice ( atIndex + 1 )
48+ }
49+
50+ const buildSubscribeErrorExtra = ( options : {
51+ body : z . infer < typeof subscribeSchema >
52+ fingerprint ?: string
53+ consentFunctional : boolean
54+ stage : NewsletterSubscribeStage
55+ userAgent : string
56+ clientAddress ?: string
57+ rateLimitIdentifier ?: string
58+ subjectIdSource : 'generated' | 'provided' | 'pending'
59+ } ) : Record < string , unknown > => {
60+ return {
61+ stage : options . stage ,
62+ source : 'newsletter_form' ,
63+ consentFunctional : options . consentFunctional ,
64+ fingerprint : options . fingerprint ,
65+ request : {
66+ hasClientAddress :
67+ typeof options . clientAddress === 'string' && options . clientAddress !== 'unknown' ,
68+ hasUserAgent : options . userAgent !== 'unknown' ,
69+ rateLimitIdentifier : options . rateLimitIdentifier ,
70+ } ,
71+ input : {
72+ emailDomain : getEmailDomain ( options . body . email ) ,
73+ emailLength : options . body . email . trim ( ) . length ,
74+ consentGiven : Boolean ( options . body . consentGiven ) ,
75+ hasFirstName :
76+ typeof options . body . firstName === 'string' && options . body . firstName . trim ( ) . length > 0 ,
77+ hasDataSubjectId :
78+ typeof options . body . DataSubjectId === 'string' && options . body . DataSubjectId . length > 0 ,
79+ subjectIdSource : options . subjectIdSource ,
80+ } ,
81+ }
82+ }
83+
2984export const newsletter = {
3085 subscribe : defineAction ( {
3186 accept : 'json' ,
@@ -35,16 +90,26 @@ export const newsletter = {
3590 context
3691 ) : Promise < { success : true ; message : string ; requiresConfirmation : true } > => {
3792 const route = '/_actions/newsletter/subscribe'
93+ let stage : NewsletterSubscribeStage = 'buildRequestFingerprint'
94+ let fingerprint : string | undefined
95+ let consentFunctional = false
96+ let rateLimitIdentifier : string | undefined
97+ let subjectIdSource : 'generated' | 'provided' | 'pending' = 'pending'
98+ const userAgent = context . request . headers . get ( 'user-agent' ) || 'unknown'
3899
39100 try {
40- const { fingerprint } = buildRequestFingerprint ( {
101+ const requestFingerprint = buildRequestFingerprint ( {
41102 route,
42103 request : context . request ,
43104 cookies : context . cookies ,
44105 clientAddress : context . clientAddress ,
45106 } )
46107
47- const rateLimitIdentifier = createRateLimitIdentifier ( 'newsletter:consent' , fingerprint )
108+ fingerprint = requestFingerprint . fingerprint
109+ consentFunctional = requestFingerprint . consentFunctional
110+
111+ stage = 'checkRateLimit'
112+ rateLimitIdentifier = createRateLimitIdentifier ( 'newsletter:consent' , fingerprint )
48113 const { success, reset } = await checkRateLimit ( rateLimiters . consent , rateLimitIdentifier )
49114
50115 if ( ! success ) {
@@ -53,24 +118,29 @@ export const newsletter = {
53118 throw new ActionsFunctionError ( `Try again in ${ retryAfterSeconds } s` , { status : 429 } )
54119 }
55120
121+ stage = 'validateEmail'
56122 const validatedEmail = validateEmail ( body . email )
57123
124+ stage = 'validateConsent'
58125 if ( ! body . consentGiven ) {
59126 throw new ActionsFunctionError (
60127 'You must consent to receive marketing emails to subscribe.' ,
61128 { status : 400 }
62129 )
63130 }
64131
65- const userAgent = context . request . headers . get ( 'user-agent' ) || 'unknown'
66-
132+ stage = 'resolveDataSubjectId'
67133 let subjectId = body . DataSubjectId
68134 if ( ! subjectId ) {
69135 subjectId = uuidv4 ( )
136+ subjectIdSource = 'generated'
70137 } else if ( ! uuidValidate ( subjectId ) ) {
71138 throw new ActionsFunctionError ( 'Invalid DataSubjectId format' , { status : 400 } )
139+ } else {
140+ subjectIdSource = 'provided'
72141 }
73142
143+ stage = 'createConsentRecord'
74144 await createConsentRecord ( {
75145 dataSubjectId : subjectId ,
76146 email : validatedEmail ,
@@ -86,6 +156,7 @@ export const newsletter = {
86156 verified : false ,
87157 } )
88158
159+ stage = 'createPendingSubscription'
89160 const token = await createPendingSubscription ( {
90161 email : validatedEmail ,
91162 ...( body . firstName && { firstName : body . firstName } ) ,
@@ -96,6 +167,7 @@ export const newsletter = {
96167 source : 'newsletter_form' ,
97168 } )
98169
170+ stage = 'sendConfirmationEmail'
99171 await sendConfirmationEmail ( validatedEmail , token , body . firstName )
100172
101173 return {
@@ -104,10 +176,27 @@ export const newsletter = {
104176 requiresConfirmation : true ,
105177 }
106178 } catch ( error ) {
179+ const errorContext = {
180+ route,
181+ operation : 'subscribe' ,
182+ extra : buildSubscribeErrorExtra ( {
183+ body,
184+ fingerprint,
185+ consentFunctional,
186+ stage,
187+ userAgent,
188+ clientAddress : context . clientAddress ,
189+ rateLimitIdentifier,
190+ subjectIdSource,
191+ } ) ,
192+ } as const
193+
107194 if ( error instanceof ActionsFunctionError ) {
195+ handleActionsFunctionError ( error , errorContext )
108196 throw error
109197 }
110- throwActionError ( error , { route, operation : 'subscribe' } )
198+
199+ throwActionError ( error , errorContext )
111200 }
112201 } ,
113202 } ) ,
0 commit comments