1+ // Netlify function for email subscription using Brevo API
2+
3+ const brevo = require ( '@getbrevo/brevo' ) ;
4+
5+ // Email validation function for serverless backend
6+ function isValidEmail ( email ) {
7+ const emailRegex = / ^ [ ^ \s @ ] + @ [ ^ \s @ ] + \. [ ^ \s @ ] + $ / ;
8+
9+ if ( ! email || typeof email !== 'string' ) {
10+ return false ;
11+ }
12+
13+ if ( ! emailRegex . test ( email ) ) {
14+ return false ;
15+ }
16+
17+ if ( email . length > 254 ) {
18+ return false ;
19+ }
20+
21+ const [ localPart , domain ] = email . split ( '@' ) ;
22+ if ( localPart . length > 64 || domain . length > 253 ) {
23+ return false ;
24+ }
25+
26+ return true ;
27+ }
28+
29+ const BREVO_LIST_ID = 100 ;
30+
31+ // Simple in-memory rate limiting (resets on cold starts)
32+ const rateLimitMap = new Map ( ) ;
33+ const RATE_LIMIT_WINDOW = 60 * 1000 ; // 1 minute
34+ const MAX_REQUESTS_PER_WINDOW = 5 ; // 5 requests per minute per IP
35+ const MAX_EMAIL_REQUESTS_PER_HOUR = 3 ; // 3 submissions per hour per email
36+
37+ // Cleanup old entries periodically
38+ setInterval ( ( ) => {
39+ const now = Date . now ( ) ;
40+ for ( const [ key , data ] of rateLimitMap . entries ( ) ) {
41+ if ( now - data . firstRequest > RATE_LIMIT_WINDOW ) {
42+ rateLimitMap . delete ( key ) ;
43+ }
44+ }
45+ } , RATE_LIMIT_WINDOW ) ;
46+
47+ function isRateLimited ( identifier , maxRequests = MAX_REQUESTS_PER_WINDOW ) {
48+ const now = Date . now ( ) ;
49+ const key = identifier ;
50+
51+ if ( ! rateLimitMap . has ( key ) ) {
52+ rateLimitMap . set ( key , { count : 1 , firstRequest : now } ) ;
53+ return false ;
54+ }
55+
56+ const data = rateLimitMap . get ( key ) ;
57+
58+ // Reset if window expired
59+ if ( now - data . firstRequest > RATE_LIMIT_WINDOW ) {
60+ rateLimitMap . set ( key , { count : 1 , firstRequest : now } ) ;
61+ return false ;
62+ }
63+
64+ // Check if over limit
65+ if ( data . count >= maxRequests ) {
66+ return true ;
67+ }
68+
69+ // Increment counter
70+ data . count ++ ;
71+ return false ;
72+ }
73+
74+ exports . handler = async ( event , context ) => {
75+ // Only allow POST requests
76+ if ( event . httpMethod !== 'POST' ) {
77+ return {
78+ statusCode : 405 ,
79+ headers : {
80+ 'Content-Type' : 'application/json' ,
81+ 'Access-Control-Allow-Origin' : '*' ,
82+ 'Access-Control-Allow-Methods' : 'POST' ,
83+ 'Access-Control-Allow-Headers' : 'Content-Type' ,
84+ } ,
85+ body : JSON . stringify ( { error : 'Method not allowed' } ) ,
86+ } ;
87+ }
88+
89+ // Get client IP for rate limiting
90+ const clientIP = event . headers [ 'client-ip' ] || event . headers [ 'x-forwarded-for' ] || 'unknown' ;
91+
92+ // Rate limit by IP
93+ if ( isRateLimited ( `ip:${ clientIP } ` ) ) {
94+ return {
95+ statusCode : 429 ,
96+ headers : {
97+ 'Content-Type' : 'application/json' ,
98+ 'Access-Control-Allow-Origin' : '*' ,
99+ 'Retry-After' : '60' ,
100+ } ,
101+ body : JSON . stringify ( {
102+ error : 'Too many requests. Please try again in a minute.' ,
103+ retryAfter : 60
104+ } ) ,
105+ } ;
106+ }
107+
108+ try {
109+ // Basic request validation
110+ if ( ! event . body ) {
111+ return {
112+ statusCode : 400 ,
113+ headers : {
114+ 'Content-Type' : 'application/json' ,
115+ 'Access-Control-Allow-Origin' : '*' ,
116+ } ,
117+ body : JSON . stringify ( { error : 'Request body is required' } ) ,
118+ } ;
119+ }
120+
121+ // Limit request body size (prevent large payload attacks)
122+ if ( event . body . length > 1000 ) {
123+ return {
124+ statusCode : 413 ,
125+ headers : {
126+ 'Content-Type' : 'application/json' ,
127+ 'Access-Control-Allow-Origin' : '*' ,
128+ } ,
129+ body : JSON . stringify ( { error : 'Request too large' } ) ,
130+ } ;
131+ }
132+
133+ const { email, source } = JSON . parse ( event . body ) ;
134+
135+ if ( ! email || typeof email !== 'string' || ! isValidEmail ( email ) ) {
136+ return {
137+ statusCode : 400 ,
138+ headers : {
139+ 'Content-Type' : 'application/json' ,
140+ 'Access-Control-Allow-Origin' : '*' ,
141+ } ,
142+ body : JSON . stringify ( { error : 'Please provide a valid email address' } ) ,
143+ } ;
144+ }
145+
146+ // Normalize and sanitize email
147+ const normalizedEmail = email . toLowerCase ( ) . trim ( ) ;
148+
149+ // Additional length check (RFC 5321 max email length)
150+ if ( normalizedEmail . length > 254 ) {
151+ return {
152+ statusCode : 400 ,
153+ headers : {
154+ 'Content-Type' : 'application/json' ,
155+ 'Access-Control-Allow-Origin' : '*' ,
156+ } ,
157+ body : JSON . stringify ( { error : 'Email address is too long' } ) ,
158+ } ;
159+ }
160+
161+ // Rate limit by email (prevent spam submissions for same email)
162+ if ( isRateLimited ( `email:${ normalizedEmail } ` , MAX_EMAIL_REQUESTS_PER_HOUR ) ) {
163+ return {
164+ statusCode : 429 ,
165+ headers : {
166+ 'Content-Type' : 'application/json' ,
167+ 'Access-Control-Allow-Origin' : '*' ,
168+ 'Retry-After' : '3600' ,
169+ } ,
170+ body : JSON . stringify ( {
171+ error : 'This email has been submitted recently. Please try again later.' ,
172+ retryAfter : 3600
173+ } ) ,
174+ } ;
175+ }
176+
177+ // Initialize Brevo API client
178+ const apiInstance = new brevo . ContactsApi ( ) ;
179+ apiInstance . setApiKey ( brevo . ContactsApiApiKeys . apiKey , process . env . BREVO_API_KEY ) ;
180+
181+ try {
182+ // Create contact in Brevo
183+ const createContact = new brevo . CreateContact ( ) ;
184+ createContact . email = normalizedEmail ;
185+ createContact . listIds = [ BREVO_LIST_ID ] ;
186+
187+ // Add source tracking
188+ if ( source ) {
189+ createContact . attributes = { SOURCE : source } ;
190+ }
191+
192+ await apiInstance . createContact ( createContact ) ;
193+
194+ } catch ( brevoError ) {
195+ // Check if contact already exists (Brevo returns 409 for duplicates)
196+ if ( brevoError && brevoError . status === 409 ) {
197+ // Contact exists, try to add to list
198+ try {
199+ const addToList = new brevo . AddContactToList ( ) ;
200+ addToList . emails = [ normalizedEmail ] ;
201+
202+ await apiInstance . addContactToList ( BREVO_LIST_ID , addToList ) ;
203+
204+ } catch ( listError ) {
205+ console . error ( 'Error adding existing contact to list:' , listError ) ;
206+ // Contact exists and might already be in the list
207+ return {
208+ statusCode : 200 ,
209+ headers : {
210+ 'Content-Type' : 'application/json' ,
211+ 'Access-Control-Allow-Origin' : '*' ,
212+ } ,
213+ body : JSON . stringify ( { message : 'Email already subscribed' } ) ,
214+ } ;
215+ }
216+ } else {
217+ // Other Brevo API error
218+ console . error ( 'Brevo API error:' , brevoError ) ;
219+ throw brevoError ;
220+ }
221+ }
222+
223+ return {
224+ statusCode : 200 ,
225+ headers : {
226+ 'Content-Type' : 'application/json' ,
227+ 'Access-Control-Allow-Origin' : '*' ,
228+ } ,
229+ body : JSON . stringify ( {
230+ message : 'Successfully subscribed!' ,
231+ email : normalizedEmail
232+ } ) ,
233+ } ;
234+ } catch ( error ) {
235+ console . error ( 'Subscription error:' , error ) ;
236+ return {
237+ statusCode : 500 ,
238+ headers : {
239+ 'Content-Type' : 'application/json' ,
240+ 'Access-Control-Allow-Origin' : '*' ,
241+ } ,
242+ body : JSON . stringify ( { error : 'Failed to subscribe. Please try again.' } ) ,
243+ } ;
244+ }
245+ } ;
0 commit comments