@@ -7,13 +7,21 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
77 * Proxies subscribe requests to Buttondown server-side so no third-party JS or
88 * API key is ever exposed to the client. Requires BUTTONDOWN_API_KEY to be set
99 * in the Vercel project's environment variables.
10+ *
11+ * Provider choice: Buttondown
12+ * - Open-source-friendly, privacy-respecting operator (no tracking pixels by
13+ * default, GDPR-compliant hosting)
14+ * - Simple REST API requiring only an API key — no client SDK needed
15+ * - Supports double opt-in natively via a list toggle, not custom code
16+ * - Free tier covers the initial subscriber volume; no vendor lock-in
1017 */
1118
1219type SubscribeBody = { email ?: string ; tag ?: string } ;
1320type SubscribeRequest = IncomingMessage & { body ?: SubscribeBody } ;
1421
1522const BUTTONDOWN_API_URL = 'https://api.buttondown.email/v1/subscribers' ;
16- const EMAIL_RE = / ^ [ ^ \s @ ] + @ [ ^ \s @ ] + \. [ ^ \s @ ] + $ / ;
23+ // Simple email regex — we validate server-side to avoid trusting the client.
24+ const EMAIL_RE = / ^ [ ^ \s @ ] + @ [ ^ \s @ ] [ ^ @ ] * \. [ ^ \s @ ] + $ / ;
1725
1826function sendJson ( res : ServerResponse , status : number , payload : unknown ) {
1927 res . statusCode = status ;
@@ -27,39 +35,62 @@ export default async function handler(req: SubscribeRequest, res: ServerResponse
2735 return ;
2836 }
2937
30- const email = req . body ?. email ?. trim ( ) ;
31- const tag = req . body ?. tag ?. trim ( ) || 'newsletter' ;
32-
33- if ( ! email || ! EMAIL_RE . test ( email ) ) {
34- sendJson ( res , 400 , { error : 'A valid email address is required.' } ) ;
35- return ;
36- }
37-
3838 const apiKey = process . env . BUTTONDOWN_API_KEY ;
3939 if ( ! apiKey ) {
40+ console . error ( 'BUTTONDOWN_API_KEY env var is not set' ) ;
4041 sendJson ( res , 500 , { error : 'Subscription service is not configured.' } ) ;
4142 return ;
4243 }
4344
45+ const rawEmail = typeof req . body ?. email === 'string' ? req . body . email . trim ( ) . toLowerCase ( ) : '' ;
46+ const tag = typeof req . body ?. tag === 'string' ? req . body . tag . trim ( ) : 'newsletter' ;
47+
48+ if ( ! rawEmail || ! EMAIL_RE . test ( rawEmail ) ) {
49+ sendJson ( res , 422 , { error : 'invalid_email' } ) ;
50+ return ;
51+ }
52+
4453 try {
45- const buttondownRes = await fetch ( BUTTONDOWN_API_URL , {
54+ const bdRes = await fetch ( BUTTONDOWN_API_URL , {
4655 method : 'POST' ,
4756 headers : {
4857 Authorization : `Token ${ apiKey } ` ,
4958 'Content-Type' : 'application/json' ,
5059 } ,
51- body : JSON . stringify ( { email, tags : [ tag ] , type : 'unconfirmed' } ) ,
60+ // Ask Buttondown to send the double opt-in confirmation email.
61+ body : JSON . stringify ( { email : rawEmail , tags : [ tag ] , type : 'unconfirmed' } ) ,
5262 } ) ;
5363
64+ // 201 Created — subscription queued, confirmation email sent.
65+ if ( bdRes . status === 201 ) {
66+ sendJson ( res , 201 , { ok : true } ) ;
67+ return ;
68+ }
69+
5470 // Buttondown returns 409 when the address is already subscribed. Treat it
55- // as success so the response never reveals whether an email was already on the list.
56- if ( buttondownRes . ok || buttondownRes . status === 409 ) {
57- sendJson ( res , 200 , { success : true } ) ;
71+ // as a distinct code so clients can show a friendly message without
72+ // revealing list membership (the client decides whether to surface it).
73+ if ( bdRes . status === 409 ) {
74+ sendJson ( res , 409 , { error : 'already_subscribed' } ) ;
75+ return ;
76+ }
77+
78+ if ( bdRes . status === 400 || bdRes . status === 422 ) {
79+ const body = ( await bdRes . json ( ) ) as Record < string , unknown > ;
80+ const code = typeof body ?. code === 'string' ? body . code : 'unknown' ;
81+ if ( code === 'email_already_exists' || code === 'subscriber_already_exists' ) {
82+ sendJson ( res , 409 , { error : 'already_subscribed' } ) ;
83+ return ;
84+ }
85+ sendJson ( res , 422 , { error : 'invalid_email' } ) ;
5886 return ;
5987 }
6088
89+ // Unexpected upstream error.
90+ console . error ( 'Buttondown unexpected status' , bdRes . status ) ;
6191 sendJson ( res , 502 , { error : 'Subscription service is unavailable.' } ) ;
62- } catch {
92+ } catch ( err ) {
93+ console . error ( 'Buttondown fetch failed' , err ) ;
6394 sendJson ( res , 502 , { error : 'Subscription service is unavailable.' } ) ;
6495 }
6596}
0 commit comments